Skip to content

Commit 855d24d

Browse files
committed
訪問予定リストを開始日の昇順に並べ、訪問日未定のリストを作れるようにする
一覧はこれから回る順に読めるよう開始日の昇順にし、日付を持たないリストは末尾へ 回す(同じ開始日なら作った順)。アーカイブの一覧は従来どおり最近しまった順。 開始日・終了日を任意にし、どちらもnullなら「訪問日未定」として扱う。行き先だけ 先に決めて日取りは後から、という旅程を作れるようにするため。開始日と終了日は セットで持ち、片方だけの状態はDBのcheck制約と、作成・更新で共用する正規化 (lib/visitPlanListDates.ts)の両方で塞ぐ ——「開始日なしの終了日」は期間として 意味を持たず、一覧の並び順も天気の基準日も決まらないため。 天気は基準になる日が無いと出せないので、未定の旅程では各スポットの予報も 「前後1週間で天気の良い日を探す」も出さない。今日で代用すると、今日行く予定だと 読めてしまう。 旧スキーマに016を当てたDBが新規作成したDBと列・索引・check制約まで一致すること、 016を再適用しても通ることを確認した。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent db801da commit 855d24d

14 files changed

Lines changed: 208 additions & 98 deletions

CLAUDE.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1269,7 +1269,7 @@ CSVインポートは差分更新で、`AdminView`側が事前読み込み済み
12691269

12701270
### 訪問予定リスト(旅程)
12711271

1272-
複数スポットを順序付きでまとめる「訪問予定リスト」(旅程)。1スポットごとの`visit_plans`(行きたい場所のブックマーク)とは**独立**で、`/[type]/spots`の訪問予定欄に個別の予定スポットと**混じって**並ぶ(見出しは0件でも常に表示する)。スキーマは`visit_plan_lists`(種別ごと=`spot_type_id``title``description``start_date``end_date`(単日は開始=終了)・`user_id`)+`visit_plan_list_items`(`list_id``spot_id``seq``visited_at``(list_id, spot_id)`一意)の2テーブル(`db/init/01_schema.sql`、移行は`migrations/006``008`)。**種別ごと**(地図の作成が`/[type]/map`上で行われるため。CLAUDE作成時の判断で種別横断は不可)。
1272+
複数スポットを順序付きでまとめる「訪問予定リスト」(旅程)。1スポットごとの`visit_plans`(行きたい場所のブックマーク)とは**独立**で、`/[type]/spots`の訪問予定欄に個別の予定スポットと**混じって**並ぶ(見出しは0件でも常に表示する)。スキーマは`visit_plan_lists`(種別ごと=`spot_type_id``title``description``start_date``end_date`(単日は開始=終了**どちらもnullなら「訪問日未定」**)・`user_id`)+`visit_plan_list_items`(`list_id``spot_id``seq``visited_at``(list_id, spot_id)`一意)の2テーブル(`db/init/01_schema.sql`、移行は`migrations/006``008``016`)。**種別ごと**(地図の作成が`/[type]/map`上で行われるため。CLAUDE作成時の判断で種別横断は不可)。
12731273

12741274
作成フローは、訪問予定欄の「+ 訪問予定リストを追加」→ 基本情報モーダル(`VisitPlanListFormModal`。タイトル・説明・期間)→ **下書きをlocalStorageへ保存**(`lib/planListDraft.ts`。「入力完了」までDBに保存しないため、SpotsView→MapViewのページ遷移をまたいで保持する必要がある)→ `/[type]/map?buildList=1`へ遷移して**地図の作成モード**に入る。作成モードでは`MapView`が右側に`PlanBuildPanel`(リスト名・選択済みスポットの並び替え/削除・「入力完了」)を出し、**ピンのタップを詳細表示ではなく追加確認ダイアログに回す**(`buildModeRef`で`ensureClusterLayers`が一度だけ束縛するクリックハンドラ`handleSpotSelect`の分岐を切り替える)。この確認ダイアログには名前だけでなく**スポットの説明とWikipediaの概要への入口**(スポット詳細と同じ`SpotInfoModal`)を出す —— 名前だけでは入れるかどうか決められないため。**表示にはスポットを`api.spots.get`で取り直す**(`addCandidateDetail`)—— 地図の公開スポットはIndexedDBキャッシュ由来で、容量のため`description`も`spot_type_id`も保存されておらず`expandSpot`がnull・空文字を返すため(`lib/spotCacheDb.ts`)。**空の`spot_type_id`のまま種別を引くと必ず見つからず、Wikipediaの可否が種別の設定ではなく既定値(true)で決まってしまう**ので、取り直しが済むまでボタン自体を出さない(ちらつき防止も兼ねる)。Wikipediaの言語・検索の起点は**スポット自身の種別**の設定で引く(重ね表示のピンから開いたときは今の地図の種別と食い違う)。並び替えはタッチでも動くようポインタイベントの自前実装(ライブラリ非依存。3本線ハンドル)で、**`lib/useDragReorder.ts`に切り出してリスト詳細・経路詳細と共用する**(下記「経由スポットの並び替え」)。「入力完了」で`POST /api/visit-plan-lists`(`{ type, title, description, start_date, end_date, spot_ids }`。spot_idsはseq順)して下書きを消し`/[type]/spots`へ戻る。
12751275

