@supersuit/suggest-a-change 0.1.6 → 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 +6 -0
- package/README.md +6 -1
- package/dist/client/detail.js +3 -0
- package/dist/client/entry.js +56 -2
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.js +1 -0
- package/dist/client/mine.js +16 -3
- package/dist/client/seen.d.ts +24 -0
- package/dist/client/seen.js +71 -0
- package/dist/client/words.d.ts +6 -0
- package/dist/client/words.js +3 -0
- package/dist/contract.d.mts +1 -0
- package/dist/contract.mjs +2 -0
- package/dist/server/handlers.d.ts +22 -0
- package/dist/server/handlers.js +26 -3
- package/dist/server/index.d.ts +2 -2
- package/dist/server/index.js +1 -1
- package/package.json +1 -1
- package/src/check/init.mjs +12 -0
- package/src/client/detail.tsx +6 -1
- package/src/client/entry.tsx +45 -3
- package/src/client/index.ts +1 -0
- package/src/client/mine.tsx +15 -2
- package/src/client/seen.ts +71 -0
- package/src/client/styles.css +3 -0
- package/src/client/words.ts +9 -0
- package/src/contract.mjs +2 -0
- package/src/server/handlers.ts +31 -4
- package/src/server/index.ts +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 0.1.6 (2026-10-15)
|
|
4
10
|
|
|
5
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.
|
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 |
|
package/dist/client/detail.js
CHANGED
|
@@ -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;
|
package/dist/client/entry.js
CHANGED
|
@@ -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 {
|
|
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: [
|
|
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 }) {
|
package/dist/client/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/client/index.js
CHANGED
|
@@ -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';
|
package/dist/client/mine.js
CHANGED
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|
package/dist/client/words.d.ts
CHANGED
|
@@ -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;
|
package/dist/client/words.js
CHANGED
|
@@ -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 };
|
package/dist/contract.d.mts
CHANGED
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;
|
package/dist/server/handlers.js
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/server/index.js
CHANGED
|
@@ -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.
|
|
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": {
|
package/src/check/init.mjs
CHANGED
|
@@ -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'))))
|
package/src/client/detail.tsx
CHANGED
|
@@ -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) {
|
|
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)
|
package/src/client/entry.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
<
|
|
35
|
-
|
|
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
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -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'
|
package/src/client/mine.tsx
CHANGED
|
@@ -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>
|
|
@@ -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
|
+
}
|
package/src/client/styles.css
CHANGED
|
@@ -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; }
|
package/src/client/words.ts
CHANGED
|
@@ -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
|
package/src/server/handlers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/server/index.ts
CHANGED
|
@@ -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'
|