Skip to content

Commit ddb49ac

Browse files
committed
訪問予定リストの詳細に、その日の天気とリストの地図表示への導線を足す
リスト詳細(VisitPlanListDetailModal)から外へ出る導線を2つ追加した。 - **各スポットの右の太陽アイコン**: そのスポットの「予定の日」の天気をAIに聞く (`lib/weather.ts`)。聞く日は開始日→終了日→今日の順。 **天気サービスのページは日付を指定して開けない** —— Yahoo!天気やtenki.jpの ピンポイント予報は地点コード(手元にあるのは緯度経度だけ)が要るうえ日付も選べず、 座標で開ける海外サービスも「今日から数日」を出すだけ。旅程で知りたいのは **その日その場所の天気**なので、日付を添えて調べてもらう形にした。 質問文には**所在地と座標を添える**(「光明寺」のような同名の別スポットに 答えられないため。スポットの質問と同じ理由)。**予報がまだ無い先の日付では 平年の傾向を答えるよう頼む**(10日以上先でも服装・雨具の見当は付くため)。 Geminiの開き方は`buildGeminiSearchUrl`(lib/askAi.ts)へ切り出してスポットの質問と共用する - **「このリストだけを地図で表示」**: `/[type]/map?planList=<id>` へ遷移し、地図側が そのリストを経路の対象に選び「これだけを表示」にして経路全体が入るよう移動する。 **適用は一度だけ**で、以後は普通の絞り込みと同じ扱い(利用者が地図で変えたものを 上書きしない)。処理後は`?spot=`と同様にURLからパラメータを消す。 **地図から開いたリスト詳細には出さない**(今いる画面へのリンクになるため) 天気リンクは**行のボタンの中には置けない**(ボタンの入れ子になる)ので、独立した リンクとして訪問済みボタンの隣に並べてある。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3f651ad commit ddb49ac

5 files changed

Lines changed: 198 additions & 3 deletions

File tree

CLAUDE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,25 @@ CSVインポートは差分更新で、`AdminView`側が事前読み込み済み
549549

550550
**基本情報モーダルには出口が2つある**。「スポットを選ぶ/編集 →」が上記の地図へ進む経路で、もう一方の**「保存」は経由スポットに触らずその場で保存する**(新規はPOST、編集はPATCH。`onSaved`で呼び出し元が一覧を読み直す)。タイトルや期間だけ直したいときに地図まで行かなくて済むようにしたもので、**編集で「保存」したときは既存の`spot_ids`をそのまま送り直す** —— PATCHは経由スポットを丸ごと置き換える仕様なので、送らないと全部消える。ボタンは下の行に「保存」「スポットを選ぶ →」の2つを並べ、**キャンセルは見出し行の右端の`×`**へ移した(下の行を前へ進む操作だけにするため)。
551551

