Skip to content

Commit 6488d5c

Browse files
committed
訪問記録を付けた経由スポットを、訪問予定リストから消さず訪問済みにする
これまでは訪問を記録するとその経由スポットをリストから外していたが、 旅程は「その旅行で何を回ったか」の記録でもあるため、行が消えると後から 辿れなくなる。visit_plan_list_items に visited_at を足し、消す代わりに 訪問済みの印を付けるようにした(migrations/008)。 - 印は POST /api/visits が付ける。対象は本人の全リストで、既に印のある行は 上書きしない(最初にそこへ行った時刻を残す)。かつてはクライアント側で、 しかも地図でそのリストを経路表示しているときだけ動いていたため、 リストを開かずに記録しても何も起きていなかった - 訪問済みは経路から外す(地図の紫の矢印=buildPlanListPath と、リスト詳細の Google マップの経路検索)。済んだ場所を通り続ける線が残ると、次にどこへ 行くかが読めなくなるため。リスト自体には残るので詳細では並んだままになる - 印は手で付け外しできる(リスト詳細の各行のボタン → PATCH /api/visit-plan-lists/[id]/items/[spotId])。記録するほどでもない 立ち寄りや、誤って付けた分を直せる - PATCH /api/visit-plan-lists/[id] は経由スポットを丸ごと入れ替えるため、 visited_at を控えてから入れ直す(戻さないと並び替えやタイトルの変更だけで 訪問済みが消える) - 経路の取り直しは onVisitRecorded を廃して onPlanListChange に一本化した。 サーバーが印を付けるようになり、クライアントは読み直すだけでよくなったため。 スポット一覧から記録したときも件数が更新されるようになった 1スポットごとの訪問予定(visit_plans)は従来どおり訪問記録で削除する (ブックマークは行った時点で役目が終わるため)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2403990 commit 6488d5c

15 files changed

Lines changed: 251 additions & 79 deletions

File tree

CLAUDE.md

Lines changed: 9 additions & 7 deletions
Large diffs are not rendered by default.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { NextResponse } from "next/server";
2+
import { query } from "@/lib/db";
3+
import { getCurrentUserId } from "@/lib/auth/current-user";
4+
import type { VisitPlanList } from "@/lib/types";
5+
import { PLAN_LIST_COLUMNS } from "@/lib/visitPlanListSql";
6+
7+
/**
8+
* 訪問予定リストの経由スポット1件の「訪問済み」(visited_at)を付け外しする。
9+
* 訪問記録を付けたときは POST /api/visits が自動で立てるが、記録するほどでもない
10+
* 立ち寄りや、誤って付けた分をここで手直しできる。
11+
*
12+
* 更新後のリストをそのまま返すので、呼び出し側は取り直さずに画面へ反映できる。
13+
*/
14+
export async function PATCH(
15+
request: Request,
16+
{ params }: { params: Promise<{ id: string; spotId: string }> }
17+
) {
18+
const userId = await getCurrentUserId();
19+
if (!userId) {
20+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
21+
}
22+
const { id, spotId } = await params;
23+
24+
const body = await request.json();
25+
if (typeof body?.visited !== "boolean") {
26+
return NextResponse.json(
27+
{ error: "visited (boolean) は必須です。" },
28+
{ status: 400 }
29+
);
30+
}
31+
32+
// 本人のリストの経由スポットだけを更新する(他人のリストは404扱いで存在も伏せる)
33+
const { rowCount } = await query(
34+
`update visit_plan_list_items it
35+
set visited_at = case when $3 then now() else null end
36+
from visit_plan_lists l
37+
where it.list_id = l.id
38+
and l.id = $1 and l.user_id = $2 and it.spot_id = $4`,
39+
[id, userId, body.visited, spotId]
40+
);
41+
if (!rowCount) {
42+
return NextResponse.json({ error: "not found" }, { status: 404 });
43+
}
44+
45+
const { rows } = await query<VisitPlanList>(
46+
`select ${PLAN_LIST_COLUMNS}
47+
from visit_plan_lists l
48+
left join visit_plan_list_items i on i.list_id = l.id
49+
where l.id = $1 and l.user_id = $2
50+
group by l.id`,
51+
[id, userId]
52+
);
53+
return NextResponse.json({ data: rows[0] });
54+
}

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

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,13 @@ import { NextResponse } from "next/server";
22
import { query } from "@/lib/db";
33
import { getCurrentUserId } from "@/lib/auth/current-user";
44
import type { VisitPlanList } from "@/lib/types";
5+
import { PLAN_LIST_COLUMNS } from "@/lib/visitPlanListSql";
56

67
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
78

