2026-08-27 21:38:51 +02:00
|
|
|
|
import Link from "next/link";
|
2026-08-25 07:51:38 +02:00
|
|
|
|
import { notFound } from "next/navigation";
|
2026-08-25 21:26:18 +02:00
|
|
|
|
import type { Metadata } from "next";
|
2026-08-27 21:38:51 +02:00
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-08-25 07:51:38 +02:00
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
|
|
|
|
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
|
2026-08-30 20:49:34 +02:00
|
|
|
|
import { SessionTitleEditor } from "@/components/sessions/SessionTitleEditor";
|
2026-08-27 21:38:51 +02:00
|
|
|
|
import { getSessionDetail, getSessionName } from "@/lib/db/queries/sessions";
|
2026-08-25 07:51:38 +02:00
|
|
|
|
import { getSessionTimeline } from "@/lib/db/queries/stats";
|
2026-08-27 21:38:51 +02:00
|
|
|
|
import { Play } from "lucide-react";
|
2026-08-25 07:51:38 +02:00
|
|
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
|
|
2026-08-25 21:26:18 +02:00
|
|
|
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
|
|
|
|
|
const { id } = await params;
|
2026-08-27 21:38:51 +02:00
|
|
|
|
const name = await getSessionName(Number(id));
|
2026-08-25 21:26:18 +02:00
|
|
|
|
return { title: name ?? `Session #${id}` };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 07:51:38 +02:00
|
|
|
|
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);
|
2026-08-27 21:38:51 +02:00
|
|
|
|
const [detail, timeline] = await Promise.all([getSessionDetail(sessionId), getSessionTimeline(sessionId)]);
|
2026-08-25 07:51:38 +02:00
|
|
|
|
if (!detail) notFound();
|
|
|
|
|
|
|
2026-08-27 21:38:51 +02:00
|
|
|
|
const canReplay = detail.session.status === "completed" && detail.session.durationMs !== null;
|
|
|
|
|
|
|
2026-08-25 07:51:38 +02:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="flex flex-col gap-6">
|
2026-08-27 21:38:51 +02:00
|
|
|
|
<div className="flex items-center justify-between gap-4">
|
2026-08-30 20:49:34 +02:00
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
|
<SessionTitleEditor sessionId={detail.session.id} initialName={detail.session.name} />
|
|
|
|
|
|
<p className="mt-1 px-2 text-sm text-muted-foreground">
|
2026-08-27 21:38:51 +02:00
|
|
|
|
{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 && (
|
2026-08-30 20:49:34 +02:00
|
|
|
|
<p className="mt-1 px-2 text-sm text-muted-foreground">{detail.session.description}</p>
|
2026-08-27 21:38:51 +02:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{canReplay && (
|
|
|
|
|
|
<Button asChild>
|
|
|
|
|
|
<Link href={`/sessions/${detail.session.id}/replay`}>
|
|
|
|
|
|
<Play className="size-4" /> Replay
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
)}
|
2026-08-25 07:51:38 +02:00
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|