char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somshow timestamps in /tree and /forkc582293

main
5.2 KiB157 linesraw
1import { Signal } from "@char/aftercare";
2
3// signals hold small values one view owns, such as whether a card is open; replicated state stays plain data, which
4// views take apart themselves. a derive of a signal outliving the view would never be released, since a derive
5// attaches to its root signal for good: use `computed`, or a subscription the view's scope ends.
6
7/** Cleanups of one view. Aftercare has no unmount, so views that subscribe own a scope. */
8export class Scope {
9  #cleanups: (() => void)[] = [];
10  add(cleanup: () => void) {
11    this.#cleanups.push(cleanup);
12  }
13  dispose() {
14    for (const cleanup of this.#cleanups.splice(0)) cleanup();
15  }
16}
17
18/** One item's view in a `keyed` list: `update` gets each new value of the item, and `dispose` runs once it goes. */
19export type Handle<T> = {
20  readonly node: Node;
21  update?(item: T): void;
22  dispose?(): void;
23};
24
25/**
26 * `parent`'s children, one per key of the items `sync` is given, in order; a new key makes its handle.
27 */
28export function keyed<T, K>(
29  parent: Element,
30  key: (item: T, index: number) => K,
31  create: (item: T, index: number) => Handle<T>,
32) {
33  let rendered = new Map<K, { handle: Handle<T>; item: T }>();
34  return {
35    sync(items: readonly T[], more = () => true, newestFirst = false): boolean {
36      const next = new Map<K, { handle: Handle<T>; item: T }>();
37      let complete = true;
38      for (let offset = 0; offset < items.length; offset++) {
39        const index = newestFirst ? items.length - offset - 1 : offset;
40        const item = items[index];
41        const k = key(item, index);
42        const entry = rendered.get(k);
43        if (entry !== undefined) {
44          if (entry.item !== item) entry.handle.update?.(item);
45          next.set(k, { handle: entry.handle, item });
46        } else if (more()) next.set(k, { handle: create(item, index), item });
47        else complete = false;
48      }
49      for (const [k, { handle }] of rendered) {
50        if (next.has(k)) continue;
51        handle.dispose?.();
52        handle.node.parentNode?.removeChild(handle.node);
53      }
54      if (newestFirst) {
55        let following: Node | null = null;
56        for (const { handle: { node } } of next.values()) {
57          const expected: Node | null = following === null
58            ? parent.lastChild
59            : following.previousSibling;
60          if (node !== expected) parent.insertBefore(node, following);
61          following = node;
62        }
63      } else {
64        let previous: Node | null = null;
65        for (const { handle: { node } } of next.values()) {
66          const expected: Node | null = previous === null
67            ? parent.firstChild
68            : previous.nextSibling;
69          if (node !== expected) parent.insertBefore(node, expected);
70          previous = node;
71        }
72      }
73      rendered = next;
74      return complete;
75    },
76    dispose() {
77      for (const { handle } of rendered.values()) handle.dispose?.();
78      rendered = new Map();
79    },
80  };
81}
82
83/** Render `parent`'s content afresh whenever `value` changes. */
84export function swap<T>(
85  scope: Scope,
86  parent: Element,
87  value: Signal<T>,
88  render: (value: T, scope: Scope) => Node | Node[] | null,
89) {
90  let current = new Scope();
91  const { unsubscribe } = value.subscribeImmediate((v) => {
92    current.dispose();
93    current = new Scope();
94    const content = render(v, current);
95    parent.replaceChildren(
96      ...(content === null ? [] : Array.isArray(content) ? content : [content]),
97    );
98  });
99  scope.add(() => {
100    unsubscribe();
101    current.dispose();
102  });
103}
104
105/** A signal computed from others, notifying only when `compute` returns a different value. */
106export function computed<T>(
107  scope: Scope,
108  sources: readonly Signal<unknown>[],
109  compute: () => T,
110): Signal<T> {
111  const out = new Signal(compute());
112  for (const source of sources) {
113    const { unsubscribe } = source.subscribe(() => out.set(compute()));
114    scope.add(unsubscribe);
115  }
116  return out;
117}
118
119export const json = (value: unknown) => JSON.stringify(value, null, 2);
120
121const TID_ALPHABET = "234567abcdefghijklmnopqrstuvwxyz";
122
123/** When a TID was minted. */
124export function tidDate(tid: string): Date {
125  let n = 0n;
126  for (const char of tid) n = (n << 5n) | BigInt(TID_ALPHABET.indexOf(char));
127  return new Date(Number(n >> 10n) / 1000);
128}
129
130const relative = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
131const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
132  ["year", 365 * 24 * 60 * 60],
133  ["month", 30 * 24 * 60 * 60],
134  ["week", 7 * 24 * 60 * 60],
135  ["day", 24 * 60 * 60],
136  ["hour", 60 * 60],
137  ["minute", 60],
138];
139
140export function relativeTime(date: Date, now = Date.now()): string {
141  const seconds = (date.getTime() - now) / 1000;
142  for (const [unit, duration] of RELATIVE_UNITS) {
143    if (Math.abs(seconds) >= duration) return relative.format(Math.trunc(seconds / duration), unit);
144  }
145  return relative.format(0, "second");
146}
147
148export function when(date: Date, { seconds = false } = {}): string {
149  const sameDay = date.toDateString() === new Date().toDateString();
150  return sameDay
151    ? date.toLocaleTimeString(undefined, {
152      hour: "2-digit",
153      minute: "2-digit",
154      ...(seconds ? { second: "2-digit" } : {}),
155    })
156    : date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
157}