From bffc2c74408ff7163cea0c0392dfc4b15c620a5f Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@mosquit.one>
Date: Thu, 7 May 2026 10:26:10 +0900
Subject: 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>
---
 app/routes/events.by-date.tsx | 191 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 191 insertions(+)
 create mode 100644 app/routes/events.by-date.tsx

(limited to 'app/routes/events.by-date.tsx')

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>
+  );
+}
-- 
cgit v1.2.3