89
/** 指定リスト(本人)のspot_ids付き1件を返すSELECT(GET/PATCHの返却で共用) */
910
const LIST_SELECT = `
10-
select l.id, l.spot_type_id, l.title, l.description,
11-
to_char(l.start_date, 'YYYY-MM-DD') as start_date,
12-
to_char(l.end_date, 'YYYY-MM-DD') as end_date,
13-
l.created_at, l.updated_at,
14-
coalesce(
15-
array_agg(i.spot_id order by i.seq)
16-
filter (where i.spot_id is not null),
17-
'{}'
18-
) as spot_ids
11+
select ${PLAN_LIST_COLUMNS}
1912
from visit_plan_lists l
2013
left join visit_plan_list_items i on i.list_id = l.id
2114
where l.id = $1 and l.user_id = $2
@@ -98,7 +91,13 @@ export async function PATCH(
9891
);
9992

10093
// 経由スポットは丸ごと置き換える(重複除去+存在するスポットに限定)。
101-
// 地図で別スポット種別を重ねて追加できるため種別は問わない(itemsテーブルも種別非依存)
94+
// 地図で別スポット種別を重ねて追加できるため種別は問わない(itemsテーブルも種別非依存)。
95+
// 置き換えで行が作り直されるため、訪問済み(visited_at)は先に控えて後で戻す
96+
// —— 戻さないと、リストの並び替えやタイトルの編集をしただけで訪問済みが消える
97+
const before = await query<{ spot_id: string; visited_at: string | null }>(
98+
"select spot_id, visited_at from visit_plan_list_items where list_id = $1",
99+
[id]
100+
);
102101
await query("delete from visit_plan_list_items where list_id = $1", [id]);
103102
const ordered = spotIds.filter((s, i) => spotIds.indexOf(s) === i);
104103
if (ordered.length > 0) {
@@ -110,6 +109,16 @@ export async function PATCH(
110109
on conflict (list_id, spot_id) do nothing`,
111110
[id, ordered]
112111
);
112+
const visited = before.rows.filter((r) => r.visited_at !== null);
113+
if (visited.length > 0) {
114+
await query(
115+
`update visit_plan_list_items it
116+
set visited_at = v.visited_at
117+
from unnest($2::uuid[], $3::timestamptz[]) as v(spot_id, visited_at)
118+
where it.list_id = $1 and it.spot_id = v.spot_id`,
119+
[id, visited.map((r) => r.spot_id), visited.map((r) => r.visited_at)]
120+
);
121+
}
113122
}
114123

115124
const { rows } = await query<VisitPlanList>(LIST_SELECT, [id, userId]);

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

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { NextResponse } from "next/server";
22
import { query } from "@/lib/db";
33
import { getCurrentUserId } from "@/lib/auth/current-user";
44
import type { VisitPlanList } from "@/lib/types";
5+
import { PLAN_LIST_COLUMNS } from "@/lib/visitPlanListSql";
56

67
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
78

