@supersuit/suggest-a-change 0.1.9 → 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 +10 -0
- package/README.md +1 -1
- package/dist/client/recorder.d.ts +15 -1
- package/dist/client/recorder.js +19 -2
- package/dist/client/sheet.js +6 -10
- package/package.json +1 -1
- package/src/client/recorder.ts +21 -3
- package/src/client/sheet.tsx +6 -19
- package/src/client/styles.css +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
9
|
+
## 0.1.10 (2026-10-17)
|
|
10
|
+
|
|
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.
|
|
12
|
+
|
|
3
13
|
## 0.1.9 (2026-10-17)
|
|
4
14
|
|
|
5
15
|
- **The door helpers are exported from `./server`.** `json`, `signIn`, `notFound`, `cappedText` and `peek` are what an app's own route wrapper needs to answer as the handlers do and never read a body past a cap. Two apps (cofounders-template, first-years-template) carried byte-identical copies; they now import these. The handlers use the same functions, so there is one copy of the door. No behaviour change.
|
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ Styling: structure only, class names prefixed `sac-`. `@supersuit/suggest-a-chan
|
|
|
69
69
|
| `--sac-backdrop` | `rgba(0, 0, 0, 0.45)` | the dim behind the sheet |
|
|
70
70
|
| `--sac-focus` | `#1a56db` | focus rings |
|
|
71
71
|
|
|
72
|
-
The four diagnostics are the app's version, the time, the screen size and the phone type
|
|
72
|
+
The four diagnostics are the app's version, the time, the screen size and the phone type. All four go with every report, with the screen's name and address, and the sheet says so in one small line above Send rather than a section of switches.
|
|
73
73
|
|
|
74
74
|
**Identity.** Every screen asks `adapters.identity()` when it mounts, whenever it is handed a different `adapters` object, and whenever the host calls `refreshIdentity()` (exported from `"./client"`; call it after a sign-in, sign-out or account switch). When the account differs, the old account's rows leave the screen and its waiting reports stay on the phone, to be sent only while that account is signed in; every send re-confirms the account first. Keep `adapters` stable where you can; a parent that rebuilds it every render is safe, only slower (one `identity()` call per render).
|
|
75
75
|
|
|
@@ -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
|
-
|
|
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;
|
package/dist/client/recorder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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/dist/client/sheet.js
CHANGED
|
@@ -69,8 +69,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
69
69
|
const [answers, setAnswers] = useState({ ...NO_ANSWERS, happened: initial?.happened ?? '' });
|
|
70
70
|
const [voice, setVoice] = useState([]);
|
|
71
71
|
const [media, setMedia] = useState([]);
|
|
72
|
-
// The design's example line is the default: version, screen name (always) and phone type. The time and the screen size are opt-in.
|
|
73
|
-
const [on, setOn] = useState({ appVersion: true, at: false, screen: false, device: true });
|
|
74
72
|
const [error, setError] = useState('');
|
|
75
73
|
const [announce, setAnnounce] = useState('');
|
|
76
74
|
const [sent, setSent] = useState(null);
|
|
@@ -253,17 +251,16 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
253
251
|
await rt?.store.del('kv', metaKey(v.key));
|
|
254
252
|
setAnnounce(S.voiceNoteRemoved);
|
|
255
253
|
}
|
|
256
|
-
// What goes with it:
|
|
257
|
-
//
|
|
254
|
+
// What goes with it: the contract's four diagnostics, always sent, with no switches. Whoever reads the report
|
|
255
|
+
// needs the device, the time and the screen to act on it, so leaving one out only ever cost a round trip.
|
|
256
|
+
// The sheet says so in one small FYI line rather than a section to configure.
|
|
258
257
|
const avail = [
|
|
259
258
|
{ name: 'appVersion', label: S.diagAppVersion, value: adapters.about?.version },
|
|
260
259
|
{ name: 'at', label: S.diagAt, value: new Date(adapters.phone?.now?.() ?? Date.now()).toISOString() },
|
|
261
260
|
{ name: 'screen', label: S.diagScreen, value: screenSize() },
|
|
262
261
|
{ name: 'device', label: S.diagDevice, value: adapters.device ? adapters.device() : phoneType() },
|
|
263
262
|
].filter((d) => Boolean(d.value));
|
|
264
|
-
|
|
265
|
-
const isOn = (n) => avail.find((d) => d.name === n && on[d.name])?.label;
|
|
266
|
-
const lineParts = [isOn('appVersion'), S.screenName, isOn('at'), isOn('screen'), isOn('device')].filter((x) => Boolean(x));
|
|
263
|
+
const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)];
|
|
267
264
|
const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`;
|
|
268
265
|
// Answers to the broken questions only count while the kind is broken: they are not sent otherwise.
|
|
269
266
|
const hasAnything = Boolean(words.trim() || voice.length || media.length || (kind === 'broken' && (answers.happened.trim() || answers.expected.trim() || answers.steps.trim())));
|
|
@@ -275,8 +272,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
275
272
|
const d = await saveDraft(rt.store, key, { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] });
|
|
276
273
|
const diagnostics = {};
|
|
277
274
|
for (const x of avail)
|
|
278
|
-
|
|
279
|
-
diagnostics[x.name] = x.value;
|
|
275
|
+
diagnostics[x.name] = x.value;
|
|
280
276
|
const report = {
|
|
281
277
|
id: d.reportId,
|
|
282
278
|
kind,
|
|
@@ -335,5 +331,5 @@ export function SuggestSheet({ adapters, where, onClose, initial }) {
|
|
|
335
331
|
return (_jsx("div", { className: "sac-backdrop", "data-suggest-sheet": "", children: _jsxs("div", { ref: box, className: "sac-sheet", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId, tabIndex: -1, children: [_jsxs("header", { className: "sac-header", children: [_jsx("h2", { id: titleId, className: "sac-title", children: S.sheetTitle }), onClose ? _jsx("button", { type: "button", className: "sac-button sac-close", onClick: onClose, children: S.close }) : null] }), _jsx("div", { className: "sac-sr", "data-sac-live": "", role: "status", "aria-live": "polite", "aria-atomic": "true", children: announce }), who === undefined ? _jsx("p", { children: S.loading }) : who === null ? _jsx("p", { role: "alert", children: S.signedOut }) : !rt ? _jsx("p", { children: S.loading }) : sent ? (_jsxs("div", { className: "sac-sent", children: [_jsx(Receipt, { adapters: adapters, reportId: sent.id, quiet: true, onLine: setAnnounce }), sent.files.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: sent.files.map((f) => {
|
|
336
332
|
const p = rt.part(sent.id, f.file);
|
|
337
333
|
return (_jsxs("li", { className: "sac-item", "data-attachment-state": p.state, children: [_jsxs("span", { className: "sac-item-name", children: [f.name, f.bytes ? `, ${sizeWords(f.bytes)}` : ''] }), _jsxs("span", { className: "sac-item-state", children: [p.state === 'sending' ? _jsx("progress", { "aria-label": `${S.sending} ${f.name}` }) : null, p.state === 'waiting' ? S.waitingToSend : p.state === 'sending' ? S.sending : p.state === 'sent' ? S.sentWord : S.didNotSend(p.reason ?? '')] }), p.state === 'failed' ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(sent.id), children: S.retry }) : null] }, f.file));
|
|
338
|
-
}) })) : null, onClose ? _jsx("button", { type: "button", className: "sac-button sac-primary", onClick: onClose, children: S.done }) : null] })) : (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void send(); }, children: [_jsxs("fieldset", { role: "radiogroup", className: "sac-group sac-kinds", children: [_jsx("legend", { children: S.kindLegend }), kinds.map((k) => (_jsxs("label", { className: "sac-choice", children: [_jsx("input", { type: "radio", name: "sac-kind", value: k.id, checked: kind === k.id, onChange: () => setKind(k.id) }), _jsx("span", { children: k.label })] }, k.id)))] }), kind === 'broken' ? (_jsxs("fieldset", { className: "sac-group", children: [_jsxs("legend", { children: [S.kindBroken, " (", S.optional, ")"] }), fieldFor('happened', S.brokenHappened, answers.happened, (v) => setAnswers((a) => ({ ...a, happened: v }))), fieldFor('expected', S.brokenExpected, answers.expected, (v) => setAnswers((a) => ({ ...a, expected: v }))), fieldFor('steps', S.brokenSteps, answers.steps, (v) => setAnswers((a) => ({ ...a, steps: v })))] })) : null, _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: S.sayItPlaceholder }), voice.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => (_jsxs("li", { className: "sac-item", children: [_jsx("span", { className: "sac-item-name", children: S.voiceNote(i + 1, lengthWords(v.ms)) }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${S.voiceNote(i + 1, lengthWords(v.ms))}`, onClick: () => void removeVoice(v), children: S.remove })] }, v.key))) })) : null] }), _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.showItLegend }), _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => fileInput.current?.click(), children: S.addImages }), _jsx("button", { type: "button", className: "sac-button", onClick: () => videoInput.current?.click(), children: S.addVideo }), adapters.snapshot ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void takeScreenshot(), children: S.takeScreenshot }) : null] }), _jsx("input", { ref: fileInput, type: "file", accept: "image/*", multiple: true, hidden: true, "aria-label": S.addImages, "data-sac-images": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'image'); } }), _jsx("input", { ref: videoInput, type: "file", accept: "video/*", hidden: true, "aria-label": S.addVideo, "data-sac-video": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'video'); } }), media.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => (_jsxs("li", { className: "sac-item", children: [_jsxs("span", { className: "sac-item-name", children: [m.name, ", ", sizeWords(m.bytes)] }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${m.name}`, onClick: () => void removeMedia(m), children: S.remove })] }, m.key))) })) : null] }),
|
|
334
|
+
}) })) : null, onClose ? _jsx("button", { type: "button", className: "sac-button sac-primary", onClick: onClose, children: S.done }) : null] })) : (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void send(); }, children: [_jsxs("fieldset", { role: "radiogroup", className: "sac-group sac-kinds", children: [_jsx("legend", { children: S.kindLegend }), kinds.map((k) => (_jsxs("label", { className: "sac-choice", children: [_jsx("input", { type: "radio", name: "sac-kind", value: k.id, checked: kind === k.id, onChange: () => setKind(k.id) }), _jsx("span", { children: k.label })] }, k.id)))] }), kind === 'broken' ? (_jsxs("fieldset", { className: "sac-group", children: [_jsxs("legend", { children: [S.kindBroken, " (", S.optional, ")"] }), fieldFor('happened', S.brokenHappened, answers.happened, (v) => setAnswers((a) => ({ ...a, happened: v }))), fieldFor('expected', S.brokenExpected, answers.expected, (v) => setAnswers((a) => ({ ...a, expected: v }))), fieldFor('steps', S.brokenSteps, answers.steps, (v) => setAnswers((a) => ({ ...a, steps: v })))] })) : null, _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.sayItLegend }), fieldFor('words', S.sayItLabel, words, setWords, { placeholder: S.sayItPlaceholder }), voice.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => (_jsxs("li", { className: "sac-item", children: [_jsx("span", { className: "sac-item-name", children: S.voiceNote(i + 1, lengthWords(v.ms)) }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${S.voiceNote(i + 1, lengthWords(v.ms))}`, onClick: () => void removeVoice(v), children: S.remove })] }, v.key))) })) : null] }), _jsxs("fieldset", { className: "sac-group", children: [_jsx("legend", { children: S.showItLegend }), _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => fileInput.current?.click(), children: S.addImages }), _jsx("button", { type: "button", className: "sac-button", onClick: () => videoInput.current?.click(), children: S.addVideo }), adapters.snapshot ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void takeScreenshot(), children: S.takeScreenshot }) : null] }), _jsx("input", { ref: fileInput, type: "file", accept: "image/*", multiple: true, hidden: true, "aria-label": S.addImages, "data-sac-images": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'image'); } }), _jsx("input", { ref: videoInput, type: "file", accept: "video/*", hidden: true, "aria-label": S.addVideo, "data-sac-video": "", onChange: (e) => { const f = [...(e.target.files ?? [])]; e.target.value = ''; void addMedia(f, 'video'); } }), media.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => (_jsxs("li", { className: "sac-item", children: [_jsxs("span", { className: "sac-item-name", children: [m.name, ", ", sizeWords(m.bytes)] }), _jsx("button", { type: "button", className: "sac-button", "aria-label": `${S.remove} ${m.name}`, onClick: () => void removeMedia(m), children: S.remove })] }, m.key))) })) : null] }), _jsx("p", { className: "sac-muted sac-fyi", "data-sac-summary": "", children: summary }), error ? _jsx("p", { role: "alert", className: "sac-error", children: error }) : null, _jsxs("div", { className: "sac-actions", children: [_jsx("button", { type: "submit", className: "sac-button sac-primary", disabled: !hasAnything, "aria-describedby": hasAnything ? undefined : `${titleId}-hint`, children: S.send }), hasAnything ? null : _jsx("span", { id: `${titleId}-hint`, className: "sac-muted", children: S.sendHint })] })] })), _jsx(Toasts, {})] }) }));
|
|
339
335
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/suggest-a-change",
|
|
3
|
-
"version": "0.1.
|
|
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": {
|
package/src/client/recorder.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/client/sheet.tsx
CHANGED
|
@@ -75,8 +75,6 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
75
75
|
const [answers, setAnswers] = useState<Answers>({ ...NO_ANSWERS, happened: initial?.happened ?? '' })
|
|
76
76
|
const [voice, setVoice] = useState<Voice[]>([])
|
|
77
77
|
const [media, setMedia] = useState<Media[]>([])
|
|
78
|
-
// The design's example line is the default: version, screen name (always) and phone type. The time and the screen size are opt-in.
|
|
79
|
-
const [on, setOn] = useState<Record<Diag, boolean>>({ appVersion: true, at: false, screen: false, device: true })
|
|
80
78
|
const [error, setError] = useState('')
|
|
81
79
|
const [announce, setAnnounce] = useState('')
|
|
82
80
|
const [sent, setSent] = useState<{ id: string; files: { file: string; name: string; bytes: number }[] } | null>(null)
|
|
@@ -251,17 +249,16 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
251
249
|
setAnnounce(S.voiceNoteRemoved)
|
|
252
250
|
}
|
|
253
251
|
|
|
254
|
-
// What goes with it:
|
|
255
|
-
//
|
|
252
|
+
// What goes with it: the contract's four diagnostics, always sent, with no switches. Whoever reads the report
|
|
253
|
+
// needs the device, the time and the screen to act on it, so leaving one out only ever cost a round trip.
|
|
254
|
+
// The sheet says so in one small FYI line rather than a section to configure.
|
|
256
255
|
const avail = ([
|
|
257
256
|
{ name: 'appVersion', label: S.diagAppVersion, value: adapters.about?.version },
|
|
258
257
|
{ name: 'at', label: S.diagAt, value: new Date(adapters.phone?.now?.() ?? Date.now()).toISOString() },
|
|
259
258
|
{ name: 'screen', label: S.diagScreen, value: screenSize() },
|
|
260
259
|
{ name: 'device', label: S.diagDevice, value: adapters.device ? adapters.device() : phoneType() },
|
|
261
260
|
] as { name: Diag; label: string; value: string | undefined }[]).filter((d): d is { name: Diag; label: string; value: string } => Boolean(d.value))
|
|
262
|
-
|
|
263
|
-
const isOn = (n: Diag) => avail.find((d) => d.name === n && on[d.name])?.label
|
|
264
|
-
const lineParts = [isOn('appVersion'), S.screenName, isOn('at'), isOn('screen'), isOn('device')].filter((x): x is string => Boolean(x))
|
|
261
|
+
const lineParts = [`this screen (${where.screen}, ${sanitizeRoute(where.route)})`, ...avail.map((d) => d.label)]
|
|
265
262
|
const summary = `${S.goesTo(adapters.words.owner)} ${S.includes(lineParts)}`
|
|
266
263
|
|
|
267
264
|
// Answers to the broken questions only count while the kind is broken: they are not sent otherwise.
|
|
@@ -273,7 +270,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
273
270
|
await chain.current
|
|
274
271
|
const d = await saveDraft(rt.store, key, { kind, words, attachments: [...voice.map((v) => v.key), ...media.map((m) => m.key)] })
|
|
275
272
|
const diagnostics: Record<string, string> = {}
|
|
276
|
-
for (const x of avail)
|
|
273
|
+
for (const x of avail) diagnostics[x.name] = x.value
|
|
277
274
|
const report: Record<string, unknown> & { id: string } = {
|
|
278
275
|
id: d.reportId,
|
|
279
276
|
kind,
|
|
@@ -411,17 +408,7 @@ export function SuggestSheet({ adapters, where, onClose, initial }: { adapters:
|
|
|
411
408
|
) : null}
|
|
412
409
|
</fieldset>
|
|
413
410
|
|
|
414
|
-
<
|
|
415
|
-
<legend>{S.withLegend}</legend>
|
|
416
|
-
<p className="sac-summary" data-sac-summary="" aria-live="polite">{summary}</p>
|
|
417
|
-
<p className="sac-muted" data-sac-where="">{S.alwaysSent(where.screen, sanitizeRoute(where.route))}</p>
|
|
418
|
-
{avail.map((d) => (
|
|
419
|
-
<label key={d.name} className="sac-switch">
|
|
420
|
-
<input type="checkbox" role="switch" checked={on[d.name]} aria-label={S.include(d.label)} onChange={(e) => setOn((cur) => ({ ...cur, [d.name]: e.target.checked }))} />
|
|
421
|
-
<span>{d.label}: <span className="sac-muted">{d.value}</span></span>
|
|
422
|
-
</label>
|
|
423
|
-
))}
|
|
424
|
-
</fieldset>
|
|
411
|
+
<p className="sac-muted sac-fyi" data-sac-summary="">{summary}</p>
|
|
425
412
|
|
|
426
413
|
{error ? <p role="alert" className="sac-error">{error}</p> : null}
|
|
427
414
|
<div className="sac-actions">
|
package/src/client/styles.css
CHANGED
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
.sac-entry-dot { position: absolute; top: 6px; right: 6px; width: 10px; height: 10px; border-radius: 50%; background: var(--sac-accent); box-shadow: 0 0 0 2px var(--sac-bg); }
|
|
50
50
|
.sac-new { align-self: flex-start; font-size: 0.8rem; font-weight: 600; padding: 1px 8px; border-radius: 999px; background: var(--sac-accent); color: var(--sac-accent-fg); }
|
|
51
51
|
.sac-muted { color: var(--sac-muted); font-size: 0.9rem; }
|
|
52
|
+
.sac-fyi { font-size: 0.8rem; margin: 0; }
|
|
52
53
|
.sac-error { color: var(--sac-danger); margin: 0; }
|
|
53
54
|
.sac-receipt-line { font-weight: 600; margin: 0; }
|
|
54
55
|
.sac-strip { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 6px; }
|