@supersuit/suggest-a-change 0.1.0 → 0.1.2

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.2 (2026-10-13)
4
+
5
+ - **My suggestions and the detail view draw at once.** Each keeps its last successful answer (`mine()` and each `one(id)`) in the module's own IndexedDB store, keyed by app and account like a draft, and draws it as soon as the phone has read it, with a quiet "Updating" line until the app answers. The app's answer replaces it (a report that vanished on the app disappears). If the app cannot be reached, the saved rows stay and the screen says so: "Could not reach the app. Showing what this phone last saw." A first open with nothing saved shows the loading state, as before. Reports waiting in the outbox show as they did. Another account never reads this account's saved answer. New strings `updating` and `offlineShowingSaved` (replaceable through `words.text`).
6
+ - **`<SuggestEntry role>`.** The entry button takes an ARIA `role` (for a host that shows it as a menu row with `role="menuitem"`), beside `className`. Unset by default.
7
+
8
+ ## 0.1.1 (2026-10-13)
9
+
10
+ - **A report sent with the Mac away is sent when it is back, even with the sheet closed.** The outbox used to move only while the sheet, My suggestions or a detail screen was mounted, so a report waited until the person opened one again. `<SuggestEntry>` now keeps the outbox moving while it is mounted, and `KeepSending` is exported from `./client` for a host with no always-mounted entry (it draws nothing).
11
+ - A voice file's upload link now allows 200,000 bytes per second of declared length, so an uncompressed WAV is not refused.
12
+ - When finalize refuses a file, the pending entry is removed first and the stored object is deleted only if that removal happened.
13
+ - The client test "after Send, focus moves to the receipt" waits for both of its conditions, for longer.
14
+
3
15
  ## 0.1.0 (2026-10-12)
4
16
 
5
17
  First release. The report contract and flow map: kinds, roles, ceilings, statuses, who acts at each step, and report validation.
