summaryrefslogtreecommitdiff
path: root/app/routes/events.by-date.tsx
blob: 9a5907318dda3d3aab649b2ce710900778529a7e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
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-4 sm:px-6 py-3 sm:py-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 sm:gap-0">
        <Link to="/" className="text-xl font-bold tracking-tight text-white">
          🎸 ライブに行くしかない
        </Link>
        <nav className="flex gap-4 sm: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-20 sm: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="hidden sm:block shrink-0 text-xs text-emerald-400 pt-0.5">
          ¥{event.price}
        </span>
      ) : (
        <span className="hidden sm:block shrink-0 w-16" />
      )}
    </Link>
  );
}