feat: expand utility widgets

This commit is contained in:
ajnart committed 2026-08-20 15:16:26 +02:00
1 parent a2219b72f6
commit 56c2c4dcc0
82 files changed
+7520 -740

No files matched your search

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { addRememberedBrowserAlert, requestBrowserNotificationPermission } from "./browser-alert";
const originalNotification = window.Notification;
afterEach(() => {
if (originalNotification === undefined) {
Reflect.deleteProperty(window, "Notification");
return;
}
Object.defineProperty(window, "Notification", { configurable: true, value: originalNotification });
});
describe("browser alert occurrence history", () => {
it("moves a repeated occurrence to the end without duplicating it", () => {
expect(addRememberedBrowserAlert(["first", "second"], "first")).toEqual(["second", "first"]);
});
it("bounds remembered occurrences", () => {
const values = Array.from({ length: 110 }, (_, index) => String(index));
const result = addRememberedBrowserAlert(values, "next");
expect(result).toHaveLength(100);
expect(result.at(-1)).toBe("next");
});
it("keeps the current permission when the browser rejects a permission request", async () => {
Object.defineProperty(window, "Notification", {
configurable: true,
value: {
permission: "default",
requestPermission: vi.fn().mockRejectedValue(new Error("blocked")),
},
});
await expect(requestBrowserNotificationPermission()).resolves.toBe("default");
});
});
@@ -0,0 +1,87 @@
export type BrowserNotificationPermission = NotificationPermission | "unsupported";
export interface BrowserNotificationOptions {
title: string;
body?: string;
icon?: string;
tag?: string;
}
const maxRememberedOccurrences = 100;
export const getBrowserNotificationPermission = (): BrowserNotificationPermission => {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
return window.Notification.permission;
};
export const requestBrowserNotificationPermission = async (): Promise<BrowserNotificationPermission> => {
if (typeof window === "undefined" || !("Notification" in window)) return "unsupported";
try {
return await window.Notification.requestPermission();
} catch {
return getBrowserNotificationPermission();
}
};
export const showBrowserNotification = ({ title, body, icon, tag }: BrowserNotificationOptions): boolean => {
if (typeof window === "undefined" || !("Notification" in window) || window.Notification.permission !== "granted") {
return false;
}
try {
const notification = new window.Notification(title, { body, icon, tag });
void notification;
return true;
} catch {
return false;
}
};
export const playBrowserAlertSound = (): boolean => {
if (typeof window === "undefined") return false;
const AudioContextConstructor =
window.AudioContext ?? (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioContextConstructor) return false;
try {
const context = new AudioContextConstructor();
const oscillator = context.createOscillator();
const gain = context.createGain();
const start = context.currentTime;
oscillator.type = "sine";
oscillator.frequency.setValueAtTime(880, start);
gain.gain.setValueAtTime(0.0001, start);
gain.gain.exponentialRampToValueAtTime(0.18, start + 0.01);
gain.gain.exponentialRampToValueAtTime(0.0001, start + 0.3);
oscillator.connect(gain);
gain.connect(context.destination);
oscillator.start(start);
oscillator.stop(start + 0.32);
oscillator.addEventListener("ended", () => void context.close(), { once: true });
return true;
} catch {
return false;
}
};
export const addRememberedBrowserAlert = (occurrences: string[], occurrence: string): string[] => {
const withoutDuplicate = occurrences.filter((value) => value !== occurrence);
return [...withoutDuplicate, occurrence].slice(-maxRememberedOccurrences);
};
export const claimBrowserAlertOccurrence = (storageKey: string, occurrence: string): boolean => {
if (typeof window === "undefined") return false;
try {
const serialized = window.localStorage.getItem(storageKey);
const parsed: unknown = serialized === null ? [] : JSON.parse(serialized);
const occurrences = Array.isArray(parsed)
? parsed.filter((value): value is string => typeof value === "string")
: [];
if (occurrences.includes(occurrence)) return false;
window.localStorage.setItem(storageKey, JSON.stringify(addRememberedBrowserAlert(occurrences, occurrence)));
return true;
} catch {
return false;
}
};
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { parseLocalWidgetState, serializeLocalWidgetState } from "./use-local-widget-state";
const isCounterState = (value: unknown): value is { count: number } =>
typeof value === "object" && value !== null && "count" in value && typeof value.count === "number";
describe("local widget state serialization", () => {
it("round trips validated state", () => {
const serialized = serializeLocalWidgetState(1, { count: 3 });
expect(parseLocalWidgetState(serialized, 1, isCounterState)).toEqual({ count: 3 });
});
it("rejects malformed and invalid state", () => {
expect(parseLocalWidgetState("not-json", 1, isCounterState)).toBeUndefined();
expect(parseLocalWidgetState('{"version":1,"value":{"count":"3"}}', 1, isCounterState)).toBeUndefined();
});
it("uses the supplied migration for old versions", () => {
const serialized = serializeLocalWidgetState(1, { oldCount: 4 });
expect(
parseLocalWidgetState(serialized, 2, isCounterState, (value) => {
if (typeof value !== "object" || value === null || !("oldCount" in value)) return undefined;
return { count: Number(value.oldCount) };
}),
).toEqual({ count: 4 });
});
});
@@ -0,0 +1,145 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
interface StoredWidgetState {
version: number;
value: unknown;
}
interface UseLocalWidgetStateOptions<T> {
key: string | undefined;
version: number;
defaultValue: T | (() => T);
validate?: (value: unknown) => value is T;
migrate?: (value: unknown, storedVersion: number) => T | undefined;
}
const resolveDefaultValue = <T>(value: T | (() => T)): T =>
typeof value === "function" ? (value as () => T)() : value;
export const serializeLocalWidgetState = <T>(version: number, value: T): string => JSON.stringify({ version, value });
export const parseLocalWidgetState = <T>(
serialized: string,
version: number,
validate?: (value: unknown) => value is T,
migrate?: (value: unknown, storedVersion: number) => T | undefined,
): T | undefined => {
try {
const parsed: unknown = JSON.parse(serialized);
if (typeof parsed !== "object" || parsed === null || !("version" in parsed) || !("value" in parsed)) {
return undefined;
}
const stored = parsed as StoredWidgetState;
if (typeof stored.version !== "number") return undefined;
if (stored.version !== version) {
const migrated = migrate?.(stored.value, stored.version);
if (migrated === undefined || (validate && !validate(migrated))) return undefined;
return migrated;
}
if (validate && !validate(stored.value)) return undefined;
return stored.value as T;
} catch {
return undefined;
}
};
export const useLocalWidgetState = <T>({
key,
version,
defaultValue,
validate,
migrate,
}: UseLocalWidgetStateOptions<T>): [T, Dispatch<SetStateAction<T>>, () => void] => {
const defaultValueRef = useRef(defaultValue);
const validateRef = useRef(validate);
const migrateRef = useRef(migrate);
validateRef.current = validate;
migrateRef.current = migrate;
const createDefaultValue = useCallback(() => resolveDefaultValue(defaultValueRef.current), []);
const [value, setValueState] = useState<T>(createDefaultValue);
const valueRef = useRef(value);
const setMemoryValue = useCallback((nextValue: T) => {
valueRef.current = nextValue;
setValueState(nextValue);
}, []);
const removeStoredValue = useCallback(() => {
if (!key) return;
try {
window.localStorage.removeItem(key);
} catch {
// Storage can be unavailable in private or restricted browser contexts.
}
}, [key]);
useEffect(() => {
if (!key) {
setMemoryValue(createDefaultValue());
return;
}
try {
const serialized = window.localStorage.getItem(key);
if (serialized === null) {
setMemoryValue(createDefaultValue());
return;
}
const parsed = parseLocalWidgetState(serialized, version, validateRef.current, migrateRef.current);
if (parsed === undefined) {
window.localStorage.removeItem(key);
setMemoryValue(createDefaultValue());
return;
}
setMemoryValue(parsed);
} catch {
setMemoryValue(createDefaultValue());
}
}, [createDefaultValue, key, setMemoryValue, version]);
useEffect(() => {
if (!key) return;
const handleStorage = (event: StorageEvent) => {
if (event.storageArea !== window.localStorage || event.key !== key) return;
if (event.newValue === null) {
setMemoryValue(createDefaultValue());
return;
}
const parsed = parseLocalWidgetState(event.newValue, version, validateRef.current, migrateRef.current);
if (parsed !== undefined) setMemoryValue(parsed);
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, [createDefaultValue, key, setMemoryValue, version]);
const setValue: Dispatch<SetStateAction<T>> = useCallback(
(nextValue) => {
const resolved =
typeof nextValue === "function" ? (nextValue as (previous: T) => T)(valueRef.current) : nextValue;
if (Object.is(resolved, valueRef.current)) return;
setMemoryValue(resolved);
if (key) {
try {
window.localStorage.setItem(key, serializeLocalWidgetState(version, resolved));
} catch {
// Keep the in-memory state usable when persistence is unavailable.
}
}
},
[key, setMemoryValue, version],
);
const resetValue = useCallback(() => {
removeStoredValue();
setMemoryValue(createDefaultValue());
}, [createDefaultValue, removeStoredValue, setMemoryValue]);
return [value, setValue, resetValue];
};
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { getNextWidgetTickDelay } from "./use-widget-now";
describe("getNextWidgetTickDelay", () => {
it("aligns second and minute ticks", () => {
const now = new Date(2026, 7, 20, 12, 34, 45, 250);
expect(getNextWidgetTickDelay(now, "second")).toBe(750);
expect(getNextWidgetTickDelay(now, "minute")).toBe(14_750);
});
it("aligns day ticks to the next local midnight", () => {
const now = new Date(2026, 7, 20, 23, 59, 59, 500);
expect(getNextWidgetTickDelay(now, "day")).toBe(500);
});
});
@@ -0,0 +1,54 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
export type WidgetClockPrecision = "second" | "minute" | "day";
const minimumDelayMs = 10;
export const getNextWidgetTickDelay = (now: Date, precision: WidgetClockPrecision): number => {
let next: number;
if (precision === "second") {
next = now.getTime() - now.getMilliseconds() + 1_000;
} else if (precision === "minute") {
next = now.getTime() - now.getSeconds() * 1_000 - now.getMilliseconds() + 60_000;
} else {
next = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1).getTime();
}
return Math.max(minimumDelayMs, next - now.getTime());
};
export const useWidgetNow = (precision: WidgetClockPrecision = "minute"): Date | null => {
const [now, setNow] = useState<Date | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const cancelTick = useCallback(() => {
if (timeoutRef.current === undefined) return;
clearTimeout(timeoutRef.current);
timeoutRef.current = undefined;
}, []);
const scheduleTick = useCallback(() => {
cancelTick();
const current = new Date();
setNow(current);
timeoutRef.current = setTimeout(scheduleTick, getNextWidgetTickDelay(current, precision));
}, [cancelTick, precision]);
useEffect(() => {
scheduleTick();
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") scheduleTick();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
cancelTick();
};
}, [cancelTick, scheduleTick]);
return now;
};