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";
|
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,11 +907,22 @@
|
|||||||
</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
|
||||||
|
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">
|
<div class="mt-0.5 text-text-tertiary shrink-0">
|
||||||
{#if item.source === "file"}
|
{#if sourceLabel === "File"}
|
||||||
<FileText size={16} aria-hidden="true" />
|
<FileText size={16} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<Mic size={16} aria-hidden="true" />
|
<Mic size={16} aria-hidden="true" />
|
||||||
@@ -817,19 +930,41 @@
|
|||||||
</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">
|
||||||
|
{quietRowTime(item.savedAt || item.timestamp)}
|
||||||
|
{#if item.duration}
|
||||||
|
· {formatDuration(item.duration)}
|
||||||
|
{/if}
|
||||||
|
· {sourceLabel}
|
||||||
</p>
|
</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>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<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={() => openViewer(item)}>Open</LumotiaButton>
|
||||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => copyItem(item)}>Copy</LumotiaButton>
|
<LumotiaMenu
|
||||||
<LumotiaButton size="sm" variant="tertiary" onclick={() => removeItem(item)}>Delete</LumotiaButton>
|
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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Trash view -->
|
<!-- Trash view -->
|
||||||
@@ -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">
|
||||||
|
{#if sourceLabel === "File"}
|
||||||
<FileText size={16} aria-hidden="true" />
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user