Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 57 additions & 82 deletions src/lib/components/WorkLogSection.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
import KeyboardKey from "$lib/components/KeyboardKey.svelte";
import {
addLocalDays,
formatWorkLogDateLabel,
formatWorkLogTime,
startOfLocalDay,
} from "$lib/dateFormat";
import { linkifyWorkLogBody } from "$lib/work-log/linkify";
import { moveWorkLogSelection } from "$lib/work-log/ui";
import {
buildRecentWorkLogGroups,
moveWorkLogSelection,
} from "$lib/work-log/ui";

type WorkLogCommand =
| "focusPreferred"
Expand All @@ -33,12 +35,6 @@
onEditWorkLog: (workLog: WorkLog) => void;
};

type WorkLogGroup = {
dateKey: string;
label: string;
logs: WorkLog[];
};

type LastLogTone = "neutral" | "soon" | "late" | "stale";

const RECENT_WORK_LOG_DAY_COUNT = 7;
Expand All @@ -65,7 +61,13 @@
let unlistenWorkLogCreated: UnlistenFn | undefined;
let unlistenWorkLogUpdated: UnlistenFn | undefined;
let relativeTimeInterval: ReturnType<typeof setInterval> | undefined;
const visibleWorkLogGroups = $derived(groupVisibleWorkLogs(workLogs));
const visibleWorkLogGroups = $derived(
buildRecentWorkLogGroups(
workLogs,
relativeTimeNowMs,
RECENT_WORK_LOG_DAY_COUNT,
),
);
Comment on lines +64 to +70

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep keyboard selection within visible recent logs

Because the rendered list is now derived from the moving seven-day window while workLogs itself is never pruned or reloaded as relativeTimeNowMs advances, leaving the app open across midnight can drop the previously selected oldest-day log from these groups while selectedWorkLogId still points at it. In that state the UI shows no selected row, and keyboard edit/navigation can continue to target an invisible log from outside the displayed week; the selection should be reconciled against the visible groups/logs when this derived window changes.

Useful? React with 👍 / 👎.

const lastWorkLog = $derived(workLogs[0] ?? null);
const lastLogLabel = $derived(
formatLastLogLabel(lastWorkLog, relativeTimeNowMs),
Expand Down Expand Up @@ -260,48 +262,13 @@
});
}

function groupVisibleWorkLogs(logs: WorkLog[]): WorkLogGroup[] {
const todayStartMs = startOfLocalDay(Date.now());
const groups: WorkLogGroup[] = [];
const groupByDateKey: Record<string, WorkLogGroup> = {};

for (const log of logs) {
const date = new Date(log.createdAtMs);
const dateKey = localDateKey(date);
const group = groupByDateKey[dateKey];

if (group) {
group.logs.push(log);
continue;
}

const nextGroup = {
dateKey,
label: formatWorkLogDateLabel(date, todayStartMs),
logs: [log],
};

groupByDateKey[dateKey] = nextGroup;
groups.push(nextGroup);
}

return groups;
}

function oldestVisibleDayStartMs() {
return addLocalDays(
startOfLocalDay(Date.now()),
-(RECENT_WORK_LOG_DAY_COUNT - 1),
);
}

function localDateKey(date: Date) {
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");

return `${date.getFullYear()}-${month}-${day}`;
}