89
/**
910
* 現在のユーザーの、指定スポット種別の訪問予定リスト一覧。各リストの経由スポットは
1011
* seq順の spot_ids(UUID配列)として返す(スポットの詳細は呼び出し側が保持済みの
11-
* 一覧から解決する)。
12+
* 一覧から解決する)。訪問済みの経由スポットは spot_ids に残したまま
13+
* visited_spot_ids にも入る。
1214
*/
1315
export async function GET(request: Request) {
1416
const userId = await getCurrentUserId();
@@ -22,15 +24,7 @@ export async function GET(request: Request) {
2224
}
2325

2426
const { rows } = await query<VisitPlanList>(
25-
`select l.id, l.spot_type_id, l.title, l.description,
26-
to_char(l.start_date, 'YYYY-MM-DD') as start_date,
27-
to_char(l.end_date, 'YYYY-MM-DD') as end_date,
28-
l.created_at, l.updated_at,
29-
coalesce(
30-
array_agg(i.spot_id order by i.seq)
31-
filter (where i.spot_id is not null),
32-
'{}'
33-
) as spot_ids
27+
`select ${PLAN_LIST_COLUMNS}
3428
from visit_plan_lists l
3529
left join visit_plan_list_items i on i.list_id = l.id
3630
where l.user_id = $1
@@ -119,15 +113,7 @@ export async function POST(request: Request) {
119113
}
120114

121115
const created = await query<VisitPlanList>(
122-
`select l.id, l.spot_type_id, l.title, l.description,
123-
to_char(l.start_date, 'YYYY-MM-DD') as start_date,
124-
to_char(l.end_date, 'YYYY-MM-DD') as end_date,
125-
l.created_at, l.updated_at,
126-
coalesce(
127-
array_agg(i.spot_id order by i.seq)
128-
filter (where i.spot_id is not null),
129-
'{}'
130-
) as spot_ids
116+
`select ${PLAN_LIST_COLUMNS}
131117
from visit_plan_lists l
132118
left join visit_plan_list_items i on i.list_id = l.id
133119
where l.id = $1

app/api/visits/route.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,15 +80,30 @@ export async function POST(request: Request) {
8080
throw e;
8181
}
8282

83-
// 訪問を記録したら、その場所は訪問予定リストから自動的に外す。ただし
84-
// 日時なしの未訪問記録(=まだ行っていない下調べのメモ)は行きたい場所の
85-
// ままなので外さない(日時ありの未訪問記録は「訪れたが改めて来たい」
86-
// 記録のため、通常の訪問と同じく外す)
83+
// 訪問を記録したら、その場所は訪問予定(行きたい場所のブックマーク)から
84+
// 自動的に外す。ただし日時なしの未訪問記録(=まだ行っていない下調べのメモ)
85+
// 行きたい場所のままなので外さない(日時ありの未訪問記録は「訪れたが改めて
86+
// 来たい」記録のため、通常の訪問と同じく外す)
8787
if (!(unvisited && !body.visited_on)) {
8888
await query("delete from visit_plans where user_id = $1 and spot_id = $2", [
8989
userId,
9090
body.spot_id,
9191
]);
92+
// 訪問予定リスト(旅程)側は行を消さず、訪問済みの印を付けるだけにする。
93+
// 消してしまうと「その旅程で何を回ったか」が後から辿れなくなるため。
94+
// 経路(地図の紫の矢印・Google マップの経路検索)からはこの印で外れる。
95+
// 本人の全リストが対象で、既に印が付いている行は日時を上書きしない
96+
// (最初にそこへ行った時刻を残す)
97+
await query(
98+
`update visit_plan_list_items it
99+
set visited_at = now()
100+
from visit_plan_lists l
101+
where it.list_id = l.id
102+
and l.user_id = $1
103+
and it.spot_id = $2
104+
and it.visited_at is null`,
105+
[userId, body.spot_id]
106+
);
92107
}
93108

94109
return NextResponse.json({ data: rows[0] });

components/MapView.tsx

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -663,6 +663,9 @@ type RouteFeatureProps = {
663663
/**
664664
* 選んだ訪問予定リスト(旅程)の経路。そのリストのスポットをリスト順に並べる
665665
* (見えないスポット=未ダウンロード等は除いて残りを繋ぐ)。
666+
* **訪問済みの経由スポットは経路に載せない** —— 済んだ場所を通り続ける線が
667+
* 残ると「次にどこへ行くか」が読めなくなるため。リスト自体からは消えないので、
668+
* 訪問予定リストの詳細では訪問済みとして並んだままになる。
666669
*/
667670
function buildPlanListPath(
668671
planLists: VisitPlanList[],
@@ -672,7 +675,9 @@ function buildPlanListPath(
672675
if (!filters.planListId) return [];
673676
const list = planLists.find((l) => l.id === filters.planListId);
674677
if (!list) return [];
678+
const visited = new Set(list.visited_spot_ids);
675679
return list.spot_ids
680+
.filter((id) => !visited.has(id))
676681
.map((id) => spotById.get(id))
677682
.filter((s): s is Spot => s !== undefined);
678683
}
@@ -1381,31 +1386,6 @@ export default function MapView({
13811386
[filters, setFilters, planLists, pathSpotById, fitMapToSpots]
13821387
);
13831388

1384-
// 地図で訪問予定リストを経路表示中に、そのリスト内のスポットへ新しく訪問記録したら、
1385-
// 自動でそのスポットをリストから外す(訪問済みが経路に残り続けないように)。
1386-
// 表示中のリスト(filters.planListId)にそのスポットが含まれるときだけ動く
1387-
const handleVisitRecorded = useCallback(
1388-
async (spotId: string) => {
1389-
const listId = filters.planListId;
1390-
if (!listId) return;
1391-
const list = planLists.find((l) => l.id === listId);
1392-
if (!list || !list.spot_ids.includes(spotId)) return;
1393-
const nextSpotIds = list.spot_ids.filter((id) => id !== spotId);
1394-
const { data } = await api.visitPlanLists.update(listId, {
1395-
title: list.title,
1396-
description: list.description,
1397-
start_date: list.start_date,
1398-
end_date: list.end_date,
1399-
spot_ids: nextSpotIds,
1400-
});
1401-
setPlanLists((prev) =>
1402-
prev.map((l) =>
1403-
l.id === listId ? (data ?? { ...l, spot_ids: nextSpotIds }) : l
1404-
)
1405-
);
1406-
},
1407-
[filters.planListId, planLists]
1408-
);
14091389
// マウント時と、マウント中に種別が切り替わった場合に、その種別の保存済み条件を読む
14101390
useEffect(() => {
14111391
setFiltersState(loadSavedFilters(spotTypeKey));
@@ -3793,7 +3773,6 @@ export default function MapView({
37933773
allowPlanList
37943774
onClose={() => setOverlayDetailSpotId(null)}
37953775
onVisitChange={loadVisits}
3796-
onVisitRecorded={handleVisitRecorded}
37973776
// 重ね表示スポットを現在の種別のリストへ追加したら、経路表示中のリストの
37983777
// 線にも反映されるようリスト一覧を取り直す
37993778
onPlanListChange={loadPlanLists}
@@ -3868,7 +3847,6 @@ export default function MapView({
38683847
spots={spots}
38693848
onClose={() => setDetailSpotId(null)}
38703849
onVisitChange={loadVisits}
3871-
onVisitRecorded={handleVisitRecorded}
38723850
// 既存の訪問予定リストへの追加をリスト一覧へ反映する(経路表示中の
38733851
// リストに追加した場合、地図の紫の経路も引き直される)
38743852
onPlanListChange={loadPlanLists}

components/SpotDetailModal.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,6 @@ export default function SpotDetailModal({
8989
allowPlanList = false,
9090
onClose,
9191
onVisitChange,
92-
onVisitRecorded,
9392
onSpotChange,
9493
onSpotDeleted,
9594
onVisitPlanChange,
@@ -126,7 +125,6 @@ export default function SpotDetailModal({
126125
/** 新しい訪問記録が追加されたときだけ、そのスポットIDとともに呼ばれる
127126
* (地図で経路表示中の訪問予定リストから、訪問済みスポットを自動で外すのに使う。
128127
* 訪問記録の編集・削除では呼ばれない) */
129-
onVisitRecorded?: (spotId: string) => void;
130128
/** スポット自体の編集・承認/却下で内容が変わったときに、変更後の内容とともに呼ばれる
131129
* (呼び出し元の一覧の再取得・公開スポットキャッシュの更新用) */
132130
onSpotChange?: (spot: Spot) => void;
@@ -858,11 +856,14 @@ export default function SpotDetailModal({
858856
// 訪問記録時、サーバー側で訪問予定からも自動的に外れる
859857
onVisitPlanChange?.();
860858
onReviewChange?.();
861-
// 地図で経路表示中の訪問予定リストから、訪問済みスポットを自動で外す。
862-
// 日時なしの未訪問記録(下調べ)はサーバー側でも訪問予定が残る
863-
// (まだ行っていない)ため、リストからも外さない
859+
// 訪問記録を付けると、サーバー側でその場所を含む訪問予定リストの
860+
// 経由スポットに訪問済みの印が付き、地図の経路から外れる。印の付いた
861+
// 状態を出すためにリストを取り直す(自分の「訪問予定」欄と、リストを
862+
// 持っている呼び出し元の両方)。日時なしの未訪問記録(下調べ)は
863+
// サーバー側でも印が付かない(まだ行っていない)ため取り直さない
864864
if (saved && !(saved.unvisited && !saved.visited_on)) {
865-
onVisitRecorded?.(spot.id);
865+
loadPlanLists();
866+
onPlanListChange?.();
866867
}
867868
}}
868869
/>
@@ -987,6 +988,12 @@ export default function SpotDetailModal({
987988
// 表示対象の差し替えを頼む(onOpenSpot未指定の呼び出し元では何もしない)
988989
if (id !== spotId) onOpenSpot?.(id);
989990
}}
991+
// 訪問済みの付け外しは経路(地図の紫の矢印)の見え方を変えるため、
992+
// リストを持っている呼び出し元にも取り直させる
993+
onChanged={() => {
994+
loadPlanLists();
995+
onPlanListChange?.();
996+
}}
990997
/>
991998
)}
992999
{editingPlanList && typeKey && (

components/SpotsView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,6 +619,8 @@ export default function SpotsView({
619619
{formatPlanDateRange(list.start_date, list.end_date)}
620620
{" ・ "}
621621
{list.spot_ids.length}スポット
622+
{list.visited_spot_ids.length > 0 &&
623+
`(訪問済み ${list.visited_spot_ids.length})`}
622624
</p>
623625
</div>
624626
<span className="shrink-0 text-gray-400"></span>
@@ -1020,6 +1022,7 @@ export default function SpotsView({
10201022
setDetailListId(null);
10211023
loadPlanLists();
10221024
}}
1025+
onChanged={loadPlanLists}
10231026
onOpenSpot={(id) => {
10241027
setDetailListId(null);
10251028
setDetailSpotId(id);

0 commit comments

Comments
 (0)