@supersuit/artifacts 0.60.1 → 0.61.0

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
@@ -3,6 +3,14 @@
3
3
  `@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
4
4
  host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
5
5
 
6
+ ## 0.61.0 (2026-11-08)
7
+
8
+ **Send outcomes: one success indicator for any send** (issue #12, item 1, and outcomes). A page declares a `goal` (`read` by default, `respond`, `ack`, or `{ click: <cta id> }`); a link titled `cta:<id>` is a CTA whose press the page records through `POST /seen` (`event: click`, under the beat, refused for an id the page does not carry); and every reader's line in `GET /views` and `summarizeViews` gains `lastOpenAt`, `sessions`, `opens`, `clicks`, `goal`, `outcome` (`not-opened`, `opened`, `read`, `returned`, `acted`), `succeeded` and `daysSinceSent`, all under the before-send rule. Thresholds are exported (`OUTCOME_READ_SECONDS` 30, `OUTCOME_RETURN_GAP_MS` one hour) and a host overrides them with `outcomeThresholds` on the routes, `summarizeViews`'s fourth argument, or `?readSeconds=` on `/views`. `ack` is new: the only acknowledgement before was the confidential page's agreement, keyed to a signed-in email, which a per-reader page cannot carry, so `ack` is a press of the CTA named `ack`. `GET /api/artifacts/<id>` answers `goal`. Existing fields are unchanged; a page with no goal reads as `read`; a view stored before clicks reads as none.
9
+
10
+ - DETECTOR: `GET /views` rows carry no `outcome`; `goal:` in front matter is refused as an unknown key.
11
+ - REMEDY: update. Nothing to configure. Add `goal:` to a page, and `"cta:<id>"` titles to the links that are its CTAs.
12
+ - TESTS: `src/artifacts/outcomes.test.ts` (goal parsing and refusals, thresholds, clicks stored, a render before the send never counted, a second session on a known device counted as returned, each goal reaching and missing success, host thresholds), `src/routes/outcomes.test.tsx` (the click signal and its refusals, `/views` fields and thresholds by config and query, `GET` goal, the CTA link markup), `store.test.ts` (goal persisted and cleared), `quiet-view.test.tsx` (the page sends clicks, waiting for the beat), `index.test.ts` (exports). `seen.test.tsx`'s unknown-event case now uses `hover`, since `click` is known.
13
+
6
14
  ## 0.60.1 (2026-11-06)
7
15
 
8
16
  **The golden default page carries the viewer's image mark.** The 0.60.0 tag never reached npm: its publish run failed on `unchanged-default.test.tsx`, because every image now carries `data-artifact-image`. That attribute is the only difference in the regenerated golden. 0.60.1 is the first published version with the full screen viewer below.
package/README.md CHANGED
@@ -311,6 +311,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
311
311
  | `comment_name` | `required`: on a per-reader page, a comment through a group link needs a typed first name, and each browser on a group link is its own reader (see Per-reader links). Needs `links: per-reader` |
312
312
  | `forward` | `off`, `signed-in` or `open`: whether a reader of a per-reader page may make a link to pass it on (see Forward links). Absent is `off`. Needs `links: per-reader`. Sticky like `links` |
313
313
  | `history` | `everyone` or `admins`: who sees this page's version number and past versions, overriding the host's `history` for this one page (see Version history). Absent, the host decides. Refused as `everyone` on a per-reader page |
314
+ | `goal` | What a send of the page is for: `read` (the default), `respond`, `ack`, or `click: <cta id>` (see Send outcomes). A republish without the line goes back to `read` |
314
315
  | `change` | One line saying what this version changed (see Version history) |
315
316
  | `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
316
317
 
@@ -1071,8 +1072,8 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
1071
1072
  |---|---|---|
1072
1073
  | `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
1073
1074
  | `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes?, provenance? }], revoke?: [rid] or '*', expires?: ISO time or null, closes?: { rids: [rid] or '*', at: ISO time or null }, sent?: { rids: [rid] or '*', at?: ISO time } }`. `add` answers each new reader's link and its `provenance` (see Who minted a link); `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window; `sent` records when links reached their readers (see A render before the send) |
1074
- | `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments, `sentAt`, `beforeSend` and `provenance`. Each comment is `{ id, parent, at, text }` (see Comment ids in /views). A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
1075
- | `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
1075
+ | `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments, `sentAt`, `beforeSend` and `provenance`, plus each send's outcome (see Send outcomes). Each comment is `{ id, parent, at, text }` (see Comment ids in /views). A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
1076
+ | `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below: a view, beats, plays, and CTA clicks |
1076
1077
 
1077
1078
  Config, both on `createArtifactRoutes`: `recipients` (a store from `createRecipientsStore(db, '<collection path>')`, or `memoryRecipientsStore()` in tests) and `recipientSecret`, a function returning the signing secret, read on every request. A secret shorter than 32 characters is treated as unset: per-reader pages stay shut and the routes answer 404 or 501. Signing with an empty secret throws.
1078
1079
 
@@ -1233,6 +1234,74 @@ any stored entry in any slot, and a browser that removes its only entry and writ
1233
1234
  one shared reader, and every note left per browser stops being shown to its writer: the notes stay with
1234
1235
  the publisher, under their per-browser keys.
1235
1236
 
