feat(webui): unread-cursor helpers for the error-log bell

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-31 19:30:01 +08:00
parent 6b0cc81ded
commit bc2f83591a
2 changed files with 123 additions and 0 deletions
+68
View File
@@ -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<string, string> = {}) {
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();
});
});
+55
View File
@@ -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<Storage, "getItem"> | 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<Storage, "setItem"> | 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.
}
}