232 lines
8.7 KiB
TypeScript
232 lines
8.7 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||
import { useActiveSchedule } from "@/hooks/use-channels";
|
||
import type { ChannelResponse, ScheduledSlotResponse } from "@/lib/types";
|
||
import { BLOCK_COLORS } from "./block-timeline";
|
||
|
||
// Stable color per block_id within a schedule
|
||
function makeColorMap(slots: ScheduledSlotResponse[]): Map<string, string> {
|
||
const seen = new Map<string, string>();
|
||
slots.forEach((slot) => {
|
||
if (!seen.has(slot.block_id)) {
|
||
seen.set(slot.block_id, BLOCK_COLORS[seen.size % BLOCK_COLORS.length]);
|
||
}
|
||
});
|
||
return seen;
|
||
}
|
||
|
||
interface DayRowProps {
|
||
label: string;
|
||
dayStart: Date;
|
||
slots: ScheduledSlotResponse[];
|
||
colorMap: Map<string, string>;
|
||
now: Date;
|
||
}
|
||
|
||
function DayRow({ label, dayStart, slots, colorMap, now }: DayRowProps) {
|
||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
const dayEnd = new Date(dayStart.getTime() + DAY_MS);
|
||
const nowPct = ((now.getTime() - dayStart.getTime()) / DAY_MS) * 100;
|
||
|
||
// Only include slots that overlap this day
|
||
const daySlots = slots.filter((s) => {
|
||
const start = new Date(s.start_at);
|
||
const end = new Date(s.end_at);
|
||
return start < dayEnd && end > dayStart;
|
||
});
|
||
|
||
return (
|
||
<div className="space-y-1">
|
||
<p className="text-xs font-medium text-zinc-500">{label}</p>
|
||
<div className="relative h-12 rounded-md border border-zinc-700 bg-zinc-900 overflow-hidden">
|
||
{/* Hour grid */}
|
||
{Array.from({ length: 25 }, (_, i) => (
|
||
<div
|
||
key={i}
|
||
className={`absolute inset-y-0 w-px ${i % 6 === 0 ? "bg-zinc-700" : "bg-zinc-800"}`}
|
||
style={{ left: `${(i / 24) * 100}%` }}
|
||
/>
|
||
))}
|
||
{/* Current time marker */}
|
||
{nowPct >= 0 && nowPct <= 100 && (
|
||
<div
|
||
className="absolute inset-y-0 z-10 w-0.5 bg-red-500"
|
||
style={{ left: `${nowPct}%` }}
|
||
>
|
||
<div className="absolute -top-0.5 left-1/2 h-1.5 w-1.5 -translate-x-1/2 rounded-full bg-red-500" />
|
||
</div>
|
||
)}
|
||
{daySlots.map((slot) => {
|
||
const slotStart = new Date(slot.start_at);
|
||
const slotEnd = new Date(slot.end_at);
|
||
// Clamp to this day
|
||
const clampedStart = Math.max(slotStart.getTime(), dayStart.getTime());
|
||
const clampedEnd = Math.min(slotEnd.getTime(), dayEnd.getTime());
|
||
const leftPct = ((clampedStart - dayStart.getTime()) / DAY_MS) * 100;
|
||
const widthPct = ((clampedEnd - clampedStart) / DAY_MS) * 100;
|
||
const color = colorMap.get(slot.block_id) ?? "#6b7280";
|
||
|
||
const startTime = slotStart.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
const endTime = slotEnd.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||
|
||
return (
|
||
<div
|
||
key={slot.id}
|
||
title={`${slot.item.title}\n${startTime} – ${endTime}`}
|
||
className="absolute top-1 bottom-1 rounded flex items-center overflow-hidden opacity-80 hover:opacity-100 transition-opacity cursor-default"
|
||
style={{
|
||
left: `max(${leftPct}%, 0px)`,
|
||
width: `max(${widthPct}%, 3px)`,
|
||
backgroundColor: color,
|
||
}}
|
||
>
|
||
<span className="px-1.5 text-[10px] text-white truncate pointer-events-none">
|
||
{slot.item.title}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{/* Hour labels */}
|
||
<div className="relative h-3">
|
||
{[0, 6, 12, 18, 24].map((h) => (
|
||
<span
|
||
key={h}
|
||
className="absolute -translate-x-1/2 text-[9px] text-zinc-600"
|
||
style={{ left: `${(h / 24) * 100}%` }}
|
||
>
|
||
{h.toString().padStart(2, "0")}:00
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface ScheduleSheetProps {
|
||
channel: ChannelResponse | null;
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
}
|
||
|
||
export function ScheduleSheet({ channel, open, onOpenChange }: ScheduleSheetProps) {
|
||
const { data: schedule, isLoading, error } = useActiveSchedule(channel?.id ?? "");
|
||
|
||
// Live clock for the current-time marker — updates every minute
|
||
const [now, setNow] = useState(() => new Date());
|
||
useEffect(() => {
|
||
const id = setInterval(() => setNow(new Date()), 60_000);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
|
||
const colorMap = schedule ? makeColorMap(schedule.slots) : new Map();
|
||
|
||
// Build day rows from valid_from to valid_until
|
||
const days: { label: string; dayStart: Date }[] = [];
|
||
if (schedule) {
|
||
const start = new Date(schedule.valid_from);
|
||
// Start at midnight of the first day
|
||
const dayStart = new Date(start);
|
||
dayStart.setHours(0, 0, 0, 0);
|
||
const end = new Date(schedule.valid_until);
|
||
const cursor = new Date(dayStart);
|
||
while (cursor < end) {
|
||
days.push({
|
||
label: cursor.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }),
|
||
dayStart: new Date(cursor),
|
||
});
|
||
cursor.setDate(cursor.getDate() + 1);
|
||
}
|
||
}
|
||
|
||
const fmt = (iso: string) =>
|
||
new Date(iso).toLocaleString(undefined, {
|
||
weekday: "short", month: "short", day: "numeric",
|
||
hour: "2-digit", minute: "2-digit", hour12: false,
|
||
});
|
||
|
||
return (
|
||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||
<SheetContent
|
||
side="right"
|
||
className="flex w-full flex-col gap-0 border-zinc-800 bg-zinc-900 p-0 text-zinc-100 sm:max-w-2xl"
|
||
>
|
||
<SheetHeader className="border-b border-zinc-800 px-6 py-4">
|
||
<SheetTitle className="text-zinc-100">
|
||
Schedule — {channel?.name}
|
||
</SheetTitle>
|
||
</SheetHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
|
||
{isLoading && (
|
||
<div className="flex items-center justify-center py-16">
|
||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-zinc-700 border-t-zinc-300" />
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="rounded-lg border border-red-900/50 bg-red-950/20 px-4 py-3 text-sm text-red-400">
|
||
No active schedule. Generate one from the dashboard.
|
||
</div>
|
||
)}
|
||
|
||
{schedule && (
|
||
<>
|
||
{/* Meta */}
|
||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-zinc-500">
|
||
<span>Generation <span className="text-zinc-300">#{schedule.generation}</span></span>
|
||
<span>From <span className="text-zinc-300">{fmt(schedule.valid_from)}</span></span>
|
||
<span>Until <span className="text-zinc-300">{fmt(schedule.valid_until)}</span></span>
|
||
<span><span className="text-zinc-300">{schedule.slots.length}</span> slots</span>
|
||
</div>
|
||
|
||
{/* Timeline per day */}
|
||
<div className="space-y-5">
|
||
{days.map(({ label, dayStart }) => (
|
||
<DayRow
|
||
key={dayStart.toISOString()}
|
||
label={label}
|
||
dayStart={dayStart}
|
||
slots={schedule.slots}
|
||
colorMap={colorMap}
|
||
now={now}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* Slot list */}
|
||
<div className="space-y-2">
|
||
<h3 className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Slots</h3>
|
||
<div className="rounded-md border border-zinc-800 divide-y divide-zinc-800">
|
||
{schedule.slots.map((slot) => {
|
||
const color = colorMap.get(slot.block_id) ?? "#6b7280";
|
||
const start = new Date(slot.start_at).toLocaleString(undefined, {
|
||
weekday: "short", hour: "2-digit", minute: "2-digit", hour12: false,
|
||
});
|
||
const durationMins = Math.round(
|
||
(new Date(slot.end_at).getTime() - new Date(slot.start_at).getTime()) / 60000
|
||
);
|
||
return (
|
||
<div key={slot.id} className="flex items-center gap-3 px-3 py-2.5">
|
||
<div className="h-2.5 w-2.5 rounded-full shrink-0" style={{ backgroundColor: color }} />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm text-zinc-200">{slot.item.title}</p>
|
||
<p className="text-xs text-zinc-500">
|
||
{start} · {durationMins}m
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|