From bc2f83591af00849872dafeb59c3610b552217f7 Mon Sep 17 00:00:00 2001 From: m4 Date: Fri, 31 Jul 2026 19:30:01 +0800 Subject: [PATCH] feat(webui): unread-cursor helpers for the error-log bell Co-Authored-By: Claude Opus 4.7 --- src/lib/errorLogUnread.test.ts | 68 ++++++++++++++++++++++++++++++++++ src/lib/errorLogUnread.ts | 55 +++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 src/lib/errorLogUnread.test.ts create mode 100644 src/lib/errorLogUnread.ts diff --git a/src/lib/errorLogUnread.test.ts b/src/lib/errorLogUnread.test.ts new file mode 100644 index 0000000..e07156b --- /dev/null +++ b/src/lib/errorLogUnread.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { + LAST_SEEN_STORAGE_KEY, + countUnread, + formatBadgeCount, + latestLogId, + readLastSeenId, + writeLastSeenId, +} from "./errorLogUnread"; + +function fakeStorage(initial: Record = {}) { + const map = new Map(Object.entries(initial)); + return { + getItem: (key: string) => map.get(key) ?? null, + setItem: (key: string, value: string) => { + map.set(key, value); + }, + map, + }; +} + +describe("errorLogUnread", () => { + it("latestLogId returns the max id, 0 for empty", () => { + expect(latestLogId([])).toBe(0); + expect(latestLogId([{ id: 3 }, { id: 9 }, { id: 5 }])).toBe(9); + }); + + it("countUnread counts entries newer than the cursor", () => { + const logs = [{ id: 10 }, { id: 8 }, { id: 4 }]; + expect(countUnread(logs, 0)).toBe(3); + expect(countUnread(logs, 8)).toBe(1); + expect(countUnread(logs, 10)).toBe(0); + }); + + it("formatBadgeCount caps at 99+ and hides zero", () => { + expect(formatBadgeCount(0)).toBe(""); + expect(formatBadgeCount(7)).toBe("7"); + expect(formatBadgeCount(99)).toBe("99"); + expect(formatBadgeCount(150)).toBe("99+"); + }); + + it("readLastSeenId returns 0 for null storage, missing, or corrupt values", () => { + expect(readLastSeenId(null)).toBe(0); + expect(readLastSeenId(fakeStorage())).toBe(0); + expect( + readLastSeenId(fakeStorage({ [LAST_SEEN_STORAGE_KEY]: "abc" })) + ).toBe(0); + expect( + readLastSeenId(fakeStorage({ [LAST_SEEN_STORAGE_KEY]: "-5" })) + ).toBe(0); + }); + + it("writeLastSeenId round-trips through storage", () => { + const storage = fakeStorage(); + writeLastSeenId(storage, 42); + expect(readLastSeenId(storage)).toBe(42); + }); + + it("writeLastSeenId swallows storage failures", () => { + const throwing = { + setItem: () => { + throw new Error("quota"); + }, + }; + expect(() => writeLastSeenId(throwing, 1)).not.toThrow(); + }); +}); diff --git a/src/lib/errorLogUnread.ts b/src/lib/errorLogUnread.ts new file mode 100644 index 0000000..fbd0c6a --- /dev/null +++ b/src/lib/errorLogUnread.ts @@ -0,0 +1,55 @@ +/** Unread-badge state for the error-log bell. The server assigns each entry a + * monotonically increasing id, so a single "last seen id" cursor is enough; + * the cursor lives in localStorage (per browser, per user is unnecessary + * because ids are global and monotonic). */ + +export const LAST_SEEN_STORAGE_KEY = "evoscientist.errorLogs.lastSeenId"; + +const BADGE_CAP = 99; + +export function latestLogId(logs: ReadonlyArray<{ id: number }>): number { + let max = 0; + for (const log of logs) { + if (log.id > max) max = log.id; + } + return max; +} + +export function countUnread( + logs: ReadonlyArray<{ id: number }>, + lastSeenId: number +): number { + let count = 0; + for (const log of logs) { + if (log.id > lastSeenId) count += 1; + } + return count; +} + +export function formatBadgeCount(count: number): string { + if (count <= 0) return ""; + return count > BADGE_CAP ? `${BADGE_CAP}+` : String(count); +} + +export function readLastSeenId( + storage: Pick | null +): number { + if (!storage) return 0; + const raw = storage.getItem(LAST_SEEN_STORAGE_KEY); + if (raw === null) return 0; + const value = Number(raw); + if (!Number.isInteger(value) || value < 0) return 0; + return value; +} + +export function writeLastSeenId( + storage: Pick | null, + id: number +): void { + if (!storage) return; + try { + storage.setItem(LAST_SEEN_STORAGE_KEY, String(id)); + } catch { + // Private-mode quota errors must not break the bell. + } +}