Skip to content

Commit 0a6c5ef

Browse files
rtcode337claude
andauthored
未訪問記録と非表示スポットを追加 (#54)
* 未訪問記録と非表示スポットを追加 未訪問記録(spot_notes): 訪問したが休みや時間の都合でちゃんと見られなかった ときの記録や、事前の下調べのメモを、訪問記録とは別の非公開メモとして残せる (日時は任意+メモ必須。訪問済みにはならず、訪問記録をつけても自動では消えない)。 スポット詳細に専用セクション、/[type]/spotsに自分の未訪問記録の一覧を追加。 非表示スポット(spot_hides): 興味のない公開スポットをユーザーごとに自分の 地図・一覧から非表示にできる(他のユーザーには影響しない)。スポット詳細の トグルで切り替え、地図のピン・地域別一覧・シリーズから探す(サーバー側で除外) から消える。訪問順の経路・訪問予定リストなど明示的に選んだ表示対象は免除。 解除は/[type]/spotsの「非表示にしたスポット」一覧から。 - スキーマにspot_notes/spot_hidesを追加し、migrations/007で既存DBを移行 (旧スキーマ+007と新規スキーマの列・索引・トリガーの一致を確認済み) - API: /api/spot-notes(+/[id])、/api/spot-hides(+/[spotId]) - GET /api/spotsのページング分岐のみ非表示スポットをSQLで除外 (CSV差分インポート等が使う全件取得には適用しない) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHfn9JddE6JBkcWXxHPRgb * 未訪問記録を訪問記録に統合(visits.unvisitedフラグ化) 独立テーブル(spot_notes)をやめ、訪問記録と同じ場所に記録する設計に変更。 visitsにunvisited列(boolean、既定false)を追加し、同じフォーム (VisitFormModalのチェックボックス/「+ 未訪問記録」ボタン)・同じ訪問履歴・ 「最近の訪問場所」・ZIPエクスポート(「未訪問記録」列)に琥珀色の 「未訪問」バッジ付きで並ぶ。 - 訪問日あり=「訪れたが改めて来たい」: その日の訪問順の経路に含まれ、 訪問予定からも通常の訪問と同じく自動で外れる(経路表示中の 訪問予定リストからの自動除外も同じ) - 訪問日なし=「下調べ」: まだ行っていないため訪問予定は残る (表示も「時期不明」ではなく「下調べ」) - どちらも訪問済みの判定(ピンの緑色・訪問状況の絞り込み・✓回数・ 地域別の訪問数・訪問日順ソート)には数えない(countedVisits) - それ以外(写真・メモ・Exif・編集・削除)は通常の訪問記録と同じ migrations/007を書き換え(spot_notes作成→visits.unvisited追加。 このブランチはまだ未マージのため版の付け直しはしない)。 旧スキーマ+007と新規スキーマの列・索引・トリガーの一致を確認済み。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHfn9JddE6JBkcWXxHPRgb * 「+ 未訪問記録」から開くフォームの訪問日時を空欄(下調べ)既定にする 下調べ(日時なしの未訪問記録)はどの経路にも含まれず訪問予定も消えない、 という挙動自体は実装済みだが、フォームの訪問日時が現在時刻でプリセット されるため、そのまま保存すると意図せず「訪れたが改めて来たい」記録 (=今日の経路に含まれ、訪問予定も外れる)になってしまっていた。 「+ 未訪問記録」から開いたときは空欄=下調べを既定にする。 あわせて日時欄のヒント文を未訪問記録では「空欄なら下調べのメモになる (どの経路にも含まれず、訪問予定も残る)」に差し替えられるようにした (VisitFieldsのvisitedOnHint prop)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHfn9JddE6JBkcWXxHPRgb --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f48da3d commit 0a6c5ef

17 files changed

Lines changed: 529 additions & 57 deletions

File tree

CLAUDE.md

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

217217
自分の訪問記録は`/[type]/spots`の「最近の訪問場所」見出し右のボタンからZIPで一括エクスポートできる(`GET /api/visits/export?type=<種別キー>`。typeは必須で、その種別の分のみ。種別横断のエクスポートは意図的に持たない)。ZIPの中身は`visits.csv`(BOM付きUTF-8。訪問のメモ+スポット情報、`lib/csv.ts``buildCsv`)と`photos/<uuid>.<ext>`(添付写真。CSVの「写真」列がこのZIP内パスを指す)。ZIP生成は依存を増やさず`lib/zip.ts`の自前実装(無圧縮STORE。中身が圧縮済み画像と小さなCSVのみのため)で、写真は配信APIと同じく`parseVisitPhotoPath`の所有者チェックを通ったものだけを`readVisitPhoto`で読む(保存先の切り替えに追随する)。
218218

219+
### 未訪問記録(`visits.unvisited`)と非表示スポット(`spot_hides`)
220+
221+
**未訪問記録**は「訪問したが休みや時間の都合でちゃんと見られなかった(改めて来たい)」「事前の下調べをメモしておきたい」ときの、**訪問済みには数えない訪問記録**。独立したテーブルではなく`visits``unvisited`フラグ(boolean、既定false)で、訪問記録と同じ場所(同じフォーム`VisitFormModal`のチェックボックス、スポット詳細の同じ訪問履歴一覧、`/[type]/spots`の「最近の訪問場所」、訪問記録ZIPエクスポートの「未訪問記録」列)に記録される。日時の有無で意味が分かれる:
222+
223+
- **`visited_on`あり=「訪れたが改めて来たい」**。その日の訪問順の経路(`buildVisitPath``unvisited`を見ない)に含まれ、訪問予定(`visit_plans`)からも通常の訪問と同じく自動で外れる(地図で経路表示中の訪問予定リストからの自動除外も同じ。`VisitFormModal``onSaved`が保存済みレコードを渡し、`SpotDetailModal`が出し分ける)
224+
- **`visited_on`なし=「下調べ」**。まだ行っていないため、どの経路にも含まれず(`buildVisitPath`は日付一致で拾うため日時なしは自然に対象外)、訪問予定も外れない(POST `/api/visits``unvisited && !visited_on`のときだけ`visit_plans`の削除をスキップ)。表示は「時期不明」ではなく「下調べ」
225+
226+
どちらも**訪問済みの判定には数えない**: `lib/types.ts``countedVisits`(`unvisited`を除いたvisits)をピンの緑色・訪問状況の絞り込み・✓回数・地域別の訪問数・訪問日順ソートの`visitedIds`/`latestVisitDate`算出に使う。それ以外(写真・メモ・Exif・編集・削除・一覧表示)は通常の訪問記録と完全に同じで、一覧では琥珀色の「未訪問」バッジで見分ける。作成は訪問履歴の「+ 未訪問記録」ボタン(同じフォームを`initialUnvisited`で開くだけ)からも、通常フォーム内のチェックボックスからもできる。**「+ 未訪問記録」から開いたときの訪問日時は空欄=下調べが既定**(通常の訪問記録と同じく現在時刻をプリセットすると、そのまま保存しただけで日時ありの「改めて来たい」扱い=経路に含まれ訪問予定も外れる、になってしまうため)。
227+
228+
**非表示スポット**は「公開スポットのうち自分は興味がないもの」をユーザーごとに自分の地図・一覧から隠す設定。`spot_hides`(user_id×spot_idで一意。`visit_plans`と同じトグル構造)で、スポット自体には影響しない。APIは`/api/spot-hides`(GET/POST upsert)と`/api/spot-hides/[spotId]`(DELETE)。切り替えはスポット詳細の最下部のトグル(公開スポットのみ表示)。除外の掛かり方:
229+
230+
- 地図(`MapView`)はピンの絞り込み段階で除外する(`hiddenIds``spots`配列自体は削らないので、訪問順の経路・訪問予定リスト・ルートのスポット解決は壊れない)。ただし**経路(訪問順・訪問予定リスト・作成中の下書き)の対象スポット(`pathIds`)と「これだけを表示」は非表示でも出す**(ユーザーが明示的に選んで辿っている表示のため。他の絞り込み免除と同じ扱い)。ルート経由地の免除(`routeMemberIds`)は非表示を上書きしない(線は通るがピンは出ない)。スポットIDで引くユーザーごとの設定のため種別をまたいで共通に効き、重ね表示側にも同じ集合を適用する
231+
- `/[type]/spots`の地域別ドリルダウン(件数・一覧)からはクライアント側で除外し、「シリーズから探す」(サーバーページング)は`GET /api/spots`のページング分岐だけが`spot_hides`をSQLで除外する(**非ページングの全件取得には適用しない** — 管理画面のCSV差分インポート・自分の非公開スポット取得が欠けると困るため)。公開スポットのIndexedDBキャッシュにも手を入れない(非表示の切り替えに再ダウンロードが要らないよう、表示側でのみ除外する)
232+
- 解除の入口は`/[type]/spots`の「非表示にしたスポット」一覧(タップで詳細を開いてトグル)。訪問予定・最近の訪問場所など自分の記録の一覧は明示データのため除外しない
233+
219234
### 訪問予定リスト(旅程)
220235

221236
複数スポットを順序付きでまとめる「訪問予定リスト」(旅程)。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``(list_id, spot_id)`一意)の2テーブル(`db/init/01_schema.sql`、移行は`migrations/006`)。**種別ごと**(地図の作成が`/[type]/map`上で行われるため。CLAUDE作成時の判断で種別横断は不可)。

README.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
- **シリーズ**(1スポットに1つ)と**カテゴリ**(1スポットに複数可)をキュレーション項目として付与(口コミ評価とは別軸)。観光地では知名度をA〜Eの5段階にしたものをシリーズに使っている
1111
- **訪問記録**(いつ・何回・メモ・写真)と、同一スポットへの複数回訪問に対応(訪問日時は未入力なら「時期不明」)。記録した内容は後から編集できる
1212
- **訪問予定**(行きたい場所のブックマーク)。訪問を記録すると自動的に外れる
13+
- **未訪問記録**。訪問したけれど休みや時間の都合でちゃんと見られなかった(改めて来たい)ときの記録や、事前の下調べのメモを、訪問記録と同じ場所に「訪問済みに数えない記録」として残せる。訪問日を入れればその日の訪問順の経路に含まれ訪問予定からも外れる。訪問日なしは下調べのメモになり訪問予定は残る
14+
- **非表示スポット**。興味のない公開スポットを、ユーザーごとに自分の地図・一覧から非表示にできる(他のユーザーには影響しない)
1315
- **口コミ**(公開・本文のみのシンプルな投稿)
1416
- スポットには「種別」があり(観光地・郵便局・御朱印など)、種別ごとに独立したURL・独自のシリーズ/カテゴリ/対象地域を持てる。管理者が自由に追加・削除できる
1517
- **ルート(巡った順の矢印)**。スポットを巡った順に繋いだラインと進行方向の矢印を地図に表示できる(訪問順のある種別向け。CSVで取り込み、絞り込みモーダルの「ルートを表示」トグルでオン/オフする。シリーズ・カテゴリの絞り込みにも連動)。線をタップするとルート詳細(ルート名・説明・全経由地の一覧。経由地の間には区間ごとの説明=移動手段なども出せる)が開く。ルートはスポットと同じ公開/非公開の仕組みを持ち、公開ルートは公開スポットのダウンロード時に一緒に端末へ保存される
@@ -154,11 +156,18 @@ docker compose pull && docker compose up -d
154156
ロールにより既定が異なり(`user`は非公開、それ以外は承認待ち)、admin/spot_adminは
155157
`/[type]/admin`の承認待ちキューから個別承認、または「すべて承認」で一括公開できる。
156158

157-
## 訪問予定・口コミ・訪問写真
159+
## 訪問予定・未訪問記録・口コミ・訪問写真
158160

159161
スポット詳細モーダルから、以下を管理できる。
160162

161163
- **訪問予定(非公開)**: 「行きたい場所」のブックマーク。訪問を記録すると自動的に外れる
164+
- **未訪問記録(非公開)**: 訪問記録と同じフォーム・同じ訪問履歴に「訪問済みに数えない
165+
記録」として残すメモ(フォームのチェックボックスまたは「+ 未訪問記録」ボタンから)。
166+
訪問日を入れると「訪れたが改めて来たい」記録としてその日の訪問順の経路に含まれ、
167+
訪問予定からも外れる。訪問日なしは下調べのメモになり、訪問予定は残る。
168+
どちらもピンは緑にならず、一覧では「未訪問」バッジ付きで表示される
169+
- **非表示スポット**: 興味のない公開スポットを自分の地図・一覧から隠すユーザーごとの設定。
170+
解除は`/[type]/spots`の「非表示にしたスポット」から
162171
- **口コミ(公開)**: 星評価はなく本文のみ。スポット1件につきユーザー1人1件(再投稿で上書き)
163172
- **写真(非公開)**: 自分の訪問記録にのみ添付。ブラウザ側で縮小・圧縮した上で`photos/`
164173
フォルダ(Dockerではbindマウント)に保存され、`/api/photos/...`経由(本人のみ)で配信する
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { NextResponse } from "next/server";
2+
import { query } from "@/lib/db";
3+
import { getCurrentUserId } from "@/lib/auth/current-user";
4+
5+
export async function DELETE(
6+
_request: Request,
7+
{ params }: { params: Promise<{ spotId: string }> }
8+
) {
9+
const userId = await getCurrentUserId();
10+
if (!userId) {
11+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
12+
}
13+
14+
const { spotId } = await params;
15+
await query("delete from spot_hides where user_id = $1 and spot_id = $2", [
16+
userId,
17+
spotId,
18+
]);
19+
return NextResponse.json({ ok: true });
20+
}

app/api/spot-hides/route.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { NextResponse } from "next/server";
2+
import { query } from "@/lib/db";
3+
import { getCurrentUserId } from "@/lib/auth/current-user";
4+
import type { SpotHide } from "@/lib/types";
5+
6+
export async function GET(request: Request) {
7+
const userId = await getCurrentUserId();
8+
if (!userId) {
9+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
10+
}
11+
12+
const { searchParams } = new URL(request.url);
13+
const spotId = searchParams.get("spot_id");
14+
15+
const { rows } = spotId
16+
? await query<SpotHide>(
17+
"select * from spot_hides where user_id = $1 and spot_id = $2",
18+
[userId, spotId]
19+
)
20+
: await query<SpotHide>(
21+
"select * from spot_hides where user_id = $1 order by created_at desc",
22+
[userId]
23+
);
24+
25+
return NextResponse.json({ data: rows });
26+
}
27+
28+
export async function POST(request: Request) {
29+
const userId = await getCurrentUserId();
30+
if (!userId) {
31+
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
32+
}
33+
34+
const { spot_id } = await request.json();
35+
if (typeof spot_id !== "string") {
36+
return NextResponse.json({ error: "invalid request" }, { status: 400 });
37+
}
38+
39+
// 既に非表示でも同じ結果を返す(トグルUI側での二重送信に強くする)
40+
const { rows } = await query<SpotHide>(
41+
`insert into spot_hides (user_id, spot_id)
42+
values ($1, $2)
43+
on conflict (user_id, spot_id) do update set user_id = excluded.user_id
44+
returning *`,
45+
[userId, spot_id]
46+
);
47+
return NextResponse.json({ data: rows[0] });
48+
}

app/api/spots/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ export async function GET(request: Request) {
7474
const conditions = [...baseConditions];
7575
const listParams = [...params];
7676

77+
// 自分が非表示にしたスポット(spot_hides)は「シリーズから探す」のページング一覧に
78+
// 出さない(件数にも含めない)。解除は/[type]/spotsの「非表示にしたスポット」から。
79+
// 非ページングの全件取得(自分の非公開スポット・管理画面のCSV差分等)には適用しない
80+
conditions.push(
81+
"not exists (select 1 from spot_hides h where h.user_id = $1 and h.spot_id = spots.id)"
82+
);
83+
7784
const search = searchParams.get("search");
7885
if (search) {
7986
listParams.push(`%${search}%`);

app/api/visits/[id]/route.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,17 @@ export async function PATCH(
6767
);
6868
}
6969

70+
// unvisited(未訪問記録かどうか)は指定されたときだけ更新する(旧クライアント互換)
71+
const unvisited =
72+
typeof body.unvisited === "boolean" ? body.unvisited : existing.unvisited;
73+
7074
let rows: Visit[];
7175
try {
7276
({ rows } = await query<Visit>(
73-
`update visits set visited_on = $1, memo = $2, photos = $3
74-
where id = $4 and user_id = $5
77+
`update visits set visited_on = $1, memo = $2, photos = $3, unvisited = $4
78+
where id = $5 and user_id = $6
7579
returning *`,
76-
[body.visited_on, body.memo, photoPaths, id, userId]
80+
[body.visited_on, body.memo, photoPaths, unvisited, id, userId]
7781
));
7882
} catch (e) {
7983
// DBに記録できなかった新規写真ファイルを残さない

app/api/visits/export/route.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ interface ExportRow {
2222
visited_on: Date | null;
2323
memo: string | null;
2424
photos: string[];
25+
unvisited: boolean;
2526
name: string;
2627
name_kana: string | null;
2728
lat: number;
@@ -60,7 +61,7 @@ export async function GET(request: Request) {
6061
}
6162

6263
const { rows } = await query<ExportRow>(
63-
`select v.visited_on, v.memo, v.photos,
64+
`select v.visited_on, v.memo, v.photos, v.unvisited,
6465
s.name, s.name_kana, s.lat, s.lng, s.region, s.series, s.categories
6566
from visits v
6667
join spots s on s.id = v.spot_id
@@ -83,6 +84,7 @@ export async function GET(request: Request) {
8384
"訪問日時(JST)",
8485
"メモ",
8586
"写真",
87+
"未訪問記録",
8688
],
8789
];
8890

@@ -113,6 +115,8 @@ export async function GET(request: Request) {
113115
formatVisitedAtJst(row.visited_on),
114116
row.memo,
115117
zipPaths.join(";"),
118+
// 未訪問記録(訪問済みに数えない記録)は「未訪問」、通常の訪問記録は空欄
119+
row.unvisited ? "未訪問" : "",
116120
]);
117121
}
118122

