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:
@@ -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 @@
|
||||
<!-- =================================================================
|
||||
v0.3 Phase 5f — quietware History layout. Calm local archive.
|
||||
|
||||
Header "History" + count
|
||||
Primary surface search + view tabs + list / empty state
|
||||
Action dock contextual per item
|
||||
Mono footer saved count · storage · local only
|
||||
Header "History" + Live / Trash tabs with counts
|
||||
Primary surface search + date-grouped list / empty state
|
||||
Row icon · title · 2-line preview · meta-line
|
||||
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),
|
||||
trashItems, viewMode, searchQuery, filtered (derived),
|
||||
compactPreviewText, copyItem, removeItem, openViewer,
|
||||
copyItem, exportMarkdown, removeItem, openViewer,
|
||||
restoreFromTrash. Bulk actions, audio playback, tag chips
|
||||
and ClearAll modal stay accessible via the v0.2 fallback —
|
||||
the quietware view focuses on the core archive flow.
|
||||
@@ -775,9 +871,11 @@
|
||||
</div>
|
||||
{/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="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" />
|
||||
<input
|
||||
type="search"
|
||||
@@ -791,7 +889,11 @@
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
{#if viewMode === "live"}
|
||||
{#if filtered.length === 0}
|
||||
@@ -805,28 +907,61 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<ul class="divide-y divide-border-subtle">
|
||||
{#each filtered as item (item.id)}
|
||||
<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">
|
||||
{#if item.source === "file"}
|
||||
<FileText size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Mic size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] font-medium text-text truncate">
|
||||
{item.title || item.preview?.slice(0, 80) || "Untitled"}
|
||||
</p>
|
||||
<p class="text-[12px] text-text-secondary mt-0.5 line-clamp-2">{compactPreviewText(item)}</p>
|
||||
<p class="text-[11px] text-text-tertiary mt-1 font-mono">{formatTimestamp(item.savedAt || item.timestamp)}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => openViewer(item)}>Open</LumotiaButton>
|
||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => copyItem(item)}>Copy</LumotiaButton>
|
||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => removeItem(item)}>Delete</LumotiaButton>
|
||||
</div>
|
||||
<ul class="pb-2">
|
||||
{#each liveGroups as group (group.key)}
|
||||
<li
|
||||
class="px-6 pt-5 pb-2 text-[11px] uppercase tracking-wider text-text-tertiary font-mono"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{group.label}
|
||||
</li>
|
||||
<li>
|
||||
<ul class="divide-y divide-border-subtle border-y border-border-subtle">
|
||||
{#each group.items as item (item.id)}
|
||||
{@const previewText = quietRowPreview(item)}
|
||||
{@const sourceLabel = quietRowSourceLabel(item)}
|
||||
<li class="group/row flex items-start gap-3 px-6 py-3 hover:bg-hover transition-colors">
|
||||
<div class="mt-0.5 text-text-tertiary shrink-0">
|
||||
{#if sourceLabel === "File"}
|
||||
<FileText size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Mic size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
</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}
|
||||
· {formatDuration(item.duration)}
|
||||
{/if}
|
||||
· {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>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -844,21 +979,31 @@
|
||||
{:else}
|
||||
<ul class="divide-y divide-border-subtle">
|
||||
{#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">
|
||||
<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 class="flex-1 min-w-0">
|
||||
<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 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 class="flex items-center gap-1 shrink-0">
|
||||
<LumotiaButton
|
||||
size="sm"
|
||||
variant={restoringId === item.id ? "secondary" : "primary"}
|
||||
variant={restoringId === item.id ? "secondary" : "tertiary"}
|
||||
disabled={restoringId === item.id}
|
||||
onclick={() => restoreFromTrash(item.id)}
|
||||
>{restoringId === item.id ? "Restoring…" : "Restore"}</LumotiaButton>
|
||||
|
||||
Reference in New Issue
Block a user