Skip to content

Commit bf962b1

Browse files
rtcode337claude
andauthored
訪問予定リストに別スポット種別のスポットも追加できるようにする (#45)
作成モード中、地図に重ねて表示している別種別スポットのピンをタップしても 本体スポットと同じく追加確認へ回す(handleOverlaySpotSelect)。作成中パネルと 追加確認は overlaySpotById で別種別スポットの名前を解決し、保存後のリスト詳細は spotsById に無いIDを api.spots.get で補完する。 保存API(POST/PATCH)は経由スポットの挿入時に `and s.spot_type_id = $3` で リストの種別に限定していたため別種別スポットが黙って落ちていた。この条件を外し、 存在するスポットなら種別を問わず入れられるようにした(itemsテーブルは元々種別非依存、 リスト自体のspot_type_idは所属の目印)。 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent cc97b91 commit bf962b1

4 files changed

Lines changed: 65 additions & 12 deletions

File tree

app/api/visit-plan-lists/[id]/route.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export async function PATCH(
8080
);
8181
}
8282

83-
// 本人のリストであることを確認しつつ、経由スポットの絞り込み用に種別IDを得る
83+
// 本人のリストであることを確認する(存在しなければ404)
8484
const owned = await query<{ spot_type_id: string }>(
8585
"select spot_type_id from visit_plan_lists where id = $1 and user_id = $2",
8686
[id, userId]
@@ -97,17 +97,18 @@ export async function PATCH(
9797
[title, description, startDate, endDate, id]
9898
);
9999

100-
// 経由スポットは丸ごと置き換える(重複除去+その種別のスポットに限定)
100+
// 経由スポットは丸ごと置き換える(重複除去+存在するスポットに限定)。
101+
// 地図で別スポット種別を重ねて追加できるため種別は問わない(itemsテーブルも種別非依存)
101102
await query("delete from visit_plan_list_items where list_id = $1", [id]);
102103
const ordered = spotIds.filter((s, i) => spotIds.indexOf(s) === i);
103104
if (ordered.length > 0) {
104105
await query(
105106
`insert into visit_plan_list_items (list_id, spot_id, seq)
106107
select $1, s.id, ord.seq
107108
from unnest($2::uuid[]) with ordinality as ord(spot_id, seq)
108-
join spots s on s.id = ord.spot_id and s.spot_type_id = $3
109+
join spots s on s.id = ord.spot_id
109110
on conflict (list_id, spot_id) do nothing`,
110-
[id, ordered, spotTypeId]
111+
[id, ordered]
111112
);
112113
}
113114

app/api/visit-plan-lists/route.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,16 +104,17 @@ export async function POST(request: Request) {
104104
const listId = rows[0].id;
105105

106106
// 重複を除いた並び順のままseqを振って経由スポットを登録する。
107-
// その種別に属する既存スポットだけを入れる(defensive)
107+
// 存在するスポットだけを入れる(defensive)。地図で別スポット種別を重ねて追加できる
108+
// ため種別は問わない(itemsテーブルも種別非依存。リスト自体のspot_type_idは所属の目印)
108109
const ordered = spotIds.filter((s, i) => spotIds.indexOf(s) === i);
109110
if (ordered.length > 0) {
110111
await query(
111112
`insert into visit_plan_list_items (list_id, spot_id, seq)
112113
select $1, s.id, ord.seq
113114
from unnest($2::uuid[]) with ordinality as ord(spot_id, seq)
114-
join spots s on s.id = ord.spot_id and s.spot_type_id = $3
115+
join spots s on s.id = ord.spot_id
115116
on conflict (list_id, spot_id) do nothing`,
116-
[listId, ordered, spotTypeId]
117+
[listId, ordered]
117118
);
118119
}
119120

components/MapView.tsx

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,6 +1123,29 @@ export default function MapView({
11231123
// 重ね表示が無効の間は使われない(現在種別の値を返すだけ)
11241124
const overlaySeriesStyles = useSeriesStyles(overlayTypeKey ?? spotTypeKey);
11251125
const overlayCategories = useCategories(overlayTypeKey ?? spotTypeKey);
1126+
1127+
// 重ね表示(別種別)のピンのタップ: 作成モード中は本体ピンと同じく追加確認へ回す。
1128+
// それ以外は従来どおり読み取り専用の詳細を開く。ハンドラはレイヤー生成時に一度だけ
1129+
// 束縛されるため、buildModeRef を見て呼び出し時に分岐する(handleSpotSelectと同じ理由)
1130+
const handleOverlaySpotSelect = useCallback((id: string) => {
1131+
if (buildModeRef.current) setAddCandidate(id);
1132+
else setOverlayDetailSpotId(id);
1133+
}, []);
1134+
1135+
// 重ね表示スポットのID→Spot。作成中パネルや追加確認で別種別スポットの名前を解決する
1136+
const overlaySpotById = useMemo(() => {
1137+
const m = new Map<string, Spot>();
1138+
if (overlaySpots) for (const s of overlaySpots) m.set(s.id, s);
1139+
return m;
1140+
}, [overlaySpots]);
1141+
1142+
// 作成中パネルに渡す解決用マップ。本体スポットに重ね表示スポットを足したもの
1143+
// (IDが被ったら本体を優先)。これで別種別スポットも名前つきで一覧表示できる
1144+
const buildPanelSpotById = useMemo(() => {
1145+
const m = new Map(overlaySpotById);
1146+
for (const [id, s] of spotById) m.set(id, s);
1147+
return m;
1148+
}, [overlaySpotById, spotById]);
11261149
// 重ね表示の絞り込み変更をその種別のlocalStorageへ保存しつつstateへ反映する
11271150
// (overlayFiltersが変わると重ね表示の描画effectが再実行され、地図に即反映される)
11281151
const setOverlayFiltersAndSave = useCallback(
@@ -1890,7 +1913,7 @@ export default function MapView({
18901913
);
18911914

18921915
const render = async () => {
1893-
ensureOverlayLayers(map, setOverlayDetailSpotId, setOverlayDetailRouteId);
1916+
ensureOverlayLayers(map, handleOverlaySpotSelect, setOverlayDetailRouteId);
18941917
// クラスタは重ね先の種別の先頭シリーズの色で塗り、本体の青いクラスタと
18951918
// 見分けられるようにする(シリーズ設定が空の種別は未知シリーズのピンと同系のグレー)
18961919
const clusterColor = overlaySeriesStyles[0]?.color ?? "#9ca3af";
@@ -1933,6 +1956,7 @@ export default function MapView({
19331956
overlaySeriesStyles,
19341957
visitedIds,
19351958
runWhenMapReady,
1959+
handleOverlaySpotSelect,
19361960
]);
19371961

19381962
// 今回のセッションで送信した承認待ち/非公開スポットの仮ピン(破線)を表示
@@ -2378,7 +2402,7 @@ export default function MapView({
23782402
title={buildDraft.title}
23792403
editing={buildDraft.editingId !== null}
23802404
spotIds={buildDraft.spotIds}
2381-
spotsById={spotById}
2405+
spotsById={buildPanelSpotById}
23822406
seriesStyles={seriesStyles}
23832407
saving={savingList}
23842408
onReorder={(spotIds) =>
@@ -2411,7 +2435,8 @@ export default function MapView({
24112435
className="w-full max-w-xs space-y-3 rounded-2xl bg-white p-4"
24122436
>
24132437
{(() => {
2414-
const spot = spotById.get(addCandidate);
2438+
const spot =
2439+
spotById.get(addCandidate) ?? overlaySpotById.get(addCandidate);
24152440
const already = buildDraft.spotIds.includes(addCandidate);
24162441
return (
24172442
<>

components/VisitPlanListDetailModal.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useEffect, useState } from "react";
3+
import { useEffect, useRef, useState } from "react";
44
import { api } from "@/lib/api-client";
55
import { formatPlanDateRange } from "@/lib/planListDraft";
66
import type { Spot, VisitPlanList } from "@/lib/types";
@@ -34,6 +34,10 @@ export default function VisitPlanListDetailModal({
3434
const [loading, setLoading] = useState(true);
3535
const [deleting, setDeleting] = useState(false);
3636
const [error, setError] = useState<string | null>(null);
37+
// 呼び出し側の spotsById に無い(=別スポット種別を重ねて追加した)スポットを
38+
// IDから個別取得して補完する。resolvedRef で一度取得したIDの再取得を防ぐ
39+
const [extraSpots, setExtraSpots] = useState<Map<string, Spot>>(new Map());
40+
const resolvedRef = useRef<Set<string>>(new Set());
3741

3842
useEffect(() => {
3943
api.visitPlanLists.get(listId).then(({ data }) => {
@@ -42,6 +46,28 @@ export default function VisitPlanListDetailModal({
4246
});
4347
}, [listId]);
4448

49+
// 別種別スポット(spotsById に無いID)を api.spots.get で解決する
50+
useEffect(() => {
51+
if (!list) return;
52+
const missing = list.spot_ids.filter(
53+
(id) => !spotsById.has(id) && !resolvedRef.current.has(id)
54+
);
55+
if (missing.length === 0) return;
56+
missing.forEach((id) => resolvedRef.current.add(id));
57+
let cancelled = false;
58+
Promise.all(missing.map((id) => api.spots.get(id))).then((results) => {
59+
if (cancelled) return;
60+
setExtraSpots((prev) => {
61+
const next = new Map(prev);
62+
for (const { data } of results) if (data) next.set(data.id, data);
63+
return next;
64+
});
65+
});
66+
return () => {
67+
cancelled = true;
68+
};
69+
}, [list, spotsById]);
70+
4571
const handleDelete = async () => {
4672
if (!list) return;
4773
if (!confirm(`「${list.title}」を削除しますか?`)) return;
@@ -99,7 +125,7 @@ export default function VisitPlanListDetailModal({
99125

100126
<ol className="divide-y divide-gray-100 overflow-hidden rounded-xl border border-gray-200">
101127
{list.spot_ids.map((spotId, i) => {
102-
const spot = spotsById.get(spotId);
128+
const spot = spotsById.get(spotId) ?? extraSpots.get(spotId);
103129
return (
104130
<li key={spotId}>
105131
<button

0 commit comments

Comments
 (0)