@supersuit/suggest-a-change 0.1.5 → 0.1.7

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.7 (2026-10-17)
4
+
5
+ - **The person who suggested a change is told when it is being made and when it ends.** `createReportHandlers` takes a required `tellSubmitter(e)`, called once per move to `being-made` or an end (`TOLD`, now in the contract) with `{ member, reportId, status, words, meaning, ended, note? }`. The words are the module's own (`wordsFor`/`meaningFor`), never the report's text; `note` is the one-line reason, on `not-now` only. The planning steps, a repeat, a refused move and a maintainer's own report starting tell nobody, and a failure never fails the move. **Breaking:** without `tellSubmitter` the handlers throw at startup; an app with nobody to tell passes `{ none: "<reason>" }`. The `status` tell event also carries `member` and `status`, and a `not-now` row in the member's list carries its `note`.
6
+ - **In the app, with no setup.** The entry compares each report's status with the one its owner last opened (kept on the phone per app and account, cleared on sign-out): a report that has since started or ended shows a dot on the entry, says New in My suggestions, and the newest move gets one toast per app open ("Your suggestion: Done"; new strings `newMark`, `entryMoved`, `movedToast`). Opening the report clears it. A report seen for the first time is recorded silently, so nothing old is announced.
7
+ - `init` writes a `tellSubmitter` stub into the adapters file and names it in its notes.
8
+
9
+ ## 0.1.6 (2026-10-15)
10
+
11
+ - **Writes made in the same millisecond leave the phone in the order they were made.** `enqueue` stamps each op with `seq`, and the queue sorts by `at`, then `seq`, then id. Before, ties fell to the id, so a screen that saved a draft and then asked for it to be prepared in one tick sent the prepare first about half the time, and the receiver prepared the draft as it had been.
12
+
3
13
  ## 0.1.5 (2026-10-14)
4
14
 
5
15
  - **The agent is Pluma.** Every word a person reads now names Pluma, the agent formerly called Freeda: "With Pluma", "Goes to <owner> and Pluma", "Pluma picks it up". The status key `with-freeda` is unchanged, so stored reports and older clients still read. A copy of the contract vendored elsewhere must be taken again: these are new bytes.
package/README.md CHANGED
@@ -73,6 +73,8 @@ The four diagnostics are the app's version, the time, the screen size and the ph
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
 