@@ -1289,6 +1289,10 @@ CSVインポートは差分更新で、`AdminView`側が事前読み込み済み
12891289

12901290
一覧APIは`GET /api/visit-plan-lists?type=<キー>`で、各リストの経由スポットを**seq順の`spot_ids`(UUID配列)**と、そのうち訪問済みの**`visited_spot_ids`**として返す(スポット詳細は呼び出し側が保持済みの一覧から解決するため軽い)。この列の作り方は3つのAPIで同じものを使う(`lib/visitPlanListSql.ts``PLAN_LIST_COLUMNS`)。リストのタップで`VisitPlanListDetailModal`(タイトル・説明・期間・経由スポット一覧・並び替え・編集・削除)。`GET/PATCH/DELETE /api/visit-plan-lists/[id]`は作成者本人のみ。
12911291

1292+
**一覧の並びは開始日の昇順で、訪問日未定は末尾**(同じ開始日なら作った順)。これから回る順に読めるようにするため。アーカイブの一覧だけは新しくしまった順(最近しまったものから探すため)。
1293+
1294+
**訪問日は「未定」にできる**(`start_date``end_date`がnull)。行き先だけ先に決めて日取りは後から、という旅程のため。**開始日と終了日はセットで持ち、片方だけの状態は作らせない** —— DBのcheck制約(`visit_plan_lists_dates_ck`)と、作成・更新のAPIで共用する正規化(`lib/visitPlanListDates.ts`。終了日だけの指定は400、終了日が空なら開始日と同じ)の両方で塞いである。「開始日なしの終了日」は期間として意味を持たないうえ、一覧の並び順も天気の基準日もどちらを見るかで変わってしまう。入口は基本情報モーダルの「訪問日は未定」で、チェックすると日付欄が無効になり、外せば今日が入る。**画面の表記は「訪問日未定」**(`formatPlanDateRange`)—— 空欄にすると、日付を消し忘れたのか決めていないのかが読み手に伝わらない。
1295+
12921296
**訪問記録を付けても経由スポットはリストから消さず、`visited_at`に日時を入れて「訪問済み」にする**(`POST /api/visits`が、その場所を含む**本人の全リスト**の行を更新する。既に印のある行は上書きしない=最初に行った時刻を残す)。**訪問済みは経路から外れる**(地図の紫の矢印=`buildPlanListPath`、`VisitPlanListDetailModal`のGoogle マップの経路検索)が、リストには残るので「その旅程で何を回ったか」を後から辿れる。かつては行ごと外していた(しかも地図でそのリストを経路表示中のときだけ動いていた)が、旅程の記録が消えるためやめた。**印は手で付け外しできる**(詳細モーダルの各行の「訪問済み / 未訪問」ボタン → `PATCH /api/visit-plan-lists/[id]/items/[spotId]`。記録するほどでもない立ち寄りや誤操作を直せる)。日時なしの未訪問記録(=下調べ)では印は付かない(まだ行っていないため。`visit_plans`を消さない条件と同じ)。**`PATCH /api/visit-plan-lists/[id]`は経由スポットを丸ごと入れ替えるので、`visited_at`を控えてから入れ直す** —— 戻さないと並び替えやタイトルの変更だけで訪問済みが消える。1スポットごとの`visit_plans`は従来どおり訪問記録で削除する(ブックマークは行った時点で役目が終わるため)。
12931297

