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
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
|
import { useLoaderData, useSearchParams, Link, Form } from "react-router";
import { queryEvents, getVenues, type CapacityRange } from "~/lib/db.server";
import type { Event } from "~/lib/db.server";
import { useVenueFilter } from "~/lib/venue-filter";
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 keyword = url.searchParams.get("keyword") ?? undefined;
const venue_id = url.searchParams.get("venue_id") ?? undefined;
const capacity_range = (url.searchParams.get("capacity_range") ?? undefined) as CapacityRange | undefined;
const area = url.searchParams.get("area") ?? undefined;
const events = queryEvents({ date_from: date, date_to: date, keyword, venue_id, capacity_range, area, limit: 500 });
const venues = getVenues();
const areas = [...new Set(venues.filter((v) => v.area).map((v) => v.area as string))].sort();
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, venues, areas };
}
export default function EventsByDate() {
const { date, groups, totalEvents, venues, areas } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const { hiddenIds } = useVenueFilter();
const hasVenueFilter = !!searchParams.get("venue_id");
const visibleGroups = hasVenueFilter
? groups
: groups.filter((g) => !hiddenIds.has(g.venue.id));
const hiddenCount = groups.length - visibleGroups.length;
function dateUrl(d: string) {
const p = new URLSearchParams(searchParams);
p.set("date", d);
return `?${p.toString()}`;
}
const hasFilters = ["keyword", "venue_id", "capacity_range", "area"].some(
(k) => searchParams.get(k)
);
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-6 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>
{/* Filter bar */}
<Form method="get" className="mb-6 flex flex-wrap gap-3 items-end">
<input type="hidden" name="date" value={date} />
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">キーワード</label>
<input
name="keyword"
type="text"
defaultValue={searchParams.get("keyword") ?? ""}
placeholder="アーティスト名、イベント名..."
className="rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500 w-full sm:w-44"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">会場</label>
<select
name="venue_id"
defaultValue={searchParams.get("venue_id") ?? ""}
className="rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="">すべて</option>
{venues.map((v) => (
<option key={v.id} value={v.id}>{v.name}</option>
))}
</select>
</div>
{areas.length > 0 && (
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">エリア</label>
<select
name="area"
defaultValue={searchParams.get("area") ?? ""}
className="rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="">すべて</option>
{areas.map((a) => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
)}
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-400">キャパシティ</label>
<select
name="capacity_range"
defaultValue={searchParams.get("capacity_range") ?? ""}
className="rounded-md bg-gray-800 border border-gray-700 px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
<option value="">すべて</option>
<option value="small">〜100人(小箱)</option>
<option value="medium">100〜300人(中箱)</option>
<option value="large">300人〜(大箱)</option>
</select>
</div>
<button
type="submit"
className="rounded-md bg-gray-700 px-4 py-1.5 text-sm font-medium hover:bg-gray-600 transition-colors"
>
絞り込む
</button>
{hasFilters && (
<a
href={`/events/by-date?date=${date}`}
className="rounded-md border border-gray-700 px-4 py-1.5 text-sm text-gray-400 hover:text-white transition-colors"
>
クリア
</a>
)}
</Form>
{/* Summary */}
{totalEvents > 0 && (
<p className="mb-6 text-sm text-gray-500">
{visibleGroups.length} 会場 / {totalEvents} イベント
{hiddenCount > 0 && (
<> — <Link to="/venues" className="text-indigo-400 hover:text-indigo-300 underline">{hiddenCount} 会場非表示</Link></>
)}
</p>
)}
{/* Venue groups */}
{visibleGroups.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">
{visibleGroups.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>
);
}
|