76
+ **Telling the person in the app, with no setup.** The entry asks `mine()` once when it mounts and compares each report's status with the one this account last opened (kept on the phone beside the cached views, per app and account, and cleared on sign-out). A report that has since been started or ended (`TOLD`) shows a dot on the entry, says New on its row in My suggestions, and the newest such move gets one toast per app open in the module's words (`words.text.movedToast`, given the status's own word and, on `not-now`, the reason: "Your suggestion: Done", "Your suggestion: Not now. too big for now"). Opening the report clears it. A report this phone sees for the first time is recorded silently, so an app taking this release does not announce every old report. The list's `not-now` rows carry their one-line `note` for this. Out of the app, the server's `tellSubmitter` does the telling (below).
77
+
76
78
  **Follow-ups.** Words and voice notes added to a report afterwards are queued in the outbox like the report (a `followUp` write, then one part per voice note), keep the app's refusal with a Try again, and delete their local copy only once they have landed. `StoreAdapter.append` must therefore dedupe by report id and `more.followId`.
77
79
 
78
80
  ## The check: every screen carries the button
@@ -86,7 +88,7 @@ npx suggest-a-change-check --help
86
88
 
87
89
  Exit 0 is clean, 1 is problems (each printed with the file and the fix), 2 is a usage error or nothing to check. `--json` prints `{ ok, problems, checked, exempted }`. The flags are `--repo`, `--config`, `--app-dir`, `--adapters` and `--strict`. An app directory with no pages is a usage error (exit 2: nothing was checked), as is an app directory outside the repo.
88
90
 
89
- **Put `suggest-a-change-check --strict` in the app's check script** once the adapters are real. `init` writes stubs that sign nobody in and refuse every report, and marks the file with a `suggest-a-change: STUB` line. Without `--strict` that line is a `warning:` (also in the `--json` `warnings`) and the exit stays 0; with `--strict` it is a problem and the exit is 1. Delete the line when the four adapters are real.
91
+ **Put `suggest-a-change-check --strict` in the app's check script** once the adapters are real. `init` writes stubs that sign nobody in and refuse every report, and marks the file with a `suggest-a-change: STUB` line. Without `--strict` that line is a `warning:` (also in the `--json` `warnings`) and the exit stays 0; with `--strict` it is a problem and the exit is 1. Delete the line when the four adapters are real. The stub also carries `tellSubmitter` for the server half, which `createReportHandlers` requires (see below), and `init` says so in its notes whenever the adapters file has none.
90
92
 
91
93
  For a Next.js app (app router) it reads source, not a running app:
92
94
 
@@ -162,9 +164,12 @@ const h = createReportHandlers({
162
164
  app: { name: "Notes" }, // stamped into every report from here; an app in the body is ignored
163
165
  maintainersSince: { mo: "2026-10-01" }, // optional: reports from before this are not offered to that maintainer's Mac
164
166
  tell: (e) => notify(e), // ids only, never words; a failure never fails the request
167
+ tellSubmitter: (e) => push(e.member, e.words, e.note), // REQUIRED: tell the person who suggested it (or { none: "<reason>" })
165
168
  });
166
169
  ```
167
170
 
171
+ **Telling the person who suggested it is required.** `tellSubmitter` is called once when a report moves to `being-made` and once when it ends (`live`, `sent-to-template`, `filed`, `not-now`; the list is exported as `TOLD`), with `{ member, reportId, status, words, meaning, ended, note? }`. `words` and `meaning` are the module's own words for the status (`wordsFor`/`meaningFor`, with `app.owner` as the owner's name when given), never the report's text, so a lock screen never shows what someone wrote; `note` is the one-line reason and comes with `not-now` only. The planning steps (`with-freeda`, `planned`, `waiting-on-owner`) tell nobody, a repeat of a status tells nobody, a move the store refused tells nobody, and a maintainer is not told that the work they just started on their own report has started (its end is still told). Send it on the app's own channel (push, email, an activity row) to that member only. A failure never fails the move. `createReportHandlers` throws at startup when `tellSubmitter` is missing; an app that genuinely has nobody to tell passes `tellSubmitter: { none: "only the maintainer uses this app" }`, and an empty reason throws too. The `tell` event for a status move now also carries `member` and `status`.
172
+
168
173
  | Route | Handler | Answers |
169
174
  | --- | --- | --- |
170
175
  | `POST /api/reports` | `submit` | 200 (a repeat of the same id is 200 too), 400, 401, 413, 422, 429 |
@@ -10,6 +10,7 @@ import { draftKey } from './drafts.js';
10
10
  import { recoverTakes } from './recorder.js';
11
11
  import { queueFollowUp } from './reports.js';
12
12
  import { useRuntime, useTick } from './runtime.js';
13
+ import { markSeen } from './seen.js';
13
14
  import { Toasts } from './toast.js';
14
15
  import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.js';
15
16
  import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.js';
@@ -158,6 +159,8 @@ export function SuggestionDetail({ adapters, id }) {
158
159
  setOffline(false);
159
160
  setReport(r);
160
161
  void writeCache(rt.store, ckey, r);
162
+ // The person has now seen it at this status: its New mark and the entry's dot clear.
163
+ void markSeen(rt.store, { app: adapters.words.app, account: rt.who.account }, id, statusOf(r));
161
164
  }
162
165
  else if (refused) {
163
166
  shown.current = false;
@@ -5,8 +5,11 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
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
7
  import { useRuntime } from './runtime.js';
8
+ import { firstToastThisOpen, moved, onSeen } from './seen.js';
8
9
  import { SuggestSheet } from './sheet.js';
9
- import { stringsFor } from './words.js';
10
+ import { toast, Toasts } from './toast.js';
11
+ import { useClearOnSignOut } from './view-cache.js';
12
+ import { statusWords, stringsFor } from './words.js';
10
13
  /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
11
14
  * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
12
15
  export function KeepSending({ adapters }) {
@@ -17,7 +20,58 @@ export function SuggestEntry({ adapters, onOpen, className, role }) {
17
20
  const S = stringsFor(adapters.words);
18
21
  const [why, setWhy] = useState('');
19
22
  const [busy, setBusy] = useState(false);
23
+ // The runtime keeps the outbox moving while the entry is mounted (what <KeepSending> does).
24
+ const { rt, who } = useRuntime(adapters);
25
+ const [news, setNews] = useState(0);
26
+ const [toasting, setToasting] = useState(false);
20
27
  useEffect(() => { setWhy(''); }, [adapters]);
28
+ useClearOnSignOut(rt, who, adapters.words.app);
29
+ // Once per mount, asks the app for this person's reports and compares each with what they last opened: a move since
30
+ // then shows the dot, and the newest one gets the open's one toast. Opening a report (anywhere) clears it.
31
+ useEffect(() => {
32
+ setNews(0);
33
+ if (!rt)
34
+ return;
35
+ let live = true;
36
+ const id = { app: adapters.words.app, account: rt.who.account };
37
+ let rows = null;
38
+ const count = async () => {
39
+ if (!rows)
40
+ return;
41
+ const m = await moved(rt.store, id, rows);
42
+ if (live)
43
+ setNews(m.length);
44
+ return m;
45
+ };
46
+ void (async () => {
47
+ try {
48
+ rows = await adapters.store.mine();
49
+ }
50
+ catch {
51
+ rows = null;
52
+ }
53
+ let now = null;
54
+ try {
55
+ now = await adapters.identity();
56
+ }
57
+ catch {
58
+ now = null;
59
+ }
60
+ if (!live || !rows || !now || now.account !== rt.who.account)
61
+ return;
62
+ const m = await count();
63
+ if (!live || !m?.length || !firstToastThisOpen(id))
64
+ return;
65
+ const at = (x) => rows.find((r) => r.id === x)?.at ?? 0;
66
+ const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0];
67
+ const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined;
68
+ setToasting(true);
69
+ toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 });
70
+ })();
71
+ const off = onSeen(() => { void count(); });
72
+ return () => { live = false; off(); };
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [rt]);
21
75
  async function press() {
22
76
  setBusy(true);
23
77
  let who = null;
@@ -35,7 +89,7 @@ export function SuggestEntry({ adapters, onOpen, className, role }) {
35
89
  setWhy('');
36
90
  onOpen();
37
91
  }
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] }));
92
+ return (_jsxs("span", { className: "sac-entry-wrap", children: [_jsxs("button", { type: "button", role: role, "data-suggest-entry": "", className: `sac-entry${className ? ` ${className}` : ''}`, "aria-busy": busy || undefined, onClick: () => void press(), children: [S.entry, news ? _jsxs(_Fragment, { children: [_jsx("span", { className: "sac-entry-dot", "data-sac-moved": "", "aria-hidden": "true" }), _jsx("span", { className: "sac-sr", children: S.entryMoved })] }) : null] }), why ? _jsx("span", { role: "status", className: "sac-entry-why", children: why }) : null, toasting ? _jsx(Toasts, {}) : null] }));
39
93
  }
40
94
  /** For an error screen: opens the sheet as "something is broken", with the screen and what went wrong filled in. */
41
95
  export function ReportProblem({ adapters, screen, error }) {
@@ -12,5 +12,6 @@ export * from './receipt.tsx';
12
12
  export * from './entry.tsx';
13
13
  export * from './sheet.tsx';
14
14
  export * from './mine.tsx';
15
+ export { seenStatuses, markSeen, moved, onSeen, seenKey, type Seen } from './seen.ts';
15
16
  export * from './detail.tsx';
16
17
  export { refreshIdentity } from './runtime.ts';
@@ -12,5 +12,6 @@ export * from './receipt.js';
12
12
  export * from './entry.js';
13
13
  export * from './sheet.js';
14
14
  export * from './mine.js';
15
+ export { seenStatuses, markSeen, moved, onSeen, seenKey } from './seen.js';
15
16
  export * from './detail.js';
16
17
  export { refreshIdentity } from './runtime.js';
@@ -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 { moved, onSeen } from './seen.js';
10
11
  import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.js';
11
12
  import { Toasts } from './toast.js';
12
13
  import { statusMeaning, statusWords, stringsFor } from './words.js';
@@ -19,6 +20,8 @@ export function MySuggestions({ adapters, onOpen }) {
19
20
  const [open, setOpen] = useState(null);
20
21
  const [bad, setBad] = useState('');
21
22
  const [updating, setUpdating] = useState(false);
23
+ // Reports that moved to a telling status since the person last opened them: their rows say New.
24
+ const [fresh, setFresh] = useState(() => new Set());
22
25
  // Another account (or none) shows nothing of the last one's rows.
23
26
  const loads = useRef(0);
24
27
  // What the app last said (or the phone kept of it) for this account; null until either is known.
@@ -26,7 +29,16 @@ export function MySuggestions({ adapters, onOpen }) {
26
29
  // The first answer after mount ends "Updating"; later periodic reloads refresh quietly.
27
30
  const answered = useRef(false);
28
31
  useClearOnSignOut(rt, who, adapters.words.app);
29
- useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false); }, [rt]);
32
+ useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false); setFresh(new Set()); }, [rt]);
33
+ const recount = useCallback(async () => {
34
+ if (!rt || !known.current)
35
+ return;
36
+ const mine_ = loads.current;
37
+ const m = await moved(rt.store, { app: adapters.words.app, account: rt.who.account }, known.current);
38
+ if (mine_ === loads.current)
39
+ setFresh(new Set(m.map((x) => x.id)));
40
+ }, [adapters.words.app, rt]);
41
+ useEffect(() => onSeen(() => { void recount(); }), [recount]);
30
42
  const load = useCallback(async () => {
31
43
  if (!rt)
32
44
  return;
@@ -85,6 +97,7 @@ export function MySuggestions({ adapters, onOpen }) {
85
97
  void pruneOnes(rt.store, { app: adapters.words.app, account: rt.who.account }, theirs.map((t) => t.id));
86
98
  setBad('');
87
99
  paint(theirs, local);
100
+ void recount();
88
101
  }
89
102
  else if (refused) {
90
103
  // The app said no: what the phone held is no longer this person's to see.
@@ -101,7 +114,7 @@ export function MySuggestions({ adapters, onOpen }) {
101
114
  setBad(S.couldNotLoadMine);
102
115
  paint([], local);
103
116
  }
104
- }, [adapters, rt, S.couldNotLoadMine, S.offlineShowingSaved]);
117
+ }, [adapters, rt, recount, S.couldNotLoadMine, S.offlineShowingSaved]);
105
118
  useEffect(() => { void load(); }, [load, tick]);
106
119
  if (who === null)
107
120
  return _jsx("p", { role: "alert", children: S.signedOutMine });
@@ -110,6 +123,6 @@ export function MySuggestions({ adapters, onOpen }) {
110
123
  }
111
124
  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) => {
112
125
  const preview = typeof r.words === 'string' ? r.words : typeof r.title === 'string' ? r.title : '';
113
- 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));
126
+ 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: [fresh.has(r.id) ? _jsx("span", { className: "sac-new", "data-new": "", children: S.newMark }) : null, 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));
114
127
  }) })), _jsx(Toasts, {})] }));
115
128
  }
@@ -9,6 +9,8 @@ export interface Op {
9
9
  /** The id of the write this one belongs to (a part's report or follow-up). While that write is refused, this one is held, not sent. */
10
10
  parent?: string;
11
11
  at: number;
12
+ /** The order this phone made it in, among writes with the same `at`. Written by enqueue. */
13
+ seq?: number;
12
14
  error?: string;
13
15
  }
14
16
  export type Sent = 'done' | {
@@ -5,8 +5,11 @@ export function makeOutbox({ store, send, reach }) {
5
5
  // the new write existed. Measured on the phone: without this, a second answer sat on the phone
6
6
  // until the 15 s tick.
7
7
  let again = false;
8
+ // Two writes in one millisecond (a save, then the step that reads it) keep the order they were made
9
+ // in. Ordering them by id instead sent the second first about half the time.
10
+ let made = 0;
8
11
  async function pending() {
9
- return (await store.all('outbox')).map((r) => r.value).sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
12
+ return (await store.all('outbox')).map((r) => r.value).sort((a, b) => a.at - b.at || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id));
10
13
  }
11
14
  async function tell(landed) {
12
15
  const all = await pending();
@@ -56,7 +59,7 @@ export function makeOutbox({ store, send, reach }) {
56
59
  }
57
60
  return {
58
61
  pending,
59
- async enqueue(op) { await store.put('outbox', op.id, op); await tell(); },
62
+ async enqueue(op) { await store.put('outbox', op.id, { ...op, seq: op.seq ?? ++made }); await tell(); },
60
63
  drain() {
61
64
  if (running) {
62
65
  again = true;
@@ -0,0 +1,24 @@
1
+ import type { Store } from './idb.ts';
2
+ type Id = {
3
+ app: string;
4
+ account: string;
5
+ };
6
+ export type Seen = Record<string, string>;
7
+ export declare const seenKey: (id: Id) => string;
8
+ /** Called whenever something is marked seen, so a dot drawn elsewhere can clear. */
9
+ export declare function onSeen(fn: () => void): () => void;
10
+ export declare function seenStatuses(store: Store, id: Id): Promise<Seen>;
11
+ /** The person has looked at this report at this status. */
12
+ export declare function markSeen(store: Store, id: Id, reportId: string, status: string): Promise<void>;
13
+ /** The rows that moved to a telling status since the person last looked, in the order given. A report seen for the first
14
+ * time is recorded silently (so an app taking this release does not announce every old report), and so is a move to a
15
+ * quiet status (the planning steps); a telling move stays unrecorded until the person opens the report. */
16
+ export declare function moved(store: Store, id: Id, rows: {
17
+ id: string;
18
+ status: string;
19
+ }[]): Promise<{
20
+ id: string;
21
+ status: string;
22
+ }[]>;
23
+ export declare function firstToastThisOpen(id: Id): boolean;
24
+ export {};
@@ -0,0 +1,71 @@
1
+ 'use client';
2
+ // WHAT THE PERSON LAST LOOKED AT. Per app and account, the status each of their reports had the last time they
3
+ // opened it. A report whose status has since moved to one they should hear about (`TOLD`: the work starting, and
4
+ // every end) is "moved": the entry shows a dot, My suggestions marks the row New, and one toast says it. Opening
5
+ // the report marks it seen. The record sits beside the cached views, under the same app-and-account key, so another
6
+ // account never reads it and signing out clears it with them (clearViewCache).
7
+ import { TOLD } from '../contract.mjs';
8
+ import { draftKey } from './drafts.js';
9
+ import { readCache, writeCache } from './view-cache.js';
10
+ export const seenKey = (id) => `${draftKey(id)}#view:seen`;
11
+ const listeners = new Set();
12
+ /** Called whenever something is marked seen, so a dot drawn elsewhere can clear. */
13
+ export function onSeen(fn) { listeners.add(fn); return () => { listeners.delete(fn); }; }
14
+ // One write at a time per key, so two screens recording at once never drop each other's entries.
15
+ const chains = new Map();
16
+ function serial(key, job) {
17
+ const next = (chains.get(key) ?? Promise.resolve()).then(job, job);
18
+ chains.set(key, next.catch(() => { }));
19
+ return next;
20
+ }
21
+ export async function seenStatuses(store, id) {
22
+ const v = await readCache(store, seenKey(id));
23
+ return v && typeof v === 'object' ? { ...v } : {};
24
+ }
25
+ /** The person has looked at this report at this status. */
26
+ export function markSeen(store, id, reportId, status) {
27
+ return serial(seenKey(id), async () => {
28
+ const seen = await seenStatuses(store, id);
29
+ if (seen[reportId] === status)
30
+ return;
31
+ seen[reportId] = status;
32
+ await writeCache(store, seenKey(id), seen);
33
+ for (const l of listeners)
34
+ l();
35
+ });
36
+ }
37
+ /** The rows that moved to a telling status since the person last looked, in the order given. A report seen for the first
38
+ * time is recorded silently (so an app taking this release does not announce every old report), and so is a move to a
39
+ * quiet status (the planning steps); a telling move stays unrecorded until the person opens the report. */
40
+ export function moved(store, id, rows) {
41
+ return serial(seenKey(id), async () => {
42
+ const seen = await seenStatuses(store, id);
43
+ const out = [];
44
+ let changed = false;
45
+ for (const r of rows) {
46
+ if (typeof r?.id !== 'string' || typeof r.status !== 'string' || r.status === 'on-phone')
47
+ continue;
48
+ const had = seen[r.id];
49
+ if (had === r.status)
50
+ continue;
51
+ if (had === undefined || !TOLD.includes(r.status)) {
52
+ seen[r.id] = r.status;
53
+ changed = true;
54
+ continue;
55
+ }
56
+ out.push({ id: r.id, status: r.status });
57
+ }
58
+ if (changed)
59
+ await writeCache(store, seenKey(id), seen);
60
+ return out;
61
+ });
62
+ }
63
+ /** One toast per app open, per account: a remounted entry in the same open does not say it again. */
64
+ const toasted = new Set();
65
+ export function firstToastThisOpen(id) {
66
+ const k = seenKey(id);
67
+ if (toasted.has(k))
68
+ return false;
69
+ toasted.add(k);
70
+ return true;
71
+ }
@@ -90,6 +90,12 @@ export interface Strings {
90
90
  addFailed: string;
91
91
  overLimit: string;
92
92
  stepLabel: string;
93
+ /** On a row in My suggestions that moved since the person last opened it. */
94
+ newMark: string;
95
+ /** Read by a screen reader on the entry when a suggestion has moved (the dot itself is decoration). */
96
+ entryMoved: string;
97
+ /** The one toast per open. `words` is the status's own word (wordsFor); `note` is the not-now reason, when there is one. */
98
+ movedToast: (words: string, note?: string) => string;
93
99
  }
94
100
  export declare const DEFAULT_STRINGS: Strings;
95
101
  export declare function stringsFor(words: Words): Strings;
@@ -84,6 +84,9 @@ export const DEFAULT_STRINGS = {
84
84
  addFailed: 'That could not be added. Try again.',
85
85
  overLimit: 'Something in this suggestion is over a limit. Remove an attachment and try again.',
86
86
  stepLabel: 'Where it is',
87
+ newMark: 'New',
88
+ entryMoved: 'One of your suggestions has news',
89
+ movedToast: (words, note) => (note ? `Your suggestion: ${words}. ${note}` : `Your suggestion: ${words}`),
87
90
  };
88
91
  export function stringsFor(words) {
89
92
  return { ...DEFAULT_STRINGS, ...words.text };
@@ -12,6 +12,7 @@ export declare const CEILINGS: {
12
12
  };
13
13
  export declare const STATUSES: string[];
14
14
  export declare const END: string[];
15
+ export declare const TOLD: string[];
15
16
  export declare const FLOW: {
16
17
  "on-phone": {
17
18
  actor: string;
package/dist/contract.mjs CHANGED
@@ -13,6 +13,8 @@ export const CEILINGS = {
13
13
  };
14
14
  export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "waiting-on-owner", "being-made", "live", "sent-to-template", "filed", "not-now"];
15
15
  export const END = ["live", "sent-to-template", "filed", "not-now"];
16
+ // The moves the person who suggested a change hears about: the work starting, and every end. The planning steps stay quiet.
17
+ export const TOLD = ["being-made", ...END];
16
18
  // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Pluma by the Mac. A Helm on the owner's Mac posts straight into
17
19
  // the inbox, so a report may go from the phone to Pluma without the hosted app's "received". The owner may decline
18
20
  // ("not-now") before Pluma has a plan or before she asks: a report she cannot plan would otherwise have no exit.
@@ -1,3 +1,4 @@
1
+ import { TOLD } from '../contract.mjs';
1
2
  import { type Limits } from './rate.ts';
2
3
  /** `by` is the actor (contract FLOW). `holder` is the maintainer whose Mac started or ended the work, written on a being-made
3
4
  * or end entry: the approval on such a report never lapses, and it is how the list and the claim name who has it. */
@@ -131,10 +132,24 @@ export interface HostedStore {
131
132
  expiresAt: number;
132
133
  }>;
133
134
  }
135
+ /** `status` events also carry the report's member and its new status. */
134
136
  export interface TellEvent {
135
137
  type: 'submitted' | 'followed-up' | 'status' | 'settled';
136
138
  reportId: string;
137
139
  member?: string;
140
+ status?: string;
141
+ }
142
+ export { TOLD };
143
+ /** What `tellSubmitter` is handed. `words` and `meaning` are the module's own words for the status (`wordsFor`/`meaningFor`),
144
+ * never the report's text, so a lock screen never shows what someone wrote. `note` is the one-line reason, on `not-now` only. */
145
+ export interface SubmitterNews {
146
+ member: string;
147
+ reportId: string;
148
+ status: string;
149
+ words: string;
150
+ meaning: string;
151
+ ended: boolean;
152
+ note?: string;
138
153
  }
139
154
  export interface ReportHandlerOptions {
140
155
  store: HostedStore;
@@ -153,6 +168,13 @@ export interface ReportHandlerOptions {
153
168
  maintainersSince?: Record<string, number | string>;
154
169
  /** Told when something happens, with ids and never words. A failure here never fails the request. */
155
170
  tell?(event: TellEvent): void | Promise<void>;
171
+ /** REQUIRED: tells the person who suggested a change that it is being made or has ended, on the app's own channel (push,
172
+ * email, an activity row). Called once per telling move (`TOLD`), never on a repeat, never when the store refused the move,
173
+ * and never to a maintainer about their own report being made. A failure never fails the move. An app that genuinely
174
+ * has nobody to tell passes `{ none: "<reason>" }`; leaving it out throws at startup. */
175
+ tellSubmitter: ((news: SubmitterNews) => void | Promise<void>) | {
176
+ none: string;
177
+ };
156
178
  now?: () => number;
157
179
  }
158
180
  export declare const LEASE_MS: number;
@@ -7,7 +7,7 @@
7
7
  // a report is decided here from the signed-in member and the explicit maintainers list, never from the
8
8
  // request. Nothing is uploaded through this server: `uploadUrl` issues a direct-to-storage link with the
9
9
  // size and type fixed, and `finalize` checks what actually landed.
10
- import { END, FLOW, STATUSES, isAcceptedMedia, isReportId, mayMove, validateReport } from '../contract.mjs';
10
+ import { END, FLOW, STATUSES, TOLD, isAcceptedMedia, isReportId, mayMove, meaningFor, validateReport, wordsFor } from '../contract.mjs';
11
11
  import { effectiveLimits } from './rate.js';
12
12
  import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.js';
13
13
  /** What a collector saw of a report: its verified files and its follow-ups. A store computes this from its own copy,
@@ -17,6 +17,7 @@ export function reportRevision(r) {
17
17
  const ups = (r.followUps ?? []).map((f) => f.followId).sort();
18
18
  return `f:${files.join(',')}|u:${ups.join(',')}`;
19
19
  }
20
+ export { TOLD };
20
21
  export const LEASE_MS = 10 * 60_000;
21
22
  const WORDS_MAX = 20_000;
22
23
  // Sized from the field limits: words and the three answers at 4 bytes a character (any script), plus the rest.
@@ -100,6 +101,15 @@ function declaredFile(r, file) {
100
101
  }
101
102
  export function createReportHandlers(opts) {
102
103
  const { store, identity, agent } = opts;
104
+ const hook = opts.tellSubmitter;
105
+ if (typeof hook !== 'function') {
106
+ const reason = hook && typeof hook === 'object' ? hook.none : undefined;
107
+ if (hook === undefined || hook === null)
108
+ throw new Error('createReportHandlers needs tellSubmitter: a function that tells the person who suggested a change when it is being made and when it ends, or { none: "<reason>" } when the app has nobody to tell');
109
+ if (typeof reason !== 'string' || !reason.trim())
110
+ throw new Error('tellSubmitter { none } needs a reason: say why this app tells nobody, as { none: "<reason>" }');
111
+ }
112
+ const tellSubmitter = typeof hook === 'function' ? hook : null;
103
113
  const maintainers = [...opts.maintainers];
104
114
  const limits = effectiveLimits(opts.limits);
105
115
  const contractCeilings = { images: limits.images, imageBytes: limits.imageBytes, videos: limits.videos, videoBytes: limits.videoBytes, videoMs: limits.videoMs, voiceMs: limits.voiceMs, voiceTotalMs: limits.voiceTotalMs, perPersonPerDay: limits.perPersonPerDay };
@@ -109,6 +119,11 @@ export function createReportHandlers(opts) {
109
119
  await opts.tell?.(e);
110
120
  }
111
121
  catch { /* telling is best effort */ } };
122
+ const owner = typeof opts.app?.owner === 'string' && opts.app.owner ? opts.app.owner : 'the owner';
123
+ const news = async (n) => { try {
124
+ await tellSubmitter?.(n);
125
+ }
126
+ catch { /* telling is best effort */ } };
112
127
  const isMaintainer = (m) => maintainers.includes(m);
113
128
  const roleOf = (m) => (isMaintainer(m) ? 'maintainer' : 'member');
114
129
  const wrongMethod = (req, m) => (req.method === m ? null : json(405, { error: 'method not allowed' }));
@@ -259,7 +274,9 @@ export function createReportHandlers(opts) {
259
274
  // Filtered again here: a store that over-returns still cannot show another member's report.
260
275
  const rows = (await store.listFor(member)).filter((r) => r.who?.member === member);
261
276
  rows.sort((a, b) => (a.receivedAt < b.receivedAt ? 1 : -1));
262
- return json(200, rows.map((r) => ({ id: r.id, status: lastStatus(r), kind: r.kind, at: Date.parse(r.receivedAt) })));
277
+ // A declined report carries its one-line reason, which the member's own view already shows; no other note is listed.
278
+ const reason = (r) => { const t = r.timeline[r.timeline.length - 1]; return t?.status === 'not-now' && t.note ? { note: t.note } : {}; };
279
+ return json(200, rows.map((r) => ({ id: r.id, status: lastStatus(r), kind: r.kind, at: Date.parse(r.receivedAt), ...reason(r) })));
263
280
  }
264
281
  async function one(req, id) {
265
282
  const m = wrongMethod(req, 'GET');
@@ -572,7 +589,13 @@ export function createReportHandlers(opts) {
572
589
  await store.claimApproval(id, by, APPROVAL_LEASE_MS);
573
590
  return json(409, { error: 'the report changed while this was sent; read it again' });
574
591
  }
575
- await tell({ type: 'status', reportId: id });
592
+ await tell({ type: 'status', reportId: id, member: r.who?.member, status });
593
+ const member = r.who?.member;
594
+ // A maintainer is not told that the work they just started on their own report has started; an end is still news.
595
+ const ownStart = status === 'being-made' && member === by;
596
+ if (member && TOLD.includes(status) && !ownStart) {
597
+ await news({ member, reportId: id, status, words: wordsFor(status, { owner }), meaning: meaningFor(status, { owner }), ended: END.includes(status), ...(status === 'not-now' && note ? { note } : {}) });
598
+ }
576
599
  return json(200, { id, status });
577
600
  }
578
601
  return { submit, uploadUrl, finalize, mine, one, append, agentList, agentFile, agentSettle, agentClaimApproval, agentReleaseApproval, agentStatus };
@@ -1,5 +1,5 @@
1
- export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts';
2
- export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, ReportHandlerOptions } from './handlers.ts';
1
+ export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts';
2
+ export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts';
3
3
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts';
4
4
  export { effectiveLimits } from './rate.ts';
