From 3810fdda433c65dacc8973683f16b10bc4f5ddd7 Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 15 May 2026 20:37:51 +0100 Subject: [PATCH] v0.3 Phase 5f.1 polish: date groups, useful previews, quiet row actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/pages/HistoryPage.svelte | 217 ++++++++++++++++++++++++++----- 1 file changed, 181 insertions(+), 36 deletions(-) diff --git a/src/lib/pages/HistoryPage.svelte b/src/lib/pages/HistoryPage.svelte index 1d65f58..c9a083f 100644 --- a/src/lib/pages/HistoryPage.svelte +++ b/src/lib/pages/HistoryPage.svelte @@ -28,10 +28,12 @@ import LumotiaNotice from "$lib/ui/LumotiaNotice.svelte"; // v0.3 Phase 5f — quietware History layout. 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 { formatTime, formatDuration, formatTimestamp } from "$lib/utils/time.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 COLLAPSED_ROW_MIN_HEIGHT = 54; @@ -298,6 +300,95 @@ 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(() => { return filtered.map((item) => { const text = compactPreviewText(item); @@ -721,14 +812,19 @@ +
-
+