fix(logbook): bound background refresh concurrency (#106397)

* fix(logbook): bound background refresh concurrency

* fix(logbook): unify refresh ownership

* style(logbook): format type import

* test(logbook): use typed rejection error

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Alix-007
2026-07-14 22:27:20 -07:00
committed by GitHub
co-authored by Peter Steinberger
parent b9a837ec8d
commit ebb1e83b64
4 changed files with 624 additions and 97 deletions
@@ -4,9 +4,12 @@ import {
askLogbook,
configureLogbookPolling,
getLogbookState,
loadLogbook,
loadLogbookStandup,
runLogbookAnalysisNow,
stopLogbookPolling,
} from "./logbook-controller.ts";
import type { LogbookStatusPayload } from "./logbook-types.ts";
function clientWithRequest(
request: (method: string, params: unknown) => Promise<unknown>,
@@ -22,6 +25,42 @@ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
return { promise, resolve };
}
function statusFor(day: string): LogbookStatusPayload {
return {
captureEnabled: true,
capturePaused: false,
captureIntervalSeconds: 30,
analysisIntervalMinutes: 15,
retentionDays: 30,
pendingFrames: 0,
analysisRunning: false,
visionModelSource: "missing",
today: day,
todayCards: 1,
timeZone: "UTC",
};
}
function timelineFor(day: string, title: string) {
return {
day,
cards: [
{
id: 1,
day,
startMs: 1,
endMs: 2,
title,
summary: "Summary",
detail: "",
category: "Coding",
distractions: [],
},
],
stats: { trackedMs: 1, distractionMs: 0, categories: [], apps: [] },
};
}
describe("Logbook controller", () => {
const hosts: object[] = [];
@@ -48,6 +87,338 @@ describe("Logbook controller", () => {
expect(secondRequest).toHaveBeenCalled();
});
it("lets an in-flight load settle after polling stops", async () => {
const host = {};
hosts.push(host);
const state = getLogbookState(host);
state.day = "2026-07-04";
state.dayPinned = true;
const status = deferred<unknown>();
const days = deferred<unknown>();
const timeline = deferred<unknown>();
const responses = new Map([
["logbook.status", status],
["logbook.days", days],
["logbook.timeline", timeline],
]);
const request = loadLogbook(
state,
clientWithRequest(
(method) =>
responses.get(method)?.promise ?? Promise.reject(new Error(`Unexpected ${method}`)),
),
);
stopLogbookPolling(host);
status.resolve(statusFor("2026-07-04"));
days.resolve({ days: [] });
timeline.resolve(timelineFor("2026-07-04", "Detached host"));
await request;
expect(state.timeline?.cards[0]?.title).toBe("Detached host");
expect(state.pollTimer).toBeNull();
});
it("does not overlap silent poll refreshes and resumes after settlement", async () => {
vi.useFakeTimers();
const host = {};
hosts.push(host);
const state = getLogbookState(host);
state.day = "2026-07-04";
state.dayPinned = true;
const status = deferred<unknown>();
const days = deferred<unknown>();
const timeline = deferred<unknown>();
const firstBatch = new Map([
["logbook.status", status],
["logbook.days", days],
["logbook.timeline", timeline],
]);
const request = vi.fn((method: string) => {
const pending = firstBatch.get(method);
if (pending) {
firstBatch.delete(method);
return pending.promise;
}
if (method === "logbook.status") {
return Promise.resolve(statusFor("2026-07-04"));
}
if (method === "logbook.days") {
return Promise.resolve({ days: [] });
}
return Promise.resolve(timelineFor("2026-07-04", "Resumed poll"));
});
configureLogbookPolling(state, clientWithRequest(request), true);
await vi.advanceTimersByTimeAsync(30_000);
expect(request).toHaveBeenCalledTimes(3);
await vi.advanceTimersByTimeAsync(90_000);
expect(request).toHaveBeenCalledTimes(3);
status.resolve(statusFor("2026-07-04"));
days.resolve({ days: [] });
timeline.resolve(timelineFor("2026-07-04", "First poll"));
await vi.advanceTimersByTimeAsync(0);
expect(state.timeline?.cards[0]?.title).toBe("First poll");
await vi.advanceTimersByTimeAsync(30_000);
expect(request).toHaveBeenCalledTimes(6);
expect(request.mock.calls.filter(([method]) => method === "logbook.status")).toHaveLength(2);
expect(request.mock.calls.filter(([method]) => method === "logbook.days")).toHaveLength(2);
expect(request.mock.calls.filter(([method]) => method === "logbook.timeline")).toHaveLength(2);
expect(state.timeline?.cards[0]?.title).toBe("Resumed poll");
});
it("retires a pending poll refresh when the client changes", async () => {
vi.useFakeTimers();
const host = {};
hosts.push(host);
const state = getLogbookState(host);
state.day = "2026-07-04";
state.dayPinned = true;
const oldStatus = deferred<unknown>();
const oldDays = deferred<unknown>();
const oldTimeline = deferred<unknown>();
const oldResponses = new Map([
["logbook.status", oldStatus],
["logbook.days", oldDays],
["logbook.timeline", oldTimeline],
]);
const oldRequest = vi.fn((method: string) => {
const response = oldResponses.get(method);
if (!response) {
throw new Error(`Unexpected old-client request: ${method}`);
}
return response.promise;
});
const newRequest = vi.fn(async (method: string) => {
if (method === "logbook.status") {
return statusFor("2026-07-04");
}
if (method === "logbook.days") {
return { days: [] };
}
return timelineFor("2026-07-04", "New client");
});
configureLogbookPolling(state, clientWithRequest(oldRequest), true);
await vi.advanceTimersByTimeAsync(30_000);
expect(oldRequest).toHaveBeenCalledTimes(3);
configureLogbookPolling(state, clientWithRequest(newRequest), true);
await vi.advanceTimersByTimeAsync(30_000);
expect(newRequest).toHaveBeenCalledTimes(3);
expect(state.timeline?.cards[0]?.title).toBe("New client");
oldStatus.resolve(statusFor("2026-07-04"));
oldDays.resolve({ days: [] });
oldTimeline.resolve(timelineFor("2026-07-04", "Retired client"));
await vi.advanceTimersByTimeAsync(0);
expect(state.timeline?.cards[0]?.title).toBe("New client");
});
it("shares the background refresh owner with analysis completion", async () => {
vi.useFakeTimers();
const host = {};
hosts.push(host);
const state = getLogbookState(host);
state.day = "2026-07-04";
state.dayPinned = true;
const status = deferred<unknown>();
const days = deferred<unknown>();
const timeline = deferred<unknown>();
const pending = new Map([
["logbook.status", status],
["logbook.days", days],
["logbook.timeline", timeline],
]);
const request = vi.fn((method: string) => {
if (method === "logbook.analyze.now") {
return Promise.resolve({ started: true });
}
const response = pending.get(method);
if (response) {
pending.delete(method);
return response.promise;
}
if (method === "logbook.status") {
return Promise.resolve(statusFor("2026-07-04"));
}
if (method === "logbook.days") {
return Promise.resolve({ days: [] });
}
return Promise.resolve(timelineFor("2026-07-04", "Resumed poll"));
});
const client = clientWithRequest(request);
configureLogbookPolling(state, client, true);
await runLogbookAnalysisNow(state, client);
expect(request).toHaveBeenCalledTimes(4);
await vi.advanceTimersByTimeAsync(60_000);
expect(request).toHaveBeenCalledTimes(4);
status.resolve(statusFor("2026-07-04"));
days.resolve({ days: [] });
timeline.resolve(timelineFor("2026-07-04", "Analysis refresh"));
await vi.advanceTimersByTimeAsync(0);
expect(state.timeline?.cards[0]?.title).toBe("Analysis refresh");
await vi.advanceTimersByTimeAsync(30_000);
expect(request).toHaveBeenCalledTimes(7);
expect(state.timeline?.cards[0]?.title).toBe("Resumed poll");
});
it("queues an analysis refresh behind an in-flight poll", async () => {
vi.useFakeTimers();
const host = {};
hosts.push(host);
const state = getLogbookState(host);
state.day = "2026-07-04";
state.dayPinned = true;
const status = deferred<unknown>();
const days = deferred<unknown>();
const timeline = deferred<unknown>();
const pending = new Map([
["logbook.status", status],
["logbook.days", days],
["logbook.timeline", timeline],
]);
const request = vi.fn((method: string) => {
if (method === "logbook.analyze.now") {
return Promise.resolve({ started: true });
}
const response = pending.get(method);
if (response) {
pending.delete(method);
return response.promise;
}
if (method === "logbook.status") {
return Promise.resolve(statusFor("2026-07-04"));
}
if (method === "logbook.days") {
return Promise.resolve({ days: [] });
}
return Promise.resolve(timelineFor("2026-07-04", "Post-analysis refresh"));
});
const client = clientWithRequest(request);
configureLogbookPolling(state, client, true);
await vi.advanceTimersByTimeAsync(30_000);
expect(request).toHaveBeenCalledTimes(3);
await runLogbookAnalysisNow(state, client);
expect(request).toHaveBeenCalledTimes(4);
status.resolve(statusFor("2026-07-04"));
days.resolve({ days: [] });
timeline.resolve(timelineFor("2026-07-04", "Pre-analysis refresh"));
await vi.advanceTimersByTimeAsync(0);
expect(request).toHaveBeenCalledTimes(7);
expect(state.timeline?.cards[0]?.title).toBe("Post-analysis refresh");
});
it("does not let a retired analysis action affect the new client", async () => {
vi.useFakeTimers();
const host = {};
hosts.push(host);
const state = getLogbookState(host);
state.day = "2026-07-04";
state.dayPinned = true;
const oldAnalysis = deferred<unknown>();
const newAnalysis = deferred<unknown>();
const oldRequest = vi.fn((method: string) => {
if (method !== "logbook.analyze.now") {
throw new Error(`Unexpected retired-client request: ${method}`);
}
return oldAnalysis.promise;
});
const newRequest = vi.fn(async (method: string) => {
if (method === "logbook.analyze.now") {
return await newAnalysis.promise;
}
if (method === "logbook.status") {
return statusFor("2026-07-04");
}
if (method === "logbook.days") {
return { days: [] };
}
return timelineFor("2026-07-04", "New client");
});
const oldClient = clientWithRequest(oldRequest);
const newClient = clientWithRequest(newRequest);
configureLogbookPolling(state, oldClient, true);
const oldAction = runLogbookAnalysisNow(state, oldClient);
configureLogbookPolling(state, newClient, true);
const newAction = runLogbookAnalysisNow(state, newClient);
expect(state.actionPending).toBe(true);
oldAnalysis.resolve({ started: false, reason: "Retired analysis error" });
await oldAction;
expect(oldRequest).toHaveBeenCalledTimes(1);
expect(state.actionPending).toBe(true);
expect(state.error).not.toBe("Retired analysis error");
newAnalysis.resolve({ started: true });
await newAction;
await vi.advanceTimersByTimeAsync(0);
expect(state.actionPending).toBe(false);
expect(newRequest).toHaveBeenCalledTimes(4);
expect(state.timeline?.cards[0]?.title).toBe("New client");
});
it("does not let an older day load overwrite a newer selection", async () => {
const host = {};
hosts.push(host);
const state = getLogbookState(host);
const oldStatus = deferred<unknown>();
const oldDays = deferred<unknown>();
const oldTimeline = deferred<unknown>();
const oldResponses = new Map([
["logbook.status", oldStatus],
["logbook.days", oldDays],
["logbook.timeline", oldTimeline],
]);
const oldRequest = vi.fn((method: string) => {
const response = oldResponses.get(method);
if (!response) {
throw new Error(`Unexpected request: ${method}`);
}
return response.promise;
});
const newerRequest = vi.fn(async (method: string) => {
if (method === "logbook.status") {
return statusFor("2026-07-05");
}
if (method === "logbook.days") {
return { days: [{ day: "2026-07-05", cards: 1, firstMs: 1, lastMs: 2 }] };
}
return timelineFor("2026-07-05", "New day");
});
const olderLoad = loadLogbook(state, clientWithRequest(oldRequest), { day: "2026-07-04" });
expect(oldRequest).toHaveBeenCalledWith("logbook.timeline", { day: "2026-07-04" });
await loadLogbook(state, clientWithRequest(newerRequest), { day: "2026-07-05" });
expect(newerRequest).toHaveBeenCalledWith("logbook.timeline", { day: "2026-07-05" });
expect(state.timeline?.cards[0]?.title).toBe("New day");
oldStatus.resolve(statusFor("2026-07-04"));
oldDays.resolve({ days: [{ day: "2026-07-04", cards: 1, firstMs: 1, lastMs: 2 }] });
oldTimeline.resolve(timelineFor("2026-07-04", "Old day"));
await olderLoad;
expect(state.day).toBe("2026-07-05");
expect(state.status?.today).toBe("2026-07-05");
expect(state.days[0]?.day).toBe("2026-07-05");
expect(state.timeline?.cards[0]?.title).toBe("New day");
expect(state.loading).toBe(false);
});
it("discards a standup response after the selected day changes", async () => {
const host = {};
hosts.push(host);
+159 -94
View File
@@ -1,83 +1,12 @@
// Control UI controller for the Logbook tab: state, gateway calls, polling.
import type { GatewayBrowserClient } from "../../api/gateway.ts";
export type LogbookStatusPayload = {
captureEnabled: boolean;
capturePaused: boolean;
captureIntervalSeconds: number;
analysisIntervalMinutes: number;
retentionDays: number;
nodeId?: string;
nodeName?: string;
lastCaptureAtMs?: number;
lastCaptureError?: string;
pendingFrames: number;
analysisRunning: boolean;
lastBatch?: { id: number; day: string; status: string; endMs: number; error?: string };
visionModel?: string;
visionModelSource: "config" | "media-defaults" | "missing";
today: string;
todayCards: number;
timeZone: string;
};
type LogbookDistractionPayload = { startMs: number; endMs: number; title: string };
export type LogbookCardPayload = {
id: number;
day: string;
startMs: number;
endMs: number;
title: string;
summary: string;
detail: string;
category: string;
appPrimary?: string;
appSecondary?: string;
distractions: LogbookDistractionPayload[];
keyframeId?: number;
};
type LogbookDayStatsPayload = {
trackedMs: number;
distractionMs: number;
categories: Array<{ category: string; ms: number }>;
apps: Array<{ domain: string; ms: number }>;
};
type LogbookTimelinePayload = {
day: string;
cards: LogbookCardPayload[];
stats: LogbookDayStatsPayload;
};
type LogbookDaysPayload = {
days: Array<{ day: string; cards: number; firstMs: number; lastMs: number }>;
};
export type LogbookUiState = {
day: string;
/** True once the user navigated to a specific day; unpinned views follow the gateway's today. */
dayPinned: boolean;
status: LogbookStatusPayload | null;
days: LogbookDaysPayload["days"];
timeline: LogbookTimelinePayload | null;
loading: boolean;
error: string | null;
expandedCardIds: Set<number>;
framePreviews: Map<number, string>;
frameLoads: Set<number>;
framePreviewFailed: Set<number>;
standup: { day: string; text: string; updatedMs: number } | null;
standupLoading: boolean;
askQuestion: string;
askAnswer: string | null;
askLoading: boolean;
actionPending: boolean;
pollTimer: ReturnType<typeof globalThis.setInterval> | null;
pollClient: GatewayBrowserClient | null;
requestUpdate: (() => void) | null;
};
import type {
LogbookBackgroundRefresh,
LogbookDaysPayload,
LogbookStatusPayload,
LogbookTimelinePayload,
LogbookUiState,
} from "./logbook-types.ts";
const FRAME_PREVIEW_CACHE_LIMIT = 48;
const POLL_INTERVAL_MS = 30_000;
@@ -117,6 +46,13 @@ export function getLogbookState(host: object): LogbookUiState {
askAnswer: null,
askLoading: false,
actionPending: false,
actionGeneration: 0,
actionPendingGeneration: null,
loadGeneration: 0,
loadingGeneration: null,
lifecycleGeneration: 0,
backgroundRefresh: null,
backgroundRefreshQueued: null,
pollTimer: null,
pollClient: null,
requestUpdate: null,
@@ -154,7 +90,10 @@ export async function loadLogbook(
} else if (opts?.today) {
state.dayPinned = false;
}
const generation = ++state.loadGeneration;
const requestedDay = state.day;
if (!opts?.silent) {
state.loadingGeneration = generation;
state.loading = true;
state.error = null;
notify(state);
@@ -163,8 +102,11 @@ export async function loadLogbook(
const [status, days, timeline] = await Promise.all([
client.request<LogbookStatusPayload>("logbook.status", {}),
client.request<LogbookDaysPayload>("logbook.days", {}),
client.request<LogbookTimelinePayload>("logbook.timeline", { day: state.day }),
client.request<LogbookTimelinePayload>("logbook.timeline", { day: requestedDay }),
]);
if (generation !== state.loadGeneration || state.day !== requestedDay) {
return;
}
state.status = status;
state.days = days.days;
// Unpinned views follow the gateway's day: the browser clock can sit in
@@ -172,21 +114,111 @@ export async function loadLogbook(
// advance the default view.
if (!state.dayPinned && status.today !== state.day) {
resetDayView(state, status.today);
state.timeline = await client.request<LogbookTimelinePayload>("logbook.timeline", {
const todayTimeline = await client.request<LogbookTimelinePayload>("logbook.timeline", {
day: status.today,
});
if (generation !== state.loadGeneration || state.day !== status.today) {
return;
}
state.timeline = todayTimeline;
} else {
state.timeline = timeline;
}
state.error = null;
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
if (generation === state.loadGeneration) {
state.error = err instanceof Error ? err.message : String(err);
}
} finally {
state.loading = false;
notify(state);
let shouldNotify = generation === state.loadGeneration;
if (state.loadingGeneration === generation) {
state.loadingGeneration = null;
state.loading = false;
shouldNotify = true;
}
if (shouldNotify) {
notify(state);
}
drainQueuedLogbookRefresh(state);
}
}
function retireLogbookLoads(state: LogbookUiState): void {
// A stopped or rebound view must not accept results from its retired client,
// and an abandoned background request must not block the next polling epoch.
state.loadGeneration += 1;
state.lifecycleGeneration += 1;
state.actionGeneration += 1;
state.actionPendingGeneration = null;
state.actionPending = false;
state.loadingGeneration = null;
state.loading = false;
state.backgroundRefresh = null;
state.backgroundRefreshQueued = null;
}
function isLogbookActionCurrent(
state: LogbookUiState,
actionGeneration: number,
refresh: LogbookBackgroundRefresh,
): boolean {
return actionGeneration === state.actionGeneration && isLogbookRefreshCurrent(state, refresh);
}
function isLogbookRefreshCurrent(
state: LogbookUiState,
refresh: LogbookBackgroundRefresh,
): boolean {
return (
refresh.lifecycleGeneration === state.lifecycleGeneration &&
(state.pollClient === null || state.pollClient === refresh.client)
);
}
function drainQueuedLogbookRefresh(state: LogbookUiState): void {
if (state.loading || state.backgroundRefresh) {
return;
}
const queued = state.backgroundRefreshQueued;
state.backgroundRefreshQueued = null;
if (!queued || !isLogbookRefreshCurrent(state, queued)) {
return;
}
void refreshLogbookSilently(state, queued.client, {
lifecycleGeneration: queued.lifecycleGeneration,
required: true,
});
}
function refreshLogbookSilently(
state: LogbookUiState,
client: GatewayBrowserClient,
opts?: { lifecycleGeneration?: number; required?: boolean },
): Promise<void> {
const refreshRequest = {
client,
lifecycleGeneration: opts?.lifecycleGeneration ?? state.lifecycleGeneration,
};
if (!isLogbookRefreshCurrent(state, refreshRequest)) {
return Promise.resolve();
}
if (state.loading || state.backgroundRefresh) {
if (opts?.required) {
state.backgroundRefreshQueued = refreshRequest;
}
return state.backgroundRefresh ?? Promise.resolve();
}
const refresh = loadLogbook(state, client, { silent: true });
state.backgroundRefresh = refresh;
void refresh.finally(() => {
if (state.backgroundRefresh === refresh) {
state.backgroundRefresh = null;
}
drainQueuedLogbookRefresh(state);
});
return refresh;
}
/** Stops background polling; wired into tab-switch and disconnect cleanup. */
export function stopLogbookPolling(host: object): void {
const state = logbookStates.get(host);
@@ -196,6 +228,8 @@ export function stopLogbookPolling(host: object): void {
}
if (state) {
state.pollClient = null;
// PluginPage replaces the host before calling stop. Let detached-host loads
// settle; host identity keeps their results out of the replacement view.
}
}
@@ -210,6 +244,7 @@ export function configureLogbookPolling(
state.pollTimer = null;
}
state.pollClient = null;
retireLogbookLoads(state);
return;
}
if (state.pollTimer && state.pollClient === client) {
@@ -218,10 +253,12 @@ export function configureLogbookPolling(
if (state.pollTimer) {
clearInterval(state.pollTimer);
}
retireLogbookLoads(state);
state.pollClient = client;
state.pollTimer = setInterval(() => {
// Silent refresh keeps the timeline current while analysis batches land.
void loadLogbook(state, client, { silent: true });
// All background refresh sources share one owner so slow gateway responses
// cannot stack another status/days/timeline batch on every interval.
void refreshLogbookSilently(state, client);
}, POLL_INTERVAL_MS);
}
@@ -268,15 +305,26 @@ export async function setLogbookCapturePaused(
if (!client || state.actionPending) {
return;
}
const actionGeneration = ++state.actionGeneration;
state.actionPendingGeneration = actionGeneration;
state.actionPending = true;
notify(state);
const refresh = { client, lifecycleGeneration: state.lifecycleGeneration };
try {
state.status = await client.request<LogbookStatusPayload>("logbook.capture.set", { paused });
const status = await client.request<LogbookStatusPayload>("logbook.capture.set", { paused });
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
state.status = status;
}
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
state.error = err instanceof Error ? err.message : String(err);
}
} finally {
state.actionPending = false;
notify(state);
if (state.actionPendingGeneration === actionGeneration) {
state.actionPendingGeneration = null;
state.actionPending = false;
notify(state);
}
}
}
@@ -287,22 +335,39 @@ export async function runLogbookAnalysisNow(
if (!client || state.actionPending) {
return;
}
const actionGeneration = ++state.actionGeneration;
state.actionPendingGeneration = actionGeneration;
state.actionPending = true;
notify(state);
const refresh = { client, lifecycleGeneration: state.lifecycleGeneration };
try {
const result = await client.request<{ started: boolean; reason?: string }>(
"logbook.analyze.now",
{},
);
if (!result.started && result.reason) {
if (
isLogbookActionCurrent(state, actionGeneration, refresh) &&
!result.started &&
result.reason
) {
state.error = result.reason;
}
} catch (err) {
state.error = err instanceof Error ? err.message : String(err);
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
state.error = err instanceof Error ? err.message : String(err);
}
} finally {
state.actionPending = false;
notify(state);
void loadLogbook(state, client, { silent: true });
if (state.actionPendingGeneration === actionGeneration) {
state.actionPendingGeneration = null;
state.actionPending = false;
notify(state);
}
if (isLogbookActionCurrent(state, actionGeneration, refresh)) {
void refreshLogbookSilently(state, client, {
lifecycleGeneration: refresh.lifecycleGeneration,
required: true,
});
}
}
}
+93
View File
@@ -0,0 +1,93 @@
import type { GatewayBrowserClient } from "../../api/gateway.ts";
export type LogbookStatusPayload = {
captureEnabled: boolean;
capturePaused: boolean;
captureIntervalSeconds: number;
analysisIntervalMinutes: number;
retentionDays: number;
nodeId?: string;
nodeName?: string;
lastCaptureAtMs?: number;
lastCaptureError?: string;
pendingFrames: number;
analysisRunning: boolean;
lastBatch?: { id: number; day: string; status: string; endMs: number; error?: string };
visionModel?: string;
visionModelSource: "config" | "media-defaults" | "missing";
today: string;
todayCards: number;
timeZone: string;
};
type LogbookDistractionPayload = { startMs: number; endMs: number; title: string };
export type LogbookCardPayload = {
id: number;
day: string;
startMs: number;
endMs: number;
title: string;
summary: string;
detail: string;
category: string;
appPrimary?: string;
appSecondary?: string;
distractions: LogbookDistractionPayload[];
keyframeId?: number;
};
type LogbookDayStatsPayload = {
trackedMs: number;
distractionMs: number;
categories: Array<{ category: string; ms: number }>;
apps: Array<{ domain: string; ms: number }>;
};
export type LogbookTimelinePayload = {
day: string;
cards: LogbookCardPayload[];
stats: LogbookDayStatsPayload;
};
export type LogbookDaysPayload = {
days: Array<{ day: string; cards: number; firstMs: number; lastMs: number }>;
};
export type LogbookBackgroundRefresh = {
client: GatewayBrowserClient;
lifecycleGeneration: number;
};
export type LogbookUiState = {
day: string;
/** True once the user navigated to a specific day; unpinned views follow the gateway's today. */
dayPinned: boolean;
status: LogbookStatusPayload | null;
days: LogbookDaysPayload["days"];
timeline: LogbookTimelinePayload | null;
loading: boolean;
error: string | null;
expandedCardIds: Set<number>;
framePreviews: Map<number, string>;
frameLoads: Set<number>;
framePreviewFailed: Set<number>;
standup: { day: string; text: string; updatedMs: number } | null;
standupLoading: boolean;
askQuestion: string;
askAnswer: string | null;
askLoading: boolean;
actionPending: boolean;
actionGeneration: number;
actionPendingGeneration: number | null;
// Every load advances result ownership; foreground loading state has its own
// owner so a superseded request cannot clear a newer spinner.
loadGeneration: number;
loadingGeneration: number | null;
lifecycleGeneration: number;
backgroundRefresh: Promise<void> | null;
backgroundRefreshQueued: LogbookBackgroundRefresh | null;
pollTimer: ReturnType<typeof globalThis.setInterval> | null;
pollClient: GatewayBrowserClient | null;
requestUpdate: (() => void) | null;
};
+1 -3
View File
@@ -18,10 +18,8 @@ import {
runLogbookAnalysisNow,
setLogbookCapturePaused,
shiftDay,
type LogbookCardPayload,
type LogbookStatusPayload,
type LogbookUiState,
} from "./logbook-controller.ts";
import type { LogbookCardPayload, LogbookStatusPayload, LogbookUiState } from "./logbook-types.ts";
type LogbookProps = {
host: object;