5
5
  export type { Limits } from './rate.ts';
@@ -1,5 +1,5 @@
1
1
  // "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
2
- export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.js';
2
+ export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.js';
3
3
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.js';
4
4
  export { effectiveLimits } from './rate.js';
5
5
  export { runStoreConformance, checks as conformanceChecks } from './conformance.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/suggest-a-change",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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": {
@@ -39,6 +39,7 @@ const stubFor = (app, owner) => `// ${STUB_SENTINEL}. These adapters sign nobody
39
39
  // Fill in the four below, then delete this line. "suggest-a-change-check --strict" fails while it is here.
40
40
  // The four things this app supplies to Suggest a change; the module owns everything else.
41
41
  import type { Adapters } from "${NAME}/client";
42
+ import type { ReportHandlerOptions } from "${NAME}/server";
42
43
 
43
44
  /** Who is signed in: { account, role } where role is "operator", "member" or "maintainer", or null. */
44
45
  export const identity: Adapters["identity"] = async () => null; // TODO: read this app's own session
@@ -57,6 +58,11 @@ export const words: Adapters["words"] = { app: ${JSON.stringify(app)}, owner: ${
57
58
 
58
59
  /** Attachment limits. May lower the module's ceilings, never raise them. */
59
60
  export const limits: Adapters["limits"] = {};
61
+
62
+ /** For the server half: createReportHandlers({ ..., tellSubmitter }) requires it. Tell e.member that their suggestion
63
+ * is being made or has ended, on this app's own channel (push, email, an activity row), using e.words and e.note,
64
+ * never the report's text. An app with nobody to tell passes { none: "<reason>" } instead. */
65
+ export const tellSubmitter: ReportHandlerOptions["tellSubmitter"] = async () => {}; // TODO: tell e.member e.words
60
66
  `
