21 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 dde6adb089 Bump to 0.7.0
CI / Static checks (push) Successful in 1m38s
CI / Build and push image (push) Successful in 1m7s
Inline-editable session title/description (autosave, no Save button,
same pattern as device display names), status badge on the session
detail page, dashboard's Recent sessions restyled as a real table,
stats page tab switcher removed, and various card/nav polish.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:23:30 +02:00
valknarandClaude Sonnet 5 b0394d5c9d Size the description input to its content, like the title
Dropping flex-1 stops the textarea (and its underline) from stretching
to fill the row width regardless of text length - now it hugs the
actual content the same way the title input does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:21:40 +02:00
valknarandClaude Sonnet 5 5c92b416c0 Tone down the focus underline to just a step past hover
focus:border-b-muted-foreground was a big jump from the faint hover
border tone. Use foreground at 40% opacity instead - a bit more
present than hover, not a solid gray line.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:19:07 +02:00
valknarandClaude Sonnet 5 2a52b5db9b Use a gray focus underline for the title/description inputs
focus:border-b-muted-foreground instead of the primary accent color -
matches the existing hover state's neutral tone rather than switching
to a bright accent on focus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:17:23 +02:00
valknarandClaude Sonnet 5 39539b6f94 Fix title/description overflow by restoring stretch on mobile
items-start on the stacked mobile header removed the default stretch,
so the min-w-0/flex-1 title column sized to its own content instead of
the row's width - leaving max-w-full on the field-sizing:content
inputs with no definite containing-block width to clamp against, so
they (and the whole row) could overflow horizontally. Dropping
items-start restores the default stretch; the right-hand badge/
duration/replay group already had its own self-end override, so it
still sits at the right edge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:15:48 +02:00
valknarandClaude Sonnet 5 a0211f90e4 Cap title and description editor width
Both used field-sizing:content with only max-w-full, which doesn't
reliably clamp a long single-line value and could push the header row
wider than its container. Add an explicit sm:max-w-xl ceiling on both.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:11:27 +02:00
valknarandClaude Sonnet 5 26897e1621 Right-align the status/duration/replay row on mobile
self-end (reverting to self-auto at sm+) so the stacked row sits
against the right edge under the title instead of the left, matching
where it sits in the desktop row layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:10:00 +02:00
valknarandClaude Sonnet 5 1bd38f8194 Stack session header below description on mobile
The title/status/replay row was a fixed flex-row that squeezed the
badge, duration, and Replay button next to a long title on narrow
screens. Now stacks (flex-col) below sm and goes side-by-side from sm
up, matching how the rest of the app breaks to mobile.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:09:35 +02:00
valknarandClaude Sonnet 5 ffc63f0b03 Make the session description always visible and inline-editable
New SessionDescriptionEditor renders even when empty (with an "Add a
description..." placeholder) and autosaves on typing - same debounced,
no-Save-button pattern as the title and device name editors. Unlike
the name field, an empty description is a valid saved state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:08:45 +02:00
valknarandClaude Sonnet 5 a94f2f33b6 Move status badge and duration next to the Replay button
They now sit inline in the right-hand column alongside Replay instead
of prefixing the left column's meta line, which now only shows the
replay-lineage/play-count info when there is any.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:07:18 +02:00
valknarandClaude Sonnet 5 e9d5bb37d2 Show session status as a badge on the detail page
Reuses SessionsTable's STATUS_VARIANT mapping (now exported) so the
detail page's status matches the table's badge instead of plain text.
Also drops the now-stale px-1.5 alignment padding on the meta lines
now that the title input carries no horizontal padding of its own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 21:03:00 +02:00
valknarandClaude Sonnet 5 354c30d641 Underline the session title input instead of a full border
Drops the boxed border-b-2/rounded treatment for just a bottom
border - transparent at rest, muted on hover, primary-colored on
focus - which reads as inline editing rather than a form field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:55:07 +02:00
valknarandClaude Sonnet 5 7bf4b3d4bf Fix session title input's border offset and width
The -mx-2 trick pushed the hover/focus border noticeably left of the
text, and flex-1 stretched the input across the whole header row.
Tighten the offset to px-1.5/-ml-1.5 and size the input to its content
via field-sizing:content instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:52:44 +02:00
valknarandClaude Sonnet 5 884ced7a47 Make the session title editable inline on the detail page
New SessionTitleEditor swaps the static <h1> for a heading-styled input
that autosaves on typing (600ms debounce, spinner-then-checkmark) - the
same click-in-place, no-Save-button pattern as the device display name.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:49:34 +02:00
valknarandClaude Sonnet 5 005d729119 Autosave device display name instead of an explicit Save button
Typing in the name field now debounces (600ms) and saves in the
background, with an inline spinner-then-checkmark instead of a button
click - one less step, and no risk of leaving an edited name unsaved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:46:53 +02:00
valknarandClaude Sonnet 5 fa04cb32b4 Streamline card titles to a single color
Every CardTitle now uses the default foreground color; only size marks
the hierarchy (text-sm for stat-tile labels, text-base for section
titles, text-2xl for the login wordmark) instead of some titles being
muted-gray and others white.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:42:41 +02:00
valknarandClaude Sonnet 5 c778ab3cf2 Add a title to the device usage table card on Stats
Matches the "History"/"Known devices" card-title pattern used on the
Sessions and Devices pages.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:41:05 +02:00
valknarandClaude Sonnet 5 7643e9d509 Style dashboard's Recent sessions as a real table
Reuse SessionsTable (same component as the Sessions page) instead of a
bespoke flex/hairline list, so status badges, replay/delete actions,
and column layout match the rest of the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:39:43 +02:00
valknarandClaude Sonnet 5 d5a8cb81cf Highlight nav link as active for sub-routes too
/sessions/16 (and any other nested route) now underlines its parent
nav link instead of only an exact pathname match, which previously
left every detail/replay page with no active tab at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:37:25 +02:00
valknarandClaude Sonnet 5 c4ed849d37 Remove stats tab switcher, drop duration-per-device, show devices inline
Sessions and Devices no longer live behind a Tabs switcher on the stats
page - both sections render directly, one after the other. Also drops
the "Duration per device" list from the sessions summary cards since
DeviceUsageTable already shows per-device active time in more detail.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:33:47 +02:00
valknarandClaude Sonnet 5 e459ccd0ed Polish replays stat into the summary card grid
The replay count was a lone plain-text bar below the three stat cards,
and read oddly as "Replayed 0x" when nothing had been replayed yet.
Fold it into the same 4-tile grid with the same styling, and show "-"
at zero instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-30 20:31:31 +02:00
12 changed files with 298 additions and 136 deletions
+7 -17
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BrandMark } from "@/components/layout/BrandMark"; import { BrandMark } from "@/components/layout/BrandMark";
import { SessionsTable } from "@/components/sessions/SessionsTable";
import { listSessions } from "@/lib/db/queries/sessions"; import { listSessions } from "@/lib/db/queries/sessions";
import { getSessionsSummary } from "@/lib/db/queries/stats"; import { getSessionsSummary } from "@/lib/db/queries/stats";
@@ -33,13 +34,13 @@ export default async function DashboardPage() {
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle> <CardTitle className="text-sm">Completed sessions</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="bp-readout text-3xl">{summary.count}</CardContent> <CardContent className="bp-readout text-3xl">{summary.count}</CardContent>
</Card> </Card>
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total play time</CardTitle> <CardTitle className="text-sm">Total play time</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="bp-readout text-3xl"> <CardContent className="bp-readout text-3xl">
{(summary.totalDurationMs / 3_600_000).toFixed(1)}h {(summary.totalDurationMs / 3_600_000).toFixed(1)}h
@@ -47,7 +48,7 @@ export default async function DashboardPage() {
</Card> </Card>
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle className="text-sm text-muted-foreground">Replays</CardTitle> <CardTitle className="text-sm">Replays</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="bp-readout text-3xl">{summary.totalReplays}</CardContent> <CardContent className="bp-readout text-3xl">{summary.totalReplays}</CardContent>
</Card> </Card>
@@ -55,21 +56,10 @@ export default async function DashboardPage() {
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader> <CardHeader>
<CardTitle>Recent sessions</CardTitle> <CardTitle className="text-base">Recent sessions</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col"> <CardContent className="flex flex-col gap-3">
{recentSessions.length === 0 && <p className="text-sm text-muted-foreground">No sessions yet.</p>} <SessionsTable sessions={recentSessions} />
{recentSessions.map((s, i) => (
<Link key={s.id} href={`/sessions/${s.id}`} className="group">
{i > 0 && <div className="bp-hairline" />}
<div className="flex items-center justify-between px-1 py-2 text-sm">
<span className="group-hover:text-primary">{s.name ?? `Session #${s.id}`}</span>
<span className="bp-readout text-xs text-muted-foreground">
{new Date(s.startedAt).toLocaleDateString()}
</span>
</div>
</Link>
))}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+32 -25
View File
@@ -1,9 +1,13 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart"; import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
import { SessionTitleEditor } from "@/components/sessions/SessionTitleEditor";
import { SessionDescriptionEditor } from "@/components/sessions/SessionDescriptionEditor";
import { STATUS_VARIANT } from "@/components/sessions/SessionsTable";
import { getSessionDetail, getSessionName } from "@/lib/db/queries/sessions"; import { getSessionDetail, getSessionName } from "@/lib/db/queries/sessions";
import { getSessionTimeline } from "@/lib/db/queries/stats"; import { getSessionTimeline } from "@/lib/db/queries/stats";
import { Play } from "lucide-react"; import { Play } from "lucide-react";
@@ -32,33 +36,36 @@ export default async function SessionDetailPage({ params }: { params: Promise<{
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4"> <div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div> <div className="min-w-0 flex-1">
<h1 className="font-heading text-2xl font-semibold">{detail.session.name ?? `Session #${detail.session.id}`}</h1> <SessionTitleEditor sessionId={detail.session.id} initialName={detail.session.name} />
<p className="text-sm text-muted-foreground"> {(detail.session.kind === "replay" || detail.session.playCount > 0) && (
{detail.session.status} · {formatDuration(detail.session.durationMs)} <p className="mt-1 text-sm text-muted-foreground">
{detail.session.kind === "replay" && detail.replayedFromName !== undefined && ( {detail.session.kind === "replay" && detail.replayedFromName !== undefined && (
<> · replayed from {detail.replayedFromName ?? `session #${detail.session.replayedSessionId}`}</> <>replayed from {detail.replayedFromName ?? `session #${detail.session.replayedSessionId}`}</>
)} )}
{detail.session.playCount > 0 && ( {detail.session.playCount > 0 && (
<> <>
{" "} {detail.session.kind === "replay" && detail.replayedFromName !== undefined && " · "}
· replayed {detail.session.playCount}× replayed {detail.session.playCount}×
{detail.session.lastPlayedAt && ` (last ${new Date(detail.session.lastPlayedAt).toLocaleString()})`} {detail.session.lastPlayedAt && ` (last ${new Date(detail.session.lastPlayedAt).toLocaleString()})`}
</> </>
)} )}
</p> </p>
{detail.session.description && ( )}
<p className="mt-1 text-sm text-muted-foreground">{detail.session.description}</p> <SessionDescriptionEditor sessionId={detail.session.id} initialDescription={detail.session.description} />
</div>
<div className="flex shrink-0 items-center gap-3 self-end sm:self-auto">
<Badge variant={STATUS_VARIANT[detail.session.status]}>{detail.session.status}</Badge>
<span className="bp-readout text-sm text-muted-foreground">{formatDuration(detail.session.durationMs)}</span>
{canReplay && (
<Button asChild>
<Link href={`/sessions/${detail.session.id}/replay`}>
<Play className="size-4" /> Replay
</Link>
</Button>
)} )}
</div> </div>
{canReplay && (
<Button asChild>
<Link href={`/sessions/${detail.session.id}/replay`}>
<Play className="size-4" /> Replay
</Link>
</Button>
)}
</div> </div>
<Card className="bp-glass"> <Card className="bp-glass">
+2 -13
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards"; import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards";
import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable"; import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable";
import { getDeviceCommandCounts, getDeviceUsageStats, getSessionsSummary } from "@/lib/db/queries/stats"; import { getDeviceCommandCounts, getDeviceUsageStats, getSessionsSummary } from "@/lib/db/queries/stats";
@@ -24,18 +23,8 @@ export default async function StatsPage() {
<p className="text-sm text-muted-foreground">Usage across sessions and devices.</p> <p className="text-sm text-muted-foreground">Usage across sessions and devices.</p>
</div> </div>
<Tabs defaultValue="sessions"> <SessionsSummaryCards summary={sessionsSummary} />
<TabsList className="bp-glass"> <DeviceUsageTable devices={devices} />
<TabsTrigger value="sessions">Sessions</TabsTrigger>
<TabsTrigger value="devices">Devices</TabsTrigger>
</TabsList>
<TabsContent value="sessions" className="pt-4">
<SessionsSummaryCards summary={sessionsSummary} />
</TabsContent>
<TabsContent value="devices" className="pt-4">
<DeviceUsageTable devices={devices} />
</TabsContent>
</Tabs>
</div> </div>
); );
} }
+59 -25
View File
@@ -1,11 +1,12 @@
"use client"; "use client";
import { useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Check, Loader2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { cn } from "@/lib/utils";
export interface DeviceRow { export interface DeviceRow {
id: number; id: number;
@@ -14,34 +15,67 @@ export interface DeviceRow {
lastConnectedAt: number | null; lastConnectedAt: number | null;
} }
const AUTOSAVE_DELAY_MS = 600;
function DeviceNameCell({ device }: { device: DeviceRow }) { function DeviceNameCell({ device }: { device: DeviceRow }) {
const router = useRouter(); const router = useRouter();
const [value, setValue] = useState(device.displayName ?? device.bleName); const initial = device.displayName ?? device.bleName;
const [saving, setSaving] = useState(false); const [value, setValue] = useState(initial);
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");
const lastSaved = useRef(initial);
async function handleSave() { useEffect(() => {
if (value.trim().length === 0) return; const trimmed = value.trim();
setSaving(true); if (trimmed.length === 0 || trimmed === lastSaved.current) return;
const res = await fetch(`/api/devices/${device.id}`, {
method: "PATCH", setStatus("saving");
headers: { "Content-Type": "application/json" }, const timer = setTimeout(async () => {
body: JSON.stringify({ displayName: value.trim() }), const res = await fetch(`/api/devices/${device.id}`, {
}); method: "PATCH",
setSaving(false); headers: { "Content-Type": "application/json" },
if (res.ok) { body: JSON.stringify({ displayName: trimmed }),
toast.success("Renamed"); });
router.refresh(); if (res.ok) {
} else { lastSaved.current = trimmed;
toast.error("Could not rename device"); setStatus("saved");
} router.refresh();
} } else {
setStatus("idle");
toast.error("Could not rename device");
}
}, AUTOSAVE_DELAY_MS);
return () => clearTimeout(timer);
}, [value, device.id, router]);
useEffect(() => {
if (status !== "saved") return;
const timer = setTimeout(() => setStatus("idle"), 1500);
return () => clearTimeout(timer);
}, [status]);
return ( return (
<div className="flex items-center gap-2"> <div className="relative w-48">
<Input value={value} onChange={(e) => setValue(e.target.value)} className="h-8 w-40 min-w-40" /> <Input
<Button size="sm" variant="outline" onClick={handleSave} disabled={saving} className="shrink-0"> value={value}
Save onChange={(e) => setValue(e.target.value)}
</Button> className="h-8 pr-8"
aria-label="Device display name"
/>
<div className="pointer-events-none absolute inset-y-0 right-2 flex w-3.5 items-center justify-center">
<Loader2
className={cn(
"absolute size-3.5 animate-spin text-muted-foreground transition-opacity",
status === "saving" ? "opacity-100" : "opacity-0",
)}
/>
<Check
className={cn(
"absolute size-3.5 text-primary transition-opacity",
status === "saved" ? "opacity-100" : "opacity-0",
)}
/>
</div>
</div> </div>
); );
} }
+5 -1
View File
@@ -19,6 +19,10 @@ const LINKS = [
{ href: "/devices", label: "Devices" }, { href: "/devices", label: "Devices" },
]; ];
function isActive(pathname: string, href: string): boolean {
return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
}
function NavLinks({ onNavigate }: { onNavigate?: () => void }) { function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname(); const pathname = usePathname();
return ( return (
@@ -30,7 +34,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
onClick={onNavigate} onClick={onNavigate}
className={cn( className={cn(
"border-b-2 px-2.5 py-1.5 text-xs font-medium tracking-wide uppercase transition-colors", "border-b-2 px-2.5 py-1.5 text-xs font-medium tracking-wide uppercase transition-colors",
pathname === link.href isActive(pathname, link.href)
? "border-primary text-foreground" ? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground", : "border-transparent text-muted-foreground hover:text-foreground",
)} )}
@@ -0,0 +1,81 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
const AUTOSAVE_DELAY_MS = 600;
export function SessionDescriptionEditor({
sessionId,
initialDescription,
}: {
sessionId: number;
initialDescription: string | null;
}) {
const router = useRouter();
const initial = initialDescription ?? "";
const [value, setValue] = useState(initial);
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");
const lastSaved = useRef(initial);
useEffect(() => {
// Unlike the session name, an empty description is a valid, saveable
// state (it just means "no description"), so no non-empty guard here.
if (value === lastSaved.current) return;
setStatus("saving");
const timer = setTimeout(async () => {
const res = await fetch(`/api/sessions/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: value }),
});
if (res.ok) {
lastSaved.current = value;
setStatus("saved");
router.refresh();
} else {
setStatus("idle");
toast.error("Could not save description");
}
}, AUTOSAVE_DELAY_MS);
return () => clearTimeout(timer);
}, [value, sessionId, router]);
useEffect(() => {
if (status !== "saved") return;
const timer = setTimeout(() => setStatus("idle"), 1500);
return () => clearTimeout(timer);
}, [status]);
return (
<div className="mt-1 flex items-start gap-2">
<textarea
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Add a description..."
aria-label="Session description"
rows={1}
className="max-w-full min-w-0 resize-none border-b-2 border-transparent bg-transparent text-sm text-muted-foreground outline-none transition-colors placeholder:text-muted-foreground/60 hover:border-b-border focus:border-b-foreground/40 focus:text-foreground sm:max-w-xl [field-sizing:content]"
/>
<div className="relative mt-0.5 flex size-4 shrink-0 items-center justify-center">
<Loader2
className={cn(
"absolute size-4 animate-spin text-muted-foreground transition-opacity",
status === "saving" ? "opacity-100" : "opacity-0",
)}
/>
<Check
className={cn(
"absolute size-4 text-primary transition-opacity",
status === "saved" ? "opacity-100" : "opacity-0",
)}
/>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Check, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
const AUTOSAVE_DELAY_MS = 600;
export function SessionTitleEditor({ sessionId, initialName }: { sessionId: number; initialName: string | null }) {
const router = useRouter();
const initial = initialName ?? "";
const [value, setValue] = useState(initial);
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");
const lastSaved = useRef(initial);
useEffect(() => {
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed === lastSaved.current) return;
setStatus("saving");
const timer = setTimeout(async () => {
const res = await fetch(`/api/sessions/${sessionId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: trimmed }),
});
if (res.ok) {
lastSaved.current = trimmed;
setStatus("saved");
router.refresh();
} else {
setStatus("idle");
toast.error("Could not rename session");
}
}, AUTOSAVE_DELAY_MS);
return () => clearTimeout(timer);
}, [value, sessionId, router]);
useEffect(() => {
if (status !== "saved") return;
const timer = setTimeout(() => setStatus("idle"), 1500);
return () => clearTimeout(timer);
}, [status]);
return (
<div className="flex items-center gap-2">
<input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder={`Session #${sessionId}`}
aria-label="Session name"
className="max-w-full min-w-0 border-b-2 border-transparent bg-transparent font-heading text-2xl font-semibold text-foreground outline-none transition-colors placeholder:text-muted-foreground/70 hover:border-b-border focus:border-b-foreground/40 sm:max-w-xl [field-sizing:content]"
/>
<div className="relative flex size-4 shrink-0 items-center justify-center">
<Loader2
className={cn(
"absolute size-4 animate-spin text-muted-foreground transition-opacity",
status === "saving" ? "opacity-100" : "opacity-0",
)}
/>
<Check
className={cn(
"absolute size-4 text-primary transition-opacity",
status === "saved" ? "opacity-100" : "opacity-0",
)}
/>
</div>
</div>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export interface SessionRow {
durationMs: number | null; durationMs: number | null;
} }
const STATUS_VARIANT = { export const STATUS_VARIANT = {
active: "default", active: "default",
completed: "secondary", completed: "secondary",
aborted: "destructive", aborted: "destructive",
+7 -1
View File
@@ -1,4 +1,4 @@
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
export interface DeviceUsageRow { export interface DeviceUsageRow {
@@ -15,6 +15,9 @@ export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) {
if (devices.length === 0) { if (devices.length === 0) {
return ( return (
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Device usage</CardTitle>
</CardHeader>
<CardContent className="py-6 text-sm text-muted-foreground">No device activity yet.</CardContent> <CardContent className="py-6 text-sm text-muted-foreground">No device activity yet.</CardContent>
</Card> </Card>
); );
@@ -22,6 +25,9 @@ export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) {
return ( return (
<Card className="bp-glass"> <Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Device usage</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
+29 -40
View File
@@ -5,56 +5,45 @@ export interface SessionsSummary {
totalDurationMs: number; totalDurationMs: number;
avgDurationMs: number; avgDurationMs: number;
totalReplays: number; totalReplays: number;
durationPerDevice: { deviceId: number; displayName: string | null; bleName: string; totalActiveMs: number }[];
} }
function formatHours(ms: number): string { function formatHours(ms: number): string {
return `${(ms / 3_600_000).toFixed(1)}h`; return `${(ms / 3_600_000).toFixed(1)}h`;
} }
function formatReplays(count: number): string {
return count === 0 ? "-" : `${count}×`;
}
export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) { export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) {
return ( return (
<div className="flex flex-col gap-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-3">
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{summary.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Total time</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{formatHours(summary.totalDurationMs)}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Avg session length</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">
{Math.round(summary.avgDurationMs / 60_000)}m
</CardContent>
</Card>
</div>
<Card className="bp-glass"> <Card className="bp-glass">
<CardContent className="py-3 text-sm text-muted-foreground">Replayed {summary.totalReplays}×</CardContent> <CardHeader>
<CardTitle className="text-sm">Completed sessions</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{summary.count}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm">Total time</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{formatHours(summary.totalDurationMs)}</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm">Avg session length</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">
{Math.round(summary.avgDurationMs / 60_000)}m
</CardContent>
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm">Replays</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{formatReplays(summary.totalReplays)}</CardContent>
</Card> </Card>
{summary.durationPerDevice.length > 0 && (
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Duration per device</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{summary.durationPerDevice.map((d) => (
<div key={d.deviceId} className="flex items-center justify-between text-sm">
<span>{d.displayName ?? d.bleName}</span>
<span className="text-muted-foreground">{formatHours(d.totalActiveMs)}</span>
</div>
))}
</CardContent>
</Card>
)}
</div> </div>
); );
} }
+1 -12
View File
@@ -15,18 +15,7 @@ export async function getSessionsSummary() {
.from(sessions) .from(sessions)
.where(eq(sessions.status, "completed")); .where(eq(sessions.status, "completed"));
const durationPerDevice = await db return totals;
.select({
deviceId: devices.id,
displayName: devices.displayName,
bleName: devices.bleName,
totalActiveMs: sql<number>`coalesce(sum(coalesce(${sessionDevices.disconnectedAt}, ${sessionDevices.connectedAt}) - ${sessionDevices.connectedAt}), 0)`,
})
.from(sessionDevices)
.innerJoin(devices, eq(sessionDevices.deviceId, devices.id))
.groupBy(devices.id);
return { ...totals, durationPerDevice };
} }
export async function getSessionTimeline(sessionId: number, bucketMs = 1000) { export async function getSessionTimeline(sessionId: number, bucketMs = 1000) {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sexy", "name": "sexy",
"version": "0.6.0", "version": "0.7.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",