@supersuit/suggest-a-change 0.1.10 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.1.11 (2026-10-18)
6
+
7
+ - **A second tab no longer takes over the first tab's recording.** Two tabs share one IndexedDB, and a tab that opened while another was recording ran `recoverTakes` over the live take: it deleted its marker and chunks, so every second said after that had no marker and was lost if the recording tab then died (freedom-app-template#1). Each second of a take now refreshes `beatAt` on its marker, and `recoverTakes` leaves a take that beat within `LIVE_TAKE_MS` (5 s, exported) exactly as it is; it is made whole on an open after it goes quiet. `recoverTakes(store, claim, { now, liveMs })` takes the clock for tests. An app reopened within 5 s of a crash recovers that take on its next open instead of this one; nothing is lost.
8
+
3
9
  ## 0.1.10 (2026-10-17)
4
10
 
5
11
  - **What goes with it is one small line.** The sheet no longer draws a section with a legend, an explainer and four switches above Send. Every diagnostic (the app's version, the time, the screen size, the phone type) goes with every report, beside the screen's name and address, and one muted line says what goes and to whom. Whoever acts on a report needs all of them, so leaving one out only ever cost a round trip. The `Words` keys the switches used (`withLegend`, `alwaysSent`, `include`, `screenName`) stay, so an app that overrides them still typechecks.
@@ -1,5 +1,15 @@
1
1
  import type { Store } from './idb.ts';
2
2
  export declare function pickMime(): string;
3
+ /**
4
+ * A take whose last second landed this recently is LIVE in some page, never abandoned. Two tabs
5
+ * share one IndexedDB, so a second tab opening while the first records would otherwise "recover"
6
+ * the first tab's take mid-recording: delete its marker and chunks, and leave every second said
7
+ * after that with no marker, lost if that tab then dies (freedom-app-template#1). Each second of a
8
+ * take refreshes `beatAt`, so a take nobody is recording goes quiet within a second and is made
9
+ * whole on the next open after this window. Chunks arrive every 1000 ms; the margin covers a busy
10
+ * main thread.
11
+ */
12
+ export declare const LIVE_TAKE_MS = 5000;
3
13
  export interface Recorded {
4
14
  key: string;
5
15
  blob: Blob;
@@ -14,7 +24,11 @@ export declare function startTake(store: Store, key: string, questionId: string)
14
24
  }>;
15
25
  /** Takes the app was closed in the middle of, made whole again. */
16
26
  /** `claim` limits the recovery to takes the caller owns (by question id); the rest are left exactly as they are for their owner. */
17
- export declare function recoverTakes(store: Store, claim?: (questionId: string) => boolean): Promise<{
27
+ /** A take still beating (another tab recording it, LIVE_TAKE_MS) is left exactly as it is; it is recovered on an open after it goes quiet. */
28
+ export declare function recoverTakes(store: Store, claim?: (questionId: string) => boolean, { now, liveMs }?: {
29
+ now?: number;
30
+ liveMs?: number;
31
+ }): Promise<{
18
32
  questionId: string;
19
33
  key: string;
20
34
  ms: number;
@@ -6,6 +6,16 @@ export function pickMime() {
6
6
  return m;
7
7
  return '';
8
8
  }
9
+ /**
10
+ * A take whose last second landed this recently is LIVE in some page, never abandoned. Two tabs
11
+ * share one IndexedDB, so a second tab opening while the first records would otherwise "recover"
12
+ * the first tab's take mid-recording: delete its marker and chunks, and leave every second said
13
+ * after that with no marker, lost if that tab then dies (freedom-app-template#1). Each second of a
14
+ * take refreshes `beatAt`, so a take nobody is recording goes quiet within a second and is made
15
+ * whole on the next open after this window. Chunks arrive every 1000 ms; the margin covers a busy
16
+ * main thread.
17
+ */
18
+ export const LIVE_TAKE_MS = 5000;
9
19
  export async function startTake(store, key, questionId) {
10
20
  const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
11
21
  const mime = pickMime();
@@ -14,13 +24,16 @@ export async function startTake(store, key, questionId) {
14
24
  const started = Date.now();
15
25
  const chunks = [];
16
26
  const writes = [];
17
- await store.put('kv', `rec:${key}`, { questionId, mime: type, startedAt: started });
27
+ const meta = { questionId, mime: type, startedAt: started, beatAt: started };
28
+ await store.put('kv', `rec:${key}`, meta);
18
29
  rec.ondataavailable = (e) => {
19
30
  if (!e.data?.size)
20
31
  return;
21
32
  const seq = chunks.length;
22
33
  chunks.push(e.data);
23
34
  writes.push(store.put('blobs', `${key}#${String(seq).padStart(5, '0')}`, e.data).catch(() => { }));
35
+ // The heartbeat another tab's recovery reads (LIVE_TAKE_MS).
36
+ writes.push(store.put('kv', `rec:${key}`, { ...meta, beatAt: Date.now() }).catch(() => { }));
24
37
  };
25
38
  rec.start(1000);
26
39
  const meter = makeMeter(stream);
@@ -100,7 +113,8 @@ function makeMeter(stream) {
100
113
  }
101
114
  /** Takes the app was closed in the middle of, made whole again. */
102
115
  /** `claim` limits the recovery to takes the caller owns (by question id); the rest are left exactly as they are for their owner. */
103
- export async function recoverTakes(store, claim) {
116
+ /** A take still beating (another tab recording it, LIVE_TAKE_MS) is left exactly as it is; it is recovered on an open after it goes quiet. */
117
+ export async function recoverTakes(store, claim, { now = Date.now(), liveMs = LIVE_TAKE_MS } = {}) {
104
118
  const out = [];
105
119
  // By prefix: this runs before the first paint, and `kv` also holds every saved view.
106
120
  const metas = (await store.all('kv', 'rec:')).filter((r) => r.key.startsWith('rec:'));
@@ -110,6 +124,9 @@ export async function recoverTakes(store, claim) {
110
124
  for (const { key: metaKey, value: m } of metas) {
111
125
  if (claim && !claim(m.questionId))
112
126
  continue;
127
+ const beat = Number(m.beatAt ?? m.startedAt);
128
+ if (Number.isFinite(beat) && now - beat < liveMs)
129
+ continue;
113
130
  const key = metaKey.slice(4);
114
131
  const parts = blobs.filter((b) => b.key.startsWith(`${key}#`)).sort((a, b) => a.key.localeCompare(b.key));
115
132
  if (parts.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "The report format and flow behind a Suggest a change button: what a report is, who acts at each step, the screens, and the check every app runs. The contract has zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,18 @@ export function pickMime(): string {
10
10
  return ''
11
11
  }
12
12
 
13
- interface Meta { questionId: string; mime: string; startedAt: number }
13
+ interface Meta { questionId: string; mime: string; startedAt: number; beatAt?: number }
14
+
15
+ /**
16
+ * A take whose last second landed this recently is LIVE in some page, never abandoned. Two tabs
17
+ * share one IndexedDB, so a second tab opening while the first records would otherwise "recover"
18
+ * the first tab's take mid-recording: delete its marker and chunks, and leave every second said
19
+ * after that with no marker, lost if that tab then dies (freedom-app-template#1). Each second of a
20
+ * take refreshes `beatAt`, so a take nobody is recording goes quiet within a second and is made
21
+ * whole on the next open after this window. Chunks arrive every 1000 ms; the margin covers a busy
22
+ * main thread.
23
+ */
24
+ export const LIVE_TAKE_MS = 5000
14
25
  export interface Recorded { key: string; blob: Blob; ms: number }
15
26
 
16
27
  export async function startTake(store: Store, key: string, questionId: string) {
@@ -21,12 +32,15 @@ export async function startTake(store: Store, key: string, questionId: string) {
21
32
  const started = Date.now()
22
33
  const chunks: Blob[] = []
23
34
  const writes: Promise<void>[] = []
24
- await store.put('kv', `rec:${key}`, { questionId, mime: type, startedAt: started } satisfies Meta)
35
+ const meta: Meta = { questionId, mime: type, startedAt: started, beatAt: started }
36
+ await store.put('kv', `rec:${key}`, meta)
25
37
  rec.ondataavailable = (e) => {
26
38
  if (!e.data?.size) return
27
39
  const seq = chunks.length
28
40
  chunks.push(e.data)
29
41
  writes.push(store.put('blobs', `${key}#${String(seq).padStart(5, '0')}`, e.data).catch(() => {}))
42
+ // The heartbeat another tab's recovery reads (LIVE_TAKE_MS).
43
+ writes.push(store.put('kv', `rec:${key}`, { ...meta, beatAt: Date.now() }).catch(() => {}))
30
44
  }
31
45
  rec.start(1000)
32
46
  const meter = makeMeter(stream)
@@ -88,7 +102,9 @@ function makeMeter(stream: MediaStream): { level(): number; close(): void } | nu
88
102
 
89
103
  /** Takes the app was closed in the middle of, made whole again. */
90
104
  /** `claim` limits the recovery to takes the caller owns (by question id); the rest are left exactly as they are for their owner. */
91
- export async function recoverTakes(store: Store, claim?: (questionId: string) => boolean): Promise<{ questionId: string; key: string; ms: number }[]> {
105
+ /** A take still beating (another tab recording it, LIVE_TAKE_MS) is left exactly as it is; it is recovered on an open after it goes quiet. */
106
+ export async function recoverTakes(store: Store, claim?: (questionId: string) => boolean,
107
+ { now = Date.now(), liveMs = LIVE_TAKE_MS }: { now?: number; liveMs?: number } = {}): Promise<{ questionId: string; key: string; ms: number }[]> {
92
108
  const out: { questionId: string; key: string; ms: number }[] = []
93
109
  // By prefix: this runs before the first paint, and `kv` also holds every saved view.
94
110
  const metas = (await store.all<Meta>('kv', 'rec:')).filter((r) => r.key.startsWith('rec:'))
@@ -96,6 +112,8 @@ export async function recoverTakes(store: Store, claim?: (questionId: string) =>
96
112
  const blobs = await store.all<Blob>('blobs')
97
113
  for (const { key: metaKey, value: m } of metas) {
98
114
  if (claim && !claim(m.questionId)) continue
115
+ const beat = Number(m.beatAt ?? m.startedAt)
116
+ if (Number.isFinite(beat) && now - beat < liveMs) continue
99
117
  const key = metaKey.slice(4)
100
118
  const parts = blobs.filter((b) => b.key.startsWith(`${key}#`)).sort((a, b) => a.key.localeCompare(b.key))
101
119
  if (parts.length) {