61
67
 
62
68
  const componentFor = (adaptersImport) => `"use client";
@@ -143,6 +149,12 @@ export function initApp({ repo, app, owner = 'Owner' }) {
143
149
  if (writeIfAbsent(join(root, 'tailor', 'suggest-a-change.ts'), stubFor(id, owner), log, 'tailor/suggest-a-change.ts'))
144
150
  log.notes.push('the adapters in tailor/suggest-a-change.ts are stubs: nobody is signed in and every report is refused, so the button opens nothing until you fill them in. Then delete the "suggest-a-change: STUB" line and use "suggest-a-change-check --strict" in your check script')
145
151
 
152
+ // The server half refuses to start without tellSubmitter, so say so wherever the adapters file does not carry one.
153
+ const tailor = join(root, 'tailor', 'suggest-a-change.ts')
154
+ const tailorText = existsSync(tailor) ? readFileSync(tailor, 'utf8') : ''
155
+ if (log.did.includes('wrote tailor/suggest-a-change.ts') || !/\btellSubmitter\b/.test(tailorText))
156
+ log.notes.push('the server half needs tellSubmitter: createReportHandlers({ ..., tellSubmitter }) throws at startup without it. Tell the person who suggested a change, on this app\'s own channel, when it is being made and when it ends (the stub in tailor/suggest-a-change.ts is where it goes), or pass tellSubmitter: { none: "<reason>" } when the app has nobody to tell')
157
+
146
158
  // 4. The component and the root layout.
147
159
  const compPath = join(root, dirname(appDir), 'components', 'suggest-a-change.tsx')
148
160
  const adaptersImport = dotted(posix(relative(dirname(compPath), join(root, 'tailor', 'suggest-a-change'))))
@@ -10,6 +10,7 @@ import { draftKey } from './drafts.ts'
10
10
  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
+ import { markSeen } from './seen.ts'
13
14
  import { Toasts } from './toast.tsx'
14
15
  import { dropCache, isRefusal, oneCacheKey, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
15
16
  import { effectiveCeilings, lengthWords, sizeWords, statusMeaning, statusWords, stringsFor } from './words.ts'
@@ -137,7 +138,11 @@ export function SuggestionDetail({ adapters, id }: { adapters: Adapters; id: str
137
138
  if (!fresh()) return
138
139
  if (!now || now.account !== rt.who.account) return
139
140
  setUpdating(false)
140
- if (r) { shown.current = true; setOffline(false); setReport(r); void writeCache(rt.store, ckey, r) }
141
+ if (r) {
142
+ shown.current = true; setOffline(false); setReport(r); void writeCache(rt.store, ckey, r)
143
+ // The person has now seen it at this status: its New mark and the entry's dot clear.
144
+ void markSeen(rt.store, { app: adapters.words.app, account: rt.who.account }, id, statusOf(r))
145
+ }
141
146
  else if (refused) { shown.current = false; setOffline(false); void dropCache(rt.store, ckey); setReport(null) }
142
147
  else if (shown.current) setOffline(true)
143
148
  else setReport(null)
@@ -5,8 +5,11 @@
5
5
  import { useEffect, useState, type AriaRole } from 'react'
6
6
  import type { Adapters } from './adapters.ts'
7
7
  import { useRuntime } from './runtime.ts'
8
+ import { firstToastThisOpen, moved, onSeen } from './seen.ts'
8
9
  import { SuggestSheet } from './sheet.tsx'
9
- import { stringsFor } from './words.ts'
10
+ import { toast, Toasts } from './toast.tsx'
11
+ import { useClearOnSignOut } from './view-cache.ts'
12
+ import { statusWords, stringsFor } from './words.ts'
10
13
 
11
14
  /** Keeps the phone's outbox moving (on reconnect and every 15 seconds) for as long as it is mounted, with nothing drawn.
12
15
  * <SuggestEntry> already does this; a host with no always-mounted entry mounts this once, high in the app. */
@@ -19,7 +22,43 @@ export function SuggestEntry({ adapters, onOpen, className, role }: { adapters:
19
22
  const S = stringsFor(adapters.words)
20
23
  const [why, setWhy] = useState('')
21
24
  const [busy, setBusy] = useState(false)
25
+ // The runtime keeps the outbox moving while the entry is mounted (what <KeepSending> does).
26
+ const { rt, who } = useRuntime(adapters)
27
+ const [news, setNews] = useState(0)
28
+ const [toasting, setToasting] = useState(false)
22
29
  useEffect(() => { setWhy('') }, [adapters])
30
+ useClearOnSignOut(rt, who, adapters.words.app)
31
+ // Once per mount, asks the app for this person's reports and compares each with what they last opened: a move since
32
+ // then shows the dot, and the newest one gets the open's one toast. Opening a report (anywhere) clears it.
33
+ useEffect(() => {
34
+ setNews(0)
35
+ if (!rt) return
36
+ let live = true
37
+ const id = { app: adapters.words.app, account: rt.who.account }
38
+ let rows: { id: string; status: string; note?: unknown; at?: number }[] | null = null
39
+ const count = async () => {
40
+ if (!rows) return
41
+ const m = await moved(rt.store, id, rows)
42
+ if (live) setNews(m.length)
43
+ return m
44
+ }
45
+ void (async () => {
46
+ try { rows = await adapters.store.mine() } catch { rows = null }
47
+ let now: { account: string } | null = null
48
+ try { now = await adapters.identity() } catch { now = null }
49
+ if (!live || !rows || !now || now.account !== rt.who.account) return
50
+ const m = await count()
51
+ if (!live || !m?.length || !firstToastThisOpen(id)) return
52
+ const at = (x: string) => rows!.find((r) => r.id === x)?.at ?? 0
53
+ const top = [...m].sort((a, b) => at(b.id) - at(a.id))[0]
54
+ const note = top.status === 'not-now' ? rows.find((r) => r.id === top.id)?.note : undefined
55
+ setToasting(true)
56
+ toast({ id: `sac-moved-${top.id}`, text: S.movedToast(statusWords(top.status, adapters.words.owner), typeof note === 'string' && note ? note : undefined), ms: 6000 })
57
+ })()
58
+ const off = onSeen(() => { void count() })
59
+ return () => { live = false; off() }
60
+ // eslint-disable-next-line react-hooks/exhaustive-deps
61
+ }, [rt])
23
62
  async function press() {
24
63
  setBusy(true)
25
64
  let who = null
@@ -31,9 +70,12 @@ export function SuggestEntry({ adapters, onOpen, className, role }: { adapters:
31
70
  }
32
71
  return (
33
72
  <span className="sac-entry-wrap">
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>
73
+ <button type="button" role={role} data-suggest-entry="" className={`sac-entry${className ? ` ${className}` : ''}`} aria-busy={busy || undefined} onClick={() => void press()}>
74
+ {S.entry}
75
+ {news ? <><span className="sac-entry-dot" data-sac-moved="" aria-hidden="true" /><span className="sac-sr">{S.entryMoved}</span></> : null}
76
+ </button>
36
77
  {why ? <span role="status" className="sac-entry-why">{why}</span> : null}
78
+ {toasting ? <Toasts /> : null}
37
79
  </span>
38
80
  )
39
81
  }
@@ -12,5 +12,6 @@ export * from './receipt.tsx'
12
12
  export * from './entry.tsx'
13
13
  export * from './sheet.tsx'
14
14
  export * from './mine.tsx'
15
+ export { seenStatuses, markSeen, moved, onSeen, seenKey, type Seen } from './seen.ts'
15
16
  export * from './detail.tsx'
16
17
  export { refreshIdentity } from './runtime.ts'
@@ -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 { moved, onSeen } from './seen.ts'
11
12
  import { dropCache, isRefusal, mineCacheKey, pruneOnes, readCache, useClearOnSignOut, writeCache } from './view-cache.ts'
12
13
  import { Toasts } from './toast.tsx'
13
14
  import { statusMeaning, statusWords, stringsFor } from './words.ts'
@@ -23,6 +24,8 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
23
24
  const [open, setOpen] = useState<string | null>(null)
24
25
  const [bad, setBad] = useState('')
25
26
  const [updating, setUpdating] = useState(false)
27
+ // Reports that moved to a telling status since the person last opened them: their rows say New.
28
+ const [fresh, setFresh] = useState<Set<string>>(() => new Set())
26
29
 
27
30
  // Another account (or none) shows nothing of the last one's rows.
28
31
  const loads = useRef(0)
@@ -31,7 +34,15 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
31
34
  // The first answer after mount ends "Updating"; later periodic reloads refresh quietly.
32
35
  const answered = useRef(false)
33
36
  useClearOnSignOut(rt, who, adapters.words.app)
34
- useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false) }, [rt])
37
+ useEffect(() => { loads.current++; known.current = null; answered.current = false; setRows(null); setOpen(null); setBad(''); setUpdating(false); setFresh(new Set()) }, [rt])
38
+
39
+ const recount = useCallback(async () => {
40
+ if (!rt || !known.current) return
41
+ const mine_ = loads.current
42
+ const m = await moved(rt.store, { app: adapters.words.app, account: rt.who.account }, known.current)
43
+ if (mine_ === loads.current) setFresh(new Set(m.map((x) => x.id)))
44
+ }, [adapters.words.app, rt])
45
+ useEffect(() => onSeen(() => { void recount() }), [recount])
35
46
 
36
47
  const load = useCallback(async () => {
37
48
  if (!rt) return
@@ -73,6 +84,7 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
73
84
  void pruneOnes(rt.store, { app: adapters.words.app, account: rt.who.account }, theirs.map((t) => t.id))
74
85
  setBad('')
75
86
  paint(theirs, local)
87
+ void recount()
76
88
  } else if (refused) {
77
89
  // The app said no: what the phone held is no longer this person's to see.
78
90
  known.current = null
@@ -86,7 +98,7 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
86
98
  setBad(S.couldNotLoadMine)
87
99
  paint([], local)
88
100
  }
89
- }, [adapters, rt, S.couldNotLoadMine, S.offlineShowingSaved])
101
+ }, [adapters, rt, recount, S.couldNotLoadMine, S.offlineShowingSaved])
90
102
  useEffect(() => { void load() }, [load, tick])
91
103
 
92
104
  if (who === null) return <p role="alert">{S.signedOutMine}</p>
@@ -110,6 +122,7 @@ export function MySuggestions({ adapters, onOpen }: { adapters: Adapters; onOpen
110
122
  return (
111
123
  <li key={r.id} className="sac-row" data-status={r.status}>
112
124
  <button type="button" className="sac-row-button" onClick={() => (onOpen ? onOpen(r.id) : setOpen(r.id))}>
125
+ {fresh.has(r.id) ? <span className="sac-new" data-new="">{S.newMark}</span> : null}
113
126
  {preview ? <span className="sac-row-words">{preview}</span> : null}
114
127
  <span className="sac-row-status">{statusWords(r.status, owner)}</span>
115
128
  <span className="sac-muted">{statusMeaning(r.status, owner)}</span>
@@ -18,6 +18,8 @@ export interface Op {
18
18
  /** The id of the write this one belongs to (a part's report or follow-up). While that write is refused, this one is held, not sent. */
19
19
  parent?: string
20
20
  at: number
21
+ /** The order this phone made it in, among writes with the same `at`. Written by enqueue. */
22
+ seq?: number
21
23
  error?: string
22
24
  }
23
25
  export type Sent = 'done' | { refused: string }
@@ -30,9 +32,12 @@ export function makeOutbox({ store, send, reach }: { store: Store; send(op: Op):
30
32
  // the new write existed. Measured on the phone: without this, a second answer sat on the phone
31
33
  // until the 15 s tick.
32
34
  let again = false
35
+ // Two writes in one millisecond (a save, then the step that reads it) keep the order they were made
36
+ // in. Ordering them by id instead sent the second first about half the time.
37
+ let made = 0
33
38
 
34
39
  async function pending(): Promise<Op[]> {
35
- return (await store.all<Op>('outbox')).map((r) => r.value).sort((a, b) => a.at - b.at || a.id.localeCompare(b.id))
40
+ return (await store.all<Op>('outbox')).map((r) => r.value).sort((a, b) => a.at - b.at || (a.seq ?? 0) - (b.seq ?? 0) || a.id.localeCompare(b.id))
36
41
  }
37
42
  async function tell(landed?: string) {
38
43
  const all = await pending()
@@ -69,7 +74,7 @@ export function makeOutbox({ store, send, reach }: { store: Store; send(op: Op):
69
74
  }
70
75
  return {
71
76
  pending,
72
- async enqueue(op: Op) { await store.put('outbox', op.id, op); await tell() },
77
+ async enqueue(op: Op) { await store.put('outbox', op.id, { ...op, seq: op.seq ?? ++made }); await tell() },
73
78
  drain(): Promise<void> {
74
79
  if (running) { again = true; return running }
75
80
  return (running = run().finally(() => { running = null }))
@@ -0,0 +1,71 @@
1
+ 'use client'
2
+ // WHAT THE PERSON LAST LOOKED AT. Per app and account, the status each of their reports had the last time they
3
+ // opened it. A report whose status has since moved to one they should hear about (`TOLD`: the work starting, and
4
+ // every end) is "moved": the entry shows a dot, My suggestions marks the row New, and one toast says it. Opening
5
+ // the report marks it seen. The record sits beside the cached views, under the same app-and-account key, so another
6
+ // account never reads it and signing out clears it with them (clearViewCache).
7
+ import { TOLD } from '../contract.mjs'
8
+ import { draftKey } from './drafts.ts'
9
+ import type { Store } from './idb.ts'
10
+ import { readCache, writeCache } from './view-cache.ts'
11
+
12
+ type Id = { app: string; account: string }
13
+ export type Seen = Record<string, string>
14
+ export const seenKey = (id: Id) => `${draftKey(id)}#view:seen`
15
+
16
+ const listeners = new Set<() => void>()
17
+ /** Called whenever something is marked seen, so a dot drawn elsewhere can clear. */
18
+ export function onSeen(fn: () => void): () => void { listeners.add(fn); return () => { listeners.delete(fn) } }
19
+
20
+ // One write at a time per key, so two screens recording at once never drop each other's entries.
21
+ const chains = new Map<string, Promise<unknown>>()
22
+ function serial<T>(key: string, job: () => Promise<T>): Promise<T> {
23
+ const next = (chains.get(key) ?? Promise.resolve()).then(job, job)
24
+ chains.set(key, next.catch(() => {}))
25
+ return next
26
+ }
27
+
28
+ export async function seenStatuses(store: Store, id: Id): Promise<Seen> {
29
+ const v = await readCache<Seen>(store, seenKey(id))
30
+ return v && typeof v === 'object' ? { ...v } : {}
31
+ }
32
+
33
+ /** The person has looked at this report at this status. */
34
+ export function markSeen(store: Store, id: Id, reportId: string, status: string): Promise<void> {
35
+ return serial(seenKey(id), async () => {
36
+ const seen = await seenStatuses(store, id)
37
+ if (seen[reportId] === status) return
38
+ seen[reportId] = status
39
+ await writeCache(store, seenKey(id), seen)
40
+ for (const l of listeners) l()
41
+ })
42
+ }
43
+
44
+ /** The rows that moved to a telling status since the person last looked, in the order given. A report seen for the first
45
+ * time is recorded silently (so an app taking this release does not announce every old report), and so is a move to a
46
+ * quiet status (the planning steps); a telling move stays unrecorded until the person opens the report. */
47
+ export function moved(store: Store, id: Id, rows: { id: string; status: string }[]): Promise<{ id: string; status: string }[]> {
48
+ return serial(seenKey(id), async () => {
49
+ const seen = await seenStatuses(store, id)
50
+ const out: { id: string; status: string }[] = []
51
+ let changed = false
52
+ for (const r of rows) {
53
+ if (typeof r?.id !== 'string' || typeof r.status !== 'string' || r.status === 'on-phone') continue
54
+ const had = seen[r.id]
55
+ if (had === r.status) continue
56
+ if (had === undefined || !TOLD.includes(r.status)) { seen[r.id] = r.status; changed = true; continue }
57
+ out.push({ id: r.id, status: r.status })
58
+ }
59
+ if (changed) await writeCache(store, seenKey(id), seen)
60
+ return out
61
+ })
62
+ }
63
+
64
+ /** One toast per app open, per account: a remounted entry in the same open does not say it again. */
65
+ const toasted = new Set<string>()
66
+ export function firstToastThisOpen(id: Id): boolean {
67
+ const k = seenKey(id)
68
+ if (toasted.has(k)) return false
69
+ toasted.add(k)
70
+ return true
71
+ }
@@ -45,6 +45,9 @@
45
45
  .sac-item { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
46
46
  .sac-row-button { width: 100%; text-align: left; display: flex; flex-direction: column; gap: 2px; }
47
47
  .sac-row-status { font-weight: 600; }
48
+ .sac-entry { position: relative; }
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
+ .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); }
48
51
  .sac-muted { color: var(--sac-muted); font-size: 0.9rem; }