552+
**リスト詳細から外へ出る導線が2つある**(`VisitPlanListDetailModal`)。
553+
554+
- **各スポットの右の太陽アイコン**は、**そのスポットの「予定の日」の天気をAIに聞く**(`lib/weather.ts`)。
555+
聞く日は**開始日→終了日→今日**の順で決める。**天気サービスのページは日付を指定して開けない**
556+
——Yahoo!天気やtenki.jpのピンポイント予報は地点コード(手元にあるのは緯度経度だけ)が要るうえ
557+
日付も選べず、座標で開ける海外サービス(The Weather Channel の10日間予報など)も「今日から数日」を
558+
出すだけ。旅程で知りたいのは**その日その場所の天気**なので、日付を添えて調べてもらう形にした。
559+
相手は`buildGeminiSearchUrl`(`lib/askAi.ts`)=Google検索のAIモードで、Geminiの開き方は
560+
スポットの質問と共用する。質問文には**所在地と座標を添える**(同名の別スポットに答えられないため。
561+
スポットの質問と同じ理由)、**予報が出ていない先の日付では平年の傾向を答えるよう頼む**
562+
(10日以上先の予定でも服装・雨具の見当は付くため)。**行のボタンの中には置けない**
563+
(ボタンの入れ子になる)ので、独立したリンクとして訪問済みボタンの隣に並べてある
564+
- **「このリストだけを地図で表示」**`/[type]/map?planList=<id>`へ遷移し、地図側がそのリストを
565+
経路の対象(`filters.planListId`)に選び「これだけを表示」(`isolate: "plan"`)にして、経路全体が
566+
入るよう移動する。**適用は一度だけ**(`appliedPlanListRef`)で、以後は普通の絞り込みと同じ扱い
567+
—— 利用者が地図で変えたものを上書きしない。処理後は`?spot=`と同様にURLからパラメータを消す
568+
(見つからないIDでも消す)。**地図から開いたリスト詳細には出さない**(今いる画面へのリンクに
569+
なるため。`usePathname`で判定)
570+
552571
**回る順番は、作成モードに入らなくてもその場で入れ替えられる**。並び替えの操作(左端の三本線ハンドルをつかんでドラッグ)は`lib/useDragReorder.ts`に切り出してあり、**3か所で共用する** —— 作成モードの`PlanBuildPanel`・リスト詳細(`VisitPlanListDetailModal`)・地図の経路詳細(`MapView``routeDetailView`。訪問予定リストのときだけハンドルを出す)。`touch-action: none`はハンドルにだけ当てる(`REORDER_HANDLE_CLASS`。行全体に当てると一覧そのものがタッチスクロールできなくなる)。
553572

554573
- **ドラッグ中は手元の並びだけを入れ替え、指を離した時点で1回だけ保存する**(フックの`onReorder``onCommit`)。動かすたびにPATCHすると、1回の並び替えで何度も書き込むことになる。作成モードだけは下書き(localStorage)なので`onReorder`で毎回渡す

