feat: expand utility widgets
This commit is contained in:
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;
|
||||
};
|
||||
Reference in new issue
Block a user