diff --git a/TODO.md b/TODO.md index 610834d..df78e2e 100644 --- a/TODO.md +++ b/TODO.md @@ -180,7 +180,11 @@ Work Log decisions: - Work logs are persisted to SQLite with creation timestamps. - Work logs display the latest seven local calendar days, grouped by day. - Work logs are displayed newest first within each day. -- `j` / `k` and `ArrowDown` / `ArrowUp`: move the selected Work Log entry when the Log section is active. +- `j` / `k` and `ArrowDown` / `ArrowUp`: move the selected Work Log row when the Log section is active. +- `gg` / `G`: move the selected Work Log row to the first or last visible row when the Log section is active. +- Empty `No log` day rows are selectable navigation targets, but cannot be edited with `e`. +- When navigating upward to the first row in a Work Log day group, scrolling keeps that day header visible. +- When the first or last visible Work Log row is selected, the Log list scrolls fully to the top or bottom. - `e`: edit the selected Work Log entry when the Log section is active. - Activating the Log section focuses the Work Log list and selects the latest log when there is no valid current selection. - When a new Work Log is added, selection moves to it only if the latest log was selected; older selected logs stay selected. diff --git a/src/lib/components/KeyboardShortcutsDialog.svelte b/src/lib/components/KeyboardShortcutsDialog.svelte index 52335c1..6c8db23 100644 --- a/src/lib/components/KeyboardShortcutsDialog.svelte +++ b/src/lib/components/KeyboardShortcutsDialog.svelte @@ -116,6 +116,11 @@ ], separator: "or", }, + { + action: "Move to First or Last Log", + keys: [{ value: "gg" }, { value: "G" }], + separator: "or", + }, ], }, ]; @@ -141,6 +146,14 @@ onClose(); } } + + function separatorAfterIndex(shortcut: ShortcutItem) { + if (!shortcut.separator) { + return -1; + } + + return Math.ceil(shortcut.keys.length / 2) - 1; + } - {#if shortcut.separator} - {#each shortcut.keys.slice(0, 2) as key (key.value)} - - {/each} - {shortcut.separator} - {#each shortcut.keys.slice(2) as key (key.value)} - - {/each} - {:else} - {#each shortcut.keys as key (key.value)} - - {/each} - {/if} + {#each shortcut.keys as key, keyIndex (key.value)} + + {#if shortcut.separator && keyIndex === separatorAfterIndex(shortcut)} + + {shortcut.separator} + + {/if} + {/each} {/each} diff --git a/src/lib/components/WorkLogSection.svelte b/src/lib/components/WorkLogSection.svelte index 0eb1377..6691d2d 100644 --- a/src/lib/components/WorkLogSection.svelte +++ b/src/lib/components/WorkLogSection.svelte @@ -12,13 +12,21 @@ import { linkifyWorkLogBody } from "$lib/work-log/linkify"; import { buildRecentWorkLogGroups, + buildWorkLogSelectableItems, moveWorkLogSelection, + selectWorkLogBoundary, + workLogSelectionForDate, + workLogSelectionKey, + workLogSelectionsEqual, + type WorkLogSelection, } from "$lib/work-log/ui"; type WorkLogCommand = | "focusPreferred" | "moveDown" | "moveUp" + | "moveFirst" + | "moveLast" | "editSelected"; type WorkLogCommandRequest = { @@ -54,13 +62,14 @@ let workLogs = $state([]); let workLogError = $state(null); let isLoadingWorkLogs = $state(true); - let selectedWorkLogId = $state(null); + let selectedWorkLogItem = $state(null); let workLogListElement = $state(); let relativeTimeNowMs = $state(Date.now()); let lastCommandRequestId = 0; let unlistenWorkLogCreated: UnlistenFn | undefined; let unlistenWorkLogUpdated: UnlistenFn | undefined; let relativeTimeInterval: ReturnType | undefined; + let keepSelectedDateHeaderVisible = false; const visibleWorkLogGroups = $derived( buildRecentWorkLogGroups( workLogs, @@ -68,6 +77,12 @@ RECENT_WORK_LOG_DAY_COUNT, ), ); + const visibleWorkLogItems = $derived( + buildWorkLogSelectableItems(visibleWorkLogGroups), + ); + const selectedWorkLogKey = $derived( + selectedWorkLogItem ? workLogSelectionKey(selectedWorkLogItem) : null, + ); const lastWorkLog = $derived(workLogs[0] ?? null); const lastLogLabel = $derived( formatLastLogLabel(lastWorkLog, relativeTimeNowMs), @@ -125,6 +140,12 @@ case "moveUp": moveSelection(-1); break; + case "moveFirst": + moveSelectionToBoundary("first"); + break; + case "moveLast": + moveSelectionToBoundary("last"); + break; case "editSelected": editSelectedWorkLog(); break; @@ -132,7 +153,7 @@ }); $effect(() => { - if (!active || selectedWorkLogId === null) { + if (!active || !selectedWorkLogItem) { return; } @@ -147,7 +168,7 @@ workLogs = (await listWorkLogs(oldestVisibleDayStartMs())).sort( compareWorkLogs, ); - ensureSelectedWorkLog(); + ensureSelectedWorkLogItem(); } catch (error) { workLogError = errorMessage(error); } finally { @@ -157,7 +178,7 @@ async function focusList() { onActivate(); - ensureSelectedWorkLog(); + ensureSelectedWorkLogItem(); await tick(); workLogListElement?.focus({ preventScroll: true }); await scrollSelectedWorkLogIntoView(); @@ -185,13 +206,19 @@ } const shouldSelectCreatedWorkLog = - selectedWorkLogId === null || selectedWorkLogId === lastWorkLog?.id; + selectedWorkLogItem === null || + (selectedWorkLogItem.kind === "log" && + selectedWorkLogItem.id === lastWorkLog?.id) || + workLogSelectionsEqual( + selectedWorkLogItem, + workLogSelectionForDate(workLog.createdAtMs), + ); workLogs = [...workLogs, workLog].sort(compareWorkLogs); if (shouldSelectCreatedWorkLog) { - selectedWorkLogId = workLog.id; + selectedWorkLogItem = { kind: "log", id: workLog.id }; } else { - ensureSelectedWorkLog(); + ensureSelectedWorkLogItem(); } relativeTimeNowMs = Date.now(); @@ -207,24 +234,42 @@ existingWorkLog.id === workLog.id ? workLog : existingWorkLog, ) .sort(compareWorkLogs); - ensureSelectedWorkLog(); + ensureSelectedWorkLogItem(); relativeTimeNowMs = Date.now(); } function selectWorkLog(id: number) { onActivate(); - selectedWorkLogId = id; + keepSelectedDateHeaderVisible = false; + selectedWorkLogItem = { kind: "log", id }; + } + + function selectEmptyDay(dateKey: string) { + onActivate(); + keepSelectedDateHeaderVisible = false; + selectedWorkLogItem = { kind: "emptyDay", dateKey }; } function moveSelection(direction: 1 | -1) { - selectedWorkLogId = moveWorkLogSelection( - workLogs, - selectedWorkLogId, + keepSelectedDateHeaderVisible = direction === -1; + selectedWorkLogItem = moveWorkLogSelection( + visibleWorkLogItems, + selectedWorkLogItem, direction, ); } + function moveSelectionToBoundary(boundary: "first" | "last") { + keepSelectedDateHeaderVisible = boundary === "first"; + selectedWorkLogItem = selectWorkLogBoundary(visibleWorkLogItems, boundary); + } + function editSelectedWorkLog() { + if (!selectedWorkLogItem || selectedWorkLogItem.kind !== "log") { + return; + } + + const selectedWorkLogId = selectedWorkLogItem.id; const selectedWorkLog = workLogs.find( (workLog) => workLog.id === selectedWorkLogId, ); @@ -237,14 +282,18 @@ onEditWorkLog(selectedWorkLog); } - function ensureSelectedWorkLog() { - if (workLogs.length === 0) { - selectedWorkLogId = null; + function ensureSelectedWorkLogItem() { + if (visibleWorkLogItems.length === 0) { + selectedWorkLogItem = null; return; } - if (!workLogs.some((workLog) => workLog.id === selectedWorkLogId)) { - selectedWorkLogId = workLogs[0]?.id ?? null; + if ( + !visibleWorkLogItems.some((item) => + workLogSelectionsEqual(item, selectedWorkLogItem), + ) + ) { + selectedWorkLogItem = visibleWorkLogItems[0] ?? null; } } @@ -253,13 +302,48 @@ const selectedWorkLogElement = workLogListElement?.querySelector( - `[data-work-log-id="${selectedWorkLogId}"]`, + `[data-work-log-selection="${selectedWorkLogKey}"]`, + ); + const selectedDateGroupElement = + selectedWorkLogElement?.closest( + "[data-work-log-date-group]", + ); + const firstGroupSelectionElement = + selectedDateGroupElement?.querySelector( + "[data-work-log-selection]", ); + const scrollTarget = + keepSelectedDateHeaderVisible && + selectedWorkLogElement === firstGroupSelectionElement + ? selectedDateGroupElement + : selectedWorkLogElement; - selectedWorkLogElement?.scrollIntoView({ + scrollTarget?.scrollIntoView({ block: "nearest", inline: "nearest", }); + scrollLogListToBoundary(); + keepSelectedDateHeaderVisible = false; + } + + function scrollLogListToBoundary() { + if (!workLogListElement || !selectedWorkLogItem) { + return; + } + + if (workLogSelectionsEqual(selectedWorkLogItem, visibleWorkLogItems[0])) { + workLogListElement.scrollTop = 0; + return; + } + + if ( + workLogSelectionsEqual( + selectedWorkLogItem, + visibleWorkLogItems.at(-1) ?? null, + ) + ) { + workLogListElement.scrollTop = workLogListElement.scrollHeight; + } } function oldestVisibleDayStartMs() { @@ -342,6 +426,21 @@ function isTauriRuntime() { return "__TAURI_INTERNALS__" in window; } + + function emptyDaySelectionKey(dateKey: string) { + return workLogSelectionKey({ kind: "emptyDay", dateKey }); + } + + function isEmptyDaySelected(dateKey: string) { + return ( + selectedWorkLogItem?.kind === "emptyDay" && + selectedWorkLogItem.dateKey === dateKey + ); + } + + function isWorkLogSelected(id: number) { + return selectedWorkLogItem?.kind === "log" && selectedWorkLogItem.id === id; + }
Loading logs... {:else} {#each visibleWorkLogGroups as group (group.dateKey)} -
  • +
  • {group.label}

    {#if group.logs.length === 0} -

    No log

    +

    + + No log +

    {:else}
      {#each group.logs as log (log.id)}