1237
+ ### Send outcomes (Unreleased)
1238
+
1239
+ One send is one page to one reader. Every reader's line in `GET /views` (and every row
1240
+ `summarizeViews` returns) says how far that send got, on one ladder that works for any page, so a
1241
+ host can draw each send in the column where it landed without reimplementing anything.
1242
+
1243
+ **The page says what a send is for**, in front matter:
1244
+
1245
+ ```markdown
1246
+ ---
1247
+ title: Next week
1248
+ summary: Two times that work.
1249
+ links: per-reader
1250
+ goal:
1251
+ click: book-call
1252
+ ---
1253
+ Pick one: [Book a call](https://cal.example.com/robin "cta:book-call").
1254
+ ```
1255
+
1256
+ | `goal:` | The send succeeded when |
1257
+ |---|---|
1258
+ | `read` (the default, and what a page without the line is) | the reader read it: at least `readSeconds` active seconds |
1259
+ | `respond` | the reader left a comment (a page needs `comments:` on for this to happen) |
1260
+ | `ack` | the reader pressed the page's ack CTA, a link titled `cta:ack`, e.g. `[Got it](#got-it "cta:ack")` |
1261
+ | `{ click: <cta id> }` | the reader pressed the CTA with that id |
1262
+
1263
+ **A CTA is a link whose title is `cta:<id>`** (lowercase letters, digits, dashes, underscores, up to
1264
+ 64). The title is never shown: the link renders with `data-artifact-cta="<id>"`, and an in-page CTA
1265
+ (`#...`) does not open a tab. On a reader's own link, a press sends `{ event: "click", beat, cta }`
1266
+ to `POST /seen`, under the same signature, render token and beat as every other signal (a press
1267
+ before the view lands waits for it). The route refuses a click without the beat (403), a malformed
1268
+ id or an id the page does not carry (400), and a revoked or expired reader (410); the owner's records
1269
+ nothing. Each session keeps up to 50 clicks. Publishing refuses a `goal` it does not know (`goal must be read, respond, ack, or { click:
1270
+ <cta id> }`), a click goal whose CTA the body does not carry, `goal: ack` with no `cta:ack` link, and
1271
+ a `cta:` title whose id could never be recorded.
1272
+
1273
+ **`ack` is new.** The package's only acknowledgement before this was the confidential page's
1274
+ agreement (`POST /api/reader/ack`), which is keyed to a signed-in email on an `access:` page, and a
1275
+ per-reader page cannot carry `access:`. So `ack` is the smallest thing that is the reader's own
1276
+ word: a press of the CTA named `ack`.
1277
+
1278
+ Each reader's line gains, every field under the before-send rule (a render before the recorded
1279
+ send is never the reader, nor anything later on its device; a comment written before the send is
1280
+ not their response):
1281
+
1282
+ | Field | What it is |
1283
+ |---|---|
1284
+ | `lastOpenAt` | When the reader's latest session began, or null |
1285
+ | `sessions` | How many sessions the reader had |
1286
+ | `opens` | `[{ at, activeSeconds, device }]`, one per session, oldest first |
1287
+ | `clicks` | `[{ cta, at }]`, every CTA the reader pressed, oldest first |
1288
+ | `goal` | The page's goal (`read` when it has none) |
1289
+ | `outcome` | The highest rung reached: `not-opened`, `opened`, `read` (at least `readSeconds` active), `returned` (a second session at least `returnGapMs` after the first, on any device, the known one included), `acted` (the goal's action: a comment for `respond`, the `ack` CTA for `ack`, that CTA for a click goal; never for `read`) |
1290
+ | `succeeded` | Whether the send reached the goal: for `read`, reading; for every other goal, acting |
1291
+ | `daysSinceSent` | Whole days from `sentAt` to now, or null with no recorded send, for drawing age |
1292
+
1293
+ `sentAt` was already on the line. The response also carries the page's `goal` and the `thresholds`
1294
+ it measured by. Every field that was there before is unchanged.
1295
+
1296
+ **The thresholds are exported**: `OUTCOME_READ_SECONDS` (30: a skim of a short page, about what a
1297
+ phone reader gives a sent link), `OUTCOME_RETURN_GAP_MS` (an hour, the line `cameBack` already
1298
+ draws, so two tabs in one sitting are not a return), together `OUTCOME_THRESHOLDS`. A host passes
1299
+ its own as `outcomeThresholds: { readSeconds?, returnGapMs? }` on `createArtifactRoutes`, as
1300
+ `summarizeViews(rec, views, comments, { goal, thresholds, now })`, or for one request as
1301
+ `GET /views?readSeconds=<n>`. `OUTCOMES` lists the rungs in order; `parseGoal`, `ctasIn` and
1302
+ `outcomeThresholds` are exported for hosts that check a page themselves. `GET /api/artifacts/<id>`
1303
+ answers the page's `goal` too.
1304
+
1236
1305
  ## Broadcasts (`template: broadcast`)
1237
1306
 
1238
1307
  A broadcast is its own page kind, not a document with extra flags. It is one message sent to several people, each through their own link, and it renders as one card: the sender's words at the top, then the media in the order the words put it (a video with sound and controls, photos, a voice note's player, link cards), then that person's own note. None of the document's furniture: no table of contents, no version line, no read-along, and no page menu for a reader.
@@ -3,6 +3,7 @@ import { type StateConfig } from './state.js';
3
3
  import { type CommentsMode, type CommentsVisible } from './comments.js';
4
4
  import { type ThemeMode } from '../brand/theme.js';
5
5
  import { type Definition } from './definitions.js';