12941298
**回り終わったリストはアーカイブできる**(`visit_plan_lists.archived_at`。リスト詳細の
@@ -1317,7 +1321,10 @@ CSVインポートは差分更新で、`AdminView`側が事前読み込み済み
13171321

13181322
- **各スポットの右の天気**(`WeatherAskLink`)は、**その日の予報のアイコン**を出し、押すと
13191323
**そのスポットの「予定の日」の天気をAIに聞く**(`lib/weather.ts`)。
1320-
聞く日は**開始日→終了日→今日**の順で決める。**天気サービスのページは日付を指定して開けない**
1324+
聞く日は**開始日→終了日**の順で決め、**どちらも無い(訪問日未定)ときは天気を出さない**
1325+
(`planWeatherDate`がnullを返し、各行の天気も「前後1週間で天気の良い日を探す」も消える)。
1326+
**今日で代用しない** —— 日が決まっていないのに今日の予報を出すと、今日行くものと読める。
1327+
**天気サービスのページは日付を指定して開けない**
13211328
——Yahoo!天気やtenki.jpのピンポイント予報は地点コード(手元にあるのは緯度経度だけ)が要るうえ
13221329
日付も選べず、座標で開ける海外サービス(The Weather Channel の10日間予報など)も「今日から数日」を
13231330
出すだけ。旅程で知りたいのは**その日その場所の天気**なので、日付を添えて調べてもらう形にした。

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

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import { query } from "@/lib/db";
33
import { getCurrentUserId } from "@/lib/auth/current-user";
44
import type { VisitPlanList } from "@/lib/types";
55
import { PLAN_LIST_COLUMNS } from "@/lib/visitPlanListSql";
6-
7-
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
6+
import { normalizePlanDates } from "@/lib/visitPlanListDates";
87

98
/** 指定リスト(本人)のspot_ids付き1件を返すSELECT(GET/PATCHの返却で共用) */
109
const LIST_SELECT = `
@@ -51,26 +50,17 @@ export async function PATCH(
5150
typeof body?.description === "string" && body.description.trim()
5251
? body.description.trim()
5352
: null;
54-
const startDate = body?.start_date;
55-
const endDate = body?.end_date || startDate;
53+
// 日付は未指定なら「訪問日未定」(両方null)。終了日だけの指定は断る
54+
const dates = normalizePlanDates(body ?? {});
5655
const spotIds: string[] = Array.isArray(body?.spot_ids)
5756
? body.spot_ids.filter((s: unknown): s is string => typeof s === "string")
5857
: [];
5958

6059
if (!title) {
6160
return NextResponse.json({ error: "title は必須です。" }, { status: 400 });
6261
}
63-
if (!DATE_RE.test(startDate) || !DATE_RE.test(endDate)) {
64-
return NextResponse.json(
65-
{ error: "訪問予定期間の日付が不正です。" },
66-
{ status: 400 }
67-
);
68-
}
69-
if (endDate < startDate) {
70-
return NextResponse.json(
71-
{ error: "終了日は開始日以降にしてください。" },
72-
{ status: 400 }
73-
);
62+
if (!dates.ok) {
63+
return NextResponse.json({ error: dates.error }, { status: 400 });
7464
}
7565

7666
// 本人のリストであることを確認する(存在しなければ404)
@@ -87,7 +77,7 @@ export async function PATCH(
8777
`update visit_plan_lists
8878
set title = $1, description = $2, start_date = $3, end_date = $4
8979
where id = $5`,
90-
[title, description, startDate, endDate, id]
80+
[title, description, dates.start, dates.end, id]
9181
);
9282

9383
// 経由スポットは丸ごと置き換える(重複除去+存在するスポットに限定)。

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

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import { query } from "@/lib/db";
33
import { getCurrentUserId } from "@/lib/auth/current-user";
44
import type { VisitPlanList } from "@/lib/types";
55
import { PLAN_LIST_COLUMNS } from "@/lib/visitPlanListSql";
6-
7-
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
6+
import { normalizePlanDates } from "@/lib/visitPlanListDates";
87

98
/**
109
* 現在のユーザーの、指定スポット種別の訪問予定リスト一覧。各リストの経由スポットは
@@ -39,10 +38,11 @@ export async function GET(request: Request) {
3938
group by l.id
4039
order by ${
4140
// アーカイブの一覧は「最近しまったもの」から見たいので archived_at 順。
42-
// 通常の一覧は従来どおり訪問予定期間の新しい順
41+
// 通常の一覧はこれから回る順に読めるよう開始日の昇順(開始日を持たない
42+
// リストは末尾へ)。同じ開始日なら作った順
4343
archived
4444
? "l.archived_at desc"
45-
: "l.start_date desc, l.created_at desc"
45+
: "l.start_date asc nulls last, l.created_at asc"
4646
}`,
4747
[userId, typeKey]
4848
);
@@ -63,9 +63,8 @@ export async function POST(request: Request) {
6363
typeof body?.description === "string" && body.description.trim()
6464
? body.description.trim()
6565
: null;
66-
const startDate = body?.start_date;
67-
// 終了日が空なら開始日と同じ(=単日)にする
68-
const endDate = body?.end_date || startDate;
66+
// 日付は未指定なら「訪問日未定」(両方null)。終了日だけの指定は断る
67+
const dates = normalizePlanDates(body ?? {});
6968
const spotIds: string[] = Array.isArray(body?.spot_ids)
7069
? body.spot_ids.filter((s: unknown): s is string => typeof s === "string")
7170
: [];
@@ -76,17 +75,8 @@ export async function POST(request: Request) {
7675
{ status: 400 }
7776
);
7877
}
79-
if (!DATE_RE.test(startDate) || !DATE_RE.test(endDate)) {
80-
return NextResponse.json(
81-
{ error: "訪問予定期間の日付が不正です。" },
82-
{ status: 400 }
83-
);
84-
}
85-
if (endDate < startDate) {
86-
return NextResponse.json(
87-
{ error: "終了日は開始日以降にしてください。" },
88-
{ status: 400 }
89-
);
78+
if (!dates.ok) {
79+
return NextResponse.json({ error: dates.error }, { status: 400 });
9080
}
9181

9282
const typeRow = await query<{ id: string }>(
@@ -106,7 +96,7 @@ export async function POST(request: Request) {
10696
(user_id, spot_type_id, title, description, start_date, end_date)
10797
values ($1, $2, $3, $4, $5, $6)
10898
returning id`,
109-
[userId, spotTypeId, title, description, startDate, endDate]
99+
[userId, spotTypeId, title, description, dates.start, dates.end]
110100
);
111101
const listId = rows[0].id;
112102

components/MapView.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4214,8 +4214,9 @@ export default function MapView({
42144214
* ルートと訪問順は済んだ話で、これから行く日が無い(天気を聞く意味が無い) */
42154215
weatherSpot?: Spot;
42164216
}[];
4217-
/** 天気を聞く日(訪問予定リストのときだけ)。開始日→終了日→今日 */
4218-
weatherDate?: string;
4217+
/** 天気を聞く日(訪問予定リストのときだけ)。開始日→終了日。
4218+
* 訪問日未定のリストはnullで、その場合は天気を出さない */
4219+
weatherDate?: string | null;
42194220
} | null = detailRoute
42204221
? {
42214222
title: detailRoute.name,

components/VisitPlanListDetailModal.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ export default function VisitPlanListDetailModal({
9191
}, [list, spotsById]);
9292

9393
const visitedIds = new Set(list?.visited_spot_ids ?? []);
94-
// 天気を見る日(開始日→終了日→今日)。リストが読めるまでは今日として扱う
94+
// 天気を見る日(開始日→終了日)。訪問日未定のリストはnullになり、天気を出さない
9595
const weatherDate = planWeatherDate(list ?? {});
9696
// その日の予報を、並んでいるスポットぶんまとめて1回で引く
9797
const weatherPoints = (list?.spot_ids ?? [])
@@ -346,8 +346,9 @@ export default function VisitPlanListDetailModal({
346346
</HelpTip>
347347
</div>
348348
)}
349-
{/* そのスポットの、予定の日の天気(地図の経路詳細にも同じものを出す) */}
350-
{spot && (
349+
{/* そのスポットの、予定の日の天気(地図の経路詳細にも同じものを出す)。
350+
訪問日未定のリストでは日が決まらないので出さない */}
351+
{spot && weatherDate && (
351352
<WeatherAskLink
352353
spot={spot}
353354
date={weatherDate}
@@ -411,14 +412,18 @@ export default function VisitPlanListDetailModal({
411412
)}
412413

413414
{/* 予定日の前後1週間の天気。予定を立てたあとに雨予報になったとき、
414-
近い日にずらせるかをこの画面で確かめられるようにする */}
415-
<PlanWeatherFinder
416-
points={weatherPoints}
417-
date={weatherDate}
418-
endDate={list.end_date}
419-
onPick={movePlanDate}
420-
saving={movingDate}
421-
/>
415+
近い日にずらせるかをこの画面で確かめられるようにする。
416+
訪問日未定のリストは中心に置く日が無いので出さない
417+
(日を決めるのは編集画面から) */}
418+
{weatherDate && (
419+
<PlanWeatherFinder
420+
points={weatherPoints}
421+
date={weatherDate}
422+
endDate={list.end_date ?? weatherDate}
423+
onPick={movePlanDate}
424+
saving={movingDate}
425+
/>
426+
)}
422427

423428
{/* 残りのスポットをGoogle マップの経路検索で開く(途中のスポットは経由地、
424429
最後のスポットは目的地になる)。読み込めていないスポットは飛ばし、

0 commit comments

Comments
 (0)