Storylines: the same site told several ways, one per visitor, counted
docs / deploy (push) Successful in 3s
Test / test (push) Successful in 1m1s

Any text a person reads may be a list, one entry per storyline, every
list the same length. A visitor is given one storyline for the whole
site and reads it on every page and in every mail about their case.
Records and events carry the storyline; GET /report gives counts per
bucket, storyline and state, and nothing else.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GT37Z1Xtfd9pUuQtMTg6Yt
This commit is contained in:
Bendik Aagaard Lynghaug
2026-09-30 15:11:27 +02:00
co-authored by Claude Fable 5.1
parent cc3107e502
commit f395ace556
24 changed files with 561 additions and 45 deletions
+1
View File
@@ -11,6 +11,7 @@ engine does with it.
| [Content reference](content-reference.md) | Every key a content repo may write, and what it does. |
| [State machines](state-machines.md) | `aggregates.yaml`: stages, who moves a case, mail, deadlines, memberships. |
| [Access](access.md) | How the Cedar policy is compiled from content, and who may ask it what. |
| [Storylines](storylines.md) | The same site told several ways: lists of texts, one storyline per visitor, counted per storyline. |
| [Memberships](memberships.md) | Groups kept by portal itself: a membership as a case, a person as a phone number first, and the provider only saying who. |
| [Operations](operations.md) | Releasing, deploying, adding a site, environment variables. |
| [Filesystem routes](design/filesystem-routes.md) | Why the `questions/` tree is the router. |
+1
View File
@@ -8,6 +8,7 @@
- [Content reference](content-reference.md)
- [State machines](state-machines.md)
- [Access](access.md)
- [Storylines](storylines.md)
- [Memberships](memberships.md)
- [Operations](operations.md)
+5
View File
@@ -38,6 +38,11 @@ mail:
`mail.from` is required as soon as any state declares a mail.
Any text a person reads (`name`, `description`, `label`,
`placeholder`, a mail's `subject` and `body`, `consequence`,
`encouragements`) may be a list instead of one string: one entry per
[storyline](storylines.md), all lists the same length.
## A question
One YAML file is one page.
+6
View File
@@ -130,6 +130,12 @@ then `GARAGE_S3_ENDPOINT`, `GARAGE_ACCESS_KEY`, `GARAGE_SECRET_KEY` and
`GARAGE_UPLOADS_BUCKET` in that instance's env. Unset, an upload field
fails closed rather than appearing to work and storing nowhere.
## The report
`GET /report` is public and answers with counts only: per bucket, per
[storyline](storylines.md), per state. It is what the trainer reads to
compare the storylines on what people did.
## The docs site
These pages are [portal.uhhm.no](https://portal.uhhm.no): `docs/` built
+60
View File
@@ -0,0 +1,60 @@
# Storylines
The same site, told two ways, and counted.
Anywhere content says something to a person, it may say it in several
ways by giving a list instead of one string:
```yaml
name: Vil du hjelpe til i nabolaget?
description:
- Dugnad, 17. mai og komiteene trenger flere hender. Si hva du kan.
- Naboene gjør det meste selv. Her melder du deg til det du har lyst på.
alternatives:
- name: [Bli med, Meld deg]
consequence: [[Send], [Meld meg]]
```
Each index is a *storyline*. A visitor is given one at their first
request, kept in a cookie (`portal_storyline`) for a year, and reads
that storyline on every page, in every button and in every mail the
site sends them about their case. A single string is every storyline.
## The rules
- **Same length everywhere.** Every list of texts in the content has
the site's arity, so a storyline never has a hole in it. The lint
refuses a list of another length and names it.
- **What varies is what a person reads:** `name`, `description`,
`label`, `placeholder`, `title`, a mail's `subject` and `body`, a
list's `empty` line, `consequence` and `encouragements` (those two as
lists of lists).
- **What the machine reads never varies:** a field's `name` (the stored
key), a select's `options` (the stored values), states, groups,
paths, buckets.
- **One storyline per site**, not per page. That is what makes it a
storyline rather than a scatter of variants: a warm front door leads
to a warm form and a warm mail.
- A signed-in person keeps the storyline they had as a visitor.
- A site that tells one storyline sets no cookie.
## What is counted
Every record and every answer event carries the storyline it came
from (`storyline` on `Answer` and on `portal.answers.submitted`). The
mail a case gets tells the same storyline, so a person who read the
warm front door is not thanked in the plain voice.
`GET /report` gives the counts, per bucket, per storyline, per state:
```json
{ "storylines": 2,
"buckets": { "hjelpebehov": { "0": { "open": 3, "ordnet": 9 },
"1": { "open": 5, "ordnet": 14 } } } }
```
Storylines are assigned evenly, so those numbers are the comparison:
which words brought more people to send, and how their cases went. No
record, field, address or time leaves the site. The trainer reads this
(`portal-trainer report`) next to what its personas scored offline, and
the plan's `tones` are where the storylines are written.