6
+ import { type ArtifactGoal } from './goal.js';
6
7
  export type ArtifactMeta = {
7
8
  title: string;
8
9
  summary: string;
@@ -72,6 +73,10 @@ export type ArtifactMeta = {
72
73
  * link to pass it on, and whether they must sign in first. Absent is off. A door like `links:`,
73
74
  * so sticky: a republish that leaves the line out changes nothing. */
74
75
  forward?: ForwardMode;
76
+ /** `goal: read | respond | ack | { click: <cta id> }` (issue #12): what a send of this page is
77
+ * for, so each reader's outcome says whether the send succeeded. Absent is `read`. Content, like
78
+ * `toc:`: a republish without the line goes back to read. */
79
+ goal?: ArtifactGoal;
75
80
  };
76
81
  export declare const FORWARD_MODES: readonly ["off", "signed-in", "open"];
77
82
  export type ForwardMode = (typeof FORWARD_MODES)[number];
@@ -7,6 +7,7 @@ import { mergeChecklistState, mergeWidgetState, scanWidgets } from './widgets.js
7
7
  import { COMMENTS_MODES, mergeCommentsState } from './comments.js';
8
8
  import { THEME_MODES } from '../brand/theme.js';
9
9
  import { parseDefinitions } from './definitions.js';
10
+ import { parseGoal, checkGoalAgainstBody } from './goal.js';
10
11
  export const FORWARD_MODES = ['off', 'signed-in', 'open'];
11
12
  export const PAGE_HISTORY = ['admins', 'everyone'];
12
13
  export const ARTIFACT_KINDS = ['document', 'broadcast'];
@@ -21,7 +22,7 @@ const PRIVATE_REF_IN_BODY = /^private:/;
21
22
  export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
22
23
  /** The longest `to:` a page may carry: a name or two, never a sentence. */
23
24
  export const MAX_TO_CHARS = 60;
24
- const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media', 'comment_name', 'history', 'forward']);
25
+ const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media', 'comment_name', 'history', 'forward', 'goal']);
25
26
  export function parseArtifactSource(text,
26
27
  /** The kind a file that leaves `template:` out is read as: the stored page's kind on a
27
28
  * republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
@@ -99,6 +100,17 @@ opts = {}) {
99
100
  return { ok: false, error: 'forward must be off, signed-in or open' };
100
101
  meta.forward = d.forward;
101
102
  }
103
+ if (d.goal !== undefined && d.goal !== null) {
104
+ const g = parseGoal(d.goal);
105
+ if (!g.ok)
106
+ return { ok: false, error: g.error };
107
+ meta.goal = g.goal;
108
+ }
109
+ {
110
+ const why = checkGoalAgainstBody(meta.goal, content);
111
+ if (why)
112
+ return { ok: false, error: why };
113
+ }
102
114
  if (d.theme !== undefined) {
103
115
  if (!THEME_MODES.includes(d.theme))
104
116
  return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
@@ -0,0 +1,47 @@
1
+ /** `read` (the default): the reader spent OUTCOME_READ_SECONDS on the page. `respond`: the reader
2
+ * left a comment. `ack`: the reader pressed the page's ack CTA (a link titled "cta:ack").
3
+ * `{ click }`: the reader pressed the CTA with that id. */
4
+ export type ArtifactGoal = 'read' | 'respond' | 'ack' | {
5
+ click: string;
6
+ };
7
+ export declare const GOAL_KINDS: readonly ["read", "respond", "ack"];
8
+ export declare const DEFAULT_GOAL: ArtifactGoal;
9
+ /** A CTA id: lowercase letters, digits, dashes, underscores, up to 64. */
10
+ export declare const CTA_ID: RegExp;
11
+ /** The CTA id `goal: ack` counts. */
12
+ export declare const ACK_CTA = "ack";
13
+ /** A link title that marks the link as a CTA: `[Book a call](https://... "cta:book-call")`. */
14
+ export declare const CTA_TITLE: RegExp;
15
+ /** Clicks kept per session; a page that sends more is not a reader pressing a button. */
16
+ export declare const MAX_CLICKS_PER_SESSION = 50;
17
+ export type Outcome = 'not-opened' | 'opened' | 'read' | 'returned' | 'acted';
18
+ /** The rungs, lowest first. A send's outcome is the highest one it reached. */
19
+ export declare const OUTCOMES: readonly Outcome[];
20
+ /** Active seconds that make an open a read. Thirty seconds is a skim of a short page, which is
21
+ * as much as a phone reader gives a sent link; a host with long pages passes its own. */
22
+ export declare const OUTCOME_READ_SECONDS = 30;
23
+ /** A second open at least this long after the first is a return: an hour, the same line the
24
+ * summary's `cameBack` draws, so two tabs in one sitting are never a return. */
25
+ export declare const OUTCOME_RETURN_GAP_MS = 3600000;
26
+ export type OutcomeThresholds = {
27
+ readSeconds: number;
28
+ returnGapMs: number;
29
+ };
30
+ export declare const OUTCOME_THRESHOLDS: Readonly<OutcomeThresholds>;
31
+ /** A host's own thresholds over the defaults; anything not a finite non-negative number is ignored. */
32
+ export declare function outcomeThresholds(over?: Partial<OutcomeThresholds>): OutcomeThresholds;
33
+ /** The front matter's `goal:` value, checked. Does not look at the body; parseArtifactSource does. */
34
+ export declare function parseGoal(v: unknown): {
35
+ ok: true;
36
+ goal: ArtifactGoal;
37
+ } | {
38
+ ok: false;
39
+ error: string;
40
+ };
41
+ /** The CTA id `goal` counts, or null for read and respond. */
42
+ export declare function goalCta(goal: ArtifactGoal): string | null;
43
+ /** The CTA ids a page carries, each once, in order. */
44
+ export declare function ctasIn(markdown: string): string[];
45
+ /** A body's CTAs and its goal, checked together: a CTA id that could never be recorded, and a goal
46
+ * whose CTA the page does not carry, are refused rather than shipped. */
47
+ export declare function checkGoalAgainstBody(goal: ArtifactGoal | undefined, markdown: string): string | null;
@@ -0,0 +1,70 @@
1
+ // What a send is for, and how far one reader got toward it (issue #12). A page declares one `goal`;
2
+ // each per-reader send then lands on one rung of OUTCOMES, computed under the before-send rule in
3
+ // summarizeViews, so a host can draw every send in the column where it landed.
4
+ export const GOAL_KINDS = ['read', 'respond', 'ack'];
5
+ export const DEFAULT_GOAL = 'read';
6
+ /** A CTA id: lowercase letters, digits, dashes, underscores, up to 64. */
7
+ export const CTA_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
8
+ /** The CTA id `goal: ack` counts. */
9
+ export const ACK_CTA = 'ack';
10
+ /** A link title that marks the link as a CTA: `[Book a call](https://... "cta:book-call")`. */
11
+ export const CTA_TITLE = /^cta:\s*(.*)$/;
12
+ /** Clicks kept per session; a page that sends more is not a reader pressing a button. */
13
+ export const MAX_CLICKS_PER_SESSION = 50;
14
+ /** The rungs, lowest first. A send's outcome is the highest one it reached. */
15
+ export const OUTCOMES = ['not-opened', 'opened', 'read', 'returned', 'acted'];
16
+ /** Active seconds that make an open a read. Thirty seconds is a skim of a short page, which is
17
+ * as much as a phone reader gives a sent link; a host with long pages passes its own. */
18
+ export const OUTCOME_READ_SECONDS = 30;
19
+ /** A second open at least this long after the first is a return: an hour, the same line the
20
+ * summary's `cameBack` draws, so two tabs in one sitting are never a return. */
21
+ export const OUTCOME_RETURN_GAP_MS = 3_600_000;
22
+ export const OUTCOME_THRESHOLDS = Object.freeze({ readSeconds: OUTCOME_READ_SECONDS, returnGapMs: OUTCOME_RETURN_GAP_MS });
23
+ /** A host's own thresholds over the defaults; anything not a finite non-negative number is ignored. */
24
+ export function outcomeThresholds(over = {}) {
25
+ const ok = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
26
+ return { readSeconds: ok(over.readSeconds) ? over.readSeconds : OUTCOME_READ_SECONDS, returnGapMs: ok(over.returnGapMs) ? over.returnGapMs : OUTCOME_RETURN_GAP_MS };
27
+ }
28
+ const GOAL_ERROR = 'goal must be read, respond, ack, or { click: <cta id> }';
29
+ /** The front matter's `goal:` value, checked. Does not look at the body; parseArtifactSource does. */
30
+ export function parseGoal(v) {
31
+ if (typeof v === 'string')
32
+ return GOAL_KINDS.includes(v) ? { ok: true, goal: v } : { ok: false, error: GOAL_ERROR };
33
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
34
+ const keys = Object.keys(v);
35
+ const cta = v.click;
36
+ if (keys.length === 1 && keys[0] === 'click' && typeof cta === 'string' && CTA_ID.test(cta))
37
+ return { ok: true, goal: { click: cta } };
38
+ }
39
+ return { ok: false, error: GOAL_ERROR };
40
+ }
41
+ /** The CTA id `goal` counts, or null for read and respond. */
42
+ export function goalCta(goal) {
43
+ return goal === 'ack' ? ACK_CTA : typeof goal === 'object' ? goal.click : null;
44
+ }
45
+ const LINK_TITLE = /\]\(\s*<?[^\s)>]*>?\s+(?:"([^"]*)"|'([^']*)')\s*\)/g;
46
+ /** Every link title in a body that starts `cta:`, as written (the id may be malformed). */
47
+ function ctaTitles(markdown) {
48
+ const out = [];
49
+ for (const m of markdown.matchAll(LINK_TITLE)) {
50
+ const t = CTA_TITLE.exec((m[1] ?? m[2] ?? '').trim());
51
+ if (t)
52
+ out.push(t[1].trim());
53
+ }
54
+ return out;
55
+ }
56
+ /** The CTA ids a page carries, each once, in order. */
57
+ export function ctasIn(markdown) {
58
+ return [...new Set(ctaTitles(markdown).filter((id) => CTA_ID.test(id)))];
59
+ }
60
+ /** A body's CTAs and its goal, checked together: a CTA id that could never be recorded, and a goal
61
+ * whose CTA the page does not carry, are refused rather than shipped. */
62
+ export function checkGoalAgainstBody(goal, markdown) {
63
+ const bad = ctaTitles(markdown).find((id) => !CTA_ID.test(id));
64
+ if (bad !== undefined)
65
+ return `a CTA id is lowercase letters, digits, dashes and underscores: "cta:${bad}" is not one`;
66
+ const cta = goal ? goalCta(goal) : null;
67
+ if (!cta || ctasIn(markdown).includes(cta))
68
+ return null;
69
+ return goal === 'ack' ? `goal: ack needs a link marked "cta:${ACK_CTA}" for the reader to press` : `goal names a CTA the page does not carry: ${cta} (mark a link with "cta:${cta}")`;
70
+ }
@@ -26,6 +26,7 @@ export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_
26
26
  export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