49
52
  .sac-error { color: var(--sac-danger); margin: 0; }
50
53
  .sac-receipt-line { font-weight: 600; margin: 0; }
@@ -96,6 +96,12 @@ export interface Strings {
96
96
  addFailed: string
97
97
  overLimit: string
98
98
  stepLabel: string
99
+ /** On a row in My suggestions that moved since the person last opened it. */
100
+ newMark: string
101
+ /** Read by a screen reader on the entry when a suggestion has moved (the dot itself is decoration). */
102
+ entryMoved: string
103
+ /** The one toast per open. `words` is the status's own word (wordsFor); `note` is the not-now reason, when there is one. */
104
+ movedToast: (words: string, note?: string) => string
99
105
  }
100
106
 
101
107
  export const DEFAULT_STRINGS: Strings = {
@@ -179,6 +185,9 @@ export const DEFAULT_STRINGS: Strings = {
179
185
  addFailed: 'That could not be added. Try again.',
180
186
  overLimit: 'Something in this suggestion is over a limit. Remove an attachment and try again.',
181
187
  stepLabel: 'Where it is',
188
+ newMark: 'New',
189
+ entryMoved: 'One of your suggestions has news',
190
+ movedToast: (words, note) => (note ? `Your suggestion: ${words}. ${note}` : `Your suggestion: ${words}`),
182
191
  }
183
192
 
184
193
  export function stringsFor(words: Words): Strings {
package/src/contract.mjs CHANGED
@@ -16,6 +16,8 @@ export const CEILINGS = {
16
16
 
17
17
  export const STATUSES = ["on-phone", "received", "with-freeda", "planned", "waiting-on-owner", "being-made", "live", "sent-to-template", "filed", "not-now"];
18
18
  export const END = ["live", "sent-to-template", "filed", "not-now"];
19
+ // The moves the person who suggested a change hears about: the work starting, and every end. The planning steps stay quiet.
20
+ export const TOLD = ["being-made", ...END];
19
21
 
20
22
  // actor: who PERFORMS this step (design section 7): sent by the person, received by the app, with Pluma by the Mac. A Helm on the owner's Mac posts straight into
21
23
  // the inbox, so a report may go from the phone to Pluma without the hosted app's "received". The owner may decline
@@ -7,7 +7,7 @@
7
7
  // a report is decided here from the signed-in member and the explicit maintainers list, never from the
8
8
  // request. Nothing is uploaded through this server: `uploadUrl` issues a direct-to-storage link with the
9
9
  // size and type fixed, and `finalize` checks what actually landed.
10
- import { END, FLOW, STATUSES, isAcceptedMedia, isReportId, mayMove, validateReport } from '../contract.mjs'
10
+ import { END, FLOW, STATUSES, TOLD, isAcceptedMedia, isReportId, mayMove, meaningFor, validateReport, wordsFor } from '../contract.mjs'
11
11
  import { effectiveLimits, type Limits } from './rate.ts'
12
12
  import { ACCEPTED_TYPES, canonicalType, sameType } from './sniff.ts'
13
13
 
@@ -82,7 +82,12 @@ export interface HostedStore {
82
82
  readUrl(a: { reportId: string; file: string; ttlMs: number }): Promise<{ url: string; expiresAt: number }>
83
83
  }
84
84
 
85
- export interface TellEvent { type: 'submitted' | 'followed-up' | 'status' | 'settled'; reportId: string; member?: string }
85
+ /** `status` events also carry the report's member and its new status. */
86
+ export interface TellEvent { type: 'submitted' | 'followed-up' | 'status' | 'settled'; reportId: string; member?: string; status?: string }
87
+ export { TOLD }
88
+ /** What `tellSubmitter` is handed. `words` and `meaning` are the module's own words for the status (`wordsFor`/`meaningFor`),
89
+ * never the report's text, so a lock screen never shows what someone wrote. `note` is the one-line reason, on `not-now` only. */
90
+ export interface SubmitterNews { member: string; reportId: string; status: string; words: string; meaning: string; ended: boolean; note?: string }
86
91
  export interface ReportHandlerOptions {
87
92
  store: HostedStore
88
93
  /** The signed-in member, or null. Anything but `account` is ignored: the role is decided here. */
@@ -98,6 +103,11 @@ export interface ReportHandlerOptions {
98
103
  maintainersSince?: Record<string, number | string>
99
104
  /** Told when something happens, with ids and never words. A failure here never fails the request. */
100
105
  tell?(event: TellEvent): void | Promise<void>
106
+ /** REQUIRED: tells the person who suggested a change that it is being made or has ended, on the app's own channel (push,
107
+ * email, an activity row). Called once per telling move (`TOLD`), never on a repeat, never when the store refused the move,
108
+ * and never to a maintainer about their own report being made. A failure never fails the move. An app that genuinely
109
+ * has nobody to tell passes `{ none: "<reason>" }`; leaving it out throws at startup. */
110
+ tellSubmitter: ((news: SubmitterNews) => void | Promise<void>) | { none: string }
101
111
  now?: () => number
102
112
  }
103
113
 
@@ -177,12 +187,21 @@ function declaredFile(r: StoredReport, file: string): Declared | null {
177
187
 
178
188
  export function createReportHandlers(opts: ReportHandlerOptions) {
179
189
  const { store, identity, agent } = opts
190
+ const hook = opts.tellSubmitter as unknown
191
+ if (typeof hook !== 'function') {
192
+ const reason = hook && typeof hook === 'object' ? (hook as { none?: unknown }).none : undefined
193
+ if (hook === undefined || hook === null) throw new Error('createReportHandlers needs tellSubmitter: a function that tells the person who suggested a change when it is being made and when it ends, or { none: "<reason>" } when the app has nobody to tell')
194
+ if (typeof reason !== 'string' || !reason.trim()) throw new Error('tellSubmitter { none } needs a reason: say why this app tells nobody, as { none: "<reason>" }')
195
+ }
196
+ const tellSubmitter = typeof hook === 'function' ? (hook as (n: SubmitterNews) => void | Promise<void>) : null
180
197
  const maintainers = [...opts.maintainers]
181
198
  const limits = effectiveLimits(opts.limits)
182
199
  const contractCeilings = { images: limits.images, imageBytes: limits.imageBytes, videos: limits.videos, videoBytes: limits.videoBytes, videoMs: limits.videoMs, voiceMs: limits.voiceMs, voiceTotalMs: limits.voiceTotalMs, perPersonPerDay: limits.perPersonPerDay }
183
200
  const now = opts.now ?? (() => Date.now())
184
201
  const since = (m: string) => { const v = opts.maintainersSince?.[m]; const t = typeof v === 'string' ? Date.parse(v) : v; return t !== undefined && Number.isFinite(t) ? t : undefined }
185
202
  const tell = async (e: TellEvent) => { try { await opts.tell?.(e) } catch { /* telling is best effort */ } }
203
+ const owner = typeof opts.app?.owner === 'string' && opts.app.owner ? opts.app.owner : 'the owner'
204
+ const news = async (n: SubmitterNews) => { try { await tellSubmitter?.(n) } catch { /* telling is best effort */ } }
186
205
  const isMaintainer = (m: string) => maintainers.includes(m)
187
206
  const roleOf = (m: string) => (isMaintainer(m) ? 'maintainer' : 'member')
188
207
 
@@ -296,7 +315,9 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
296
315
  // Filtered again here: a store that over-returns still cannot show another member's report.
297
316
  const rows = (await store.listFor(member)).filter((r) => r.who?.member === member)
298
317
  rows.sort((a, b) => (a.receivedAt < b.receivedAt ? 1 : -1))
299
- return json(200, rows.map((r) => ({ id: r.id, status: lastStatus(r), kind: r.kind, at: Date.parse(r.receivedAt) })))
318
+ // A declined report carries its one-line reason, which the member's own view already shows; no other note is listed.
319
+ const reason = (r: StoredReport) => { const t = r.timeline[r.timeline.length - 1]; return t?.status === 'not-now' && t.note ? { note: t.note } : {} }
320
+ return json(200, rows.map((r) => ({ id: r.id, status: lastStatus(r), kind: r.kind, at: Date.parse(r.receivedAt), ...reason(r) })))
300
321
  }
301
322
 
302
323
  async function one(req: Request, id: string): Promise<Response> {
@@ -510,7 +531,13 @@ export function createReportHandlers(opts: ReportHandlerOptions) {
510
531
  if (needsApproval && from !== 'being-made' && PAST_ASKING.includes(status)) await store.claimApproval(id, by, APPROVAL_LEASE_MS)
511
532
  return json(409, { error: 'the report changed while this was sent; read it again' })
512
533
  }
513
- await tell({ type: 'status', reportId: id })
534
+ await tell({ type: 'status', reportId: id, member: r.who?.member, status })
535
+ const member = r.who?.member
536
+ // A maintainer is not told that the work they just started on their own report has started; an end is still news.
537
+ const ownStart = status === 'being-made' && member === by
538
+ if (member && TOLD.includes(status) && !ownStart) {
539
+ await news({ member, reportId: id, status, words: wordsFor(status, { owner }), meaning: meaningFor(status, { owner }), ended: END.includes(status), ...(status === 'not-now' && note ? { note } : {}) })
540
+ }
514
541
  return json(200, { id, status })
515
542
  }
516
543
 
@@ -1,6 +1,6 @@
1
1
  // "@supersuit/suggest-a-change/server": web-standard APIs only, no runtime dependency, nothing from react or the client.
2
- export { createReportHandlers, reportRevision, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts'
3
- export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, ReportHandlerOptions } from './handlers.ts'
2
+ export { createReportHandlers, reportRevision, TOLD, VOICE_BYTES_PER_SECOND, LEASE_MS, BODY_CAP, APPROVAL_LEASE_MS, HELD_MS, READ_TTL_MS, INCOMPLETE_GRACE_MS, VOICE_FILE_BYTES } from './handlers.ts'
3
+ export type { HostedStore, StoredReport, TimelineEntry, FileState, FollowUp, TellEvent, SubmitterNews, ReportHandlerOptions } from './handlers.ts'
4
4
  export { sniffType, sameType, canonicalType, ACCEPTED_TYPES } from './sniff.ts'
5
5
  export { effectiveLimits } from './rate.ts'
6
6
  export type { Limits } from './rate.ts'