Files
inkwell/frontend/src/views/TimelineView.vue
T
bvandeusenandClaude Opus 5 67b9ea2938
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 59s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m33s
Android (Tauri) / Android APK (debug) (push) Successful in 4m24s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m32s
Desktop (Tauri) / Update manifest (push) Successful in 5s
frontend: one grid for every lens, and a cross-fade between surfaces (task 1913)
"The same board, re-filtered" has to be literally true to read as true. The
column classes were copy-pasted into five places — the board's pinned and other
sections, its non-board branch, search, and timeline — so a lens could drift from
home by a single edit. One already had: the FLIP reflow from 1914 landed on the
board's three grids and left search and timeline popping. NoteGrid is now the only
file that knows how the masonry is laid out or how it moves, and search and
timeline gained the motion by adopting it.

It takes activeId rather than an index. The board splits its notes across two
grids, so index-based focus made the call site do offset arithmetic
(focusedIndex === pinnedNotes.length + i) against a list the grid didn't own.

The lens cross-fade is deliberately UNKEYED, which is the whole trick. Board,
archive, trash and label all render the same BoardView; keying the transition on
the route would remount it, blanking the board and refetching — exactly the
page-change feeling this is meant to remove. Unkeyed, Vue transitions only when
the component TYPE changes (board to search to timeline to graph), and moving
between the board's own lenses stays an in-place reflow that NoteGrid animates.
The two behaviours fall out of one rule rather than needing to be special-cased.

Out is quicker than in because mode="out-in" makes the durations additive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 13:39:49 -04:00

162 lines
6.1 KiB
Vue

<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { repo } from "../adapters";
import type { NoteListQuery } from "../adapters/repo";
import { type Note } from "../stores/notes";
import { addLocalDays, parseLocalDate } from "../notes/datetime";
import { useNoteList } from "../composables/useNoteList";
import { useNoteEditor } from "../composables/useNoteEditor";
import AsyncState from "../components/AsyncState.vue";
import EmptyState from "../components/EmptyState.vue";
import NoteGrid from "../components/NoteGrid.vue";
import NoteEditor from "../components/NoteEditor.vue";
// The "find by WHEN" recall lens: your notes grouped by when you captured them,
// newest first, with an optional date range. A distinct recall axis from search /
// labels — memory is often temporal even when the content is fuzzy.
// Optional local date-range filter (YYYY-MM-DD from <input type="date">).
const fromDate = ref("");
const toDate = ref("");
const hasRange = computed(() => !!fromDate.value || !!toDate.value);
function buildQuery(): NoteListQuery {
const from = parseLocalDate(fromDate.value);
const to = parseLocalDate(toDate.value);
return {
view: "active",
sort: "created",
facets: {
created_after: from ? from.toISOString() : undefined,
// Half-open upper bound: start of the day AFTER `to`, so the whole `to` day is included.
created_before: to ? addLocalDays(to, 1).toISOString() : undefined,
},
};
}
const { items, loading, error, load } = useNoteList(
async () => repo.notes.list(buildQuery()),
"Couldn't load the timeline.",
);
const { editing, open: openEditor, close: closeEditor, navigate: onNavigate } = useNoteEditor({
list: () => items.value,
onClose: load, // reflect any edits made from a card
});
function clearRange() {
fromDate.value = "";
toDate.value = "";
}
// Reload when the range changes (watch doesn't fire on mount; onMounted covers first load).
watch([fromDate, toDate], load);
// --- Group notes (already newest-first from the server) into human date buckets,
// computed in the viewer's LOCAL timezone. Server desc order keeps buckets contiguous. ---
interface Group {
key: string;
label: string;
notes: Note[];
}
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
}
function bucketLabel(created: string | null, now: Date): string {
if (!created) return "Undated";
const d = new Date(created);
if (Number.isNaN(d.getTime())) return "Undated";
const dayDiff = Math.round((startOfDay(now) - startOfDay(d)) / 86400000);
if (dayDiff <= 0) return "Today";
if (dayDiff === 1) return "Yesterday";
if (dayDiff < 7) return "Earlier this week";
if (d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()) return "Earlier this month";
return d.toLocaleString(undefined, { month: "long", year: "numeric" });
}
const groups = computed<Group[]>(() => {
const now = new Date();
const out: Group[] = [];
let current: Group | null = null;
for (const n of items.value) {
const label = bucketLabel(n.created_at, now);
if (!current || current.label !== label) {
current = { key: label, label, notes: [] };
out.push(current);
}
current.notes.push(n);
}
return out;
});
const empty = computed(() =>
hasRange.value
? { title: "No notes in this range", subtitle: "Try widening the date range, or clear it to see everything." }
: { title: "No notes yet", subtitle: "Capture a thought on the board and it'll appear here, dated." },
);
onMounted(load);
</script>
<template>
<div class="mx-auto w-full max-w-6xl px-4 py-6">
<div class="mb-1 flex flex-wrap items-center justify-between gap-3">
<h1 class="text-lg font-semibold">Timeline</h1>
<div class="flex flex-wrap items-center gap-2 text-sm">
<label class="flex items-center gap-1.5 text-neutral-500 dark:text-neutral-400">
<span>From</span>
<input
v-model="fromDate"
type="date"
:max="toDate || undefined"
aria-label="From date"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm text-neutral-800 outline-none [color-scheme:light] focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:[color-scheme:dark]"
/>
</label>
<label class="flex items-center gap-1.5 text-neutral-500 dark:text-neutral-400">
<span>To</span>
<input
v-model="toDate"
type="date"
:min="fromDate || undefined"
aria-label="To date"
class="rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm text-neutral-800 outline-none [color-scheme:light] focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:bg-neutral-900 dark:text-neutral-100 dark:[color-scheme:dark]"
/>
</label>
<button
v-if="hasRange"
type="button"
class="rounded-md border border-neutral-300 px-2.5 py-1 text-sm hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
@click="clearRange"
>
Clear
</button>
</div>
</div>
<p class="mb-5 text-sm text-neutral-500 dark:text-neutral-400">
Your notes by when you captured them{{ hasRange ? " — filtered to the chosen dates" : "" }}.
</p>
<AsyncState
:loading="loading"
:error="error || undefined"
error-title="Couldn't load the timeline"
@retry="load"
>
<EmptyState v-if="items.length === 0" :title="empty.title" :subtitle="empty.subtitle" />
<div v-else class="flex flex-col gap-8">
<section v-for="group in groups" :key="group.key">
<h2 class="mb-3 text-xs font-semibold uppercase tracking-wide text-neutral-400">{{ group.label }}</h2>
<NoteGrid :notes="group.notes" @open="openEditor" />
</section>
</div>
</AsyncState>
<template v-if="editing">
<NoteEditor :note="editing" @close="closeEditor" @navigate="onNavigate" />
</template>
</div>
</template>