Skip to content

Commit 682bc99

Browse files
committed
DBの置き場をdata/に移し、訪問順の経路を日ごとに分ける
- Postgresの実データの置き場を db/data から data へ移した。他のプロジェクトと 同じくリポジトリ直下の data/ に揃える。既存環境は更新時に mv db/data data が 要るため、README と docs/postgres-18-upgrade.md に手順を書いた (移さずに起動すると空のDBが作られる。旧データは消えない) - 訪問順の経路を日ごとに分けた(buildVisitPath → buildVisitPathsByDay)。 期間を指定したときに日をまたいでスポットを結ばない —— 宿へ帰って翌朝また出る 間の移動は実際には辿っていないので、繋ぐと1日の道のりが読めなくなるため。 線・詳細・Google マップの経路検索のいずれも日ごとに別のルートとして扱う (フィーチャに pathDate を持たせ、タップした線の日ぶんだけを詳細に出す)。 地図の移動(fitBounds)だけは選んだ期間の全体で行う - ルート・経路の詳細でスポット名をタップしたとき、その位置へ flyTo したあとに そのスポットの詳細モーダルを開くようにした。一覧から辿ったときに「そこが 何なのか」を見に行くまでを1タップで済ませるため。詳細の出し分けはピンの タップと同じで、本体種別なら通常のモーダル、別種別なら読み取り専用 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 6488d5c commit 682bc99

12 files changed

Lines changed: 124 additions & 64 deletions

.dockerignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ node_modules
44
.github
55
.env
66
.env.local
7-
db/data
7+
data
88
photos

.github/dependabot.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ updates:
3333
day: "monday"
3434
timezone: "Asia/Tokyo"
3535
ignore:
36-
# Postgresのメジャーアップデートはdb/dataの移行作業(pg_upgradeまたは
36+
# Postgresのメジャーアップデートはdata/の移行作業(pg_upgradeまたは
3737
# dump/restore)が必要なため、自動PRにはしない(意図的に手動で上げる)
3838
- dependency-name: "postgres"
3939
update-types: ["version-update:semver-major"]

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ yarn-error.log*
3131
# (docker-compose.yml)が起動時にmkdirするので.gitkeepは不要。中身ではなく
3232
# ディレクトリごと無視することで、postgresユーザー所有で権限が絞られた
3333
# 中身をgitが読みに行かず「could not open directory」警告が出ないようにする
34-
/db/data/
34+
/data/
3535

