Skip to content

Commit f48da3d

Browse files
rtcode337claude
andauthored
公開スポットキャッシュの更新検知と訪問予定リスト・経路まわりの改善 (#53)
- 公開スポット・公開ルートの更新検知: 軽量API GET /api/spots/last-updated (公開分の最新updated_atと件数)を追加し、地図を開いたときにダウンロード済み キャッシュが古ければ再ダウンロードを促すダイアログを出す。比較は端末の時計に 依存しないよう、ダウンロード時にデータから求めて保存したサーバー日時 (StoredSpotCache.latestUpdatedAt)と行い、削除だけの変更も拾えるよう件数も 突き合わせる。チェックの失敗(オフライン等)は黙って無視する - スポット一覧(/[type]/spots)では未ダウンロード時・更新ありの自動ダイアログを 出さない(useSpotCacheのautoPromptオプション。ダウンロードは地図側から促す) - 訪問予定リストの経路の「現在地→先頭スポット」区間を現在地の青丸と同じ青 (#1da1f2)の独立したLineStringで描き、リストの作成・編集モード中の下書き 経路でも同じ線を引く - 地図のスポット詳細から「訪問予定リストへ追加→新しいリストを作成→スポットを 選ぶ」と進んだときにモーダルが閉じない問題を修正(同一ページ遷移のため ?buildList=1 処理で明示的に閉じる) - スポット詳細の「Google マップで経路を表示」で、位置情報の権限が許可済みの ときだけ現在地をorigin(出発地)に渡す(Google マップ側が別の出発地を 復元してしまうのを防ぐ。未許可なら従来どおりoriginなし) - スポット詳細から既存の訪問予定リストへ追加したとき、リスト一覧を再取得して 地図の経路表示・一覧画面の訪問予定欄に即反映する(onPlanListChange) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 6224631 commit f48da3d

10 files changed

Lines changed: 286 additions & 42 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Large diffs are not rendered by default.

app/api/routes/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export async function GET(request: Request) {
4949

5050
const { rows } = await query<SpotRoute>(
5151
`select r.id, r.spot_type_id, r.name, r.series, r.description,
52-
r.status, r.created_by, r.created_at,
52+
r.status, r.created_by, r.created_at, r.updated_at,
5353
coalesce(
5454
(select json_agg(json_build_object(
5555
'spot_id', p.spot_id, 'seq', p.seq,
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { NextResponse } from "next/server";
2+
import { query } from "@/lib/db";
3+
import { getCurrentUser } from "@/lib/auth/current-user";
4+
import { getSpotTypeSetting, SPOT_ADMIN_ROLES, type SpotType } from "@/lib/types";
5+
import { SPOT_TYPE_SELECT } from "@/lib/spot-types-query";
6+
7+
/**
8+
* ダウンロード済み公開スポットキャッシュの鮮度チェック用の軽量エンドポイント。
9+
* 指定種別の公開(published)スポット・公開ルートの最新updated_atと件数だけを返す
10+
* (地図を開いたときに全件を取り直さずに「キャッシュが古いか」を判定するため。
11+
* 件数も返すのは、削除だけが起きた場合はmax(updated_at)が変わらないため)。
12+
* 認可の扱いはGET /api/spotsと同じ
13+
*/
14+
export async function GET(request: Request) {
15+
const user = await getCurrentUser();
16+
if (!user) {
17+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
18+
}
19+
20+
const typeKey = new URL(request.url).searchParams.get("type");
21+
if (!typeKey) {
22+
return NextResponse.json({ error: "type is required" }, { status: 400 });
23+
}
24+
const { rows: typeRows } = await query<SpotType>(
25+
`${SPOT_TYPE_SELECT} where t.key = $1`,
26+
[typeKey]
27+
);
28+
const activeType = typeRows[0];
29+
if (
30+
!activeType ||
31+
(!getSpotTypeSetting(activeType, "public_visible") && !SPOT_ADMIN_ROLES.includes(user.role))
32+
) {
33+
return NextResponse.json({ error: "存在しない種別です。" }, { status: 404 });
34+
}
35+
36+
// ルートの経由地(spot_route_points)の入れ替えは親のspot_routesがupsertで
37+
// 必ずUPDATEされる(updated_atが進む)ため、ここでは親テーブルだけ見れば足りる
38+
const { rows } = await query<{
39+
latest: string | null;
40+
spot_count: string;
41+
route_count: string;
42+
}>(
43+
`select
44+
greatest(
45+
(select max(updated_at) from spots where spot_type_id = $1 and status = 'published'),
46+
(select max(updated_at) from spot_routes where spot_type_id = $1 and status = 'published')
47+
) as latest,
48+
(select count(*) from spots where spot_type_id = $1 and status = 'published') as spot_count,
49+
(select count(*) from spot_routes where spot_type_id = $1 and status = 'published') as route_count`,
50+
[activeType.id]
51+
);
52+
const row = rows[0];
53+
return NextResponse.json({
54+
data: {
55+
latest: row.latest,
56+
spotCount: Number(row.spot_count),
57+
routeCount: Number(row.route_count),
58+
},
59+
});
60+
}

components/MapView.tsx

Lines changed: 47 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ const VISIT_PATH_COLOR = "#16a34a";
118118
/** 訪問予定リスト(旅程)の経路の線・矢印の色。訪問順の経路(緑)・ルートと区別する紫 */
119119
const PLAN_LIST_PATH_COLOR = "#9333ea";
120120

121+
/**
122+
* 「現在地→訪問予定リスト先頭のスポット」の区間の線・矢印の色。
123+
* GeolocateControlの現在地の青丸(maplibre-gl既定の.maplibregl-user-location-dot)と
124+
* 同じ青にして、現在地から出ている線だと分かるようにする
125+
*/
126+
const CURRENT_LOCATION_PATH_COLOR = "#1da1f2";
127+
121128
/**
122129
* ルートの進行方向を示す右向き矢印(白フチ付き)の画像を色ごとに生成して登録する。
123130
* symbol-placement: "line" のシンボルはライン方向に回転して置かれるため、
@@ -547,9 +554,11 @@ function buildPlanListPath(
547554
/**
548555
* ルートと、地図に重ねる色付きの経路(訪問順の経路・訪問予定リストの経路)を
549556
* GeoJSONのLineString群にする。矢印画像の登録もここで済ませる。
550-
* `start`([lng, lat])を渡した経路は、その座標を先頭に繋いで描く
551-
* (訪問予定リストの経路で「現在地→リスト先頭のスポット」の線を引くのに使う。
552-
* 経路のスポットが1件も無いときは始点だけでは線にならないため繋がない)。
557+
* `start`([lng, lat])を渡した経路は、その座標からその経路の先頭までの区間を
558+
* `startColor`(省略時は経路と同色)の別のLineStringとして繋いで描く
559+
* (訪問予定リストの経路で「現在地→リスト先頭のスポット」の線を現在地の青丸と
560+
* 同じ色で引くのに使う。経路のスポットが1件も無いときは始点だけでは線に
561+
* ならないため繋がない)。
553562
*/
554563
function buildRouteGeoJSON(
555564
map: maplibregl.Map,
@@ -560,17 +569,27 @@ function buildRouteGeoJSON(
560569
color: string;
561570
kind?: "visit" | "plan";
562571
start?: [number, number] | null;
572+
startColor?: string;
563573
}[]
564574
): GeoJSON.FeatureCollection<GeoJSON.LineString, RouteFeatureProps> {
565575
const extraFeatures: GeoJSON.Feature<GeoJSON.LineString, RouteFeatureProps>[] =
566576
extraPaths
567-
.map((p) => ({
568-
...p,
569-
coordinates: [
570-
...(p.start && p.path.length > 0 ? [p.start] : []),
571-
...p.path.map((s): [number, number] => [s.lng, s.lat]),
572-
],
573-
}))
577+
.flatMap((p) => [
578+
// 始点(現在地)→経路先頭の区間。色を分けられるよう独立した線にする
579+
...(p.start && p.path.length > 0
580+
? [
581+
{
582+
...p,
583+
color: p.startColor ?? p.color,
584+
coordinates: [
585+
p.start,
586+
[p.path[0].lng, p.path[0].lat] as [number, number],
587+
],
588+
},
589+
]
590+
: []),
591+
{ ...p, coordinates: p.path.map((s): [number, number] => [s.lng, s.lat]) },
592+
])
574593
.filter((p) => p.coordinates.length >= 2)
575594
.map((p) => ({
576595
type: "Feature" as const,
@@ -1217,6 +1236,10 @@ export default function MapView({
12171236
// 経路詳細の「編集」から来た場合、基本情報モーダルは閉じて地図の作成モードに移る
12181237
// (同一ページ遷移のため自動では閉じない。SpotsView からの遷移では unmount で消える)
12191238
setEditingPlanList(null);
1239+
// スポット詳細の「訪問予定リストへ追加」→「新しいリストを作成」から来た場合も
1240+
// 同じく同一ページ遷移のため、スポット詳細(とその中の追加モーダル・基本情報
1241+
// フォーム)を明示的に閉じる
1242+
setDetailSpotId(null);
12201243
}
12211244
}, [buildListParam, spotTypeKey]);
12221245

@@ -2171,11 +2194,20 @@ export default function MapView({
21712194
color: PLAN_LIST_PATH_COLOR,
21722195
kind: "plan",
21732196
// 現在地(青丸)を表示中は、現在地からリスト先頭のスポットまでも結ぶ
2197+
// (この区間だけ青丸と同じ青)
21742198
start: currentLocation,
2199+
startColor: CURRENT_LOCATION_PATH_COLOR,
21752200
},
21762201
// 作成モード中の下書きの経路。kind無し=線のタップで詳細は開かない
2177-
// (作成中のタップはピンの追加操作を優先するため)
2178-
{ path: buildDraftPath, color: PLAN_LIST_PATH_COLOR },
2202+
// (作成中のタップはピンの追加操作を優先するため)。
2203+
// 保存済みリストの経路表示と同じく、現在地(青丸)を表示中は
2204+
// 現在地から下書き先頭のスポットまでも青で結ぶ
2205+
{
2206+
path: buildDraftPath,
2207+
color: PLAN_LIST_PATH_COLOR,
2208+
start: currentLocation,
2209+
startColor: CURRENT_LOCATION_PATH_COLOR,
2210+
},
21792211
])
21802212
);
21812213
});
@@ -3185,6 +3217,9 @@ export default function MapView({
31853217
onClose={() => setDetailSpotId(null)}
31863218
onVisitChange={loadVisits}
31873219
onVisitRecorded={handleVisitRecorded}
3220+
// 既存の訪問予定リストへの追加をリスト一覧へ反映する(経路表示中の
3221+
// リストに追加した場合、地図の紫の経路も引き直される)
3222+
onPlanListChange={loadPlanLists}
31883223
onSpotChange={(spot) => {
31893224
spotCache.applySpotChange(spot);
31903225
loadPrivateSpots();

components/SpotDetailModal.tsx

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export default function SpotDetailModal({
7575
onSpotChange,
7676
onSpotDeleted,
7777
onVisitPlanChange,
78+
onPlanListChange,
7879
onReviewChange,
7980
}: {
8081
spotId: string;
@@ -105,6 +106,9 @@ export default function SpotDetailModal({
105106
onSpotDeleted?: (spotId: string) => void;
106107
/** 訪問予定への追加・解除があったときに呼ばれる(呼び出し元の一覧の再取得用) */
107108
onVisitPlanChange?: () => void;
109+
/** 既存の訪問予定リストへこのスポットを追加したときに呼ばれる(呼び出し元の
110+
* リスト一覧の再取得用。地図なら経路表示中のリストの線にも反映される) */
111+
onPlanListChange?: () => void;
108112
/** 口コミの投稿があったときに呼ばれる(呼び出し元の「自分が書いた口コミ」一覧の再取得用) */
109113
onReviewChange?: () => void;
110114
}) {
@@ -134,6 +138,40 @@ export default function SpotDetailModal({
134138
const [moderating, setModerating] = useState(false);
135139
// 訪問履歴のサムネイルをタップしたときに拡大表示する写真のURL
136140
const [photoPreview, setPhotoPreview] = useState<string | null>(null);
141+
// 「Google マップで経路を表示」のorigin(出発地)に使う現在地([lat, lng])。
142+
// originを指定しないとGoogle マップ側が最後に開いていた地点等を出発地にして
143+
// しまうことがあるため、現在地が分かるときは明示的に渡す。ただし位置情報の
144+
// 権限が既に許可されている場合のみ取得する(スポット詳細を開いただけで権限
145+
// ダイアログを出さないため)。取れないときはoriginなし=従来の挙動に落とす
146+
const [routeOrigin, setRouteOrigin] = useState<[number, number] | null>(null);
147+
148+
useEffect(() => {
149+
if (typeof navigator === "undefined" || !navigator.geolocation) return;
150+
let cancelled = false;
151+
(async () => {
152+
try {
153+
const status = await navigator.permissions.query({ name: "geolocation" });
154+
if (cancelled || status.state !== "granted") return;
155+
navigator.geolocation.getCurrentPosition(
156+
(pos) => {
157+
if (!cancelled) {
158+
setRouteOrigin([pos.coords.latitude, pos.coords.longitude]);
159+
}
160+
},
161+
() => {
162+
// 取得失敗(タイムアウト等)はoriginなしのままにする
163+
},
164+
// 地図の現在地表示(GeolocateControl)が直近に取った位置があればそれで足りる
165+
{ maximumAge: 60_000, timeout: 10_000 }
166+
);
167+
} catch {
168+
// permissions APIが無い環境では従来どおりoriginなし
169+
}
170+
})();
171+
return () => {
172+
cancelled = true;
173+
};
174+
}, []);
137175

138176
// 拡大表示中はEscキーでも閉じられるようにする
139177
useEffect(() => {
@@ -463,7 +501,11 @@ export default function SpotDetailModal({
463501
<a
464502
href={`https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(
465503
`${spot.lat},${spot.lng}`
466-
)}`}
504+
)}${
505+
routeOrigin
506+
? `&origin=${encodeURIComponent(`${routeOrigin[0]},${routeOrigin[1]}`)}`
507+
: ""
508+
}`}
467509
target="_blank"
468510
rel="noopener noreferrer"
469511
aria-label="Google マップで経路を表示"
@@ -757,6 +799,7 @@ export default function SpotDetailModal({
757799
spotName={spot?.name}
758800
typeKey={typeKey}
759801
onClose={() => setShowAddToList(false)}
802+
onAdded={onPlanListChange}
760803
/>
761804
)}
762805
</div>

components/SpotDownloadDialogs.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,34 +64,38 @@ export function DownloadProgressDialog({
6464

6565
/**
6666
* 公開スポットのダウンロード確認まわりのダイアログ。
67-
* 未ダウンロード時の確認と、歯車メニューからの手動ダウンロード確認の両方をここでまとめて描画する
68-
* (/[type]/map・/[type]/spots で共通利用)。
67+
* 地図を開いたときの自動確認(未ダウンロード時と、ダウンロード済みキャッシュより
68+
* 新しい更新がサーバーにあるとき)と、歯車メニューからの手動ダウンロード確認の両方を
69+
* ここでまとめて描画する(/[type]/map・/[type]/spots で共通利用。ただし一覧側は
70+
* autoPrompt: falseで自動確認を出さないため、実際に表示されるのは地図のみ)。
6971
* ダウンロード中は全画面を覆う進捗ダイアログを最前面(NavBar・他ダイアログより上)に出し、
7072
* 完了までタブ移動などの操作をできなくする。
7173
*/
7274
export default function SpotDownloadDialogs({ cache }: { cache: SpotCache }) {
7375
return (
7476
<>
75-
{cache.showMissingPrompt && (
77+
{cache.downloadPrompt && (
7678
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 p-4">
7779
<div className="w-full max-w-sm rounded-2xl bg-white p-4">
7880
<p className="text-sm text-gray-700">
79-
スポットデータが未ダウンロードです。ダウンロードしますか?
81+
{cache.downloadPrompt === "stale"
82+
? "スポットデータが更新されています。最新のデータをダウンロードしますか?"
83+
: "スポットデータが未ダウンロードです。ダウンロードしますか?"}
8084
</p>
8185
{cache.error && (
8286
<p className="mt-2 text-xs text-red-600">{cache.error}</p>
8387
)}
8488
<div className="mt-4 flex gap-2">
8589
<button
8690
type="button"
87-
onClick={cache.dismissMissingPrompt}
91+
onClick={cache.dismissDownloadPrompt}
8892
className="flex-1 rounded-lg border border-gray-300 py-2 text-sm"
8993
>
9094
あとで
9195
</button>
9296
<button
9397
type="button"
94-
onClick={cache.confirmMissingDownload}
98+
onClick={cache.confirmDownloadPrompt}
9599
className="flex-1 rounded-lg bg-blue-600 py-2 text-sm font-medium text-white"
96100
>
97101
ダウンロード

components/SpotsView.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,9 @@ export default function SpotsView({
186186
/** 表示対象のスポット種別キー(常に /[type]/spots から渡される) */
187187
spotTypeKey: string;
188188
}) {
189-
const spotCache = useSpotCache(spotTypeKey);
189+
// 一覧では未ダウンロード・更新ありの自動確認ダイアログを出さない(地図側でだけ促す。
190+
// 未ダウンロードのままなら公開スポット抜き=自分のスポットだけの一覧になる)
191+
const spotCache = useSpotCache(spotTypeKey, { autoPrompt: false });
190192
const seriesStyles = useSeriesStyles(spotTypeKey);
191193
// 種別のカテゴリ設定。絞り込みチップの並び順に使う
192194
const categories = useCategories(spotTypeKey);
@@ -894,6 +896,7 @@ export default function SpotsView({
894896
onSpotChange={refreshAfterSpotChange}
895897
onSpotDeleted={refreshAfterSpotDelete}
896898
onVisitPlanChange={loadVisitPlans}
899+
onPlanListChange={loadPlanLists}
897900
onReviewChange={loadMyReviews}
898901
/>
899902
)}
@@ -1029,6 +1032,7 @@ export default function SpotsView({
10291032
onSpotChange={refreshAfterSpotChange}
10301033
onSpotDeleted={refreshAfterSpotDelete}
10311034
onVisitPlanChange={loadVisitPlans}
1035+
onPlanListChange={loadPlanLists}
10321036
onReviewChange={loadMyReviews}
10331037
/>
10341038
)}

lib/spotCacheDb.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,14 @@ export type CachedSpot = Pick<
2525

2626
export interface StoredSpotCache {
2727
downloadedAt: string; // ISO
28+
/**
29+
* ダウンロードしたデータ(公開スポット・公開ルート)の中で最も新しいupdated_at。
30+
* 鮮度チェック(/api/spots/last-updated との比較)は端末の時計に依らないよう
31+
* downloadedAt(端末時刻)ではなくこちら(サーバー時刻)で行う。
32+
* この項目を持たない旧エントリはdownloadedAtへフォールバックする(任意項目のため
33+
* DB_VERSIONは上げない)
34+
*/
35+
latestUpdatedAt?: string | null;
2836
spots: CachedSpot[];
2937
/**
3038
* 公開(published)ルート。公開スポットのダウンロードと同時に取得して一緒に保存する

lib/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ export interface SpotRoute {
110110
status: SpotStatus;
111111
created_by: string | null;
112112
created_at: string;
113+
/**
114+
* 経由地の入れ替え(upsert)でも進む。公開スポットキャッシュの鮮度判定
115+
* (lib/useSpotCache.ts)に使う。旧バージョンで保存したキャッシュ内の
116+
* ルートには入っていないことがある
117+
*/
118+
updated_at: string;
113119
points: SpotRoutePoint[];
114120
}
115121

0 commit comments

Comments
 (0)