v0.3 Phase 5f.1 polish: date groups, useful previews, quiet row actions

Acts on the polish-pass brief returned for 5f. Touches the quietware
branch only; v0.2 fallback (and compactPreviewText, used by its
row-height measurer) is left intact.

  Date groups       liveGroups derived walks `filtered` and breaks at
                    local-calendar-day boundaries. dateGroupLabel
                    formats Today / Yesterday / D MMM / D MMM YYYY.
                    Section headers are 11px mono uppercase, separated
                    by the same divide-y the rows use.

  Row hierarchy     New quietRowPreview returns a real transcript
                    snippet (item.text → item.preview → empty). The
                    title repeat in 5f was compactPreviewText returning
                    the title under the title; that helper stays for
                    v0.2's height math, the quietware row uses the new
                    one. Falls back to "No preview available" in-template.

  Row meta-line     HH:MM · duration · Dictation|File. Date moved to
                    the group header — repeating it per row wasted
                    hierarchy.

  Row actions       Open stays visible (tertiary), other actions moved
                    behind a quiet LumotiaIconButton(MoreHorizontal)
                    trigger that opens LumotiaMenu with Copy /
                    Export markdown / Move to Trash. Trigger is
                    always present (not hover-only) so keyboard
                    focus works.

  Search alignment  Dropped max-w-2xl. Search now spans the same
                    content gutter as the rows so the right edge
                    aligns with where the actions column begins —
                    intentional, not arbitrary.

  Trash rows        Same row anatomy. Restore visible. No overflow
                    menu — perm-delete-from-trash UI is deferred per
                    the existing comment (TRANSCRIPT_TRASH_RETENTION_DAYS
                    handles hard-removal). Meta-line shows
                    "Deleted …  ·  Auto-purges after 30 days".

Date-grouping formula (per CLAUDE.md rule):
  same local-calendar day as now → "Today"
  previous local-calendar day    → "Yesterday"
  same local-calendar year       → "D MMM"
  older                          → "D MMM YYYY"
Day boundaries are strict local midnight, not 24-hour windows. Items
without savedAt fall into an "undated" bucket.

npm run check: 0 errors.
This commit is contained in:
2026-05-15 20:37:51 +01:00
parent 62e795174c
commit 3810fdda43

View File