27
27
  export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
28
28
  export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
29
- export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary, type Provenance } from './recipients-store.js';
29
+ export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary, type SummarizeOptions, type Provenance } from './recipients-store.js';
30
+ export { parseGoal, goalCta, ctasIn, outcomeThresholds, GOAL_KINDS, DEFAULT_GOAL, CTA_ID, ACK_CTA, MAX_CLICKS_PER_SESSION, OUTCOMES, OUTCOME_READ_SECONDS, OUTCOME_RETURN_GAP_MS, OUTCOME_THRESHOLDS, type ArtifactGoal, type Outcome, type OutcomeThresholds } from './goal.js';
30
31
  export { migrateMemosToPrivate, MEMO_KEY, type MemoMigration } from './migrate-memos.js';
31
32
  export { parseTranscriptFence, scanVoiceNotes, transcriptFenceFromWords, wordsFromWhisperJson, isAudioSrc, TRANSCRIPT_LANG, MAX_TRANSCRIPT_WORDS, type TranscriptParagraphs, type TimedWord, type VoiceNoteBlock } from './voice-note.js';
@@ -27,5 +27,6 @@ export { settleOnPublish, applySettingsPatch, withComments, declaredOf, password
27
27
  // Per-reader links: the host-facing surface only. The signing and token primitives stay internal.
28
28
  export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
29
29
  export { createRecipientsStore, memoryRecipientsStore, summarizeViews } from './recipients-store.js';
30
+ export { parseGoal, goalCta, ctasIn, outcomeThresholds, GOAL_KINDS, DEFAULT_GOAL, CTA_ID, ACK_CTA, MAX_CLICKS_PER_SESSION, OUTCOMES, OUTCOME_READ_SECONDS, OUTCOME_RETURN_GAP_MS, OUTCOME_THRESHOLDS } from './goal.js';
30
31
  export { migrateMemosToPrivate, MEMO_KEY } from './migrate-memos.js';
31
32
  export { parseTranscriptFence, scanVoiceNotes, transcriptFenceFromWords, wordsFromWhisperJson, isAudioSrc, TRANSCRIPT_LANG, MAX_TRANSCRIPT_WORDS } from './voice-note.js';
@@ -1,4 +1,5 @@
1
1
  import type { Firestore } from 'firebase-admin/firestore';
2
+ import { type ArtifactGoal, type Outcome, type OutcomeThresholds } from './goal.js';
2
3
  /** `sentAt`: when the sender says the link reached the reader (the publish key marks it). A view
3
4
  * that began before then was not the reader's, whoever rendered it. */
4
5
  export type Recipient = {
@@ -44,6 +45,8 @@ export type RecipientsRecord = {
44
45
  keep: boolean;
45
46
  revokedAll?: string;
46
47
  };
48
+ /** `clicks` (issue #12): each CTA the reader pressed in this session, in order. Absent on a view
49
+ * recorded before clicks existed, which reads as none. */
47
50
  export type ViewDoc = {
48
51
  artifactId: string;
49
52
  rid: string;
@@ -53,6 +56,10 @@ export type ViewDoc = {
53
56
  lastAt: string;
54
57
  activeSeconds: number;
55
58
  plays: Record<string, number>;
59
+ clicks?: {
60
+ cta: string;
61
+ at: string;
62
+ }[];
56
63
  };
57
64
  /** What rendered the link before it was sent: never counted as the reader, shown so a publisher
58
65
  * can see it happened (usually themselves, checking the page). Null when no send was recorded. */
@@ -62,6 +69,10 @@ export type BeforeSend = {
62
69
  activeSeconds: number;
63
70
  firstAt: string | null;
64
71
  } | null;
72
+ /** One reader's line. The fields from `lastOpenAt` on (issue #12) are the send's outcome, every one
73
+ * of them under the before-send rule: `opens` is each of the reader's sessions, `clicks` each CTA
74
+ * they pressed, `outcome` the highest rung of OUTCOMES the send reached, `succeeded` whether it
75
+ * reached the page's `goal`, and `daysSinceSent` whole days from `sentAt` (null with no send). */
65
76
  export type RecipientSummary = {
66
77
  rid: string;
67
78
  label: string;
@@ -85,6 +96,28 @@ export type RecipientSummary = {
85
96
  at: string;
86
97
  text: string;
87
98
  }[];
99
+ lastOpenAt: string | null;
100
+ sessions: number;
101
+ opens: {
102
+ at: string;
103
+ activeSeconds: number;
104
+ device: string;
105
+ }[];
106
+ clicks: {
107
+ cta: string;
108
+ at: string;
109
+ }[];
110
+ goal: ArtifactGoal;
111
+ outcome: Outcome;
112
+ succeeded: boolean;
113
+ daysSinceSent: number | null;
114
+ };
115
+ /** What summarizeViews measures outcomes by: the page's goal (default read), the thresholds (default
116
+ * OUTCOME_THRESHOLDS) and the clock `daysSinceSent` counts to (default now). */
117
+ export type SummarizeOptions = {
118
+ goal?: ArtifactGoal;
119
+ thresholds?: Partial<OutcomeThresholds>;
120
+ now?: Date;
88
121
  };
89
122
  type NewRecipient = {
90
123
  label: string;
@@ -110,6 +143,7 @@ export interface RecipientsStore {
110
143
  setSent?(artifactId: string, rids: string[] | '*', sentAt: string): Promise<string[]>;
111
144
  setExpiry(artifactId: string, expireAt: string | null): Promise<void>;
112
145
  useToken(key: string, expiresAt: number): Promise<boolean>;
146
+ /** `click` (issue #12): a CTA id the reader pressed, kept on the session with its time. */
113
147
  recordView(v: {
114
148
  artifactId: string;
115
149
  rid: string;
@@ -118,6 +152,7 @@ export interface RecipientsStore {
118
152
  at: string;
119
153
  addSeconds: number;
120
154
  play?: string;
155
+ click?: string;
121
156
  }): Promise<void>;
122
157
  views(artifactId: string): Promise<ViewDoc[]>;
123
158
  expiring(nowIso: string): Promise<string[]>;
@@ -133,5 +168,5 @@ export declare function summarizeViews(rec: RecipientsRecord, views: ViewDoc[],
133
168
  parent?: string | null;
134
169
  at: string;
135
170
  text: string;
136
- }[]): RecipientSummary[];
171
+ }[], opts?: SummarizeOptions): RecipientSummary[];
137
172
  export {};
@@ -1,4 +1,5 @@
1
1
  import { newRid, ridOfReaderKey } from './recipients.js';
2
+ import { DEFAULT_GOAL, MAX_CLICKS_PER_SESSION, goalCta, outcomeThresholds } from './goal.js';
2
3
  export const CAME_BACK_MS = 3_600_000;
3
4
  const LABEL_MAX = 60;
4
5
  const NOTE_MAX = 2000;
@@ -56,12 +57,14 @@ function sentIn(rec, rids, sentAt) {
56
57
  return hit;
57
58
  }
58
59
  function addView(prev, v) {
59
- const doc = prev ? { ...prev, plays: { ...prev.plays } } : { artifactId: v.artifactId, rid: v.rid, session: v.session, device: v.device, firstAt: v.at, lastAt: v.at, activeSeconds: 0, plays: {} };
60
+ const doc = prev ? { ...prev, plays: { ...prev.plays }, ...(prev.clicks ? { clicks: [...prev.clicks] } : {}) } : { artifactId: v.artifactId, rid: v.rid, session: v.session, device: v.device, firstAt: v.at, lastAt: v.at, activeSeconds: 0, plays: {} };
60
61
  doc.activeSeconds += Math.max(0, Math.min(60, v.addSeconds));
61
62
  if (v.at > doc.lastAt)
62
63
  doc.lastAt = v.at;
63
64
  if (v.play)
64
65
  doc.plays[v.play] = (doc.plays[v.play] ?? 0) + 1;
66
+ if (v.click && (doc.clicks?.length ?? 0) < MAX_CLICKS_PER_SESSION)
67
+ doc.clicks = [...(doc.clicks ?? []), { cta: v.click, at: v.at }];
65
68
  return doc;
66
69
  }
67
70
  const viewId = (artifactId, rid, session) => `${artifactId}__${rid}__${session}`;
@@ -192,7 +195,11 @@ export function createRecipientsStore(db, base) {
192
195
  };
193
196
  }
194
197
  /** One line per reader (never the owner), in the order they were added. */
195
- export function summarizeViews(rec, views, comments) {
198
+ export function summarizeViews(rec, views, comments, opts = {}) {
199
+ const goal = opts.goal ?? DEFAULT_GOAL;
200
+ const t = outcomeThresholds(opts.thresholds);
201
+ const nowMs = (opts.now ?? new Date()).getTime();
202
+ const cta = goalCta(goal);
196
203
  return Object.values(rec.recipients).filter((r) => !r.owner).sort((a, b) => a.addedAt.localeCompare(b.addedAt)).map((r) => {
197
204
  const all = views.filter((v) => v.rid === r.rid).sort((a, b) => a.firstAt.localeCompare(b.firstAt));
198
205
  // A RENDER BEFORE THE SEND IS NEVER THE READER (2026-10-25). The link was opened on the
@@ -209,6 +216,15 @@ export function summarizeViews(rec, views, comments) {
209
216
  const early = all.filter((v) => preDevices.has(v.device));
210
217
  const mine = all.filter((v) => !preDevices.has(v.device));
211
218
  const firsts = mine.map((v) => Date.parse(v.firstAt));
219
+ const activeSeconds = mine.reduce((n, v) => n + v.activeSeconds, 0);
220
+ const theirs = comments.filter((c) => ridOfReaderKey(c.readerKey) === r.rid);
221
+ const clicks = mine.flatMap((v) => v.clicks ?? []).sort((a, b) => a.at.localeCompare(b.at));
222
+ // The outcome, by the same rule: a comment written before the send was not the reader's either.
223
+ const responded = theirs.some((c) => !Number.isFinite(sentMs) || !(Date.parse(c.at) < sentMs));
224
+ const acted = goal === 'respond' ? responded : cta !== null && clicks.some((c) => c.cta === cta);
225
+ const read = mine.length > 0 && activeSeconds >= t.readSeconds;
226
+ const returned = firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= t.returnGapMs;
227
+ const outcome = acted ? 'acted' : returned ? 'returned' : read ? 'read' : mine.length ? 'opened' : 'not-opened';
212
228
  return {
213
229
  rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null,
214
230
  sentAt: r.sentAt ?? null,
@@ -219,10 +235,18 @@ export function summarizeViews(rec, views, comments) {
219
235
  opened: mine.length > 0,
220
236
  firstOpenAt: mine[0]?.firstAt ?? null,
221
237
  cameBack: firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= CAME_BACK_MS,
222
- activeSeconds: mine.reduce((n, v) => n + v.activeSeconds, 0),
238
+ activeSeconds,
223
239
  plays: mine.reduce((n, v) => n + Object.values(v.plays).reduce((a, b) => a + b, 0), 0),
224
240
  devices: new Set(mine.map((v) => v.device)).size,
225
- comments: comments.filter((c) => ridOfReaderKey(c.readerKey) === r.rid).map((c) => ({ id: c.id ?? null, parent: c.parent ?? null, at: c.at, text: c.text })),
241
+ comments: theirs.map((c) => ({ id: c.id ?? null, parent: c.parent ?? null, at: c.at, text: c.text })),
242
+ lastOpenAt: mine[mine.length - 1]?.firstAt ?? null,
243
+ sessions: mine.length,
244
+ opens: mine.map((v) => ({ at: v.firstAt, activeSeconds: v.activeSeconds, device: v.device })),
245
+ clicks,
246
+ goal, outcome,
247
+ // `read` succeeds on reading; every other goal only on its action.
248
+ succeeded: goal === 'read' ? read : acted,
249
+ daysSinceSent: Number.isFinite(sentMs) ? Math.max(0, Math.floor((nowMs - sentMs) / 86_400_000)) : null,
226
250
  };
227
251
  });
228
252
  }
@@ -27,6 +27,7 @@ import { remarkDefinitions } from './definitions.js';
27
27
  import { remarkLetterBreaks } from './letter-breaks.js';
28
28
  import { AUDIO_SRC, remarkVoiceNotes, TRANSCRIPT_LANG } from './voice-note.js';
29
29
  import { VoiceNote } from '../widgets/voice-note.js';
30
+ import { CTA_ID, CTA_TITLE } from './goal.js';
30
31
  // Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
31
32
  const GOLD = 'var(--a-accent)';
32
33
  function hostOf(url) {
@@ -66,6 +67,11 @@ const CALLOUT = /^\[!(note|warning)\]\s*/i;
66
67
  // else is a path on the author's disk (../meeting-transcripts/x.md), which the
67
68
  // publisher never uploads, so the anchor would 404. Those render as their text.
68
69
  const REACHABLE = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i;
70
+ /** The CTA id a link's title marks, or null. */
71
+ function ctaOf(title) {
72
+ const m = typeof title === 'string' ? CTA_TITLE.exec(title.trim()) : null;
73
+ return m && CTA_ID.test(m[1].trim()) ? m[1].trim() : null;
74
+ }
69
75
  export function isReachableHref(href) {
70
76
  return typeof href === 'string' && REACHABLE.test(href);
71
77
  }
@@ -207,9 +213,14 @@ function baseComponents(block, figure, checklists, embeds, voiceNotes) {
207
213
  // eslint-disable-next-line @next/next/no-img-element
208
214
  _jsx("img", { "data-artifact-image": true, src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" }));
209
215
  },
210
- a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href,
211
- // A phone link opens an app, not a tab.
212
- target: PHONE_LINK.test(href) ? undefined : '_blank', rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] })),
216
+ a: ({ href, children, title }) => {
217
+ // A link titled `cta:<id>` is a CTA (issue #12): the page records a press of it for a
218
+ // per-reader link, and the title, which is markup for this, is never shown.
219
+ const cta = ctaOf(title);
220
+ return !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href,
221
+ // A phone link opens an app, not a tab, and an in-page CTA (an ack) stays on the page.
222
+ target: PHONE_LINK.test(href) || (cta && href.startsWith('#')) ? undefined : '_blank', ...(cta ? { 'data-artifact-cta': cta } : {}), rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] }));
223
+ },
213
224
  code: ({ node, className, children }) => {
214
225
  const lang = /language-(\w+)/.exec(className ?? '')?.[1];
215
226
  if (lang === 'links')
@@ -1,5 +1,6 @@
1
1
  import { type Firestore } from 'firebase-admin/firestore';
2
2
  import type { ArtifactKind, ArtifactMeta, ForwardMode } from './front-matter.js';
3
+ import type { ArtifactGoal } from './goal.js';
3
4
  import type { Access } from './reader.js';
4
5
  import type { StateConfig } from './state.js';
5
6
  import type { ThemeMode } from '../brand/theme.js';
@@ -35,6 +36,8 @@ export type ArtifactRecord = {
35
36
  commentName?: 'required';
36
37
  /** Whether a reader may make a forward link (front-matter `forward:`); absent is off. */
37
38
  forward?: ForwardMode;
39
+ /** What a send of this page is for (front-matter `goal:`); absent is `read`. */
40
+ goal?: ArtifactGoal;
38
41
  /** The page's own `history:` line; absent, the host's setting decides. */
39
42
  history?: 'admins' | 'everyone';
40
43
  definitions?: Definition[];
@@ -172,6 +172,7 @@ async function saveArtifact(col, input) {
172
172
  ...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
173
173
  ...(input.meta.commentName ? { commentName: input.meta.commentName } : {}),
174
174
  ...(input.meta.forward ? { forward: input.meta.forward } : {}),
175
+ ...(input.meta.goal ? { goal: input.meta.goal } : {}),
175
176
  ...(input.meta.history ? { history: input.meta.history } : {}),
176
177
  ...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
177
178
  };
@@ -227,6 +228,7 @@ async function saveArtifact(col, input) {
227
228
  ...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
228
229
  ...(input.meta.commentName ? {} : { commentName: FieldValue.delete() }),
229
230
  ...(input.meta.history ? {} : { history: FieldValue.delete() }),
231
+ ...(input.meta.goal ? {} : { goal: FieldValue.delete() }),
230
232
  ...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
231
233
  };
232
234
  // `voice: none` is the page declaring it has no audio. An update merges, so the recording it
@@ -31,6 +31,11 @@ export function QuietView({ id, rid, sig, token, endpoint }) {
31
31
  let wait = null;
32
32
  let tick = null;
33
33
  let since = 0;
34
+ // CTAs pressed before the view earned its beat: sent once it lands (issue #12).
35
+ let pending = [];
36
+ const sendClicks = () => { if (!beat)
37
+ return; for (const cta of pending)
38
+ void post({ event: 'click', beat, cta }); pending = []; };
34
39
  const post = async (body) => {
35
40
  try {
36
41
  const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ...base, ...body }), keepalive: true });
@@ -81,6 +86,7 @@ export function QuietView({ id, rid, sig, token, endpoint }) {
81
86
  }
82
87
  else if (r?.beat)
83
88
  beat = r.beat;
89
+ sendClicks();
84
90
  }, VIEW_AFTER_MS);
