Compare commits
81
Commits
@@ -38,6 +38,25 @@ jobs:
|
||||
- name: Test
|
||||
run: cargo test --features ssr
|
||||
|
||||
# Every plan under plans/ built deterministically and held to the
|
||||
# lint, both by this very portal: a builder change that renders a
|
||||
# site the lint rejects fails here, not at the next training run.
|
||||
# (portal-trainer's CI did this until the trainer moved in.)
|
||||
- name: Build and lint every plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cargo build --features ssr --bin portal
|
||||
bin="$CARGO_TARGET_DIR/debug/portal"
|
||||
tmp=$(mktemp -d)
|
||||
lint="$tmp/portal-lint"
|
||||
printf '#!/bin/sh\nexec %s lint "$@"\n' "$bin" > "$lint" && chmod +x "$lint"
|
||||
for plan in plans/*.yaml; do
|
||||
name=$(basename "$plan" .yaml)
|
||||
echo "== $plan"
|
||||
rm -rf "$tmp/plan-$name"
|
||||
"$bin" build --plan "$plan" --out "$tmp/plan-$name" --lint "$lint"
|
||||
done
|
||||
|
||||
- name: Stop the throwaway JetStream
|
||||
if: always()
|
||||
run: docker rm -f portal-test-nats >/dev/null 2>&1 || true
|
||||
|
||||
@@ -4,6 +4,87 @@ One line per change, grouped by the release that shipped it. Newest
|
||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||
content repos pin the tag they run (see the end of this file).
|
||||
|
||||
## 0.6.19 (2026-10-05)
|
||||
|
||||
- A `kind: url` resource whose JSON is a releases manifest (`latest`, `versions` with per-platform `assets`, `channels` such as a package repo or TestFlight) renders as a download card: the file for the visitor's own platform first, decided in the browser, the rest and the channels folded. Written for redoal's public releases at dl.redoal.com (docs: content-reference, "A releases manifest").
|
||||
|
||||
## 0.6.18 (2026-10-03)
|
||||
|
||||
- `iris check --findings <out.jsonl>` writes every finding as JSON: a stable `id` (scope, need, code and subject, nothing of the wording), `level`, `code`, `scope`, `need`, `subject`, `message` and `fix`. Every message that knows its fix now says it after "fix:". The codes are listed in docs/lint.md.
|
||||
- `portal generate` follows its findings through: each structure round is logged as fixed, still open, new and reopened; a fix round is shown the open findings with their fixes and only the files they name; a change that reopens a closed finding is undone; and the phase stops when a round closes nothing, writing what is left to run.log. A hesitant visitor no longer triggers a wording rewrite by itself, only a misroute does. A lint without `--findings` is still driven the old way.
|
||||
|
||||
## 0.6.17 (2026-10-02)
|
||||
|
||||
- A desk shows every record still moving and only the three most recently finished (a final state: no move out of it), with "Show older" for three more at a time. A desk that has run for a year is still about the work in hand.
|
||||
- Mail variables are one set for every kind of mail (docs: Mail). Every mail has `{site}` and `{site_name}`; a state mail also has `{link}`, `{self_link}` and `{desk_link}`: the reader's own page for the record (a self-service page with `?chain=&email=`, when the record's state allows a move there), the desk that moves it, and `{link}` choosing between them by who the mail is to. `{link}` in a state mail used to render as nothing.
|
||||
- The lint follows every state of every bucket a need lands in: a state no desk, link or clock reaches fails; a state a record can reach and never leave fails, after "done" too; a declared move nobody offers warns; a desk gated to one group on the page and another on its list warns; working stages must follow each other in the business's order; a mail from an unreachable state warns, and one naming a field no form collects fails. Clocks (`after:`) count as moves, and a visitor's own link only from the states it names. Each finding ends in the fix: the `{ from, to, label }` and the desk page it goes on, or the group that already has the button. A schema error no longer hides the needs findings.
|
||||
|
||||
## 0.6.16 (2026-10-01)
|
||||
|
||||
- The lint refuses an alternative that has a form to send (fields to fill in, an invite, or a bucket to record into) and no `action`. The button is only drawn for an alternative with an `action`, so such a form could be filled in and never sent, which is what happened to uhhm.no's first invite form. An alternative that moves its own record (`self_transition`) draws its own button and is left alone.
|
||||
- `portal iris` is the lint's name, after the iris that keeps bad content out of the portal. `portal lint` is the same command and keeps working.
|
||||
|
||||
## 0.6.15 (2026-10-01)
|
||||
|
||||
- An invite may carry its own mail: `invite: { group, mail: { subject, body } }`. Every field of the form is a placeholder besides `{name}`, `{link}`, `{expires}` and the rest, and a date and time (`type: datetime-local`) is written out in words ("tirsdag 6. oktober kl. 15:00"). The words and the form are kept with the invite, so sending it again sends the same mail. This is for a desk that invites someone to a conversation at an hour, not into a group for good.
|
||||
|
||||
## 0.6.14 (2026-10-01)
|
||||
|
||||
- Less room above the wordmark: 1.75rem, 1.25rem on a narrow screen (it was 4rem). The space was kept for who is here, which now sits on the wordmark's own line, and on a tall, narrow screen it read as dead space.
|
||||
|
||||
## 0.6.13 (2026-10-01)
|
||||
|
||||
- Who is here (the name and "sign out", or "sign in") sits on the wordmark's own line instead of being pinned to the corner of the page, where it read as a row of its own. With a picture strip it is in the wordmark's bar; without, it is level with the mark at the right edge. On a narrow screen it drops under the mark.
|
||||
- Trainer: every form, public ones included, carries a signed-in person's name and address from the login instead of asking for them; a visitor still sees the fields. Only the inside forms did before, so someone signed in filled in their own name and address again on a public form.
|
||||
|
||||
## 0.6.12 (2026-10-01)
|
||||
|
||||
- Trainer: the words model is told who opens each mail: the person who sent the case, the person who posted the record an answer is about, or someone at the business. Mails to the business about another person's case used to be written as if to that person ("you will hear from us"); on Gullberg, three of nine were.
|
||||
|
||||
## 0.6.11 (2026-10-01)
|
||||
|
||||
- `wordmark_ink: true` in `site.yaml`: an SVG wordmark is drawn in the page's own ink, as a mask over the text colour, so it follows the page's light and dark in every browser. A wordmark shown as an `<img>` only knows the system's setting, and some browsers don't pass even that on. Opacity in the file still shows, so a lighter line stays lighter. Opt-in, for one-colour marks; a mark with a colour of its own keeps its colours.
|
||||
|
||||
## 0.6.10 (2026-09-30)
|
||||
|
||||
- Trainer: who a business is gets a page of its own ("who are we?", `/hvem` in Norwegian), with nothing to do on it. It has one card per need that `presents` (the people, the work, each shown as picture cards) and one for the plan's `values`. The front door leads there instead of carrying it all.
|
||||
- Trainer: the words model is told that a form page from the inside asks a real question and says where the answer ends up ("How do you present yourself?", not "Your card on the front page"), and that a `one_per_person` form is the colleague's own.
|
||||
- Trainer: `portal build` names every need no persona tries. Klingenberg Bygg's plan had personas only for customers, so nobody ever played the office filling in its own forms.
|
||||
- Klingenberg Bygg: "Hvem består Klingenberg Bygg av?" with Menneskene, Erfaringer (projects with pictures, added from the office) and Verdier (a first draft for Fredrik to replace).
|
||||
|
||||
## 0.6.9 (2026-09-30)
|
||||
|
||||
- What a person wrote on a card (a bio) is shown as markdown: paragraphs, line breaks as typed, lists, emphasis, and links to https, mailto or tel marked `rel="nofollow ugc"`. No images and no raw HTML, which is stricter than the site's own descriptions. The desk still shows the text as written.
|
||||
|
||||
## 0.6.8 (2026-09-30)
|
||||
|
||||
- An upload that did not arrive says why: larger than 20 MB (checked in the browser before anything is sent), a site that takes no files (no Garage configured), not allowed from here, or anything else. It used to say "try again, or with a smaller file" whatever the reason, which sent people looking for a smaller file when the site had no storage at all.
|
||||
|
||||
## 0.6.7 (2026-09-30)
|
||||
|
||||
- `leaves_after: { minutes: 10 }` on a state: a record that has been there that long leaves its bucket's lists (days, hours and minutes add up; swept every minute; the event log keeps it). Trainer: `leaves_after` per stage on a need. A Klingenberg Bygg card taken off the front page leaves the office's list ten minutes later instead of standing there greyed out.
|
||||
|
||||
## 0.6.6 (2026-09-30)
|
||||
|
||||
- `one_per_person: true` on an alternative: each signed-in person keeps one record there, their own. The form opens filled in with it, a file left unchosen keeps the one already there (shown beside the picker), and sending again replaces the card instead of adding one. Two sends at once settle on the newest. Trainer: `one_per_person` on a need entered from inside; Klingenberg Bygg's people each edit their own card on the front page.
|
||||
- Send is disabled from the moment it is pressed. Files upload before the form is sent, and a second press in that time used to send it twice.
|
||||
- A failed upload stops the send and says so. The form used to go without the file, which made a record that looked complete and was not.
|
||||
- A refused send says why, under its button. It used to say nothing.
|
||||
|
||||
## 0.6.5 (2026-09-30)
|
||||
|
||||
- Trainer: `paused: true` on a need. Its card stays on the front door with what the business does and a sentence saying it is not taking new ones right now, with no form and a button that does not press; nothing is recorded. The lint reports a paused need as a warning ("paused, not open now") and counts it on its own in the summary, as `planned` already did for needs not open yet. Klingenberg Bygg's building applications are paused for capacity.
|
||||
|
||||
## 0.6.4 (2026-09-30)
|
||||
|
||||
- The rate limit is a hundred at once, refilled at ten a second (it was 30, one every two seconds, which a page with a picture strip used up in one load: a few reloads and the stylesheet was refused). The files a page is made of, from portal's own disk, are not counted at all. `PORTAL_RATE_PER_SECOND` replaces `PORTAL_RATE_SECONDS`; no site set it.
|
||||
|
||||
## 0.6.3 (2026-09-30)
|
||||
|
||||
- Pages move the way their addresses do. Deeper in the address comes in from the right, back up comes in from the left, and a page at the same depth comes from below or above, with the index on top. The cards on screen leave as layers of their own, one after another, and the next page's cards arrive the same way. The old page stays on screen until the new one has loaded, where it used to go blank in between. Only transforms and opacity are animated, through the browser's View Transitions (`public/page-motion.js`, before the router). Browsers without them, and anyone who asks for reduced motion, navigate as before.
|
||||
- A record that carries a picture, in a list with no buttons, is shown as a card: the picture, the first field as the heading, the rest under it, with no field names, state or time. Cards are shown in the order they were put up. On a desk, the same picture is a thumbnail.
|
||||
- Trainer: `values` in a plan and `presents: true` on a need make a "who we are" card on the front door. The business's own statements are carried word for word, beside every record the need presents: the people who work there, entered from inside, each with a `picture` field (a file that only takes JPEG, PNG or WebP) that shows as their portrait. A public list keeps the form's field order, so the first field heads each row.
|
||||
|
||||
## 0.6.2 (2026-09-30)
|
||||
|
||||
- The header knows who is here: on a site with sign-in, a link top right to sign in, or the person's name and the way out. A page with a `slideshow` gets the wordmark as a bar of its own, the strip full width under it, and the question after.
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.6.2"
|
||||
version = "0.6.19"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.6.2"
|
||||
version = "0.6.19"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
@@ -60,6 +60,7 @@ web-sys = { version = "0.3", features = [
|
||||
"FileList",
|
||||
"FormData",
|
||||
"HtmlInputElement",
|
||||
"Navigator",
|
||||
"Request",
|
||||
"RequestInit",
|
||||
"Response",
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- [Getting started](getting-started.md)
|
||||
- [Content reference](content-reference.md)
|
||||
- [State machines](state-machines.md)
|
||||
- [Mail](mail.md)
|
||||
- [Access](access.md)
|
||||
- [Storylines](storylines.md)
|
||||
- [Memberships](memberships.md)
|
||||
|
||||
@@ -24,6 +24,7 @@ on every hot reload, and in CI through `iris check`.
|
||||
```yaml
|
||||
title: Vestmark kommune # the browser title and the header
|
||||
wordmark: /site/logo.svg # an image instead of the title
|
||||
wordmark_ink: true # a one-colour SVG mark drawn in the page's own ink
|
||||
wordmark_invert: true # flip it in dark mode
|
||||
wordmark_height: 2.4 # rem
|
||||
lang: no # the interface language: en or no
|
||||
@@ -124,6 +125,10 @@ answer:
|
||||
from: [open, incomplete] # defaults to the initial state alone
|
||||
label: Send them
|
||||
invite: { group: case_officers } # onboard someone into Kanidm
|
||||
# or with words of its own, using any field of the form:
|
||||
# invite:
|
||||
# group: guests
|
||||
# mail: { subject: "...", body: "... {tidspunkt} ... {link} ..." }
|
||||
```
|
||||
|
||||
`self_transition.from` is what keeps a person in dialogue with their
|
||||
@@ -250,6 +255,7 @@ reads is never a client-supplied parameter.
|
||||
states: [approved, refused] # only these; empty means all
|
||||
where: { field: case, equals: "{key}" }
|
||||
jq: ".[] | {name, url: .html_url}"
|
||||
limit: 1 # at most this many, after jq
|
||||
empty: Nothing decided yet.
|
||||
link: { to: "/notices/{id}", label: Answer this } # a page per record
|
||||
transitions: # the desk's buttons
|
||||
@@ -259,6 +265,14 @@ reads is never a client-supplied parameter.
|
||||
`source.kind` is one of `kv`, `gitea_starred`,
|
||||
`gitea_releases`, `url`.
|
||||
|
||||
`limit: 1` shows only the first item — for a feed where only the newest
|
||||
matters. A releases card wants the cut someone should run, not every
|
||||
cut there has ever been, and a page that grows by one card per release
|
||||
gets longer forever. It applies *after* `jq`, so a filter can reorder
|
||||
and the limit takes the top of that ordering. A source that yields a
|
||||
single record has no length and ignores it; `limit: 0` on a list shows
|
||||
nothing, and the `empty` line speaks instead.
|
||||
|
||||
A spec with neither `public: true` nor `requires_group` is
|
||||
unreachable: reads fail closed, and mutations always need a group
|
||||
whatever `public` says.
|
||||
@@ -267,6 +281,39 @@ whatever `public` says.
|
||||
records carrying the signed-in person's own address, read from the
|
||||
session. A file on such a record is theirs alone to open.
|
||||
|
||||
A record that carries a picture (a `type: file` field holding a JPEG,
|
||||
PNG, WebP, AVIF or GIF) is shown rather than read when the list has no
|
||||
`transitions`: a card with the picture on top, the first field as its
|
||||
heading, and the other fields under it, with no field names, state or
|
||||
time. The cards appear in the order the records were put up. That is
|
||||
how a site shows the people who work there, or a gallery of what it
|
||||
has built. On a desk, the same picture is a thumbnail that opens.
|
||||
`accept: image/jpeg,image/png,image/webp` on the field makes a phone
|
||||
send a JPEG whatever it keeps its photos as. What the person wrote is shown as
|
||||
markdown on stricter rules than the site's own: paragraphs, line
|
||||
breaks as typed, lists, emphasis and links to https, mailto or tel
|
||||
(marked `nofollow ugc`); no images, no raw HTML.
|
||||
|
||||
A desk (a list with `transitions`) shows every record still moving,
|
||||
and of those in a final state - one the state machine has no move out
|
||||
of - only the three most recently decided, newest first. "Show older"
|
||||
under the list brings three more each time it is pressed. The desk
|
||||
stays about the work in hand however long the site has run; nothing
|
||||
is hidden from a read-only list, and `leaves_after` still takes a
|
||||
record out of every list altogether.
|
||||
|
||||
A **releases manifest** - a `kind: url` resource whose JSON is
|
||||
`{ name, latest, versions: [{ version, date, notes, assets: [{ platform,
|
||||
url, sha256, size }] }], channels: { arch: { repo, package, note },
|
||||
ios: { testflight, note } } }`, newest version first, as a release
|
||||
pipeline writes it - renders as one card: the newest version, the file
|
||||
for the visitor's own platform first (read from the browser: `ios`,
|
||||
`macos`, `linux-x86_64`, `linux-aarch64`, `windows`, `android`), the
|
||||
other files and the channels folded under "All downloads". Linux gets
|
||||
the package repo lines beside its file; iPhones and Macs get
|
||||
TestFlight. Platforms a manifest lacks fall through to the folded
|
||||
list.
|
||||
|
||||
`states` and `where` narrow a listing. They matter most for a register
|
||||
that is public by law — public about what was decided, not about what
|
||||
was withdrawn — while the desk working the same bucket is a different
|
||||
|
||||
+42
-2
@@ -4,11 +4,15 @@ The iris over a site. A content push is an incoming wormhole; nothing
|
||||
comes through until it checks out.
|
||||
|
||||
```
|
||||
portal lint --path questions # a local checkout
|
||||
portal iris --path questions # a local checkout
|
||||
portal lint --repo https://project.uhhm.no/uhhm/questions
|
||||
portal lint replay --path questions --nats nats://127.0.0.1:14222
|
||||
```
|
||||
|
||||
`portal iris` is the lint's name, after the iris that keeps bad content
|
||||
out of the portal; `portal lint` runs the same thing, so workflows that
|
||||
already call it keep working.
|
||||
|
||||
It was its own binary, iris, until portal 0.6: the same code, released
|
||||
as portal's twin. Now it is the `portal` binary's `lint` command, so a
|
||||
content repo's CI downloads the release and runs it:
|
||||
@@ -30,7 +34,43 @@ in `needs.yaml`, when it has one:
|
||||
- the handling group holds a desk over that bucket, and every
|
||||
finished state is reachable through the buttons it (and any
|
||||
`also_moved_by` group) offers; no record strands on the way;
|
||||
- every stage the business named is a state of the machine.
|
||||
- every stage the business named is a state of the machine, and each
|
||||
working stage can follow the one before it;
|
||||
- across each bucket, whichever need brought a record in: every state
|
||||
is somewhere a record can get to, no state a record gets to holds it
|
||||
with no way on (before done or after), every declared move is a
|
||||
button, a visitor's own link or a clock (a move nobody offers warns),
|
||||
no desk is gated to one group on the page and another on its list,
|
||||
and every mail goes out from a state records reach and names only
|
||||
fields the forms collect.
|
||||
|
||||
Every finding says what to change and where: the missing
|
||||
`{ from, to, label }` and the desk page it goes on, the group that
|
||||
already has the button, or the edge aggregates.yaml lacks. A schema
|
||||
error no longer hides the needs findings - both are printed, so one
|
||||
round can fix them together.
|
||||
|
||||
`--findings <out.jsonl>` writes the same findings one JSON object per
|
||||
line: `id`, `level` (`fail`/`warn`), `code`, `scope` (`need`,
|
||||
`bucket`, or `repo` for a schema error), `need` (the need's id, or the
|
||||
bucket's name), `subject` (the state, the move as `from>to`, or the
|
||||
page), `message` and `fix`. The file is written empty when the content
|
||||
is clean. `id` hashes scope, need, code and subject and nothing of the
|
||||
wording, so the same gap keeps its id from one run to the next: a
|
||||
trainer can tell what a round closed, what is still open and what it
|
||||
broke. The codes:
|
||||
|
||||
| code | |
|
||||
|---|---|
|
||||
| `schema` | content the site would refuse to load |
|
||||
| `need-duplicate`, `persona-unknown-need` | needs.yaml itself |
|
||||
| `entry-missing`, `entry-gated`, `entry-no-responsible` | the page a visitor arrives on |
|
||||
| `path-missing`, `path-closed`, `path-over-budget`, `path-no-action` | getting the answer recorded |
|
||||
| `machine-missing`, `desk-missing`, `desk-gated-twice` | who works the bucket |
|
||||
| `done-unknown`, `done-unreachable`, `success-not-done` | finishing a case |
|
||||
| `stage-unknown`, `stage-unreachable`, `stage-order` | the business's own stages |
|
||||
| `state-unreached`, `strand`, `edge-unoffered` | every state and move of the bucket |
|
||||
| `mail-unreachable`, `mail-unknown-field` | what a state mails |
|
||||
|
||||
A need marked `planned` warns instead of failing. Exit status is the
|
||||
verdict, so a content repo's CI is one line.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Mail
|
||||
|
||||
Portal writes every mail itself and hands it to gdo to deliver
|
||||
(`portal.mail.send`); a reply comes back as a note beside the record
|
||||
(`portal.mail.received`). A mail is a subject and a markdown body with
|
||||
`{placeholders}`, sent as text with an HTML alternative. The kinds of
|
||||
mail are the ones content declares on a state (see
|
||||
[State machines](state-machines.md#mail)), the invite and its
|
||||
rewording (`invite: { group, mail }`, `mail.invite` in site.yaml), the
|
||||
note to a person named responsible for a question, and the answers to
|
||||
a suggested change.
|
||||
|
||||
## Variables
|
||||
|
||||
A placeholder is `{` letters, digits, `_`, `-` or `.` `}`. One that
|
||||
names nothing renders as nothing; a brace that opens no name stays a
|
||||
brace. The variables come in layers, so a template written for one
|
||||
kind of mail reads the same in another.
|
||||
|
||||
**On every mail**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `{site}` | the instance's public URL, no trailing slash |
|
||||
| `{site_name}` | site.yaml's `title` |
|
||||
|
||||
**On a mail about a record** (every state mail)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `{field}` | any field of the record, as the form sent it |
|
||||
| `{chain}` | the record's id |
|
||||
| `{email}` | the record's `email` field, trimmed |
|
||||
| `{bucket}`, `{state}` | where it is and the state it just entered |
|
||||
| `{notice.field}` | a field of the record this one belongs to, through its `type: record` field `notice` |
|
||||
| `{self_link}` | the record's own page for the person it belongs to: a page offering a self-service move (`self_transition`) on the bucket from the state the record is in, with the `?chain=&email=` that lets them act without signing in. Empty when there is no such page or no address |
|
||||
| `{desk_link}` | the desk that moves the record: the page whose list over the bucket has `transitions`, else one that reads it. Empty when no page reads the bucket |
|
||||
| `{link}` | where this reader acts: `{self_link}` in a mail `to: submitter`, `{desk_link}` in one to anyone else, and `{site}` when that link is empty |
|
||||
|
||||
`{link}` is the one to reach for. "Se meldingen og avtal befaring:
|
||||
{link}" sent to the caretaker opens the desk the report is on; "Feilen
|
||||
du meldte om er utbedret. {link}" sent to the tenant opens their own
|
||||
case where they can still act on it, or the site when there is
|
||||
nothing left for them to do.
|
||||
|
||||
**On an invite and the note to someone responsible**, beside every
|
||||
field of the invite's form: `{name}`, `{username}`, `{email}`,
|
||||
`{group}` (invite), `{questions}` (responsible), `{expires}`, and
|
||||
`{link}`, which here is the one-time sign-in link.
|
||||
|
||||
## What the lint holds a mail to
|
||||
|
||||
A state mail that sends from a state no record reaches warns; one that
|
||||
names a field no form recording into the bucket collects fails, since
|
||||
it would go out with a blank where the field should be. The lint
|
||||
knows every variable above, so `{link}` and the rest are never
|
||||
flagged. `to: submitter` requires a required `email` on every form
|
||||
filling the bucket, and `to: "{notice.email}"` requires the `notice`
|
||||
field.
|
||||
+16
-7
@@ -82,22 +82,31 @@ values.
|
||||
| `GARAGE_*` | uploads |
|
||||
| `MAPBOX_TOKEN` | map tiles, proxied; unset, maps are off |
|
||||
| `GARAGE_*` (4) | attachments; see below. Unset, upload fields fail closed |
|
||||
| `PORTAL_RATE_BURST`, `PORTAL_RATE_SECONDS` | the rate limit; see below |
|
||||
| `PORTAL_RATE_BURST`, `PORTAL_RATE_PER_SECOND` | the rate limit; see below |
|
||||
|
||||
Mail is delivered by [gdo](https://project.uhhm.no/uhhm/gdo), a
|
||||
separate unit on the same host reading the same NATS.
|
||||
|
||||
## Rate limiting
|
||||
|
||||
Every request passes a per-caller-IP limit before a session is loaded
|
||||
or a handler is entered: a burst of 30, refilled one every two
|
||||
seconds. A visitor filling in a form never reaches it (a page load
|
||||
fetches several resources at once); a script does.
|
||||
Every request that makes portal do something passes a per-caller-IP
|
||||
limit before a session is loaded or a handler is entered: a burst of
|
||||
100, refilled at ten a second. More than a hundred requests in ten
|
||||
seconds is a script; someone reloading a page again and again stays
|
||||
well inside it.
|
||||
|
||||
The files a page is made of, served from portal's own disk, are not
|
||||
counted: the stylesheet and scripts under `/pkg`, the fonts, the
|
||||
favicons, the vendored libraries. Refusing one of those is what turns
|
||||
a reloaded page into an unstyled mess, and serving one costs next to
|
||||
nothing. Anything that makes portal ask someone else is counted: a
|
||||
content picture under `/site/` is fetched from Gitea, a map tile from
|
||||
the map provider.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `PORTAL_RATE_BURST` | how many requests at once, default 30 |
|
||||
| `PORTAL_RATE_SECONDS` | seconds per refilled request, default 2 |
|
||||
| `PORTAL_RATE_BURST` | how many requests at once, default 100 |
|
||||
| `PORTAL_RATE_PER_SECOND` | requests refilled per second, default 10 |
|
||||
|
||||
Either set to `0` turns it off, for an instance behind something that
|
||||
already does this. The caller is read from `X-Forwarded-For` or
|
||||
|
||||
+19
-1
@@ -57,7 +57,9 @@ a record arrives in it.
|
||||
```
|
||||
|
||||
`{field}` is any field of the record, plus `{site}`, `{chain}`,
|
||||
`{email}`, `{bucket}` and `{state}`. The body is markdown; it is sent
|
||||
`{email}`, `{bucket}`, `{state}` and `{link}` - the page where the
|
||||
reader acts on the record, their own or the desk's (every variable is
|
||||
in [Mail](mail.md#variables)). The body is markdown; it is sent
|
||||
as text with an HTML alternative. `to: submitter` means the record's
|
||||
`email` field, and the lint then requires every form filling that
|
||||
bucket to collect a required `email`.
|
||||
@@ -116,6 +118,22 @@ the same transition path, fires the same mail, and lands on
|
||||
The unit is days and the sweep is hourly. This is a deadline, not a
|
||||
job queue.
|
||||
|
||||
### leaves_after
|
||||
|
||||
```yaml
|
||||
taken_down:
|
||||
event: taken_down
|
||||
leaves_after: { minutes: 10 }
|
||||
```
|
||||
|
||||
How long a record stays in its bucket's lists once it has entered the
|
||||
state, when that is not for ever. A card taken off the front page
|
||||
leaves the desk's list ten minutes later instead of standing there
|
||||
greyed out, and the ten minutes are the time to see what was just
|
||||
done. Days, hours and minutes add up, and a sweep takes records out
|
||||
once a minute. Nothing is decided and no mail goes. The record leaves
|
||||
the lists, and the event log keeps it.
|
||||
|
||||
## attended_by
|
||||
|
||||
```yaml
|
||||
|
||||
+16
-5
@@ -243,13 +243,24 @@ against the lint. It is kept for the record of what it measured.
|
||||
visitor will tolerate.
|
||||
2. **Structure.** Pages, state machines and desks are generated, then
|
||||
revised against the lint, `iris check`, until every need is met:
|
||||
a path within budget, a desk held by the right group, every
|
||||
finished state reachable, no record stranding. The page-writing
|
||||
model sees the needs and never the personas.
|
||||
a path within budget, a desk held by the right group, every state
|
||||
of every bucket reachable and none stranding a record. The
|
||||
page-writing model sees the needs and never the personas. Each
|
||||
round the lint writes its findings with stable ids (`--findings`,
|
||||
kept in `<out>/findings/round-N.jsonl`), and the round is logged as
|
||||
what it fixed, what is still open, what is new and what it
|
||||
reopened. A fix round is shown only the open findings, each with
|
||||
the change that closes it, and the files they name (the whole repo
|
||||
when a finding is about getting around the site). A change that
|
||||
reopens a closed finding is undone and the model told what it
|
||||
broke. A round that closes nothing, or leaves as many open as it
|
||||
found, ends the phase: what is left is written to `run.log` rather
|
||||
than polished at again.
|
||||
3. **Wording.** A separate model plays each persona, blind, first with
|
||||
headings only and then with descriptions. A misroute is a wording
|
||||
defect and goes back to the generator. A rewrite that breaks
|
||||
structure is reverted.
|
||||
defect and goes back to the generator; a visitor who chose right
|
||||
but hesitated is reported and rewrites nothing by itself. A rewrite
|
||||
that breaks structure is reverted.
|
||||
|
||||
Model output is only ever written to `aggregates.yaml`, `site.yaml`
|
||||
and `questions/**.yaml` inside the output directory.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 409.87222 77.398003" role="img" aria-label="GULLBERG sanatorium">
|
||||
<title>GULLBERG sanatorium</title>
|
||||
<!-- Futura, drawn as outlines so every machine shows the same mark.
|
||||
"sanatorium" is spaced out to run the width of GULLBERG, set in
|
||||
a little from each edge. As an image it colours itself for light
|
||||
and dark; with wordmark_ink in site.yaml, portal colours it with
|
||||
the page's own ink instead, and "sanatorium" stays lighter by its
|
||||
opacity. -->
|
||||
<style>
|
||||
.mark { fill: #1d1b18; }
|
||||
.sub { fill: #1d1b18; fill-opacity: 0.6; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.mark, .sub { fill: #f1ece3; }
|
||||
}
|
||||
</style>
|
||||
<path class="mark"
|
||||
d="M 0,25.216 Q 0,19.84 1.984,15.232 3.968,10.624 7.36,7.232 10.816,3.84 15.424,1.92 20.032,0 25.216,0 q 2.88,0 5.44,0.576 2.624,0.512 4.992,1.728 2.432,1.216 4.736,3.072 2.304,1.856 4.608,4.48 l -5.184,4.928 Q 33.856,6.912 25.344,6.912 q -3.84,0 -7.168,1.408 -3.264,1.408 -5.696,3.904 -2.432,2.432 -3.84,5.76 -1.344,3.328 -1.344,7.232 0,3.968 1.472,7.296 1.536,3.328 3.968,5.76 2.496,2.368 5.632,3.712 3.136,1.344 6.4,1.344 3.008,0 5.568,-0.96 2.56,-1.024 4.416,-2.688 1.92,-1.664 3.072,-3.904 1.152,-2.304 1.408,-4.8 V 30.912 H 27.2 v -6.848 h 19.968 v 1.6 q 0,5.504 -1.6,10.048 -1.6,4.48 -4.544,7.68 -2.88,3.2 -7.104,4.992 -4.16,1.728 -9.344,1.728 -5.12,0 -9.6,-1.92 Q 10.496,46.208 7.168,42.816 3.84,39.424 1.92,34.88 0,30.336 0,25.216 Z M 69.360026,0.96 h 7.296004 v 30.208 q 0,3.328 0.832,5.696 0.832,2.304 2.24,3.712 1.472,1.408 3.392,2.048 1.92,0.64 4.096,0.64 2.176,0 4.096,-0.64 1.92,-0.64 3.328,-2.048 1.472,-1.472 2.304,-3.84 0.896,-2.368 0.896,-5.824 V 0.96 h 7.232 V 32 q 0,4.352 -1.472,7.744 -1.408,3.392 -3.84,5.696 -2.432,2.304 -5.696,3.52 -3.2,1.152 -6.848,1.152 -3.648,0 -6.912,-1.152 -3.2,-1.216 -5.632004,-3.52 -2.432,-2.304 -3.904,-5.696 -1.408,-3.392 -1.408,-7.744 z M 129.18404,49.216 V 0.96 h 7.296 v 41.408 h 14.208 v 6.848 z m 41.90405,0 V 0.96 h 7.296 v 41.408 h 14.208 v 6.848 z m 41.90405,0 V 0.96 h 8.32 q 8.064,0 12.48,3.264 4.48,3.2 4.48,9.792 0,3.2 -1.536,5.76 -1.472,2.496 -3.904,3.84 v 0.064 q 4.416,1.28 6.912,4.416 2.56,3.136 2.56,7.296 0,3.52 -1.28,6.144 -1.28,2.56 -3.584,4.288 -2.304,1.728 -5.568,2.56 -3.2,0.832 -7.168,0.832 z m 12.416,-6.848 q 2.496,0 4.288,-0.576 1.792,-0.576 2.944,-1.536 1.152,-0.96 1.664,-2.24 0.512,-1.344 0.512,-2.88 0,-1.536 -0.512,-2.816 -0.512,-1.344 -1.728,-2.368 -1.152,-1.024 -3.136,-1.6 -1.92,-0.576 -4.8,-0.576 h -4.352 v 14.592 z m -2.688,-20.672 q 4.352,0 6.336,-1.984 1.984,-1.984 1.984,-5.504 0,-3.456 -2.048,-4.992 -1.984,-1.536 -6.272,-1.536 h -2.432 V 21.696 Z M 291.31219,0.96 v 6.848 h -19.328 v 11.584 h 18.816 v 6.848 h -18.816 v 16.128 h 19.328 v 6.848 h -26.624 V 0.96 Z m 32.56003,0 q 4.16,0 7.552,0.832 3.392,0.832 5.824,2.56 2.432,1.728 3.776,4.416 1.344,2.624 1.344,6.336 0,4.736 -2.688,8.32 -2.624,3.52 -7.808,5.376 l 14.72,20.416 h -8.896 l -13.568,-19.776 h -1.536 v 19.776 h -7.296 V 0.96 Z m -1.28,22.144 h 2.304 q 5.12,0 7.744,-1.984 2.624,-1.984 2.624,-5.888 0,-7.424 -10.048,-7.424 h -2.624 z m 40.112,2.112 q 0,-5.376 1.984,-9.984 1.984,-4.608 5.376,-8 3.456,-3.392 8.064,-5.312 4.608,-1.92 9.792,-1.92 2.88,0 5.44,0.576 2.624,0.512 4.992,1.728 2.432,1.216 4.736,3.072 2.304,1.856 4.608,4.48 l -5.184,4.928 q -5.952,-7.872 -14.464,-7.872 -3.84,0 -7.168,1.408 -3.264,1.408 -5.696,3.904 -2.432,2.432 -3.84,5.76 -1.344,3.328 -1.344,7.232 0,3.968 1.472,7.296 1.536,3.328 3.968,5.76 2.496,2.368 5.632,3.712 3.136,1.344 6.4,1.344 3.008,0 5.568,-0.96 2.56,-1.024 4.416,-2.688 1.92,-1.664 3.072,-3.904 1.152,-2.304 1.408,-4.8 v -0.064 h -12.032 v -6.848 h 19.968 v 1.6 q 0,5.504 -1.6,10.048 -1.6,4.48 -4.544,7.68 -2.88,3.2 -7.104,4.992 -4.16,1.728 -9.344,1.728 -5.12,0 -9.6,-1.92 -4.48,-1.984 -7.808,-5.376 -3.328,-3.392 -5.248,-7.936 -1.92,-4.544 -1.92,-9.664 z"
|
||||
id="g"
|
||||
|
||||
aria-label="GULLBERG" />
|
||||
<path class="sub"
|
||||
d="m 11.999999,76.176 1.079,-0.819 q 0.195,0.338 0.481,0.546 0.286,0.195 0.676,0.195 0.351,0 0.546,-0.143 0.195,-0.156 0.195,-0.442 0,-0.143 -0.065,-0.26 -0.052,-0.117 -0.195,-0.234 -0.143,-0.117 -0.39,-0.247 -0.247,-0.13 -0.624,-0.299 -0.793,-0.364 -1.118,-0.793 -0.325,-0.442 -0.325,-1.066 0,-0.403 0.156,-0.728 0.156,-0.325 0.416,-0.546 0.273,-0.234 0.624,-0.351 0.364,-0.13 0.78,-0.13 0.676,0 1.17,0.247 0.507,0.247 0.793,0.663 l -1.053,0.845 q -0.143,-0.182 -0.403,-0.325 -0.247,-0.143 -0.52,-0.143 -0.273,0 -0.429,0.117 -0.156,0.117 -0.156,0.312 0,0.104 0.052,0.195 0.052,0.091 0.195,0.195 0.143,0.104 0.39,0.234 0.26,0.13 0.663,0.312 0.819,0.377 1.118,0.832 0.299,0.442 0.299,1.066 0,0.455 -0.156,0.832 -0.156,0.364 -0.442,0.624 -0.286,0.26 -0.676,0.39 -0.39,0.143 -0.845,0.143 -0.884,0 -1.443,-0.377 -0.559,-0.377 -0.793,-0.845 z m 40.993985,-2.067 q 0,-0.689 0.221,-1.274 0.234,-0.598 0.637,-1.027 0.416,-0.442 0.962,-0.689 0.559,-0.247 1.209,-0.247 0.585,0 1.079,0.195 0.494,0.195 0.962,0.65 h 0.013 v -0.689 h 1.43 v 6.188 h -1.43 v -0.689 h -0.013 q -0.468,0.455 -0.962,0.663 -0.481,0.208 -1.04,0.208 -0.663,0 -1.235,-0.247 -0.559,-0.26 -0.975,-0.702 -0.403,-0.442 -0.637,-1.04 -0.221,-0.598 -0.221,-1.3 z m 1.456,0 q 0,0.429 0.117,0.806 0.13,0.364 0.364,0.637 0.234,0.273 0.572,0.429 0.351,0.143 0.78,0.143 0.429,0 0.767,-0.143 0.351,-0.156 0.598,-0.416 0.247,-0.273 0.377,-0.637 0.143,-0.364 0.143,-0.793 0,-0.416 -0.143,-0.78 -0.13,-0.364 -0.377,-0.624 -0.247,-0.273 -0.598,-0.429 -0.338,-0.156 -0.754,-0.156 -0.416,0 -0.767,0.156 -0.338,0.156 -0.585,0.416 -0.234,0.26 -0.364,0.624 -0.13,0.351 -0.13,0.767 z M 98.512,71.028 v 0.663 h 0.026 q 0.338,-0.39 0.78,-0.611 0.455,-0.221 1.092,-0.221 1.079,0 1.599,0.676 0.533,0.663 0.533,1.885 v 3.796 h -1.43 v -3.458 q 0,-0.897 -0.286,-1.248 -0.286,-0.364 -0.884,-0.364 -0.663,0 -1.053,0.442 -0.377,0.442 -0.377,1.521 v 3.107 h -1.43 v -6.188 z m 41.05899,3.081 q 0,-0.689 0.221,-1.274 0.234,-0.598 0.637,-1.027 0.416,-0.442 0.962,-0.689 0.559,-0.247 1.209,-0.247 0.585,0 1.079,0.195 0.494,0.195 0.962,0.65 h 0.013 v -0.689 h 1.43 v 6.188 h -1.43 v -0.689 h -0.013 q -0.468,0.455 -0.962,0.663 -0.481,0.208 -1.04,0.208 -0.663,0 -1.235,-0.247 -0.559,-0.26 -0.975,-0.702 -0.403,-0.442 -0.637,-1.04 -0.221,-0.598 -0.221,-1.3 z m 1.456,0 q 0,0.429 0.117,0.806 0.13,0.364 0.364,0.637 0.234,0.273 0.572,0.429 0.351,0.143 0.78,0.143 0.429,0 0.767,-0.143 0.351,-0.156 0.598,-0.416 0.247,-0.273 0.377,-0.637 0.143,-0.364 0.143,-0.793 0,-0.416 -0.143,-0.78 -0.13,-0.364 -0.377,-0.624 -0.247,-0.273 -0.598,-0.429 -0.338,-0.156 -0.754,-0.156 -0.416,0 -0.767,0.156 -0.338,0.156 -0.585,0.416 -0.234,0.26 -0.364,0.624 -0.13,0.351 -0.13,0.767 z M 183.958,71.028 v -2.262 h 1.43 v 2.262 h 1.105 v 1.339 h -1.105 v 4.849 h -1.43 v -4.849 h -1.001 v -1.339 z m 38.849,3.055 q 0,-0.663 0.234,-1.248 0.234,-0.585 0.663,-1.014 0.442,-0.442 1.04,-0.702 0.611,-0.26 1.352,-0.26 0.741,0 1.339,0.26 0.611,0.247 1.04,0.689 0.442,0.442 0.676,1.04 0.247,0.598 0.247,1.287 0,0.689 -0.247,1.287 -0.234,0.598 -0.676,1.04 -0.429,0.429 -1.04,0.689 -0.611,0.247 -1.365,0.247 -0.728,0 -1.339,-0.247 -0.598,-0.26 -1.027,-0.702 -0.429,-0.442 -0.663,-1.053 -0.234,-0.611 -0.234,-1.313 z m 1.456,0.026 q 0,0.923 0.494,1.469 0.494,0.546 1.339,0.546 0.832,0 1.339,-0.546 0.507,-0.546 0.507,-1.443 0,-0.884 -0.507,-1.43 -0.494,-0.559 -1.339,-0.559 -0.403,0 -0.741,0.143 -0.338,0.143 -0.585,0.416 -0.247,0.26 -0.377,0.624 -0.13,0.351 -0.13,0.78 z m 42.28101,-3.081 h 1.43 v 0.559 h 0.026 q 0.351,-0.377 0.65,-0.546 0.312,-0.182 0.767,-0.182 0.559,0 1.17,0.377 l -0.65,1.3 q -0.403,-0.286 -0.793,-0.286 -1.17,0 -1.17,1.755 v 3.211 h -1.43 z m 42.15098,0 v 6.188 h -1.43 v -6.188 z m -1.651,-2.691 q 0,-0.377 0.273,-0.65 0.273,-0.273 0.65,-0.273 0.39,0 0.663,0.273 0.286,0.273 0.286,0.65 0,0.377 -0.273,0.663 -0.273,0.273 -0.663,0.273 -0.403,0 -0.676,-0.273 -0.26,-0.286 -0.26,-0.663 z m 39.122,2.691 h 1.43 v 3.549 q 0,1.547 1.209,1.547 1.222,0 1.222,-1.547 v -3.549 h 1.43 v 3.588 q 0,1.313 -0.676,2.054 -0.676,0.728 -1.976,0.728 -0.598,0 -1.092,-0.169 -0.481,-0.169 -0.832,-0.507 -0.338,-0.351 -0.533,-0.871 -0.182,-0.52 -0.182,-1.235 z m 42.74902,0 h 1.43 V 71.6 h 0.013 q 0.286,-0.338 0.65,-0.533 0.377,-0.208 0.962,-0.208 0.624,0 1.027,0.234 0.403,0.221 0.702,0.689 h 0.013 q 0.377,-0.455 0.845,-0.689 0.481,-0.234 1.118,-0.234 1.105,0 1.651,0.637 0.546,0.637 0.546,1.898 v 3.822 h -1.43 v -3.432 q 0,-0.897 -0.26,-1.261 -0.26,-0.377 -0.845,-0.377 -0.65,0 -0.949,0.429 -0.286,0.416 -0.286,1.313 v 3.328 h -1.43 v -3.523 q 0,-0.806 -0.26,-1.17 -0.26,-0.377 -0.819,-0.377 -0.637,0 -0.949,0.429 -0.299,0.416 -0.299,1.313 v 3.328 h -1.43 z"
|
||||
id="s"
|
||||
|
||||
aria-label="sanatorium" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,61 @@
|
||||
# Aksjonæravtalen for Gullberg AS: punkter å dekke
|
||||
|
||||
Ikke en avtale, og ikke på siden. En liste over det aksjonæravtalen bør
|
||||
svare på, til samtale mellom de fire og med advokat eller regnskapsfører.
|
||||
Lånet går over lang tid (rundt 100 000 kr i året mot 1,6 millioner), så
|
||||
avtalen må tåle at livene til de fire endrer seg underveis.
|
||||
|
||||
## Eierskapet
|
||||
|
||||
- Hvem de fire aksjonærene er, med 25 % hver, og hva hver har skutt inn.
|
||||
- Hvordan huset går fra dødsboet til selskapet: skjøte, takst som legges
|
||||
til grunn, og hvem som betaler dokumentavgiften.
|
||||
- Hva søsknene som løses ut får, når, og at de ikke har noe krav etterpå.
|
||||
|
||||
## Lånet
|
||||
|
||||
- Hvem som står for lånet overfor banken, og om noen har kausjonert
|
||||
personlig. Hvis bare noen kausjonerer: hvordan de holdes skadesløse av
|
||||
de andre.
|
||||
- Hva som skjer hvis husleia ikke dekker avdraget en måned, eller et
|
||||
halvår: hvem skyter inn, i hvilket forhold, og som lån eller kapital.
|
||||
- Hva som skjer hvis renta stiger mye.
|
||||
|
||||
## Driften
|
||||
|
||||
- Hva styret avgjør alene, og hva som krever alle fire (nye lån, salg av
|
||||
huset, store utbedringer over et beløp, endring av husleia, ny eier).
|
||||
- Hva som skjer ved stemmelikhet, to mot to: hvem eller hva som bryter.
|
||||
- Klingenberg Bygg som forvalter, og som den som gir overslag og gjør
|
||||
arbeidet: hvordan overslag godkjennes, og hvordan en mulig
|
||||
interessekonflikt håndteres.
|
||||
- Et vedlikeholdsfond: hvor mye av overskuddet som settes av før noe
|
||||
deles ut.
|
||||
|
||||
## Bruken av huset
|
||||
|
||||
- Hvordan sommerukene fordeles mellom de fire, hvem som velger først, og
|
||||
hva som skjer med uker ingen bruker.
|
||||
- Om uker kan leies ut til andre, og hvem som får inntekten.
|
||||
- Hva aksjonærene betaler for egne uker, om noe.
|
||||
|
||||
## Når noe endrer seg
|
||||
|
||||
- En aksjonær vil selge: forkjøpsrett for de andre, hvordan prisen
|
||||
fastsettes (takst, formel), og hvor lang tid de andre har.
|
||||
- En aksjonær dør eller skiller seg: om aksjene går til arvinger eller
|
||||
ektefelle, eller om de andre kan løse dem ut.
|
||||
- En aksjonær klarer ikke sin del: hva som skjer med andelen.
|
||||
- Huset skal selges: hvordan, og hvordan pengene fordeles etter lånet.
|
||||
- Brann, stor skade, eller at huset ikke kan leies ut: forsikring, og
|
||||
hva som skjer med lånet.
|
||||
|
||||
## Utbytte og innsyn
|
||||
|
||||
- Når det deles ut utbytte, og i hvilken rekkefølge (lån, fond, utbytte).
|
||||
- Hva hver aksjonær har rett til å se (rapportene på siden, regnskapet).
|
||||
|
||||
## Tvister og varighet
|
||||
|
||||
- Hvordan uenighet løses før noen går til retten (mekling).
|
||||
- Når avtalen tas opp igjen, for eksempel hvert femte år.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Huset
|
||||
|
||||
Utkast, skrives av eierne: hva huset er, hvor det ligger, hvor mange rom det har og i hvilken stand.
|
||||
|
||||
# Forretningen
|
||||
|
||||
Utkast, skrives av eierne: de to inntektene, romleie til studenter og timeshare-uker, og hva hver av dem gir i året.
|
||||
|
||||
# Eierne
|
||||
|
||||
Utkast, skrives av eierne: Gullberg AS eies av fire aksjonærer med 25 % hver. Hvem de er, hvordan styret er satt sammen, og hva som krever alle fire.
|
||||
|
||||
# Lånet
|
||||
|
||||
Utkast, skrives av eierne: 1,6 millioner til å løse ut de to søsknene som ikke blir med (800 000 kr hver), og at selskapet overtar huset ved skjøte tinglyst på selskapet. Dokumentavgiften (normalt 2,5 % av verdien), rente, løpetid og sikkerhet slik banken tilbyr, og hvordan avdraget betjenes av husleia.
|
||||
|
||||
# Budsjettet
|
||||
|
||||
Utkast, skrives av eierne: inntekter, drift, renter og avdrag for et normalår, og hvor rapportene står.
|
||||
|
||||
# Det som mangler
|
||||
|
||||
Utkast, skrives av eierne: de største manglene ved huset, hva de koster å rette, og planen for det. Lista på siden holdes oppdatert.
|
||||
|
||||
# Forvaltningen
|
||||
|
||||
Utkast, skrives av eierne: at Klingenberg Bygg forvalter huset, og hva det innebærer.
|
||||
@@ -0,0 +1,327 @@
|
||||
# GULLBERG sanatorium - et Klingenberg Bygg-prosjekt på
|
||||
# gullberg.klingenbergbygg.no. UTKAST 2026-09-30, til samtale; ikke
|
||||
# bygget eller satt ut.
|
||||
#
|
||||
# Oppsettet (samtale 2026-10-01): hele huset legges i Gullberg AS, som
|
||||
# eies av fire aksjonærer med 25 % hver. Selskapet låner 1,6 millioner i
|
||||
# banken for å løse ut de to søsknene som ikke blir med (800 000 kr hver),
|
||||
# og overtar huset (skjøte tinglyst på selskapet). Dokumentavgiften,
|
||||
# normalt 2,5 % av verdien, må med i lånet eller budsjettet. Rundt
|
||||
# 100 000 kr i året til banken er en lang nedbetaling: alt som kan gå
|
||||
# galt underveis hører hjemme i aksjonæravtalen (se
|
||||
# documents/gullberg-aksjonaeravtale-punkter.md, ikke på siden).
|
||||
#
|
||||
# Huset ligger bak Ås kirke, i Syverudveien (17?), et kort stykke fra
|
||||
# NMBU. Rommene er for alle, men studenter går foran.
|
||||
#
|
||||
# Siden er huset i drift: rom til leie, feil meldt til Klingenberg
|
||||
# Bygg som forvalter, styrets liste over det som er bra, mangler og er
|
||||
# planlagt, husleia måned for måned, kvartalsrapporter, nedbetalingsplanen
|
||||
# alle fire står bak, og eierskiftet steg for steg. Banken er ikke en egen
|
||||
# gruppe på siden ennå; styret sender rapportene.
|
||||
#
|
||||
# Pengene: 3 soverom à 8 000 kr er 24 000 kr i måneden, rundt 264 000 i
|
||||
# året over 11 måneder; sommeren er timeshare-uker. Husleia går som fast
|
||||
# trekk eller AvtaleGiro til selskapets konto; Vipps eller Stripe bare for
|
||||
# engangsbeløp. Depositum på egen depositumskonto (husleieloven).
|
||||
#
|
||||
# Åpne spørsmål:
|
||||
# - hvem de fire aksjonærene er, og om nye medeiere søkes ved siden av
|
||||
# lånet (medeier-tilbudet er tatt ut av forsiden til det er avgjort)
|
||||
# - bankens tilbud: rente, løpetid, sikkerhet, kausjon
|
||||
# - takst; hvem som sitter i styret
|
||||
# - antall måneder studentene leier; timeshare bare for aksjonærene?
|
||||
# - regnskapsfører/advokat: skiftet, dokumentavgift, skatt på husleie
|
||||
# - kontakt og avsender (under er plassholdere)
|
||||
business: GULLBERG sanatorium
|
||||
language: no
|
||||
# Plassholder til styret har en adresse.
|
||||
contact: { name: Styret i Gullberg, email: bl@klingenbergbygg.no, group: styret }
|
||||
people: memberships
|
||||
mail: { from: "Gullberg <gullberg@klingenbergbygg.no>", reply_to: bl@klingenbergbygg.no }
|
||||
about: >
|
||||
Et gammelt hus bak Ås kirke, i Syverudveien, med rimelige rom til leie;
|
||||
studenter ved NMBU går foran, men alle kan søke,
|
||||
og uker om sommeren for dem som eier det. Drevet av eierne, forvaltet
|
||||
av Klingenberg Bygg; det som mangler, står på lista med planen for
|
||||
det, og tallene ligger åpent.
|
||||
|
||||
groups:
|
||||
- { id: styret, label: Styret }
|
||||
- { id: aksjonaerer, label: Aksjonærer }
|
||||
- { id: leietakere, label: Leietakere }
|
||||
- { id: klingenberg, label: Klingenberg Bygg }
|
||||
invites_by: [styret]
|
||||
|
||||
does:
|
||||
- id: rom
|
||||
for: leie-rom
|
||||
text: >
|
||||
Rimelige rom bak Ås kirke, et kort stykke fra NMBU. Studenter går
|
||||
foran, men alle kan søke. Du søker her, kommer på visning og sier
|
||||
selv ja til tilbudet.
|
||||
|
||||
# Forretningsplanen, ord for ord: ett kort per del. Skal skrives av
|
||||
# eierne; filen har bare delene og hva som hører hjemme i dem.
|
||||
documents:
|
||||
- id: forretningsplan
|
||||
question: Hvordan går Gullberg rundt?
|
||||
description: Forretningsplanen for Gullberg, slik eierne har lagt den fram for banken.
|
||||
file: documents/gullberg-forretningsplan.md
|
||||
|
||||
needs:
|
||||
# Offentlig: noen søker rom. Studenter går foran; styret ser hva den
|
||||
# som søker gjør til daglig, og velger etter det.
|
||||
- id: leie-rom
|
||||
who: en som trenger et rimelig rom i Ås, oftest en student ved NMBU
|
||||
wants: et rom å bo på til en leie som går opp, med studenter først i køen
|
||||
lands_in: romsoknader
|
||||
handled_by: styret
|
||||
stages: [open, visning, tilbud, akseptert, signert, avslatt]
|
||||
stages_text: { open: "mottatt", visning: "visning avtalt", tilbud: "tilbud sendt", akseptert: "tilbud godtatt", signert: "kontrakt signert", avslatt: "avsluttet uten leie" }
|
||||
done_when: [signert, avslatt]
|
||||
success: [signert]
|
||||
turned_down_from: [open, visning, tilbud]
|
||||
moves_by: { akseptert: [submitter], avslatt: [submitter, styret] }
|
||||
mails: { open: submitter, visning: submitter, tilbud: submitter, akseptert: bl@klingenbergbygg.no, signert: submitter }
|
||||
grants: { signert: leietakere }
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: email, type: email }
|
||||
- { name: telefon, type: tel }
|
||||
- { name: situasjon, type: select, options: [Student ved NMBU, Student et annet sted, Jobber eller annet] }
|
||||
- { name: innflytting, type: date, optional: true }
|
||||
- { name: om, type: textarea, optional: true }
|
||||
|
||||
# Rommene som er ledige, som bildekort styret legger inn og tar ned.
|
||||
- id: ledig-rom
|
||||
who: styret, når et rom blir ledig
|
||||
wants: at de som søker ser rommet før de søker
|
||||
enters_as: styret
|
||||
lands_in: rom
|
||||
handled_by: styret
|
||||
watched_by: [public]
|
||||
presents: true
|
||||
public_states: [open]
|
||||
stages: [open, utleid]
|
||||
stages_text: { open: "ledig", utleid: "leid ut" }
|
||||
done_when: [utleid]
|
||||
success: [utleid]
|
||||
leaves_after: { utleid: { minutes: 10 } }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: tittel }
|
||||
- { name: bilde, type: picture }
|
||||
- { name: om, type: textarea }
|
||||
|
||||
# Styrets liste: det som er bra, det som mangler, det som er planlagt.
|
||||
# Aksjonærene ser alt; allmennheten bare planene styret har publisert,
|
||||
# og de står som kunngjøringer.
|
||||
- id: liste
|
||||
who: styret, om huset og driften
|
||||
wants: én liste over det som er bra, det som mangler og det som er planlagt, med pris og tid
|
||||
enters_as: styret
|
||||
lands_in: liste
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer, public]
|
||||
public_states: [publisert]
|
||||
stages: [open, vedtatt, publisert, forkastet]
|
||||
stages_text: { open: "utkast", vedtatt: "vedtatt", publisert: "publisert", forkastet: "forkastet" }
|
||||
done_when: [publisert, forkastet]
|
||||
success: [publisert]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: tittel }
|
||||
- { name: slag, type: select, options: [Kvalitet, Mangel, Plan] }
|
||||
- { name: beskrivelse, type: textarea }
|
||||
- { name: kostnad, optional: true }
|
||||
- { name: naar, optional: true }
|
||||
|
||||
# Budsjettrapporten: tallene i felt, ikke bare en PDF. Aksjonærene ser
|
||||
# den her; styret sender den til banken og fører inn at den er sendt.
|
||||
- id: rapport
|
||||
who: styret, hvert kvartal
|
||||
wants: at aksjonærene ser hvordan det går, i tall, og at banken får det den skal ha
|
||||
enters_as: styret
|
||||
lands_in: rapporter
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer]
|
||||
stages: [open, sendt]
|
||||
stages_text: { open: "lagt fram for aksjonærene", sendt: "sendt til banken" }
|
||||
done_when: [sendt]
|
||||
success: [sendt]
|
||||
max_fields: 7
|
||||
collects:
|
||||
- { name: periode }
|
||||
- { name: leieinntekter }
|
||||
- { name: timeshare }
|
||||
- { name: driftskostnader }
|
||||
- { name: renter-og-avdrag }
|
||||
- { name: saldo }
|
||||
- { name: kommentar, type: textarea }
|
||||
- { name: dokument, type: file, optional: true }
|
||||
|
||||
# Nedbetalingsplanen: hvordan pengene går og hva banken får. Styret
|
||||
# legger den fram, BEGGE aksjonærene må godkjenne før den går til
|
||||
# banken, og styret fører inn bankens svar. "Alle i gruppa" finnes ikke
|
||||
# i portal ennå: i dag holder det at én trykker. Ny portal-funksjon før
|
||||
# dette brukes.
|
||||
- id: nedbetalingsplan
|
||||
who: styret, når veien videre og pengestrømmen skal avtales
|
||||
wants: en nedbetalingsplan alle fire aksjonærene står bak, som banken sier ja til
|
||||
enters_as: styret
|
||||
lands_in: nedbetalingsplaner
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer]
|
||||
stages: [open, godkjent, sendt, innvilget, endringer]
|
||||
stages_text: { open: "til godkjenning hos aksjonærene", godkjent: "godkjent av aksjonærene", sendt: "sendt til banken", innvilget: "innvilget av banken", endringer: "banken ber om endringer" }
|
||||
reached_from: { godkjent: [open], sendt: [godkjent], innvilget: [sendt], endringer: [sendt] }
|
||||
done_when: [innvilget, endringer]
|
||||
success: [innvilget]
|
||||
moves_by: { godkjent: [aksjonaerer], sendt: [styret], innvilget: [styret], endringer: [styret] }
|
||||
max_fields: 6
|
||||
collects:
|
||||
- { name: tittel }
|
||||
- { name: laanebelop }
|
||||
- { name: maanedlig-avdrag }
|
||||
- { name: forste-trekk, type: date }
|
||||
- { name: pengestrom, type: textarea }
|
||||
- { name: dokument, type: file, optional: true }
|
||||
|
||||
# Husleia, måned for måned: forventet mot betalt, fra kontoutskriften.
|
||||
# Aksjonærene og långiveren ser hvordan det går mellom rapportene.
|
||||
- id: husleie
|
||||
who: styret, når husleia for en måned er kommet inn
|
||||
wants: at aksjonærene ser hvilke rom som har betalt, og hva som gikk til banken
|
||||
enters_as: styret
|
||||
lands_in: husleie
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer]
|
||||
stages: [open, betalt, purret]
|
||||
stages_text: { open: "forventet", betalt: "betalt", purret: "purret" }
|
||||
reached_from: { purret: [open], betalt: [open, purret] }
|
||||
done_when: [betalt]
|
||||
success: [betalt]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: rom }
|
||||
- { name: maaned }
|
||||
- { name: belop }
|
||||
- { name: betalt-dato, type: date, optional: true }
|
||||
|
||||
# Feil i huset: en leietaker melder fra, Klingenberg Bygg ser på det og
|
||||
# gir et overslag, styret godkjenner kostnaden før arbeidet gjøres.
|
||||
- id: feil
|
||||
who: en leietaker som har funnet noe som er galt i huset
|
||||
wants: at det blir sett på og rettet
|
||||
enters_as: leietakere
|
||||
lands_in: feil
|
||||
handled_by: klingenberg
|
||||
# Leietakeren ser sine egne meldinger og hvor langt de har kommet.
|
||||
followed_by: [leietakere]
|
||||
watched_by: [styret]
|
||||
stages: [open, befaring, overslag, godkjent, utbedret, avvist]
|
||||
stages_text: { open: "meldt", befaring: "befaring avtalt", overslag: "overslag gitt", godkjent: "godkjent av styret", utbedret: "utbedret", avvist: "avvist" }
|
||||
reached_from: { avvist: [open, befaring, overslag] }
|
||||
done_when: [utbedret, avvist]
|
||||
success: [utbedret]
|
||||
moves_by: { godkjent: [styret], avvist: [styret, klingenberg] }
|
||||
mails: { open: fredrik@klingenbergbygg.no, overslag: bl@klingenbergbygg.no, utbedret: submitter }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: hva }
|
||||
- { name: hvor, optional: true }
|
||||
- { name: beskrivelse, type: textarea }
|
||||
- { name: email, type: email }
|
||||
- { name: bilde, type: picture, optional: true }
|
||||
|
||||
# Overslaget: Klingenberg Bygg gir et beløp for å rette en feil. Styret
|
||||
# og aksjonærene ser det; godkjente overslag hører med i rapporten.
|
||||
- id: overslag
|
||||
who: Klingenberg Bygg, når en feil er sett på
|
||||
wants: at styret får et beløp å ta stilling til, og at det havner i regnskapet
|
||||
enters_as: klingenberg
|
||||
lands_in: overslag
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer, klingenberg]
|
||||
stages: [open, godkjent, avvist, fakturert]
|
||||
stages_text: { open: "til styret", godkjent: "godkjent", avvist: "avvist", fakturert: "fakturert" }
|
||||
reached_from: { avvist: [open], fakturert: [godkjent] }
|
||||
done_when: [fakturert, avvist]
|
||||
success: [fakturert]
|
||||
moves_by: { fakturert: [klingenberg] }
|
||||
mails: { open: bl@klingenbergbygg.no }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: gjelder }
|
||||
- { name: belop }
|
||||
- { name: hva-det-dekker, type: textarea }
|
||||
- { name: dokument, type: file, optional: true }
|
||||
|
||||
# Eierskiftet, steg for steg: fra selskapet stiftes til søsknene er
|
||||
# utbetalt. Aksjonærene og banken ser hvor langt det har kommet.
|
||||
- id: eierskifte
|
||||
who: styret, mens huset går over til selskapet
|
||||
wants: at aksjonærene og banken ser hvor langt eierskiftet har kommet, med dokumentene
|
||||
enters_as: styret
|
||||
lands_in: eierskifte
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer]
|
||||
stages: [open, arbeid, ferdig]
|
||||
stages_text: { open: "ikke startet", arbeid: "i gang", ferdig: "ferdig" }
|
||||
done_when: [ferdig]
|
||||
success: [ferdig]
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: steg, type: select, options: [Selskapet stiftet, Takst, Lånet innvilget, Aksjonæravtale signert, Skjøte signert, Skjøte tinglyst, Dokumentavgift betalt, Søsknene utbetalt] }
|
||||
- { name: merknad, type: textarea, optional: true }
|
||||
- { name: dokument, type: file, optional: true }
|
||||
|
||||
# Aksjonærene: uker i huset, og saker til styret.
|
||||
- id: uke
|
||||
who: en aksjonær som vil bruke huset en periode
|
||||
wants: en uke i huset, bekreftet av styret
|
||||
enters_as: aksjonaerer
|
||||
lands_in: uker
|
||||
handled_by: styret
|
||||
stages: [open, bekreftet, avslatt]
|
||||
stages_text: { open: "ønsket", bekreftet: "bekreftet", avslatt: "ikke ledig" }
|
||||
done_when: [bekreftet, avslatt]
|
||||
success: [bekreftet]
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: fra, type: date }
|
||||
- { name: til, type: date }
|
||||
- { name: merknad, type: textarea, optional: true }
|
||||
|
||||
- id: sak
|
||||
who: en aksjonær som vil ha noe avgjort
|
||||
wants: at styret tar saken opp og avgjør den
|
||||
enters_as: aksjonaerer
|
||||
lands_in: saker
|
||||
handled_by: styret
|
||||
watched_by: [aksjonaerer]
|
||||
stages: [open, sakslisten, vedtatt, avvist]
|
||||
stages_text: { open: "meldt", sakslisten: "på sakslisten", vedtatt: "vedtatt", avvist: "avvist" }
|
||||
done_when: [vedtatt, avvist]
|
||||
success: [vedtatt]
|
||||
max_fields: 2
|
||||
collects:
|
||||
- { name: tittel }
|
||||
- { name: sak, type: textarea }
|
||||
|
||||
personas:
|
||||
- { id: student-nmbu, need: leie-rom, text: "Jeg begynner på master på NMBU til høsten og har ikke råd til hybel i Ås sentrum. Et rimelig rom i et stort hus høres perfekt ut." }
|
||||
- { id: ung-arbeider, need: leie-rom, text: "Jeg har fått jobb i Ås og trenger et rimelig sted å bo. Jeg er ikke student; kan jeg søke likevel?" }
|
||||
- { id: student-kort, need: leie-rom, text: "Jeg skal på utveksling og trenger et rom bare for ett semester. Går det an?" }
|
||||
- { id: styret-rom, need: ledig-rom, text: "Rommet i andre etasje blir ledig til jul, og jeg har et fint bilde av det. Det vil jeg legge ut." }
|
||||
- { id: styret-mangel, need: liste, text: "Taket på uthuset lekker. Det må stå på lista, og vi må ha en plan og en pris for det." }
|
||||
- { id: styret-kvartal, need: rapport, text: "Tredje kvartal er gjort opp. Leia kom inn, men strømmen ble dyrere enn vi trodde. Rapporten skal til banken." }
|
||||
- { id: leietaker-vann, need: feil, text: "Det drypper fra taket på badet i andre etasje når noen dusjer over. Hvem sier jeg fra til?" }
|
||||
- { id: klingenberg-overslag, need: overslag, text: "Vi har sett på lekkasjen på badet. Det er en pakning og litt flis, rundt 6 500 kroner. Styret må si ja før vi setter i gang." }
|
||||
- { id: styret-skjote, need: eierskifte, text: "Skjøtet er tinglyst på selskapet i dag. Banken skal se det, og kopien skal ligge ved." }
|
||||
- { id: aksjonaer-sommer, need: uke, text: "Vi vil gjerne være i huset første uka i juli med barna." }
|
||||
- { id: styret-plan, need: nedbetalingsplan, text: "Vi har regnet på det: 24 000 i måneden fra rommene, og vi kan sette av en fast sum til långiveren hver måned. Alle fire må stå bak det før vi går til banken." }
|
||||
- { id: styret-husleie, need: husleie, text: "Oktober er inne for to av rommene, det tredje mangler. Det må stå et sted alle ser." }
|
||||
- { id: aksjonaer-sak, need: sak, text: "Jeg mener vi bør se på solceller før vi tar opp mer lån. Kan styret ta det opp?" }
|
||||
@@ -38,6 +38,19 @@ does:
|
||||
godkjenning innen flere fagområder. Når tilbudet er godtatt,
|
||||
følger du oppdraget og henter dokumentene innlogget.
|
||||
|
||||
# Hvem vi er: en egen side med menneskene, erfaringene (oppdrag med
|
||||
# bilder, lagt inn fra kontoret) og verdiene under. Verdiene er et
|
||||
# utkast i husets tone; Fredrik bytter dem ut når han vil.
|
||||
values:
|
||||
- id: mal-to-ganger
|
||||
text: Vi måler to ganger og skriver én gang. Det gjelder betong, og det gjelder rapporter.
|
||||
- id: bygget-forst
|
||||
text: Et bygg bryr seg ikke om hvem som har rett. Vi skriver det bygget trenger, ikke det som er lettest å si.
|
||||
- id: ingen-overraskelser
|
||||
text: Det vi finner, får du vite. Også sprekkene.
|
||||
- id: vannet
|
||||
text: Vannet finner alltid en vei. Derfor leter vi etter den først.
|
||||
|
||||
needs:
|
||||
- id: tilstand
|
||||
who: en eier eller forvalter som må vite hvilken forfatning bygget er i, for en kommune, et borettslag, en bedrift eller seg selv
|
||||
@@ -89,7 +102,9 @@ needs:
|
||||
- { name: oppstart, type: date, optional: true }
|
||||
- { name: skisser, type: file, optional: true }
|
||||
|
||||
# På pause: fullt hos oss. Ta linjen ut for å åpne igjen.
|
||||
- id: soknad
|
||||
paused: true
|
||||
who: en byggherre med et ferdig prosjektert bygg som trenger offentlig godkjenning før første spadetak
|
||||
wants: en godkjent byggesøknad, med ansvarsrett der det kreves
|
||||
lands_in: soknader
|
||||
@@ -132,6 +147,52 @@ needs:
|
||||
- { name: hva }
|
||||
- { name: dokument, type: file }
|
||||
|
||||
# Menneskene: hver ansatt har sitt eget kort på forsiden, med rolle,
|
||||
# erfaring og portrett, og retter det selv. Kontoret kan ta et kort ned.
|
||||
- id: ansatt
|
||||
who: en av oss på kontoret, som vil vise hvem hen er og hva hen kan
|
||||
wants: at den som vurderer oss ser hvem som gjør jobben
|
||||
enters_as: kontor
|
||||
one_per_person: true
|
||||
# Et kort som er tatt ned, forsvinner fra kontorets liste etter ti minutter.
|
||||
leaves_after: { sluttet: { minutes: 10 } }
|
||||
lands_in: ansatte
|
||||
handled_by: kontor
|
||||
watched_by: [public]
|
||||
presents: true
|
||||
public_states: [open]
|
||||
stages: [open, sluttet]
|
||||
stages_text: { open: "vises på forsiden", sluttet: "sluttet" }
|
||||
done_when: [sluttet]
|
||||
success: [sluttet]
|
||||
max_fields: 4
|
||||
collects:
|
||||
- { name: name }
|
||||
- { name: rolle }
|
||||
- { name: erfaring, type: textarea }
|
||||
- { name: portrett, type: picture }
|
||||
|
||||
# Erfaringene: oppdrag vi vil vise fram, med bilde og noen løse ord.
|
||||
- id: erfaring
|
||||
who: en av oss på kontoret, når et oppdrag er verdt å vise fram
|
||||
wants: at den som vurderer oss ser hva vi har gjort
|
||||
enters_as: kontor
|
||||
lands_in: erfaringer
|
||||
handled_by: kontor
|
||||
watched_by: [public]
|
||||
presents: true
|
||||
public_states: [open]
|
||||
stages: [open, fjernet]
|
||||
stages_text: { open: "vises", fjernet: "fjernet" }
|
||||
done_when: [fjernet]
|
||||
success: [fjernet]
|
||||
leaves_after: { fjernet: { minutes: 10 } }
|
||||
max_fields: 3
|
||||
collects:
|
||||
- { name: tittel }
|
||||
- { name: bilde, type: picture }
|
||||
- { name: om, type: textarea }
|
||||
|
||||
personas:
|
||||
- { id: kommune-skole, need: tilstand, text: "Vi i kommunen skal legge fram en sak om rehabilitering av en barneskole fra 1972 og trenger en tilstandsanalyse politikerne kan beslutte ut fra." }
|
||||
- { id: borettslag, need: tilstand, text: "Styret i borettslaget lurer på hva som haster mest av tak, fasade og rør. Vi trenger noen som kan gå over byggene og si hva det står til." }
|
||||
@@ -140,6 +201,11 @@ personas:
|
||||
- { id: skole-i-drift, need: prosjekt, text: "Skolen skal utvides mens den er i drift. Vi trenger en beskrivelse og en fremdriftsplan med byggetrinn." }
|
||||
- { id: soke-garasje, need: soknad, text: "Tegningene til garasjen er ferdige, men jeg aner ikke hvordan jeg søker kommunen. Kan dere ta søknaden?" }
|
||||
- { id: ansvarsrett, need: soknad, text: "Vi har prosjektert ombyggingen selv, men mangler noen med ansvarsrett som kan stå for søknaden." }
|
||||
# Innenfra: det kontoret selv fyller ut.
|
||||
- { id: rapport-klar, need: leveranse, text: "Tilstandsrapporten til borettslaget er ferdig. Jeg vil at styreleder skal få den og vite at den ligger der." }
|
||||
- { id: ny-hos-oss, need: ansatt, text: "Jeg har nettopp begynt hos Klingenberg Bygg og vil at kundene skal se hvem jeg er og hva jeg kan." }
|
||||
- { id: nytt-portrett, need: ansatt, text: "Portrettet mitt på forsiden er fra da jeg begynte. Jeg vil bytte det uten å skrive alt det andre på nytt." }
|
||||
- { id: brannstasjonen, need: erfaring, text: "Brannstasjonen i Ytre Enebakk står ferdig, og vi har gode bilder fra fundamenteringen. Den vil jeg vise fram." }
|
||||
|
||||
slideshow:
|
||||
max_height: 60
|
||||
@@ -187,19 +253,19 @@ slideshow:
|
||||
- image: images/bildeserie/11.webp
|
||||
width: 1050
|
||||
height: 1400
|
||||
caption: Solid veggprofil tilpasset lyd og brann-krav
|
||||
caption: Solid veggprofil tilpasset lyd- og brannkrav
|
||||
- image: images/bildeserie/12.webp
|
||||
width: 1867
|
||||
height: 1400
|
||||
caption: Nye vinduer med tradisjonelt-utseende «Restover» glass som avslører utsikten fra Munkeramme på Hvitsten
|
||||
caption: Nye vinduer med tradisjonelt utseende «Restover»-glass som avslører utsikten fra Munkeramme på Hvitsten
|
||||
- image: images/bildeserie/13.webp
|
||||
width: 2571
|
||||
height: 1400
|
||||
caption: Kjærneved til varanda og inngangspartiet til Muncheramme
|
||||
caption: Kjerneved til verandaen og inngangspartiet til Muncheramme
|
||||
- image: images/bildeserie/14.webp
|
||||
width: 1050
|
||||
height: 1400
|
||||
caption: Drenering og riktige rammer før kunsgressbanen legges mesterfullt ned helt uten gummikuler
|
||||
caption: Drenering og riktige rammer før kunstgressbanen legges mesterfullt ned helt uten gummikuler
|
||||
- image: images/bildeserie/15.webp
|
||||
width: 2098
|
||||
height: 1400
|
||||
@@ -211,11 +277,11 @@ slideshow:
|
||||
- image: images/bildeserie/17.webp
|
||||
width: 1050
|
||||
height: 1400
|
||||
caption: Etanblering av ny byggekran
|
||||
caption: Etablering av ny byggekran
|
||||
- image: images/bildeserie/18.webp
|
||||
width: 1050
|
||||
height: 1400
|
||||
caption: Armerignskontroll før støping
|
||||
caption: Armeringskontroll før støping
|
||||
- image: images/bildeserie/19.webp
|
||||
width: 1867
|
||||
height: 1400
|
||||
@@ -223,7 +289,7 @@ slideshow:
|
||||
- image: images/bildeserie/20.webp
|
||||
width: 1867
|
||||
height: 1400
|
||||
caption: Her heises Moelvens modulskole i vestby på plass. Den blir stående i 10 år.
|
||||
caption: Her heises Moelvens modulskole i Vestby på plass. Den blir stående i minst 10 år.
|
||||
- image: images/bildeserie/21.webp
|
||||
width: 1867
|
||||
height: 1400
|
||||
@@ -235,7 +301,7 @@ slideshow:
|
||||
- image: images/bildeserie/23.webp
|
||||
width: 2095
|
||||
height: 1400
|
||||
caption: Nytt kunstgress med varme på Fredikstad stasion.
|
||||
caption: Nytt kunstgress med varme på Fredrikstad stasjon.
|
||||
- image: images/bildeserie/24.webp
|
||||
width: 1050
|
||||
height: 1400
|
||||
@@ -243,4 +309,4 @@ slideshow:
|
||||
- image: images/bildeserie/25.webp
|
||||
width: 1487
|
||||
height: 1400
|
||||
caption: pH-test av borreprøver i mur fra forskjellige dybder
|
||||
caption: pH-test av borprøver i betong fra forskjellige dybder
|
||||
|
||||
@@ -0,0 +1,985 @@
|
||||
{
|
||||
"aggregates.yaml": {
|
||||
"feil": {
|
||||
"open": {
|
||||
"subject": "Ny feil meldt på Gullberg: {hva}",
|
||||
"body": "En leietaker på Gullberg har meldt fra om {hva}. Se meldingen og avtal befaring: {link}"
|
||||
},
|
||||
"overslag": {
|
||||
"subject": "Overslag til styret: {hva}",
|
||||
"body": "Klingenberg Bygg har sett på {hva} og gitt et overslag. Styret godkjenner eller avviser det før arbeidet settes i gang: {link}"
|
||||
},
|
||||
"utbedret": {
|
||||
"subject": "{hva} er rettet",
|
||||
"body": "Feilen du meldte om, {hva}, er nå utbedret. Si gjerne til hvis noe fortsatt ikke er som det skal. {link}"
|
||||
}
|
||||
},
|
||||
"overslag": {
|
||||
"open": {
|
||||
"subject": "Nytt overslag fra Klingenberg Bygg: {gjelder}",
|
||||
"body": "Klingenberg Bygg har sendt et overslag for {gjelder}. Det ligger hos styret til godkjenning: {link}"
|
||||
}
|
||||
},
|
||||
"romsoknader": {
|
||||
"akseptert": {
|
||||
"subject": "{name} har takket ja til rommet",
|
||||
"body": "{name} har takket ja til tilbudet om rom på Gullberg. Neste steg er kontrakten og depositumet. {link}"
|
||||
},
|
||||
"open": {
|
||||
"subject": "Søknaden din om rom på Gullberg er mottatt",
|
||||
"body": "Hei {name},\n\nsøknaden din om rom på Gullberg er mottatt. Styret går gjennom søknadene fortløpende; studenter går foran, men alle blir sett på. Du hører fra oss om visning. {link}\n\nDet du kan gjøre nå: {site}/decide/leie-rom?chain={chain}&email={email}\n\nHilsen styret i Gullberg"
|
||||
},
|
||||
"signert": {
|
||||
"subject": "Kontrakten er signert",
|
||||
"body": "Kontrakten for rommet du søkte om er nå signert av begge parter. Rommet er ditt - velkommen inn. {link}"
|
||||
},
|
||||
"tilbud": {
|
||||
"subject": "Du har fått tilbud om rom",
|
||||
"body": "Styret har sett på søknaden din og kan tilby deg rommet. Svar her så snart du kan, så går vi videre med kontrakten. {link}\n\nDet du kan gjøre nå: {site}/decide/leie-rom?chain={chain}&email={email}"
|
||||
},
|
||||
"visning": {
|
||||
"subject": "Visning av rommet er satt opp",
|
||||
"body": "Søknaden din har kommet til visning - styret vil vise deg rommet før det avgjøres. Du hører fra oss om tid og sted. {link}\n\nDet du kan gjøre nå: {site}/decide/leie-rom?chain={chain}&email={email}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"questions/aksjonaerer/index.yaml": {
|
||||
"question": "Hvordan ligger huset og driften an?",
|
||||
"description": "Her ser aksjonærene hvordan huset og driften står: eierskiftet, husleia, lista, overslagene og sakene.",
|
||||
"confirm": "Bekreft",
|
||||
"buckets": {
|
||||
"eierskifte": {
|
||||
"heading": "Se hvor langt eierskiftet har kommet",
|
||||
"description": "Hvert steg i overgangen til Gullberg AS, med dokumentene som hører til.",
|
||||
"moves": {},
|
||||
"list_title": "Steg i eierskiftet",
|
||||
"list_note": "Hver rad er et steg med merknad og dokument; flytt det videre når det er i arbeid eller ferdig."
|
||||
},
|
||||
"husleie": {
|
||||
"heading": "Se hvilke rom som har betalt",
|
||||
"description": "Husleia for hvert rom, måned for måned, og hva som er purret på.",
|
||||
"moves": {},
|
||||
"list_title": "Husleie",
|
||||
"list_note": "Hver rad er ett rom for én måned, med beløp og betalingsdato; merk den betalt eller purret."
|
||||
},
|
||||
"liste": {
|
||||
"heading": "Se det som er bra, mangler og planlagt",
|
||||
"description": "Én liste over tilstanden i huset, med pris og tid på det som står igjen.",
|
||||
"moves": {},
|
||||
"list_title": "Lista",
|
||||
"list_note": "Hver rad er ett punkt med kostnad og tidspunkt; vedta den, publiser den eller forkast den."
|
||||
},
|
||||
"nedbetalingsplaner": {
|
||||
"heading": "Godkjenn nedbetalingsplanen",
|
||||
"description": "Planen for lånet, lagt fram for alle fire aksjonærene og banken.",
|
||||
"moves": {
|
||||
"open->godkjent": "Godkjenn planen"
|
||||
},
|
||||
"list_title": "Nedbetalingsplaner",
|
||||
"list_note": "Hver rad er en plan med lånebeløp og pengestrøm; følg den fra godkjent til innvilget hos banken."
|
||||
},
|
||||
"overslag": {
|
||||
"heading": "Se overslagene fra Klingenberg Bygg",
|
||||
"description": "Beløpet Klingenberg Bygg har satt på en feil, klart til vedtak.",
|
||||
"moves": {},
|
||||
"list_title": "Overslag",
|
||||
"list_note": "Hver rad er ett overslag med beløp og hva det dekker; godkjenn, avvis eller fakturer det."
|
||||
},
|
||||
"rapporter": {
|
||||
"heading": "Se kvartalsrapportene",
|
||||
"description": "Tallene aksjonærene og banken skal ha, kvartal for kvartal.",
|
||||
"moves": {},
|
||||
"list_title": "Rapporter",
|
||||
"list_note": "Hver rad er én periode med inntekter, kostnader og saldo; send den når den er klar."
|
||||
},
|
||||
"saker": {
|
||||
"heading": "Se sakene som er meldt til styret",
|
||||
"description": "Det en aksjonær vil ha styrets ord på, samlet på ett sted.",
|
||||
"moves": {},
|
||||
"list_title": "Saksliste",
|
||||
"list_note": "Hver rad er én sak; sett den på sakslisten, vedta den eller avvis den."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"uke": {
|
||||
"heading": "Be om en uke i huset",
|
||||
"description": "Reserver en periode selv, bekreftet av styret.",
|
||||
"button": "Søk om uke"
|
||||
},
|
||||
"sak": {
|
||||
"heading": "Få noe avgjort av styret",
|
||||
"description": "Meld en sak du vil ha styrets ord på.",
|
||||
"button": "Meld sak"
|
||||
}
|
||||
},
|
||||
"invites": {
|
||||
"aksjonaerer": {
|
||||
"heading": "Inviter en ny aksjonær inn",
|
||||
"description": "De får en konto og en lenke som gjelder én gang.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Ny aksjonær",
|
||||
"form_description": "Ha navn, e-post og telefon klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"invited": {
|
||||
"heading": "Se hvem som er invitert",
|
||||
"description": "Status på invitasjonene som er sendt ut.",
|
||||
"done": "Aktiv",
|
||||
"withdraw": "Trekk tilbake",
|
||||
"again": "Send på nytt",
|
||||
"list_title": "Invitasjoner",
|
||||
"list_note": "Hver rad er én invitasjon; send den på nytt om lenken har gått ut, eller trekk den tilbake."
|
||||
},
|
||||
"suggestions": null
|
||||
},
|
||||
"questions/aksjonaerer/sak-sent.yaml": {
|
||||
"question": "Hva skjer med saken jeg nettopp sendte inn?",
|
||||
"description": "Saken er sendt til styret. Den havner på sakslisten og blir tatt opp der.",
|
||||
"next_heading": "Følg saken videre",
|
||||
"next_description": "Styret vurderer saken og vedtar eller avviser den. Du finner status på sakslisten."
|
||||
},
|
||||
"questions/aksjonaerer/sak.yaml": {
|
||||
"heading": "Få en sak opp i styret",
|
||||
"description": "Noe som bør avgjøres, men ikke er ditt å avgjøre alene? Legg den fram, så tar styret den opp.",
|
||||
"button": "Send saken",
|
||||
"encouragement": "Styret ser den og setter den på sakslisten.",
|
||||
"icon": "vote",
|
||||
"paused": "Pause mottak av saker",
|
||||
"requires_title": "Krev tittel",
|
||||
"requires_description": "Krev beskrivelse av saken",
|
||||
"requires_icon": "Krev ikon",
|
||||
"road_title": "Fra forslag til vedtak",
|
||||
"road_description": "Saken kommer inn og settes på sakslisten, blir tatt opp av styret og ender med et vedtak eller en avvisning.",
|
||||
"road_icon": "list",
|
||||
"who_title": "Hvem som leser den",
|
||||
"who_description": "Styret får saken og tar stilling til den; haster det, nå dem på bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Ny sak",
|
||||
"form_description": "Skriv kort hva saken går ut på og hvorfor den bør opp.",
|
||||
"labels": {
|
||||
"tittel": "Tittel på saken",
|
||||
"sak": "Hva gjelder saken"
|
||||
},
|
||||
"hints": {
|
||||
"tittel": "Fast eller flytende leie for sommeren",
|
||||
"sak": "Vi bør avklare om timeshare-ukene skal prises likt hele året, eller om sommeren skal koste mer. Foreslår at styret tar stilling til dette før neste sesong."
|
||||
},
|
||||
"question": "Er det noe som bør avgjøres av styret?",
|
||||
"page_description": "Her melder du inn en sak du vil at styret skal ta opp og avgjøre."
|
||||
},
|
||||
"questions/aksjonaerer/uke-sent.yaml": {
|
||||
"question": "Hva skjer nå med ukesøknaden din?",
|
||||
"description": "Den er sendt og ligger hos styret, som avgjør om uken blir bekreftet.",
|
||||
"next_heading": "Styret ser på søknaden din",
|
||||
"next_description": "De bekrefter eller avslår uken. Du finner svaret i oversikten når det er klart."
|
||||
},
|
||||
"questions/aksjonaerer/uke.yaml": {
|
||||
"heading": "Reserver en uke i huset",
|
||||
"description": "Si fra når du vil bruke huset, så ser styret om datoene passer.",
|
||||
"button": "Send ønske",
|
||||
"encouragement": "Du hører fra styret så snart datoene er sjekket.",
|
||||
"icon": "calendar",
|
||||
"paused": "Tar vi ikke imot ukeønsker nå",
|
||||
"requires_title": "Krever tittel",
|
||||
"requires_description": "Krever beskrivelse",
|
||||
"requires_icon": "Krever ikon",
|
||||
"road_title": "Veien videre",
|
||||
"road_description": "Styret ser på ønsket ditt og bekrefter eller avslår uken.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem leser dette",
|
||||
"who_description": "Styret ser ønsket ditt; skriv til bl@klingenbergbygg.no om det haster.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Ukeønske",
|
||||
"form_description": "Fyll inn datoene du ønsker huset.",
|
||||
"labels": {
|
||||
"fra": "Fra dato",
|
||||
"til": "Til dato",
|
||||
"merknad": "Merknad"
|
||||
},
|
||||
"hints": {
|
||||
"fra": "f.eks. 6. juli 2027",
|
||||
"til": "f.eks. 13. juli 2027",
|
||||
"merknad": "f.eks. kommer med familie, trenger ekstra seng"
|
||||
},
|
||||
"question": "Hvilken uke vil du bruke huset?",
|
||||
"page_description": "Meld fra om uken du ønsker, så bekrefter styret den."
|
||||
},
|
||||
"questions/decide/leie-rom.yaml": {
|
||||
"question": "Vil du ta imot tilbudet om rom?",
|
||||
"description": "Styret har sett på søknaden din og tilbyr deg et rom. Si ja og det er ditt, si nei og det går videre til neste på lista.",
|
||||
"moves": {
|
||||
"akseptert": {
|
||||
"heading": "Ta imot rommet",
|
||||
"description": "Du sier ja til rommet du har fått tilbud om. Styret tar kontakt om innflytting og signering.",
|
||||
"button": "Ta imot"
|
||||
},
|
||||
"avslatt": {
|
||||
"heading": "Takk nei til rommet",
|
||||
"description": "Du sier nei til tilbudet, og rommet går videre til neste søker.",
|
||||
"button": "Takk nei"
|
||||
}
|
||||
}
|
||||
},
|
||||
"questions/hvem.yaml": {
|
||||
"question": "Hva er Gullberg?",
|
||||
"description": "Et gammelt hus bak Ås kirke, eid av fire aksjonærer og drevet av styret, med Klingenberg Bygg som forvalter. Her er rommene som er ledige nå.",
|
||||
"presents": {
|
||||
"rom": {
|
||||
"heading": "Rommene som er ledige",
|
||||
"description": "Rommene som står ledige for dem som søker et sted å bo.",
|
||||
"empty": "Ingen rom er ledige akkurat nå, men nye kommer når noen flytter ut."
|
||||
}
|
||||
},
|
||||
"values": {
|
||||
"heading": "Det styret har vedtatt",
|
||||
"description": "Det som gjelder for huset og driften, slik styret har satt det.",
|
||||
"items": {}
|
||||
}
|
||||
},
|
||||
"questions/index.yaml": {
|
||||
"question": "Trenger du et rimelig rom i Ås?",
|
||||
"description": "Gullberg er et gammelt hus bak Ås kirke, et kort stykke fra NMBU. Eierne driver det selv, og Klingenberg Bygg forvalter huset og vedlikeholdet.",
|
||||
"opener": {
|
||||
"heading": "Et rimelig sted å bo, nær NMBU",
|
||||
"description": "Rommene leies ut for et år av gangen, til en pris som går opp med studielånet. Studenter går foran, men alle kan søke.",
|
||||
"features": {
|
||||
"rom": {
|
||||
"heading": "Rom til leie, studenter først",
|
||||
"description": "Du leier et rom i huset for et år av gangen. Studenter ved NMBU får rommene først; er det ledig, får andre også.",
|
||||
"icon": "house"
|
||||
}
|
||||
}
|
||||
},
|
||||
"who": {
|
||||
"heading": "Hva er Gullberg?",
|
||||
"description": "Fire aksjonærer eier huset sammen og driver det selv, med Klingenberg Bygg som forvalter.",
|
||||
"button": "Se huset og rommene"
|
||||
},
|
||||
"shopping": {
|
||||
"heading": "Hva som er gjort, og hva som mangler",
|
||||
"description": "Det styret har vedtatt for huset: planene, med pris og tid der det er kjent.",
|
||||
"items": {
|
||||
"status-liste": {
|
||||
"heading": "Siste fra lista",
|
||||
"description": "Punktene styret har tatt stilling til, ferskest øverst.",
|
||||
"link": "Se hele lista",
|
||||
"empty": "Snart kommer de første punktene på lista.",
|
||||
"icon": "clipboard-list"
|
||||
}
|
||||
}
|
||||
},
|
||||
"doors": {},
|
||||
"needs": {
|
||||
"leie-rom": {
|
||||
"heading": "Søk om et rom",
|
||||
"description": "Fortell litt om deg og hva du gjør til daglig. Studenter går foran, men alle kan søke.",
|
||||
"button": "Søk rom",
|
||||
"encouragement": "Det tar bare et par minutter, og du hører fra styret så snart et rom er ledig.",
|
||||
"icon": "key",
|
||||
"paused": "Søknader er satt på pause",
|
||||
"requires_title": "Dette trenger vi",
|
||||
"requires_description": "Navn, e-post og hvilket lærested du går på.",
|
||||
"requires_icon": "clipboard-list",
|
||||
"road_title": "Veien videre",
|
||||
"road_description": "Søknaden havner hos styret, som inviterer deg på visning av et ledig rom. Sier du ja til tilbudet, signerer du leieavtalen og flytter inn.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem leser søknaden",
|
||||
"who_description": "Styret i Gullberg leser søknaden din, og du når dem i mellomtiden på bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Søknadsskjema",
|
||||
"form_description": "Fyll inn det du vet nå - resten kan vente til visningen.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"email": "E-post",
|
||||
"telefon": "Telefon",
|
||||
"innflytting": "Ønsket innflytting",
|
||||
"om": "Litt om deg",
|
||||
"situasjon": "Hva gjør du til daglig?"
|
||||
},
|
||||
"hints": {
|
||||
"name": "Kari Nordmann",
|
||||
"email": "kari@student.no",
|
||||
"telefon": "123 45 678",
|
||||
"innflytting": "August 2026",
|
||||
"om": "Jeg studerer medisin og trenger et rolig sted å bo nær campus.",
|
||||
"situasjon": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"questions/klingenberg/index.yaml": {
|
||||
"question": "Hva trenger huset nå?",
|
||||
"description": "Her samler Klingenberg Bygg det leietakerne melder inn, og styret ser overslagene før de godkjennes.",
|
||||
"confirm": "Bekreft",
|
||||
"buckets": {
|
||||
"feil": {
|
||||
"heading": "Se hva som er meldt inn av feil",
|
||||
"description": "Her ser du det leietakerne har meldt, fra ny melding til den er utbedret.",
|
||||
"moves": {
|
||||
"befaring->overslag": "Lag overslag",
|
||||
"befaring->avvist": "Avvis saken",
|
||||
"godkjent->utbedret": "Meld utbedret",
|
||||
"open->befaring": "Sett opp befaring",
|
||||
"open->avvist": "Avvis saken",
|
||||
"overslag->avvist": "Avvis overslaget"
|
||||
},
|
||||
"list_title": "Meldte feil",
|
||||
"list_note": "Hver rad er en feil som er meldt inn; knappene under flytter den videre til befaring, overslag eller utbedret."
|
||||
},
|
||||
"overslag": {
|
||||
"heading": "Følg opp overslagene dine",
|
||||
"description": "Her ser du overslagene du har levert, og om styret har godkjent dem.",
|
||||
"moves": {
|
||||
"godkjent->fakturert": "Merk fakturert"
|
||||
},
|
||||
"list_title": "Leverte overslag",
|
||||
"list_note": "Hver rad er et overslag du har levert; marker det fakturert når regningen er sendt."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"overslag": {
|
||||
"heading": "Se alle overslag i ett regnskap",
|
||||
"description": "Her finner du overslagene samlet, uansett hvem som har meldt feilen.",
|
||||
"button": "Åpne overslag"
|
||||
}
|
||||
},
|
||||
"invites": {
|
||||
"klingenberg": {
|
||||
"heading": "Gi noen hos Klingenberg Bygg tilgang",
|
||||
"description": "Personen får en konto og en lenke som gjelder én gang, så de kan logge inn og se feilene selv.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Inviter ny person",
|
||||
"form_description": "Ha navn, telefon og e-post klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"invited": {
|
||||
"heading": "Hold oversikt over inviterte",
|
||||
"description": "Her ser du hvem som er invitert, og om de har tatt lenken i bruk.",
|
||||
"done": "Tatt i bruk",
|
||||
"withdraw": "Trekk tilbake",
|
||||
"again": "Send på nytt",
|
||||
"list_title": "Sendte invitasjoner",
|
||||
"list_note": "Hver rad er en invitasjon; send den på nytt hvis lenken har gått ut, eller trekk den tilbake."
|
||||
},
|
||||
"suggestions": null
|
||||
},
|
||||
"questions/klingenberg/overslag-sent.yaml": {
|
||||
"question": "Hva skjer med overslaget du nettopp sendte inn?",
|
||||
"description": "Det ligger nå hos styret, med beløp og hva det dekker. De tar stilling til det og fører det videre i regnskapet.",
|
||||
"next_heading": "Styret vurderer overslaget",
|
||||
"next_description": "Du hører fra dem hvis de trenger mer, ellers venter du på svar om det blir godkjent, avvist eller fakturert."
|
||||
},
|
||||
"questions/klingenberg/overslag.yaml": {
|
||||
"heading": "Overslaget går til styret",
|
||||
"description": "Når du har sett på en feil og satt en pris, sender du overslaget hit, så styret kan ta stilling til det.",
|
||||
"button": "Send overslag",
|
||||
"encouragement": "Styret ser det i løpet av kort tid, og det følger saken videre til regnskapet.",
|
||||
"icon": "receipt",
|
||||
"paused": "Ta ikke mot nye overslag nå",
|
||||
"requires_title": "Krev tittel",
|
||||
"requires_description": "Krev beskrivelse",
|
||||
"requires_icon": "Krev ikon",
|
||||
"road_title": "Veien videre",
|
||||
"road_description": "Styret godkjenner eller avviser overslaget. Blir det godkjent og arbeidet utført, blir det fakturert.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem leser det",
|
||||
"who_description": "Styret i Gullberg leser overslaget og tar stilling til det, du når dem på bl@klingenbergbygg.no i mellomtiden.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Overslag på feil",
|
||||
"form_description": "Ha prisen og hva den dekker klart før du sender.",
|
||||
"labels": {
|
||||
"gjelder": "Hva overslaget gjelder",
|
||||
"belop": "Beløp",
|
||||
"hva-det-dekker": "Hva beløpet dekker",
|
||||
"dokument": "Dokument"
|
||||
},
|
||||
"hints": {
|
||||
"gjelder": "Lekkasje på badet i rom 4",
|
||||
"belop": "12 500 kr",
|
||||
"hva-det-dekker": "Ny membran, fliser og arbeidstid, to dagers arbeid",
|
||||
"dokument": "overslag-rom4.pdf"
|
||||
},
|
||||
"question": "Hva vil det koste å rette feilen?",
|
||||
"page_description": "Overslaget går til styret, som godkjenner eller avviser det før det føres i regnskapet."
|
||||
},
|
||||
"questions/leietakere/feil-sent.yaml": {
|
||||
"question": "Hva skjer nå med feilen jeg meldte?",
|
||||
"description": "Meldingen er sendt til Klingenberg Bygg, som drar på befaring og vurderer hva den koster å rette.",
|
||||
"next_heading": "Klingenberg Bygg ser på saken",
|
||||
"next_description": "De befarer feilen og lager et overslag som styret tar stilling til."
|
||||
},
|
||||
"questions/leietakere/feil.yaml": {
|
||||
"heading": "Meld fra om noe som er galt i huset",
|
||||
"description": "Fant du noe som ikke virker eller er ødelagt? Si fra, så ser styret på det.",
|
||||
"button": "Send feilen",
|
||||
"encouragement": "Tar et par minutter, og styret får beskjed med én gang.",
|
||||
"icon": "wrench",
|
||||
"paused": "Ta imot nye feilmeldinger",
|
||||
"requires_title": "Overskrift",
|
||||
"requires_description": "Forklaring",
|
||||
"requires_icon": "Ikon",
|
||||
"road_title": "Hva skjer videre",
|
||||
"road_description": "Styret ser på feilen og avtaler en befaring. Deretter henter de inn et overslag, godkjenner det, og feilen rettes opp – eller avvises, med beskjed om hvorfor.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem leser dette",
|
||||
"who_description": "Styret i Gullberg får meldingen og følger den opp; haster det, send en e-post til bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Meld en feil",
|
||||
"form_description": "Fortell hva som er galt og hvor, gjerne med bilde.",
|
||||
"labels": {
|
||||
"hva": "Hva er galt",
|
||||
"hvor": "Hvor i huset",
|
||||
"beskrivelse": "Beskriv feilen",
|
||||
"email": "E-post",
|
||||
"bilde": "Bilde"
|
||||
},
|
||||
"hints": {
|
||||
"hva": "Vannlekkasje under kjøkkenvasken",
|
||||
"hvor": "Kjøkkenet i 2. etasje",
|
||||
"beskrivelse": "Det drypper fra røret under vasken, har blitt verre siste uken",
|
||||
"email": "navn@student.no",
|
||||
"bilde": "Last opp et bilde av feilen"
|
||||
},
|
||||
"question": "Har du funnet noe i huset som bør rettes opp?",
|
||||
"page_description": "Meldingen går rett til Klingenberg Bygg, som forvalter huset. De ser på det, og styret godkjenner det som koster penger."
|
||||
},
|
||||
"questions/leietakere/index.yaml": {
|
||||
"question": "Hva kan vi gjøre for deg i huset?",
|
||||
"description": "Feil leietakerne melder inn, og overslagene Klingenberg Bygg sender over, samlet på ett sted.",
|
||||
"confirm": "Bekreft",
|
||||
"buckets": {},
|
||||
"links": {
|
||||
"feil": {
|
||||
"heading": "Har du funnet noe som er galt i huset?",
|
||||
"description": "Meld fra, så går det rett til Klingenberg Bygg.",
|
||||
"button": "Meld fra"
|
||||
}
|
||||
},
|
||||
"invites": {},
|
||||
"invited": null,
|
||||
"suggestions": null
|
||||
},
|
||||
"questions/received/leie-rom.yaml": {
|
||||
"question": "Hva skjer med søknaden min nå?",
|
||||
"description": "Styret går gjennom romsøknader fortløpende og svarer så snart det er en visning å tilby.",
|
||||
"next_heading": "Du hører fra styret om en visning",
|
||||
"next_description": "Styret tar kontakt på e-post eller telefon for å avtale tid. Får du tilbud om rommet, får du beskjed rett etter."
|
||||
},
|
||||
"questions/status/liste/index.yaml": {
|
||||
"question": "Hva er vedtatt og gjort for huset?",
|
||||
"description": "Her ligger det som styret har vedtatt og satt i verk, med pris og tid der det er kjent.",
|
||||
"heading": "Det som er vedtatt og gjort",
|
||||
"list_description": "Åpen liste over det styret har vedtatt for huset.",
|
||||
"list_title": "Vedtatt og gjort",
|
||||
"list_note": "Hver rad er én sak, med hva den gjelder, kostnad og tidspunkt der det er satt.",
|
||||
"empty": "Ingen saker er publisert ennå.",
|
||||
"answer": "Svar"
|
||||
},
|
||||
"questions/status/rom/index.yaml": {
|
||||
"question": "Hvilke rom er ledige nå?",
|
||||
"description": "Her finner du rommene som er ledige for innflytting, med bilde og en kort beskrivelse av hvert rom.",
|
||||
"heading": "Ledige rom i huset",
|
||||
"list_description": "Rommene som er ledige akkurat nå.",
|
||||
"list_title": "Ledige rom",
|
||||
"list_note": "Hver rad er et rom du kan søke på.",
|
||||
"empty": "Ingen rom er ledige akkurat nå, men se gjerne innom igjen senere.",
|
||||
"answer": "Svar"
|
||||
},
|
||||
"questions/styret/eierskifte-sent.yaml": {
|
||||
"question": "Hva skjer nå med eierskiftesaken du sendte inn?",
|
||||
"description": "Steget er registrert og legger seg på listen over eierskiftet. Aksjonærene ser det neste gang de ser på fremdriften.",
|
||||
"next_heading": "Følg eierskiftet videre",
|
||||
"next_description": "Du finner status og alle steg i oversikten over eierskiftet, og kan legge inn mer når noe nytt skjer."
|
||||
},
|
||||
"questions/styret/eierskifte.yaml": {
|
||||
"heading": "Se hvor eierskiftet står",
|
||||
"description": "Meld fra om neste steg i overgangen til selskapet, så ser aksjonærene og banken fremgangen.",
|
||||
"button": "Legg inn steg",
|
||||
"encouragement": "Tar et minutt, og listen oppdateres med én gang.",
|
||||
"icon": "file-text",
|
||||
"paused": "Satt på vent",
|
||||
"requires_title": "Krever tittel",
|
||||
"requires_description": "Krever beskrivelse",
|
||||
"requires_icon": "Krever ikon",
|
||||
"road_title": "Fra melding til ferdig",
|
||||
"road_description": "Steget legges inn, så arbeides det med, og til slutt merkes det som ferdig.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem leser det",
|
||||
"who_description": "Styret følger eierskiftet her; ring styret i mellomtiden hvis noe ikke kan vente.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Nytt steg i eierskiftet",
|
||||
"form_description": "Skriv hva som er gjort, og legg ved dokumentet hvis det finnes et.",
|
||||
"labels": {
|
||||
"steg": "Hva er gjort",
|
||||
"merknad": "Merknad",
|
||||
"dokument": "Dokument"
|
||||
},
|
||||
"hints": {
|
||||
"steg": "Skjøtet er sendt til tinglysing",
|
||||
"merknad": "Megler venter på signatur fra den siste selgeren",
|
||||
"dokument": "skjote-gullberg.pdf"
|
||||
},
|
||||
"question": "Hvor langt har eierskiftet kommet?",
|
||||
"page_description": "Her legger styret inn stegene i overgangen til selskapet, så aksjonærene kan følge med. Banken får det den skal ha fra styret."
|
||||
},
|
||||
"questions/styret/husleie-sent.yaml": {
|
||||
"question": "Hva skjer nå med husleia jeg registrerte?",
|
||||
"description": "Betalingen er lagt inn og synlig for resten av styret, sammen med de andre rommenes status denne måneden.",
|
||||
"next_heading": "Følg med i husleielista",
|
||||
"next_description": "Der ser dere hvilke rom som har betalt og hva som er purret på."
|
||||
},
|
||||
"questions/styret/husleie.yaml": {
|
||||
"heading": "Husleia for måneden, samlet ett sted",
|
||||
"description": "Legg inn hva som er betalt per rom, så aksjonærene ser det og banken får sitt.",
|
||||
"button": "Registrer husleie",
|
||||
"encouragement": "Det tar et minutt per rom.",
|
||||
"icon": "banknote",
|
||||
"paused": "Tas ikke imot nå",
|
||||
"requires_title": "Krever tittel",
|
||||
"requires_description": "Krever beskrivelse",
|
||||
"requires_icon": "Krever ikon",
|
||||
"road_title": "Hva som skjer videre",
|
||||
"road_description": "Du registrerer at husleia er betalt. Hvis et rom ikke har betalt, markeres det som purret til det kommer inn.",
|
||||
"road_icon": "refresh-cw",
|
||||
"who_title": "Hvem som ser dette",
|
||||
"who_description": "Styret ser oversikten; har du spørsmål underveis, ta kontakt med styret på bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Registrer husleie",
|
||||
"form_description": "Ha rom, måned og beløp klart.",
|
||||
"labels": {
|
||||
"rom": "Hvilket rom",
|
||||
"maaned": "Hvilken måned",
|
||||
"belop": "Beløp",
|
||||
"betalt-dato": "Betalt dato"
|
||||
},
|
||||
"hints": {
|
||||
"rom": "Rom 2",
|
||||
"maaned": "Oktober 2026",
|
||||
"belop": "4500",
|
||||
"betalt-dato": "2026-10-05"
|
||||
},
|
||||
"question": "Hvilke rom har betalt husleie denne måneden?",
|
||||
"page_description": "Her ser styret hvilke rom som har betalt husleia og hva som har gått til banken."
|
||||
},
|
||||
"questions/styret/index.yaml": {
|
||||
"question": "Hva trenger styret å gjøre nå?",
|
||||
"description": "Her samler styret alt som skjer i huset: søknader, feil, økonomi og eierskiftet, med knappene som flytter hver sak videre.",
|
||||
"confirm": "Bekreft",
|
||||
"buckets": {
|
||||
"eierskifte": {
|
||||
"heading": "Se hvor langt eierskiftet har kommet",
|
||||
"description": "Følg stegene når huset går over til selskapet, med dokumentene på plass for aksjonærer og bank.",
|
||||
"moves": {
|
||||
"arbeid->ferdig": "Merk steget ferdig",
|
||||
"open->arbeid": "Start arbeidet"
|
||||
},
|
||||
"list_title": "Steg i eierskiftet",
|
||||
"list_note": "Hver rad er ett steg i overgangen, med merknad og dokument; knappen under flytter steget videre."
|
||||
},
|
||||
"feil": {
|
||||
"heading": "Følg opp feil som er meldt inn",
|
||||
"description": "Se hva leietakerne har funnet galt i huset, og send det videre til Klingenberg Bygg for befaring og overslag.",
|
||||
"moves": {
|
||||
"befaring->avvist": "Avvis feilen",
|
||||
"open->avvist": "Avvis feilen",
|
||||
"overslag->godkjent": "Godkjenn overslaget",
|
||||
"overslag->avvist": "Avvis overslaget"
|
||||
},
|
||||
"list_title": "Meldte feil",
|
||||
"list_note": "Hver rad er en feil meldt av en leietaker, med bilde og beskrivelse; knappene under sender den videre eller avviser den."
|
||||
},
|
||||
"husleie": {
|
||||
"heading": "Hold oversikt over husleia hver måned",
|
||||
"description": "Se hvilke rom som har betalt, og purr dem som ikke har gjort det.",
|
||||
"moves": {
|
||||
"open->betalt": "Merk som betalt",
|
||||
"open->purret": "Send purring",
|
||||
"purret->betalt": "Merk som betalt"
|
||||
},
|
||||
"list_title": "Husleie per rom",
|
||||
"list_note": "Hver rad er husleia for ett rom én måned, med beløp og betalingsdato; knappene under merker betalt eller sender purring."
|
||||
},
|
||||
"liste": {
|
||||
"heading": "Styr lista over det som er bra, mangler og er planlagt",
|
||||
"description": "Vedta punktene, sett pris og tid, og publiser dem som leietakerne skal se.",
|
||||
"moves": {
|
||||
"open->vedtatt": "Vedta punktet",
|
||||
"open->forkastet": "Forkast punktet",
|
||||
"vedtatt->publisert": "Publiser punktet",
|
||||
"vedtatt->forkastet": "Forkast punktet"
|
||||
},
|
||||
"list_title": "Punkter på lista",
|
||||
"list_note": "Hver rad er ett punkt med slag, beskrivelse, kostnad og tid; knappene under vedtar, publiserer eller forkaster det."
|
||||
},
|
||||
"nedbetalingsplaner": {
|
||||
"heading": "Følg nedbetalingsplanen fra aksjonærene til banken",
|
||||
"description": "Følg planen fra forslag til bankens svar, med pengestrømmen alle fire aksjonærene står bak.",
|
||||
"moves": {
|
||||
"godkjent->sendt": "Send til banken",
|
||||
"sendt->endringer": "Be om endringer",
|
||||
"sendt->innvilget": "Merk som innvilget"
|
||||
},
|
||||
"list_title": "Nedbetalingsplaner",
|
||||
"list_note": "Hver rad er en plan med lånebeløp, avdrag og pengestrøm; knappene under sender den til banken eller merker svaret."
|
||||
},
|
||||
"overslag": {
|
||||
"heading": "Ta stilling til overslag fra Klingenberg Bygg",
|
||||
"description": "Se beløpet for hver feil som er sett på, og godkjenn det som skal inn i regnskapet.",
|
||||
"moves": {
|
||||
"open->godkjent": "Godkjenn overslaget",
|
||||
"open->avvist": "Avvis overslaget"
|
||||
},
|
||||
"list_title": "Overslag",
|
||||
"list_note": "Hver rad er et overslag med beløp og hva det dekker; knappene under godkjenner eller avviser det."
|
||||
},
|
||||
"rapporter": {
|
||||
"heading": "Send kvartalsrapporten til aksjonærene og banken",
|
||||
"description": "Samle tallene for kvartalet, og send rapporten videre når den er klar.",
|
||||
"moves": {
|
||||
"open->sendt": "Send rapporten"
|
||||
},
|
||||
"list_title": "Kvartalsrapporter",
|
||||
"list_note": "Hver rad er rapporten for ett kvartal, med leieinntekter, kostnader og saldo; knappen under sender den."
|
||||
},
|
||||
"rom": {
|
||||
"heading": "Vis ledige rom frem til studentene",
|
||||
"description": "Legg ut rommet som er blitt ledig, og merk det utleid når noen har flyttet inn.",
|
||||
"moves": {
|
||||
"open->utleid": "Merk som utleid"
|
||||
},
|
||||
"list_title": "Rom",
|
||||
"list_note": "Hver rad er et rom med bilde og beskrivelse; knappen under merker det utleid."
|
||||
},
|
||||
"romsoknader": {
|
||||
"heading": "Følg opp søknader om rom",
|
||||
"description": "Se hvem som har søkt og hva de gjør til daglig (studenter går foran), sett opp visning, og før søknaden frem til signert leieavtale.",
|
||||
"moves": {
|
||||
"akseptert->signert": "Merk som signert",
|
||||
"open->visning": "Sett opp visning",
|
||||
"open->avslatt": "Avslå søknaden",
|
||||
"tilbud->avslatt": "Avslå søknaden",
|
||||
"visning->tilbud": "Gi tilbud om rom",
|
||||
"visning->avslatt": "Avslå søknaden"
|
||||
},
|
||||
"list_title": "Søknader om rom",
|
||||
"list_note": "Hver rad er en søknad, med hva søkeren gjør til daglig og ønsket innflytting. Studenter går foran. Knappene fører søknaden videre eller avslår den."
|
||||
},
|
||||
"saker": {
|
||||
"heading": "Saker aksjonærene har meldt",
|
||||
"description": "Ta saker aksjonærene melder inn, sett dem på sakslisten, og vedta eller avvis dem.",
|
||||
"moves": {
|
||||
"open->sakslisten": "Sett på sakslisten",
|
||||
"open->avvist": "Avvis saken",
|
||||
"sakslisten->vedtatt": "Vedta saken",
|
||||
"sakslisten->avvist": "Avvis saken"
|
||||
},
|
||||
"list_title": "Saker",
|
||||
"list_note": "Hver rad er en sak meldt inn av en aksjonær, med tittel og beskrivelse; knappene under setter den på sakslisten, vedtar eller avviser den."
|
||||
},
|
||||
"uker": {
|
||||
"heading": "Bekreft ukene aksjonærene vil bruke huset",
|
||||
"description": "Se hvem som har meldt seg på en uke, og bekreft eller avslå den.",
|
||||
"moves": {
|
||||
"open->bekreftet": "Bekreft uken",
|
||||
"open->avslatt": "Avslå uken"
|
||||
},
|
||||
"list_title": "Ønskede uker",
|
||||
"list_note": "Hver rad er en uke en aksjonær har meldt seg på, med datoer og merknad; knappene under bekrefter eller avslår den."
|
||||
}
|
||||
},
|
||||
"links": {
|
||||
"ledig-rom": {
|
||||
"heading": "Legg ut et rom som er blitt ledig",
|
||||
"description": "Skriv tittel, legg ved bilde og beskriv rommet, så studentene kan søke.",
|
||||
"button": "Legg ut rom"
|
||||
},
|
||||
"liste": {
|
||||
"heading": "Legg til et nytt punkt på lista",
|
||||
"description": "Skriv hva som er bra, mangler eller planlagt, med pris og tid om du har det.",
|
||||
"button": "Legg til punkt"
|
||||
},
|
||||
"rapport": {
|
||||
"heading": "Lag kvartalets rapport",
|
||||
"description": "Fyll inn tallene for kvartalet, så aksjonærene og banken får det de skal ha.",
|
||||
"button": "Lag rapport"
|
||||
},
|
||||
"nedbetalingsplan": {
|
||||
"heading": "Lag en ny nedbetalingsplan",
|
||||
"description": "Skriv lånebeløp, avdrag og pengestrøm, så banken har noe å ta stilling til.",
|
||||
"button": "Lag plan"
|
||||
},
|
||||
"husleie": {
|
||||
"heading": "Registrer husleie for et rom",
|
||||
"description": "Før inn beløp og måned når husleia for et rom er kommet inn.",
|
||||
"button": "Registrer husleie"
|
||||
},
|
||||
"eierskifte": {
|
||||
"heading": "Legg til et steg i eierskiftet",
|
||||
"description": "Beskriv steget og legg ved dokument, så aksjonærene ser fremgangen.",
|
||||
"button": "Legg til steg"
|
||||
}
|
||||
},
|
||||
"invites": {
|
||||
"aksjonaerer": {
|
||||
"heading": "Gi en aksjonær tilgang til sin side",
|
||||
"description": "Send en invitasjon så aksjonæren får konto og engangslenke.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Inviter aksjonær",
|
||||
"form_description": "Ha navn, e-post og telefon klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
},
|
||||
"klingenberg": {
|
||||
"heading": "Gi Klingenberg Bygg tilgang til feilmeldinger",
|
||||
"description": "Send en invitasjon så de får konto og engangslenke.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Inviter Klingenberg Bygg",
|
||||
"form_description": "Ha navn, e-post og telefon klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
},
|
||||
"leietakere": {
|
||||
"heading": "Gi en leietaker tilgang til sin side",
|
||||
"description": "Send en invitasjon så leietakeren får konto og engangslenke.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Inviter leietaker",
|
||||
"form_description": "Ha navn, e-post og telefon klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
},
|
||||
"styret": {
|
||||
"heading": "Gi et styremedlem tilgang til desken",
|
||||
"description": "Send en invitasjon så styremedlemmet får konto og engangslenke.",
|
||||
"button": "Send invitasjon",
|
||||
"form_title": "Inviter styremedlem",
|
||||
"form_description": "Ha navn, e-post og telefon klart.",
|
||||
"labels": {
|
||||
"name": "Navn",
|
||||
"phone": "Telefon",
|
||||
"email": "E-post"
|
||||
}
|
||||
}
|
||||
},
|
||||
"invited": {
|
||||
"heading": "Se hvem som er invitert og hvor langt de har kommet",
|
||||
"description": "Følg med på invitasjoner som er sendt, og send en ny lenke om den gamle har løpt ut.",
|
||||
"done": "Tatt i bruk",
|
||||
"withdraw": "Trekk tilbake",
|
||||
"again": "Send på nytt",
|
||||
"list_title": "Sendte invitasjoner",
|
||||
"list_note": "Hver rad er en invitasjon sendt til en person; knappene under trekker den tilbake eller sender den på nytt."
|
||||
},
|
||||
"suggestions": {
|
||||
"heading": "Ta stilling til foreslåtte endringer",
|
||||
"description": "Se hva andre har foreslått å endre på sidene de har ansvar for.",
|
||||
"list_title": "Foreslåtte endringer",
|
||||
"list_note": "Hver rad er en foreslått endring; knappene under tar den til vurdering, godtar eller avslår den.",
|
||||
"looking": "Tar en titt",
|
||||
"changed": "Endret",
|
||||
"declined": "Avslått"
|
||||
}
|
||||
},
|
||||
"questions/styret/ledig-rom-sent.yaml": {
|
||||
"question": "Hva skjer med rommet du nettopp la ut?",
|
||||
"description": "Rommet er lagt inn og synlig for dem som søker. Dere ser det under ledige rom til det er leid ut.",
|
||||
"next_heading": "Studenter kan søke på rommet nå",
|
||||
"next_description": "Søknader kommer inn etter hvert, og dere i styret ser dem sammen med de andre søknadene.",
|
||||
"see_all": {
|
||||
"heading": "Se alle rommene og status",
|
||||
"description": "Full oversikt over hvilke rom som er ledige og hvilke som er leid ut.",
|
||||
"button": "Se rommene"
|
||||
}
|
||||
},
|
||||
"questions/styret/ledig-rom.yaml": {
|
||||
"heading": "Meld inn et rom som er ledig",
|
||||
"description": "Når et rom står tomt, legger du det ut her, så ser de som søker det først.",
|
||||
"button": "Legg ut rommet",
|
||||
"encouragement": "Så snart det er lagt ut, kan folk søke på det.",
|
||||
"icon": "key",
|
||||
"paused": "Ta rommet ned fra listen for nå",
|
||||
"requires_title": "Krever tittel",
|
||||
"requires_description": "Krever beskrivelse",
|
||||
"requires_icon": "Krever bilde",
|
||||
"road_title": "Fra ledig til utleid",
|
||||
"road_description": "Rommet ligger ute til det er utleid.",
|
||||
"road_icon": "key",
|
||||
"who_title": "Hvem ser dette",
|
||||
"who_description": "Studenter som søker rom ser dette, og styret kan nås på bl@klingenbergbygg.no i mellomtiden.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Om rommet",
|
||||
"form_description": "Et bilde og en kort beskrivelse er nok til at de som søker kan se seg om.",
|
||||
"labels": {
|
||||
"tittel": "Tittel på rommet",
|
||||
"bilde": "Bilde av rommet",
|
||||
"om": "Om rommet"
|
||||
},
|
||||
"hints": {
|
||||
"tittel": "Rom i 2. etasje, mot gata",
|
||||
"bilde": "",
|
||||
"om": "Lyst rom med eget bad, nær kjøkken og felles stue."
|
||||
},
|
||||
"question": "Hvilket rom er ledig nå?",
|
||||
"page_description": "Legg ut et rom som har blitt ledig, så de som søker kan se det."
|
||||
},
|
||||
"questions/styret/liste-sent.yaml": {
|
||||
"question": "Hva skjer med punktet nå?",
|
||||
"description": "Forslaget ditt er lagret. Styret tar det opp til behandling.",
|
||||
"next_heading": "Styret vedtar eller forkaster punktet",
|
||||
"next_description": "Det blir stående til styret har tatt stilling, med pris og tid synlig for alle.",
|
||||
"see_all": {
|
||||
"heading": "Se hele lista",
|
||||
"description": "Alt som er meldt inn, vedtatt eller publisert ligger samlet.",
|
||||
"button": "Til lista"
|
||||
}
|
||||
},
|
||||
"questions/styret/liste.yaml": {
|
||||
"heading": "Listen over huset og planene",
|
||||
"description": "Legg til det som er bra, det som mangler og det som er planlagt, med pris og tid der dere vet det.",
|
||||
"button": "Legg til punkt",
|
||||
"encouragement": "Det havner på listen med en gang, og dere kan vedta det senere.",
|
||||
"icon": "list",
|
||||
"paused": "Listen er stengt for nye punkter",
|
||||
"requires_title": "Krev tittel",
|
||||
"requires_description": "Krev beskrivelse",
|
||||
"requires_icon": "Krev ikon",
|
||||
"road_title": "Fra forslag til vedtak",
|
||||
"road_description": "Punktet legges inn, styret tar stilling til det og vedtar eller forkaster, og det som vedtas blir publisert på listen alle ser.",
|
||||
"road_icon": "check-circle",
|
||||
"who_title": "Hvem ser listen",
|
||||
"who_description": "Styret fører og vedtar listen; ta kontakt på bl@klingenbergbygg.no om noe haster.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Nytt punkt på listen",
|
||||
"form_description": "Skriv kort hva det gjelder, og ta med pris og tid hvis dere har tall.",
|
||||
"labels": {
|
||||
"tittel": "Tittel",
|
||||
"slag": "Hva slags punkt",
|
||||
"beskrivelse": "Beskrivelse",
|
||||
"kostnad": "Kostnad",
|
||||
"naar": "Når"
|
||||
},
|
||||
"hints": {
|
||||
"tittel": "Nytt kjøkken i andre etasje",
|
||||
"slag": "Mangler",
|
||||
"beskrivelse": "Kjøkkenet er slitt og trenger nye hvitevarer og benkeplate.",
|
||||
"kostnad": "ca. 45 000 kr",
|
||||
"naar": "Våren 2027"
|
||||
},
|
||||
"question": "Hva bør med på listen over huset og driften?",
|
||||
"page_description": "Punktene går inn på listen styret fører over det som er bra, det som mangler og det som er planlagt."
|
||||
},
|
||||
"questions/styret/nedbetalingsplan-sent.yaml": {
|
||||
"question": "Hvor er nedbetalingsplanen nå?",
|
||||
"description": "Den ligger nå hos aksjonærene. Når alle fire har godkjent den, sender styret den til banken og fører inn svaret.",
|
||||
"next_heading": "Følg svaret fra banken",
|
||||
"next_description": "Når banken svarer, oppdaterer dere planen med godkjent, innvilget eller endringer – så de andre aksjonærene ser hvor den står."
|
||||
},
|
||||
"questions/styret/nedbetalingsplan.yaml": {
|
||||
"heading": "Nedbetalingsplanen alle fire står bak",
|
||||
"description": "Legg fram planen for lånet, så banken og aksjonærene vet hvordan det skal betales ned.",
|
||||
"button": "Send plan",
|
||||
"encouragement": "Styret tar den videre til banken så snart alle fire har sagt ja.",
|
||||
"icon": "banknote",
|
||||
"paused": "Satt på pause",
|
||||
"requires_title": "Krever tittel",
|
||||
"requires_description": "Krever beskrivelse",
|
||||
"requires_icon": "Krever ikon",
|
||||
"road_title": "Veien videre",
|
||||
"road_description": "Planen går til godkjenning hos aksjonærene, så sendes den til banken. Sier banken ja, er den innvilget; ber den om endringer, kommer det en ny runde.",
|
||||
"road_icon": "route",
|
||||
"who_title": "Hvem ser den",
|
||||
"who_description": "Styret og aksjonærene ser planen; spørsmål før den er sendt går til styret på bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Nedbetalingsplan",
|
||||
"form_description": "Ha lånebeløp, avdrag og når det første trekket kommer, klart.",
|
||||
"labels": {
|
||||
"tittel": "Tittel",
|
||||
"laanebelop": "Lånebeløp",
|
||||
"maanedlig-avdrag": "Månedlig avdrag",
|
||||
"forste-trekk": "Første trekk",
|
||||
"pengestrom": "Hvordan pengene flyter",
|
||||
"dokument": "Dokument"
|
||||
},
|
||||
"hints": {
|
||||
"tittel": "Nedbetalingsplan 2026",
|
||||
"laanebelop": "2 400 000 kr",
|
||||
"maanedlig-avdrag": "18 000 kr",
|
||||
"forste-trekk": "1. desember 2026",
|
||||
"pengestrom": "Husleie dekker avdraget, timeshare går til vedlikehold, overskudd settes av til buffer",
|
||||
"dokument": "Last opp plan eller avtaleutkast"
|
||||
},
|
||||
"question": "Hvordan skal lånet betales ned, og står alle fire bak det?",
|
||||
"page_description": "Planen går til aksjonærene og banken, og viser hvordan lånet skal betales ned."
|
||||
},
|
||||
"questions/styret/rapport-sent.yaml": {
|
||||
"question": "Hvor går rapporten nå?",
|
||||
"description": "Den er sendt. Aksjonærene får tallene, og det som skal til banken går dit.",
|
||||
"next_heading": "Neste rapport venter til kvartalet er omme",
|
||||
"next_description": "Neste rapport legger styret inn når kvartalet er omme."
|
||||
},
|
||||
"questions/styret/rapport.yaml": {
|
||||
"heading": "Rapporten som viser hvordan det går",
|
||||
"description": "Hvert kvartal legger styret inn tallene, så aksjonærene ser status og banken får det den skal ha.",
|
||||
"button": "Send rapport",
|
||||
"encouragement": "Så snart den er sendt, ligger den klar for aksjonærene og banken.",
|
||||
"icon": "file-text",
|
||||
"paused": "Sett rapportering på pause",
|
||||
"requires_title": "Krev tittel",
|
||||
"requires_description": "Krev beskrivelse",
|
||||
"requires_icon": "Krev ikon",
|
||||
"road_title": "Hva skjer videre",
|
||||
"road_description": "Rapporten sendes til aksjonærene og banken. Når den er ute, merkes den som sendt.",
|
||||
"road_icon": "send",
|
||||
"who_title": "Hvem leser den",
|
||||
"who_description": "Styret står for rapportene; har du spørsmål underveis, ta kontakt på bl@klingenbergbygg.no.",
|
||||
"who_icon": "users",
|
||||
"form_title": "Kvartalets tall",
|
||||
"form_description": "Ha regnskapstallene for kvartalet klare før du fyller ut.",
|
||||
"labels": {
|
||||
"periode": "Hvilket kvartal gjelder dette",
|
||||
"leieinntekter": "Leieinntekter",
|
||||
"timeshare": "Inntekter fra ukene",
|
||||
"driftskostnader": "Driftskostnader",
|
||||
"renter-og-avdrag": "Renter og avdrag til banken",
|
||||
"saldo": "Saldo ved kvartalets slutt",
|
||||
"kommentar": "Kommentar til tallene",
|
||||
"dokument": "Regnskapsdokument"
|
||||
},
|
||||
"hints": {
|
||||
"periode": "3. kvartal 2026",
|
||||
"leieinntekter": "54 000",
|
||||
"timeshare": "12 000",
|
||||
"driftskostnader": "18 500",
|
||||
"renter-og-avdrag": "22 000",
|
||||
"saldo": "145 000",
|
||||
"kommentar": "Ett rom stod tomt i juli, ellers som normalt",
|
||||
"dokument": "kvartalsregnskap-q3-2026.pdf"
|
||||
},
|
||||
"question": "Hvordan gikk kvartalet, i tall?",
|
||||
"page_description": "Rapporten går til aksjonærene og banken, så de ser inntekter, kostnader og saldo for kvartalet."
|
||||
},
|
||||
"site.yaml": {
|
||||
"invite": {
|
||||
"subject": "Du er invitert til Gullberg",
|
||||
"body": "Hei {name},\n\nDu er invitert inn på Gullberg sin side, som {group}.\n\nVelg passord her før {expires}:\n{link}\n\nBrukernavnet ditt er {username}. Etterpå logger du inn på {site}.\n\nHilsen styret i Gullberg"
|
||||
}
|
||||
}
|
||||
}
|
||||
+609
-428
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,144 @@
|
||||
// Pages move the way their addresses do.
|
||||
//
|
||||
// Deeper in the address comes in from the right and going back up
|
||||
// comes in from the left: /kontor/ansatt sits to the right of /kontor.
|
||||
// A page at the same depth comes from below, or from above when it
|
||||
// sorts first, so the index is on top. The cards on a page leave as
|
||||
// islands of their own and the next page's cards arrive one after
|
||||
// another.
|
||||
//
|
||||
// The router swaps the page after its data has arrived, and in between
|
||||
// shows nothing at all. So the click is held here, before the router
|
||||
// sees it: the browser takes its picture of the page as it is, the
|
||||
// click is handed on, and the transition waits until the new page has
|
||||
// replaced the old one. Only transforms and opacity are animated, on
|
||||
// layers the browser composites itself. Without View Transitions, or
|
||||
// with reduced motion asked for, nothing here does anything and the
|
||||
// router navigates as it always did.
|
||||
(() => {
|
||||
if (!document.startViewTransition) return;
|
||||
const still = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const MAX_CARDS = 8;
|
||||
const WAIT_MS = 2500;
|
||||
const handed = new WeakSet();
|
||||
|
||||
const segments = (path) => path.split("/").filter(Boolean);
|
||||
|
||||
// forward, back, down or up: which way the new page comes from.
|
||||
function direction(from, to) {
|
||||
const a = segments(from);
|
||||
const b = segments(to);
|
||||
if (b.length > a.length) return "forward";
|
||||
if (b.length < a.length) return "back";
|
||||
return to < from ? "up" : "down";
|
||||
}
|
||||
|
||||
// Only the cards on screen become islands: each is a picture the
|
||||
// browser has to take and hold, and one nobody sees costs the same.
|
||||
function inView(card) {
|
||||
const r = card.getBoundingClientRect();
|
||||
return r.bottom > 0 && r.top < innerHeight;
|
||||
}
|
||||
|
||||
function name(cards, prefix) {
|
||||
cards.filter(inView).slice(0, MAX_CARDS).forEach((card, i) => {
|
||||
card.style.viewTransitionName = `${prefix}-${i + 1}`;
|
||||
});
|
||||
}
|
||||
|
||||
function unname() {
|
||||
document.querySelectorAll(".alt-card").forEach((card) => {
|
||||
card.style.viewTransitionName = "";
|
||||
});
|
||||
}
|
||||
|
||||
// Resolves once the page that was showing has been replaced by the
|
||||
// one at `path`, or after WAIT_MS whatever happened.
|
||||
function replaced(old, path) {
|
||||
return new Promise((resolve) => {
|
||||
const done = () => {
|
||||
observer.disconnect();
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const check = () => {
|
||||
if (location.pathname !== path) return;
|
||||
if (document.querySelector("main.loading")) return;
|
||||
if (old && old.isConnected) return;
|
||||
if (!document.querySelector(".alternatives, main.not-found")) return;
|
||||
done();
|
||||
};
|
||||
const observer = new MutationObserver(check);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
const timer = setTimeout(done, WAIT_MS);
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
function move(from, to, handOn) {
|
||||
const root = document.documentElement;
|
||||
const old = document.querySelector(".alternatives");
|
||||
root.dataset.motion = direction(from, to);
|
||||
name([...document.querySelectorAll(".alt-card")], "pm-out");
|
||||
const transition = document.startViewTransition(async () => {
|
||||
unname();
|
||||
handOn();
|
||||
await replaced(old, to);
|
||||
name([...document.querySelectorAll(".alt-card")], "pm-in");
|
||||
});
|
||||
transition.finished.finally(() => {
|
||||
unname();
|
||||
delete root.dataset.motion;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(ev) => {
|
||||
if (still.matches || handed.has(ev) || ev.defaultPrevented) return;
|
||||
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
|
||||
const a = ev.target.closest && ev.target.closest("a[href]");
|
||||
if (!a || a.target || a.hasAttribute("download")) return;
|
||||
if ((a.getAttribute("rel") || "").split(/\s+/).includes("external")) return;
|
||||
const url = new URL(a.href, location.href);
|
||||
if (url.origin !== location.origin || url.pathname === location.pathname) return;
|
||||
ev.preventDefault();
|
||||
ev.stopImmediatePropagation();
|
||||
move(location.pathname, url.pathname, () => {
|
||||
const again = new MouseEvent("click", { bubbles: true, cancelable: true, composed: true, button: 0 });
|
||||
handed.add(again);
|
||||
a.dispatchEvent(again);
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
// Back and forward: the address has already changed when this runs,
|
||||
// so the router is told a moment later, inside the transition.
|
||||
let shown = location.pathname;
|
||||
window.addEventListener(
|
||||
"popstate",
|
||||
(ev) => {
|
||||
const to = location.pathname;
|
||||
const from = shown;
|
||||
shown = to;
|
||||
if (still.matches || handed.has(ev) || from === to) return;
|
||||
ev.stopImmediatePropagation();
|
||||
move(from, to, () => {
|
||||
const again = new PopStateEvent("popstate", { state: ev.state });
|
||||
handed.add(again);
|
||||
window.dispatchEvent(again);
|
||||
});
|
||||
},
|
||||
true,
|
||||
);
|
||||
// Keep `shown` right after the router's own pushes.
|
||||
for (const method of ["pushState", "replaceState"]) {
|
||||
const original = history[method].bind(history);
|
||||
history[method] = (...args) => {
|
||||
const result = original(...args);
|
||||
shown = location.pathname;
|
||||
return result;
|
||||
};
|
||||
}
|
||||
})();
|
||||
@@ -51,6 +51,35 @@ pub struct AggregateSchema {
|
||||
/// no answer in six weeks has to go on without one, and nothing in
|
||||
/// a building is going to press that button.
|
||||
pub after_for_state: HashMap<String, AfterSpec>,
|
||||
/// How long a record stays in the list once it has entered a
|
||||
/// state, when that is not for ever: a card taken off the front
|
||||
/// page leaves the desk's list a little later, instead of standing
|
||||
/// there greyed out. The event log keeps it.
|
||||
pub leaves_for_state: HashMap<String, LeavesSpec>,
|
||||
}
|
||||
|
||||
/// `leaves_after: { minutes: 10 }` on a state: how long a record that
|
||||
/// has entered it stays in its bucket's list. Days, hours and minutes
|
||||
/// add up; the sweep that takes it out runs every minute.
|
||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct LeavesSpec {
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub days: u32,
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub hours: u32,
|
||||
#[serde(default, skip_serializing_if = "is_zero")]
|
||||
pub minutes: u32,
|
||||
}
|
||||
|
||||
fn is_zero(n: &u32) -> bool {
|
||||
*n == 0
|
||||
}
|
||||
|
||||
impl LeavesSpec {
|
||||
pub fn millis(&self) -> i64 {
|
||||
((i64::from(self.days) * 24 + i64::from(self.hours)) * 60 + i64::from(self.minutes)) * 60 * 1000
|
||||
}
|
||||
}
|
||||
|
||||
/// A mail to send when a record enters a state. `to` is `submitter`
|
||||
@@ -125,6 +154,8 @@ struct RawState {
|
||||
grants: Option<String>,
|
||||
#[serde(default)]
|
||||
after: Option<AfterSpec>,
|
||||
#[serde(default)]
|
||||
leaves_after: Option<LeavesSpec>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
@@ -197,7 +228,14 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
let mut mail_for_state = HashMap::new();
|
||||
let mut grants_for_state = HashMap::new();
|
||||
let mut after_for_state = HashMap::new();
|
||||
let mut leaves_for_state = HashMap::new();
|
||||
for (state, raw_state) in raw_schema.states {
|
||||
if let Some(leaves) = raw_state.leaves_after {
|
||||
if leaves.millis() == 0 {
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: leaves_after needs some days, hours or minutes");
|
||||
}
|
||||
leaves_for_state.insert(state.clone(), leaves);
|
||||
}
|
||||
if let Some(group) = raw_state.grants {
|
||||
if group.trim().is_empty() {
|
||||
anyhow::bail!("aggregates.yaml: bucket {bucket:?}: state {state:?}: grants needs a group");
|
||||
@@ -245,6 +283,7 @@ pub fn parse_aggregates_yaml(raw: &str) -> anyhow::Result<HashMap<String, Aggreg
|
||||
mail_for_state,
|
||||
grants_for_state,
|
||||
after_for_state,
|
||||
leaves_for_state,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ pub struct Answer {
|
||||
/// not see before.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub notes: Vec<Note>,
|
||||
/// Set by a desk listing, never stored: the record is in a final
|
||||
/// state (one with no move out). A desk shows the newest few of
|
||||
/// those and offers the rest on request (`resource::cap_finished`).
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub finished: bool,
|
||||
}
|
||||
|
||||
/// One reply on a record. It lives here rather than with the mail
|
||||
@@ -97,6 +102,7 @@ pub async fn store_answer(
|
||||
storyline,
|
||||
decided_ms: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
decided_by: None,
|
||||
};
|
||||
let data = serde_json::to_vec(&answer)?;
|
||||
@@ -114,6 +120,73 @@ pub async fn store_answer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whose a record is, on an alternative that keeps one per person
|
||||
/// (`one_per_person`): the signed-in person's username, written by the
|
||||
/// server into the record's fields, never taken from the form.
|
||||
pub const OWNER_FIELD: &str = "by";
|
||||
|
||||
/// Every record in `bucket` that belongs to `username`, newest first.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn own_records(js: &async_nats::jetstream::Context, bucket: &str, username: &str) -> Vec<Answer> {
|
||||
use futures::TryStreamExt;
|
||||
let Ok(store) = js.get_key_value(bucket).await else { return Vec::new() };
|
||||
let keys: Vec<String> = match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let mut own = Vec::new();
|
||||
for key in keys {
|
||||
let Ok(Some(bytes)) = store.get(&key).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
|
||||
if answer.responses.get(OWNER_FIELD).and_then(|v| v.as_str()) == Some(username) {
|
||||
own.push(answer);
|
||||
}
|
||||
}
|
||||
own.sort_by(|a, b| (b.submitted_ms, &b.id).cmp(&(a.submitted_ms, &a.id)));
|
||||
own
|
||||
}
|
||||
|
||||
/// A person keeps one card: of their records still in the bucket's
|
||||
/// first state, only the newest stays in the list, and the rest leave
|
||||
/// it (the event log keeps them). Run after every store, so two sends
|
||||
/// racing each other - a double click - both settle on the same one.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn keep_newest_own(js: &async_nats::jetstream::Context, bucket: &str, initial: &str, username: &str) {
|
||||
let own = own_records(js, bucket, username).await;
|
||||
let Ok(store) = js.get_key_value(bucket).await else { return };
|
||||
for older in own.iter().filter(|a| a.state == initial).skip(1) {
|
||||
if let Err(e) = store.delete(&older.id).await {
|
||||
tracing::error!(bucket, id = %older.id, error = %e, "could not retire an older own record");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fills the form of every `one_per_person` alternative with what the
|
||||
/// signed-in person's own newest record says, so sending it again is
|
||||
/// editing their card. A file field shows the file already there, at
|
||||
/// the address a list would open it from, and may be left empty to
|
||||
/// keep it.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn fill_own(js: &async_nats::jetstream::Context, question: &mut crate::content::Question, user: Option<&crate::auth::User>) {
|
||||
let Some(user) = user else { return };
|
||||
for alt in &mut question.alternatives {
|
||||
let (true, Some(bucket)) = (alt.one_per_person, alt.record_as.clone()) else { continue };
|
||||
let Some(mine) = own_records(js, &bucket, &user.username).await.into_iter().next() else { continue };
|
||||
for req in alt.features.iter_mut().flat_map(|f| f.requirements.iter_mut()) {
|
||||
if req.kind == "hidden" {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = mine.responses.get(&req.name).and_then(|v| v.as_str()).filter(|v| !v.trim().is_empty()) else { continue };
|
||||
if req.kind == "file" {
|
||||
req.value = Some(format!("/attachment/{bucket}/{}/{}", mine.id, req.name));
|
||||
req.optional = true;
|
||||
} else {
|
||||
req.value = Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One selected row's pending transition. `feature_name` is per-item
|
||||
/// so a batch can span more than one resource feature on the same
|
||||
/// alternative.
|
||||
@@ -470,6 +543,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
},
|
||||
Answer {
|
||||
id: "def456".to_string(),
|
||||
@@ -482,6 +556,7 @@ mod tests {
|
||||
decided_ms: Some(1_700_000_002_000),
|
||||
decided_by: Some("bl".to_string()),
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
},
|
||||
];
|
||||
let value = serde_json::Value::Array(
|
||||
|
||||
+459
-17
@@ -57,6 +57,8 @@ pub fn shell(options: LeptosOptions) -> impl IntoView {
|
||||
// Server-side, so it can read hash.txt and link the
|
||||
// content-hashed stylesheet (hash-files = true).
|
||||
<HashedStylesheet id="leptos" options=options.clone()/>
|
||||
// Before the router, so it sees a link's click first.
|
||||
<script src="/page-motion.js" defer=true></script>
|
||||
<AutoReload options=options.clone()/>
|
||||
<HydrationScripts options/>
|
||||
<MetaTags/>
|
||||
@@ -971,16 +973,37 @@ fn Hero(
|
||||
}
|
||||
});
|
||||
let has_slides = slideshow.is_some();
|
||||
let mark = move || view! {
|
||||
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
|
||||
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
|
||||
</a>
|
||||
// In the page's ink (`wordmark_ink`): a span the file masks, sized
|
||||
// by the file's own proportions through an invisible copy of it,
|
||||
// and named for screen readers as the image would have been.
|
||||
let ink = site.wordmark_ink;
|
||||
let mark = move || {
|
||||
if ink {
|
||||
let mask = format!("--wordmark-src:url(\"{}\")", wordmark.replace('"', "%22"));
|
||||
view! {
|
||||
<a class="wordmark wordmark-ink" href="/" aria-label=site_title.clone()>
|
||||
<span class="wordmark-ink-mark" style=mask>
|
||||
<img src=wordmark.clone() alt="" aria-hidden="true" style=wordmark_style.clone()/>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
.into_any()
|
||||
} else {
|
||||
view! {
|
||||
<a class="wordmark" class:wordmark-house=house_wordmark href="/">
|
||||
<img src=wordmark.clone() alt=site_title.clone() style=wordmark_style.clone()/>
|
||||
</a>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
};
|
||||
|
||||
let top = view! { <div class="hero-top">{mark()}{session}</div> };
|
||||
let (bar_top, copy_top) = if has_slides { (Some(top), None) } else { (None, Some(top)) };
|
||||
|
||||
view! {
|
||||
<header class="hero" class:hero-module=has_module class:hero-slides=has_slides>
|
||||
<Announcements current_id=current_id/>
|
||||
{session}
|
||||
{has_module
|
||||
.then(|| {
|
||||
view! {
|
||||
@@ -991,10 +1014,12 @@ fn Hero(
|
||||
// With pictures, the wordmark is a bar of its own above
|
||||
// them and the question comes under; without, the wordmark
|
||||
// heads the copy as it always has.
|
||||
{has_slides.then(|| view! { <div class="hero-bar">{mark()}</div> })}
|
||||
// Who is here sits on the wordmark's own line, not on a row
|
||||
// of its own above it.
|
||||
{bar_top.map(|top| view! { <div class="hero-bar">{top}</div> })}
|
||||
{slideshow.map(|show| view! { <SlideStrip show=show/> })}
|
||||
<div class="hero-copy">
|
||||
{(!has_slides).then(|| mark())}
|
||||
{copy_top}
|
||||
<h1>{title}</h1>
|
||||
<p inner_html=render_inline_markdown(&description)></p>
|
||||
</div>
|
||||
@@ -1196,9 +1221,11 @@ fn AlternativeCard(
|
||||
if req.kind == "file" {
|
||||
file_refs.entry(req.name.clone()).or_default();
|
||||
} else if req.kind == "select" {
|
||||
// Preset by a card of one's own (`answers::fill_own`).
|
||||
let preset: Vec<String> = req.value.clone().filter(|v| !v.is_empty()).into_iter().collect();
|
||||
select_field_map
|
||||
.entry(req.name.clone())
|
||||
.or_insert_with(|| RwSignal::new(Vec::new()));
|
||||
.or_insert_with(|| RwSignal::new(preset));
|
||||
select_multi.insert(req.name.clone(), req.multiple);
|
||||
} else {
|
||||
// Widget-owned fields hold JSON objects, not strings.
|
||||
@@ -1236,6 +1263,11 @@ fn AlternativeCard(
|
||||
let gesture_fields_for_submit = gesture_fields.clone();
|
||||
let question_id_for_transition = question_id.clone();
|
||||
let alt_name_for_transition = alternative.name.clone();
|
||||
// From the moment Send is pressed until the server has answered:
|
||||
// files upload first, and a second press in that time used to send
|
||||
// the form twice.
|
||||
let sending = RwSignal::new(false);
|
||||
let send_error: RwSignal<Option<String>> = RwSignal::new(None);
|
||||
let on_submit = move |ev: leptos::ev::SubmitEvent| {
|
||||
ev.prevent_default();
|
||||
|
||||
@@ -1264,6 +1296,8 @@ fn AlternativeCard(
|
||||
let question_id_for_submit = question_id_for_submit.clone();
|
||||
let alt_name_for_submit = alt_name_for_submit.clone();
|
||||
let parent_hash_for_submit = parent_hash_for_submit.clone();
|
||||
sending.set(true);
|
||||
send_error.set(None);
|
||||
leptos::task::spawn_local(async move {
|
||||
let mut map = serde_json::Map::new();
|
||||
for (name, sig) in field_map_for_submit.iter() {
|
||||
@@ -1299,12 +1333,25 @@ fn AlternativeCard(
|
||||
};
|
||||
let Some(files) = input.files() else { continue };
|
||||
let Some(file) = files.get(0) else { continue };
|
||||
// Too big is known before a byte is sent.
|
||||
if file.size() > MAX_UPLOAD_BYTES as f64 {
|
||||
send_error.set(Some(t(&lang().get_untracked(), "upload_too_large").to_string()));
|
||||
sending.set(false);
|
||||
return;
|
||||
}
|
||||
match upload_file(&file, &question_id_for_submit, &alt_name_for_submit).await {
|
||||
Ok(key) => {
|
||||
map.insert(name.clone(), serde_json::Value::String(key));
|
||||
}
|
||||
// A form sent without the file it asked for is a
|
||||
// record that looks complete and is not: stop, and
|
||||
// say so.
|
||||
Err(e) => {
|
||||
leptos::logging::error!("upload of {name} failed: {e:?}");
|
||||
let status = e.as_string().and_then(|m| m.rsplit(' ').next().and_then(|n| n.parse().ok()));
|
||||
send_error.set(Some(t(&lang().get_untracked(), upload_refusal(status)).to_string()));
|
||||
sending.set(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1317,6 +1364,7 @@ fn AlternativeCard(
|
||||
parent_hash: parent_hash_for_submit,
|
||||
responses_json,
|
||||
});
|
||||
sending.set(false);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1510,9 +1558,26 @@ fn AlternativeCard(
|
||||
|
||||
if req.kind == "file" {
|
||||
let file_ref = file_refs.get(&req.name).copied().unwrap_or_else(NodeRef::new);
|
||||
// On a card of one's own, the file already
|
||||
// there (see `answers::fill_own`).
|
||||
let current = req.value.clone().filter(|v| v.starts_with("/attachment/")).map(|href| {
|
||||
let picture = req.accept.as_deref().is_some_and(|a| a.contains("image/"));
|
||||
view! {
|
||||
<span class="field-current">
|
||||
<span>{move || t(&lang().get(), "current_file")}</span>
|
||||
{if picture {
|
||||
view! { <img class="answer-thumb" src=href.clone() alt="" /> }.into_any()
|
||||
} else {
|
||||
view! { <a href=href.clone() rel="external">{href.rsplit('/').next().unwrap_or_default().to_string()}</a> }.into_any()
|
||||
}}
|
||||
<span class="field-hint">{move || t(&lang().get(), "keep_file")}</span>
|
||||
</span>
|
||||
}
|
||||
});
|
||||
return view! {
|
||||
<label class="field" for=label_for>
|
||||
{label_text}
|
||||
{current}
|
||||
<input
|
||||
id=field_id
|
||||
type="file"
|
||||
@@ -1824,12 +1889,24 @@ fn AlternativeCard(
|
||||
class="alt-submit"
|
||||
disabled=move || {
|
||||
(!has_requirements && pending_transitions.get().is_empty())
|
||||
|| sending.get()
|
||||
|| submit.pending().get()
|
||||
|| transition_batch.pending().get()
|
||||
}
|
||||
>
|
||||
{button_label.clone()}
|
||||
</button>
|
||||
// Why nothing happened, when something
|
||||
// was refused: it used to say nothing.
|
||||
{move || {
|
||||
send_error
|
||||
.get()
|
||||
.or_else(|| match submit.value().get() {
|
||||
Some(Err(e)) => Some(server_message(&e)),
|
||||
_ => None,
|
||||
})
|
||||
.map(|m| view! { <p class="alt-error" role="alert">{m}</p> })
|
||||
}}
|
||||
</form>
|
||||
}
|
||||
.into_any()
|
||||
@@ -1859,18 +1936,26 @@ fn ResourceFeature(
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
) -> impl IntoView {
|
||||
// A desk shows the newest few finished records; "show older" asks
|
||||
// for more (see `resource::cap_finished`).
|
||||
let finished_shown = RwSignal::new(crate::resource::FINISHED_SHOWN);
|
||||
let data = Resource::new(
|
||||
{
|
||||
let question_id = question_id.clone();
|
||||
let alternative = alternative.clone();
|
||||
let feature_name = feature_name.clone();
|
||||
move || (question_id.clone(), alternative.clone(), feature_name.clone())
|
||||
move || (question_id.clone(), alternative.clone(), feature_name.clone(), finished_shown.get())
|
||||
},
|
||||
// Forwarding sibling requirement values as query params (for a
|
||||
// resource parameterized by other form fields) is deferred, see
|
||||
// the resource-backed multi-select requirement UI follow-up.
|
||||
|(q, a, f, shown)| {
|
||||
let mut params = std::collections::HashMap::new();
|
||||
if shown != crate::resource::FINISHED_SHOWN {
|
||||
params.insert(crate::resource::FINISHED_PARAM.to_string(), shown.to_string());
|
||||
}
|
||||
get_resource(q, a, f, params)
|
||||
},
|
||||
// Empty params for now - forwarding sibling requirement values
|
||||
// as query params (for a resource parameterized by other form
|
||||
// fields) is deferred, see the resource-backed multi-select
|
||||
// requirement UI follow-up.
|
||||
|(q, a, f)| get_resource(q, a, f, std::collections::HashMap::new()),
|
||||
);
|
||||
|
||||
// The shared batch button lives on `AlternativeCard`, above every
|
||||
@@ -1905,6 +1990,7 @@ fn ResourceFeature(
|
||||
value=value
|
||||
pending_transitions=pending_transitions
|
||||
transition_batch=transition_batch
|
||||
finished_shown=finished_shown
|
||||
/>
|
||||
}
|
||||
.into_any()
|
||||
@@ -1934,6 +2020,7 @@ fn ResourceValue(
|
||||
value: serde_json::Value,
|
||||
pending_transitions: RwSignal<std::collections::HashMap<(String, String), String>>,
|
||||
transition_batch: ServerAction<TransitionAnswers>,
|
||||
finished_shown: RwSignal<usize>,
|
||||
) -> impl IntoView {
|
||||
// A jq like `.[] | {...}` over an empty list yields no output at
|
||||
// all - that arrives as null, and it's the same silence as [].
|
||||
@@ -1949,12 +2036,42 @@ fn ResourceValue(
|
||||
}
|
||||
if let Ok(answers) = serde_json::from_value::<Vec<Answer>>(value.clone()) {
|
||||
let mut answers = answers;
|
||||
// Cards are shown in the order they were put up, the way a
|
||||
// team or a gallery is read; a list of cases, newest first.
|
||||
let cards = transitions.is_empty() && answers.iter().all(|a| picture_of(a, bucket.as_deref()).is_some());
|
||||
answers.sort_by(|a, b| {
|
||||
let open = |s: &str| s != crate::answers::OPEN_STATE;
|
||||
open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms))
|
||||
let when = if cards { a.submitted_ms.cmp(&b.submitted_ms) } else { b.submitted_ms.cmp(&a.submitted_ms) };
|
||||
open(&a.state).cmp(&open(&b.state)).then(when)
|
||||
});
|
||||
// The desk sent one finished record more than it shows, when
|
||||
// there are older ones: drop the oldest and offer the rest.
|
||||
let shown = finished_shown.get_untracked();
|
||||
let older = answers.iter().filter(|a| a.finished).count() > shown;
|
||||
if older {
|
||||
if let Some(oldest) = answers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, a)| a.finished)
|
||||
.min_by_key(|(_, a)| a.decided_ms.unwrap_or(a.submitted_ms))
|
||||
.map(|(i, _)| i)
|
||||
{
|
||||
answers.remove(oldest);
|
||||
}
|
||||
}
|
||||
let show_older = older.then(|| {
|
||||
view! {
|
||||
<button
|
||||
type="button"
|
||||
class="show-older"
|
||||
on:click=move |_| finished_shown.update(|n| *n += crate::resource::FINISHED_SHOWN)
|
||||
>
|
||||
{move || t(&lang().get(), "show_older")}
|
||||
</button>
|
||||
}
|
||||
});
|
||||
return view! {
|
||||
<div class="answer-list">
|
||||
<div class="answer-list" class:answer-cards=cards>
|
||||
<For
|
||||
each=move || answers.clone()
|
||||
key=|a| a.id.clone()
|
||||
@@ -1979,6 +2096,7 @@ fn ResourceValue(
|
||||
}
|
||||
}
|
||||
/>
|
||||
{show_older}
|
||||
</div>
|
||||
}
|
||||
.into_any();
|
||||
@@ -2006,9 +2124,147 @@ fn ResourceValue(
|
||||
}
|
||||
}
|
||||
|
||||
// A releases manifest (content-reference, "A releases manifest"):
|
||||
// the newest version, its files per platform, and the standing
|
||||
// channels, with the visitor's own platform picked in the browser.
|
||||
if is_releases_manifest(&value) {
|
||||
return view! { <ReleasesCard manifest=value/> }.into_any();
|
||||
}
|
||||
|
||||
view! { <pre class="resource-raw">{value.to_string()}</pre> }.into_any()
|
||||
}
|
||||
|
||||
/// `{ name, latest, versions: [{ version, date, notes, assets: [{
|
||||
/// platform, url, sha256, size }] }], channels: {...} }` - the shape a
|
||||
/// release pipeline writes (redoal's does) for a site to offer downloads.
|
||||
pub fn is_releases_manifest(value: &serde_json::Value) -> bool {
|
||||
value.get("latest").and_then(|v| v.as_str()).is_some()
|
||||
&& value.get("versions").and_then(|v| v.as_array()).is_some_and(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
/// The platform a browser says it is, in the manifest's words:
|
||||
/// `ios`, `macos`, `android`, `windows`, `linux-x86_64`,
|
||||
/// `linux-aarch64`, or `other`. Read, never trusted for anything but
|
||||
/// which download to put first.
|
||||
pub fn platform_of(user_agent: &str) -> &'static str {
|
||||
let ua = user_agent.to_ascii_lowercase();
|
||||
if ua.contains("iphone") || ua.contains("ipad") {
|
||||
"ios"
|
||||
} else if ua.contains("mac os x") || ua.contains("macintosh") {
|
||||
"macos"
|
||||
} else if ua.contains("android") {
|
||||
"android"
|
||||
} else if ua.contains("windows") {
|
||||
"windows"
|
||||
} else if ua.contains("linux") || ua.contains("x11") {
|
||||
if ua.contains("aarch64") || ua.contains("arm64") {
|
||||
"linux-aarch64"
|
||||
} else {
|
||||
"linux-x86_64"
|
||||
}
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest release as a card: the file for this device first (the
|
||||
/// platform is read in the browser, so the server renders every file
|
||||
/// and the pick lands on hydration), the rest after, and the channels
|
||||
/// that are not files - a package repo, TestFlight.
|
||||
#[component]
|
||||
fn ReleasesCard(manifest: serde_json::Value) -> impl IntoView {
|
||||
let name = manifest.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let latest = manifest.get("latest").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let newest = manifest["versions"][0].clone();
|
||||
let date = newest.get("date").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let notes = newest.get("notes").and_then(|v| v.as_str()).filter(|u| u.starts_with("https://")).map(str::to_string);
|
||||
let assets: Vec<(String, String, String)> = newest
|
||||
.get("assets")
|
||||
.and_then(|a| a.as_array())
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|x| {
|
||||
let platform = x.get("platform")?.as_str()?.to_string();
|
||||
let url = x.get("url")?.as_str()?.to_string();
|
||||
let sha = x.get("sha256").and_then(|s| s.as_str()).unwrap_or("").to_string();
|
||||
Some((platform, url, sha))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let channels = manifest.get("channels").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let arch = channels.get("arch").cloned();
|
||||
let ios = channels.get("ios").cloned();
|
||||
|
||||
let platform = RwSignal::new(None::<&'static str>);
|
||||
Effect::new(move |_| {
|
||||
#[cfg(feature = "hydrate")]
|
||||
if let Some(ua) = web_sys::window().and_then(|w| w.navigator().user_agent().ok()) {
|
||||
platform.set(Some(platform_of(&ua)));
|
||||
}
|
||||
});
|
||||
let label = |p: &str| match p {
|
||||
"linux-x86_64" => "Linux (x86_64)",
|
||||
"linux-aarch64" => "Linux (aarch64)",
|
||||
"macos" | "macos-arm64" => "macOS",
|
||||
"windows" | "windows-x86_64" => "Windows",
|
||||
other => other,
|
||||
}.to_string();
|
||||
let assets_for_list = assets.clone();
|
||||
let arch_for_pick = arch.clone();
|
||||
let ios_for_pick = ios.clone();
|
||||
let assets_for_pick = assets.clone();
|
||||
view! {
|
||||
<article class="item-card releases">
|
||||
<div class="item-card-title">
|
||||
<strong>{name.clone()} " " {latest.clone()}</strong>
|
||||
{(!date.is_empty()).then(|| view! { <span class="dim">" · " {date.clone()}</span> })}
|
||||
{notes.clone().map(|u| view! { " · " <a href=u target="_blank" rel="noopener noreferrer">{move || t(&lang().get(), "release_notes")}</a> })}
|
||||
</div>
|
||||
// What this device should take, decided in the browser.
|
||||
{move || {
|
||||
let p = platform.get()?;
|
||||
let arch = arch_for_pick.clone();
|
||||
let ios = ios_for_pick.clone();
|
||||
let own = assets_for_pick.iter().find(|(pl, _, _)| pl == p).cloned();
|
||||
let pick: Option<AnyView> = match (p, own) {
|
||||
(_, Some((pl, url, _))) => Some(view! {
|
||||
<p><a class="pill" href=url download>{t(&lang().get(), "for_this_device")} ": " {label(&pl)}</a></p>
|
||||
{(p.starts_with("linux") && arch.is_some()).then(|| {
|
||||
let a = arch.clone().unwrap();
|
||||
let repo = a.get("repo").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let pkg = a.get("package").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
view! { <pre class="monospace dim">{repo} "\n\npacman -Syu " {pkg}</pre> }
|
||||
})}
|
||||
}.into_any()),
|
||||
("ios" | "macos", None) => ios.as_ref().and_then(|i| i.get("testflight").and_then(|v| v.as_str()).map(str::to_string)).map(|tf| {
|
||||
let note = ios.as_ref().and_then(|i| i.get("note").and_then(|v| v.as_str())).unwrap_or("").to_string();
|
||||
view! { <p><a class="pill" href=tf target="_blank" rel="noopener noreferrer">{t(&lang().get(), "for_this_device")} ": TestFlight"</a> <span class="dim">" " {note}</span></p> }.into_any()
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
pick
|
||||
}}
|
||||
<details class="releases-all">
|
||||
<summary>{move || t(&lang().get(), "all_downloads")}</summary>
|
||||
<ul>
|
||||
{assets_for_list.iter().map(|(pl, url, sha)| view! {
|
||||
<li><a href=url.clone() download>{label(pl)}</a> {(!sha.is_empty()).then(|| view! { <span class="dim monospace">" sha256 " {sha[..sha.len().min(12)].to_string()} "…"</span> })}</li>
|
||||
}).collect::<Vec<_>>()}
|
||||
{arch.as_ref().map(|a| {
|
||||
let note = a.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
view! { <li>{note}</li> }
|
||||
})}
|
||||
{ios.as_ref().and_then(|i| i.get("testflight").and_then(|v| v.as_str()).map(|tf| {
|
||||
let note = i.get("note").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
view! { <li><a href=tf.to_string() target="_blank" rel="noopener noreferrer">"TestFlight"</a> " " {note}</li> }
|
||||
}))}
|
||||
</ul>
|
||||
</details>
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
/// Pulls the first present, non-null string field out of a JSON object
|
||||
/// matching one of `keys`, tried in order - shared between `ItemCard`
|
||||
/// and `SelectField`'s option rendering, since both need "guess a
|
||||
@@ -2232,6 +2488,48 @@ pub fn uploaded_filename(value: &str) -> Option<&str> {
|
||||
rest.strip_prefix('-').filter(|name| shaped && !name.is_empty())
|
||||
}
|
||||
|
||||
/// The largest file one upload may carry. Here, not in `upload.rs`,
|
||||
/// so the browser checks the same number before it sends anything.
|
||||
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
|
||||
|
||||
/// Why an upload did not arrive, in words a person can act on, from
|
||||
/// the status `POST /upload` answered with (see `upload.rs`): too big,
|
||||
/// a site that takes no files, a page they may not send from, or
|
||||
/// anything else.
|
||||
#[cfg_attr(not(feature = "hydrate"), allow(dead_code))]
|
||||
fn upload_refusal(status: Option<u16>) -> &'static str {
|
||||
match status {
|
||||
Some(413) => "upload_too_large",
|
||||
Some(503) => "upload_unavailable",
|
||||
Some(403) => "upload_forbidden",
|
||||
_ => "upload_failed",
|
||||
}
|
||||
}
|
||||
|
||||
/// What a refused server call says, without the wrapping a person
|
||||
/// has no use for.
|
||||
fn server_message(e: &ServerFnError) -> String {
|
||||
let text = e.to_string();
|
||||
text.rsplit_once(": ").map(|(_, m)| m.to_string()).filter(|m| !m.is_empty()).unwrap_or(text)
|
||||
}
|
||||
|
||||
/// Whether an uploaded file is a picture a browser shows as it is.
|
||||
pub fn is_picture(filename: &str) -> bool {
|
||||
let lower = filename.to_ascii_lowercase();
|
||||
[".jpg", ".jpeg", ".png", ".webp", ".avif", ".gif"].iter().any(|ext| lower.ends_with(ext))
|
||||
}
|
||||
|
||||
/// A record that carries a picture, in a list nobody works from, is
|
||||
/// shown rather than read: the picture, then what it says, first field
|
||||
/// as its heading. A team, a gallery of what has been built.
|
||||
fn picture_of(answer: &Answer, bucket: Option<&str>) -> Option<String> {
|
||||
let bucket = bucket?;
|
||||
answer.responses.as_object()?.iter().find_map(|(key, value)| {
|
||||
let name = uploaded_filename(value.as_str()?)?;
|
||||
is_picture(name).then(|| format!("/attachment/{bucket}/{}/{key}", answer.id))
|
||||
})
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn AnswerRow(
|
||||
feature_name: String,
|
||||
@@ -2260,6 +2558,11 @@ fn AnswerRow(
|
||||
} else {
|
||||
available.is_empty()
|
||||
};
|
||||
if transitions.is_empty() {
|
||||
if let Some(picture) = picture_of(&answer, bucket.as_deref()) {
|
||||
return AnswerCard(answer, picture, link).into_any();
|
||||
}
|
||||
}
|
||||
let responses = answer
|
||||
.responses
|
||||
.as_object()
|
||||
@@ -2310,6 +2613,17 @@ fn AnswerRow(
|
||||
// A stored file shows as its name, and opens.
|
||||
if let (Some(base), Some(filename)) = (attachment_base.clone(), uploaded_filename(&text)) {
|
||||
let href = format!("{base}/{key}");
|
||||
if is_picture(filename) {
|
||||
return view! {
|
||||
<div class="answer-field">
|
||||
<span class="answer-field-key">{key}</span>
|
||||
<a class="answer-field-value" href=href.clone() rel="external">
|
||||
<img class="answer-thumb" src=href.clone() alt=filename.to_string() loading="lazy" />
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
.into_any();
|
||||
}
|
||||
return view! {
|
||||
<div class="answer-field">
|
||||
<span class="answer-field-key">{key}</span>
|
||||
@@ -2412,6 +2726,41 @@ fn AnswerRow(
|
||||
</Show>
|
||||
</article>
|
||||
}
|
||||
.into_any()
|
||||
}
|
||||
|
||||
/// A record shown as a card: its picture, its first field as the
|
||||
/// heading and the others under it, and nothing a reader has no use
|
||||
/// for - no field names, no state, no time.
|
||||
#[allow(non_snake_case)]
|
||||
fn AnswerCard(answer: Answer, picture: String, link: Option<RowLink>) -> impl IntoView {
|
||||
let link = link.map(|l| (l.href(&answer.id, &answer.responses), l.label));
|
||||
let texts: Vec<String> = answer
|
||||
.responses
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(_, v)| v.as_str())
|
||||
.filter(|v| uploaded_filename(v).is_none() && !v.trim().is_empty())
|
||||
.map(str::to_string)
|
||||
.collect();
|
||||
let heading = texts.first().cloned().unwrap_or_default();
|
||||
let rest: Vec<String> = texts.into_iter().skip(1).collect();
|
||||
view! {
|
||||
<article class="answer-row answer-card">
|
||||
<img class="answer-picture" src=picture alt=heading.clone() loading="lazy" />
|
||||
<div class="answer-card-text">
|
||||
<h4 class="answer-card-heading">{heading}</h4>
|
||||
// What the person wrote, as they wrote it: markdown, on
|
||||
// the stricter rules for text that is not the site's.
|
||||
{rest
|
||||
.into_iter()
|
||||
.map(|t| view! { <div class="card-field" inner_html=crate::content::render_written_markdown(&t)></div> })
|
||||
.collect_view()}
|
||||
</div>
|
||||
{link.map(|(href, label)| view! { <a class="answer-link" href=href>{label}</a> })}
|
||||
</article>
|
||||
}
|
||||
}
|
||||
|
||||
/// Iconify names are `{prefix}:{name}`, both lowercase-alphanumeric-
|
||||
@@ -2490,6 +2839,7 @@ pub async fn get_question(
|
||||
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||||
let user = session.get::<User>(SESSION_USER_KEY).await.map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||
fill_from_user(&mut question, user.as_ref());
|
||||
crate::answers::fill_own(&state.jetstream, &mut question, user.as_ref()).await;
|
||||
let mut chain_gate = None;
|
||||
if let Some(target) = &question.requires_chain {
|
||||
// The claimed lineage must verifiably end at the required
|
||||
@@ -2843,6 +3193,26 @@ pub async fn submit_answer(
|
||||
let mut responses: serde_json::Value = serde_json::from_str(&responses_json)
|
||||
.map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
|
||||
|
||||
// One card each: whose it is comes from the session, and a file the
|
||||
// person did not choose again is the one already on their card.
|
||||
let owner = if alt.one_per_person {
|
||||
let user = user.as_ref().ok_or_else(|| ServerFnError::new("sign in to keep a card of your own"))?;
|
||||
let bucket = record_as.clone().ok_or_else(|| ServerFnError::new("this alternative keeps nothing"))?;
|
||||
let previous = crate::answers::own_records(&state.jetstream, &bucket, &user.username).await.into_iter().next();
|
||||
if let Some(map) = responses.as_object_mut() {
|
||||
for req in alt.features.iter().flat_map(|f| &f.requirements).filter(|r| r.kind == "file") {
|
||||
let chosen = map.get(&req.name).and_then(|v| v.as_str()).is_some_and(|v| !v.is_empty());
|
||||
if let (false, Some(kept)) = (chosen, previous.as_ref().and_then(|p| p.responses.get(&req.name))) {
|
||||
map.insert(req.name.clone(), kept.clone());
|
||||
}
|
||||
}
|
||||
map.insert(crate::answers::OWNER_FIELD.to_string(), serde_json::Value::String(user.username.clone()));
|
||||
}
|
||||
Some((bucket, user.username.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// A record that belongs to another record: check the other one is
|
||||
// there before this one is written, so a reference in a stored
|
||||
// record is one that resolved at least once. An optional field
|
||||
@@ -2887,10 +3257,16 @@ pub async fn submit_answer(
|
||||
let Some((name, who)) = crate::people::who_for(&responses) else {
|
||||
return Err(ServerFnError::new("an invite needs a name, and a phone number or an email"));
|
||||
};
|
||||
let invited = people
|
||||
let mut invited = people
|
||||
.invite(&state, &name, &who, &invite.group, &principal_name(&principal))
|
||||
.await
|
||||
.map_err(|e| ServerFnError::new(format!("invite failed: {e}")))?;
|
||||
// This invite's own words, and the form they draw on: kept with
|
||||
// the invite, so sending it again says the same thing.
|
||||
invited.mail = invite.mail.clone();
|
||||
if invited.mail.is_some() {
|
||||
invited.fields = responses.as_object().cloned().unwrap_or_default();
|
||||
}
|
||||
if let Some(map) = responses.as_object_mut() {
|
||||
map.insert("invited".to_string(), serde_json::to_value(&invited).unwrap_or_default());
|
||||
}
|
||||
@@ -2958,6 +3334,15 @@ pub async fn submit_answer(
|
||||
{
|
||||
tracing::error!("failed to store answer in {bucket}: {e}");
|
||||
}
|
||||
if let Some((own_bucket, username)) = &owner {
|
||||
let initial = state
|
||||
.aggregates
|
||||
.load()
|
||||
.get(own_bucket)
|
||||
.map(|s| s.initial.clone())
|
||||
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
|
||||
crate::answers::keep_newest_own(&state.jetstream, own_bucket, &initial, username).await;
|
||||
}
|
||||
// Arrival is a state too: what the schema says the first
|
||||
// state sends, it sends now.
|
||||
if let Some(initial) = state.aggregates.load().get(bucket).map(|s| s.initial.clone()) {
|
||||
@@ -3085,3 +3470,60 @@ mod responsible_tests {
|
||||
assert!(!is_responsible(None, &board));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod picture_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_upload_refusal_says_which_it_was() {
|
||||
assert_eq!(upload_refusal(Some(413)), "upload_too_large");
|
||||
assert_eq!(upload_refusal(Some(503)), "upload_unavailable");
|
||||
assert_eq!(upload_refusal(Some(403)), "upload_forbidden");
|
||||
assert_eq!(upload_refusal(Some(502)), "upload_failed");
|
||||
assert_eq!(upload_refusal(None), "upload_failed");
|
||||
for lang in ["en", "no"] {
|
||||
for status in [Some(413), Some(503), Some(403), None] {
|
||||
let key = upload_refusal(status);
|
||||
assert_ne!(crate::i18n::t(lang, key), key, "{lang} has words for {key}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_with_a_picture_is_shown_by_it() {
|
||||
assert!(is_picture("Portrett.JPG") && is_picture("a.webp") && !is_picture("rapport.pdf"));
|
||||
let answer: Answer = serde_json::from_value(serde_json::json!({
|
||||
"id": "r1", "question_id": "kontor/ansatt", "alternative": "a", "state": "open", "submitted_ms": 1,
|
||||
"responses": { "name": "Kari", "portrett": "0f8fad5b-d9cb-469f-a165-70867728950e-kari.jpg", "cv": "0f8fad5b-d9cb-469f-a165-70867728950e-cv.pdf" }
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(picture_of(&answer, Some("ansatte")).as_deref(), Some("/attachment/ansatte/r1/portrett"));
|
||||
// Without a bucket there is nowhere to fetch it from.
|
||||
assert_eq!(picture_of(&answer, None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod releases_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_browser_names_its_platform_in_the_manifests_words() {
|
||||
assert_eq!(platform_of("Mozilla/5.0 (iPhone; CPU iPhone OS 19_0 like Mac OS X)"), "ios");
|
||||
assert_eq!(platform_of("Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0)"), "macos");
|
||||
assert_eq!(platform_of("Mozilla/5.0 (X11; Linux x86_64) Firefox"), "linux-x86_64");
|
||||
assert_eq!(platform_of("Mozilla/5.0 (X11; Linux aarch64) Firefox"), "linux-aarch64");
|
||||
assert_eq!(platform_of("Mozilla/5.0 (Windows NT 10.0; Win64; x64)"), "windows");
|
||||
assert_eq!(platform_of("Mozilla/5.0 (Linux; Android 15)"), "android");
|
||||
assert_eq!(platform_of("curl/8"), "other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_manifest_shape_is_a_manifest() {
|
||||
let m = serde_json::json!({"name":"redoal","latest":"0.1.17","versions":[{"version":"0.1.17","assets":[]}]});
|
||||
assert!(is_releases_manifest(&m));
|
||||
assert!(!is_releases_manifest(&serde_json::json!({"latest":"1","versions":[]})));
|
||||
assert!(!is_releases_manifest(&serde_json::json!([{"name":"x"}])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,3 +118,67 @@ pub(crate) fn rewrite_img_tag(tag: &str) -> String {
|
||||
cleaned.replacen("<img", &format!("<img{attrs}"), 1)
|
||||
}
|
||||
|
||||
|
||||
/// Markdown that a person wrote into a record - a bio on a card - as
|
||||
/// opposed to the site's own content. Paragraphs, line breaks as they
|
||||
/// were typed, lists, emphasis and links; no images (a picture has its
|
||||
/// own field, and an image in text is a way to load something from
|
||||
/// elsewhere into every visitor's page), no raw HTML, headings as plain
|
||||
/// paragraphs. Links go to https, mailto or tel only, and say they are
|
||||
/// the writer's, not the site's (`rel="nofollow ugc"`).
|
||||
pub fn render_written_markdown(text: &str) -> String {
|
||||
use pulldown_cmark::{html, Event, Options, Parser, Tag, TagEnd};
|
||||
let parser = Parser::new_ext(text, Options::ENABLE_STRIKETHROUGH);
|
||||
let mut in_dropped_link = 0usize;
|
||||
let mut in_image = 0usize;
|
||||
let filtered = parser.filter_map(|event| match event {
|
||||
Event::Html(_) | Event::InlineHtml(_) => None,
|
||||
Event::Start(Tag::Image { .. }) => {
|
||||
in_image += 1;
|
||||
None
|
||||
}
|
||||
Event::End(TagEnd::Image) => {
|
||||
in_image = in_image.saturating_sub(1);
|
||||
None
|
||||
}
|
||||
_ if in_image > 0 => None,
|
||||
Event::Start(Tag::Heading { .. }) => Some(Event::Start(Tag::Paragraph)),
|
||||
Event::End(TagEnd::Heading(_)) => Some(Event::End(TagEnd::Paragraph)),
|
||||
// What someone typed on a new line is on a new line.
|
||||
Event::SoftBreak => Some(Event::HardBreak),
|
||||
Event::Start(Tag::Link { dest_url, .. })
|
||||
if !(dest_url.starts_with("https://") || dest_url.starts_with("mailto:") || dest_url.starts_with("tel:")) =>
|
||||
{
|
||||
in_dropped_link += 1;
|
||||
None
|
||||
}
|
||||
Event::End(TagEnd::Link) if in_dropped_link > 0 => {
|
||||
in_dropped_link -= 1;
|
||||
None
|
||||
}
|
||||
other => Some(other),
|
||||
});
|
||||
let mut out = String::new();
|
||||
html::push_html(&mut out, filtered);
|
||||
out.trim().replace("<a href=", "<a rel=\"nofollow ugc\" href=")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod written_tests {
|
||||
use super::render_written_markdown as md;
|
||||
|
||||
#[test]
|
||||
fn a_bio_keeps_what_a_person_would_write_and_nothing_that_reaches_out() {
|
||||
assert_eq!(md("Line one\nline two"), "<p>Line one<br />\nline two</p>");
|
||||
assert!(md("- NS 3424\n- NS 3423").contains("<ul>\n<li>NS 3424</li>"));
|
||||
assert!(md("**Sivilingeniør**, *NTNU*").contains("<strong>Sivilingeniør</strong>, <em>NTNU</em>"));
|
||||
assert_eq!(md("[Westra](https://westra.no)"), "<p><a rel=\"nofollow ugc\" href=\"https://westra.no\">Westra</a></p>");
|
||||
// Everything that would put something else in the page is gone.
|
||||
assert_eq!(md("<script>alert(1)</script>"), "");
|
||||
assert_eq!(md("hi <img src=x onerror=alert(1)>"), "<p>hi </p>");
|
||||
assert_eq!(md("[click](javascript:alert(1))"), "<p>click</p>");
|
||||
assert_eq!(md("[home](/kontor)"), "<p>home</p>", "a site path is the site's to link, not a bio's");
|
||||
assert_eq!(md("before  after"), "<p>before after</p>");
|
||||
assert_eq!(md("# Shouting"), "<p>Shouting</p>");
|
||||
}
|
||||
}
|
||||
|
||||
+41
-3
@@ -343,6 +343,14 @@ pub struct Alternative {
|
||||
/// works (lysbue had the same flag).
|
||||
#[serde(default)]
|
||||
pub disabled: bool,
|
||||
/// Each signed-in person keeps one record here: their own, which
|
||||
/// sending again replaces. The form opens with what their record
|
||||
/// says, a file left empty keeps the one already there, and the
|
||||
/// older record leaves the list. For what a person says about
|
||||
/// themselves - a card on the front page, a profile. Needs
|
||||
/// `record_as`, and someone signed in.
|
||||
#[serde(default)]
|
||||
pub one_per_person: bool,
|
||||
#[serde(default)]
|
||||
pub consequence: Vec<String>,
|
||||
#[serde(default)]
|
||||
@@ -383,6 +391,13 @@ pub struct Alternative {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Invite {
|
||||
pub group: String,
|
||||
/// This invite's own mail, in place of the site's (`mail.invite`):
|
||||
/// an invitation to a conversation at an hour, say. Besides
|
||||
/// `{name}`, `{link}`, `{expires}`, `{username}`, `{group}`,
|
||||
/// `{site}` and `{site_name}`, every field of the form is a
|
||||
/// placeholder; a date and time is written out in words.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mail: Option<MailTemplate>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -504,6 +519,18 @@ pub struct ResourceSpec {
|
||||
/// it where the silence needs a voice ("Nothing released yet").
|
||||
#[serde(default)]
|
||||
pub empty: Option<String>,
|
||||
/// Show at most this many items, applied after `jq`. For a feed
|
||||
/// where only the newest matters: a releases card wants the cut you
|
||||
/// should run, not every cut there has ever been, and a page that
|
||||
/// grows by one card per release gets longer forever.
|
||||
///
|
||||
/// After `jq` rather than before, so it caps what the page will
|
||||
/// actually render whatever the filter did — and so content can
|
||||
/// reorder with jq and still take the top of its own ordering. A
|
||||
/// source that yields a single record has no length and passes
|
||||
/// through untouched. `None` shows everything.
|
||||
#[serde(default)]
|
||||
pub limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Where a resource's live data comes from. `Kv` (a NATS KV bucket
|
||||
@@ -759,6 +786,14 @@ pub struct SiteConfig {
|
||||
/// intrinsic pixel height.
|
||||
#[serde(default)]
|
||||
pub wordmark_height: Option<f32>,
|
||||
/// Draw an SVG wordmark in the page's own ink instead of its own
|
||||
/// colours: the file becomes a mask over the text colour, so it
|
||||
/// follows the page's light and dark (and any theme a site adds),
|
||||
/// where an `<img>` only ever knows the system's. Opacity in the
|
||||
/// file still shows, so a lighter line stays lighter. For a
|
||||
/// one-colour mark; a mark with a colour of its own keeps `false`.
|
||||
#[serde(default)]
|
||||
pub wordmark_ink: bool,
|
||||
/// BCP 47-ish language code for the portal's own chrome strings
|
||||
/// ("Asked by", the nav lead...) and the html lang attribute.
|
||||
/// `None` means "en"; unknown codes fall back to English per key.
|
||||
@@ -1300,6 +1335,7 @@ alternatives:
|
||||
public: true
|
||||
source: {{ kind: kv, bucket: things }}
|
||||
- name: Object
|
||||
action: /object
|
||||
record_as: things
|
||||
features:
|
||||
- name: Yours
|
||||
@@ -1397,6 +1433,7 @@ id: /t
|
||||
name: Where is it?
|
||||
alternatives:
|
||||
- name: A
|
||||
action: /t
|
||||
features:
|
||||
- name: Where
|
||||
requirements:
|
||||
@@ -1500,6 +1537,7 @@ id: /b
|
||||
name: B
|
||||
alternatives:
|
||||
- name: A
|
||||
action: /b
|
||||
features:
|
||||
- name: ""
|
||||
requirements:
|
||||
@@ -1541,7 +1579,7 @@ alternatives:
|
||||
|
||||
fn question_with_requirement(req_yaml: &str) -> std::collections::HashMap<String, Question> {
|
||||
let question: Question = serde_yaml::from_str(&format!(
|
||||
"id: /g\nname: G\nalternatives:\n - name: A\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
|
||||
"id: /g\nname: G\nalternatives:\n - name: A\n action: /g\n features:\n - name: \"\"\n requirements:\n{req_yaml}\n"
|
||||
))
|
||||
.unwrap();
|
||||
std::collections::HashMap::from([(question.id.clone(), question)])
|
||||
@@ -1812,7 +1850,7 @@ alternatives:
|
||||
fn recorded_buckets_must_be_attended() {
|
||||
let write_only = vec![(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: send\n record_as: black_hole\n".to_string(),
|
||||
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: black_hole\n".to_string(),
|
||||
)];
|
||||
let questions = build_questions(&write_only).unwrap();
|
||||
let err = validate_questions(&questions, &Default::default()).unwrap_err();
|
||||
@@ -1823,7 +1861,7 @@ alternatives:
|
||||
let with_desk = vec![
|
||||
(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: send\n record_as: inbox\n".to_string(),
|
||||
"name: Home\nalternatives:\n - name: send\n action: /\n record_as: inbox\n".to_string(),
|
||||
),
|
||||
(
|
||||
"review/index.yaml".to_string(),
|
||||
|
||||
@@ -512,6 +512,27 @@ pub fn validate_questions(
|
||||
}
|
||||
for question in questions.values() {
|
||||
for alternative in &question.alternatives {
|
||||
// A form is sent with its button, and the button is only
|
||||
// drawn for an alternative that says where to go afterwards:
|
||||
// without `action` the person fills it in and cannot send it.
|
||||
let asks = alternative.features.iter().any(|f| f.requirements.iter().any(|r| r.kind != "hidden"));
|
||||
if (asks || alternative.invite.is_some() || alternative.record_as.is_some())
|
||||
&& alternative.action.is_none()
|
||||
&& alternative.self_transition.is_none()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: it has a form to send but no `action`, so no button is drawn; give it `action:`, the page to go to once it is sent (its own page is fine)",
|
||||
question.id,
|
||||
alternative.name
|
||||
);
|
||||
}
|
||||
if alternative.one_per_person && alternative.record_as.is_none() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: one_per_person keeps each person's own record, and there is no record_as to keep it in",
|
||||
question.id,
|
||||
alternative.name
|
||||
);
|
||||
}
|
||||
if let Some(bucket) = &alternative.record_as {
|
||||
let attended = read_buckets.contains(bucket)
|
||||
|| aggregates
|
||||
|
||||
+65
-1
@@ -37,6 +37,55 @@ pub async fn run(state: AppState) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records leaving their list: once a record has been in a state with
|
||||
/// `leaves_after` for that long, it is taken out of the bucket. Not a
|
||||
/// move - nothing is decided, no mail goes - and the event log keeps
|
||||
/// the record. Every minute, over the buckets that use it and no
|
||||
/// others.
|
||||
pub async fn run_leaving(state: AppState) {
|
||||
let mut tick = tokio::time::interval(std::time::Duration::from_secs(60));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
if let Err(e) = sweep_leaving(&state).await {
|
||||
tracing::warn!(error = %e, "leaving sweep failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a record has stayed out its time in the state it is in.
|
||||
pub fn has_left(schema: &crate::aggregates::AggregateSchema, answer: &Answer, now_ms: i64) -> bool {
|
||||
schema
|
||||
.leaves_for_state
|
||||
.get(&answer.state)
|
||||
.is_some_and(|leaves| now_ms >= waiting_since(answer) + leaves.millis())
|
||||
}
|
||||
|
||||
pub async fn sweep_leaving(state: &AppState) -> anyhow::Result<()> {
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
let aggregates = state.aggregates.load();
|
||||
for schema in aggregates.values().filter(|s| !s.leaves_for_state.is_empty()) {
|
||||
let Ok(store) = state.jetstream.get_key_value(&schema.bucket).await else { continue };
|
||||
let keys: Vec<String> = {
|
||||
use futures::TryStreamExt;
|
||||
match store.keys().await {
|
||||
Ok(keys) => keys.try_collect().await.unwrap_or_default(),
|
||||
Err(_) => continue,
|
||||
}
|
||||
};
|
||||
for id in keys {
|
||||
let Ok(Some(bytes)) = store.get(&id).await else { continue };
|
||||
let Ok(answer) = serde_json::from_slice::<Answer>(&bytes) else { continue };
|
||||
if has_left(schema, &answer, now_ms) {
|
||||
match store.delete(&id).await {
|
||||
Ok(()) => tracing::info!(bucket = %schema.bucket, id, state = %answer.state, "a record left its list"),
|
||||
Err(e) => tracing::warn!(bucket = %schema.bucket, id, "a record could not leave its list: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// When a record entered the state it is in now. A record nobody has
|
||||
/// moved has been waiting since it was sent.
|
||||
fn waiting_since(answer: &Answer) -> i64 {
|
||||
@@ -145,11 +194,26 @@ aggregates:
|
||||
Answer {
|
||||
id: "r".into(), question_id: "/q".into(), alternative: "a".into(),
|
||||
responses: serde_json::json!({}), state: state.into(),
|
||||
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![],
|
||||
submitted_ms: since_ms, decided_ms: None, decided_by: None, notes: vec![], finished: false,
|
||||
storyline: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_leaves_its_list_when_its_time_in_the_state_is_up() {
|
||||
let s = schema(
|
||||
"aggregates:\n - bucket: people\n initial: open\n states:\n open: { event: shown }\n gone: { event: gone, leaves_after: { minutes: 10 } }\n transitions:\n open: [gone]\n",
|
||||
);
|
||||
let t = 1_000_000_000_000;
|
||||
let mut answer = waiting("gone", t - 60 * 60 * 1000);
|
||||
answer.decided_ms = Some(t);
|
||||
assert!(!has_left(&s, &answer, t + 9 * 60 * 1000), "measured from entering the state, not from sending");
|
||||
assert!(has_left(&s, &answer, t + 10 * 60 * 1000));
|
||||
// A state without it keeps its records.
|
||||
assert!(!has_left(&s, &waiting("open", 0), t));
|
||||
assert!(crate::aggregates::parse_aggregates_yaml("aggregates:\n - bucket: p\n initial: open\n states:\n open: { event: o, leaves_after: {} }\n transitions: {}\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deadline_comes_due_the_day_it_says_and_not_before() {
|
||||
let s = schema(HEARINGS);
|
||||
|
||||
+30
@@ -24,6 +24,16 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("en", "sign_in") => "Sign in",
|
||||
("en", "sign_out") => "Sign out",
|
||||
("en", "nothing_here_yet") => "Nothing here yet.",
|
||||
("en", "show_older") => "Show older",
|
||||
("en", "for_this_device") => "For this device",
|
||||
("en", "all_downloads") => "All downloads",
|
||||
("en", "release_notes") => "release notes",
|
||||
("en", "upload_failed") => "The file did not arrive, so nothing was sent. Try again.",
|
||||
("en", "upload_too_large") => "The file is larger than 20 MB, so nothing was sent. Choose a smaller one, or a picture taken at a lower resolution.",
|
||||
("en", "upload_unavailable") => "This site cannot take files at the moment, so nothing was sent. Let whoever runs the site know.",
|
||||
("en", "upload_forbidden") => "Files cannot be sent from here by you, so nothing was sent. Are you signed in?",
|
||||
("en", "current_file") => "Now:",
|
||||
("en", "keep_file") => "Choose a new one to replace it, or leave it as it is.",
|
||||
("en", "nothing_here") => "Nothing here",
|
||||
("en", "back_to_start") => "back to the start",
|
||||
("en", "done") => "Done.",
|
||||
@@ -54,6 +64,16 @@ fn lookup(lang: &str, key: &str) -> Option<&'static str> {
|
||||
("no", "sign_in") => "Logg inn",
|
||||
("no", "sign_out") => "Logg ut",
|
||||
("no", "nothing_here_yet") => "Ingenting her ennå.",
|
||||
("no", "show_older") => "Vis eldre",
|
||||
("no", "for_this_device") => "Til denne enheten",
|
||||
("no", "all_downloads") => "Alle nedlastinger",
|
||||
("no", "release_notes") => "utgivelsesnotater",
|
||||
("no", "upload_failed") => "Filen kom ikke fram, så ingenting ble sendt. Prøv igjen.",
|
||||
("no", "upload_too_large") => "Filen er større enn 20 MB, så ingenting ble sendt. Velg en mindre fil, eller et bilde med lavere oppløsning.",
|
||||
("no", "upload_unavailable") => "Siden kan ikke ta imot filer akkurat nå, så ingenting ble sendt. Si fra til den som har siden.",
|
||||
("no", "upload_forbidden") => "Du kan ikke sende filer herfra, så ingenting ble sendt. Er du logget inn?",
|
||||
("no", "current_file") => "Nå:",
|
||||
("no", "keep_file") => "Velg en ny for å bytte, eller la den være.",
|
||||
("no", "nothing_here") => "Ingenting her",
|
||||
("no", "back_to_start") => "tilbake til start",
|
||||
("no", "done") => "Ferdig.",
|
||||
@@ -102,6 +122,16 @@ mod tests {
|
||||
"sign_in",
|
||||
"sign_out",
|
||||
"nothing_here_yet",
|
||||
"show_older",
|
||||
"for_this_device",
|
||||
"all_downloads",
|
||||
"release_notes",
|
||||
"upload_failed",
|
||||
"upload_too_large",
|
||||
"upload_unavailable",
|
||||
"upload_forbidden",
|
||||
"current_file",
|
||||
"keep_file",
|
||||
"nothing_here",
|
||||
"back_to_start",
|
||||
"done",
|
||||
|
||||
+66
-15
@@ -24,6 +24,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
let mut skim = false;
|
||||
let mut sim_out: Option<String> = None;
|
||||
let mut show_access = false;
|
||||
let mut findings_out: Option<String> = None;
|
||||
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
@@ -35,6 +36,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
"--skim" => skim = true,
|
||||
"--needs-sim" => sim_out = args.next(),
|
||||
"--access" => show_access = true,
|
||||
"--findings" => findings_out = args.next(),
|
||||
"--needs-score" => answers_in = args.next(),
|
||||
"--min-accuracy" => {
|
||||
min_accuracy = args.next().and_then(|v| v.parse().ok()).unwrap_or(min_accuracy)
|
||||
@@ -66,13 +68,14 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
(questions, aggregates_map, site, needs_raw)
|
||||
} else {
|
||||
eprintln!(
|
||||
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
||||
"usage: iris check (--repo <gitea-url> [--branch main] [--subdir questions] | --path <local-dir>) [--findings <out.jsonl>] [--needs-tasks <out.jsonl> [--skim]] [--needs-sim <model.json>] [--access] [--needs-score <answers.jsonl> [--min-accuracy 0.8]]"
|
||||
);
|
||||
std::process::exit(2);
|
||||
};
|
||||
|
||||
if let Err(e) = site.validate() {
|
||||
eprintln!("FAIL: {e}");
|
||||
write_findings(findings_out.as_deref(), &[schema_finding(&e.to_string())]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
let aggregates_map = content::with_builtin_aggregates(aggregates_map);
|
||||
@@ -114,6 +117,7 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
println!();
|
||||
}
|
||||
check_needs(
|
||||
findings_out.as_deref(),
|
||||
needs_raw.as_deref(),
|
||||
&questions,
|
||||
&aggregates_map,
|
||||
@@ -126,15 +130,67 @@ pub async fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: {e}");
|
||||
// One schema error is no reason to hide the rest: the needs
|
||||
// pass reads the same content and says what else is wrong,
|
||||
// so a fix round can take them all at once.
|
||||
let mut all = vec![schema_finding(&e.to_string())];
|
||||
if let Some(file) = needs_raw.as_deref().and_then(|raw| needs::parse(raw).ok()) {
|
||||
let findings = needs::check(&file, &questions, &aggregates_map);
|
||||
print_findings(&findings);
|
||||
all.extend(findings);
|
||||
}
|
||||
write_findings(findings_out.as_deref(), &all);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A content error the schema pass stopped at, as a finding: the repo
|
||||
/// is its scope and the message its subject, since it has no other.
|
||||
fn schema_finding(message: &str) -> needs::Finding {
|
||||
needs::Finding {
|
||||
need: String::new(),
|
||||
level: needs::Level::Fail,
|
||||
message: message.to_string(),
|
||||
scope: "repo",
|
||||
code: "schema",
|
||||
subject: message.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `--findings`: every finding as one JSON line (id, level, code,
|
||||
/// scope, need, subject, message, fix), the same ones the text output
|
||||
/// prints - an empty file when there are none, so a reader can tell
|
||||
/// "clean" from "not run".
|
||||
fn write_findings(out: Option<&str>, findings: &[needs::Finding]) {
|
||||
let Some(out) = out else { return };
|
||||
let lines: String = findings.iter().map(|f| format!("{}\n", f.to_json())).collect();
|
||||
if let Err(e) = std::fs::write(out, lines) {
|
||||
eprintln!("WARN: could not write the findings to {out}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Each finding on its own line, FAIL or WARN first; the count of FAILs.
|
||||
fn print_findings(findings: &[needs::Finding]) -> usize {
|
||||
let mut failed = 0;
|
||||
for finding in findings {
|
||||
let level = match finding.level {
|
||||
needs::Level::Fail => {
|
||||
failed += 1;
|
||||
"FAIL"
|
||||
}
|
||||
needs::Level::Warn => "WARN",
|
||||
};
|
||||
eprintln!("{level}: {} {:?}: {}", finding.scope, finding.need, finding.message);
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
/// The business-needs pass (see `crate::lint::needs`): structure always,
|
||||
/// then the wording tasks written out and/or an engine's answers
|
||||
/// scored, when asked. A repo without `needs.yaml` skips all of it.
|
||||
fn check_needs(
|
||||
findings_out: Option<&str>,
|
||||
raw: Option<&str>,
|
||||
questions: &std::collections::HashMap<String, content::Question>,
|
||||
aggregates_map: &std::collections::HashMap<String, aggregates::AggregateSchema>,
|
||||
@@ -145,6 +201,7 @@ fn check_needs(
|
||||
min_accuracy: f64,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some(raw) = raw else {
|
||||
write_findings(findings_out, &[]);
|
||||
if tasks_out.is_some() || answers_in.is_some() {
|
||||
eprintln!("FAIL: --needs-tasks/--needs-score given, but the repo has no needs.yaml");
|
||||
std::process::exit(1);
|
||||
@@ -155,21 +212,13 @@ fn check_needs(
|
||||
Ok(file) => file,
|
||||
Err(e) => {
|
||||
eprintln!("FAIL: {e}");
|
||||
write_findings(findings_out, &[schema_finding(&e.to_string())]);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let findings = needs::check(&file, questions, aggregates_map);
|
||||
let mut failed = 0;
|
||||
for finding in &findings {
|
||||
let level = match finding.level {
|
||||
needs::Level::Fail => {
|
||||
failed += 1;
|
||||
"FAIL"
|
||||
}
|
||||
needs::Level::Warn => "WARN",
|
||||
};
|
||||
eprintln!("{level}: need {:?}: {}", finding.need, finding.message);
|
||||
}
|
||||
let failed = print_findings(&findings);
|
||||
write_findings(findings_out, &findings);
|
||||
for need in &file.needs {
|
||||
if let Some(path) = needs::path_for(questions, need) {
|
||||
let steps: Vec<String> = path
|
||||
@@ -180,11 +229,13 @@ fn check_needs(
|
||||
}
|
||||
}
|
||||
if failed == 0 {
|
||||
let planned = file.needs.iter().filter(|n| n.planned).count();
|
||||
let planned = file.needs.iter().filter(|n| n.planned && !n.paused).count();
|
||||
let paused = file.needs.iter().filter(|n| n.paused).count();
|
||||
println!(
|
||||
"OK: {} need(s) structurally met, {} planned, {} persona(s)",
|
||||
file.needs.len() - planned,
|
||||
"OK: {} need(s) structurally met, {} planned, {} paused, {} persona(s)",
|
||||
file.needs.len() - planned - paused,
|
||||
planned,
|
||||
paused,
|
||||
file.personas.len()
|
||||
);
|
||||
}
|
||||
|
||||
+560
-35
@@ -78,6 +78,10 @@ pub struct Need {
|
||||
/// as a warning, so the gap stays visible without failing CI.
|
||||
#[serde(default)]
|
||||
pub planned: bool,
|
||||
/// Open before and closed for now (full up, out of season): its
|
||||
/// card is on the page with no form. Reports like `planned`.
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
/// Each state of the bucket in plain words, in the site's
|
||||
/// language, for the scorer: state ids are ASCII, and a Norwegian
|
||||
/// `ikke_lost` reads as English to a model.
|
||||
@@ -133,9 +137,55 @@ pub enum Level {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Finding {
|
||||
/// The need's id, or the bucket's name when `scope` is "bucket".
|
||||
pub need: String,
|
||||
pub level: Level,
|
||||
pub message: String,
|
||||
/// "need" for a need's own path; "bucket" for what holds for every
|
||||
/// record in a bucket whichever need brought it in.
|
||||
pub scope: &'static str,
|
||||
/// What kind of gap it is, stable across releases: `strand`,
|
||||
/// `edge-unoffered`, `done-unreachable`, ... (docs/lint.md).
|
||||
pub code: &'static str,
|
||||
/// What in the content it is about: a state, a move (`from>to`), a
|
||||
/// page. With the scope, need and code it makes the finding's id.
|
||||
pub subject: String,
|
||||
}
|
||||
|
||||
impl Finding {
|
||||
/// The same gap keeps the same id however its message is worded,
|
||||
/// so a fix round can tell what it closed, what is still open and
|
||||
/// what it broke. FNV-1a over scope, need, code and subject.
|
||||
pub fn id(&self) -> String {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for part in [self.scope, &self.need, self.code, &self.subject] {
|
||||
for byte in part.bytes().chain(std::iter::once(0)) {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
format!("{:08x}", hash >> 32)
|
||||
}
|
||||
|
||||
/// The change that closes it, when the lint knows one: what follows
|
||||
/// "fix:" in the message.
|
||||
pub fn fix(&self) -> Option<&str> {
|
||||
self.message.split_once(" - fix: ").map(|(_, fix)| fix)
|
||||
}
|
||||
|
||||
/// One line of `--findings` JSONL.
|
||||
pub fn to_json(&self) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"id": self.id(),
|
||||
"level": match self.level { Level::Fail => "fail", Level::Warn => "warn" },
|
||||
"code": self.code,
|
||||
"scope": self.scope,
|
||||
"need": self.need,
|
||||
"subject": self.subject,
|
||||
"message": self.message,
|
||||
"fix": self.fix(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -251,6 +301,10 @@ pub fn path_for(questions: &Questions, need: &Need) -> Option<Vec<Step>> {
|
||||
struct Desk {
|
||||
page: String,
|
||||
group: Option<String>,
|
||||
/// The page's own `qualifies`, kept apart from the resource's
|
||||
/// `requires_group`: when both are set and differ, only members of
|
||||
/// both ever see the buttons.
|
||||
page_group: Option<String>,
|
||||
/// (from, to, button label)
|
||||
moves: Vec<(String, String, String)>,
|
||||
}
|
||||
@@ -268,6 +322,7 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
|
||||
desks.push(Desk {
|
||||
page: q.id.clone(),
|
||||
group: resource.requires_group.clone().or_else(|| q.qualifies.clone()),
|
||||
page_group: q.qualifies.clone(),
|
||||
moves: resource
|
||||
.transitions
|
||||
.iter()
|
||||
@@ -280,15 +335,49 @@ fn desks_over(questions: &Questions, bucket: &str) -> Vec<Desk> {
|
||||
desks
|
||||
}
|
||||
|
||||
/// Self-service moves into `bucket` - a visitor holding their own
|
||||
/// record's link, no group involved (unsubscribe).
|
||||
fn self_moves(questions: &Questions, bucket: &str) -> Vec<String> {
|
||||
/// Self-service moves in `bucket` - a visitor holding their own
|
||||
/// record's link, no group involved (unsubscribe, withdraw) - as
|
||||
/// (from, to), from the states each may be made from.
|
||||
fn self_moves(questions: &Questions, bucket: &str, initial: &str) -> Vec<(String, String)> {
|
||||
questions
|
||||
.values()
|
||||
.flat_map(|q| &q.alternatives)
|
||||
.filter_map(|a| a.self_transition.as_ref())
|
||||
.filter(|s| s.bucket == bucket)
|
||||
.map(|s| s.to.clone())
|
||||
.flat_map(|s| s.from_states(initial).into_iter().map(|from| (from, s.to.clone())))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The moves a clock makes in `schema` (`after:` on a state), as
|
||||
/// (from, to). Nobody presses them, and they still move records.
|
||||
fn clock_moves(schema: &AggregateSchema) -> Vec<(String, String)> {
|
||||
let mut moves: Vec<(String, String)> =
|
||||
schema.after_for_state.iter().map(|(from, after)| (from.clone(), after.to.clone())).collect();
|
||||
moves.sort();
|
||||
moves
|
||||
}
|
||||
|
||||
/// States reachable from `start` along `moves`.
|
||||
fn reachable(start: &str, moves: &[(String, String)]) -> HashSet<String> {
|
||||
let mut reached = HashSet::from([start.to_string()]);
|
||||
let mut queue = VecDeque::from([start.to_string()]);
|
||||
while let Some(state) = queue.pop_front() {
|
||||
for (_, to) in moves.iter().filter(|(from, _)| *from == state) {
|
||||
if reached.insert(to.clone()) {
|
||||
queue.push_back(to.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
reached
|
||||
}
|
||||
|
||||
/// Who else offers `from -> to` in the bucket, as "page (group)", for
|
||||
/// a finding that has to say where the missing button already is.
|
||||
fn offered_elsewhere(desks: &[Desk], from: &str, to: &str) -> Vec<String> {
|
||||
desks
|
||||
.iter()
|
||||
.filter(|d| d.moves.iter().any(|(f, t, _)| f == from && t == to))
|
||||
.map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -301,28 +390,38 @@ pub fn check(
|
||||
) -> Vec<Finding> {
|
||||
let mut findings = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
// (bucket, state) a need already reported - stranding on its way
|
||||
// to done, or out of its reach - so the bucket pass does not say
|
||||
// it twice.
|
||||
let mut strands_reported: HashSet<(String, String)> = HashSet::new();
|
||||
let mut unreached_reported: HashSet<(String, String)> = HashSet::new();
|
||||
for need in &file.needs {
|
||||
let mut report = |level: Level, message: String| {
|
||||
let mut report = |level: Level, code: &'static str, subject: String, message: String| {
|
||||
findings.push(Finding {
|
||||
need: need.id.clone(),
|
||||
level: if need.planned { Level::Warn } else { level },
|
||||
message: if need.planned && level == Level::Fail {
|
||||
format!("planned, not met yet: {message}")
|
||||
} else {
|
||||
message
|
||||
scope: "need",
|
||||
code,
|
||||
subject,
|
||||
level: if need.planned || need.paused { Level::Warn } else { level },
|
||||
message: match (need.paused, need.planned, level == Level::Fail) {
|
||||
(true, _, true) => format!("paused, not open now: {message}"),
|
||||
(false, true, true) => format!("planned, not met yet: {message}"),
|
||||
_ => message,
|
||||
},
|
||||
})
|
||||
};
|
||||
if !seen.insert(need.id.as_str()) {
|
||||
report(Level::Fail, "declared more than once".to_string());
|
||||
report(Level::Fail, "need-duplicate", String::new(), "declared more than once".to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
// The visitor's side: can they get their answer recorded?
|
||||
match questions.get(&need.enters) {
|
||||
None => report(Level::Fail, format!("entry page {:?} does not exist", need.enters)),
|
||||
None => report(Level::Fail, "entry-missing", need.enters.clone(), format!("entry page {:?} does not exist", need.enters)),
|
||||
Some(page) if !visible(page, need.enters_as.as_deref()) => report(
|
||||
Level::Fail,
|
||||
"entry-gated",
|
||||
need.enters.clone(),
|
||||
format!(
|
||||
"entry page {:?} is gated on {:?}, which this visitor does not hold",
|
||||
need.enters, page.qualifies
|
||||
@@ -332,6 +431,8 @@ pub fn check(
|
||||
if page.responsible.is_none() {
|
||||
report(
|
||||
Level::Warn,
|
||||
"entry-no-responsible",
|
||||
need.enters.clone(),
|
||||
format!("entry page {:?} names no one responsible to turn to", need.enters),
|
||||
);
|
||||
}
|
||||
@@ -359,6 +460,8 @@ pub fn check(
|
||||
.unwrap_or_default();
|
||||
report(
|
||||
Level::Fail,
|
||||
"path-closed",
|
||||
at.clone(),
|
||||
format!(
|
||||
"the path exists but is closed: {at} is `disabled`, so the answer is advertised and refused"
|
||||
),
|
||||
@@ -366,6 +469,8 @@ pub fn check(
|
||||
}
|
||||
None => report(
|
||||
Level::Fail,
|
||||
"path-missing",
|
||||
String::new(),
|
||||
format!(
|
||||
"no path from {:?}{} records into {:?} within {} submission(s)",
|
||||
need.enters,
|
||||
@@ -382,6 +487,8 @@ pub fn check(
|
||||
if best.fields > budget {
|
||||
report(
|
||||
Level::Fail,
|
||||
"path-over-budget",
|
||||
String::new(),
|
||||
format!(
|
||||
"the path asks for {} required field(s), over the budget of {budget}",
|
||||
best.fields
|
||||
@@ -392,6 +499,8 @@ pub fn check(
|
||||
if best.records_with_action == Some(false) {
|
||||
report(
|
||||
Level::Warn,
|
||||
"path-no-action",
|
||||
String::new(),
|
||||
"the recording alternative has no `action` - the visitor is never told what happens next".to_string(),
|
||||
);
|
||||
}
|
||||
@@ -404,6 +513,8 @@ pub fn check(
|
||||
let Some(schema) = aggregates.get(&need.lands_in) else {
|
||||
report(
|
||||
Level::Fail,
|
||||
"machine-missing",
|
||||
need.lands_in.clone(),
|
||||
format!("aggregates.yaml declares no state machine for {:?}", need.lands_in),
|
||||
);
|
||||
continue;
|
||||
@@ -422,6 +533,8 @@ pub fn check(
|
||||
.collect();
|
||||
report(
|
||||
Level::Fail,
|
||||
"desk-missing",
|
||||
need.handled_by.clone(),
|
||||
format!(
|
||||
"no desk gated on {:?} reads {:?}{}",
|
||||
need.handled_by,
|
||||
@@ -435,23 +548,44 @@ pub fn check(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let selfs = self_moves(questions, &need.lands_in);
|
||||
// Every move that carries this need's records: the buttons on
|
||||
// the desks of handled_by/also_moved_by, the visitor's own
|
||||
// links, and the clocks.
|
||||
let mut moves: Vec<(String, String)> = own
|
||||
.iter()
|
||||
.flat_map(|d| &d.moves)
|
||||
.filter(|(from, to, _)| schema.allowed(from).contains(to))
|
||||
.map(|(from, to, _)| (from.clone(), to.clone()))
|
||||
.collect();
|
||||
moves.extend(self_moves(questions, &need.lands_in, &schema.initial));
|
||||
moves.extend(clock_moves(schema));
|
||||
let exits = |state: &str| -> Vec<String> {
|
||||
let allowed = schema.allowed(state);
|
||||
let mut to: Vec<String> = own
|
||||
.iter()
|
||||
.flat_map(|d| &d.moves)
|
||||
.filter(|(from, to, _)| from == state && allowed.contains(to))
|
||||
.map(|(_, to, _)| to.clone())
|
||||
.collect();
|
||||
to.extend(selfs.iter().filter(|to| allowed.contains(to)).cloned());
|
||||
to
|
||||
moves.iter().filter(|(from, _)| from == state).map(|(_, to)| to.clone()).collect()
|
||||
};
|
||||
let movers = std::iter::once(need.handled_by.as_str())
|
||||
.chain(need.also_moved_by.iter().map(String::as_str))
|
||||
.map(|g| format!("{g:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" or ");
|
||||
// The fix for a missing move: the button on handled_by's desk,
|
||||
// or the group whose desk already has it added to also_moved_by.
|
||||
let fix_for = |from: &str, to: &str| -> String {
|
||||
let elsewhere = offered_elsewhere(&desks, from, to);
|
||||
let desk = own.first().map(|d| d.page.as_str()).unwrap_or("their desk");
|
||||
if elsewhere.is_empty() {
|
||||
format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {desk}")
|
||||
} else {
|
||||
format!(
|
||||
"{} already offers it - add that group to also_moved_by, or add `{{ from: {from}, to: {to}, label: ... }}` on {desk}",
|
||||
elsewhere.join(", ")
|
||||
)
|
||||
}
|
||||
};
|
||||
let done: HashSet<&str> = need.done_when.iter().map(String::as_str).collect();
|
||||
// Reachability walks the whole machine (a finished state may
|
||||
// lead on to another: subscribed, then unsubscribed). Stranding
|
||||
// only counts on the way to the first finished state - what
|
||||
// happens to a handled record afterwards is not this need.
|
||||
// only counts on the way to the first finished state here; what
|
||||
// strands a record afterwards is the bucket's finding (below).
|
||||
let mut reached: HashSet<String> = HashSet::from([schema.initial.clone()]);
|
||||
let mut queue = VecDeque::from([(schema.initial.clone(), false)]);
|
||||
let mut stranded = Vec::new();
|
||||
@@ -467,18 +601,44 @@ pub fn check(
|
||||
}
|
||||
}
|
||||
}
|
||||
// The declared edges into `state` from somewhere a record does
|
||||
// get to: the moves that would close the gap.
|
||||
let ways_in = |state: &str| -> Vec<(String, String)> {
|
||||
let mut ways: Vec<(String, String)> = reached
|
||||
.iter()
|
||||
.filter(|from| schema.allowed(from).iter().any(|to| to == state))
|
||||
.map(|from| (from.clone(), state.to_string()))
|
||||
.collect();
|
||||
ways.sort();
|
||||
ways
|
||||
};
|
||||
let missing = |state: &str| -> String {
|
||||
match ways_in(state).first() {
|
||||
Some((from, to)) => format!(" - fix: {}", fix_for(from, to)),
|
||||
None => format!(
|
||||
" - fix: no state a record reaches has a declared edge into it; add one in aggregates.yaml under {:?}",
|
||||
need.lands_in
|
||||
),
|
||||
}
|
||||
};
|
||||
for state in &need.done_when {
|
||||
if !schema.has_state(state) {
|
||||
report(
|
||||
Level::Fail,
|
||||
"done-unknown",
|
||||
state.clone(),
|
||||
format!("done_when names {state:?}, which {:?} has no such state", need.lands_in),
|
||||
);
|
||||
} else if !reached.contains(state) {
|
||||
unreached_reported.insert((need.lands_in.clone(), state.clone()));
|
||||
report(
|
||||
Level::Fail,
|
||||
"done-unreachable",
|
||||
state.clone(),
|
||||
format!(
|
||||
"{:?} cannot move a record to {state:?} - no desk of theirs offers the moves",
|
||||
need.handled_by
|
||||
"{:?} cannot move a record to {state:?} - no desk of theirs offers the moves{}",
|
||||
need.handled_by,
|
||||
missing(state)
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -487,17 +647,48 @@ pub fn check(
|
||||
if !schema.has_state(stage) {
|
||||
report(
|
||||
Level::Fail,
|
||||
"stage-unknown",
|
||||
stage.clone(),
|
||||
format!(
|
||||
"the business describes a stage {stage:?} that {:?} has no state for",
|
||||
need.lands_in
|
||||
),
|
||||
);
|
||||
} else if !reached.contains(stage) {
|
||||
unreached_reported.insert((need.lands_in.clone(), stage.clone()));
|
||||
report(
|
||||
Level::Fail,
|
||||
"stage-unreachable",
|
||||
stage.clone(),
|
||||
format!(
|
||||
"stage {stage:?} exists but {:?} is offered no moves that reach it",
|
||||
need.handled_by
|
||||
"stage {stage:?} exists but {:?} is offered no moves that reach it{}",
|
||||
need.handled_by,
|
||||
missing(stage)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// In the business's order: each working stage reachable from
|
||||
// the one before it, so a machine where "quoted" cannot follow
|
||||
// "surveyed" does not pass for one that goes surveyed -> quoted.
|
||||
// The finished states that close the list are alternatives
|
||||
// (won, lost), not a sequence, so they are left out.
|
||||
for pair in need.stages.windows(2) {
|
||||
let (before, after) = (&pair[0], &pair[1]);
|
||||
if done.contains(before.as_str()) || done.contains(after.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if reached.contains(before) && reached.contains(after) && !reachable(before, &moves).contains(after) {
|
||||
report(
|
||||
Level::Fail,
|
||||
"stage-order",
|
||||
format!("{before}>{after}"),
|
||||
format!(
|
||||
"stage {after:?} comes after {before:?} for the business, but no move leads on from {before:?} to it{}",
|
||||
match schema.allowed(before).iter().find(|to| reachable(to, &moves).contains(after) || *to == after) {
|
||||
Some(to) => format!(" - fix: {}", fix_for(before, to)),
|
||||
None => format!(" - fix: add an edge from {before:?} toward {after:?} in aggregates.yaml under {:?}", need.lands_in),
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -506,27 +697,43 @@ pub fn check(
|
||||
if !need.done_when.contains(state) {
|
||||
report(
|
||||
Level::Fail,
|
||||
"success-not-done",
|
||||
state.clone(),
|
||||
format!("success names {state:?}, which is not one of done_when"),
|
||||
);
|
||||
}
|
||||
}
|
||||
stranded.sort();
|
||||
for state in &stranded {
|
||||
strands_reported.insert((need.lands_in.clone(), state.clone()));
|
||||
}
|
||||
for state in stranded {
|
||||
let fix = match schema.allowed(&state).first() {
|
||||
Some(to) => format!(" - fix: {}", fix_for(&state, to)),
|
||||
None => format!(
|
||||
" - fix: {:?} declares no way out of it; add {state:?} to done_when if it is finished, or an edge out in aggregates.yaml",
|
||||
need.lands_in
|
||||
),
|
||||
};
|
||||
report(
|
||||
Level::Fail,
|
||||
format!(
|
||||
"records strand in {state:?}: {:?} is offered no move out of it",
|
||||
need.handled_by
|
||||
),
|
||||
"strand",
|
||||
state.clone(),
|
||||
format!("records strand in {state:?}: {movers} is offered no move out of it{fix}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
findings.extend(operations(file, questions, aggregates, &strands_reported, &unreached_reported));
|
||||
|
||||
let need_ids: HashSet<&str> = file.needs.iter().map(|n| n.id.as_str()).collect();
|
||||
for persona in &file.personas {
|
||||
if !need_ids.contains(persona.need.as_str()) {
|
||||
findings.push(Finding {
|
||||
need: persona.need.clone(),
|
||||
scope: "need",
|
||||
code: "persona-unknown-need",
|
||||
subject: persona.id.clone(),
|
||||
level: Level::Fail,
|
||||
message: format!("persona {:?} points at a need that is not declared", persona.id),
|
||||
});
|
||||
@@ -535,6 +742,200 @@ pub fn check(
|
||||
findings
|
||||
}
|
||||
|
||||
/// What must hold for every record in a bucket some need lands in,
|
||||
/// whichever need brought it: every state is somewhere a record can
|
||||
/// get to, no state a record gets to holds it with no way on (before
|
||||
/// done or after), every declared move is a button someone has, every
|
||||
/// desk can be seen by whoever its buttons are for, and every mail
|
||||
/// goes out from a state records reach, saying only what the record
|
||||
/// holds.
|
||||
fn operations(
|
||||
file: &NeedsFile,
|
||||
questions: &Questions,
|
||||
aggregates: &HashMap<String, AggregateSchema>,
|
||||
strands_reported: &HashSet<(String, String)>,
|
||||
unreached_reported: &HashSet<(String, String)>,
|
||||
) -> Vec<Finding> {
|
||||
let mut findings = Vec::new();
|
||||
let mut buckets: Vec<&str> = file.needs.iter().map(|n| n.lands_in.as_str()).collect();
|
||||
buckets.sort();
|
||||
buckets.dedup();
|
||||
for bucket in buckets {
|
||||
let Some(schema) = aggregates.get(bucket) else { continue };
|
||||
let needs: Vec<&Need> = file.needs.iter().filter(|n| n.lands_in == bucket).collect();
|
||||
// Only open needs make a gap fail; a bucket that only planned
|
||||
// or paused needs use reports it as a warning.
|
||||
let open = needs.iter().any(|n| !n.planned && !n.paused);
|
||||
let mut report = |level: Level, code: &'static str, subject: String, message: String| {
|
||||
findings.push(Finding {
|
||||
need: bucket.to_string(),
|
||||
scope: "bucket",
|
||||
code,
|
||||
subject,
|
||||
level: if open { level } else { Level::Warn },
|
||||
message,
|
||||
})
|
||||
};
|
||||
let desks = desks_over(questions, bucket);
|
||||
let mut moves: Vec<(String, String)> = desks
|
||||
.iter()
|
||||
.flat_map(|d| &d.moves)
|
||||
.filter(|(from, to, _)| schema.allowed(from).contains(to))
|
||||
.map(|(from, to, _)| (from.clone(), to.clone()))
|
||||
.collect();
|
||||
moves.extend(self_moves(questions, bucket, &schema.initial));
|
||||
moves.extend(clock_moves(schema));
|
||||
let reached = reachable(&schema.initial, &moves);
|
||||
let offered = |from: &str, to: &str| moves.iter().any(|(f, t)| f == from && t == to);
|
||||
// Where a missing button goes: the desk of the group answerable
|
||||
// for the bucket's first need.
|
||||
let home = needs
|
||||
.iter()
|
||||
.find_map(|n| desks.iter().find(|d| d.group.as_deref() == Some(n.handled_by.as_str())))
|
||||
.map(|d| d.page.clone())
|
||||
.unwrap_or_else(|| "the handling group's desk".to_string());
|
||||
let button = |from: &str, to: &str| format!("add `{{ from: {from}, to: {to}, label: ... }}` to the transitions on {home}");
|
||||
|
||||
let mut states: Vec<&String> = schema.event_for_state.keys().collect();
|
||||
states.sort();
|
||||
for state in &states {
|
||||
if reached.contains(*state) || unreached_reported.contains(&(bucket.to_string(), (*state).clone())) {
|
||||
continue;
|
||||
}
|
||||
let mut ways: Vec<&String> = reached.iter().filter(|from| schema.allowed(from).contains(state)).collect();
|
||||
ways.sort();
|
||||
report(
|
||||
Level::Fail,
|
||||
"state-unreached",
|
||||
(*state).clone(),
|
||||
format!(
|
||||
"state {state:?} is never reached: no desk, link or clock moves a record there - fix: {}",
|
||||
match ways.first() {
|
||||
Some(from) => button(from, state),
|
||||
None => "declare an edge into it from a state records reach in aggregates.yaml, or remove it".to_string(),
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
for state in states.iter().filter(|s| reached.contains(**s)) {
|
||||
let out = schema.allowed(state);
|
||||
if out.is_empty() {
|
||||
continue; // a final state: nothing should move it
|
||||
}
|
||||
let offered_out: Vec<&String> = out.iter().filter(|to| offered(state, to)).collect();
|
||||
if offered_out.is_empty() {
|
||||
if !strands_reported.contains(&(bucket.to_string(), (*state).clone())) {
|
||||
report(
|
||||
Level::Fail,
|
||||
"strand",
|
||||
(*state).clone(),
|
||||
format!(
|
||||
"records strand in {state:?}: {bucket:?} declares moves on to {} but no desk, link or clock offers one - fix: {}",
|
||||
out.join(", "),
|
||||
button(state, &out[0])
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for to in out.iter().filter(|to| !offered(state, to)) {
|
||||
report(
|
||||
Level::Warn,
|
||||
"edge-unoffered",
|
||||
format!("{state}>{to}"),
|
||||
format!(
|
||||
"the move {state:?} -> {to:?} is declared but no desk, link or clock offers it - fix: {}, or drop the edge from aggregates.yaml",
|
||||
button(state, to)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for desk in &desks {
|
||||
if let (Some(page_group), Some(group)) = (&desk.page_group, &desk.group) {
|
||||
if page_group != group {
|
||||
report(
|
||||
Level::Warn,
|
||||
"desk-gated-twice",
|
||||
desk.page.clone(),
|
||||
format!(
|
||||
"the desk on {} is gated twice: the page qualifies {page_group:?}, its list requires {group:?}, so only members of both see the buttons - fix: make them the same group",
|
||||
desk.page
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The fields a record of this bucket holds: everything asked on
|
||||
// a form that records into it.
|
||||
let fields: Vec<(&str, &str)> = questions
|
||||
.values()
|
||||
.flat_map(|q| &q.alternatives)
|
||||
.filter(|a| a.record_as.as_deref() == Some(bucket))
|
||||
.flat_map(|a| &a.features)
|
||||
.flat_map(|f| &f.requirements)
|
||||
.map(|r| (r.name.as_str(), r.kind.as_str()))
|
||||
.collect();
|
||||
let mut mail_states: Vec<(&String, &crate::aggregates::MailSpec)> = schema.mail_for_state.iter().collect();
|
||||
mail_states.sort_by(|a, b| a.0.cmp(b.0));
|
||||
for (state, mail) in mail_states {
|
||||
if !reached.contains(state) {
|
||||
report(Level::Warn, "mail-unreachable", state.clone(), format!("the mail on entering {state:?} never goes out: no record reaches {state:?} - fix: make {state:?} reachable, or drop its mail"));
|
||||
}
|
||||
let mut unknown: Vec<String> = Vec::new();
|
||||
for key in placeholders(&mail.subject).into_iter().chain(placeholders(&mail.body)) {
|
||||
let known = match key.split_once('.') {
|
||||
Some((parent, _)) => fields.iter().any(|(name, kind)| *name == parent && *kind == "record"),
|
||||
None => MAIL_BUILTINS.contains(&key.as_str()) || fields.iter().any(|(name, _)| *name == key),
|
||||
};
|
||||
if !known && !unknown.contains(&key) {
|
||||
unknown.push(key);
|
||||
}
|
||||
}
|
||||
if !unknown.is_empty() {
|
||||
let mut names: Vec<&str> = fields.iter().map(|(n, _)| *n).collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
report(
|
||||
Level::Fail,
|
||||
"mail-unknown-field",
|
||||
state.clone(),
|
||||
format!(
|
||||
"the mail on entering {state:?} uses {}, which no form recording into {bucket:?} collects, so it would go out blank there - fix: use one of the fields the forms collect ({}), or ask for it on the form",
|
||||
unknown.iter().map(|k| format!("{{{k}}}")).collect::<Vec<_>>().join(", "),
|
||||
if names.is_empty() { "nothing".to_string() } else { names.join(", ") }
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
/// What the mailer fills in on every state mail, whatever the record
|
||||
/// holds (`mail::vars_for`, `mail::site_vars`, `mail::record_links`).
|
||||
const MAIL_BUILTINS: &[&str] = &["site", "site_name", "chain", "email", "bucket", "state", "link", "self_link", "desk_link"];
|
||||
|
||||
/// The `{key}`s in a mail template, read the way `mail::render` reads
|
||||
/// them: letters, digits, `_`, `-` and `.`, closed by `}`.
|
||||
fn placeholders(template: &str) -> Vec<String> {
|
||||
let mut keys = Vec::new();
|
||||
let mut rest = template;
|
||||
while let Some(start) = rest.find('{') {
|
||||
let after = &rest[start + 1..];
|
||||
let len = after
|
||||
.char_indices()
|
||||
.take_while(|(_, c)| c.is_ascii_alphanumeric() || *c == '_' || *c == '-' || *c == '.')
|
||||
.last()
|
||||
.map(|(i, c)| i + c.len_utf8())
|
||||
.unwrap_or(0);
|
||||
if len > 0 && after[len..].starts_with('}') {
|
||||
keys.push(after[..len].to_string());
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// Everything a simulation of the business needs, resolved: each
|
||||
/// need's path with the choice on every page and the form that gets
|
||||
/// filled, each bucket's state machine with the buttons every desk
|
||||
@@ -607,6 +1008,11 @@ pub fn sim_model(
|
||||
"steps": steps,
|
||||
}));
|
||||
if let Some(schema) = aggregates.get(&need.lands_in) {
|
||||
// The simulator's shape: the states a visitor's own link
|
||||
// moves to (it does not model where from).
|
||||
let mut self_move_targets: Vec<String> =
|
||||
self_moves(questions, &need.lands_in, &schema.initial).into_iter().map(|(_, to)| to).collect();
|
||||
self_move_targets.dedup();
|
||||
buckets.entry(need.lands_in.clone()).or_insert_with(|| {
|
||||
json!({
|
||||
"initial": schema.initial,
|
||||
@@ -619,7 +1025,7 @@ pub fn sim_model(
|
||||
"from": from, "to": to, "label": label,
|
||||
})).collect::<Vec<_>>(),
|
||||
})).collect::<Vec<_>>(),
|
||||
"self_moves": self_moves(questions, &need.lands_in),
|
||||
"self_moves": self_move_targets,
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -891,8 +1297,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
fails(&findings),
|
||||
vec![
|
||||
"\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves",
|
||||
"records strand in \"quoted\": \"owners\" is offered no move out of it",
|
||||
"\"owners\" cannot move a record to \"won\" - no desk of theirs offers the moves - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk",
|
||||
"records strand in \"quoted\": \"owners\" is offered no move out of it - fix: add `{ from: quoted, to: won, label: ... }` to the transitions on /desk",
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1033,4 +1439,123 @@ mod tests {
|
||||
|
||||
assert_eq!(score(&tasks, &[], 0.6).unanswered, vec!["founder@/"]);
|
||||
}
|
||||
|
||||
/// The fixture site with its aggregates replaced, for the checks
|
||||
/// that are about the state machine rather than the pages.
|
||||
fn site_with(desk_moves: &str, projects: &str) -> (Questions, HashMap<String, AggregateSchema>) {
|
||||
let (questions, _) = site(desk_moves);
|
||||
let aggregates = parse_aggregates_yaml(&format!(
|
||||
"aggregates:\n - bucket: projects\n{projects} - bucket: subscribers\n attended_by: a mailer\n initial: open\n states:\n open: {{ event: subscribed }}\n transitions: {{}}\n"
|
||||
))
|
||||
.unwrap();
|
||||
(questions, aggregates)
|
||||
}
|
||||
|
||||
fn warns(findings: &[Finding]) -> Vec<&str> {
|
||||
findings.iter().filter(|f| f.level == Level::Warn).map(|f| f.message.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clock_is_a_move() {
|
||||
// Nobody closes a stale quote; six weeks do. That is not a strand.
|
||||
let (questions, aggregates) = site_with(
|
||||
" - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n",
|
||||
" initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted, after: { days: 42, to: lost } }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted]\n quoted: [won, lost]\n",
|
||||
);
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
assert!(fails(&findings).is_empty(), "{findings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_state_nobody_reaches_and_a_strand_after_done_are_the_buckets() {
|
||||
// "archived" follows "won" but no desk offers it; "parked" has
|
||||
// no way in at all.
|
||||
let (questions, aggregates) = site_with(
|
||||
FULL_DESK,
|
||||
" initial: open\n states:\n open: { event: submitted }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n archived: { event: archived }\n done: { event: done }\n parked: { event: parked }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n won: [archived]\n archived: [done]\n parked: [open]\n",
|
||||
);
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
let bucket: Vec<&Finding> = findings.iter().filter(|f| f.scope == "bucket").collect();
|
||||
assert!(bucket.iter().all(|f| f.need == "projects"), "{bucket:?}");
|
||||
assert_eq!(
|
||||
fails(&findings),
|
||||
vec![
|
||||
"state \"archived\" is never reached: no desk, link or clock moves a record there - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk",
|
||||
"state \"done\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it",
|
||||
"state \"parked\" is never reached: no desk, link or clock moves a record there - fix: declare an edge into it from a state records reach in aggregates.yaml, or remove it",
|
||||
"records strand in \"won\": \"projects\" declares moves on to archived but no desk, link or clock offers one - fix: add `{ from: won, to: archived, label: ... }` to the transitions on /desk",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edge_nobody_offers_is_a_warning_when_there_is_another_way_on() {
|
||||
let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n");
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
assert!(fails(&findings).is_empty(), "{findings:?}");
|
||||
assert_eq!(
|
||||
warns(&findings),
|
||||
vec!["the move \"open\" -> \"lost\" is declared but no desk, link or clock offers it - fix: add `{ from: open, to: lost, label: ... }` to the transitions on /desk, or drop the edge from aggregates.yaml"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_stages_must_follow_each_other() {
|
||||
// The business surveys, then quotes; this machine can only
|
||||
// quote straight from open, and surveying leads nowhere.
|
||||
let (questions, aggregates) = site_with(
|
||||
" - { to: quoted, label: Quote }\n - { to: surveyed, label: Survey }\n - { from: surveyed, to: lost, label: Lose }\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n",
|
||||
" initial: open\n states:\n open: { event: submitted }\n surveyed: { event: surveyed }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [surveyed, quoted]\n surveyed: [lost]\n quoted: [won, lost]\n",
|
||||
);
|
||||
let findings = check(&needs(" stages: [open, surveyed, quoted, won, lost]\n"), &questions, &aggregates);
|
||||
assert_eq!(
|
||||
fails(&findings),
|
||||
vec!["stage \"quoted\" comes after \"surveyed\" for the business, but no move leads on from \"surveyed\" to it - fix: add an edge from \"surveyed\" toward \"quoted\" in aggregates.yaml under \"projects\""]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mail_may_only_say_what_the_record_holds() {
|
||||
let (questions, aggregates) = site_with(
|
||||
FULL_DESK,
|
||||
" initial: open\n states:\n open: { event: submitted, mail: { to: submitter, subject: 'Got it, {email}', body: 'We read {brief} and will reply at {site}. {refnr} {link}' } }\n quoted: { event: quoted }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [quoted, lost]\n quoted: [won, lost]\n",
|
||||
);
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
assert_eq!(
|
||||
fails(&findings),
|
||||
vec!["the mail on entering \"open\" uses {refnr}, which no form recording into \"projects\" collects, so it would go out blank there - fix: use one of the fields the forms collect (brief, budget, email), or ask for it on the form"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_desk_gated_twice_is_seen_only_by_members_of_both() {
|
||||
let (mut questions, aggregates) = site(FULL_DESK);
|
||||
questions.get_mut("/desk").unwrap().qualifies = Some("staff".into());
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
assert!(
|
||||
warns(&findings).contains(&"the desk on /desk is gated twice: the page qualifies \"staff\", its list requires \"owners\", so only members of both see the buttons - fix: make them the same group"),
|
||||
"{findings:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finding_keeps_its_id_when_reworded_and_says_its_fix() {
|
||||
let (questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n");
|
||||
let findings = check(&needs(""), &questions, &aggregates);
|
||||
let strand = findings.iter().find(|f| f.code == "strand").unwrap();
|
||||
assert_eq!(strand.subject, "quoted");
|
||||
assert_eq!(strand.fix(), Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk"));
|
||||
let reworded = Finding { message: "something else entirely".into(), ..clone_of(strand) };
|
||||
assert_eq!(reworded.id(), strand.id());
|
||||
let elsewhere = Finding { subject: "open".into(), ..clone_of(strand) };
|
||||
assert_ne!(elsewhere.id(), strand.id());
|
||||
let json = strand.to_json();
|
||||
assert_eq!(json["code"], "strand");
|
||||
assert_eq!(json["level"], "fail");
|
||||
assert_eq!(json["id"], strand.id());
|
||||
}
|
||||
|
||||
fn clone_of(f: &Finding) -> Finding {
|
||||
Finding { need: f.need.clone(), level: f.level, message: f.message.clone(), scope: f.scope, code: f.code, subject: f.subject.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
+144
-6
@@ -251,6 +251,80 @@ pub fn vars_for(bucket: &str, id: &str, state: &str, responses: &serde_json::Val
|
||||
vars
|
||||
}
|
||||
|
||||
/// What every mail portal sends may say, whatever made it - a state
|
||||
/// mail, an invite, a suggestion's answer: `{site}` (the instance's
|
||||
/// public URL) and `{site_name}` (site.yaml's title). One place, so a
|
||||
/// template written for one kind of mail reads the same in another
|
||||
/// (docs/mail.md, Variables).
|
||||
pub fn site_vars(site: &crate::content::SiteConfig) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("site_name".into(), site_name(site).into());
|
||||
vars
|
||||
}
|
||||
|
||||
/// Where a mail about a record can send its reader (docs/mail.md):
|
||||
///
|
||||
/// - `{self_link}`: the record's own page for the person it belongs
|
||||
/// to - a page offering a self-service move on this bucket from the
|
||||
/// state the record is in, with the `?chain=&email=` that
|
||||
/// lets them act without signing in;
|
||||
/// - `{desk_link}`: the desk that moves the record - the page whose
|
||||
/// list over the bucket carries transitions, else one that reads it;
|
||||
/// - `{link}`: `{self_link}` in a mail to the submitter, `{desk_link}`
|
||||
/// in one to anyone else, and the site itself when there is neither.
|
||||
///
|
||||
/// Empty strings, never missing keys, for a link the content has no
|
||||
/// page for: a template naming one renders blank there rather than
|
||||
/// with a broken URL.
|
||||
pub fn record_links(
|
||||
questions: &std::collections::HashMap<String, crate::content::Question>,
|
||||
initial: &str,
|
||||
bucket: &str,
|
||||
id: &str,
|
||||
state: &str,
|
||||
email: &str,
|
||||
to_submitter: bool,
|
||||
) -> serde_json::Map<String, serde_json::Value> {
|
||||
let mut pages: Vec<&crate::content::Question> = questions.values().collect();
|
||||
pages.sort_by(|a, b| a.id.cmp(&b.id));
|
||||
// Only a move the engine would accept from where the record is:
|
||||
// a link to a button that refuses is worse than none.
|
||||
let self_page = pages.iter().find(|q| {
|
||||
q.alternatives
|
||||
.iter()
|
||||
.filter_map(|a| a.self_transition.as_ref())
|
||||
.any(|st| st.bucket == bucket && st.from_states(initial).iter().any(|f| f == state))
|
||||
});
|
||||
let reads = |q: &&crate::content::Question, moving: bool| {
|
||||
q.alternatives
|
||||
.iter()
|
||||
.flat_map(|a| &a.features)
|
||||
.filter_map(|f| f.resource.as_ref())
|
||||
.any(|r| r.bucket() == Some(bucket) && (!moving || !r.transitions.is_empty()))
|
||||
};
|
||||
let desk_page = pages.iter().find(|q| reads(q, true)).or_else(|| pages.iter().find(|q| reads(q, false)));
|
||||
let site = public_url();
|
||||
let self_link = match self_page {
|
||||
Some(q) if email.contains('@') => {
|
||||
let email: String = url::form_urlencoded::byte_serialize(email.as_bytes()).collect();
|
||||
format!("{site}{}?chain={id}&email={email}", q.id)
|
||||
}
|
||||
_ => String::new(),
|
||||
};
|
||||
let desk_link = desk_page.map(|q| format!("{site}{}", q.id)).unwrap_or_default();
|
||||
let link = match (to_submitter, self_link.is_empty(), desk_link.is_empty()) {
|
||||
(true, false, _) => self_link.clone(),
|
||||
(false, _, false) => desk_link.clone(),
|
||||
_ => site,
|
||||
};
|
||||
let mut vars = serde_json::Map::new();
|
||||
vars.insert("self_link".into(), self_link.into());
|
||||
vars.insert("desk_link".into(), desk_link.into());
|
||||
vars.insert("link".into(), link.into());
|
||||
vars
|
||||
}
|
||||
|
||||
/// The record field a `to` reaches through, when it is neither
|
||||
/// `submitter` nor an address: `{notice.email}` gives `notice`.
|
||||
pub fn parent_of(to: &str) -> Option<&str> {
|
||||
@@ -325,6 +399,13 @@ pub async fn on_state(state: &crate::server::AppState, bucket: &str, id: &str, t
|
||||
};
|
||||
let mut vars = vars_for(bucket, id, to_state, responses);
|
||||
parent_vars(state, bucket, responses, &mut vars).await;
|
||||
vars.extend(site_vars(site));
|
||||
{
|
||||
let email = vars.get("email").and_then(|v| v.as_str()).unwrap_or_default().to_string();
|
||||
let questions = state.questions.load();
|
||||
let initial = state.aggregates.load().at(storyline).get(bucket).map(|s| s.initial.clone()).unwrap_or_default();
|
||||
vars.extend(record_links(questions.at(storyline), &initial, bucket, id, to_state, &email, spec.to_submitter()));
|
||||
}
|
||||
let to = if spec.to_submitter() {
|
||||
match vars.get("email").and_then(|v| v.as_str()).filter(|e| e.contains('@')) {
|
||||
Some(e) => e.to_string(),
|
||||
@@ -416,12 +497,19 @@ pub async fn on_responsible(state: &crate::server::AppState, invited: &crate::pe
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
|
||||
.unwrap_or_default();
|
||||
let mut vars = serde_json::Map::new();
|
||||
// The form's own fields first, so the invite's fixed placeholders
|
||||
// win a clash; a date and time reads as one.
|
||||
for (key, value) in &invited.fields {
|
||||
let text = value.as_str().map(|v| spoken_datetime(v, norwegian).unwrap_or_else(|| v.to_string()));
|
||||
if let Some(text) = text {
|
||||
vars.insert(key.clone(), text.into());
|
||||
}
|
||||
}
|
||||
vars.insert("name".into(), invited.display_name.clone().into());
|
||||
vars.insert("username".into(), invited.username.clone().into());
|
||||
vars.insert("questions".into(), list.into());
|
||||
vars.extend(site_vars(&site));
|
||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("site_name".into(), site_name(&site).into());
|
||||
vars.insert("expires".into(), expires.into());
|
||||
let text = render(&body, &vars);
|
||||
let mail = Outgoing {
|
||||
@@ -493,7 +581,7 @@ async fn on_suggestion_state(state: &crate::server::AppState, id: &str, to_state
|
||||
return;
|
||||
}
|
||||
let quoted: String = field("text").lines().map(|l| format!("> {l}")).collect::<Vec<_>>().join("\n");
|
||||
let mut vars = serde_json::Map::new();
|
||||
let mut vars = site_vars(&site);
|
||||
vars.insert("name".into(), field("name").into());
|
||||
vars.insert("question".into(), field("question").into());
|
||||
vars.insert("page".into(), format!("{}{}", public_url(), field("page")).into());
|
||||
@@ -553,6 +641,25 @@ pub async fn on_edit_suggestion(state: &crate::server::AppState, question_id: &s
|
||||
/// Built in, since portal knows everything about it; `mail.invite` in
|
||||
/// site.yaml rewords it. `{name}`, `{email}`, `{group}`, `{link}`,
|
||||
/// `{site}`, `{expires}`.
|
||||
/// A form's date and time (`2026-10-06T15:00`, from a `datetime-local`
|
||||
/// field) as a person says it: "tirsdag 6. oktober kl. 15:00", "Tuesday
|
||||
/// 6 October at 15:00". Anything else is left as it is.
|
||||
pub fn spoken_datetime(value: &str, norwegian: bool) -> Option<String> {
|
||||
use chrono::{Datelike, NaiveDateTime, Timelike};
|
||||
let t = NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M").ok()?;
|
||||
let day = t.weekday().num_days_from_monday() as usize;
|
||||
let month = t.month0() as usize;
|
||||
Some(if norwegian {
|
||||
const DAYS: [&str; 7] = ["mandag", "tirsdag", "onsdag", "torsdag", "fredag", "lørdag", "søndag"];
|
||||
const MONTHS: [&str; 12] = ["januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember"];
|
||||
format!("{} {}. {} kl. {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
|
||||
} else {
|
||||
const DAYS: [&str; 7] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
||||
const MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
|
||||
format!("{} {} {} at {:02}:{:02}", DAYS[day], t.day(), MONTHS[month], t.hour(), t.minute())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people::Invited) {
|
||||
let site = state.site.load();
|
||||
let Some(cfg) = &site.mail else {
|
||||
@@ -560,7 +667,7 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
||||
return;
|
||||
};
|
||||
let norwegian = site.lang.as_deref().is_some_and(|l| l.starts_with("no") || l.starts_with("nb") || l.starts_with("nn"));
|
||||
let (subject, body) = match (&cfg.invite, invited.reset_link.is_some(), norwegian) {
|
||||
let (subject, body) = match (invited.mail.as_ref().or(cfg.invite.as_ref()), invited.reset_link.is_some(), norwegian) {
|
||||
(Some(t), _, _) => (t.subject.clone(), t.body.clone()),
|
||||
(None, true, true) => (
|
||||
"Du er invitert inn i {group} på {site_name}".to_string(),
|
||||
@@ -590,9 +697,8 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
||||
vars.insert("username".into(), invited.username.clone().into());
|
||||
vars.insert("email".into(), invited.email.clone().into());
|
||||
vars.insert("group".into(), invited.group.clone().into());
|
||||
vars.extend(site_vars(&site));
|
||||
vars.insert("link".into(), invited.reset_link.clone().unwrap_or_default().into());
|
||||
vars.insert("site".into(), public_url().into());
|
||||
vars.insert("site_name".into(), site_name(&site).into());
|
||||
vars.insert("expires".into(), expires.into());
|
||||
let text = render(&body, &vars);
|
||||
let mail = Outgoing {
|
||||
@@ -613,6 +719,30 @@ pub async fn on_invite(state: &crate::server::AppState, invited: &crate::people:
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_record_mail_links_its_reader_to_where_they_act() {
|
||||
let files = vec![
|
||||
("index.yaml".to_string(), "name: Meld fra\nalternatives:\n - name: Feil\n action: /takk\n record_as: feil\n features:\n - name: Feil\n requirements:\n - { name: email, type: email }\n".to_string()),
|
||||
("takk.yaml".to_string(), "name: Takk\nfollowup: true\n".to_string()),
|
||||
("trekk.yaml".to_string(), "name: Trekk saken\nalternatives:\n - name: Trekk\n self_transition: { bucket: feil, to: trukket, label: Trekk, from: [open, befaring] }\n".to_string()),
|
||||
("styret/index.yaml".to_string(), "name: Styret\nqualifies: styret\nalternatives:\n - name: Feil\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: feil }\n requires_group: styret\n transitions:\n - { to: befaring, label: Befaring }\n".to_string()),
|
||||
];
|
||||
let questions = crate::content::build_questions(&files).unwrap();
|
||||
let site = public_url();
|
||||
let to_tenant = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", true);
|
||||
assert_eq!(to_tenant["self_link"], format!("{site}/trekk?chain=abc&email=kari%40x.no"));
|
||||
assert_eq!(to_tenant["desk_link"], format!("{site}/styret"));
|
||||
assert_eq!(to_tenant["link"], to_tenant["self_link"]);
|
||||
let to_board = record_links(&questions, "open", "feil", "abc", "befaring", "kari@x.no", false);
|
||||
assert_eq!(to_board["link"], to_board["desk_link"]);
|
||||
// Past where the self-service move can be made: the person has
|
||||
// no page of their own, so `{link}` is the site.
|
||||
let after = record_links(&questions, "open", "feil", "abc", "utbedret", "kari@x.no", true);
|
||||
assert_eq!(after["self_link"], "");
|
||||
assert_eq!(after["link"], site);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -662,6 +792,13 @@ mod tests {
|
||||
assert_eq!(vars["state"], "fixed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_date_and_time_from_a_form_reads_as_a_person_says_it() {
|
||||
assert_eq!(spoken_datetime("2026-10-06T15:00", true).as_deref(), Some("tirsdag 6. oktober kl. 15:00"));
|
||||
assert_eq!(spoken_datetime("2026-10-06T15:00", false).as_deref(), Some("Tuesday 6 October at 15:00"));
|
||||
assert_eq!(spoken_datetime("Anders Kluge", true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_html_alternative_keeps_markdown_and_drops_raw_html() {
|
||||
let html = to_html("Hello **there**\n\n<script>x()</script>\n\n[link](https://x.no)");
|
||||
@@ -709,6 +846,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+67
-44
@@ -1,5 +1,6 @@
|
||||
/// One binary, three jobs: `portal` (or `portal serve`) runs a site;
|
||||
/// `portal lint` checks a content repo the way the site reads it;
|
||||
/// `portal iris` checks a content repo the way the site reads it (the
|
||||
/// iris keeps bad content out of the portal; `portal lint` is the same);
|
||||
/// `portal plan`, `build`, `report` and the rest train one. The lint
|
||||
/// and the trainer are the same code as the server, at the same
|
||||
/// version, so they never disagree about what content means.
|
||||
@@ -8,7 +9,7 @@
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match argv.first().map(String::as_str) {
|
||||
Some("lint") => return portal::lint::run(argv[1..].to_vec()).await,
|
||||
Some("iris" | "lint") => return portal::lint::run(argv[1..].to_vec()).await,
|
||||
Some("plan" | "build" | "answer" | "rate" | "prefer" | "score" | "simulate" | "compare" | "report" | "generate") => {
|
||||
// The trainer runs models through shell commands and waits
|
||||
// on them; off the runtime's threads, as a CLI should.
|
||||
@@ -19,7 +20,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
Some("serve") | None => {}
|
||||
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, lint, plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
|
||||
Some(other) => anyhow::bail!("portal: unknown command {other:?}; one of serve, iris (or lint), plan, build, answer, rate, prefer, score, simulate, compare, report, generate"),
|
||||
}
|
||||
serve().await
|
||||
}
|
||||
@@ -132,6 +133,7 @@ async fn serve() -> anyhow::Result<()> {
|
||||
// windows (see announce.rs).
|
||||
tokio::spawn(portal::announce::run(state.clone()));
|
||||
tokio::spawn(portal::deadlines::run(state.clone()));
|
||||
tokio::spawn(portal::deadlines::run_leaving(state.clone()));
|
||||
// The accounts the content mandates: the first person in, and
|
||||
// everyone a page names as responsible, at start and whenever the
|
||||
// content changes (seed.rs).
|
||||
@@ -166,30 +168,32 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.with_name("portal_session");
|
||||
|
||||
// What a person does costs a little; what a script does costs a
|
||||
// lot. A burst of 30 with one refilled every two seconds is well
|
||||
// above anything a visitor filling in a form will reach - a page
|
||||
// load fetches several resources at once - and well below what it
|
||||
// takes to be a nuisance. Per caller IP, in memory, no store: this
|
||||
// is a fence, not an accounting system, and the fence that matters
|
||||
// for uploads is the policy check on the route itself.
|
||||
// lot. More than a hundred requests in ten seconds is a script: a
|
||||
// burst of 100, refilled at ten a second. Someone reloading a page
|
||||
// again and again stays well inside it, the more so since the
|
||||
// files a page is made of (stylesheet, scripts, fonts, pictures)
|
||||
// are not counted at all - see `assets` below. Per caller IP, in
|
||||
// memory, no store: this is a fence, not an accounting system, and
|
||||
// the fence that matters for uploads is the policy check on the
|
||||
// route itself.
|
||||
//
|
||||
// PORTAL_RATE_PER_SECOND / PORTAL_RATE_BURST retune it per site;
|
||||
// 0 for either turns it off, for an instance behind something that
|
||||
// already does this.
|
||||
let rate_seconds: u64 = std::env::var("PORTAL_RATE_SECONDS")
|
||||
let rate_per_second: u64 = std::env::var("PORTAL_RATE_PER_SECOND")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(2);
|
||||
.unwrap_or(10);
|
||||
let rate_burst: u32 = std::env::var("PORTAL_RATE_BURST")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(30);
|
||||
let governor = (rate_seconds > 0 && rate_burst > 0).then(|| {
|
||||
.unwrap_or(100);
|
||||
let governor = (rate_per_second > 0 && rate_burst > 0).then(|| {
|
||||
// SmartIpKeyExtractor reads X-Forwarded-For/X-Real-IP, which is
|
||||
// what every one of these instances is behind; it falls back to
|
||||
// the socket address when neither is set.
|
||||
let conf = tower_governor::governor::GovernorConfigBuilder::default()
|
||||
.per_second(rate_seconds)
|
||||
.per_millisecond((1000 / rate_per_second).max(1))
|
||||
.burst_size(rate_burst)
|
||||
.key_extractor(tower_governor::key_extractor::SmartIpKeyExtractor)
|
||||
.finish()
|
||||
@@ -197,8 +201,8 @@ async fn serve() -> anyhow::Result<()> {
|
||||
std::sync::Arc::new(conf)
|
||||
});
|
||||
match &governor {
|
||||
Some(_) => tracing::info!(rate_seconds, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_SECONDS or PORTAL_RATE_BURST is 0)"),
|
||||
Some(_) => tracing::info!(rate_per_second, rate_burst, "rate limiting per caller IP"),
|
||||
None => tracing::warn!("rate limiting is off (PORTAL_RATE_PER_SECOND or PORTAL_RATE_BURST is 0)"),
|
||||
}
|
||||
|
||||
async fn server_fn_handler(
|
||||
@@ -231,6 +235,51 @@ async fn serve() -> anyhow::Result<()> {
|
||||
let maplibre_js = format!("{}/maplibre-gl.js", leptos_options.site_root);
|
||||
let maplibre_css = format!("{}/maplibre-gl.css", leptos_options.site_root);
|
||||
let fonts_dir = format!("{}/fonts", leptos_options.site_root);
|
||||
let page_motion = format!("{}/page-motion.js", leptos_options.site_root);
|
||||
|
||||
// The files a page is made of, from this disk: the stylesheet, the
|
||||
// scripts, the fonts. Not counted by the rate limit - they cost
|
||||
// next to nothing, and refusing one is what turns a reloaded page
|
||||
// into an unstyled mess - and no session is loaded for them.
|
||||
// Anything that makes portal ask someone else (content pictures
|
||||
// from Gitea, map tiles) stays below, counted.
|
||||
let assets: Router<AppState> = Router::new()
|
||||
// no-cache = "revalidate before reuse", not "don't cache":
|
||||
// pkg files keep the same names across releases (portal.js,
|
||||
// portal.wasm), and without this browsers heuristically cache
|
||||
// them - a stale wasm from the previous release then talks to
|
||||
// a server whose server-fn wire format has moved on and every
|
||||
// page renders as its error branch. A 304 per load is the
|
||||
// price of never shipping that skew again.
|
||||
.nest_service(
|
||||
"/pkg",
|
||||
tower::ServiceBuilder::new()
|
||||
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
axum::http::HeaderValue::from_static("no-cache"),
|
||||
))
|
||||
.service(ServeDir::new(pkg_dir)),
|
||||
)
|
||||
.nest_service("/fonts", ServeDir::new(fonts_dir))
|
||||
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
|
||||
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
|
||||
.route_service("/wordmark.svg", ServeFile::new(wordmark_path))
|
||||
.route_service(
|
||||
"/swiper-element-bundle.min.js",
|
||||
ServeFile::new(swiper_path),
|
||||
)
|
||||
// Same name every release, so revalidated like /pkg.
|
||||
.route_service(
|
||||
"/page-motion.js",
|
||||
tower::ServiceBuilder::new()
|
||||
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
axum::http::HeaderValue::from_static("no-cache"),
|
||||
))
|
||||
.service(ServeFile::new(page_motion)),
|
||||
)
|
||||
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
|
||||
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css));
|
||||
|
||||
let app = Router::new()
|
||||
.route("/auth/login", get(oidc::login))
|
||||
@@ -260,32 +309,6 @@ async fn serve() -> anyhow::Result<()> {
|
||||
.route("/map/style.json", get(portal::maps::style))
|
||||
.route("/map/tiles/{z}/{x}/{y}", get(portal::maps::tile))
|
||||
.route("/map/still/{place}/{size}", get(portal::maps::still))
|
||||
// no-cache = "revalidate before reuse", not "don't cache":
|
||||
// pkg files keep the same names across releases (portal.js,
|
||||
// portal.wasm), and without this browsers heuristically cache
|
||||
// them - a stale wasm from the previous release then talks to
|
||||
// a server whose server-fn wire format has moved on and every
|
||||
// page renders as its error branch. A 304 per load is the
|
||||
// price of never shipping that skew again.
|
||||
.nest_service(
|
||||
"/pkg",
|
||||
tower::ServiceBuilder::new()
|
||||
.layer(tower_http::set_header::SetResponseHeaderLayer::overriding(
|
||||
axum::http::header::CACHE_CONTROL,
|
||||
axum::http::HeaderValue::from_static("no-cache"),
|
||||
))
|
||||
.service(ServeDir::new(pkg_dir)),
|
||||
)
|
||||
.nest_service("/fonts", ServeDir::new(fonts_dir))
|
||||
.route_service("/favicon-light.svg", ServeFile::new(favicon_light_path))
|
||||
.route_service("/favicon-dark.svg", ServeFile::new(favicon_dark_path))
|
||||
.route_service("/wordmark.svg", ServeFile::new(wordmark_path))
|
||||
.route_service(
|
||||
"/swiper-element-bundle.min.js",
|
||||
ServeFile::new(swiper_path),
|
||||
)
|
||||
.route_service("/maplibre-gl.js", ServeFile::new(maplibre_js))
|
||||
.route_service("/maplibre-gl.css", ServeFile::new(maplibre_css))
|
||||
.leptos_routes_with_context(
|
||||
&state,
|
||||
routes,
|
||||
@@ -307,8 +330,8 @@ async fn serve() -> anyhow::Result<()> {
|
||||
let app = match governor {
|
||||
Some(conf) => app.layer(tower_governor::GovernorLayer::new(conf)),
|
||||
None => app,
|
||||
}
|
||||
.with_state(state);
|
||||
};
|
||||
let app = assets.merge(app).with_state(state);
|
||||
|
||||
tracing::info!("listening on http://{addr}");
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
@@ -254,6 +254,8 @@ pub async fn grant(state: &crate::server::AppState, group: &str, name: &str, who
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member: !created,
|
||||
};
|
||||
match existing {
|
||||
@@ -351,6 +353,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,12 @@ pub struct Invited {
|
||||
/// the membership is not portal's to take back.
|
||||
#[serde(default)]
|
||||
pub already_member: bool,
|
||||
/// The invite's own mail and the form it was sent from, kept with
|
||||
/// the invite so that sending it again sends the same words.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mail: Option<crate::content::MailTemplate>,
|
||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||
pub fields: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// When a link runs out, from Kanidm's `expiry_time`: seconds since
|
||||
@@ -367,6 +373,8 @@ impl Kanidm {
|
||||
reset_link,
|
||||
link_expires_ms,
|
||||
created,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member,
|
||||
})
|
||||
}
|
||||
@@ -714,6 +722,7 @@ mod tests {
|
||||
decided_ms: None,
|
||||
decided_by: None,
|
||||
notes: Vec::new(),
|
||||
finished: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+137
-3
@@ -328,6 +328,11 @@ async fn fetch_resource_value(
|
||||
}
|
||||
}
|
||||
}
|
||||
if !resource.transitions.is_empty() {
|
||||
if let Some(schema) = state.aggregates.load().at(0).get(bucket) {
|
||||
items = cap_finished(items, schema, finished_wanted(params));
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(items)
|
||||
}
|
||||
}
|
||||
@@ -342,12 +347,67 @@ async fn fetch_resource_value(
|
||||
ResourceSource::Url { url } => fetch_url_json(url, params).await?,
|
||||
};
|
||||
|
||||
match &resource.jq {
|
||||
Some(filter) => apply_jq(filter, &value),
|
||||
None => Ok(value),
|
||||
let shaped = match &resource.jq {
|
||||
Some(filter) => apply_jq(filter, &value)?,
|
||||
None => value,
|
||||
};
|
||||
Ok(apply_limit(resource.limit, shaped))
|
||||
}
|
||||
|
||||
/// Keep at most `n` items, after `jq` has had its say.
|
||||
///
|
||||
/// Anything that is not a list passes through: a single record has no
|
||||
/// length to cap, and silently turning one into an empty list would be
|
||||
/// worse than ignoring a limit that cannot apply. A limit of zero does
|
||||
/// mean nothing — the resource's `empty` line speaks instead, which is
|
||||
/// a coherent thing for content to ask for.
|
||||
#[cfg(feature = "ssr")]
|
||||
fn apply_limit(limit: Option<usize>, value: serde_json::Value) -> serde_json::Value {
|
||||
let Some(n) = limit else { return value };
|
||||
match value {
|
||||
serde_json::Value::Array(mut items) => {
|
||||
items.truncate(n);
|
||||
serde_json::Value::Array(items)
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many finished records a desk listing should show: three, or
|
||||
/// what the page asked for with `__finished` after "show older".
|
||||
pub const FINISHED_PARAM: &str = "__finished";
|
||||
pub const FINISHED_SHOWN: usize = 3;
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
fn finished_wanted(params: &std::collections::HashMap<String, String>) -> usize {
|
||||
params.get(FINISHED_PARAM).and_then(|n| n.parse().ok()).unwrap_or(FINISHED_SHOWN)
|
||||
}
|
||||
|
||||
/// A desk is for the cases still moving. Every record still open stays;
|
||||
/// of those in a final state (no move out), only the `shown` most
|
||||
/// recently decided, plus one more so the page knows there are older
|
||||
/// ones to offer. Each finished record is marked, so the page can tell
|
||||
/// them apart without knowing the state machine.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub(crate) fn cap_finished(items: Vec<serde_json::Value>, schema: &crate::aggregates::AggregateSchema, shown: usize) -> Vec<serde_json::Value> {
|
||||
let is_final = |v: &serde_json::Value| {
|
||||
v.get("state").and_then(|s| s.as_str()).is_some_and(|s| schema.has_state(s) && schema.allowed(s).is_empty())
|
||||
};
|
||||
let when = |v: &serde_json::Value| {
|
||||
v.get("decided_ms").and_then(|d| d.as_i64()).or_else(|| v.get("submitted_ms").and_then(|d| d.as_i64())).unwrap_or(0)
|
||||
};
|
||||
let (mut finished, mut open): (Vec<_>, Vec<_>) = items.into_iter().partition(is_final);
|
||||
finished.sort_by_key(|v| std::cmp::Reverse(when(v)));
|
||||
finished.truncate(shown + 1);
|
||||
for v in &mut finished {
|
||||
if let Some(obj) = v.as_object_mut() {
|
||||
obj.insert("finished".into(), true.into());
|
||||
}
|
||||
}
|
||||
open.extend(finished);
|
||||
open
|
||||
}
|
||||
|
||||
/// Fetches JSON from a path on the same Gitea instance content is
|
||||
/// loaded from (`AppState.gitea_base`), attaching `GITEA_API_TOKEN` if
|
||||
/// set - confirmed live against `project.uhhm.no` that at least the
|
||||
@@ -624,6 +684,51 @@ mod tests {
|
||||
assert!(!matches_filter(&fixed, &record("open", serde_json::json!({ "which_authority": "Fire service" })), ¶ms));
|
||||
}
|
||||
|
||||
/// `limit` caps what the page renders, and does it after `jq` so
|
||||
/// content can order with the filter and take the top of its own
|
||||
/// ordering. The releases card is the case: the newest cut is the
|
||||
/// one you should run, and a page that grows a card per release
|
||||
/// gets longer forever.
|
||||
#[test]
|
||||
fn limit_caps_a_list_after_the_filter() {
|
||||
let releases = serde_json::json!([
|
||||
{"name": "redoal 0.1.17", "tag_name": "v0.1.17", "body": "newest"},
|
||||
{"name": "redoal 0.1.16", "tag_name": "v0.1.16", "body": "older"},
|
||||
{"name": "redoal 0.1.15", "tag_name": "v0.1.15", "body": "oldest"}
|
||||
]);
|
||||
// The filter redoal/questions actually declares: bare objects,
|
||||
// never a wrapped array — the frontend re-wraps.
|
||||
let filter = ".[] | {name: .name, description: .body, tag: .tag_name, url: null}";
|
||||
let shaped = apply_jq(filter, &releases).expect("filter runs");
|
||||
|
||||
let all = apply_limit(None, shaped.clone());
|
||||
assert_eq!(all.as_array().map(Vec::len), Some(3), "no limit shows everything");
|
||||
|
||||
let latest = apply_limit(Some(1), shaped.clone());
|
||||
let rows = latest.as_array().expect("still a list");
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0]["tag"], "v0.1.17", "and it is the newest, which Gitea returns first");
|
||||
|
||||
let two = apply_limit(Some(2), shaped.clone());
|
||||
assert_eq!(two.as_array().map(Vec::len), Some(2));
|
||||
|
||||
// Asking for more than there is is not an error.
|
||||
let plenty = apply_limit(Some(99), shaped);
|
||||
assert_eq!(plenty.as_array().map(Vec::len), Some(3));
|
||||
}
|
||||
|
||||
/// A limit that cannot apply is ignored rather than guessed at.
|
||||
#[test]
|
||||
fn limit_leaves_a_single_record_alone() {
|
||||
let one = serde_json::json!({"name": "a record", "state": "open"});
|
||||
assert_eq!(apply_limit(Some(1), one.clone()), one);
|
||||
assert_eq!(apply_limit(Some(0), one.clone()), one, "no length to cap");
|
||||
|
||||
// Zero on a list does mean nothing — the `empty` line speaks.
|
||||
let list = serde_json::json!([{"a": 1}, {"a": 2}]);
|
||||
assert_eq!(apply_limit(Some(0), list).as_array().map(Vec::len), Some(0));
|
||||
}
|
||||
|
||||
/// A canned, Gitea-API-shaped fixture - the same fields the real
|
||||
/// `/users/{username}/starred` endpoint returns (Gitea 1.27:
|
||||
/// `stars_count`, not GitHub's `stargazers_count`; `website` is ""
|
||||
@@ -758,3 +863,32 @@ mod tests {
|
||||
assert_eq!(out, serde_json::json!(["id: /x\n"]));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "ssr"))]
|
||||
mod finished_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_desk_keeps_every_open_case_and_the_newest_finished_ones() {
|
||||
let schema = crate::aggregates::parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: leads\n initial: open\n states:\n open: { event: in }\n won: { event: won }\n lost: { event: lost }\n transitions:\n open: [won, lost]\n",
|
||||
)
|
||||
.unwrap()
|
||||
.remove("leads")
|
||||
.unwrap();
|
||||
let rec = |id: &str, state: &str, decided: i64| serde_json::json!({ "id": id, "state": state, "submitted_ms": 0, "decided_ms": decided });
|
||||
let mut items = vec![rec("o1", "open", 0), rec("o2", "open", 0)];
|
||||
items.extend((1..=6).map(|n| rec(&format!("f{n}"), if n % 2 == 0 { "won" } else { "lost" }, n)));
|
||||
let ids = |v: &[serde_json::Value]| v.iter().map(|x| x["id"].as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
|
||||
let shown = cap_finished(items.clone(), &schema, 3);
|
||||
// Both open cases, then the three newest finished and one more
|
||||
// to tell the page there are older ones.
|
||||
assert_eq!(ids(&shown), ["o1", "o2", "f6", "f5", "f4", "f3"]);
|
||||
assert!(shown[2..].iter().all(|v| v["finished"] == true));
|
||||
assert!(shown[..2].iter().all(|v| v.get("finished").is_none()));
|
||||
|
||||
// Asking for all of them: no extra, so no "show older".
|
||||
assert_eq!(cap_finished(items, &schema, 6).len(), 8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,6 +410,8 @@ mod tests {
|
||||
reset_link: None,
|
||||
link_expires_ms: None,
|
||||
created: true,
|
||||
mail: None,
|
||||
fields: Default::default(),
|
||||
already_member,
|
||||
},
|
||||
)
|
||||
|
||||
+262
-23
@@ -95,6 +95,13 @@ pub struct Plan {
|
||||
/// from its `wants`.
|
||||
#[serde(default)]
|
||||
pub does: Vec<Does>,
|
||||
/// What the business stands on, in its own words: values, the
|
||||
/// experience behind it. Each is a feature of the front door's
|
||||
/// "who we are" card, beside the needs that `present` - the people
|
||||
/// who work there. Nothing rewords the facts; the words model only
|
||||
/// heads them.
|
||||
#[serde(default)]
|
||||
pub values: Vec<Does>,
|
||||
/// Things a visitor may look at before asking for anything - the
|
||||
/// window shopping: a list of what has been built, releases, a
|
||||
/// live count - each a portal resource block, passed through as
|
||||
@@ -206,7 +213,7 @@ pub fn sections_of(markdown: &str) -> Vec<Section> {
|
||||
let mut heading: Option<String> = None;
|
||||
let mut paragraphs: Vec<String> = Vec::new();
|
||||
let mut current: Vec<&str> = Vec::new();
|
||||
let mut close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
|
||||
let close = |heading: &mut Option<String>, paragraphs: &mut Vec<String>, out: &mut Vec<Section>| {
|
||||
if let Some(h) = heading.take() {
|
||||
if !paragraphs.is_empty() {
|
||||
out.push(Section { heading: h, text: std::mem::take(paragraphs) });
|
||||
@@ -411,6 +418,34 @@ pub struct PlanNeed {
|
||||
/// taken down; a register shows what was decided.
|
||||
#[serde(default)]
|
||||
pub public_states: Vec<String>,
|
||||
/// Its records are something the business shows, not cases it
|
||||
/// works: the people who work there, what it has built. With
|
||||
/// `watched_by: [public]`, the front door shows every one of them
|
||||
/// in the "who we are" card, and a `picture` field shows as the
|
||||
/// picture.
|
||||
#[serde(default)]
|
||||
pub presents: bool,
|
||||
/// Not taking new cases for now: full up, a season closed. The
|
||||
/// card stays where it is, saying what the business does and that
|
||||
/// it is paused, with no form to fill in and a button that does not
|
||||
/// press; the server refuses a submission. Remove the line to open
|
||||
/// again - the desk, the stages and every case under way are
|
||||
/// untouched either way.
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
/// Each person in the group that enters it keeps one record: their
|
||||
/// own card, which they open filled in and send again to change.
|
||||
/// For what people say about themselves - who they are on the
|
||||
/// front page. Needs `enters_as`, so there is someone signed in to
|
||||
/// own it.
|
||||
#[serde(default)]
|
||||
pub one_per_person: bool,
|
||||
/// How long a record stays in the lists once it reaches a stage,
|
||||
/// when not for ever: `{ sluttet: { minutes: 10 } }` takes a card
|
||||
/// off the desk's list ten minutes after it came off the front
|
||||
/// page, instead of leaving it there greyed out.
|
||||
#[serde(default)]
|
||||
pub leaves_after: BTreeMap<String, crate::aggregates::LeavesSpec>,
|
||||
pub collects: Vec<Field>,
|
||||
/// How many required fields this person will tolerate: what the
|
||||
/// business said about them, not what the form happens to ask.
|
||||
@@ -488,7 +523,7 @@ impl Plan {
|
||||
}
|
||||
}
|
||||
|
||||
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file"];
|
||||
const FIELD_TYPES: &[&str] = &["text", "email", "tel", "textarea", "date", "select", "location", "file", "picture"];
|
||||
|
||||
fn is_id(s: &str) -> bool {
|
||||
!s.is_empty() && s.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
|
||||
@@ -629,8 +664,14 @@ impl Plan {
|
||||
// small application and the firm's large one, and is one
|
||||
// page either way. A bucket only a role ever writes to has
|
||||
// its group's desk and nothing to publish.
|
||||
// Unless it presents - the people are entered from inside
|
||||
// precisely so that everyone can see them - or publishes:
|
||||
// a board's list whose public view is only the stages it
|
||||
// has published (`public_states`) is announcements.
|
||||
if need.watched_by.iter().any(|g| g == PUBLIC)
|
||||
&& need.enters_as.is_some()
|
||||
&& !need.presents
|
||||
&& need.public_states.is_empty()
|
||||
&& !self
|
||||
.needs
|
||||
.iter()
|
||||
@@ -731,6 +772,18 @@ impl Plan {
|
||||
bail!(at(&format!("deadline on {state:?} goes to {:?}, which is not a move the state machine has from there", d.to)));
|
||||
}
|
||||
}
|
||||
if let Some(bad) = need.leaves_after.keys().find(|st| !need.stages.contains(st)) {
|
||||
bail!(at(&format!("leaves_after names {bad:?}, which is not one of its stages")));
|
||||
}
|
||||
if need.leaves_after.values().any(|l| l.millis() == 0) {
|
||||
bail!(at("leaves_after needs some days, hours or minutes"));
|
||||
}
|
||||
if need.one_per_person && need.enters_as.is_none() {
|
||||
bail!(at("one_per_person keeps a card for each signed-in person, and the need has no enters_as to sign them in"));
|
||||
}
|
||||
if need.presents && !need.watched_by.iter().any(|g| g == PUBLIC) {
|
||||
bail!(at("presents shows its records to everyone, and the need has no `public` in watched_by"));
|
||||
}
|
||||
if !need.public_states.is_empty() {
|
||||
if !need.watched_by.iter().any(|g| g == PUBLIC) {
|
||||
bail!(at("public_states says what the public list shows, and the need has no `public` in watched_by"));
|
||||
@@ -1062,6 +1115,8 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
"button": "Send",
|
||||
"encouragement": "",
|
||||
"icon": "",
|
||||
// Said after the description, on a paused need's card only.
|
||||
"paused": if need.paused { loc(lang, "We are not taking new ones right now, and open again as soon as we can.", "Akkurat nå tar vi ikke imot nye; vi åpner igjen så snart vi kan.") } else { String::new() },
|
||||
// Three sections of context the plan already knows: what
|
||||
// the person must have for this to work, the road a case
|
||||
// travels, and who is at the other end. The fallbacks are
|
||||
@@ -1113,7 +1168,7 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
};
|
||||
let mut shopping: Map<String, Value> =
|
||||
plan.shows.iter().map(|s| (s.id.clone(), json!({ "heading": humanize(&s.id), "description": s.text, "icon": "" }))).collect();
|
||||
for (bucket, needs) in public_buckets(plan) {
|
||||
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) {
|
||||
shopping.insert(
|
||||
format!("status-{bucket}"),
|
||||
json!({
|
||||
@@ -1125,12 +1180,31 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap<String, Value> {
|
||||
}),
|
||||
);
|
||||
}
|
||||
if has_who_page(plan) {
|
||||
words.insert(
|
||||
who_page_path(plan),
|
||||
json!({
|
||||
"question": loc(lang, "Who are we?", "Hvem er vi?"),
|
||||
"description": "",
|
||||
"presents": presenting_buckets(plan).into_iter().map(|b| (b.to_string(), json!({
|
||||
"heading": humanize(b), "description": "", "empty": loc(lang, "Nothing here yet.", "Ingenting her ennå."),
|
||||
}))).collect::<Map<String, Value>>(),
|
||||
"values": {
|
||||
"heading": loc(lang, "What we stand for", "Det vi står for"),
|
||||
"description": "",
|
||||
"items": plan.values.iter().map(|v| (v.id.clone(), json!({ "heading": humanize(&v.id), "description": v.text, "icon": "" }))).collect::<Map<String, Value>>(),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
words.insert(
|
||||
"questions/index.yaml".to_string(),
|
||||
json!({
|
||||
"question": "What brings you here?",
|
||||
"description": plan.about,
|
||||
"opener": { "heading": "What we do", "description": "", "features": does },
|
||||
// The way to the page about who the business is.
|
||||
"who": { "heading": loc(lang, "Who we are", "Hvem vi er"), "description": "", "button": loc(lang, "Meet us", "Bli kjent med oss") },
|
||||
// Named for what it shows, never an invitation to look.
|
||||
"shopping": { "heading": format!("{} {}", loc(lang, "Latest from", "Siste fra"), plan.business), "description": "", "items": shopping },
|
||||
// A door's feature says what is behind it, from the needs
|
||||
@@ -1589,9 +1663,13 @@ const FRONT_DOOR_LATEST: usize = 3;
|
||||
/// and at most `limit` records when given. Portal filters by `states`
|
||||
/// while listing, before `jq` runs, so the limit counts shown records.
|
||||
fn public_resource(bucket: &str, needs: &[&PlanNeed], empty: &str, limit: Option<usize>, answer: &str) -> Value {
|
||||
let mut shown: BTreeSet<&str> = BTreeSet::new();
|
||||
for n in needs {
|
||||
shown.extend(n.collects.iter().filter(|f| f.is_public()).map(|f| f.name.as_str()));
|
||||
// In the order the form asks for them: the first is what a row, or
|
||||
// a card, is headed by.
|
||||
let mut shown: Vec<&str> = Vec::new();
|
||||
for f in needs.iter().flat_map(|n| n.collects.iter()).filter(|f| f.is_public()) {
|
||||
if !shown.contains(&f.name.as_str()) {
|
||||
shown.push(f.name.as_str());
|
||||
}
|
||||
}
|
||||
let take = limit.map(|n| format!(" | .[:{n}]")).unwrap_or_default();
|
||||
// One record per yielded value: portal collects what the filter
|
||||
@@ -1864,18 +1942,23 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
|
||||
let parent = plan.parent_of(need);
|
||||
let mut requirements: Vec<Value> = need.collects.iter().map(|f| {
|
||||
let mut r = json!({ "name": f.name, "label": text(&w["labels"][&f.name]), "type": f.kind });
|
||||
// A picture is a file the browser can show: a phone offers its
|
||||
// camera roll and sends a JPEG, whatever it keeps them as.
|
||||
if f.kind == "picture" {
|
||||
r["type"] = json!("file");
|
||||
r["accept"] = json!("image/jpeg,image/png,image/webp");
|
||||
}
|
||||
if f.optional { r["optional"] = json!(true); }
|
||||
if f.kind == "select" { r["options"] = json!(select_options(plan, f)); }
|
||||
if f.multiple { r["multiple"] = json!(true); }
|
||||
let hint = s(&w["hints"][&f.name]).trim();
|
||||
if !hint.is_empty() && f.kind != "select" { r["placeholder"] = json!(hint); }
|
||||
// Someone signed in is not asked for what the site knows: on a
|
||||
// group's page they always are, and answering a record they
|
||||
// may be.
|
||||
if parent.is_some() || need.enters_as.is_some() {
|
||||
if f.kind == "email" && matches!(f.name.as_str(), "email" | "epost" | "e-post") { r["value"] = json!("{user.email}"); }
|
||||
if f.kind == "text" && matches!(f.name.as_str(), "name" | "navn") { r["value"] = json!("{user.name}"); }
|
||||
}
|
||||
// Someone signed in is not asked for what the site knows, on any
|
||||
// form: portal carries their name and address and the fields take
|
||||
// no room (`content::fill_from_user`). A visitor sees the fields
|
||||
// as usual, so a public form loses nothing by it.
|
||||
if f.kind == "email" && matches!(f.name.as_str(), "email" | "epost" | "e-post") { r["value"] = json!("{user.email}"); }
|
||||
if f.kind == "text" && matches!(f.name.as_str(), "name" | "navn") { r["value"] = json!("{user.name}"); }
|
||||
r
|
||||
}).collect();
|
||||
// The record this answer is about, carried in from the page's own
|
||||
@@ -1918,15 +2001,32 @@ fn form_alternative(plan: &Plan, need: &PlanNeed, w: &Value, action: &str) -> Va
|
||||
if !s(&w["who_description"]).trim().is_empty() {
|
||||
features.push(strip(&w["who_title"], &w["who_description"], s(&w["who_icon"])));
|
||||
}
|
||||
features.push(feature);
|
||||
// A paused need asks for nothing: a form nobody can send has
|
||||
// already wasted the time it took to fill in.
|
||||
if !need.paused {
|
||||
features.push(feature);
|
||||
}
|
||||
let description = if need.paused {
|
||||
text_map(&w["description"], |d| format!("{} {}", d.trim(), s(&w["paused"]).trim()).trim().to_string())
|
||||
} else {
|
||||
text(&w["description"])
|
||||
};
|
||||
let mut alt = json!({
|
||||
"name": text(&w["heading"]),
|
||||
"description": text(&w["description"]),
|
||||
"description": description,
|
||||
"action": action,
|
||||
"consequence": button(&w["button"]),
|
||||
"record_as": need.lands_in,
|
||||
"features": features,
|
||||
});
|
||||
if need.one_per_person {
|
||||
alt["one_per_person"] = json!(true);
|
||||
}
|
||||
// Paused, it records nothing and says where nothing goes.
|
||||
if need.paused {
|
||||
alt["disabled"] = json!(true);
|
||||
alt.as_object_mut().map(|a| a.remove("record_as"));
|
||||
}
|
||||
if !s(&w["encouragement"]).trim().is_empty() {
|
||||
alt["encouragements"] = button(&w["encouragement"]);
|
||||
}
|
||||
@@ -1983,6 +2083,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
if let Some(d) = need.deadlines.get(st) {
|
||||
state["after"] = json!({ "days": d.days, "to": d.to });
|
||||
}
|
||||
if let Some(leaves) = need.leaves_after.get(st) {
|
||||
state["leaves_after"] = json!(leaves);
|
||||
}
|
||||
(st.clone(), state)
|
||||
}).collect::<Map<_, _>>(),
|
||||
"transitions": machine(need),
|
||||
@@ -2024,7 +2127,17 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
.collect();
|
||||
// A public list is shown, not pointed at: its newest few records
|
||||
// inline, and a link to the whole list.
|
||||
for (bucket, needs) in public_buckets(plan) {
|
||||
// Who the business is has a page of its own (see `who_page`);
|
||||
// the front door only leads there.
|
||||
if has_who_page(plan) {
|
||||
alternatives.push(json!({
|
||||
"name": text(&front["who"]["heading"]),
|
||||
"description": text(&front["who"]["description"]),
|
||||
"action": format!("/{}", who_page_id(plan)),
|
||||
"consequence": button(&front["who"]["button"]),
|
||||
}));
|
||||
}
|
||||
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| !n.iter().any(|n| n.presents)) {
|
||||
let w = &front["shopping"]["items"][&format!("status-{bucket}")];
|
||||
let mut f = card(w);
|
||||
let link = match s(&w["link"]).trim() { "" => s(&w["heading"]).trim().to_string(), l => l.to_string() };
|
||||
@@ -2079,6 +2192,9 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
front_page["slideshow"] = slideshow.clone();
|
||||
}
|
||||
files.insert("questions/index.yaml".into(), front_page);
|
||||
if has_who_page(plan) {
|
||||
files.insert(who_page_path(plan), who_page(plan, words));
|
||||
}
|
||||
|
||||
// A door's page: a nav page (index.yaml), public, holding the
|
||||
// forms of the needs behind it.
|
||||
@@ -2397,6 +2513,7 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
"stage_words": all_stages(n).iter().map(|s| (s.clone(), json!(stage_text(n, s)))).collect::<Map<_, _>>(),
|
||||
});
|
||||
if let Some(group) = &n.enters_as { need["enters_as"] = json!(group); }
|
||||
if n.paused { need["paused"] = json!(true); }
|
||||
let others: BTreeSet<&String> = n.moves_by.values().flatten().filter(|m| *m != SUBMITTER && **m != n.handled_by).collect();
|
||||
if !others.is_empty() { need["also_moved_by"] = json!(others); }
|
||||
need
|
||||
@@ -2409,8 +2526,78 @@ pub fn render(plan: &Plan, words: &BTreeMap<String, Value>) -> anyhow::Result<BT
|
||||
files.into_iter().map(|(path, value)| Ok((path, serde_yaml::to_string(&value)?))).collect()
|
||||
}
|
||||
|
||||
const MAILS_PURPOSE: &str = "the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, at most three short sentences plus the link line, no greeting longer than one word, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it. The person reads the mail alone, days later, maybe among others, so what works is a mail that says what it is about in their terms - a field they filled in, as a `{placeholder}`, names their case far better than `this` or `your message` (each bucket's fields are listed under the needs above) - and ends with the business's name so they know who wrote. Short fields (a name, a place, a choice) make good subjects; a field marked `(long)` is a whole answer and a field marked `(optional)` may be blank, so those serve better elsewhere or not at all. A desk's decision carries no written reason, but a reply to the mail reaches the people who decided, so inviting a reply is the honest way to offer an explanation.";
|
||||
|
||||
const DESK_PURPOSE: &str = "a desk: the page one role inside the business works from. Under `buckets`, each entry is a list of cases; `moves` are the buttons that move a case from one stage to the next (key `from->to`), so label each with what the person has just done. Under `links` are pages this role can open. Under `invites`, each entry is a form that invites a person into that group - they get an account and a one-time link - and `invited` is the list of invites sent, where `again` is the button that sends an invite whose link ran out once more. `suggestions`, on the desk that has it, is the list of changes the people responsible for a page have suggested: `looking`, `changed` and `declined` are its buttons.";
|
||||
|
||||
/// The page about who the business is: a question with nothing to do
|
||||
/// on it, only things to read. One card per need that `presents` (the
|
||||
/// people, the work), each showing every record as a card, and one for
|
||||
/// the business's own statements (`values`), carried word for word.
|
||||
fn who_page(plan: &Plan, words: &BTreeMap<String, Value>) -> Value {
|
||||
let w = &words[&who_page_path(plan)];
|
||||
let mut alternatives: Vec<Value> = Vec::new();
|
||||
for (bucket, needs) in public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)) {
|
||||
let pw = &w["presents"][bucket];
|
||||
alternatives.push(json!({
|
||||
"name": text(&pw["heading"]),
|
||||
"description": text(&pw["description"]),
|
||||
"features": [{ "name": "", "description": "", "resource": public_resource(bucket, &needs, s(&pw["empty"]), None, "") }],
|
||||
}));
|
||||
}
|
||||
if !plan.values.is_empty() {
|
||||
let vw = &w["values"];
|
||||
alternatives.push(json!({
|
||||
"name": text(&vw["heading"]),
|
||||
"description": text(&vw["description"]),
|
||||
"features": plan.values.iter().map(|v| {
|
||||
let iw = &vw["items"][&v.id];
|
||||
let mut f = json!({ "name": text(&iw["heading"]), "description": text(&iw["description"]) });
|
||||
if let Some(icon) = icon_name(s(&iw["icon"])) {
|
||||
f["icon"] = json!(icon);
|
||||
}
|
||||
f
|
||||
}).collect::<Vec<_>>(),
|
||||
}));
|
||||
}
|
||||
json!({
|
||||
"name": text(&w["question"]),
|
||||
"description": text(&w["description"]),
|
||||
"responsible": { "name": plan.contact.name, "contact": plan.contact.email },
|
||||
"alternatives": alternatives,
|
||||
})
|
||||
}
|
||||
|
||||
fn presenting_buckets(plan: &Plan) -> Vec<&str> {
|
||||
public_buckets(plan).into_iter().filter(|(_, n)| n.iter().any(|n| n.presents)).map(|(b, _)| b).collect()
|
||||
}
|
||||
|
||||
fn has_who_page(plan: &Plan) -> bool {
|
||||
!plan.values.is_empty() || !presenting_buckets(plan).is_empty()
|
||||
}
|
||||
|
||||
fn who_page_id(plan: &Plan) -> String {
|
||||
loc(&plan.language, "who", "hvem")
|
||||
}
|
||||
|
||||
fn who_page_path(plan: &Plan) -> String {
|
||||
format!("questions/{}.yaml", who_page_id(plan))
|
||||
}
|
||||
|
||||
/// The needs no persona tries, paused ones aside. A need nobody plays
|
||||
/// is one whose page is only ever read for the first time by the
|
||||
/// person it is for: the forms a business fills in from the inside
|
||||
/// are the easy ones to forget, and where "is this my own card?" goes
|
||||
/// unasked.
|
||||
pub fn untried_needs(plan: &Plan) -> Vec<&str> {
|
||||
plan.needs
|
||||
.iter()
|
||||
.filter(|n| !n.paused)
|
||||
.filter(|n| !plan.personas.iter().any(|p| p.get("need").and_then(|v| v.as_str()) == Some(n.id.as_str())))
|
||||
.map(|n| n.id.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -> String {
|
||||
let voice = match tone {
|
||||
Some(tone) => format!("\n\nTone of voice for every word on this page: {tone}. The site is told in more than one tone, each written in a pass of its own, so keep to this one throughout and do not hedge between tones."),
|
||||
@@ -2435,20 +2622,55 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
|
||||
if !own.is_empty() {
|
||||
desk_purpose.push_str(&format!(" On this page, the list(s) {} show the signed-in person ONLY the cases they sent in themselves: they are following their own case, not working through other people's, so head those lists as theirs ('Your cases') and say nothing of a queue.", own.iter().map(|b| format!("`{b}`")).collect::<Vec<_>>().join(", ")));
|
||||
}
|
||||
// A form a role fills in from inside: its page still asks a
|
||||
// question, the one the form answers, and says where the answer
|
||||
// goes - a heading like "Your card on the front page" names a
|
||||
// place, and the colleague reading it does not know what they are
|
||||
// being asked.
|
||||
let own_form = plan.needs.iter().find(|n| n.enters_as.as_deref().is_some_and(|g| path == format!("questions/{g}/{}.yaml", n.id)));
|
||||
let mut inside_purpose = "a page only one role inside the business can open, holding one form. `question` is a real question, the one the form answers, asked of the colleague in their own terms (\"What has been delivered?\"), never a label or the name of a place; where their answer ends up - who sees it, on which page - belongs in `page_description`.".to_string();
|
||||
if own_form.is_some_and(|n| n.one_per_person) {
|
||||
inside_purpose.push_str(" This form is the colleague's own record, one each: it opens filled in with what they have, and saving replaces it, so ask about them (\"How do you present yourself?\"), say where it shows, and make the button save.");
|
||||
}
|
||||
// A form a role enters from inside is sent by a colleague at work,
|
||||
// not by a visitor waiting on an answer.
|
||||
let sent_purpose = match plan.needs.iter().find(|n| n.enters_as.as_deref().is_some_and(|g| path == format!("questions/{g}/{}-sent.yaml", n.id))) {
|
||||
Some(n) => format!("a page shown right after someone in the group `{}` has sent a form from inside the business. The reader is that colleague, at work, not a visitor: nobody comes back to them. Say what has now happened to what they entered, and who it reaches.", n.enters_as.as_deref().unwrap_or_default()),
|
||||
None => "a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next.".to_string(),
|
||||
};
|
||||
let purpose = if path == "site.yaml" {
|
||||
// Who reads each mail. A mail to the business about someone else's
|
||||
// case was written as if to the person who sent it ("you will hear
|
||||
// from us") until the model was told who opens it.
|
||||
let recipients: Vec<String> = plan
|
||||
.needs
|
||||
.iter()
|
||||
.flat_map(|n| {
|
||||
n.mails.iter().map(move |(stage, to)| {
|
||||
let reader = match to.as_str() {
|
||||
"submitter" => "the person who sent it".to_string(),
|
||||
"owner" => "the person who posted the record this answers".to_string(),
|
||||
address => format!("someone at the business ({address}), about another person's case; write to them, not to the person who sent it"),
|
||||
};
|
||||
format!("- `{}` entering `{stage}`: read by {reader}", n.lands_in)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mails_purpose = format!(
|
||||
"{MAILS_PURPOSE}\n\nWho opens each mail:\n{}",
|
||||
recipients.join("\n")
|
||||
);
|
||||
let who_purpose = "the page about who the business is, with nothing to do on it, only things to read. `question` asks it as a question (\"Who are we?\"). Under `presents`, each entry heads one list shown as cards with a picture each - the people who work there, the work they have done - loosely, as a person would introduce them. `values` heads the business's own statements, which are its facts: head them, never add to them.".to_string();
|
||||
let purpose = if path == who_page_path(plan) {
|
||||
who_purpose.as_str()
|
||||
} else if path == "site.yaml" {
|
||||
"the invite mail: the first mail a person gets when a member of the business brings them in to help (onto a desk, into a committee), carrying the one-time link that lets them set a password. `subject` and `body` under `invite`. They did not ask for it, so say who brought them in and to what, then the link and when it runs out, then where they sign in afterwards. Plain text, short lines. Keep every `{...}` placeholder exactly as it is: `{name}`, `{username}`, `{group}`, `{link}`, `{expires}`, `{site}`, `{site_name}` are filled in by the site, and `{link}` must stay on a line of its own."
|
||||
} else if path == "aggregates.yaml" {
|
||||
"the mails the site sends. Under each bucket, each stage that sends one has a `subject` and a `body`: the mail a person gets the moment their case enters that stage, saying what happened and what, if anything, they can do now. Plain text, at most three short sentences plus the link line, no greeting longer than one word, in the business's voice. Keep every `{...}` placeholder exactly as it is - `{site}`, `{chain}`, `{email}` and any field name are filled in by the site - and keep any line with a link in it. The person reads the mail alone, days later, maybe among others, so what works is a mail that says what it is about in their terms - a field they filled in, as a `{placeholder}`, names their case far better than `this` or `your message` (each bucket's fields are listed under the needs above) - and ends with the business's name so they know who wrote. Short fields (a name, a place, a choice) make good subjects; a field marked `(long)` is a whole answer and a field marked `(optional)` may be blank, so those serve better elsewhere or not at all. A desk's decision carries no written reason, but a reply to the mail reaches the people who decided, so inviting a reply is the honest way to offer an explanation."
|
||||
mails_purpose.as_str()
|
||||
|
||||
} else if path.contains("/decide/") {
|
||||
"a page the person who sent a form reaches later, through a private link, to make a decision that is theirs: under `moves`, each entry is one thing they can decide."
|
||||
} else if path == "questions/index.yaml" {
|
||||
"the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it."
|
||||
"the front door: the first page any visitor sees. `question` is the page's one question. It opens modestly: `opener` is the first card, what the business does in several features and no button at all - a stranger reads it and decides whether to trust the place, so say what is true and useful, not what to press. `shopping` is the second card: the site's live content, presented as it is - the latest records, a figure that matters, what has been built. It works best presented plainly: a heading that names what it shows ('Latest from the noticeboard', 'What we have built'), each item headed by its own content, `link` naming the full list. The content does the persuading; the card only has to label it, so it needs no invitation to look around and no word about forms. `who`, when there is one, is a card that leads to the page about who the business is: its heading asks that plainly, and `button` goes there. Only then come the offers: under `doors`, each entry is one alternative that leads to a page of its own for one kind of visitor, and its heading must let that visitor recognise themselves from the heading alone; under `needs`, each entry is one alternative with a form behind it."
|
||||
} else if path.starts_with("questions/status/") {
|
||||
status_purpose.as_str()
|
||||
} else if plan.doors.iter().any(|d| door_path(d) == path) {
|
||||
@@ -2460,7 +2682,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
|
||||
} else if path.contains("/received/") || path.ends_with("-sent.yaml") {
|
||||
sent_purpose.as_str()
|
||||
} else {
|
||||
"a page only one role inside the business can open, holding one form."
|
||||
inside_purpose.as_str()
|
||||
};
|
||||
let mut context: Vec<String> = plan
|
||||
.doors
|
||||
@@ -2470,7 +2692,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value, tone: Option<&str>) -
|
||||
context.extend(
|
||||
plan.needs
|
||||
.iter()
|
||||
.map(|n| format!("- {}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
|
||||
.map(|n| format!("- {}{}: {} - wants: {}. Handled by {}. Stages: {}. Lands in `{}`; its form collects: {}.", n.id, match (n.paused, n.one_per_person) { (true, _) => " (PAUSED: not taking new cases right now; its card shows no form and its button does not press, and `paused` is the sentence after its description that says so)", (_, true) => " (ONE EACH: every signed-in person keeps one record here, their own; the form opens filled in with it and sending saves the change, so it speaks to them about their own and its button saves)", _ => "" }, n.who, n.wants, n.handled_by, n.stages.join(" -> "), n.lands_in, n.collects.iter().map(|f| format!("{{{}}}{}{}", f.name, if f.kind == "textarea" { " (long)" } else { "" }, if f.optional { " (optional)" } else { "" })).collect::<Vec<_>>().join(", "))),
|
||||
);
|
||||
format!(
|
||||
"You write the words for one page of a website for this business.\n\nBusiness: {}\nAbout: {}\nContact: {} <{}>\nLanguage to write in: {}\n\nEveryone the site serves:\n{}\n\nThe page: {path}\nIt is {purpose}\n\nBelow is the page as JSON. Every value is a slot that says what to write there. Return the same JSON with every slot replaced by your words. Keep every key exactly as it is. A slot left as it is counts as no answer.\n\nRules for the words:\n- `question` is a question the reader could ask themselves.\n- A heading says what the reader gets, in the reader's own words, and must work without its description.\n- Lead with what a thing enables. No negations in headings. No trade jargon the reader would not use.\n- Descriptions are one or two short sentences. Labels and buttons are a few words.\n- The button says what pressing it does; the encouragement says what happens next or how little it takes; the form's guidance line says what to have ready. Placeholders are example answers in the reader's words.\n- An icon is one name from this list, chosen for what the reader is doing, or empty: {}\n- Write the way people in this trade and country actually speak.{voice}\n\n{}\n\nReply with the JSON object only.",
|
||||
@@ -2581,6 +2803,9 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> {
|
||||
// The words as used, so the next rebuild can start from them.
|
||||
std::fs::write(out.join("words.json"), serde_json::to_string_pretty(&words)?)?;
|
||||
println!("content repo written to {}", out.display());
|
||||
for need in untried_needs(&plan) {
|
||||
eprintln!("note: no persona tries need {need:?}; nobody will find out whether its page reads right until a person does");
|
||||
}
|
||||
|
||||
if let Some(lint) = value("--lint") {
|
||||
let output = std::process::Command::new(&lint).arg("--path").arg(out.join("questions")).output().with_context(|| format!("running {lint}"))?;
|
||||
@@ -2627,7 +2852,7 @@ mod tests {
|
||||
stages: v(stages), done_when: v(done), success: v(success), skippable: v(skippable),
|
||||
moves_by: BTreeMap::new(), watched_by: vec![],
|
||||
events: BTreeMap::new(), attended_by: None, mails: BTreeMap::new(), stages_text: BTreeMap::new(), turned_down_from: vec![], reached_from: BTreeMap::new(), requires: String::new(), followed_by: vec![],
|
||||
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![],
|
||||
grants: BTreeMap::new(), deadlines: BTreeMap::new(), escalate: BTreeMap::new(), public_states: vec![], presents: false, paused: false, one_per_person: false, leaves_after: BTreeMap::new(),
|
||||
collects: vec![], max_fields: 3,
|
||||
}
|
||||
}
|
||||
@@ -3024,6 +3249,20 @@ mod tests {
|
||||
assert!(bad.validate().unwrap_err().to_string().contains("multiple is for select"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_words_model_is_told_who_opens_each_mail() {
|
||||
let plan: Plan = serde_yaml::from_str("business: Vel\ncontact: { name: S, email: s@v.example }\nmail: { from: v@v.example }\nneeds:\n - id: notice\n who: en nabo\n wants: et oppslag\n lands_in: oppslag\n handled_by: redaktor\n stages: [open, tatt_ned]\n done_when: [tatt_ned]\n mails: { open: s@v.example }\n watched_by: [public]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: answer\n who: en nabo\n wants: svare\n about: notice\n lands_in: svar\n handled_by: redaktor\n stages: [open, lest]\n done_when: [lest]\n mails: { open: owner }\n max_fields: 1\n collects:\n - { name: email, type: email }\n").unwrap();
|
||||
let prompt = words_prompt(&plan, "aggregates.yaml", &json!({}), None);
|
||||
assert!(prompt.contains("`oppslag` entering `open`: read by someone at the business (s@v.example)"), "{prompt}");
|
||||
assert!(prompt.contains("`svar` entering `open`: read by the person who posted the record this answers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_need_no_persona_tries_is_named() {
|
||||
let plan: Plan = serde_yaml::from_str("business: B\ncontact: { name: I, email: i@b.example }\nmail: { from: post@b.example }\nneeds:\n - id: ask\n who: a customer\n wants: an answer\n lands_in: asks\n handled_by: desk\n stages: [open, done]\n done_when: [done]\n max_fields: 2\n collects:\n - { name: email, type: email }\n - id: card\n who: one of us\n wants: to be seen\n enters_as: desk\n lands_in: cards\n handled_by: desk\n stages: [open, gone]\n done_when: [gone]\n max_fields: 1\n collects:\n - { name: name }\n - id: later\n who: someone\n wants: something\n paused: true\n lands_in: later\n handled_by: desk\n stages: [open, done]\n done_when: [done]\n max_fields: 1\n collects:\n - { name: email, type: email }\npersonas:\n - { id: p, need: ask, text: I need an answer }\n").unwrap();
|
||||
assert_eq!(untried_needs(&plan), vec!["card"], "the inside need is untried; the paused one is not asked for");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_public_watcher_gets_a_read_only_page_without_contact_details() {
|
||||
let yaml = "business: B\ncontact: { name: I, email: i@b.example }\nneeds:\n - id: traffic\n who: someone who saw a hazard\n wants: at det tas opp\n lands_in: meldinger\n handled_by: traffic\n stages: [open, fixed]\n done_when: [fixed]\n watched_by: [board, public]\n max_fields: 2\n collects:\n - { name: where }\n - { name: what, type: textarea }\n - { name: epost, type: email, optional: true }\n - { name: name, optional: true, private: true }\n";
|
||||
@@ -3037,7 +3276,7 @@ mod tests {
|
||||
let resource = &view["alternatives"][0]["features"][0]["resource"];
|
||||
assert_eq!(resource["public"], true);
|
||||
assert!(resource.get("requires_group").is_none());
|
||||
assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .responses |= {what: .what, where: .where}");
|
||||
assert_eq!(resource["jq"], "sort_by(.submitted_ms) | reverse | .[] | .responses |= {where: .where, what: .what}");
|
||||
assert!(view.get("qualifies").is_none());
|
||||
// The followup page points people at the list.
|
||||
let followup: Value = serde_yaml::from_str(&files["questions/received/traffic.yaml"]).unwrap();
|
||||
|
||||
+254
-7
@@ -132,34 +132,91 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
.to_string();
|
||||
let mut green = false;
|
||||
let mut report = String::new();
|
||||
// What the lint found, by id, round over round (`--findings`): what
|
||||
// a round closed, what it left, what it broke. `None` while the
|
||||
// lint is one that predates `--findings`; then the whole report
|
||||
// goes back, as it always did.
|
||||
let mut ledger = Ledger::default();
|
||||
let mut tracked = true;
|
||||
let mut undone = String::new();
|
||||
std::fs::create_dir_all(opts.out.join("findings"))?;
|
||||
for round in 1..=opts.rounds {
|
||||
let prompt = if round == 1 {
|
||||
format!(
|
||||
"{REFERENCE}\n\n# Task\n\nWrite the content repo that meets this frozen `needs.yaml`: `aggregates.yaml`, `site.yaml`, and every page under `questions/` - the front door, followup pages, role-gated pages, one desk per handling group. Do not return needs.yaml.\n\n## Intake\n\n{intake}\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n{FORMAT}"
|
||||
)
|
||||
} else if tracked {
|
||||
let (heading, files) = match named_files(&opts.out, ledger.open.values()) {
|
||||
Some(files) => ("Files the findings name", dump_files(&opts.out, &files)?),
|
||||
None => ("Current files", dump(&opts.out)?),
|
||||
};
|
||||
format!(
|
||||
"{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo. Below is every finding still open, each with the change that closes it. Make those changes and nothing else: findings already closed stay closed. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Open findings\n\n{}\n{undone}## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## {heading}\n\n{files}\n\n{FORMAT}",
|
||||
ledger.open_list(round)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{REFERENCE}\n\n# Task\n\nThe verifier rejected the repo below. Fix it. Return only the files you change, each complete. To remove a file, return it with the single line DELETE. needs.yaml is frozen; do not return it.\n\n## Verifier output\n\n```\n{report}\n```\n\n## needs.yaml (frozen)\n\n```yaml\n{needs}\n```\n\n## Current files\n\n{}\n\n{FORMAT}",
|
||||
dump(&opts.out)?
|
||||
)
|
||||
};
|
||||
let before = snapshot(&opts.out)?;
|
||||
match parse_files(&llm(&opts.llm, &prompt)?) {
|
||||
Ok(files) => apply(&opts.out, files)?,
|
||||
Err(_) => eprintln!("structure round {round}: the reply had no file blocks"),
|
||||
}
|
||||
let (ok, r) = lint(&opts, &[])?;
|
||||
let (ok, r, found) = lint_findings(&opts, &opts.out.join(format!("findings/round-{round}.jsonl")))?;
|
||||
report = r;
|
||||
let fails = report.lines().filter(|l| l.starts_with("FAIL")).count();
|
||||
eprintln!("structure round {round}: {}", if ok { "green".to_string() } else { format!("{fails} FAIL line(s)") });
|
||||
log.push(format!("structure round {round}: ok={ok} fails={fails}"));
|
||||
let Some(found) = found else {
|
||||
tracked = false;
|
||||
let fails = report.lines().filter(|l| l.starts_with("FAIL")).count();
|
||||
eprintln!("structure round {round}: {}", if ok { "green".to_string() } else { format!("{fails} FAIL line(s)") });
|
||||
log.push(format!("structure round {round}: ok={ok} fails={fails}"));
|
||||
if ok {
|
||||
green = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let previous = ledger.clone();
|
||||
let progress = ledger.update(round, found.into_iter().filter(|f| f.level == "fail").collect());
|
||||
let line = format!("structure round {round}: {}", progress.summary(ledger.open.len()));
|
||||
eprintln!("{line}");
|
||||
log.push(line);
|
||||
undone.clear();
|
||||
if ok {
|
||||
green = true;
|
||||
break;
|
||||
}
|
||||
if round == 1 {
|
||||
continue;
|
||||
}
|
||||
// A fix that reopens what an earlier round closed is undone,
|
||||
// and the model is told what it broke, rather than chasing the
|
||||
// same findings round after round.
|
||||
if !progress.reopened.is_empty() {
|
||||
restore(&opts.out, before)?;
|
||||
ledger = previous;
|
||||
undone = format!(
|
||||
"## Your last change was undone\n\nIt reopened findings an earlier round had closed:\n\n{}\nClose the open findings without touching what made these pass.\n\n",
|
||||
progress.reopened.iter().map(|f| format!("- [{}] {}\n", f.id, f.message)).collect::<String>()
|
||||
);
|
||||
eprintln!("structure round {round}: reopened {} closed finding(s), undone", progress.reopened.len());
|
||||
log.push(format!("structure round {round}: undone (reopened {})", progress.reopened.len()));
|
||||
continue;
|
||||
}
|
||||
// No progress, no more rounds: another pass at the same findings
|
||||
// is how a site gets over-trained. What is left is reported.
|
||||
if progress.fixed.is_empty() || ledger.open.len() >= previous.open.len() {
|
||||
eprintln!("structure round {round}: no finding closed, stopping");
|
||||
log.push(format!("structure round {round}: stalled"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !green {
|
||||
std::fs::write(opts.out.join("run.log"), format!("{}\n\n{report}", log.join("\n")))?;
|
||||
bail!("structure never went green in {} round(s); last verifier output is in run.log", opts.rounds);
|
||||
let open = if tracked { format!("\n\nStill open:\n{}", ledger.open_list(opts.rounds + 1)) } else { String::new() };
|
||||
std::fs::write(opts.out.join("run.log"), format!("{}{open}\n\n{report}", log.join("\n")))?;
|
||||
bail!("structure did not go green; what is still open is in run.log");
|
||||
}
|
||||
|
||||
// Phase 3: wording.
|
||||
@@ -191,7 +248,10 @@ pub fn run(argv: Vec<String>) -> anyhow::Result<()> {
|
||||
}
|
||||
eprintln!("wording round {round}: {}", summary.join(" | "));
|
||||
log.push(format!("wording round {round}: {}", summary.join(" | ")));
|
||||
if defects.is_empty() || round == opts.wording_rounds {
|
||||
// A visitor who chose right, hesitantly, is not a defect worth a
|
||||
// rewrite on its own: only a misroute is. Hesitations ride along
|
||||
// as context when there is one.
|
||||
if !defects.contains("MISROUTE") || round == opts.wording_rounds {
|
||||
break;
|
||||
}
|
||||
let personas = needs_full.split("\npersonas:").nth(1).unwrap_or_default();
|
||||
@@ -311,6 +371,152 @@ fn lint(opts: &Opts, extra: &[String]) -> anyhow::Result<(bool, String)> {
|
||||
Ok((output.status.success(), report))
|
||||
}
|
||||
|
||||
/// One finding as the lint's `--findings` writes it (docs/lint.md).
|
||||
#[derive(Clone, Debug, serde::Deserialize)]
|
||||
struct Finding {
|
||||
id: String,
|
||||
level: String,
|
||||
code: String,
|
||||
#[serde(default)]
|
||||
need: String,
|
||||
message: String,
|
||||
#[serde(default)]
|
||||
fix: Option<String>,
|
||||
}
|
||||
|
||||
/// The lint with `--findings` into `out`, and what it found; `None` for
|
||||
/// the findings when the lint predates the flag (then it is run again
|
||||
/// the old way, and only its text comes back).
|
||||
fn lint_findings(opts: &Opts, out: &Path) -> anyhow::Result<(bool, String, Option<Vec<Finding>>)> {
|
||||
let (ok, report) = lint(opts, &["--findings".to_string(), out.display().to_string()])?;
|
||||
if report.contains("unknown argument: --findings") {
|
||||
let (ok, report) = lint(opts, &[])?;
|
||||
return Ok((ok, report, None));
|
||||
}
|
||||
let raw = std::fs::read_to_string(out).unwrap_or_default();
|
||||
let found = raw
|
||||
.lines()
|
||||
.filter(|l| !l.trim().is_empty())
|
||||
.map(|l| serde_json::from_str::<Finding>(l).with_context(|| format!("reading {}", out.display())))
|
||||
.collect::<anyhow::Result<Vec<_>>>()?;
|
||||
Ok((ok, report, Some(found)))
|
||||
}
|
||||
|
||||
/// Every failing finding by id, across rounds.
|
||||
#[derive(Clone, Default)]
|
||||
struct Ledger {
|
||||
open: BTreeMap<String, Finding>,
|
||||
/// The round each open finding was first seen in.
|
||||
since: BTreeMap<String, usize>,
|
||||
/// Ids a round has closed, so one that comes back is a regression,
|
||||
/// not news.
|
||||
closed: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
struct Progress {
|
||||
fixed: Vec<Finding>,
|
||||
new: Vec<Finding>,
|
||||
still: usize,
|
||||
reopened: Vec<Finding>,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
fn summary(&self, open: usize) -> String {
|
||||
if open == 0 {
|
||||
return format!("green (fixed {})", self.fixed.len());
|
||||
}
|
||||
format!(
|
||||
"{open} open - fixed {}, still open {}, new {}, reopened {}",
|
||||
self.fixed.len(),
|
||||
self.still,
|
||||
self.new.len(),
|
||||
self.reopened.len()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ledger {
|
||||
fn update(&mut self, round: usize, fails: Vec<Finding>) -> Progress {
|
||||
let now: BTreeMap<String, Finding> = fails.into_iter().map(|f| (f.id.clone(), f)).collect();
|
||||
let fixed: Vec<Finding> = self.open.values().filter(|f| !now.contains_key(&f.id)).cloned().collect();
|
||||
let mut new = Vec::new();
|
||||
let mut reopened = Vec::new();
|
||||
let mut still = 0;
|
||||
for (id, f) in &now {
|
||||
if self.open.contains_key(id) {
|
||||
still += 1;
|
||||
} else if self.closed.contains(id) {
|
||||
reopened.push(f.clone());
|
||||
} else {
|
||||
new.push(f.clone());
|
||||
}
|
||||
self.since.entry(id.clone()).or_insert(round);
|
||||
}
|
||||
for f in &fixed {
|
||||
self.closed.insert(f.id.clone());
|
||||
self.since.remove(&f.id);
|
||||
}
|
||||
self.open = now;
|
||||
Progress { fixed, new, still, reopened }
|
||||
}
|
||||
|
||||
/// The open findings for a prompt or a log: id, how long open, the
|
||||
/// message and its fix.
|
||||
fn open_list(&self, round: usize) -> String {
|
||||
self.open
|
||||
.values()
|
||||
.map(|f| {
|
||||
let age = match self.since.get(&f.id) {
|
||||
Some(&first) if first + 1 < round => format!(", open since round {first}"),
|
||||
_ => String::new(),
|
||||
};
|
||||
let scope = if f.need.is_empty() { String::new() } else { format!(" {:?}", f.need) };
|
||||
format!("- [{}] {}{scope}{age}: {}\n", f.id, f.code, f.message)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The files open findings point at - every desk page their fixes name,
|
||||
/// plus aggregates.yaml - when that is enough to act on. `None` when a
|
||||
/// finding is about getting around the site (a path, an entry page, a
|
||||
/// missing desk) or names no page: then the model needs to see it all.
|
||||
fn named_files<'a>(out: &Path, findings: impl Iterator<Item = &'a Finding>) -> Option<Vec<PathBuf>> {
|
||||
const WHOLE_SITE: &[&str] = &["schema", "entry-missing", "entry-gated", "path-missing", "path-closed", "path-over-budget", "desk-missing", "need-duplicate"];
|
||||
let mut files = vec![out.join("aggregates.yaml")];
|
||||
for f in findings {
|
||||
if WHOLE_SITE.contains(&f.code.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let text = format!("{} {}", f.message, f.fix.as_deref().unwrap_or_default());
|
||||
for page in text.split_whitespace().filter(|w| w.starts_with('/')) {
|
||||
let page = page.trim_end_matches(|c: char| !(c.is_alphanumeric() || c == '_' || c == '-' || c == '/'));
|
||||
let rel = page.trim_start_matches('/');
|
||||
let candidates = if rel.is_empty() {
|
||||
vec![out.join("questions/index.yaml")]
|
||||
} else {
|
||||
vec![out.join(format!("questions/{rel}.yaml")), out.join(format!("questions/{rel}/index.yaml"))]
|
||||
};
|
||||
if let Some(path) = candidates.into_iter().find(|p| p.exists()) {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(files)
|
||||
}
|
||||
|
||||
/// `dump`, for the named files only.
|
||||
fn dump_files(out: &Path, files: &[PathBuf]) -> anyhow::Result<String> {
|
||||
let mut text = String::new();
|
||||
for path in files.iter().filter(|p| p.exists()) {
|
||||
let rel = path.strip_prefix(out).unwrap_or(path).display().to_string();
|
||||
text.push_str(&format!("<<<FILE {rel}>>>\n{}<<<END>>>\n\n", std::fs::read_to_string(path)?));
|
||||
}
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// `<<<FILE path>>> ... <<<END>>>` blocks. Anything outside a block is
|
||||
/// ignored, so a model that adds a sentence does no harm.
|
||||
fn parse_files(text: &str) -> anyhow::Result<BTreeMap<String, String>> {
|
||||
@@ -591,3 +797,44 @@ mod tests {
|
||||
assert!(!allowed(".gitea/workflows/deploy.yaml"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ledger_tests {
|
||||
use super::*;
|
||||
|
||||
fn f(id: &str, code: &str, message: &str) -> Finding {
|
||||
Finding { id: id.into(), level: "fail".into(), code: code.into(), need: "hire".into(), message: message.into(), fix: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_round_is_told_what_it_closed_kept_added_and_broke() {
|
||||
let mut ledger = Ledger::default();
|
||||
let p = ledger.update(1, vec![f("a", "strand", "x"), f("b", "strand", "y")]);
|
||||
assert_eq!((p.fixed.len(), p.new.len(), p.still, p.reopened.len()), (0, 2, 0, 0));
|
||||
let p = ledger.update(2, vec![f("b", "strand", "y"), f("c", "edge-unoffered", "z")]);
|
||||
assert_eq!((p.fixed.len(), p.new.len(), p.still, p.reopened.len()), (1, 1, 1, 0));
|
||||
assert_eq!(p.summary(ledger.open.len()), "2 open - fixed 1, still open 1, new 1, reopened 0");
|
||||
// "a" was closed in round 2; its return is a regression.
|
||||
let p = ledger.update(3, vec![f("a", "strand", "x"), f("b", "strand", "y")]);
|
||||
assert_eq!(p.reopened.iter().map(|f| f.id.as_str()).collect::<Vec<_>>(), ["a"]);
|
||||
assert!(ledger.open_list(4).contains("[b] strand \"hire\", open since round 1: y"));
|
||||
let p = ledger.update(4, vec![]);
|
||||
assert_eq!(p.summary(ledger.open.len()), "green (fixed 2)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fix_round_sees_the_pages_its_findings_name() {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("questions/desk")).unwrap();
|
||||
std::fs::write(dir.join("aggregates.yaml"), "x").unwrap();
|
||||
std::fs::write(dir.join("questions/desk/index.yaml"), "x").unwrap();
|
||||
std::fs::write(dir.join("questions/index.yaml"), "x").unwrap();
|
||||
let mut strand = f("a", "strand", "records strand in \"quoted\"");
|
||||
strand.fix = Some("add `{ from: quoted, to: won, label: ... }` to the transitions on /desk".into());
|
||||
let files = named_files(&dir, [&strand].into_iter()).unwrap();
|
||||
assert_eq!(files, vec![dir.join("aggregates.yaml"), dir.join("questions/desk/index.yaml")]);
|
||||
// A finding about getting around the site needs all of it.
|
||||
assert!(named_files(&dir, [&strand, &f("b", "path-missing", "no path")].into_iter()).is_none());
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,31 @@ rest. Each is one line in the plan, and the builder does the wiring:
|
||||
- **Carry a text word for word.** `documents` puts bylaws or terms on
|
||||
a page of their own, one card per section, from a markdown file
|
||||
beside the plan. Nothing rewords them.
|
||||
- **Say who you are.** A need with `presents: true` and
|
||||
`watched_by: [public]` (the people who work there, the work they
|
||||
have done, each with a `picture` field) and `values` (the business's
|
||||
own statements, carried word for word) make a page of their own,
|
||||
"who are we?", with nothing to do on it: one card per presented list
|
||||
and one for the values. The front door leads there.
|
||||
- **Give every need someone to play it.** A persona per need,
|
||||
including the ones a business fills in from the inside (a delivery,
|
||||
a card on the front page): a page nobody plays is first read by the
|
||||
person it is for. `portal build` names the needs no persona tries.
|
||||
- **Let people keep their own.** `one_per_person: true` on a need
|
||||
entered from inside gives each signed-in person one record there:
|
||||
their own, which the form opens with and which sending again
|
||||
replaces - a card on the front page they edit themselves. A picture
|
||||
left unchosen stays as it was.
|
||||
- **Let finished things go.** `leaves_after: { <stage>: { minutes: 10 } }`
|
||||
takes a record out of every list that long after it reaches that
|
||||
stage (days, hours and minutes add up). A card taken off the front
|
||||
page need not stand greyed out on the desk for ever; the history
|
||||
keeps it.
|
||||
- **Pause what you cannot take on.** `paused: true` on a need keeps
|
||||
its card on the page, saying what the business does and that it is
|
||||
not taking new ones right now, with no form and a button that does
|
||||
not press. Take the line out to open again; the desk and every case
|
||||
under way are untouched.
|
||||
- **Show the work.** `slideshow` on the plan is portal's own block,
|
||||
carried onto the front door as written: a strip of pictures under
|
||||
the question, each with its caption.
|
||||
|
||||
@@ -190,7 +190,16 @@ not done. Its messages are exact:
|
||||
- `no desk gated on "g" reads "b"`: add the desk.
|
||||
- `"g" cannot move a record to "s"` / `records strand in "s"`: the desk
|
||||
is missing a transition the state machine allows, or the state
|
||||
machine is missing the edge.
|
||||
machine is missing the edge. The message ends in `fix:` with the
|
||||
exact `{ from, to, label }` and the desk page to put it on - do that.
|
||||
- `state "s" is never reached`: nothing moves a record into it. Offer
|
||||
the move it names, or remove the state.
|
||||
- `stage "b" comes after "a" ... but no move leads on`: the machine
|
||||
cannot go the way the business works; add the edge it names.
|
||||
- `the mail on entering "s" uses {x}`: the mail names a field no form
|
||||
collects. Use one of the fields it lists, or collect `x`.
|
||||
- WARN `the move "a" -> "b" is declared but no desk ... offers it`:
|
||||
offer it, or drop the edge - every edge should be a button.
|
||||
- `action ... does not match any declared question id`: the page does
|
||||
not exist.
|
||||
- `entry page ... names no one responsible`: add `responsible`.
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ use serde::Serialize;
|
||||
|
||||
use crate::server::AppState;
|
||||
|
||||
pub const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
|
||||
pub const MAX_UPLOAD_BYTES: usize = crate::app::MAX_UPLOAD_BYTES;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Garage {
|
||||
|
||||
+251
-5
@@ -162,10 +162,13 @@ main.not-found {
|
||||
|
||||
/* hero */
|
||||
|
||||
/* No room is kept at the top for who is here: that sits on the
|
||||
wordmark's own line now (.hero-top), and the 4rem it used to need
|
||||
read as dead space above the mark on a tall, narrow screen. */
|
||||
.hero {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: 4rem 1.5rem 2.5rem;
|
||||
padding: 1.75rem 1.5rem 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -176,10 +179,6 @@ main.not-found {
|
||||
/* Who is here and the way in or out: top right of every page, on a
|
||||
site that has sign-in. */
|
||||
.session {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1.5rem;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
align-items: baseline;
|
||||
@@ -201,6 +200,55 @@ main.not-found {
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* The wordmark's line, with who is here on it: the mark centred and the
|
||||
session at the right edge, level with the mark. A skin that sets
|
||||
everything on one left edge spreads the two apart instead (see
|
||||
Klingenberg Bygg's and Gullberg's). On a narrow screen the session
|
||||
drops under the mark rather than over it. */
|
||||
.hero-top {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero-top .session {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.hero-bar .hero-top {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.hero-bar .hero-top .session {
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 34rem) {
|
||||
.hero {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-bar {
|
||||
padding-block: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-top {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-top .session {
|
||||
position: static;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* A page with pictures: the wordmark is a bar of its own, the strip
|
||||
runs the full width under it, and the question follows. */
|
||||
.hero-slides {
|
||||
@@ -244,6 +292,20 @@ main.not-found {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* A wordmark in the page's ink (site.yaml `wordmark_ink`): the file is a
|
||||
mask over the text colour. The invisible image inside gives the span
|
||||
the file's own size. */
|
||||
.wordmark-ink-mark {
|
||||
display: block;
|
||||
background: var(--ink);
|
||||
-webkit-mask: var(--wordmark-src) center / contain no-repeat;
|
||||
mask: var(--wordmark-src) center / contain no-repeat;
|
||||
}
|
||||
|
||||
.wordmark-ink-mark img {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
@@ -1218,6 +1280,31 @@ textarea:focus {
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
/* After the newest finished cases: the older ones, on request. Quiet,
|
||||
since the desk is for what is still moving. */
|
||||
.show-older {
|
||||
justify-self: start;
|
||||
background: none;
|
||||
border: 0.06rem solid var(--line);
|
||||
border-radius: 0.4rem;
|
||||
color: var(--ink-dim);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.35rem 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* A releases manifest: the pick for this device, the rest folded. */
|
||||
.releases .pill { display: inline-block; }
|
||||
.releases pre { white-space: pre-wrap; font-size: 0.85rem; margin: 0.4rem 0 0; }
|
||||
.releases-all summary { color: var(--ink-dim); font-size: 0.9rem; cursor: pointer; }
|
||||
.releases-all ul { margin: 0.4rem 0 0 1rem; font-size: 0.9rem; }
|
||||
|
||||
.show-older:hover {
|
||||
color: inherit;
|
||||
border-color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.answer-row {
|
||||
border: 0.06rem solid var(--line);
|
||||
border-radius: 0.8rem;
|
||||
@@ -1269,6 +1356,101 @@ textarea:focus {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* A read-only list whose records carry a picture: shown, not read. */
|
||||
.answer-cards {
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(100%, 11rem), 1fr));
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.answer-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.answer-picture {
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 5;
|
||||
object-fit: cover;
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.answer-card-text {
|
||||
padding: 0.9rem 1.1rem 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.answer-card-heading {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.card-field {
|
||||
font-size: 0.92rem;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
.card-field > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card-field > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-field p,
|
||||
.card-field ul,
|
||||
.card-field ol {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.card-field ul,
|
||||
.card-field ol {
|
||||
padding-left: 1.2em;
|
||||
}
|
||||
|
||||
.card-field a {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* The field right under the name - a role - reads as part of the name. */
|
||||
.answer-card-text .card-field:first-of-type {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.answer-card .answer-link {
|
||||
margin: 0 1.1rem 1.1rem;
|
||||
}
|
||||
|
||||
/* A picture on a desk's record: small, and it opens. */
|
||||
.answer-thumb {
|
||||
display: block;
|
||||
max-width: 8rem;
|
||||
max-height: 8rem;
|
||||
border-radius: 0.4rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* Why a send was refused, under its button. */
|
||||
.alt-error {
|
||||
margin: 0.6em 0 0;
|
||||
color: var(--accent);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* The file already on a card of one's own, above the picker. */
|
||||
.field-current {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6em;
|
||||
font-size: 0.9em;
|
||||
color: var(--ink-dim);
|
||||
}
|
||||
|
||||
/* A stored file on a record: its name, and it opens. */
|
||||
.answer-file {
|
||||
color: var(--accent);
|
||||
@@ -1520,3 +1702,67 @@ textarea:focus {
|
||||
.resource-loading {
|
||||
min-height: 1.4em;
|
||||
}
|
||||
|
||||
/* Pages move the way their addresses do (public/page-motion.js):
|
||||
deeper comes in from the right, back up from the left, a page at the
|
||||
same depth from below or above. The cards leave and arrive as
|
||||
islands of their own, one after another. Transforms and opacity
|
||||
only. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
--pm-duration: 340ms;
|
||||
--pm-ease: cubic-bezier(0.22, 0.8, 0.24, 1);
|
||||
--pm-step: 45ms;
|
||||
}
|
||||
|
||||
::view-transition-old(*),
|
||||
::view-transition-new(*) {
|
||||
animation-duration: var(--pm-duration);
|
||||
animation-timing-function: var(--pm-ease);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* A named card has only an old or only a new picture; its group must
|
||||
not also morph. */
|
||||
::view-transition-group(*) {
|
||||
animation-duration: var(--pm-duration);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@keyframes pm-out-left { to { transform: translateX(-18%); opacity: 0; } }
|
||||
@keyframes pm-out-right { to { transform: translateX(18%); opacity: 0; } }
|
||||
@keyframes pm-out-up { to { transform: translateY(-10%); opacity: 0; } }
|
||||
@keyframes pm-out-down { to { transform: translateY(10%); opacity: 0; } }
|
||||
@keyframes pm-in-right { from { transform: translateX(18%); opacity: 0; } }
|
||||
@keyframes pm-in-left { from { transform: translateX(-18%); opacity: 0; } }
|
||||
@keyframes pm-in-below { from { transform: translateY(10%); opacity: 0; } }
|
||||
@keyframes pm-in-above { from { transform: translateY(-10%); opacity: 0; } }
|
||||
|
||||
/* The cards, one step apart: out first, in after the page has begun. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root[data-motion="forward"]::view-transition-old(*) { animation-name: pm-out-left; }
|
||||
:root[data-motion="forward"]::view-transition-new(*) { animation-name: pm-in-right; }
|
||||
:root[data-motion="back"]::view-transition-old(*) { animation-name: pm-out-right; }
|
||||
:root[data-motion="back"]::view-transition-new(*) { animation-name: pm-in-left; }
|
||||
:root[data-motion="down"]::view-transition-old(*) { animation-name: pm-out-up; }
|
||||
:root[data-motion="down"]::view-transition-new(*) { animation-name: pm-in-below; }
|
||||
:root[data-motion="up"]::view-transition-old(*) { animation-name: pm-out-down; }
|
||||
:root[data-motion="up"]::view-transition-new(*) { animation-name: pm-in-above; }
|
||||
::view-transition-old(pm-out-1) { animation-delay: calc(0 * var(--pm-step)); }
|
||||
::view-transition-new(pm-in-1) { animation-delay: calc(90ms + 0 * var(--pm-step)); }
|
||||
::view-transition-old(pm-out-2) { animation-delay: calc(1 * var(--pm-step)); }
|
||||
::view-transition-new(pm-in-2) { animation-delay: calc(90ms + 1 * var(--pm-step)); }
|
||||
::view-transition-old(pm-out-3) { animation-delay: calc(2 * var(--pm-step)); }
|
||||
::view-transition-new(pm-in-3) { animation-delay: calc(90ms + 2 * var(--pm-step)); }
|
||||
::view-transition-old(pm-out-4) { animation-delay: calc(3 * var(--pm-step)); }
|
||||
::view-transition-new(pm-in-4) { animation-delay: calc(90ms + 3 * var(--pm-step)); }
|
||||
::view-transition-old(pm-out-5) { animation-delay: calc(4 * var(--pm-step)); }
|
||||
::view-transition-new(pm-in-5) { animation-delay: calc(90ms + 4 * var(--pm-step)); }
|
||||
::view-transition-old(pm-out-6) { animation-delay: calc(5 * var(--pm-step)); }
|
||||
::view-transition-new(pm-in-6) { animation-delay: calc(90ms + 5 * var(--pm-step)); }
|
||||
::view-transition-old(pm-out-7) { animation-delay: calc(6 * var(--pm-step)); }
|
||||
::view-transition-new(pm-in-7) { animation-delay: calc(90ms + 6 * var(--pm-step)); }
|
||||
::view-transition-old(pm-out-8) { animation-delay: calc(7 * var(--pm-step)); }
|
||||
::view-transition-new(pm-in-8) { animation-delay: calc(90ms + 7 * var(--pm-step)); }
|
||||
}
|
||||
|
||||
@@ -248,3 +248,32 @@ async fn a_membership_is_invited_becomes_active_at_sign_in_and_can_be_taken_back
|
||||
let groups = memberships::on_signed_in(&state, &kari).await;
|
||||
assert!(groups.contains(&styret.to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_card_of_ones_own_is_replaced_not_added_to_even_on_a_double_click() {
|
||||
use portal::answers::{keep_newest_own, own_records, OWNER_FIELD};
|
||||
let Ok(url) = std::env::var("PORTAL_TEST_NATS_URL") else {
|
||||
eprintln!("skipped: PORTAL_TEST_NATS_URL is not set");
|
||||
return;
|
||||
};
|
||||
let js = async_nats::jetstream::new(async_nats::connect(&url).await.expect("connect"));
|
||||
ensure_stream(&js).await.expect("event stream");
|
||||
let bucket = format!("own_{}", chrono::Utc::now().timestamp_millis());
|
||||
let schemas = std::collections::HashMap::new();
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let card = |name: &str, by: &str| json!({"name": name, OWNER_FIELD: by});
|
||||
|
||||
// Kari's first card, then two sends of her second pressed at once,
|
||||
// and Ola's card beside them.
|
||||
store_answer(&js, &schemas, &bucket, "k1".into(), "/kontor/ansatt", "a", &card("Kari", "kari"), now, 0).await.unwrap();
|
||||
store_answer(&js, &schemas, &bucket, "o1".into(), "/kontor/ansatt", "a", &card("Ola", "ola"), now + 1, 0).await.unwrap();
|
||||
store_answer(&js, &schemas, &bucket, "k2".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
|
||||
store_answer(&js, &schemas, &bucket, "k3".into(), "/kontor/ansatt", "a", &card("Kari N.", "kari"), now + 2, 0).await.unwrap();
|
||||
// Both racing sends settle after storing; both must keep the same one.
|
||||
let (a, b) = tokio::join!(keep_newest_own(&js, &bucket, "open", "kari"), keep_newest_own(&js, &bucket, "open", "kari"));
|
||||
let _ = (a, b);
|
||||
|
||||
let kari = own_records(&js, &bucket, "kari").await;
|
||||
assert_eq!(kari.iter().map(|a| a.id.as_str()).collect::<Vec<_>>(), vec!["k3"], "one card, the newest");
|
||||
assert_eq!(own_records(&js, &bucket, "ola").await.len(), 1, "nobody else's card is touched");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user