app/api/visits/route.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -63,25 +63,33 @@ export async function POST(request: Request) {
6363
);
6464
}
6565

66+
// unvisited=trueは「未訪問記録」(訪問済みには数えない記録)。省略時は通常の訪問記録
67+
const unvisited = body.unvisited === true;
68+
6669
let rows: Visit[];
6770
try {
6871
({ rows } = await query<Visit>(
69-
`insert into visits (user_id, spot_id, visited_on, memo, photos)
70-
values ($1, $2, $3, $4, $5)
72+
`insert into visits (user_id, spot_id, visited_on, memo, photos, unvisited)
73+
values ($1, $2, $3, $4, $5, $6)
7174
returning *`,
72-
[userId, body.spot_id, body.visited_on, body.memo, photoPaths]
75+
[userId, body.spot_id, body.visited_on, body.memo, photoPaths, unvisited]
7376
));
7477
} catch (e) {
7578
// DBに記録できなかった写真ファイルを残さない
7679
await deleteVisitPhotos(photoPaths);
7780
throw e;
7881
}
7982

80-
// 訪問を記録したら、その場所は訪問予定リストから自動的に外す
81-
await query("delete from visit_plans where user_id = $1 and spot_id = $2", [
82-
userId,
83-
body.spot_id,
84-
]);
83+
// 訪問を記録したら、その場所は訪問予定リストから自動的に外す。ただし
84+
// 日時なしの未訪問記録(=まだ行っていない下調べのメモ)は行きたい場所の
85+
// ままなので外さない(日時ありの未訪問記録は「訪れたが改めて来たい」
86+
// 記録のため、通常の訪問と同じく外す)
87+
if (!(unvisited && !body.visited_on)) {
88+
await query("delete from visit_plans where user_id = $1 and spot_id = $2", [
89+
userId,
90+
body.spot_id,
91+
]);
92+
}
8593

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

0 commit comments

Comments
 (0)