components/MapView.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,9 @@ export default function MapView({
13171317
// 元の種別のキーがfromに入る。左下に「元の地図に戻る」リンクを出すのに使う
13181318
// (戻り先の表示位置は種別ごとのlastViewsが復元するため、キーだけあればよい)
13191319
const returnTypeKey = searchParams.get("from");
1320+
// /map?planList=<id> で開かれたら、そのリストを経路の対象に選び「これだけを表示」にする
1321+
// (スポット一覧の訪問予定リスト詳細「このリストだけを地図で表示」からの遷移)
1322+
const focusPlanListId = searchParams.get("planList");
13201323
// /map?filter=1 で開かれたら絞り込みモーダルを最初から開く(直リンク用に残す。
13211324
// 重ね表示側の絞り込みは種別を切り替えず現在の地図の上のモーダルで編集するため、
13221325
// アプリ内からこのパラメータ付きで遷移する箇所は現在はない)
@@ -2576,6 +2579,48 @@ export default function MapView({
25762579
);
25772580
}, [focusSpotId, spots, spotTypeKey, returnTypeKey, runWhenMapReady]);
25782581

2582+
// /map?planList= の処理。リストの読み込みを待ってから、そのリストを経路の対象に選び
2583+
// 「これだけを表示」にして経路全体が入るよう移動する。**一度だけ**実行する
2584+
// (以後は普通の絞り込みと同じで、利用者が変えたものを上書きしない)。
2585+
// ?spot= と同様に、処理したらURLからパラメータを消す
2586+
const appliedPlanListRef = useRef<string | null>(null);
2587+
useEffect(() => {
2588+
if (!focusPlanListId || planLists.length === 0) return;
2589+
if (appliedPlanListRef.current === focusPlanListId) return;
2590+
const list = planLists.find((l) => l.id === focusPlanListId);
2591+
if (!list) return; // 別の種別のリスト・削除済みなら何もしない(URLだけは下で消す)
2592+
appliedPlanListRef.current = focusPlanListId;
2593+
const next: SpotFilters = {
2594+
...filters,
2595+
planListId: list.id,
2596+
isolate: "plan",
2597+
};
2598+
setFilters(next);
2599+
runWhenMapReady(() =>
2600+
fitMapToSpots(buildPlanListPath(planLists, next, pathSpotById))
2601+
);
2602+
}, [
2603+
focusPlanListId,
2604+
planLists,
2605+
filters,
2606+
pathSpotById,
2607+
setFilters,
2608+
fitMapToSpots,
2609+
runWhenMapReady,
2610+
]);
2611+
2612+
// 見つからないリストIDでもURLは片付ける(戻る操作やリストの取り直しで再発火しないように)
2613+
useEffect(() => {
2614+
if (!focusPlanListId || planLists.length === 0) return;
2615+
window.history.replaceState(
2616+
null,
2617+
"",
2618+
returnTypeKey
2619+
? `/${spotTypeKey}/map?from=${encodeURIComponent(returnTypeKey)}`
2620+
: `/${spotTypeKey}/map`
2621+
);
2622+
}, [focusPlanListId, planLists, spotTypeKey, returnTypeKey]);
2623+
25792624
// /map?filter=1 の処理。絞り込みモーダルを開き、?spot=と同様に一度処理したら
25802625
// URLから消す(fromは「元の地図に戻る」リンクのため残す)
25812626
useEffect(() => {

components/VisitPlanListDetailModal.tsx

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,30 @@
33
import { useEffect, useRef, useState } from "react";
44
import { api } from "@/lib/api-client";
55
import { formatPlanDateRange } from "@/lib/planListDraft";
6+
import {
7+
buildSpotWeatherAskUrl,
8+
planWeatherDate,
9+
weatherLinkLabel,
10+
} from "@/lib/weather";
611
import { useDragReorder, REORDER_HANDLE_CLASS } from "@/lib/useDragReorder";
712
import { formatSpotMeta } from "@/lib/spotMeta";
813
import type { Spot, VisitPlanList } from "@/lib/types";
914
import type { SeriesStyleDefinition } from "@/lib/seriesStyle";
1015
import SpotBadge from "@/components/SpotBadge";
1116
import HelpTip from "@/components/HelpTip";
1217
import GoogleMapsRouteLink from "@/components/GoogleMapsRouteLink";
18+
import { useCurrentSpotTypeKey } from "@/lib/useSpotTypeKey";
19+
import { usePathname } from "next/navigation";
20+
21+
/** 天気アイコン(太陽)。外部の天気ページへ開くリンクの印 */
22+
function SunIcon({ className }: { className?: string }) {
23+
return (
24+
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
25+
<path d="M12 17a5 5 0 100-10 5 5 0 000 10z" />
26+
<path d="M12 1.5a1 1 0 011 1V4a1 1 0 11-2 0V2.5a1 1 0 011-1zm0 17a1 1 0 011 1v1.5a1 1 0 11-2 0V19.5a1 1 0 011-1zM22.5 12a1 1 0 01-1 1H20a1 1 0 110-2h1.5a1 1 0 011 1zm-17 0a1 1 0 01-1 1H3a1 1 0 110-2h1.5a1 1 0 011 1zm13.6-6.6a1 1 0 010 1.42l-1.06 1.06a1 1 0 11-1.42-1.42l1.06-1.06a1 1 0 011.42 0zM7.38 16.62a1 1 0 010 1.41l-1.06 1.06a1 1 0 11-1.41-1.41l1.06-1.06a1 1 0 011.41 0zm11.24 2.47a1 1 0 01-1.42 0l-1.06-1.06a1 1 0 111.42-1.41l1.06 1.06a1 1 0 010 1.41zM7.38 7.38a1 1 0 01-1.41 0L4.91 6.32A1 1 0 016.32 4.9l1.06 1.06a1 1 0 010 1.42z" />
27+
</svg>
28+
);
29+
}
1330

1431
/**
1532
* 訪問予定リスト(旅程)の詳細モーダル。タイトル・説明・訪問予定期間と、
@@ -43,6 +60,10 @@ export default function VisitPlanListDetailModal({
4360
}) {
4461
const [list, setList] = useState<VisitPlanList | null>(null);
4562
const [loading, setLoading] = useState(true);
63+
// 「地図で見る」の遷移先。地図そのものから開いたときは出さない(今いる画面へのリンクになるため)
64+
const typeKey = useCurrentSpotTypeKey();
65+
const pathname = usePathname();
66+
const onMapPage = pathname.endsWith("/map");
4667
const [deleting, setDeleting] = useState(false);
4768
const [error, setError] = useState<string | null>(null);
4869
// 呼び出し側の spotsById に無い(=別スポット種別を重ねて追加した)スポットを
@@ -80,6 +101,8 @@ export default function VisitPlanListDetailModal({
80101
}, [list, spotsById]);
81102

82103
const visitedIds = new Set(list?.visited_spot_ids ?? []);
104+
// 天気を見る日(開始日→終了日→今日)。リストが読めるまでは今日として扱う
105+
const weatherDate = planWeatherDate(list ?? {});
83106

84107
// 経由スポットの並び替え。ドラッグ中は手元の並びだけを入れ替え(画面がその場で
85108
// 追従する)、指を離した時点で1回だけPATCHする —— 動かすたびに保存すると
@@ -273,6 +296,23 @@ export default function VisitPlanListDetailModal({
273296
</HelpTip>
274297
</div>
275298
)}
299+
{/* そのスポットの、予定の日の天気。**天気サービスは日付を指定して
300+
開けない**ので、日付を添えてAIに聞く形にしてある(lib/weather.ts)。
301+
日は開始日→終了日→今日の順。**行のボタンの中には置けない**
302+
(ボタンの入れ子になるため)ので、独立したリンクとして隣に並べる */}
303+
{spot && (
304+
<a
305+
href={buildSpotWeatherAskUrl(spot, weatherDate)}
306+
target="_blank"
307+
rel="noopener noreferrer"
308+
title={weatherLinkLabel(spot.name, weatherDate)}
309+
aria-label={weatherLinkLabel(spot.name, weatherDate)}
310+
onClick={(e) => e.stopPropagation()}
311+
className="mr-1 shrink-0 rounded-full p-1.5 text-amber-500 hover:bg-amber-50"
312+
>
313+
<SunIcon className="size-5" />
314+
</a>
315+
)}
276316
{/* 訪問済みの付け外し。訪問記録を付ければ自動で付くが、ここでも直せる
277317
(訪問済みは経路から外れるだけで、リストからは消えない) */}
278318
<button
@@ -311,6 +351,18 @@ export default function VisitPlanListDetailModal({
311351
</p>
312352
)}
313353

354+
{/* このリストだけを地図で見る。地図側は`?planList=`を受け取ると、そのリストを
355+
経路の対象に選び「これだけを表示」にして全体が入るよう移動する
356+
(MapView)。地図から開いたときは出さない —— 今いる画面へのリンクになるため */}
357+
{!onMapPage && typeKey && (
358+
<a
359+
href={`/${typeKey}/map?planList=${encodeURIComponent(list.id)}`}
360+
className="mt-3 flex w-full items-center justify-center gap-1.5 rounded-lg border border-blue-600 py-2 text-sm font-medium text-blue-600"
361+
>
362+
🗺️ このリストだけを地図で表示
363+
</a>
364+
)}
365+
314366
{/* 残りのスポットをGoogle マップの経路検索で開く(途中のスポットは経由地、
315367
最後のスポットは目的地になる)。読み込めていないスポットは飛ばし、
316368
訪問済みも外す —— 地図の経路と同じで、これから回る先だけを繋ぐ */}

lib/askAi.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,14 @@ export function buildGeminiAskUrl(
106106
spot: Spot,
107107
spotType?: SpotType | null
108108
): string {
109-
return `https://www.google.com/search?udm=50&q=${encodeURIComponent(
110-
buildSpotQuestion(spot, spotType)
111-
)}`;
109+
return buildGeminiSearchUrl(buildSpotQuestion(spot, spotType));
110+
}
111+
112+
/**
113+
* 任意の質問文でGemini(Google検索のAIモード)を開くURL。
114+
* **「Geminiをどう開くか」はここ1か所に置く** —— 上記のとおり渡し方に癖があり、
115+
* 変わったときに直す場所を散らさないため(天気の質問もこれを使う。lib/weather.ts)。
116+
*/
117+
export function buildGeminiSearchUrl(question: string): string {
118+
return `https://www.google.com/search?udm=50&q=${encodeURIComponent(question)}`;
112119
}

lib/weather.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { buildGeminiSearchUrl } from "@/lib/askAi";
2+
import type { Spot } from "@/lib/types";
3+
4+
/**
5+
* 訪問予定リストの各スポットの天気を見るためのリンクを組み立てる。
6+
*
7+
* **AI(Gemini=Google検索のAIモード)に日付を添えて聞く形にしてある。**
8+
* 天気サービスのページはどれも「今日から数日」の予報を出すだけで、
9+
* **予定の日を指定して開けるものが無い**(Yahoo!天気やtenki.jpのピンポイント予報は
10+
* 地点コードが要るうえ日付も選べず、座標で開ける海外サービスも同様)。
11+
* 旅程で知りたいのは「その日その場所の天気」なので、日付とスポットを渡して
12+
* 調べてもらうほうが目的に近い。
13+
*
14+
* **同名の別スポットに答えられないよう、所在地と座標を添える**
15+
* (`lib/askAi.ts`の質問文と同じ理由。「光明寺」のように同じ名前の場所が各地にある)。
16+
*
17+
* **予報が出ていない先の日付でも空振りにしない** —— その場合は平年の傾向を答えるよう
18+
* 頼んでおく。10日先より後の予定でも、服装や雨具の見当を付ける役には立つため。
19+
*/
20+
export function buildSpotWeatherQuestion(spot: Spot, date: string): string {
21+
const where = [spot.region, `${spot.lat.toFixed(5)},${spot.lng.toFixed(5)}`]
22+
.filter(Boolean)
23+
.join(" / ");
24+
return (
25+
`${formatWeatherDateLong(date)}の「${spot.name}」(${where})の天気を教えてください。` +
26+
`最高気温・最低気温、降水の見込み、風の強さが知りたいです。` +
27+
`その日の予報がまだ出ていない場合は、予報が出ている直近までの傾向と、` +
28+
`その時期の平年の天候(気温・降水)を教えてください。`
29+
);
30+
}
31+
32+
/** そのスポットのその日の天気をAIに聞くURL */
33+
export function buildSpotWeatherAskUrl(spot: Spot, date: string): string {
34+
return buildGeminiSearchUrl(buildSpotWeatherQuestion(spot, date));
35+
}
36+
37+
/**
38+
* 天気を聞く対象の日。**開始日 → 終了日 → 今日**の順に決める
39+
* (現在のDBでは開始日は必須だが、入っていない場合の順序を決めておく)。
40+
*/
41+
export function planWeatherDate(list: {
42+
start_date?: string | null;
43+
end_date?: string | null;
44+
}): string {
45+
return list.start_date || list.end_date || todayKey();
46+
}
47+
48+
/** 今日のローカル日付(`YYYY-MM-DD`)。VisitPlanListFormModalと同じ決め方 */
49+
function todayKey(): string {
50+
const d = new Date();
51+
const p = (n: number) => String(n).padStart(2, "0");
52+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
53+
}
54+
55+
/** 「8/20」。リンクの説明に添える短い表記 */
56+
export function formatWeatherDate(date: string): string {
57+
const m = date.match(/^(\d{4})-(\d{2})-(\d{2})$/);
58+
if (!m) return date;
59+
return `${Number(m[2])}/${Number(m[3])}`;
60+
}
61+
62+
/** 「2026年8月20日」。質問文に入れる表記(年まで書かないと別の年に取られる) */
63+
function formatWeatherDateLong(date: string): string {
64+
const m = date.match(/^(\d{4})-(\d{2})-(\d{2})$/);
65+
if (!m) return date;
66+
return `${Number(m[1])}${Number(m[2])}${Number(m[3])}日`;
67+
}
68+
69+
/** 天気リンクに添える説明(`title`/`aria-label`) */
70+
export function weatherLinkLabel(spotName: string, date: string): string {
71+
return `${spotName}${formatWeatherDate(date)}の天気をAIに聞く`;
72+
}

0 commit comments

Comments
 (0)