85
91
  };
86
92
  const disarm = () => {
@@ -101,6 +107,17 @@ export function QuietView({ id, rid, sig, token, endpoint }) {
101
107
  if (media && beat)
102
108
  void post({ event: 'play', beat, media });
103
109
  };
110
+ // A press of anything marked data-artifact-cta (a link titled `cta:<id>`) is the reader taking
111
+ // that CTA. keepalive carries it through the new tab a link opens.
112
+ const onClick = (e) => {
113
+ const el = e.target?.closest?.('[data-artifact-cta]');
114
+ const cta = el?.getAttribute('data-artifact-cta');
115
+ if (!cta)
116
+ return;
117
+ pending.push(cta);
118
+ sendClicks();
119
+ };
120
+ document.addEventListener('click', onClick, true);
104
121
  document.addEventListener('visibilitychange', onVisibility);
105
122
  document.addEventListener('prerenderingchange', onVisibility);
106
123
  document.addEventListener('play', onPlay, true);
@@ -110,6 +127,7 @@ export function QuietView({ id, rid, sig, token, endpoint }) {
110
127
  document.removeEventListener('visibilitychange', onVisibility);
111
128
  document.removeEventListener('prerenderingchange', onVisibility);
112
129
  document.removeEventListener('play', onPlay, true);
130
+ document.removeEventListener('click', onClick, true);
113
131
  window.removeEventListener('pagehide', disarm);
114
132
  disarm();
115
133
  };
@@ -11,6 +11,7 @@ import { type Access, type AllowEntry } from '../artifacts/reader.js';
11
11
  import { type ReadersStore } from '../artifacts/readers-store.js';
12
12
  import { type ReaderEvent } from '../artifacts/read-alerts.js';
13
13
  import type { ResolveEvent } from '../artifacts/resolve-digest.js';
14
+ import type { OutcomeThresholds } from '../artifacts/goal.js';
14
15
  import { type AnalyticsConfig } from '../reader/analytics-plan.js';
15
16
  import type { PersonalStore } from '../artifacts/personal-store.js';
16
17
  import type { Transcriber } from '../artifacts/transcribe.js';
@@ -123,6 +124,9 @@ export type ArtifactRoutesConfig = {
123
124
  recipients?: RecipientsStore;
124
125
  /** Signs per-reader links and render tokens. At least 32 characters. Read at request time. */
125
126
  recipientSecret?: () => string | undefined;
127
+ /** The thresholds `GET /views` measures each send's outcome by (issue #12), over the package's
128
+ * OUTCOME_THRESHOLDS: `readSeconds` (default 30) and `returnGapMs` (default an hour). */
129
+ outcomeThresholds?: Partial<OutcomeThresholds>;
126
130
  /** Product analytics for this host's pages (0.50.0): PostHog with session replay, heatmaps,
127
131
  * autocapture, scroll depth on leave, and section_viewed / narration_* events. Without a key no
128
132
  * page loads any analytics script. Never records the owner or an admin, never a link-preview
@@ -260,6 +264,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
260
264
  markdown: string;
261
265
  version: number;
262
266
  updatedAt: string;
267
+ goal: import("../index.js").ArtifactGoal;
263
268
  share: {
264
269
  links: string;
265
270
  forward: "signed-in" | "off" | "open";
@@ -140,7 +140,7 @@ export function createArtifactRoutes(config) {
140
140
  });
141
141
  const recipientsRoutes = createRecipientsRoutes({
142
142
  store, recipients: config.recipients, recipientSecret: config.recipientSecret, publishKey: config.publishKey,
143
- state: config.state, assets: config.assets, pageUrl, authed, readerSecret, signInOrigin, siteUrl,
143
+ state: config.state, assets: config.assets, pageUrl, authed, readerSecret, signInOrigin, siteUrl, outcomes: config.outcomeThresholds,
144
144
  });
145
145
  const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
146
146
  const voiceRoutes = createVoiceRoutes({
@@ -909,6 +909,8 @@ export function createArtifactRoutes(config) {
909
909
  text: narrationText({ title: a.title, subtitle: a.subtitle, summary: a.summary, markdown: a.markdown }),
910
910
  title: a.title, ...(a.subtitle ? { subtitle: a.subtitle } : {}), summary: a.summary, ...(a.to ? { to: a.to } : {}),
911
911
  markdown: a.markdown, version: a.version ?? 1, updatedAt: a.updatedAt,
912
+ // What a send of the page is for (issue #12): the goal each reader's outcome is measured by.
913
+ goal: a.goal ?? 'read',
912
914
  // What a link to this page lets its reader do (0.57.0), so a sender can say so before the
913
915
  // reader's link exists: the send card is approved before anything is minted.
914
916
  share: {
@@ -1,5 +1,6 @@
1
1
  import { NextResponse, type NextRequest } from 'next/server';
2
2
  import { type Provenance, type RecipientsStore } from '../artifacts/recipients-store.js';
3
+ import { type OutcomeThresholds } from '../artifacts/goal.js';
3
4
  import type { ArtifactStore } from '../artifacts/store.js';
4
5
  import type { StateStore } from '../artifacts/state-store.js';
5
6
  import type { ArtifactAssets } from '../artifacts/assets.js';
@@ -24,6 +25,9 @@ export type RecipientsRoutesCtx = {
24
25
  readerSecret?: () => string | undefined;
25
26
  signInOrigin?: string;
26
27
  siteUrl?: string;
28
+ /** The outcome thresholds `GET /views` measures by (issue #12), over OUTCOME_THRESHOLDS. A caller
29
+ * may move `readSeconds` per request with `?readSeconds=<n>`. */
30
+ outcomes?: Partial<OutcomeThresholds>;
27
31
  };
28
32
  /** How many forward links one reader may make on one page in a day. */
29
33
  export declare const FORWARDS_PER_DAY = 20;
@@ -5,6 +5,7 @@ import { GRANT_COOKIE, signInUrl, verifyGrant } from '../artifacts/reader.js';
5
5
  import { perReaderGate } from './per-reader.js';
6
6
  import { summarizeViews } from '../artifacts/recipients-store.js';
7
7
  import { COMMENTS_SLOT } from '../artifacts/comments.js';
8
+ import { CTA_ID, ctasIn, outcomeThresholds } from '../artifacts/goal.js';
8
9
  import { ARTIFACT_ID_RE } from './ids.js';
9
10
  /** How many forward links one reader may make on one page in a day. */
10
11
  export const FORWARDS_PER_DAY = 20;
@@ -98,7 +99,21 @@ export function createRecipientsRoutes(given) {
98
99
  await ctx.recipients.recordView({ artifactId: id, rid, session, device, at, addSeconds: seconds, ...(media ? { play: media } : {}) });
99
100
  return json({});
100
101
  }
101
- return json({ error: 'event must be view, beat or play' }, 400);
102
+ // A CTA the reader pressed (issue #12): the same beat as any other signal after the view, and
103
+ // only an id the page itself carries, so a click is never recorded for a button nobody drew.
104
+ if (event === 'click') {
105
+ if (str(b?.beat) !== beatKey(secret, id, rid, session))
106
+ return json({ error: 'forbidden' }, 403);
107
+ const cta = str(b?.cta);
108
+ if (!CTA_ID.test(cta))
109
+ return json({ error: 'cta must be a CTA id' }, 400);
110
+ const page = await ctx.store.get(id);
111
+ if (!page || !ctasIn(page.markdown ?? '').includes(cta))
112
+ return json({ error: 'this page carries no such CTA' }, 400);
113
+ await ctx.recipients.recordView({ artifactId: id, rid, session, device, at, addSeconds: 0, click: cta });
114
+ return json({});
115
+ }
116
+ return json({ error: 'event must be view, beat, play or click' }, 400);
102
117
  }
