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(); 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(); 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 (
🎸 ライブに行くしかない
{/* Date navigation */}
← 前日

{formatDateJa(date)}

{ 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" />
翌日 →
{/* Filter bar */}
{areas.length > 0 && (
)}
{hasFilters && ( クリア )}
{/* Summary */} {totalEvents > 0 && (

{visibleGroups.length} 会場 / {totalEvents} イベント {hiddenCount > 0 && ( <> — {hiddenCount} 会場非表示 )}

)} {/* Venue groups */} {visibleGroups.length === 0 ? (

この日のイベントはありません

スクレイパーを実行してデータを取得してください:{" "} npm run scrape

) : (
{visibleGroups.map(({ venue, events }) => (

{venue.name}

{venue.area && ( {venue.area} )} {events.length} 件
{events.map((event) => ( ))}
))}
)}
); } function VenueEventRow({ event }: { event: Event }) { const timeLabel = buildTimeLabel(event.open_time, event.start_time); return ( {/* Time */} {timeLabel || 時間未定} {/* Title + artist */} {event.title} {event.artist && ( {event.artist} )} {/* Price */} {event.price ? ( ¥{event.price} ) : ( )} ); }