package/README.md CHANGED
@@ -47,7 +47,9 @@ const adapters: Adapters = {
47
47
  // then: {open && <SuggestSheet adapters={adapters} where={{ screen: "Photos", route: location.pathname }} onClose={() => setOpen(false)} />}
48
48
  ```
49
49
 
50
- The entry carries `data-suggest-entry` (the attribute the screen check looks for; see "The check" below). Signed out, it opens nothing and says why. The receipt says "Got it" only after the adapter has answered `done` for the report and every attachment; until then it says the report is on the phone. Every text field the module renders sits in a `[data-voice-field]` container with a `[data-voice-control]`; a host's `Field` slot must keep that shape (it receives `FieldProps`, which is the Helm's voice field props). A voice note alone is a whole report: the recording is kept and uploaded as a part, and the receiving Mac writes the transcript.
50
+ `SuggestEntry` also takes `className` and `role` (for example `role="menuitem"` when a host shows it as a menu row); no role is set by default. My suggestions and the detail view keep their last answer from the app on the phone (module-owned IndexedDB, keyed by app and account like a draft), draw it at once with a quiet "Updating" line, and replace it when the app answers; if the app cannot be reached they keep the saved rows and say so. A first open with nothing saved shows "Loading".
51
+
52
+ The entry also keeps the phone's outbox moving while it is mounted (a report sent with the Mac away goes when it is back, with the sheet closed); a host with no always-mounted entry renders `<KeepSending adapters={adapters} />` once, high in the app. The entry carries `data-suggest-entry` (the attribute the screen check looks for; see "The check" below). Signed out, it opens nothing and says why. The receipt says "Got it" only after the adapter has answered `done` for the report and every attachment; until then it says the report is on the phone. Every text field the module renders sits in a `[data-voice-field]` container with a `[data-voice-control]`; a host's `Field` slot must keep that shape (it receives `FieldProps`, which is the Helm's voice field props). A voice note alone is a whole report: the recording is kept and uploaded as a part, and the receiving Mac writes the transcript.
51
53
 
52
54
  Styling: structure only, class names prefixed `sac-`. `@supersuit/suggest-a-change/styles.css` is optional defaults (16px inputs so iOS does not zoom, 44px touch targets, safe-area padding on the sheet). Re-skin it with these custom properties:
53
55
 
@@ -11,6 +11,7 @@ import { recoverTakes } from './recorder.js';
11
11
  import { queueFollowUp } from './reports.js';
12
12
  import { useRuntime, useTick } from './runtime.js';
13
13
  import { Toasts } from './toast.js';
14
+ import { oneCacheKey, readCache, writeCache } from './view-cache.js';
14
15
  import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.js';
15
16
  export function statusOf(r) {
16
17
  if (typeof r.status === 'string')
@@ -79,6 +80,8 @@ export function SuggestionDetail({ adapters, id }) {
79
80
  const [note, setNote] = useState('');
80
81
  const [bad, setBad] = useState('');
81
82
  const [ready, setReady] = useState(false);
83
+ const [updating, setUpdating] = useState(false);
84
+ const [offline, setOffline] = useState(false);
82
85
  // The follow-up being written: its id is made when the person starts it, so queuing it twice is one follow-up.
83
86
  const followId = useRef(null);
84
87
  const n = useRef(0);
@@ -88,10 +91,12 @@ export function SuggestionDetail({ adapters, id }) {
88
91
  // Each load takes a number; only the newest may paint, so a load still running for the account that
89
92
  // was just replaced never draws its rows under the new one.
90
93
  const loads = useRef(0);
94
+ // True once a report from the phone's copy or the app is on screen for this id and account.
95
+ const shown = useRef(false);
91
96
  // Anything that writes the follow-up waits until its draft has been read, so the counter is right.
92
97
  const restored = useRef(makeDeferred());
93
98
  // A different account (or none) shows nothing of the last one.
94
- useEffect(() => { loads.current++; setReport(undefined); setPending([]); setReady(false); setMore(''); setHeard([]); followId.current = null; n.current = 0; restored.current = makeDeferred(); }, [rt]);
99
+ useEffect(() => { loads.current++; shown.current = false; setUpdating(false); setOffline(false); setReport(undefined); setPending([]); setReady(false); setMore(''); setHeard([]); followId.current = null; n.current = 0; restored.current = makeDeferred(); }, [rt]);
95
100
  const load = useCallback(async () => {
96
101
  if (!rt)
97
102
  return;
@@ -109,9 +114,38 @@ export function SuggestionDetail({ adapters, id }) {
109
114
  return;
110
115
  }
111
116
  setRefusal(undefined);
112
- const r = (await adapters.store.one(id));
113
- if (fresh())
117
+ const ckey = oneCacheKey({ app: adapters.words.app, account: rt.who.account }, id);
118
+ // Draw at once from the phone's copy, then ask the app.
119
+ if (!shown.current) {
120
+ const saved = await readCache(rt.store, ckey);
121
+ if (!fresh())
122
+ return;
123
+ if (saved && typeof saved === 'object') {
124
+ shown.current = true;
125
+ setReport(saved);
126
+ setUpdating(true);
127
+ }
128
+ }
129
+ let r;
130
+ try {
131
+ r = (await adapters.store.one(id));
132
+ }
133
+ catch {
134
+ r = undefined;
135
+ }
136
+ if (!fresh())
137
+ return;
138
+ setUpdating(false);
139
+ if (r) {
140
+ shown.current = true;
141
+ setOffline(false);
114
142
  setReport(r);
143
+ void writeCache(rt.store, ckey, r);
144
+ }
145
+ else if (shown.current)
146
+ setOffline(true);
147
+ else
148
+ setReport(null);
115
149
  }
116
150
  catch {
117
151
  if (fresh())
@@ -211,5 +245,5 @@ export function SuggestionDetail({ adapters, id }) {
211
245
  setHeard((cur) => [...cur, { key: k, file, ms }]);
212
246
  return { audio: k, text: '' };
213
247
  }
214
- return (_jsxs("article", { className: "sac-detail", "data-report": id, children: [_jsx("h3", { className: "sac-status", "data-status": status, children: statusWords(status, owner) }), _jsxs("p", { className: "sac-muted", children: [statusMeaning(status, owner), status === 'live' && typeof report.liveAt === 'string' ? ` (${new Date(report.liveAt).toLocaleDateString()})` : ''] }), refusal ? (_jsxs(_Fragment, { children: [_jsx("p", { role: "alert", className: "sac-error", children: refusal }), rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null] })) : null, _jsx(StatusStrip, { status: status, owner: owner, label: S.stepLabel, step: stepOf(report), timeline: timelineOf(report) }), typeof report.words === 'string' && report.words ? _jsx("p", { className: "sac-words", children: report.words }) : null, Object.entries(answers).filter(([, v]) => v).map(([k, v]) => _jsx("p", { className: "sac-words", "data-answer": k, children: v }, k)), voice.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(v.ms)) }, v.file)) }) : null, media.length ? _jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => _jsxs("li", { className: "sac-item", children: [m.file, ", ", sizeWords(m.bytes)] }, m.file)) }) : null, Array.isArray(report.added) ? report.added.map((a, i) => _jsx("p", { className: "sac-words", children: a.words }, i)) : null, pending.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.waitingToSend, "data-followups": "", children: pending.map((f) => (_jsxs("li", { className: "sac-item", "data-followup": f.followId, children: [_jsx("span", { className: "sac-words", children: f.words || S.attachmentsLabel }), f.error ? _jsx("span", { role: "alert", className: "sac-error", children: f.error }) : _jsx("span", { className: "sac-muted", children: S.waitingToSend }), f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null, f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.discardFollowUp(id, f.followId), children: S.discard }) : null] }, f.followId))) })) : null, open ? (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void add(); }, children: [rt ? (_jsx(Field, { rt: { store: rt.store }, id: `append-${id}`, label: S.appendLabel, value: more, onChange: setMore, multiline: true, persist: false, startTake: adapters.startTake, maxMs: ceilings.voiceMs, scope: key ?? undefined, labels: labels, ...(adapters.appendVoice === false ? {} : { upload: (k, b, ms) => keepHeard(k, b, ms) }) })) : null, heard.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: heard.map((h, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(h.ms)) }, h.key)) }) : null, bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, note ? _jsx("p", { role: "status", children: note }) : null, _jsx("button", { type: "submit", className: "sac-button", disabled: !more.trim() && !heard.length, children: S.appendSend })] })) : null, _jsx(Toasts, {})] }));
248
+ return (_jsxs("article", { className: "sac-detail", "data-report": id, children: [_jsx("h3", { className: "sac-status", "data-status": status, children: statusWords(status, owner) }), _jsxs("p", { className: "sac-muted", children: [statusMeaning(status, owner), status === 'live' && typeof report.liveAt === 'string' ? ` (${new Date(report.liveAt).toLocaleDateString()})` : ''] }), refusal ? (_jsxs(_Fragment, { children: [_jsx("p", { role: "alert", className: "sac-error", children: refusal }), rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null] })) : null, updating ? _jsx("p", { role: "status", className: "sac-muted", "data-updating": "", children: S.updating }) : null, offline ? _jsx("p", { role: "alert", className: "sac-error", children: S.offlineShowingSaved }) : null, _jsx(StatusStrip, { status: status, owner: owner, label: S.stepLabel, step: stepOf(report), timeline: timelineOf(report) }), typeof report.words === 'string' && report.words ? _jsx("p", { className: "sac-words", children: report.words }) : null, Object.entries(answers).filter(([, v]) => v).map(([k, v]) => _jsx("p", { className: "sac-words", "data-answer": k, children: v }, k)), voice.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: voice.map((v, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(v.ms)) }, v.file)) }) : null, media.length ? _jsx("ul", { className: "sac-list", "aria-label": S.attachmentsLabel, children: media.map((m) => _jsxs("li", { className: "sac-item", children: [m.file, ", ", sizeWords(m.bytes)] }, m.file)) }) : null, Array.isArray(report.added) ? report.added.map((a, i) => _jsx("p", { className: "sac-words", children: a.words }, i)) : null, pending.length ? (_jsx("ul", { className: "sac-list", "aria-label": S.waitingToSend, "data-followups": "", children: pending.map((f) => (_jsxs("li", { className: "sac-item", "data-followup": f.followId, children: [_jsx("span", { className: "sac-words", children: f.words || S.attachmentsLabel }), f.error ? _jsx("span", { role: "alert", className: "sac-error", children: f.error }) : _jsx("span", { className: "sac-muted", children: S.waitingToSend }), f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(id), children: S.retry }) : null, f.error && rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.discardFollowUp(id, f.followId), children: S.discard }) : null] }, f.followId))) })) : null, open ? (_jsxs("form", { className: "sac-form", onSubmit: (e) => { e.preventDefault(); void add(); }, children: [rt ? (_jsx(Field, { rt: { store: rt.store }, id: `append-${id}`, label: S.appendLabel, value: more, onChange: setMore, multiline: true, persist: false, startTake: adapters.startTake, maxMs: ceilings.voiceMs, scope: key ?? undefined, labels: labels, ...(adapters.appendVoice === false ? {} : { upload: (k, b, ms) => keepHeard(k, b, ms) }) })) : null, heard.length ? _jsx("ul", { className: "sac-list", "aria-label": S.voiceNotesLabel, children: heard.map((h, i) => _jsx("li", { className: "sac-item", children: S.voiceNote(i + 1, lengthWords(h.ms)) }, h.key)) }) : null, bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, note ? _jsx("p", { role: "status", children: note }) : null, _jsx("button", { type: "submit", className: "sac-button", disabled: !more.trim() && !heard.length, children: S.appendSend })] })) : null, _jsx(Toasts, {})] }));
215
249
  }
@@ -1,8 +1,15 @@
1
+ import { type AriaRole } from 'react';
1
2
  import type { Adapters } from './adapters.ts';
2
- export declare function SuggestEntry({ adapters, onOpen, className }: {
3
+ /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
4
+ * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
5
+ export declare function KeepSending({ adapters }: {
6
+ adapters: Adapters;
7
+ }): null;
8
+ export declare function SuggestEntry({ adapters, onOpen, className, role }: {
3
9
  adapters: Adapters;
4
10
  onOpen(): void;
5
- className?: string;
11
+ className?: string; /** An ARIA role for the button (for example `menuitem` when the host shows it as a menu row). Unset by default. */
12
+ role?: AriaRole;
6
13
  }): import("react").JSX.Element;
7
14
  /** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
8
15
  export declare function ReportProblem({ adapters, screen, error }: {
@@ -4,9 +4,16 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
4
4
  // Signed out, it opens nothing and says why. Signed in, it hands over to the host (`onOpen`), which
5
5
  // shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
6
6
  import { useEffect, useState } from 'react';
7
+ import { useRuntime } from './runtime.js';
7
8
  import { SuggestSheet } from './sheet.js';
8
9
  import { stringsFor } from './words.js';
9
- export function SuggestEntry({ adapters, onOpen, className }) {
10
+ /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
11
+ * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
12
+ export function KeepSending({ adapters }) {
13
+ useRuntime(adapters);
14
+ return null;
15
+ }
16
+ export function SuggestEntry({ adapters, onOpen, className, role }) {
10
17
  const S = stringsFor(adapters.words);
11
18
  const [why, setWhy] = useState('');
12
19
  const [busy, setBusy] = useState(false);
@@ -28,7 +35,7 @@ export function SuggestEntry({ adapters, onOpen, className }) {
28
35
  setWhy('');
29
36
  onOpen();
30
37
  }
31
- return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsx("button", { type: "button", "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: S.entry }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null] }));
38
+ return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsx(KeepSending, { adapters: adapters }), _jsx("button", { type: "button", role: role, "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: S.entry }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null] }));
32
39
  }
33
40
  /** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
34
41
  export function ReportProblem({ adapters, screen, error }) {
@@ -7,6 +7,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
7
7
  import { useCallback, useEffect, useRef, useState } from 'react';
8
8
  import { SuggestionDetail } from './detail.js';
9
9
  import { useRuntime, useTick } from './runtime.js';
10
+ import { mineCacheKey, readCache, writeCache } from './view-cache.js';
10
11
  import { Toasts } from './toast.js';
11
12
  import { statusMeaning, statusWords, stringsFor } from './words.js';
12
13
  export function MySuggestions({ adapters, onOpen }) {
@@ -17,38 +18,72 @@ export function MySuggestions({ adapters, onOpen }) {
17
18
  const [rows, setRows] = useState(null);
18
19
  const [open, setOpen] = useState(null);
19
20
  const [bad, setBad] = useState('');
21
+ const [updating, setUpdating] = useState(false);
20
22
  // Another account (or none) shows nothing of the last one's rows.
21
23
  const loads = useRef(0);
22
- useEffect(() => { loads.current++; setRows(null); setOpen(null); }, [rt]);
24
+ // What the app last said (or the phone kept of it) for this account; null until either is known.
25
+ const known = useRef(null);
26
+ useEffect(() => { loads.current++; known.current = null; setRows(null); setOpen(null); setBad(''); setUpdating(false); }, [rt]);
23
27
  const load = useCallback(async () => {
24
28
  if (!rt)
25
29
  return;
26
30
  // Only the newest load may paint: one still running for the account just replaced must not draw its rows.
27
31
  const mine_ = ++loads.current;
28
- let theirs = [];
29
- let failed = false;
32
+ const key = mineCacheKey({ app: adapters.words.app, account: rt.who.account });
33
+ const paint = (theirs, local) => {
34
+ const waiting = new Map(local.map((l) => [l.id, l]));
35
+ // A report the app already lists keeps its own status; one still on the phone is listed as on the phone.
36
+ const phone = local.filter((l) => !l.onlyFollowUp && !theirs.some((t) => t.id === l.id)).map((l) => ({ id: l.id, status: 'on-phone', at: l.at, ...(l.words ? { words: l.words } : {}), ...(l.error ? { error: l.error } : {}) }));
37
+ setRows([...theirs.map((t) => ({ ...t, ...(waiting.get(t.id)?.error ? { error: waiting.get(t.id).error } : {}) })), ...phone].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)));
38
+ };
39
+ // Draw at once from what the phone already holds, then ask the app.
40
+ if (known.current === null) {
41
+ const saved = await readCache(rt.store, key);
42
+ if (mine_ !== loads.current)
43
+ return;
44
+ if (Array.isArray(saved))
45
+ known.current = saved;
46
+ }
47
+ if (known.current !== null) {
48
+ const local = await rt.waiting();
49
+ if (mine_ !== loads.current)
50
+ return;
51
+ paint(known.current, local);
52
+ setUpdating(true);
53
+ }
54
+ let theirs = null;
30
55
  try {
31
56
  theirs = await adapters.store.mine();
32
57
  }
33
58
  catch {
34
- failed = true;
59
+ theirs = null;
35
60
  }
36
61
  const local = await rt.waiting();
37
62
  if (mine_ !== loads.current)
38
63
  return;
39
- setBad(failed ? S.couldNotLoadMine : '');
40
- const waiting = new Map(local.map((l) => [l.id, l]));
41
- // A report the app already lists keeps its own status; one still on the phone is listed as on the phone.
42
- const phone = local.filter((l) => !l.onlyFollowUp && !theirs.some((t) => t.id === l.id)).map((l) => ({ id: l.id, status: 'on-phone', at: l.at, ...(l.words ? { words: l.words } : {}), ...(l.error ? { error: l.error } : {}) }));
43
- setRows([...theirs.map((t) => ({ ...t, ...(waiting.get(t.id)?.error ? { error: waiting.get(t.id).error } : {}) })), ...phone].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)));
44
- }, [adapters, rt, S.couldNotLoadMine]);
64
+ setUpdating(false);
65
+ if (theirs) {
66
+ known.current = theirs;
67
+ void writeCache(rt.store, key, theirs);
68
+ setBad('');
69
+ paint(theirs, local);
70
+ }
71
+ else if (known.current !== null) {
72
+ setBad(S.offlineShowingSaved);
73
+ paint(known.current, local);
74
+ }
75
+ else {
76
+ setBad(S.couldNotLoadMine);
77
+ paint([], local);
78
+ }
79
+ }, [adapters, rt, S.couldNotLoadMine, S.offlineShowingSaved]);
45
80
  useEffect(() => { void load(); }, [load, tick]);
46
81
  if (who === null)
47
82
  return _jsx("p", { role: "alert", children: S.signedOutMine });
48
83
  if (open && !onOpen) {
49
84
  return (_jsxs("section", { className: "sac-mine", children: [_jsx("button", { type: "button", className: "sac-button", onClick: () => setOpen(null), children: S.back }), _jsx(SuggestionDetail, { adapters: adapters, id: open })] }));
50
85
  }
51
- return (_jsxs("section", { className: "sac-mine", "aria-labelledby": "sac-mine-title", children: [_jsx("h2", { id: "sac-mine-title", className: "sac-title", children: S.mineTitle }), bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, rows === null ? _jsx("p", { children: S.loading }) : rows.length === 0 ? _jsx("p", { children: S.mineEmpty }) : (_jsx("ul", { className: "sac-list", children: rows.map((r) => {
86
+ return (_jsxs("section", { className: "sac-mine", "aria-labelledby": "sac-mine-title", children: [_jsx("h2", { id: "sac-mine-title", className: "sac-title", children: S.mineTitle }), bad ? _jsx("p", { role: "alert", className: "sac-error", children: bad }) : null, updating ? _jsx("p", { role: "status", className: "sac-muted", "data-updating": "", children: S.updating }) : null, rows === null ? _jsx("p", { children: S.loading }) : rows.length === 0 ? _jsx("p", { children: S.mineEmpty }) : (_jsx("ul", { className: "sac-list", children: rows.map((r) => {
52
87
  const preview = typeof r.words === 'string' ? r.words : typeof r.title === 'string' ? r.title : '';
53
88
  return (_jsxs("li", { className: "sac-row", "data-status": r.status, children: [_jsxs("button", { type: "button", className: "sac-row-button", onClick: () => (onOpen ? onOpen(r.id) : setOpen(r.id)), children: [preview ? _jsx("span", { className: "sac-row-words", children: preview }) : null, _jsx("span", { className: "sac-row-status", children: statusWords(r.status, owner) }), _jsx("span", { className: "sac-muted", children: statusMeaning(r.status, owner) })] }), r.error ? (_jsxs(_Fragment, { children: [_jsx("p", { role: "alert", className: "sac-error", children: r.error }), rt ? _jsx("button", { type: "button", className: "sac-button", onClick: () => void rt.retry(r.id), children: S.retry }) : null] })) : null] }, r.id));
54
89
  }) })), _jsx(Toasts, {})] }));
@@ -0,0 +1,11 @@
1
+ import type { Store } from './idb.ts';
2
+ export declare const mineCacheKey: (id: {
3
+ app: string;
4
+ account: string;
5
+ }) => string;
6
+ export declare const oneCacheKey: (id: {
7
+ app: string;
8
+ account: string;
9
+ }, reportId: string) => string;
10
+ export declare function readCache<T>(store: Store, key: string): Promise<T | undefined>;
11
+ export declare function writeCache(store: Store, key: string, value: unknown): Promise<void>;
@@ -0,0 +1,21 @@
1
+ // THE LAST ANSWER, KEPT ON THE PHONE. My suggestions and the detail view draw from this at once and then
2
+ // refresh from the app, so opening them never waits on the app being reachable. The key names the app and
3
+ // the account exactly as a draft's does, so one person's cache is never read for another.
4
+ import { draftKey } from './drafts.js';
5
+ const base = (id) => `${draftKey(id)}#view`;
6
+ export const mineCacheKey = (id) => `${base(id)}:mine`;
7
+ export const oneCacheKey = (id, reportId) => `${base(id)}:one:${reportId}`;
8
+ export async function readCache(store, key) {
9
+ try {
10
+ return await store.get('kv', key);
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ export async function writeCache(store, key, value) {
17
+ try {
18
+ await store.put('kv', key, value);
19
+ }
20
+ catch { /* a full or blocked database only costs the next open its head start */ }
21
+ }
@@ -85,6 +85,8 @@ export interface Strings {
85
85
  screenshotFailed: string;
86
86
  couldNotOpen: string;
87
87
  couldNotLoadMine: string;
88
+ updating: string;
89
+ offlineShowingSaved: string;
88
90
  addFailed: string;
89
91
  overLimit: string;
90
92
  stepLabel: string;
@@ -79,6 +79,8 @@ export const DEFAULT_STRINGS = {
79
79
  screenshotFailed: 'The screenshot could not be taken.',
80
80
  couldNotOpen: 'This suggestion could not be opened.',
81
81
  couldNotLoadMine: 'Your suggestions could not be loaded.',
82
+ updating: 'Updating',
83
+ offlineShowingSaved: 'Could not reach the app. Showing what this phone last saw.',
82
84
  addFailed: 'That could not be added. Try again.',
83
85
  overLimit: 'Something in this suggestion is over a limit. Remove an attachment and try again.',
84
86
  stepLabel: 'Where it is',
@@ -158,7 +158,7 @@ export declare const APPROVAL_LEASE_MS: number;
158
158
  export declare const READ_TTL_MS: number;
159
159
  export declare const VOICE_FILE_BYTES: number;
160
160
  /** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
161
- export declare const VOICE_BYTES_PER_SECOND = 96000;
161
+ export declare const VOICE_BYTES_PER_SECOND = 200000;
162
162
  /** How long a report with missing attachments waits before it is offered to a Mac anyway. */
163
163
  export declare const INCOMPLETE_GRACE_MS: number;
164
164
  export declare function createReportHandlers(opts: ReportHandlerOptions): {
@@ -26,7 +26,7 @@ export const APPROVAL_LEASE_MS = 7 * 24 * 3600_000;
26
26
  export const READ_TTL_MS = 5 * 60_000;
27
27
  export const VOICE_FILE_BYTES = 25 * 2 ** 20;
28
28
  /** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
29
- export const VOICE_BYTES_PER_SECOND = 96_000;
29
+ export const VOICE_BYTES_PER_SECOND = 200_000;
30
30
  /** How long a report with missing attachments waits before it is offered to a Mac anyway. */
31
31
  export const INCOMPLETE_GRACE_MS = 60 * 60_000;
32
32
  const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
@@ -374,8 +374,9 @@ export function createReportHandlers(opts) {
374
374
  return json(409, { error: 'that file has not arrived yet' });
375
375
  const declared = declaredFile(r, file);
376
376
  const refuse = async (error) => {
377
- await store.discard({ reportId: id, file }); // nothing unverified stays in storage
378
- await store.setFile(id, file, null);
377
+ // Drop the pending entry first and delete the object only if that really removed it: a verified entry that won a race is never orphaned.
378
+ if (await store.setFile(id, file, null))
379
+ await store.discard({ reportId: id, file }); // nothing unverified stays in storage
379
380
  return json(422, { error });
380
381
  };
381
382
  if (!sameType(state.pending.type, real.type) || !sameType(declared?.type ?? state.pending.type, real.type))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
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": {
@@ -11,6 +11,7 @@ import { recoverTakes } from './recorder.ts'
11
11
  import { queueFollowUp, type Report } from './reports.ts'
12
12
  import { useRuntime, useTick, type FollowUp } from './runtime.ts'
13
13
  import { Toasts } from './toast.tsx'
14
+ import { oneCacheKey, readCache, writeCache } from './view-cache.ts'
14
15
  import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.ts'
15
16
 
16
17
  export function statusOf(r: Record<string, unknown>): string {
@@ -84,6 +85,8 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
84
85
  const [note, setNote] = useState('')
85
86
  const [bad, setBad] = useState('')
86
87
  const [ready, setReady] = useState(false)
88
+ const [updating, setUpdating] = useState(false)
89
+ const [offline, setOffline] = useState(false)
87
90
  // The follow-up being written: its id is made when the person starts it, so queuing it twice is one follow-up.
88
91
  const followId = useRef<string | null>(null)
89
92
  const n = useRef(0)
@@ -94,11 +97,13 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
94
97
  // Each load takes a number; only the newest may paint, so a load still running for the account that
95
98
  // was just replaced never draws its rows under the new one.
96
99
  const loads = useRef(0)
100
+ // True once a report from the phone's copy or the app is on screen for this id and account.
101
+ const shown = useRef(false)
97
102
  // Anything that writes the follow-up waits until its draft has been read, so the counter is right.
98
103
  const restored = useRef(makeDeferred())
99
104
 
100
105
  // A different account (or none) shows nothing of the last one.
101
- useEffect(() => { loads.current++; setReport(undefined); setPending([]); setReady(false); setMore(''); setHeard([]); followId.current = null; n.current = 0; restored.current = makeDeferred() }, [rt])
106
+ useEffect(() => { loads.current++; shown.current = false; setUpdating(false); setOffline(false); setReport(undefined); setPending([]); setReady(false); setMore(''); setHeard([]); followId.current = null; n.current = 0; restored.current = makeDeferred() }, [rt])
102
107
 
103
108
  const load = useCallback(async () => {
104
109
  if (!rt) return
@@ -111,8 +116,20 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
111
116
  setPending(waiting)
112
117
  if (mine) { setReport({ ...(mine.report as Report & Record<string, unknown>), status: 'on-phone' }); setRefusal(mine.error); return }
113
118
  setRefusal(undefined)
114
- const r = (await adapters.store.one(id)) as Report & Record<string, unknown>
115
- if (fresh()) setReport(r)
119
+ const ckey = oneCacheKey({ app: adapters.words.app, account: rt.who.account }, id)
120
+ // Draw at once from the phone's copy, then ask the app.
121
+ if (!shown.current) {
122
+ const saved = await readCache<Report & Record<string, unknown>>(rt.store, ckey)
123
+ if (!fresh()) return
124
+ if (saved && typeof saved === 'object') { shown.current = true; setReport(saved); setUpdating(true) }
125
+ }
126
+ let r: (Report & Record<string, unknown>) | undefined
127
+ try { r = (await adapters.store.one(id)) as Report & Record<string, unknown> } catch { r = undefined }
128
+ if (!fresh()) return
129
+ setUpdating(false)
130
+ if (r) { shown.current = true; setOffline(false); setReport(r); void writeCache(rt.store, ckey, r) }
131
+ else if (shown.current) setOffline(true)
132
+ else setReport(null)
116
133
  } catch { if (fresh()) setReport(null) }
117
134
  }, [adapters, id, rt])
118
135
  useEffect(() => { void load() }, [load, tick])
@@ -203,6 +220,8 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
203
220
  {rt ? <button type="button" className="sac-button" onClick={() => void rt.retry(id)}>{S.retry}</button> : null}
204
221
  </>
205
222
  ) : null}
223
+ {updating ? <p role="status" className="sac-muted" data-updating="">{S.updating}</p> : null}
224
+ {offline ? <p role="alert" className="sac-error">{S.offlineShowingSaved}</p> : null}
206
225
  <StatusStrip status={status} owner={owner} label={S.stepLabel} step={stepOf(report)} timeline={timelineOf(report)} />
207
226
  {typeof report.words === 'string' && report.words ? <p className="sac-words">{report.words}</p> : null}
208
227
  {Object.entries(answers).filter(([, v]) => v).map(([k, v]) => <p key={k} className="sac-words" data-answer={k}>{v}</p>)}
@@ -2,12 +2,20 @@
2
2
  // THE ENTRY: one control, `data-suggest-entry`, which is the contract the screen checker looks for.
3
3
  // Signed out, it opens nothing and says why. Signed in, it hands over to the host (`onOpen`), which
4
4
  // shows the sheet. `<ReportProblem>` is the same control for an error screen, already filled in.
5
- import { useEffect, useState } from 'react'
5
+ import { useEffect, useState, type AriaRole } from 'react'
6
6
  import type { Adapters } from './adapters.ts'
7
+ import { useRuntime } from './runtime.ts'
7
8
  import { SuggestSheet } from './sheet.tsx'
8
9
  import { stringsFor } from './words.ts'
9
10
 
10
- export function SuggestEntry({ adapters, onOpen, className }: { adapters: Adapters; onOpen(): void; className?: string }) {
11
+ /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
12
+ * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
13
+ export function KeepSending({ adapters }: { adapters: Adapters }) {
14
+ useRuntime(adapters)
15
+ return null
16
+ }
17
+
18
+ export function SuggestEntry({ adapters, onOpen, className, role }: { adapters: Adapters; onOpen(): void; className?: string; /** An ARIA role for the button (for example `menuitem` when the host shows it as a menu row). Unset by default. */ role?: AriaRole }) {
11
19
  const S = stringsFor(adapters.words)
12
20
  const [why, setWhy] = useState('')
13
21
  const [busy, setBusy] = useState(false)
@@ -23,7 +31,8 @@ export function SuggestEntry({ adapters, onOpen, className }: { adapters: Adapte
23
31
  }
24
32
  return (
25
33
  <span className="sac-entry-wrap">
26
- <button type="button" data-suggest-entry="" className={`sac-entry${className ? ` ${className}` : ''}`} aria-busy={busy || undefined} onClick={() => void press()}>{S.entry}</button>
34
+ <KeepSending adapters={adapters} />
35
+ <button type="button" role={role} data-suggest-entry="" className={`sac-entry${className ? ` ${className}` : ''}`} aria-busy={busy || undefined} onClick={() => void press()}>{S.entry}</button>
27
36
  {why ? <span role="status" className="sac-entry-why">{why}</span> : null}
28
37
  </span>
29
38
  )
@@ -8,6 +8,7 @@ import type { Adapters } from './adapters.ts'
8
8
  import { SuggestionDetail } from './detail.tsx'
9
9
  import type { Summary } from './reports.ts'
10
10
  import { useRuntime, useTick } from './runtime.ts'
11
+ import { mineCacheKey, readCache, writeCache } from './view-cache.ts'
11
12
  import { Toasts } from './toast.tsx'
12
13
  import { statusMeaning, statusWords, stringsFor } from './words.ts'
13
14
 
@@ -21,26 +22,55 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
21
22
  const [rows, setRows] = useState<Row[] | null>(null)
22
23
  const [open, setOpen] = useState<string | null>(null)
23
24
  const [bad, setBad] = useState('')
25
+ const [updating, setUpdating] = useState(false)
24
26
 
25
27
  // Another account (or none) shows nothing of the last one's rows.
26
28
  const loads = useRef(0)
27
- useEffect(() => { loads.current++; setRows(null); setOpen(null) }, [rt])
29
+ // What the app last said (or the phone kept of it) for this account; null until either is known.
30
+ const known = useRef<Summary[] | null>(null)
31
+ useEffect(() => { loads.current++; known.current = null; setRows(null); setOpen(null); setBad(''); setUpdating(false) }, [rt])
28
32
 
29
33
  const load = useCallback(async () => {
30
34
  if (!rt) return
31
35
  // Only the newest load may paint: one still running for the account just replaced must not draw its rows.
32
36
  const mine_ = ++loads.current
33
- let theirs: Summary[] = []
34
- let failed = false
35
- try { theirs = await adapters.store.mine() } catch { failed = true }
37
+ const key = mineCacheKey({ app: adapters.words.app, account: rt.who.account })
38
+ const paint = (theirs: Summary[], local: Awaited<ReturnType<typeof rt.waiting>>) => {
39
+ const waiting = new Map(local.map((l) => [l.id, l]))
40
+ // A report the app already lists keeps its own status; one still on the phone is listed as on the phone.
41
+ const phone: Row[] = local.filter((l) => !l.onlyFollowUp && !theirs.some((t) => t.id === l.id)).map((l) => ({ id: l.id, status: 'on-phone', at: l.at, ...(l.words ? { words: l.words } : {}), ...(l.error ? { error: l.error } : {}) }))
42
+ setRows([...theirs.map((t) => ({ ...t, ...(waiting.get(t.id)?.error ? { error: waiting.get(t.id)!.error } : {}) })), ...phone].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)))
43
+ }
44
+ // Draw at once from what the phone already holds, then ask the app.
45
+ if (known.current === null) {
46
+ const saved = await readCache<Summary[]>(rt.store, key)
47
+ if (mine_ !== loads.current) return
48
+ if (Array.isArray(saved)) known.current = saved
49
+ }
50
+ if (known.current !== null) {
51
+ const local = await rt.waiting()
52
+ if (mine_ !== loads.current) return
53
+ paint(known.current, local)
54
+ setUpdating(true)
55
+ }
56
+ let theirs: Summary[] | null = null
57
+ try { theirs = await adapters.store.mine() } catch { theirs = null }
36
58
  const local = await rt.waiting()
37
59
  if (mine_ !== loads.current) return
38
- setBad(failed ? S.couldNotLoadMine : '')
39
- const waiting = new Map(local.map((l) => [l.id, l]))
40
- // A report the app already lists keeps its own status; one still on the phone is listed as on the phone.
41
- const phone: Row[] = local.filter((l) => !l.onlyFollowUp && !theirs.some((t) => t.id === l.id)).map((l) => ({ id: l.id, status: 'on-phone', at: l.at, ...(l.words ? { words: l.words } : {}), ...(l.error ? { error: l.error } : {}) }))
42
- setRows([...theirs.map((t) => ({ ...t, ...(waiting.get(t.id)?.error ? { error: waiting.get(t.id)!.error } : {}) })), ...phone].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)))
43
- }, [adapters, rt, S.couldNotLoadMine])
60
+ setUpdating(false)
61
+ if (theirs) {
62
+ known.current = theirs
63
+ void writeCache(rt.store, key, theirs)
64
+ setBad('')
65
+ paint(theirs, local)
66
+ } else if (known.current !== null) {
67
+ setBad(S.offlineShowingSaved)
68
+ paint(known.current, local)
69
+ } else {
70
+ setBad(S.couldNotLoadMine)
71
+ paint([], local)
72
+ }
73
+ }, [adapters, rt, S.couldNotLoadMine, S.offlineShowingSaved])
44
74
  useEffect(() => { void load() }, [load, tick])
45
75
 
46
76
  if (who === null) return <p role="alert">{S.signedOutMine}</p>
@@ -56,6 +86,7 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
56
86
  <section className="sac-mine" aria-labelledby="sac-mine-title">
57
87
  <h2 id="sac-mine-title" className="sac-title">{S.mineTitle}</h2>
58
88
  {bad ? <p role="alert" className="sac-error">{bad}</p> : null}
89
+ {updating ? <p role="status" className="sac-muted" data-updating="">{S.updating}</p> : null}
59
90
  {rows === null ? <p>{S.loading}</p> : rows.length === 0 ? <p>{S.mineEmpty}</p> : (
60
91
  <ul className="sac-list">
61
92
  {rows.map((r) => {
@@ -0,0 +1,16 @@
1
+ // THE LAST ANSWER, KEPT ON THE PHONE. My suggestions and the detail view draw from this at once and then
2
+ // refresh from the app, so opening them never waits on the app being reachable. The key names the app and
3
+ // the account exactly as a draft's does, so one person's cache is never read for another.
4
+ import { draftKey } from './drafts.ts'
5
+ import type { Store } from './idb.ts'
6
+
7
+ const base = (id: { app: string; account: string }) => `${draftKey(id)}#view`
8
+ export const mineCacheKey = (id: { app: string; account: string }) => `${base(id)}:mine`
9
+ export const oneCacheKey = (id: { app: string; account: string }, reportId: string) => `${base(id)}:one:${reportId}`
10
+
11
+ export async function readCache<T>(store: Store, key: string): Promise<T | undefined> {
12
+ try { return await store.get<T>('kv', key) } catch { return undefined }
13
+ }
14
+ export async function writeCache(store: Store, key: string, value: unknown): Promise<void> {
15
+ try { await store.put('kv', key, value) } catch { /* a full or blocked database only costs the next open its head start */ }
16
+ }
@@ -91,6 +91,8 @@ export interface Strings {
91
91
  screenshotFailed: string
92
92
  couldNotOpen: string
93
93
  couldNotLoadMine: string
94
+ updating: string
95
+ offlineShowingSaved: string
94
96
  addFailed: string
95
97
  overLimit: string
96
98
  stepLabel: string
@@ -172,6 +174,8 @@ export const DEFAULT_STRINGS: Strings = {
172
174
  screenshotFailed: 'The screenshot could not be taken.',
173
175
  couldNotOpen: 'This suggestion could not be opened.',
174
176
  couldNotLoadMine: 'Your suggestions could not be loaded.',
177
+ updating: 'Updating',
178
+ offlineShowingSaved: 'Could not reach the app. Showing what this phone last saw.',
175
179
  addFailed: 'That could not be added. Try again.',
176
180
  overLimit: 'Something in this suggestion is over a limit. Remove an attachment and try again.',
177
181
  stepLabel: 'Where it is',
@@ -108,7 +108,7 @@ export const APPROVAL_LEASE_MS = 7 * 24 * 3600_000
108
108
  export const READ_TTL_MS = 5 * 60_000
109
109
  export const VOICE_FILE_BYTES = 25 * 2 ** 20
110
110
  /** A voice file may be this many bytes per declared second (768 kbit/s, generous for any recorder) plus a little slack: ten seconds cannot claim 25 MB. */
111
- export const VOICE_BYTES_PER_SECOND = 96_000
111
+ export const VOICE_BYTES_PER_SECOND = 200_000
112
112
  /** How long a report with missing attachments waits before it is offered to a Mac anyway. */
113
113
  export const INCOMPLETE_GRACE_MS = 60 * 60_000
114
114
  const FILE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/
@@ -362,8 +362,8 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
362
362
  if (!real) return json(409, { error: 'that file has not arrived yet' })
363
363
  const declared = declaredFile(r, file)
364
364
  const refuse = async (error: string) => {
365
- await store.discard({ reportId: id, file }) // nothing unverified stays in storage
366
- await store.setFile(id, file, null)
365
+ // Drop the pending entry first and delete the object only if that really removed it: a verified entry that won a race is never orphaned.
366
+ if (await store.setFile(id, file, null)) await store.discard({ reportId: id, file }) // nothing unverified stays in storage
367
367
  return json(422, { error })
368
368
  }
369
369
  if (!sameType(state.pending.type, real.type) || !sameType(declared?.type ?? state.pending.type, real.type)) return refuse('the file\'s contents are not the type it was declared as')