summaryrefslogtreecommitdiff
path: root/app/routes
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes')
-rw-r--r--app/routes/events._index.tsx1
-rw-r--r--app/routes/events.by-date.tsx191
2 files changed, 192 insertions, 0 deletions
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>
+ );
+}