26 Commits
Author SHA1 Message Date
valknarandClaude Sonnet 5 afa6e8d473 Bump to 0.8.0
CI / Static checks (push) Successful in 1m40s
CI / Build and push image (push) Successful in 1m8s
Control view button layout fixes (buttons sit next to each other, wrap
responsively on narrow screens), Replay view streamlined to match the
Control view (title includes the session name, buttons below the
header, matching "no devices connected" hint card, Play icon on
"Match & replay"), and the session-status Badge color mismatch between
the sessions overview and detail view fixed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 17:33:49 +02:00
valknarandClaude Sonnet 5 1bad1f4e33 Streamline the Replay view with the Control view
Page title is now "{session name} - Replay" (matching generateMetadata)
instead of a bare "Replay" heading with the name duplicated inside a
Card title. Dropped that redundant outer Card - the pre-connection
scan/match buttons now sit in a flat row below the header, same as
Control's scan/record row, with the same "no devices connected" hint
card shown underneath when nothing's connected yet. Playback controls
once a replay starts still live in a card, matching DeviceCard's style.
Also gave "Match & replay" a Play icon.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 17:31:57 +02:00
valknarandClaude Sonnet 5 c06f119dcf Fix Control view button layout and responsiveness
The scan/record button row used justify-between, spreading the two
button groups to opposite ends instead of sitting next to each other.
Also made the record-controls row and each device card's header wrap
instead of overflowing on narrow screens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 17:31:51 +02:00
valknar 20433a6749 chore: cleanup 2026-08-31 17:20:22 +02:00
valknarandClaude Sonnet 5 1d88ab96c8 Move STATUS_VARIANT out of the "use client" SessionsTable module
The session detail page (a server component) was importing STATUS_VARIANT
from SessionsTable.tsx, a "use client" file. Next.js compiles client
modules separately for the server and client bundles, and pulling a plain
value (not a component) across that boundary let the two compiler caches
drift out of sync in dev, so the same session status could render a
different Badge color depending on which page rendered it. Moved the
constant into its own plain module that both files import.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzyfRyA7h5rs5SCAahLAWd
2026-08-31 17:19:24 +02:00
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
18 changed files with 422 additions and 244 deletions
+7 -17
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { BrandMark } from "@/components/layout/BrandMark";
import { SessionsTable } from "@/components/sessions/SessionsTable";
import { listSessions } from "@/lib/db/queries/sessions";
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">
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
<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 text-muted-foreground">Total play time</CardTitle>
<CardTitle className="text-sm">Total play time</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">
{(summary.totalDurationMs / 3_600_000).toFixed(1)}h
@@ -47,7 +48,7 @@ export default async function DashboardPage() {
</Card>
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Replays</CardTitle>
<CardTitle className="text-sm">Replays</CardTitle>
</CardHeader>
<CardContent className="bp-readout text-3xl">{summary.totalReplays}</CardContent>
</Card>
@@ -55,21 +56,10 @@ export default async function DashboardPage() {
<Card className="bp-glass">
<CardHeader>
<CardTitle>Recent sessions</CardTitle>
<CardTitle className="text-base">Recent sessions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col">
{recentSessions.length === 0 && <p className="text-sm text-muted-foreground">No sessions yet.</p>}
{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 className="flex flex-col gap-3">
<SessionsTable sessions={recentSessions} />
</CardContent>
</Card>
</div>
+17 -10
View File
@@ -1,9 +1,13 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { SessionTimelineChart } from "@/components/sessions/SessionTimelineChart";
import { SessionTitleEditor } from "@/components/sessions/SessionTitleEditor";
import { SessionDescriptionEditor } from "@/components/sessions/SessionDescriptionEditor";
import { STATUS_VARIANT } from "@/components/sessions/session-status";
import { getSessionDetail, getSessionName } from "@/lib/db/queries/sessions";
import { getSessionTimeline } from "@/lib/db/queries/stats";
import { Play } from "lucide-react";
@@ -32,26 +36,28 @@ export default async function SessionDetailPage({ params }: { params: Promise<{
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)}
<div className="flex flex-col justify-between gap-4 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<SessionTitleEditor sessionId={detail.session.id} initialName={detail.session.name} />
{(detail.session.kind === "replay" || detail.session.playCount > 0) && (
<p className="mt-1 text-sm text-muted-foreground">
{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 && (
<>
{" "}
· replayed {detail.session.playCount}×
{detail.session.kind === "replay" && detail.replayedFromName !== undefined && " · "}
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>
)}
<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`}>
@@ -60,6 +66,7 @@ export default async function SessionDetailPage({ params }: { params: Promise<{
</Button>
)}
</div>
</div>
<Card className="bp-glass">
<CardHeader>
+9 -2
View File
@@ -5,14 +5,21 @@ import { getSessionName } from "@/lib/db/queries/sessions";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const name = await getSessionName(Number(id));
return { title: name ? `Replay ${name}` : "Replay" };
return { title: name ? `${name} - Replay` : "Replay" };
}
export default async function ReplayPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const name = await getSessionName(Number(id));
return (
<div className="flex flex-col gap-6">
<h1 className="font-heading text-2xl font-semibold">Replay</h1>
<div>
<h1 className="font-heading text-2xl font-semibold">{name ?? `Session #${id}`} - Replay</h1>
<p className="text-sm text-muted-foreground">
Connect the devices you want to replay onto, then match them to this session&apos;s original device
slots and play its recorded intensity back live over Web Bluetooth.
</p>
</div>
<ReplayPlayerLoader sessionId={Number(id)} />
</div>
);
-11
View File
@@ -1,5 +1,4 @@
import type { Metadata } from "next";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { SessionsSummaryCards } from "@/components/stats/SessionsSummaryCards";
import { DeviceUsageTable } from "@/components/stats/DeviceUsageTable";
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>
</div>
<Tabs defaultValue="sessions">
<TabsList className="bp-glass">
<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>
);
}
+1 -1
View File
@@ -225,7 +225,7 @@ export function ButtplugConsole() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-4">
<DeviceScanPanel scanning={scanning} onScan={handleScan} onStopScan={() => void stopScanning()} />
<RecordControls
active={activeSession !== null}
+3 -3
View File
@@ -37,9 +37,9 @@ export function DeviceCard({
}: DeviceCardProps) {
return (
<Card className="bp-glass">
<CardHeader className="flex flex-row items-center justify-between pb-3">
<span className="text-sm font-medium text-foreground">{device.displayName ?? device.name}</span>
<div className="flex items-center gap-3">
<CardHeader className="flex flex-row flex-wrap items-center justify-between gap-2 pb-3">
<span className="min-w-0 truncate text-sm font-medium text-foreground">{device.displayName ?? device.name}</span>
<div className="flex shrink-0 items-center gap-3">
{device.hasBattery &&
(batteryLevel !== null ? (
<BatteryIndicator level={batteryLevel} />
+1 -1
View File
@@ -15,7 +15,7 @@ interface RecordControlsProps {
export function RecordControls({ active, elapsedLabel, disabled, busy, onStart, onEnd }: RecordControlsProps) {
if (active) {
return (
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<span className="flex items-center gap-2 text-sm font-medium">
<Circle className="bp-pulse size-2.5 fill-destructive text-destructive" />
Session live · {elapsedLabel}
+50 -16
View File
@@ -1,11 +1,12 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Check, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
export interface DeviceRow {
id: number;
@@ -14,34 +15,67 @@ export interface DeviceRow {
lastConnectedAt: number | null;
}
const AUTOSAVE_DELAY_MS = 600;
function DeviceNameCell({ device }: { device: DeviceRow }) {
const router = useRouter();
const [value, setValue] = useState(device.displayName ?? device.bleName);
const [saving, setSaving] = useState(false);
const initial = device.displayName ?? device.bleName;
const [value, setValue] = useState(initial);
const [status, setStatus] = useState<"idle" | "saving" | "saved">("idle");
const lastSaved = useRef(initial);
async function handleSave() {
if (value.trim().length === 0) return;
setSaving(true);
useEffect(() => {
const trimmed = value.trim();
if (trimmed.length === 0 || trimmed === lastSaved.current) return;
setStatus("saving");
const timer = setTimeout(async () => {
const res = await fetch(`/api/devices/${device.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ displayName: value.trim() }),
body: JSON.stringify({ displayName: trimmed }),
});
setSaving(false);
if (res.ok) {
toast.success("Renamed");
lastSaved.current = trimmed;
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 (
<div className="flex items-center gap-2">
<Input value={value} onChange={(e) => setValue(e.target.value)} className="h-8 w-40 min-w-40" />
<Button size="sm" variant="outline" onClick={handleSave} disabled={saving} className="shrink-0">
Save
</Button>
<div className="relative w-48">
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
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>
);
}
+5 -1
View File
@@ -19,6 +19,10 @@ const LINKS = [
{ href: "/devices", label: "Devices" },
];
function isActive(pathname: string, href: string): boolean {
return href === "/" ? pathname === "/" : pathname === href || pathname.startsWith(`${href}/`);
}
function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
return (
@@ -30,7 +34,7 @@ function NavLinks({ onNavigate }: { onNavigate?: () => void }) {
onClick={onNavigate}
className={cn(
"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-transparent text-muted-foreground hover:text-foreground",
)}
+22 -16
View File
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Slider } from "@/components/ui/slider";
import { DeviceScanPanel } from "@/components/control/DeviceScanPanel";
@@ -137,18 +137,9 @@ export function ReplayPlayer({ sessionId }: { sessionId: number }) {
return (
<div className="flex flex-col gap-6">
<Card className="bp-glass">
<CardHeader>
<CardTitle>{data.session.name ?? `Session #${data.session.id}`}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{!player ? (
<>
<p className="text-sm text-muted-foreground">
Connect the devices you want to replay onto, then match them to the session&apos;s
original device slots.
</p>
<div className="flex items-center gap-3">
<div className="flex flex-wrap items-center gap-3">
<DeviceScanPanel scanning={scanning} onScan={() => void startScanning()} onStopScan={() => void stopScanning()} />
<Button
onClick={async () => {
@@ -161,12 +152,28 @@ export function ReplayPlayer({ sessionId }: { sessionId: number }) {
}}
disabled={connectedDevices.length === 0 || starting}
>
{starting ? "Starting..." : "Match devices & replay"}
{starting ? (
"Starting..."
) : (
<>
<Play className="size-3.5" /> Match & replay
</>
)}
</Button>
</div>
{connectedDevices.length === 0 && (
<Card className="bp-glass">
<CardContent className="py-6 text-sm text-muted-foreground">
No devices connected yet. Scan to discover nearby toys, then match them to this session&apos;s
original device slots.
</CardContent>
</Card>
)}
</>
) : (
<div className="flex flex-col gap-3">
<Card className="bp-glass">
<CardContent className="flex flex-col gap-3 py-6">
{replayTargets.length > 0 && (
<div className="flex flex-wrap gap-2">
{replayTargets.map((t) => (
@@ -184,7 +191,7 @@ export function ReplayPlayer({ sessionId }: { sessionId: number }) {
max={data.session.durationMs}
onValueChange={([v]) => player.seek(v)}
/>
<div className="flex items-center justify-between">
<div className="flex flex-wrap items-center justify-between gap-3">
<span className="bp-readout text-xs text-muted-foreground">
{formatTime(elapsedMs)} / {formatTime(data.session.durationMs)}
</span>
@@ -236,10 +243,9 @@ export function ReplayPlayer({ sessionId }: { sessionId: number }) {
</Button>
</div>
</div>
</div>
)}
</CardContent>
</Card>
)}
<DeviceRemapDialog
open={showRemap}
@@ -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 -6
View File
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/dialog";
import { Play, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { STATUS_VARIANT } from "@/components/sessions/session-status";
export interface SessionRow {
id: number;
@@ -25,12 +26,6 @@ export interface SessionRow {
durationMs: number | null;
}
const STATUS_VARIANT = {
active: "default",
completed: "secondary",
aborted: "destructive",
} as const;
function formatDuration(ms: number | null): string {
if (ms === null) return "-";
const totalSeconds = Math.round(ms / 1000);
+8
View File
@@ -0,0 +1,8 @@
// Plain data, deliberately kept out of SessionsTable.tsx (a "use client"
// module) so server components (e.g. the session detail page) can import it
// without pulling a value across the client/server compilation boundary.
export const STATUS_VARIANT = {
active: "default",
completed: "secondary",
aborted: "destructive",
} as const;
+8 -2
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";
export interface DeviceUsageRow {
@@ -15,13 +15,19 @@ export function DeviceUsageTable({ devices }: { devices: DeviceUsageRow[] }) {
if (devices.length === 0) {
return (
<Card className="bp-glass">
<CardContent className="py-6 text-sm text-muted-foreground">No device activity yet.</CardContent>
<CardHeader>
<CardTitle className="text-base">Device usage</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">No device activity yet.</CardContent>
</Card>
);
}
return (
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Device usage</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
+10 -21
View File
@@ -5,56 +5,45 @@ export interface SessionsSummary {
totalDurationMs: number;
avgDurationMs: number;
totalReplays: number;
durationPerDevice: { deviceId: number; displayName: string | null; bleName: string; totalActiveMs: number }[];
}
function formatHours(ms: number): string {
return `${(ms / 3_600_000).toFixed(1)}h`;
}
function formatReplays(count: number): string {
return count === 0 ? "-" : `${count}×`;
}
export function SessionsSummaryCards({ summary }: { summary: SessionsSummary }) {
return (
<div className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-3">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-sm text-muted-foreground">Completed sessions</CardTitle>
<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 text-muted-foreground">Total time</CardTitle>
<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 text-muted-foreground">Avg session length</CardTitle>
<CardTitle className="text-sm">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">
<CardContent className="py-3 text-sm text-muted-foreground">Replayed {summary.totalReplays}×</CardContent>
</Card>
{summary.durationPerDevice.length > 0 && (
<Card className="bp-glass">
<CardHeader>
<CardTitle className="text-base">Duration per device</CardTitle>
<CardTitle className="text-sm">Replays</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>
<CardContent className="bp-readout text-3xl">{formatReplays(summary.totalReplays)}</CardContent>
</Card>
)}
</div>
);
}
+1 -12
View File
@@ -15,18 +15,7 @@ export async function getSessionsSummary() {
.from(sessions)
.where(eq(sessions.status, "completed"));
const durationPerDevice = await db
.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 };
return totals;
}
export async function getSessionTimeline(sessionId: number, bucketMs = 1000) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sexy",
"version": "0.6.0",
"version": "0.8.0",
"private": true,
"scripts": {
"dev": "next dev",