import { Signal } from "@char/aftercare"; // signals hold small values one view owns, such as whether a card is open; replicated state stays plain data, which // views take apart themselves. a derive of a signal outliving the view would never be released, since a derive // attaches to its root signal for good: use `computed`, or a subscription the view's scope ends. /** Cleanups of one view. Aftercare has no unmount, so views that subscribe own a scope. */ export class Scope { #cleanups: (() => void)[] = []; add(cleanup: () => void) { this.#cleanups.push(cleanup); } dispose() { for (const cleanup of this.#cleanups.splice(0)) cleanup(); } } /** One item's view in a `keyed` list: `update` gets each new value of the item, and `dispose` runs once it goes. */ export type Handle = { readonly node: Node; update?(item: T): void; dispose?(): void; }; /** * `parent`'s children, one per key of the items `sync` is given, in order; a new key makes its handle. */ export function keyed( parent: Element, key: (item: T, index: number) => K, create: (item: T, index: number) => Handle, ) { let rendered = new Map; item: T }>(); return { sync(items: readonly T[], more = () => true, newestFirst = false): boolean { const next = new Map; item: T }>(); let complete = true; for (let offset = 0; offset < items.length; offset++) { const index = newestFirst ? items.length - offset - 1 : offset; const item = items[index]; const k = key(item, index); const entry = rendered.get(k); if (entry !== undefined) { if (entry.item !== item) entry.handle.update?.(item); next.set(k, { handle: entry.handle, item }); } else if (more()) next.set(k, { handle: create(item, index), item }); else complete = false; } for (const [k, { handle }] of rendered) { if (next.has(k)) continue; handle.dispose?.(); handle.node.parentNode?.removeChild(handle.node); } if (newestFirst) { let following: Node | null = null; for (const { handle: { node } } of next.values()) { const expected: Node | null = following === null ? parent.lastChild : following.previousSibling; if (node !== expected) parent.insertBefore(node, following); following = node; } } else { let previous: Node | null = null; for (const { handle: { node } } of next.values()) { const expected: Node | null = previous === null ? parent.firstChild : previous.nextSibling; if (node !== expected) parent.insertBefore(node, expected); previous = node; } } rendered = next; return complete; }, dispose() { for (const { handle } of rendered.values()) handle.dispose?.(); rendered = new Map(); }, }; } /** Render `parent`'s content afresh whenever `value` changes. */ export function swap( scope: Scope, parent: Element, value: Signal, render: (value: T, scope: Scope) => Node | Node[] | null, ) { let current = new Scope(); const { unsubscribe } = value.subscribeImmediate((v) => { current.dispose(); current = new Scope(); const content = render(v, current); parent.replaceChildren( ...(content === null ? [] : Array.isArray(content) ? content : [content]), ); }); scope.add(() => { unsubscribe(); current.dispose(); }); } /** A signal computed from others, notifying only when `compute` returns a different value. */ export function computed( scope: Scope, sources: readonly Signal[], compute: () => T, ): Signal { const out = new Signal(compute()); for (const source of sources) { const { unsubscribe } = source.subscribe(() => out.set(compute())); scope.add(unsubscribe); } return out; } export const json = (value: unknown) => JSON.stringify(value, null, 2); const TID_ALPHABET = "234567abcdefghijklmnopqrstuvwxyz"; /** When a TID was minted. */ export function tidDate(tid: string): Date { let n = 0n; for (const char of tid) n = (n << 5n) | BigInt(TID_ALPHABET.indexOf(char)); return new Date(Number(n >> 10n) / 1000); } const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }); const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ ["year", 365 * 24 * 60 * 60], ["month", 30 * 24 * 60 * 60], ["week", 7 * 24 * 60 * 60], ["day", 24 * 60 * 60], ["hour", 60 * 60], ["minute", 60], ]; export function relativeTime(date: Date, now = Date.now()): string { const seconds = (date.getTime() - now) / 1000; for (const [unit, duration] of RELATIVE_UNITS) { if (Math.abs(seconds) >= duration) return relative.format(Math.trunc(seconds / duration), unit); } return relative.format(0, "second"); } export function when(date: Date, { seconds = false } = {}): string { const sameDay = date.toDateString() === new Date().toDateString(); return sameDay ? date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit", ...(seconds ? { second: "2-digit" } : {}), }) : date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); }