function formatLastLogLabel(workLog: WorkLog | null, nowMs: number) {
if (!workLog) {
return "Last log: none";
Expand Down Expand Up @@ -410,49 +377,51 @@
>
{#if isLoadingWorkLogs}
<li class="log-empty">Loading logs...</li>
{:else if workLogs.length === 0 || visibleWorkLogGroups.length === 0}
<li class="log-empty">No recent logs.</li>
{:else}
{#each visibleWorkLogGroups as group (group.dateKey)}
<li class="log-date-group">
<h3>{group.label}</h3>
<ol class="log-day-list" aria-label={`${group.label} logs`}>
{#each group.logs as log (log.id)}
<li
data-work-log-id={log.id}
class="log-item"
class:log-selected={active && selectedWorkLogId === log.id}
>
<button
class="log-row-button"
type="button"
aria-label={`Select log from ${formatWorkLogTime(log.createdAtMs)}`}
onclick={() => selectWorkLog(log.id)}
></button>
<time>{formatWorkLogTime(log.createdAtMs)}</time>
<span>
{#each linkifyWorkLogBody(log.body) as part, partIndex (`${part.kind}-${partIndex}`)}
{#if part.kind === "url"}
<!-- External log URLs are opened through Tauri opener, not SvelteKit navigation. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={part.value}
target="_blank"
rel="noreferrer"
onclick={(event) =>
openExternalUrl(event, part.value)}
>
{#if group.logs.length === 0}
<p class="log-empty-day">No log</p>
{:else}
<ol class="log-day-list" aria-label={`${group.label} logs`}>
{#each group.logs as log (log.id)}
<li
data-work-log-id={log.id}
class="log-item"
class:log-selected={active && selectedWorkLogId === log.id}
>
<button
class="log-row-button"
type="button"
aria-label={`Select log from ${formatWorkLogTime(log.createdAtMs)}`}
onclick={() => selectWorkLog(log.id)}
></button>
<time>{formatWorkLogTime(log.createdAtMs)}</time>
<span>
{#each linkifyWorkLogBody(log.body) as part, partIndex (`${part.kind}-${partIndex}`)}
{#if part.kind === "url"}
<!-- External log URLs are opened through Tauri opener, not SvelteKit navigation. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a
href={part.value}
target="_blank"
rel="noreferrer"
onclick={(event) =>
openExternalUrl(event, part.value)}
>
{part.value}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{:else}
{part.value}
</a>
<!-- eslint-enable svelte/no-navigation-without-resolve -->
{:else}
{part.value}
{/if}
{/each}
</span>
</li>
{/each}
</ol>
{/if}
{/each}
</span>
</li>
{/each}
</ol>
{/if}
</li>
{/each}
{/if}
Expand Down Expand Up @@ -710,6 +679,12 @@
color: #858d9a;
}

.log-empty-day {
margin: 0;
color: #6f7784;
font-size: 0.86rem;
}

.log-item time {
color: #a8b0be;
font-variant-numeric: tabular-nums;
Expand Down
64 changes: 63 additions & 1 deletion src/lib/work-log/ui.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, it } from "vitest";
import type { WorkLog } from "$lib/api/workLogs";
import { moveWorkLogSelection } from "$lib/work-log/ui";
import {
buildRecentWorkLogGroups,
moveWorkLogSelection,
} from "$lib/work-log/ui";

function workLog(overrides: Partial<WorkLog> & Pick<WorkLog, "id">): WorkLog {
return {
Expand All @@ -10,6 +13,65 @@ function workLog(overrides: Partial<WorkLog> & Pick<WorkLog, "id">): WorkLog {
};
}

describe("buildRecentWorkLogGroups", () => {
it("builds one group for each of the latest seven local calendar days", () => {
const today = new Date(2026, 4, 22, 12).getTime();
const logs = [
workLog({
id: 1,
body: "today",
createdAtMs: new Date(2026, 4, 22, 9).getTime(),
}),
workLog({
id: 2,
body: "four days ago",
createdAtMs: new Date(2026, 4, 18, 18).getTime(),
}),
];

const groups = buildRecentWorkLogGroups(logs, today, 7);

expect(groups.map((group) => group.label)).toEqual([
"Today",
"Yesterday",
"May 20",
"May 19",
"May 18",
"May 17",
"May 16",
]);
expect(groups.map((group) => group.logs.map((log) => log.id))).toEqual([
[1],
[],
[],
[],
[2],
[],
[],
]);
});

it("does not include logs outside the recent calendar window", () => {
const today = new Date(2026, 4, 22, 12).getTime();
const logs = [
workLog({
id: 1,
createdAtMs: new Date(2026, 4, 16, 9).getTime(),
}),
workLog({
id: 2,
createdAtMs: new Date(2026, 3, 22, 9).getTime(),
}),
];

const groups = buildRecentWorkLogGroups(logs, today, 7);

expect(groups.flatMap((group) => group.logs.map((log) => log.id))).toEqual([
1,
]);
});
});

describe("moveWorkLogSelection", () => {
const logs = [workLog({ id: 1 }), workLog({ id: 2 }), workLog({ id: 3 })];

Expand Down
47 changes: 47 additions & 0 deletions src/lib/work-log/ui.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,44 @@
import type { WorkLog } from "$lib/api/workLogs";
import {
addLocalDays,
formatWorkLogDateLabel,
startOfLocalDay,
} from "$lib/dateFormat";

export type WorkLogGroup = {
dateKey: string;
label: string;
logs: WorkLog[];
};

export function buildRecentWorkLogGroups(
logs: WorkLog[],
todayTimestampMs: number,
dayCount: number,
): WorkLogGroup[] {
const todayStartMs = startOfLocalDay(todayTimestampMs);
const groups: WorkLogGroup[] = [];
const groupByDateKey = new Map<string, WorkLogGroup>();

for (let dayOffset = 0; dayOffset > -dayCount; dayOffset -= 1) {
const dayStartMs = addLocalDays(todayStartMs, dayOffset);
const date = new Date(dayStartMs);
const group = {
dateKey: localDateKey(date),
label: formatWorkLogDateLabel(date, todayStartMs),
logs: [],
};

groups.push(group);
groupByDateKey.set(group.dateKey, group);
}

for (const log of logs) {
groupByDateKey.get(localDateKey(new Date(log.createdAtMs)))?.logs.push(log);
}

return groups;
}

export function moveWorkLogSelection(
logs: WorkLog[],
Expand All @@ -19,3 +59,10 @@ export function moveWorkLogSelection(

return logs[nextIndex]?.id ?? null;
}

function localDateKey(date: Date) {
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");

return `${date.getFullYear()}-${month}-${day}`;
}