diff options
| author | yyamashita <yyamashita@mosquit.one> | 2026-05-07 10:26:10 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@mosquit.one> | 2026-05-07 10:26:10 +0900 |
| commit | bffc2c74408ff7163cea0c0392dfc4b15c620a5f (patch) | |
| tree | 4dd01e086ae2cb577d1d5f03b0a1fff4bb61d70a /app | |
| parent | 8aa1986661199e919df354dc0a5a2819155f023a (diff) | |
Add date-based event view grouped by venue
New route /events/by-date shows events for a single day (default today)
with prev/next day navigation and a date picker, grouped by livehouse.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app')
| -rw-r--r-- | app/routes.ts | 1 | ||||
| -rw-r--r-- | app/routes/events._index.tsx | 1 | ||||
| -rw-r--r-- | app/routes/events.by-date.tsx | 191 |
3 files changed, 193 insertions, 0 deletions
diff --git a/app/routes.ts b/app/routes.ts index 74fb552..99ecf42 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -4,6 +4,7 @@ export default [ index("routes/index.tsx"), ...prefix("events", [ index("routes/events._index.tsx"), + route("by-date", "routes/events.by-date.tsx"), route(":id", "routes/events.$id.tsx"), ]), route("venues", "routes/venues.tsx"), diff --git a/app/routes/events._index.tsx b/app/routes/events._index.tsx index 174c81e..7e6ca5d 100644 --- a/app/routes/events._index.tsx +++ b/app/routes/events._index.tsx @@ -53,6 +53,7 @@ export default function EventsIndex() { </Link> <nav className="flex gap-6 text-sm text-gray-400"> <Link to="/events" className="hover:text-white transition-colors">イベント</Link> + <Link to="/events/by-date" className="hover:text-white transition-colors">日付別</Link> <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link> </nav> </header> diff --git a/app/routes/events.by-date.tsx b/app/routes/events.by-date.tsx new file mode 100644 index 0000000..fadb737 --- /dev/null +++ b/app/routes/events.by-date.tsx @@ -0,0 +1,191 @@ +import { useLoaderData, useSearchParams, Link } from "react-router"; +import { queryEvents, getVenues } from "~/lib/db.server"; +import type { Event } from "~/lib/db.server"; + +function todayJst(): string { + // JST = UTC+9 + const now = new Date(); + const jst = new Date(now.getTime() + 9 * 60 * 60 * 1000); + return jst.toISOString().slice(0, 10); +} + +function addDays(iso: string, delta: number): string { + const d = new Date(`${iso}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + delta); + return d.toISOString().slice(0, 10); +} + +function formatDateJa(iso: string): string { + const [y, m, d] = iso.split("-"); + const days = ["日", "月", "火", "水", "木", "金", "土"]; + const dayIdx = new Date(`${iso}T00:00:00`).getDay(); + return `${y}/${m}/${d}(${days[dayIdx]})`; +} + +function buildTimeLabel(open: string | null, start: string | null): string { + const parts: string[] = []; + if (open) parts.push(`OPEN ${open}`); + if (start) parts.push(`START ${start}`); + return parts.join(" / "); +} + +export async function loader({ request }: { request: Request }) { + const url = new URL(request.url); + const date = url.searchParams.get("date") ?? todayJst(); + + const events = queryEvents({ date_from: date, date_to: date, limit: 500 }); + const venues = getVenues(); + + const byVenue = new Map<string, Event[]>(); + for (const event of events) { + if (!byVenue.has(event.venue_id)) byVenue.set(event.venue_id, []); + byVenue.get(event.venue_id)!.push(event); + } + + const groups = venues + .filter((v) => byVenue.has(v.id)) + .map((v) => ({ venue: v, events: byVenue.get(v.id)! })); + + return { date, groups, totalEvents: events.length }; +} + +export default function EventsByDate() { + const { date, groups, totalEvents } = useLoaderData<typeof loader>(); + const [searchParams] = useSearchParams(); + + function dateUrl(d: string) { + const p = new URLSearchParams(searchParams); + p.set("date", d); + return `?${p.toString()}`; + } + + const prevDay = addDays(date, -1); + const nextDay = addDays(date, 1); + + return ( + <div className="min-h-screen bg-gray-950 text-gray-100"> + <header className="border-b border-gray-800 px-6 py-4 flex items-center justify-between"> + <Link to="/" className="text-xl font-bold tracking-tight text-white"> + 🎸 東京ライブハウス + </Link> + <nav className="flex gap-6 text-sm text-gray-400"> + <Link to="/events" className="hover:text-white transition-colors">イベント</Link> + <Link to="/events/by-date" className="text-white">日付別</Link> + <Link to="/venues" className="hover:text-white transition-colors">会場一覧</Link> + </nav> + </header> + + <main className="max-w-4xl mx-auto px-4 py-8"> + {/* Date navigation */} + <div className="mb-8 flex items-center justify-between"> + <Link + to={dateUrl(prevDay)} + className="rounded-lg bg-gray-800 px-4 py-2 text-sm hover:bg-gray-700 transition-colors" + > + ← 前日 + </Link> + + <div className="flex flex-col items-center gap-2"> + <h1 className="text-2xl font-bold">{formatDateJa(date)}</h1> + <div className="flex items-center gap-2"> + <input + type="date" + value={date} + onChange={(e) => { + if (e.target.value) window.location.href = dateUrl(e.target.value); + }} + className="rounded bg-gray-800 border border-gray-700 px-2 py-1 text-sm text-gray-200 focus:outline-none focus:border-indigo-500" + /> + </div> + </div> + + <Link + to={dateUrl(nextDay)} + className="rounded-lg bg-gray-800 px-4 py-2 text-sm hover:bg-gray-700 transition-colors" + > + 翌日 → + </Link> + </div> + + {/* Summary */} + {totalEvents > 0 && ( + <p className="mb-6 text-sm text-gray-500"> + {groups.length} 会場 / {totalEvents} イベント + </p> + )} + + {/* Venue groups */} + {groups.length === 0 ? ( + <div className="mt-16 text-center text-gray-500"> + <p className="text-lg">この日のイベントはありません</p> + <p className="mt-2 text-sm"> + スクレイパーを実行してデータを取得してください:{" "} + <code className="text-gray-400">npm run scrape</code> + </p> + </div> + ) : ( + <div className="flex flex-col gap-8"> + {groups.map(({ venue, events }) => ( + <section key={venue.id}> + <div className="mb-3 flex items-baseline gap-2"> + <h2 className="text-lg font-semibold text-white"> + {venue.name} + </h2> + {venue.area && ( + <span className="text-xs text-gray-500">{venue.area}</span> + )} + <span className="ml-auto text-xs text-gray-600"> + {events.length} 件 + </span> + </div> + + <div className="flex flex-col gap-1.5"> + {events.map((event) => ( + <VenueEventRow key={event.id} event={event} /> + ))} + </div> + </section> + ))} + </div> + )} + </main> + </div> + ); +} + +function VenueEventRow({ event }: { event: Event }) { + const timeLabel = buildTimeLabel(event.open_time, event.start_time); + + return ( + <Link + to={`/events/${event.id}`} + className="group flex items-start gap-4 rounded-lg px-4 py-3 bg-gray-800/40 border border-gray-700/30 hover:border-indigo-500/50 hover:bg-gray-800/70 transition-all" + > + {/* Time */} + <span className="w-28 shrink-0 text-xs text-gray-400 tabular-nums pt-0.5"> + {timeLabel || <span className="text-gray-600">時間未定</span>} + </span> + + {/* Title + artist */} + <span className="flex-1 min-w-0"> + <span className="block truncate text-sm font-medium text-gray-100 group-hover:text-indigo-300 transition-colors"> + {event.title} + </span> + {event.artist && ( + <span className="block truncate text-xs text-indigo-300/80 mt-0.5"> + {event.artist} + </span> + )} + </span> + + {/* Price */} + {event.price ? ( + <span className="shrink-0 text-xs text-emerald-400 pt-0.5"> + ¥{event.price} + </span> + ) : ( + <span className="shrink-0 w-16" /> + )} + </Link> + ); +} |