@@ -28,10 +28,12 @@
import LumotiaNotice from "$lib/ui/LumotiaNotice.svelte"; import LumotiaNotice from "$lib/ui/LumotiaNotice.svelte";
// v0.3 Phase 5f — quietware History layout. // v0.3 Phase 5f — quietware History layout.
import LumotiaPageSkeleton from "$lib/ui/LumotiaPageSkeleton.svelte"; import LumotiaPageSkeleton from "$lib/ui/LumotiaPageSkeleton.svelte";
import LumotiaMenu from "$lib/ui/LumotiaMenu.svelte";
import LumotiaIconButton from "$lib/ui/LumotiaIconButton.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { formatTime, formatDuration, formatTimestamp } from "$lib/utils/time.js"; import { formatTime, formatDuration, formatTimestamp } from "$lib/utils/time.js";
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js"; import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag } from 'lucide-svelte'; import { Search, Clock, Play, Pause, FileText, Mic, ChevronDown, ExternalLink, Star, Tag, MoreHorizontal, Copy, Trash2, FileDown } from 'lucide-svelte';
const prefs = getPreferences(); const prefs = getPreferences();
const COLLAPSED_ROW_MIN_HEIGHT = 54; const COLLAPSED_ROW_MIN_HEIGHT = 54;
@@ -298,6 +300,95 @@
return item.title?.trim() || "Untitled"; return item.title?.trim() || "Untitled";
} }
// v0.3 Phase 5f polish — quietware row helpers. Kept separate from
// compactPreviewText so the v0.2 fallback's row-height measurement
// (which reads compactPreviews) is untouched.
/** Two-line transcript snippet for the quietware row sub-line. Falls
* through item.text → item.preview → empty string ("No preview…"
* rendering is the template's job, not this helper's). Stripped of
* leading whitespace so previews don't start mid-pause. */
function quietRowPreview(item) {
const raw = (item?.text || item?.preview || "").replace(/\s+/g, " ").trim();
return raw;
}
/** Source label for the quietware row meta-line. "Dictation" for
* mic-captured items, "File" for imports. Mirrors the icon column
* but lives in the meta-line so the row scans without colour cues. */
function quietRowSourceLabel(item) {
const s = (item?.source || "").toLowerCase();
return s.includes("file") ? "File" : "Dictation";
}
/** Just the HH:MM portion of a saved-at timestamp. The date sits in
* the group header above the row, so repeating it here would waste
* hierarchy. Uses en-GB 24h to match formatTimestamp. */
function quietRowTime(iso) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
return d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
}
/** Calendar-day label for date-group headers. Formula:
* same local-calendar day as now → "Today"
* previous local-calendar day → "Yesterday"
* same local-calendar year → "D MMM" (e.g. "12 May")
* older → "D MMM YYYY" (e.g. "12 May 2025")
* Day boundaries are strictly local midnight, not 24-hour windows. */
function dateGroupLabel(iso) {
if (!iso) return "Undated";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "Undated";
const now = new Date();
const sameDay = (a, b) =>
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate();
if (sameDay(d, now)) return "Today";
const y = new Date(now);
y.setDate(now.getDate() - 1);
if (sameDay(d, y)) return "Yesterday";
const fmt = d.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: d.getFullYear() === now.getFullYear() ? undefined : "numeric",
});
return fmt;
}
/** Stable bucket key for grouping. Local-calendar YYYY-MM-DD so the
* groups break at midnight, not on UTC date math. Items with no
* savedAt fall into an "undated" bucket that surfaces last. */
function dateGroupKey(iso) {
if (!iso) return "undated";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "undated";
const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, "0");
const day = d.getDate().toString().padStart(2, "0");
return `${y}-${m}-${day}`;
}
/** Walk `filtered` in order, breaking at calendar-day boundaries.
* Preserves the underlying sort so newest-first stays newest-first;
* groups appear in the order their first item appears. */
let liveGroups = $derived.by(() => {
const out = [];
let current = null;
for (const item of filtered) {
const iso = item.savedAt || item.timestamp;
const key = dateGroupKey(iso);
if (!current || current.key !== key) {
current = { key, label: dateGroupLabel(iso), items: [] };
out.push(current);
}
current.items.push(item);
}
return out;
});
let compactPreviews = $derived.by(() => { let compactPreviews = $derived.by(() => {
return filtered.map((item) => { return filtered.map((item) => {
const text = compactPreviewText(item); const text = compactPreviewText(item);
@@ -721,14 +812,19 @@
<!-- ================================================================= <!-- =================================================================
v0.3 Phase 5f — quietware History layout. Calm local archive. v0.3 Phase 5f — quietware History layout. Calm local archive.
Header "History" + count Header "History" + Live / Trash tabs with counts
Primary surface search + view tabs + list / empty state Primary surface search + date-grouped list / empty state
Action dock contextual per item Row icon · title · 2-line preview · meta-line
Mono footer saved count · storage · local only Open (visible) + ⋯ overflow (Copy / Export
/ Move to Trash). Trash rows show Restore
visible; permanent-delete UI is deferred
(the 30-day purge handles hard-removal,
see TRANSCRIPT_TRASH_RETENTION_DAYS).
Mono footer saved count · trash count · local only
Reuses every existing state and handler: history (store), Reuses every existing state and handler: history (store),
trashItems, viewMode, searchQuery, filtered (derived), trashItems, viewMode, searchQuery, filtered (derived),
compactPreviewText, copyItem, removeItem, openViewer, copyItem, exportMarkdown, removeItem, openViewer,
restoreFromTrash. Bulk actions, audio playback, tag chips restoreFromTrash. Bulk actions, audio playback, tag chips
and ClearAll modal stay accessible via the v0.2 fallback — and ClearAll modal stay accessible via the v0.2 fallback —
the quietware view focuses on the core archive flow. the quietware view focuses on the core archive flow.
@@ -775,9 +871,11 @@
</div> </div>
{/if} {/if}
<!-- Search input, mounted to the top of the surface. --> <!-- Search input. No max-width: spans the same content gutter
as the rows below so the right edge aligns with where the
row actions column begins. Intentional, not arbitrary. -->
<div class="px-6 pt-5 pb-3 border-b border-border-subtle"> <div class="px-6 pt-5 pb-3 border-b border-border-subtle">
<div class="relative max-w-2xl"> <div class="relative">
<Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary pointer-events-none" aria-hidden="true" /> <Search size={14} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-tertiary pointer-events-none" aria-hidden="true" />
<input <input
type="search" type="search"
@@ -791,7 +889,11 @@
</div> </div>
<!-- Archive list. Reuses the existing derived `filtered` for <!-- Archive list. Reuses the existing derived `filtered` for
live mode and `trashItems` for trash mode. --> live mode and `trashItems` for trash mode. Live mode
renders date-bucketed groups (Today / Yesterday / D MMM)
so the surface scans as an archive rather than a flat
table; trash stays ungrouped because the relevant axis
there is days-until-purge, not save date. -->
<div class="flex-1 overflow-auto min-h-0"> <div class="flex-1 overflow-auto min-h-0">
{#if viewMode === "live"} {#if viewMode === "live"}
{#if filtered.length === 0} {#if filtered.length === 0}
@@ -805,28 +907,61 @@
</p> </p>
</div> </div>
{:else} {:else}
<ul class="divide-y divide-border-subtle"> <ul class="pb-2">
{#each filtered as item (item.id)} {#each liveGroups as group (group.key)}
<li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors"> <li
<div class="mt-0.5 text-text-tertiary shrink-0"> class="px-6 pt-5 pb-2 text-[11px] uppercase tracking-wider text-text-tertiary font-mono"
{#if item.source === "file"} aria-hidden="true"
<FileText size={16} aria-hidden="true" /> >
{:else} {group.label}
<Mic size={16} aria-hidden="true" /> </li>
{/if} <li>
</div> <ul class="divide-y divide-border-subtle border-y border-border-subtle">
<div class="flex-1 min-w-0"> {#each group.items as item (item.id)}
<p class="text-[13px] font-medium text-text truncate"> {@const previewText = quietRowPreview(item)}
{item.title || item.preview?.slice(0, 80) || "Untitled"} {@const sourceLabel = quietRowSourceLabel(item)}
</p> <li class="group/row flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">{compactPreviewText(item)}</p> <div class="mt-0.5 text-text-tertiary shrink-0">
<p class="text-[11px] text-text-tertiary mt-1 font-mono">{formatTimestamp(item.savedAt || item.timestamp)}</p> {#if sourceLabel === "File"}
</div> <FileText size={16} aria-hidden="true" />
<div class="flex items-center gap-1 shrink-0"> {:else}
<LumotiaButton size="sm" variant="tertiary" onclick={() => openViewer(item)}>Open</LumotiaButton> <Mic size={16} aria-hidden="true" />
<LumotiaButton size="sm" variant="tertiary" onclick={() => copyItem(item)}>Copy</LumotiaButton> {/if}
<LumotiaButton size="sm" variant="tertiary" onclick={() => removeItem(item)}>Delete</LumotiaButton> </div>
</div> <div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-text truncate">
{item.title?.trim() || "Untitled"}
</p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">
{previewText || "No preview available"}
</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">
{quietRowTime(item.savedAt || item.timestamp)}
{#if item.duration}
&nbsp;·&nbsp;{formatDuration(item.duration)}
{/if}
&nbsp;·&nbsp;{sourceLabel}
</p>
</div>
<div class="flex items-center gap-1 shrink-0">
<LumotiaButton size="sm" variant="tertiary" onclick={() => openViewer(item)}>Open</LumotiaButton>
<LumotiaMenu
align="end"
items={[
{ value: "copy", label: "Copy transcript", icon: Copy, onSelect: () => copyItem(item) },
{ value: "export", label: "Export markdown", icon: FileDown, onSelect: () => exportMarkdown(item) },
{ kind: "separator" as const },
{ value: "delete", label: "Move to Trash", icon: Trash2, destructive: true, onSelect: () => removeItem(item) },
]}
>
{#snippet trigger()}
<LumotiaIconButton icon={MoreHorizontal} label="More actions" size="sm" />
{/snippet}
</LumotiaMenu>
</div>
</li>
{/each}
</ul>
</li> </li>
{/each} {/each}
</ul> </ul>
@@ -844,21 +979,31 @@
{:else} {:else}
<ul class="divide-y divide-border-subtle"> <ul class="divide-y divide-border-subtle">
{#each trashItems as item (item.id)} {#each trashItems as item (item.id)}
{@const previewText = quietRowPreview(item)}
{@const sourceLabel = quietRowSourceLabel(item)}
<li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors"> <li class="flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
<div class="mt-0.5 text-text-tertiary shrink-0"> <div class="mt-0.5 text-text-tertiary shrink-0">
<FileText size={16} aria-hidden="true" /> {#if sourceLabel === "File"}
<FileText size={16} aria-hidden="true" />
{:else}
<Mic size={16} aria-hidden="true" />
{/if}
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-[13px] font-medium text-text truncate"> <p class="text-[13px] font-medium text-text truncate">
{item.title || item.preview?.slice(0, 80) || "Untitled"} {item.title?.trim() || "Untitled"}
</p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">
{previewText || "No preview available"}
</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">
Deleted {formatTrashTimestamp(item.deleted_at || item.createdAt)} · Auto-purges after 30 days
</p> </p>
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-1">{item.preview || ""}</p>
<p class="text-[11px] text-text-tertiary mt-1 font-mono">Deleted {formatTrashTimestamp(item.deleted_at)}</p>
</div> </div>
<div class="flex items-center gap-1 shrink-0"> <div class="flex items-center gap-1 shrink-0">
<LumotiaButton <LumotiaButton
size="sm" size="sm"
variant={restoringId === item.id ? "secondary" : "primary"} variant={restoringId === item.id ? "secondary" : "tertiary"}
disabled={restoringId === item.id} disabled={restoringId === item.id}
onclick={() => restoreFromTrash(item.id)} onclick={() => restoreFromTrash(item.id)}
>{restoringId === item.id ? "Restoring…" : "Restore"}</LumotiaButton> >{restoringId === item.id ? "Restoring…" : "Restore"}</LumotiaButton>