103
118
  const unauth = () => json({ error: 'publish key required' }, 401);
104
119
  async function listing(id) {
@@ -207,7 +222,10 @@ export function createRecipientsRoutes(given) {
207
222
  const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
208
223
  const entries = ctx.state ? await ctx.state.entries(id) : [];
209
224
  const comments = entries.filter((e) => e.slot === COMMENTS_SLOT && e.readerKey.startsWith('r:')).map((e) => ({ readerKey: e.readerKey, id: e.id, parent: typeof e.value?.parent === 'string' ? e.value.parent : null, at: e.at, text: String(e.value?.body ?? '') }));
210
- return json({ id, expireAt: rec.expireAt, keep: rec.keep, recipients: summarizeViews(rec, await ctx.recipients.views(id), comments) });
225
+ const q = req.nextUrl.searchParams.get('readSeconds');
226
+ const thresholds = outcomeThresholds({ ...ctx.outcomes, ...(q !== null && /^\d{1,6}$/.test(q) ? { readSeconds: Number(q) } : {}) });
227
+ const goal = a.goal ?? 'read';
228
+ return json({ id, expireAt: rec.expireAt, keep: rec.keep, goal, thresholds, recipients: summarizeViews(rec, await ctx.recipients.views(id), comments, { goal, thresholds }) });
211
229
  }
212
230
  /** Reader text goes with the page: every comment and answer on it, whoever wrote it. */
213
231
  async function clearReaderText(id) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.60.1",
3
+ "version": "0.61.0",
4
4
  "description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,7 +12,7 @@
12
12
  "@fontsource/dm-sans": "^5.3.0",
13
13
  "@fontsource/dm-serif-display": "^5.3.0",
14
14
  "@google-cloud/storage": "^7.22.0",
15
- "@supersuit/artifacts": "0.60.1",
15
+ "@supersuit/artifacts": "0.61.0",
16
16
  "firebase": "^12.19.0",
17
17
  "firebase-admin": "^13.7.0",
18
18
  "next": "^16.0.1",