3636
# 訪問記録の写真ファイル(docker-compose.ymlのbindマウント先、lib/photos.ts参照)
37-
# db/data と同じ理由で、中身はコミットせずフォルダだけ .gitkeep で残す
37+
# data と同じ理由で、中身はコミットせずフォルダだけ .gitkeep で残す
3838
/photos/*
3939
!/photos/.gitkeep
4040

CLAUDE.md

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

README.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,15 +129,19 @@ docker compose pull && docker compose up -d
129129
- Composeのプロジェクト名は本番・開発・standaloneとも`travel-log`。以前は
130130
`travel-log-prod`/`travel-log-dev`に分けていたため、**それ以前から動かしている
131131
ホストでは初回だけ旧スタックを止めてから起動すること**(止めずに`up`すると、
132-
同じ`db/data`を奪い合う新旧2つのスタックが並ぶ)
132+
同じ`data`を奪い合う新旧2つのスタックが並ぶ)
133133

134134
```bash
135135
docker compose -p travel-log-prod down # 開発機では -p travel-log-dev -f docker-compose.dev.yml
136136
docker compose pull && docker compose up -d
137137
```
138138
- 公開されるイメージは2つ。`ghcr.io/rtcode337/travel-log`(アプリ本体)と
139139
`ghcr.io/rtcode337/travel-log-db-init`(スキーマ・マイグレーション適用。`init`サービスが使う)
140-
- **PostgreSQL 16 時代の`db/data`を持つ既存環境は、更新前に1回だけデータ移行が必要**
140+
- **Postgresの実データの置き場は`data/`**(リポジトリ直下)。かつては`db/data/`だったので、
141+
それ以前から動かしているホストでは更新時に1回だけ移すこと(`docker compose down`のあと
142+
`mv db/data data`。standaloneはYAML冒頭の`x-db-data-dir`も新しいパスに書き換える)。
143+
移さずに起動すると空のDBが作られ、初期状態のアプリが立ち上がる(旧データは消えない)
144+
- **PostgreSQL 16 時代のデータを持つ既存環境は、更新前に1回だけデータ移行が必要**
141145
([docs/postgres-18-upgrade.md](docs/postgres-18-upgrade.md))。移行せずに起動すると
142146
dbコンテナが起動に失敗する(データは壊れない)
143147
- **DBスキーマの更新は自動**`docker compose up`すると`init`サービスが未適用の
@@ -167,7 +171,7 @@ docker compose pull && docker compose up -d
167171
`${...}`を使わず値を直書きし、bindマウントを絶対パスで書いたもの(サービス構成・
168172
起動順は`docker-compose.yml`と同じ)。`docker-compose.standalone.yml`としてコピーし
169173
(コピー側は`.gitignore`済み。秘密を直書きするため雛形は直接編集しない)、冒頭の
170-
「ここだけ編集」——3つの置き場(`db` / `db/data` / `photos`)の絶対パスと
174+
「ここだけ編集」——3つの置き場(`db` / `data` / `photos`)の絶対パスと
171175
`SESSION_SECRET`——を書き換えて貼り付ければ起動する。
172176

173177
`SESSION_SECRET`は空のままだとログイン時に`SESSION_SECRET is not set`で失敗するので、

components/MapView.tsx

Lines changed: 87 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ function ensureRouteLayers(
226226
map: maplibregl.Map,
227227
overlayKeysRef: OverlayKeysRef,
228228
onSelectRoute: (routeId: string) => void,
229-
onSelectPath: (kind: "visit" | "plan") => void
229+
onSelectPath: (kind: "visit" | "plan", date: string | null) => void
230230
) {
231231
if (map.getSource(ROUTES_SOURCE_ID)) return;
232232

@@ -299,11 +299,16 @@ function ensureRouteLayers(
299299
onSelectRoute(routeId);
300300
return;
301301
}
302-
const pathKind = e.features?.find(
302+
const pathFeature = e.features?.find(
303303
(f) =>
304304
f.properties?.pathKind === "visit" || f.properties?.pathKind === "plan"
305-
)?.properties?.pathKind;
306-
if (pathKind === "visit" || pathKind === "plan") onSelectPath(pathKind);
305+
);
306+
const pathKind = pathFeature?.properties?.pathKind;
307+
if (pathKind === "visit" || pathKind === "plan") {
308+
// 訪問順の経路は日ごとに線が分かれているので、タップした線の日を渡す
309+
const pathDate = pathFeature?.properties?.pathDate;
310+
onSelectPath(pathKind, typeof pathDate === "string" ? pathDate : null);
311+
}
307312
});
308313
map.on("mouseenter", ROUTE_HIT_LAYER_ID, () => {
309314
map.getCanvas().style.cursor = "pointer";
@@ -624,40 +629,57 @@ function visitedSpotIdsOn(visits: Visit[], filters: SpotFilters): Set<string> {
624629

625630
/**
626631
* 訪問順の経路の対象期間(`filters.visitedDate`〜`visitedDateTo`。開始日がnullなら
627-
* 表示しない)が選ばれているとき、その期間の訪問を訪問時刻の昇順に並べた経路を返す。
628-
* **期間をまたいでも1本の経路にする**(旅行の何日ぶんかをそのまま辿れるように)。
632+
* 表示しない)が選ばれているとき、その期間の訪問を**日ごとに分けて**、それぞれ
633+
* 訪問時刻の昇順に並べた経路を返す(日付の昇順)。
634+
* **日をまたいでスポットを線で結ばない** —— 宿へ帰って翌朝また出る間の移動は
635+
* 実際には辿っていないので、繋ぐと1日の道のりが読めなくなるため。
636+
* 線・詳細・Google マップの経路検索のいずれも日ごとに別のものとして扱う。
629637
* 別のスポット種別のスポットも、座標を補完できていれば経路に含める(訪問予定リストと
630638
* 同じ扱い。補完は pathExtraSpots)。解決できないスポットだけを除く。
631639
* 同じスポットへの再訪はそのまま複数回現れる(行って戻る線になる)が、
632640
* 連続する同じスポットへの訪問(同じ場所で複数回記録した場合)はまとめる
633641
* (長さ0の線分になり、矢印の向きが定まらないため)。
634642
*/
635-
function buildVisitPath(
643+
function buildVisitPathsByDay(
636644
visits: Visit[],
637645
filters: SpotFilters,
638646
spotById: Map<string, Spot>
639-
): Spot[] {
647+
): { date: string; path: Spot[] }[] {
640648
if (!filters.visitedDate) return [];
641-
return visits
642-
.flatMap((visit) => {
643-
if (!isInVisitedRange(visit.visited_on, filters)) return [];
644-
const spot = spotById.get(visit.spot_id);
645-
return spot ? [{ time: Date.parse(visit.visited_on!), spot }] : [];
646-
})
647-
.sort((a, b) => a.time - b.time)
648-
.map((v) => v.spot)
649-
.filter((spot, i, list) => i === 0 || spot.id !== list[i - 1].id);
649+
const byDay = new Map<string, { time: number; spot: Spot }[]>();
650+
for (const visit of visits) {
651+
if (!isInVisitedRange(visit.visited_on, filters)) continue;
652+
const spot = spotById.get(visit.spot_id);
653+
const date = toVisitDateKey(visit.visited_on);
654+
if (!spot || !date) continue;
655+
const day = byDay.get(date) ?? [];
656+
day.push({ time: Date.parse(visit.visited_on!), spot });
657+
byDay.set(date, day);
658+
}
659+
return [...byDay.entries()]
660+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
661+
.map(([date, entries]) => ({
662+
date,
663+
path: entries
664+
.sort((a, b) => a.time - b.time)
665+
.map((v) => v.spot)
666+
.filter((spot, i, list) => i === 0 || spot.id !== list[i - 1].id),
667+
}))
668+
.filter((day) => day.path.length > 0);
650669
}
651670

652671
/**
653672
* routeId はタップでルート詳細を開くのに使う。訪問順の経路・訪問予定リストの経路には
654673
* routeId の代わりに pathKind を付け、タップで対応する経路の詳細を開く。
674+
* 訪問順の経路は日ごとに別の線なので、どの日の線かを pathDate(`YYYY-MM-DD`)で持ち、
675+
* タップしたときにその日ぶんの詳細を出せるようにする。
655676
*/
656677
type RouteFeatureProps = {
657678
color: string;
658679
icon: string;
659680
routeId?: string;
660681
pathKind?: "visit" | "plan";
682+
pathDate?: string;
661683
};
662684

663685
/**
@@ -699,6 +721,8 @@ function buildRouteGeoJSON(
699721
path: Spot[];
700722
color: string;
701723
kind?: "visit" | "plan";
724+
/** 訪問順の経路で、その線がどの日のものか(`YYYY-MM-DD`) */
725+
date?: string;
702726
start?: [number, number] | null;
703727
startColor?: string;
704728
}[]
@@ -732,6 +756,7 @@ function buildRouteGeoJSON(
732756
color: p.color,
733757
icon: ensureRouteArrowImage(map, p.color),
734758
...(p.kind ? { pathKind: p.kind } : {}),
759+
...(p.date ? { pathDate: p.date } : {}),
735760
},
736761
}));
737762

