Recordings were just a thin named pointer over an already-captured session's events, so the whole separate feature (recordings table, API routes, pages, UI) is gone: any completed session can now be named and replayed directly. Replaying no longer creates a session or duplicates events of its own - it just bumps the source session's playCount/lastPlayedAt. Also renames play_sessions/playSession(s) to sessions/session(s) throughout the schema, queries, API routes, and UI for consistency, and updates the README to match the new flow. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
88 lines
3.4 KiB
TypeScript
88 lines
3.4 KiB
TypeScript
import Link from "next/link";
|
||
import { notFound } from "next/navigation";
|
||
import type { Metadata } from "next";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
|
||
import { getSessionDetail, getSessionName } from "@/lib/db/queries/sessions";
|
||
import { getSessionTimeline } from "@/lib/db/queries/stats";
|
||
import { Play } from "lucide-react";
|
||
|
||
export const dynamic = "force-dynamic";
|
||
|
||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||
const { id } = await params;
|
||
const name = await getSessionName(Number(id));
|
||
return { title: name ?? `Session #${id}` };
|
||
}
|
||
|
||
function formatDuration(ms: number | null): string {
|
||
if (ms === null) return "-";
|
||
const totalSeconds = Math.round(ms / 1000);
|
||
return `${Math.floor(totalSeconds / 60)}:${(totalSeconds % 60).toString().padStart(2, "0")}`;
|
||
}
|
||
|
||
export default async function SessionDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params;
|
||
const sessionId = Number(id);
|
||
const [detail, timeline] = await Promise.all([getSessionDetail(sessionId), getSessionTimeline(sessionId)]);
|
||
if (!detail) notFound();
|
||
|
||
const canReplay = detail.session.status === "completed" && detail.session.durationMs !== null;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<div className="flex items-center justify-between gap-4">
|
||
<div>
|
||
<h1 className="font-heading text-2xl font-semibold">{detail.session.name ?? `Session #${detail.session.id}`}</h1>
|
||
<p className="text-sm text-muted-foreground">
|
||
{detail.session.status} · {formatDuration(detail.session.durationMs)}
|
||
{detail.session.kind === "replay" && detail.replayedFromName !== undefined && (
|
||
<> · replayed from {detail.replayedFromName ?? `session #${detail.session.replayedSessionId}`}</>
|
||
)}
|
||
{detail.session.playCount > 0 && (
|
||
<>
|
||
{" "}
|
||
· replayed {detail.session.playCount}×
|
||
{detail.session.lastPlayedAt && ` (last ${new Date(detail.session.lastPlayedAt).toLocaleString()})`}
|
||
</>
|
||
)}
|
||
</p>
|
||
{detail.session.description && (
|
||
<p className="mt-1 text-sm text-muted-foreground">{detail.session.description}</p>
|
||
)}
|
||
</div>
|
||
{canReplay && (
|
||
<Button asChild>
|
||
<Link href={`/sessions/${detail.session.id}/replay`}>
|
||
<Play className="size-4" /> Replay
|
||
</Link>
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
<Card className="bp-glass">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Intensity timeline</CardTitle>
|
||
</CardHeader>
|
||
<CardContent>
|
||
<SessionTimelineChart timeline={timeline} />
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="bp-glass">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Devices</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="flex flex-col gap-1">
|
||
{detail.devices.map((d) => (
|
||
<div key={d.id} className="text-sm">
|
||
{d.slotLabel} <span className="text-muted-foreground">({d.deviceDisplayName ?? d.deviceBleName})</span>
|
||
</div>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|