From bbba39c71b6c6501cf58103b3351629acdca667b Mon Sep 17 00:00:00 2001 From: okaryo Date: Tue, 2 Jun 2026 23:57:36 +0900 Subject: [PATCH 1/3] fix: add keyboard shortcuts for moving to the first and last work log entries --- TODO.md | 1 + .../components/KeyboardShortcutsDialog.svelte | 50 +++++++++---------- src/lib/components/WorkLogSection.svelte | 13 +++++ src/lib/keyboard.test.ts | 23 +++++++++ src/lib/keyboard.ts | 36 +++++++++++++ src/lib/work-log/ui.test.ts | 15 ++++++ src/lib/work-log/ui.ts | 13 +++++ src/routes/+page.svelte | 39 +++++++++++++-- 8 files changed, 161 insertions(+), 29 deletions(-) diff --git a/TODO.md b/TODO.md index 610834d..73b31ed 100644 --- a/TODO.md +++ b/TODO.md @@ -181,6 +181,7 @@ Work Log decisions: - 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. +- `gg` / `G`: move the selected Work Log entry to the first or last visible log when the Log section is active. - `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..8d7bb1a 100644 --- a/src/lib/components/WorkLogSection.svelte +++ b/src/lib/components/WorkLogSection.svelte @@ -13,12 +13,15 @@ import { buildRecentWorkLogGroups, moveWorkLogSelection, + selectWorkLogBoundary, } from "$lib/work-log/ui"; type WorkLogCommand = | "focusPreferred" | "moveDown" | "moveUp" + | "moveFirst" + | "moveLast" | "editSelected"; type WorkLogCommandRequest = { @@ -125,6 +128,12 @@ case "moveUp": moveSelection(-1); break; + case "moveFirst": + moveSelectionToBoundary("first"); + break; + case "moveLast": + moveSelectionToBoundary("last"); + break; case "editSelected": editSelectedWorkLog(); break; @@ -224,6 +233,10 @@ ); } + function moveSelectionToBoundary(boundary: "first" | "last") { + selectedWorkLogId = selectWorkLogBoundary(workLogs, boundary); + } + function editSelectedWorkLog() { const selectedWorkLog = workLogs.find( (workLog) => workLog.id === selectedWorkLogId, diff --git a/src/lib/keyboard.test.ts b/src/lib/keyboard.test.ts index 87fa1bd..1fc7112 100644 --- a/src/lib/keyboard.test.ts +++ b/src/lib/keyboard.test.ts @@ -9,6 +9,7 @@ import { todoCommandFromKeydown, updateShortcutRequested, workLogCommandFromKeydown, + workLogKeydownAction, type KeyboardShortcutEvent, } from "$lib/keyboard"; @@ -224,6 +225,7 @@ describe("workLogCommandFromKeydown", () => { [key({ key: "ArrowDown" }), "moveDown"], [key({ key: "k" }), "moveUp"], [key({ key: "ArrowUp" }), "moveUp"], + [key({ key: "G", shiftKey: true }), "moveLast"], [key({ key: "e" }), "editSelected"], ] as const)("maps work log keys to commands", (event, command) => { expect(workLogCommandFromKeydown(event)).toBe(command); @@ -237,4 +239,25 @@ describe("workLogCommandFromKeydown", () => { null, ); }); + + it("maps gg as a key sequence for moving to the first log", () => { + expect(workLogKeydownAction(key({ key: "g" }), null)).toEqual({ + command: null, + handled: true, + nextSequence: "goToFirst", + }); + expect(workLogKeydownAction(key({ key: "g" }), "goToFirst")).toEqual({ + command: "moveFirst", + handled: true, + nextSequence: null, + }); + }); + + it("resets the gg sequence when another key is pressed", () => { + expect(workLogKeydownAction(key({ key: "x" }), "goToFirst")).toEqual({ + command: null, + handled: false, + nextSequence: null, + }); + }); }); diff --git a/src/lib/keyboard.ts b/src/lib/keyboard.ts index 378e162..20efbe3 100644 --- a/src/lib/keyboard.ts +++ b/src/lib/keyboard.ts @@ -19,7 +19,16 @@ export type WorkLogCommand = | "focusPreferred" | "moveDown" | "moveUp" + | "moveFirst" + | "moveLast" | "editSelected"; +export type WorkLogKeySequence = "goToFirst"; + +export type WorkLogKeydownAction = { + command: WorkLogCommand | null; + handled: boolean; + nextSequence: WorkLogKeySequence | null; +}; export type KeyboardShortcutEvent = { key: string; @@ -124,6 +133,8 @@ export function workLogCommandFromKeydown( event: KeyboardShortcutEvent, ): WorkLogCommand | null { switch (event.key) { + case "G": + return isShiftOnlyKey(event) ? "moveLast" : null; case "j": case "ArrowDown": return "moveDown"; @@ -137,6 +148,27 @@ export function workLogCommandFromKeydown( } } +export function workLogKeydownAction( + event: KeyboardShortcutEvent, + pendingSequence: WorkLogKeySequence | null, +): WorkLogKeydownAction { + if (event.key === "g" && isPlainKey(event)) { + return { + command: pendingSequence === "goToFirst" ? "moveFirst" : null, + handled: true, + nextSequence: pendingSequence === "goToFirst" ? null : "goToFirst", + }; + } + + const command = workLogCommandFromKeydown(event); + + return { + command, + handled: command !== null, + nextSequence: null, + }; +} + export function todoCommandFromKeydown( event: KeyboardShortcutEvent, ): TodoCommand | null { @@ -173,3 +205,7 @@ export function todoCommandFromKeydown( function isPlainKey(event: KeyboardShortcutEvent) { return !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey; } + +function isShiftOnlyKey(event: KeyboardShortcutEvent) { + return !event.metaKey && !event.ctrlKey && !event.altKey && event.shiftKey; +} diff --git a/src/lib/work-log/ui.test.ts b/src/lib/work-log/ui.test.ts index cab3348..01f8d93 100644 --- a/src/lib/work-log/ui.test.ts +++ b/src/lib/work-log/ui.test.ts @@ -3,6 +3,7 @@ import type { WorkLog } from "$lib/api/workLogs"; import { buildRecentWorkLogGroups, moveWorkLogSelection, + selectWorkLogBoundary, } from "$lib/work-log/ui"; function workLog(overrides: Partial & Pick): WorkLog { @@ -90,3 +91,17 @@ describe("moveWorkLogSelection", () => { expect(moveWorkLogSelection([], 1, 1)).toBeNull(); }); }); + +describe("selectWorkLogBoundary", () => { + const logs = [workLog({ id: 1 }), workLog({ id: 2 }), workLog({ id: 3 })]; + + it("selects the first or last log", () => { + expect(selectWorkLogBoundary(logs, "first")).toBe(1); + expect(selectWorkLogBoundary(logs, "last")).toBe(3); + }); + + it("clears selection for an empty list", () => { + expect(selectWorkLogBoundary([], "first")).toBeNull(); + expect(selectWorkLogBoundary([], "last")).toBeNull(); + }); +}); diff --git a/src/lib/work-log/ui.ts b/src/lib/work-log/ui.ts index a42d243..3442cf5 100644 --- a/src/lib/work-log/ui.ts +++ b/src/lib/work-log/ui.ts @@ -60,6 +60,19 @@ export function moveWorkLogSelection( return logs[nextIndex]?.id ?? null; } +export function selectWorkLogBoundary( + logs: WorkLog[], + boundary: "first" | "last", +): number | null { + if (logs.length === 0) { + return null; + } + + return boundary === "first" + ? (logs[0]?.id ?? null) + : (logs.at(-1)?.id ?? null); +} + function localDateKey(date: Date) { const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 877cb92..4761126 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -36,11 +36,12 @@ settingsShortcutRequested, todoCommandFromKeydown, updateShortcutRequested, - workLogCommandFromKeydown, + workLogKeydownAction, type PomodoroCommand, type SectionId, type TodoCommand, type WorkLogCommand, + type WorkLogKeySequence, } from "$lib/keyboard"; import { isCurrentNotificationPermissionGranted, @@ -66,6 +67,7 @@ { id: "log", title: "Log", shortcut: "⌘3" }, ]; const UPDATE_CHECK_COOLDOWN_MS = 24 * 60 * 60 * 1000; + const WORK_LOG_KEY_SEQUENCE_TIMEOUT_MS = 700; let activeSection = $state("log"); let pomodoroCommandRequest = $state<{ id: number; @@ -94,6 +96,8 @@ let lastUpdateCheckAttemptAt = 0; let settingsDialogOpen = $state(false); let keyboardShortcutsDialogOpen = $state(false); + let workLogKeySequence = $state(null); + let workLogKeySequenceTimeout: number | null = null; let quickEntryGlobalShortcut = $state(DEFAULT_QUICK_ENTRY_GLOBAL_SHORTCUT); let pomodoroFocusDurationMinutes = $state( DEFAULT_POMODORO_FOCUS_DURATION_MINUTES, @@ -153,6 +157,7 @@ disposed = true; window.clearInterval(dateInterval); window.clearInterval(updateInterval); + clearWorkLogKeySequenceTimeout(); unlistenFocusChange?.(); }; }); @@ -240,11 +245,16 @@ } function handleWorkLogSectionKeydown(event: KeyboardEvent) { - const command = workLogCommandFromKeydown(event); + const action = workLogKeydownAction(event, workLogKeySequence); - if (command) { + setWorkLogKeySequence(action.nextSequence); + + if (action.handled) { event.preventDefault(); - requestWorkLogCommand(command); + } + + if (action.command) { + requestWorkLogCommand(action.command); } } @@ -324,6 +334,27 @@ }; } + function setWorkLogKeySequence(sequence: WorkLogKeySequence | null) { + workLogKeySequence = sequence; + clearWorkLogKeySequenceTimeout(); + + if (sequence) { + workLogKeySequenceTimeout = window.setTimeout(() => { + workLogKeySequence = null; + workLogKeySequenceTimeout = null; + }, WORK_LOG_KEY_SEQUENCE_TIMEOUT_MS); + } + } + + function clearWorkLogKeySequenceTimeout() { + if (workLogKeySequenceTimeout === null) { + return; + } + + window.clearTimeout(workLogKeySequenceTimeout); + workLogKeySequenceTimeout = null; + } + function requestWorkLogEdit(workLog: WorkLog) { workLogEditRequest = { id: ++workLogEditRequestId, From f57323fc418ecf0c0ff08b07cd95a991ef9b9a71 Mon Sep 17 00:00:00 2001 From: okaryo Date: Wed, 3 Jun 2026 00:05:39 +0900 Subject: [PATCH 2/3] fix: enhance work log navigation and selection handling --- TODO.md | 5 +- src/lib/components/WorkLogSection.svelte | 114 ++++++++++++++++++----- src/lib/work-log/ui.test.ts | 64 ++++++++++--- src/lib/work-log/ui.ts | 76 ++++++++++++--- 4 files changed, 208 insertions(+), 51 deletions(-) diff --git a/TODO.md b/TODO.md index 73b31ed..6cda266 100644 --- a/TODO.md +++ b/TODO.md @@ -180,8 +180,9 @@ 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. -- `gg` / `G`: move the selected Work Log entry to the first or last visible log 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`. - `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/WorkLogSection.svelte b/src/lib/components/WorkLogSection.svelte index 8d7bb1a..d082d54 100644 --- a/src/lib/components/WorkLogSection.svelte +++ b/src/lib/components/WorkLogSection.svelte @@ -12,8 +12,13 @@ import { linkifyWorkLogBody } from "$lib/work-log/linkify"; import { buildRecentWorkLogGroups, + buildWorkLogSelectableItems, moveWorkLogSelection, selectWorkLogBoundary, + workLogSelectionForDate, + workLogSelectionKey, + workLogSelectionsEqual, + type WorkLogSelection, } from "$lib/work-log/ui"; type WorkLogCommand = @@ -57,7 +62,7 @@ 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; @@ -71,6 +76,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), @@ -141,7 +152,7 @@ }); $effect(() => { - if (!active || selectedWorkLogId === null) { + if (!active || !selectedWorkLogItem) { return; } @@ -156,7 +167,7 @@ workLogs = (await listWorkLogs(oldestVisibleDayStartMs())).sort( compareWorkLogs, ); - ensureSelectedWorkLog(); + ensureSelectedWorkLogItem(); } catch (error) { workLogError = errorMessage(error); } finally { @@ -166,7 +177,7 @@ async function focusList() { onActivate(); - ensureSelectedWorkLog(); + ensureSelectedWorkLogItem(); await tick(); workLogListElement?.focus({ preventScroll: true }); await scrollSelectedWorkLogIntoView(); @@ -194,13 +205,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(); @@ -216,28 +233,38 @@ existingWorkLog.id === workLog.id ? workLog : existingWorkLog, ) .sort(compareWorkLogs); - ensureSelectedWorkLog(); + ensureSelectedWorkLogItem(); relativeTimeNowMs = Date.now(); } function selectWorkLog(id: number) { onActivate(); - selectedWorkLogId = id; + selectedWorkLogItem = { kind: "log", id }; + } + + function selectEmptyDay(dateKey: string) { + onActivate(); + selectedWorkLogItem = { kind: "emptyDay", dateKey }; } function moveSelection(direction: 1 | -1) { - selectedWorkLogId = moveWorkLogSelection( - workLogs, - selectedWorkLogId, + selectedWorkLogItem = moveWorkLogSelection( + visibleWorkLogItems, + selectedWorkLogItem, direction, ); } function moveSelectionToBoundary(boundary: "first" | "last") { - selectedWorkLogId = selectWorkLogBoundary(workLogs, boundary); + selectedWorkLogItem = selectWorkLogBoundary(visibleWorkLogItems, boundary); } function editSelectedWorkLog() { + if (!selectedWorkLogItem || selectedWorkLogItem.kind !== "log") { + return; + } + + const selectedWorkLogId = selectedWorkLogItem.id; const selectedWorkLog = workLogs.find( (workLog) => workLog.id === selectedWorkLogId, ); @@ -250,14 +277,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; } } @@ -266,7 +297,7 @@ const selectedWorkLogElement = workLogListElement?.querySelector( - `[data-work-log-id="${selectedWorkLogId}"]`, + `[data-work-log-selection="${selectedWorkLogKey}"]`, ); selectedWorkLogElement?.scrollIntoView({ @@ -355,6 +386,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; + }

{group.label}

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

No log

+

+ + No log +

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