@@ -1365,7 +1390,9 @@ export default function MapView({
13651390
// いないことがある。その場合は解決できた分だけで移動し、補完が届いたあとの
13661391
// 経路の描き直しに合わせて地図を動かし直すことはしない
13671392
// (ユーザーの操作なしに地図が動くのを避けるため)
1368-
fitMapToSpots(buildVisitPath(visits, next, pathSpotById));
1393+
fitMapToSpots(
1394+
buildVisitPathsByDay(visits, next, pathSpotById).flatMap((d) => d.path)
1395+
);
13691396
},
13701397
[filters, setFilters, visits, pathSpotById, fitMapToSpots]
13711398
);
@@ -1401,6 +1428,8 @@ export default function MapView({
14011428
const [detailPathKind, setDetailPathKind] = useState<"visit" | "plan" | null>(
14021429
null
14031430
);
1431+
// 訪問順の経路は日ごとに線が分かれるため、詳細を開いた線がどの日かを覚える
1432+
const [detailPathDate, setDetailPathDate] = useState<string | null>(null);
14041433
// 経路詳細の「編集」で開く訪問予定リストの基本情報編集モーダルの対象
14051434
const [editingPlanList, setEditingPlanList] = useState<VisitPlanList | null>(
14061435
null
@@ -1414,7 +1443,8 @@ export default function MapView({
14141443
setOverlayDetailRouteId(null);
14151444
setDetailRouteId(routeId);
14161445
}, []);
1417-
const openPathDetail = useCallback((kind: "visit" | "plan") => {
1446+
const openPathDetail = useCallback((kind: "visit" | "plan", date: string | null) => {
1447+
setDetailPathDate(date);
14181448
setDetailRouteId(null);
14191449
setOverlayDetailRouteId(null);
14201450
setDetailPathKind(kind);
@@ -2543,8 +2573,11 @@ export default function MapView({
25432573
isolate === null
25442574
? filterVisibleRoutes(routes, filters, seriesStyles, spotById)
25452575
: [];
2546-
const visitPath =
2547-
isolate === "plan" ? [] : buildVisitPath(visits, filters, pathSpotById);
2576+
// 訪問順の経路は日ごとに別の線にする(日をまたいで結ばない)
2577+
const visitPathsByDay =
2578+
isolate === "plan"
2579+
? []
2580+
: buildVisitPathsByDay(visits, filters, pathSpotById);
25482581
// 作成モード中に編集対象のリスト自身を経路表示していた場合は、更新前の経路が
25492582
// 下書きの経路と古い形のまま二重に残らないよう、保存済み側は描かない
25502583
const planListPath =
@@ -2560,7 +2593,12 @@ export default function MapView({
25602593
| undefined;
25612594
source?.setData(
25622595
buildRouteGeoJSON(map, visibleRoutes, seriesStyles, [
2563-
{ path: visitPath, color: VISIT_PATH_COLOR, kind: "visit" },
2596+
...visitPathsByDay.map((day) => ({
2597+
path: day.path,
2598+
color: VISIT_PATH_COLOR,
2599+
kind: "visit" as const,
2600+
date: day.date,
2601+
})),
25642602
{
25652603
path: planListPath,
25662604
color: PLAN_LIST_PATH_COLOR,
@@ -2787,6 +2825,8 @@ export default function MapView({
27872825
editList?: VisitPlanList;
27882826
points: {
27892827
key: string;
2828+
/** タップでその位置へ移動したあと、このスポットの詳細を開く */
2829+
spotId: string;
27902830
name: string;
27912831
lng: number;
27922832
lat: number;
@@ -2799,6 +2839,7 @@ export default function MapView({
27992839
pointNoun: "経由地",
28002840
points: detailRoute.points.map((p) => ({
28012841
key: `${p.spot_id}-${p.seq}`,
2842+
spotId: p.spot_id,
28022843
name: p.spot_name,
28032844
lng: p.lng,
28042845
lat: p.lat,
@@ -2807,20 +2848,20 @@ export default function MapView({
28072848
}
28082849
: detailPathKind === "visit"
28092850
? (() => {
2810-
const path = buildVisitPath(visits, filters, pathSpotById);
2811-
if (path.length === 0) return null;
2851+
// 線は日ごとに分かれているので、詳細もタップした日の1日ぶんだけを出す
2852+
// (期間指定でも「その日に辿った道のり」が読めるように)。日が分からない
2853+
// 古い状態のときは先頭の日にフォールバックする
2854+
const days = buildVisitPathsByDay(visits, filters, pathSpotById);
2855+
const day =
2856+
days.find((d) => d.date === detailPathDate) ?? days[0] ?? null;
2857+
if (!day) return null;
28122858
return {
2813-
title: "訪問順の経路",
2814-
description: filters.visitedDate
2815-
? filters.visitedDateTo
2816-
? `${formatVisitDate(filters.visitedDate)}${formatVisitDate(
2817-
filters.visitedDateTo
2818-
)}に訪問したスポットを、訪問した順に並べています。`
2819-
: `${formatVisitDate(filters.visitedDate)}に訪問したスポットを、訪問した順に並べています。`
2820-
: null,
2859+
title: `訪問順の経路(${formatVisitDate(day.date)})`,
2860+
description: `${formatVisitDate(day.date)}に訪問したスポットを、訪問した順に並べています。`,
28212861
pointNoun: "地点",
2822-
points: path.map((s, i) => ({
2862+
points: day.path.map((s, i) => ({
28232863
key: `${s.id}-${i}`,
2864+
spotId: s.id,
28242865
name: s.name,
28252866
lng: s.lng,
28262867
lat: s.lat,
@@ -2839,6 +2880,7 @@ export default function MapView({
28392880
editList: list,
28402881
points: path.map((s, i) => ({
28412882
key: `${s.id}-${i}`,
2883+
spotId: s.id,
28422884
name: s.name,
28432885
lng: s.lng,
28442886
lat: s.lat,
@@ -2850,6 +2892,7 @@ export default function MapView({
28502892
setDetailRouteId(null);
28512893
setOverlayDetailRouteId(null);
28522894
setDetailPathKind(null);
2895+
setDetailPathDate(null);
28532896
};
28542897

28552898
// 今表示中のスポット種別の表示名(左下のチップに出す)。spotTypesは重ね表示
@@ -3696,7 +3739,11 @@ export default function MapView({
36963739
<span className="w-6 shrink-0 text-right text-xs font-medium tabular-nums text-gray-500">
36973740
{i + 1}
36983741
</span>
3699-
{/* スポット名のタップでその位置へ飛ぶ(モーダルは閉じる) */}
3742+
{/* スポット名のタップでその位置へ飛び、続けてそのスポットの
3743+
詳細を開く(一覧から辿ったときに、そこが何なのかを
3744+
見に行くまでが1タップで済むように)。詳細は本体種別の
3745+
スポットなら通常のモーダル、別種別なら読み取り専用
3746+
(ピンをタップしたときと同じ出し分け) */}
37003747
<button
37013748
type="button"
37023749
onClick={() => {
@@ -3705,6 +3752,11 @@ export default function MapView({
37053752
center: [point.lng, point.lat],
37063753
zoom: 16,
37073754
});
3755+
if (spotById.has(point.spotId)) {
3756+
setDetailSpotId(point.spotId);
3757+
} else {
3758+
setOverlayDetailSpotId(point.spotId);
3759+
}
37083760
}}
37093761
className="min-w-0 truncate text-left font-medium text-blue-600 underline"
37103762
>
@@ -3727,7 +3779,7 @@ export default function MapView({
37273779
</ol>
37283780
<p className="pt-2 text-xs text-gray-500">
37293781
{routeDetailView.pointNoun}
3730-
{routeDetailView.points.length}件。スポット名をタップすると、その位置に地図を移動します
3782+
{routeDetailView.points.length}件。スポット名をタップすると、その位置へ移動して詳細を開きます
37313783
</p>
37323784
{/* 経路全体をGoogle マップの経路検索で開く(先頭が出発地、
37333785
途中が経由地、最後が目的地) */}

db/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
#
55
# mainへのpushでGitHub Actions(.github/workflows/docker-publish.yml)が
66
# ghcr.io/<repo>-db-init として公開する。本番はこのイメージをpullして使う
7-
# (かつては db/data の準備(prepare)も担っていた名残でこのイメージ名。
7+
# (かつては データディレクトリの準備(prepare)も担っていた名残でこのイメージ名。
88
# サービス名を変えてもイメージ名を変えると GHCR 側の整理が要るため据え置き)
99
FROM postgres:18-alpine
1010

docker-compose.dev.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ services:
1616
# スキーマを更新したときにgit pullが失敗するようになるため)
1717
# マウント先が /var/lib/postgresql/data ではなく1段上なのは本番用と同じ理由
1818
# (docker-compose.ymlのdbサービスのコメント参照)
19-
- ./db/data:/var/lib/postgresql
19+
- ./data:/var/lib/postgresql
2020
healthcheck:
2121
test: ["CMD-SHELL", "pg_isready -U travel_log"]
2222
interval: 5s

docker-compose.standalone.example.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ name: travel-log
3232
# db-data… Postgresの実データ(所有者はpostgresのエントリポイントが自分で揃える)
3333
# photos … 訪問記録の写真。DBには相対パスだけが入るので、DBと一緒にバックアップする
3434
# どちらも**あらかじめ作っておくこと**(bindマウント先を自動作成しない環境がある)。
35-
x-db-data-dir: &db-data-dir "/path/to/travel-log/db/data"
35+
x-db-data-dir: &db-data-dir "/path/to/travel-log/data"
3636
x-photos-dir: &photos-dir "/path/to/travel-log/photos"
3737

3838
# ---- ここだけ編集: アプリの設定(.env の代わり) ----

docker-compose.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ services:
1414
# postgres:18のイメージはPGDATAが /var/lib/postgresql/18/docker で、
1515
# VOLUME宣言も1段上の /var/lib/postgresql に変わった。マウント先を
1616
# /var/lib/postgresql/data のままにすると匿名ボリュームが毎回作られるため
17-
# 1段上をマウントする(実データはホストの db/data/18/docker に入る)。
18-
# db/dataが無ければDockerが作り、所有者はpostgresのエントリポイント
17+
# 1段上をマウントする(実データはホストの data/18/docker に入る)。
18+
# dataが無ければDockerが作り、所有者はpostgresのエントリポイント
1919
# (rootで起動して自分で揃える)が調整する。bindマウント先を自動作成しない
2020
# 環境(一部のNAS等)では、先にディレクトリを作っておくこと
21-
- ./db/data:/var/lib/postgresql
21+
- ./data:/var/lib/postgresql
2222
healthcheck:
2323
test: ["CMD-SHELL", "pg_isready -U travel_log"]
2424
interval: 5s

0 commit comments

Comments
 (0)