From 93a6fc0e10dd1408ea17836955a059f9b47f2ee6 Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Wed, 23 Sep 2026 12:42:23 +0200 Subject: [PATCH] build: invites from the plan, and rebuilding a live site without losing its words `invites_by` on a plan (and `invited_by` on a group) put an invite form per fillable group on the inviting group's desk, plus a list of the invites sent - portal's `invite:` does the onboarding. Every desk group invites into itself by default; an inviter without a desk is refused. `--words-from ` lifts the words an existing repo carries back into the words map by structure: the plan is rendered with a token in every slot and walked alongside the repo's files. Hand edits and model-chosen icons survive a rebuild; only new slots get fallbacks, which `--words ` can override. The words as used are written to words.json. First use: Tomter Vel, additions only. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH --- README.md | 17 + plans/tomtervel.yaml | 4 + runs/tomtervel-invites-2026-09-23/README.md | 11 + .../invite-words-no.json | 40 + runs/tomtervel-invites-2026-09-23/words.json | 873 ++++++++++++++++++ src/build.rs | 241 ++++- 6 files changed, 1182 insertions(+), 4 deletions(-) create mode 100644 runs/tomtervel-invites-2026-09-23/README.md create mode 100644 runs/tomtervel-invites-2026-09-23/invite-words-no.json create mode 100644 runs/tomtervel-invites-2026-09-23/words.json diff --git a/README.md b/README.md index 6372817..47484bb 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,23 @@ The model writes words only, one page at a time, into a JSON object whose shape is fixed. Its strings are merged over built-in fallbacks key by key, so a bad reply costs wording, never a working site. +### Onboarding from the plan + +`invites_by: [styret]` on a plan, or `invited_by: [...]` on a group, +puts an invite form on the inviting group's desk for each group it may +fill, and a list of the invites sent. Portal does the rest (an account +in Kanidm, the group, a one-time credential link) through `invite:` on +the alternative. Every group with a desk invites into itself by +default. + +### Rebuilding a live site + +`--words-from ` lifts the words a repo already has back into the +words map by structure, so a rebuild keeps hand-edited wording and +adds only the new slots; `--words ` overrides slots by page; +`words.json` in the output is the words as used. See +`runs/tomtervel-invites-2026-09-23`. + | Same contractor, same local model (nemotron 30B-A3B on ergo) | Free-form `generate` | `build` | |---|---|---| | A site the verifier accepts | never, in 6 rounds and 16 min | first try, 130 s | diff --git a/plans/tomtervel.yaml b/plans/tomtervel.yaml index cf15aeb..557ff45 100644 --- a/plans/tomtervel.yaml +++ b/plans/tomtervel.yaml @@ -24,6 +24,10 @@ groups: - { id: pendlerforhold, label: Pendlerforhold } - { id: trafikk, label: Trafikkforhold } +# Styret kan invitere inn i alle grupper som har en pult; hver gruppe kan +# invitere inn i seg selv. Portal oppretter kontoen og en engangslenke. +invites_by: [styret] + doors: - id: medlem who: en husstand i Tomter som vil bli medlem, betale kontingent, eller melde flytting, ny e-post eller utmelding diff --git a/runs/tomtervel-invites-2026-09-23/README.md b/runs/tomtervel-invites-2026-09-23/README.md new file mode 100644 index 0000000..5528dc0 --- /dev/null +++ b/runs/tomtervel-invites-2026-09-23/README.md @@ -0,0 +1,11 @@ +# Tomter Vel: invites on every desk, 2026-09-23 + +The first rebuild of a live site from its plan with `--words-from`: +every word the repo carried (hand-edited Norwegian, model-chosen +icons) lifted back into the words map by structure, the new invite +slots given Norwegian by `invite-words-no.json`, and the result +diffed against the live repo: additions only, 33 lines per committee +desk and 145 on styret's, which invites into all eight groups. + +`words.json` is the words as used; the next rebuild can start from it +with `--words words.json` instead of lifting. diff --git a/runs/tomtervel-invites-2026-09-23/invite-words-no.json b/runs/tomtervel-invites-2026-09-23/invite-words-no.json new file mode 100644 index 0000000..2066ba3 --- /dev/null +++ b/runs/tomtervel-invites-2026-09-23/invite-words-no.json @@ -0,0 +1,40 @@ +{ + "questions/arrangementer/index.yaml": { "invites": { + "arrangementer": { "heading": "Inviter noen inn i Arrangementer", "description": "De får en konto, plass i Arrangementer, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } } + }, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } } +, + "questions/horingsinstans/index.yaml": { "invites": { + "horingsinstans": { "heading": "Inviter noen inn i Høringsinstans", "description": "De får en konto, plass i Høringsinstans, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } } + }, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } } +, + "questions/kasserer/index.yaml": { "invites": { + "kasserer": { "heading": "Inviter noen inn i kassereren", "description": "De får en konto, plass i kassereren, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } } + }, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } } +, + "questions/komiteer/index.yaml": { "invites": { + "komiteer": { "heading": "Inviter noen inn i komiteene", "description": "De får en konto, plass i komiteene, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } } + }, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } } +, + "questions/lekeplasser/index.yaml": { "invites": { + "lekeplasser": { "heading": "Inviter noen inn i Leke- og møteplasser", "description": "De får en konto, plass i Leke- og møteplasser, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } } + }, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } } +, + "questions/nabohjelp/index.yaml": { "invites": { + "nabohjelp": { "heading": "Inviter noen inn i nabohjelpen", "description": "De får en konto, plass i nabohjelpen, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } } + }, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } } +, + "questions/styret/index.yaml": { "invites": { + "arrangementer": { "heading": "Inviter noen inn i Arrangementer", "description": "De får en konto, plass i Arrangementer, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }, + "horingsinstans": { "heading": "Inviter noen inn i Høringsinstans", "description": "De får en konto, plass i Høringsinstans, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }, + "kasserer": { "heading": "Inviter noen inn i kassereren", "description": "De får en konto, plass i kassereren, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }, + "komiteer": { "heading": "Inviter noen inn i komiteene", "description": "De får en konto, plass i komiteene, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }, + "lekeplasser": { "heading": "Inviter noen inn i Leke- og møteplasser", "description": "De får en konto, plass i Leke- og møteplasser, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }, + "nabohjelp": { "heading": "Inviter noen inn i nabohjelpen", "description": "De får en konto, plass i nabohjelpen, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }, + "styret": { "heading": "Inviter noen inn i styret", "description": "De får en konto, plass i styret, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } }, + "trafikk": { "heading": "Inviter noen inn i Trafikkforhold", "description": "De får en konto, plass i Trafikkforhold, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } } + }, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } } +, + "questions/trafikk/index.yaml": { "invites": { + "trafikk": { "heading": "Inviter noen inn i Trafikkforhold", "description": "De får en konto, plass i Trafikkforhold, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", "button": "Inviter", "form_title": "Hvem", "labels": { "name": "Navn", "email": "E-post" } } + }, "invited": { "heading": "Sendte invitasjoner", "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", "done": "Er inne" } } +} diff --git a/runs/tomtervel-invites-2026-09-23/words.json b/runs/tomtervel-invites-2026-09-23/words.json new file mode 100644 index 0000000..bfff02e --- /dev/null +++ b/runs/tomtervel-invites-2026-09-23/words.json @@ -0,0 +1,873 @@ +{ + "questions/arrangementer/index.yaml": { + "buckets": { + "frivillige": { + "description": "Her ser du hvem som har meldt seg til dugnad, 17. mai eller andre arrangementer, og hvor langt du har kommet med hver av dem.", + "heading": "Frivillige til arrangementer og dugnad", + "moves": { + "kontaktet->satt_opp": "Sett opp", + "kontaktet->takket_nei": "Takket nei", + "open->kontaktet": "Merk som kontaktet", + "open->takket_nei": "Takket nei" + } + } + }, + "confirm": "Lagre", + "description": "Oversikt over folk som vil hjelpe til, slik at du kan følge opp og sette dem opp på noe som passer.", + "invited": { + "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", + "done": "Er inne", + "heading": "Sendte invitasjoner" + }, + "invites": { + "arrangementer": { + "button": "Inviter", + "description": "De får en konto, plass i Arrangementer, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i Arrangementer", + "labels": { + "email": "E-post", + "name": "Navn" + } + } + }, + "links": {}, + "question": "Hvem har meldt seg, og hvor langt har jeg kommet med å sette dem opp?" + }, + "questions/bidra/index.yaml": { + "description": "Vil du stille opp for nabolaget? Her melder du deg til dugnad og arrangementer, eller til en av komiteene.", + "needs": { + "hjelpe-til": { + "button": "Meld meg på", + "description": "17. mai, dugnad eller andre arrangementer trenger folk som stiller opp. Si fra hva du kan bidra med, så setter vi deg opp på noe som passer.", + "encouragement": "Det holder med noen ord om hva du kan tenke deg – vi tar kontakt når det nærmer seg.", + "form_description": "Fortell kort hva du kan hjelpe til med.", + "form_title": "Meld deg som hjelper", + "heading": "Bli med på dugnad eller arrangement", + "hints": { + "hva": "", + "navn": "Kari Nordmann", + "telefon": "912 34 567" + }, + "icon": "hand-helping", + "labels": { + "hva": "Hva kan du hjelpe til med?", + "navn": "Navn", + "telefon": "Telefon" + }, + "road_description": "Arrangementskomiteen tar kontakt og finner ut hva som passer for deg. Sier du ja, blir du satt opp på en oppgave – sier du nei takk, er det helt greit.", + "road_icon": "lucide:clipboard-list", + "road_title": "Hva skjer videre", + "who_description": "Arrangementskomiteen leser det du sender, og du kan nå dem på post@tomtervel.no i mellomtiden.", + "who_icon": "lucide:users", + "who_title": "Hvem leser dette" + }, + "komite": { + "button": "Meld interesse", + "description": "Velet drives av sju komiteer på fritida, og alle trenger flere hender. Si hvilken komité du har lyst til å være med i.", + "encouragement": "Det tar bare et minutt, og noen fra styret svarer deg.", + "form_description": "Skriv hvilken komité du er interessert i.", + "form_title": "Meld deg til en komité", + "heading": "Sitt i en komité som betyr noe for deg", + "hints": { + "epost": "kari@eksempel.no", + "komite": "", + "navn": "Kari Nordmann" + }, + "icon": "users", + "labels": { + "epost": "E-post", + "komite": "Hvilken komité?", + "navn": "Navn" + }, + "road_description": "Styret ser på ønsket ditt og finner en plass i komiteen du har meldt deg til, eller sier fra hvis det ikke er rom akkurat nå.", + "road_icon": "lucide:clipboard-list", + "road_title": "Hva skjer videre", + "who_description": "Styret leser det du sender, og du kan nå dem på post@tomtervel.no i mellomtiden.", + "who_icon": "lucide:handshake", + "who_title": "Hvem leser dette" + } + }, + "question": "Vil du hjelpe til i nabolaget?" + }, + "questions/decide/dele-utstyr.yaml": { + "description": "Her sier du fra at utstyret er hentet eller ikke lenger tilgjengelig for utlån.", + "moves": { + "tatt_ned": { + "button": "Ta ned oppslaget", + "description": "Oppslaget fjernes fra siden, så ingen flere spør om å låne det.", + "heading": "Meld at utstyret er tatt ned" + } + }, + "question": "Skal oppslaget om utstyret fortsatt stå ute?" + }, + "questions/decide/kan-hjelpe.yaml": { + "description": "Her ser du naboer som har bedt om en hånd, og kan velge om du fortsatt kan bidra eller trekke deg for nå.", + "moves": { + "trukket": { + "button": "Trekk meg", + "description": "Du blir tatt av lista over naboer som kan spørres, men kan melde deg på igjen når det passer bedre.", + "heading": "Ta en pause fra nabohjelpen" + } + }, + "question": "Vil du fortsatt kunne bli spurt om å hjelpe en nabo?" + }, + "questions/decide/trenger-hjelp.yaml": { + "description": "Her ser du henvendelsen fra naboen som trengte en hånd, og kan si fra når det er ordnet.", + "moves": { + "ordnet": { + "button": "Merk som ordnet", + "description": "Da vet vi at naboen har fått hjelpen sin, og saken lukkes.", + "heading": "Meld fra at hjelpen er gitt" + } + }, + "question": "Har naboen fått hjelpen de trengte?" + }, + "questions/horingsinstans/index.yaml": { + "buckets": { + "innspill": { + "description": "Her ligger innspill fra beboere til saker kommunen har på høring.", + "heading": "Innspill til høringssvar", + "moves": { + "open->ikke_tatt_med": "Ikke tatt med", + "open->tatt_med": "Tatt med i svaret" + } + } + }, + "confirm": "Send svar", + "description": "Her samler du innspillene og ser hva som er tatt med i velets høringssvar.", + "invited": { + "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", + "done": "Er inne", + "heading": "Sendte invitasjoner" + }, + "invites": { + "horingsinstans": { + "button": "Inviter", + "description": "De får en konto, plass i Høringsinstans, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i Høringsinstans", + "labels": { + "email": "E-post", + "name": "Navn" + } + } + }, + "links": {}, + "question": "Hva sier vi i høringssvaret?" + }, + "questions/index.yaml": { + "description": "Tomter Vel tar vare på nærmiljøet – fra elvestien og lekeplassene til trafikksikkerhet og naboskap. Finn det som gjelder deg.", + "doors": { + "bidra": { + "button": "Bli med", + "description": "Vi trenger folk på 17. mai, dugnader og i komiteene. Det trengs ikke mer enn litt tid du har å avse.", + "for_whom": "Deg som vil gjøre en innsats", + "heading": "Bli med og gjør en innsats", + "icon": "hand-helping" + }, + "medlem": { + "button": "Til medlemskap", + "description": "Meld deg inn, betal kontingenten, eller gi beskjed om flytting, ny e-post eller utmelding.", + "for_whom": "Deg som bor i Tomter", + "heading": "Bli medlem eller endre opplysningene dine", + "icon": "house" + }, + "melde-fra": { + "button": "Meld fra", + "description": "Sett du noe farlig i trafikken, eller noe ødelagt på lekeplassen eller elvestien? Gi oss beskjed.", + "for_whom": "Deg som har sett noe som bør fikses", + "heading": "Meld fra om noe som bør fikses", + "icon": "alert-triangle" + }, + "naboer": { + "button": "Til nabohjelp", + "description": "Trenger du en hånd med snømåking, handling eller skyss? Eller kan du stille opp for en nabo? Her møtes dere.", + "for_whom": "Deg som trenger hjelp eller kan gi en hånd", + "heading": "Få hjelp av naboer, eller gi en hånd selv", + "icon": "heart-handshake" + } + }, + "needs": { + "horingsinnspill": { + "button": "Send innspill", + "description": "Har kommunen en sak på høring du mener noe om? Send oss innspillet ditt, så tar vi det med i velets svar.", + "encouragement": "Innspillet ditt blir lest av høringsutvalget vårt og veier inn i svaret vi sender kommunen.", + "form_description": "Fortell hvilken sak det gjelder og hva du mener.", + "form_title": "Send et høringsinnspill", + "heading": "Få din mening med i høringssvaret", + "hints": { + "innspill": "Jeg mener det bør settes opp fartsdumper langs den nye traseen, for barna krysser der til skolen.", + "navn": "Kari Nordmann", + "sak": "Reguleringsplan for Tomterveien" + }, + "icon": "megaphone", + "labels": { + "innspill": "Hva mener du?", + "navn": "Navn", + "sak": "Hvilken sak gjelder det?" + }, + "road_description": "Innspillet ditt kommer inn hos høringsutvalget. De vurderer om det tas med i velets svar til kommunen, eller om det ikke passer inn denne gangen.", + "road_icon": "lucide:route", + "road_title": "Hva skjer videre", + "who_description": "Høringsutvalget i Tomter Vel leser innspillet ditt; i mellomtiden kan du nå styret på post@tomtervel.no.", + "who_icon": "lucide:users", + "who_title": "Hvem leser dette" + } + }, + "question": "Hva gjelder det for deg?" + }, + "questions/kasserer/index.yaml": { + "buckets": { + "husstander": { + "description": "Husstander som har meldt seg inn og venter på å bli registrert som betalende medlem.", + "heading": "Nye medlemmer som venter på registrering", + "moves": { + "open->betalt": "Registrer betaling", + "open->utmeldt": "Meld ut" + } + }, + "medlemsendringer": { + "description": "Medlemmer som har flyttet, byttet e-post eller vil melde seg ut, og venter på at opplysningene blir oppdatert.", + "heading": "Endringer som venter på å bli registrert", + "moves": { + "open->oppdatert": "Registrer endring" + } + }, + "utlegg": { + "description": "Utlegg fra komitémedlemmer som venter på godkjenning eller utbetaling.", + "heading": "Utlegg som venter på deg", + "moves": { + "godkjent->avvist": "Avvis likevel", + "godkjent->utbetalt": "Merk som utbetalt", + "open->avvist": "Avvis utlegg", + "open->godkjent": "Godkjenn utlegg" + } + } + }, + "confirm": "Lagre", + "description": "Her har du oversikt over medlemmer, endringer og utlegg som venter på deg som kasserer.", + "invited": { + "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", + "done": "Er inne", + "heading": "Sendte invitasjoner" + }, + "invites": { + "kasserer": { + "button": "Inviter", + "description": "De får en konto, plass i kassereren, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i kassereren", + "labels": { + "email": "E-post", + "name": "Navn" + } + } + }, + "links": {}, + "question": "Hva venter på meg som kasserer akkurat nå?" + }, + "questions/komiteer/index.yaml": { + "buckets": {}, + "confirm": "Confirm", + "description": "Her holder du oversikt over sakene komiteen din har ansvar for, og flytter dem videre etter hvert som de blir fulgt opp.", + "invited": { + "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", + "done": "Er inne", + "heading": "Sendte invitasjoner" + }, + "invites": { + "komiteer": { + "button": "Inviter", + "description": "De får en konto, plass i komiteene, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i komiteene", + "labels": { + "email": "E-post", + "name": "Navn" + } + } + }, + "links": { + "utlegg": { + "button": "Send utlegg", + "description": "Har du lagt ut for noe til komiteen? Send det inn så kasserer kan betale deg tilbake.", + "heading": "Få tilbake det du har lagt ut" + } + }, + "question": "Hva står ubehandlet i komiteen din nå?" + }, + "questions/komiteer/utlegg-sent.yaml": { + "back": "Til forsiden", + "description": "Utlegget er sendt til kasserer. Du får beskjed når det er sjekket.", + "next_description": "Kasserer ser gjennom kvitteringen og overfører pengene til kontoen du oppga.", + "next_heading": "Pengene kommer på konto", + "question": "Hva skjer med utlegget mitt nå?" + }, + "questions/komiteer/utlegg.yaml": { + "button": "Send inn", + "description": "Har du lagt ut for noe til komiteen? Send det inn, så ordner kasserer resten.", + "encouragement": "Legg ved bilag om du har det, så går det raskere.", + "form_description": "Ha kvittering eller bilag klart før du fyller ut.", + "form_title": "Krav om refusjon", + "heading": "Få pengene tilbake", + "hints": { + "belop": "450 kr", + "hva": "Kaffe og kjeks til dugnaden", + "navn": "Kari Nordmann" + }, + "icon": "receipt", + "labels": { + "belop": "Beløp", + "hva": "Hva gjaldt det", + "navn": "Navn" + }, + "page_description": "Har du betalt for noe på vegne av en komité? Send inn utlegget, så får du pengene tilbake.", + "question": "Har du lagt ut penger for komiteen og vil ha dem tilbake?", + "road_description": "Kasserer ser gjennom kravet og godkjenner det, og deretter blir pengene utbetalt til deg. Blir det avvist, hører du hvorfor.", + "road_icon": "lucide:wallet", + "road_title": "Fra krav til utbetaling", + "who_description": "Kasserer i styret leser kravet ditt, og kan nås på post@tomtervel.no om det haster.", + "who_icon": "lucide:coins", + "who_title": "Kasserer ser dette" + }, + "questions/lekeplasser/index.yaml": { + "buckets": { + "anleggsfeil": { + "description": "Meldinger om noe som er ødelagt på lekeplassen eller langs stien, og som bør fikses.", + "heading": "Det som er meldt inn av skader", + "moves": { + "open->tatt_tak_i": "Ta tak i saken", + "tatt_tak_i->utbedret": "Merk som utbedret" + } + } + }, + "confirm": "Lagre", + "description": "Her følger komiteen for lekeplassene opp det som er meldt inn av skader, til det er fikset.", + "invited": { + "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", + "done": "Er inne", + "heading": "Sendte invitasjoner" + }, + "invites": { + "lekeplasser": { + "button": "Inviter", + "description": "De får en konto, plass i Leke- og møteplasser, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i Leke- og møteplasser", + "labels": { + "email": "E-post", + "name": "Navn" + } + } + }, + "links": {}, + "question": "Hva må fikses på lekeplassene og stien nå?" + }, + "questions/medlem/index.yaml": { + "description": "Bli medlem av Tomter Vel, eller oppdater opplysningene dine hvis du allerede er medlem.", + "needs": { + "bli-medlem": { + "button": "Bli medlem", + "description": "Meld deg inn i velet, så holder vi deg oppdatert og du støtter arbeidet i nærmiljøet.", + "encouragement": "Kasserer sender deg kontingenten på e-post rett etterpå.", + "form_description": "Ha adressen i Tomter og en e-post du bruker klar.", + "form_title": "Meld deg inn", + "heading": "Bli medlem av Tomter Vel", + "hints": { + "adresse": "Tomterveien 12", + "epost": "ola.nordmann@epost.no", + "navn": "Ola Nordmann", + "telefon": "123 45 678" + }, + "icon": "user-plus", + "labels": { + "adresse": "Adresse", + "epost": "E-post", + "navn": "Navn", + "telefon": "Telefon" + }, + "road_description": "Meldingen går til kasserer, som registrerer deg og sender deg kontingenten på e-post. Når den er betalt er du medlem.", + "road_icon": "lucide:coins", + "road_title": "Etter innmelding", + "who_description": "Kasserer i styret leser dette, og du kan nå oss på post@tomtervel.no i mellomtiden.", + "who_icon": "lucide:user", + "who_title": "Hvem svarer deg" + }, + "endre-medlemskap": { + "button": "Send endring", + "description": "Har du flyttet, byttet e-post eller vil melde deg ut? Gi oss beskjed så vi har riktige opplysninger.", + "encouragement": "Det tar bare et par minutter, og kasserer ordner resten.", + "form_description": "Skriv navnet ditt og hva som er nytt.", + "form_title": "Endre medlemskap", + "heading": "Oppdater eller avslutt medlemskapet", + "hints": { + "adresse": "Ny adresse i Tomter", + "hva_er_nytt": "Ny e-post, ny adresse, eller ønske om utmelding", + "navn": "Ola Nordmann" + }, + "icon": "pencil", + "labels": { + "adresse": "Adresse", + "hva_er_nytt": "Hva vil du endre?", + "navn": "Navn" + }, + "road_description": "Kasserer får beskjeden og oppdaterer medlemslisten, eller melder deg ut om det er det du ber om.", + "road_icon": "lucide:refresh-cw", + "road_title": "Etter du sender inn", + "who_description": "Kasserer i styret leser dette, og du kan nå oss på post@tomtervel.no i mellomtiden.", + "who_icon": "lucide:user", + "who_title": "Hvem svarer deg" + } + }, + "question": "Vil du bli medlem, eller endre medlemskapet ditt?" + }, + "questions/melde-fra/index.yaml": { + "description": "Har du sett noe farlig i trafikken, eller noe ødelagt på lekeplassen eller stien? Her sier du fra til velet.", + "needs": { + "anleggsfeil": { + "button": "Send inn", + "description": "Se noe ødelagt på en lekeplass eller langs elvestien, så tar lekeplasskomiteen tak i det.", + "encouragement": "Det holder med noen ord om hva du så og hvor.", + "form_description": "Fortell hva som er ødelagt og omtrent hvor det er.", + "form_title": "Meld anleggsfeil", + "heading": "Meld fra om noe ødelagt", + "hints": { + "hva": "Løs planke på gjerdet ved husken", + "hvor": "" + }, + "icon": "construction", + "labels": { + "hva": "Hva er ødelagt", + "hvor": "Hvor er det" + }, + "road_description": "Meldingen går til lekeplasskomiteen, som ser på den og tar tak i saken. Når feilen er utbedret, er saken avsluttet.", + "road_icon": "lucide:wrench", + "road_title": "Hva skjer videre", + "who_description": "Lekeplasskomiteen leser meldingen din; til det haster kan du nå styret på post@tomtervel.no.", + "who_icon": "lucide:hammer", + "who_title": "Hvem leser dette" + }, + "trafikkfare": { + "button": "Send inn", + "description": "Sett du noe farlig i trafikken? Fortell trafikkomiteen om det, så tar de det videre.", + "encouragement": "Bare noen linjer om hva og hvor er nok til at vi kan følge opp.", + "form_description": "Fortell hva du så, hvor det var, og legg gjerne igjen e-posten din hvis vi trenger å spørre mer.", + "form_title": "Meld trafikkfare", + "heading": "Meld fra om en trafikkfare", + "hints": { + "epost": "ola.nordmann@epost.no", + "hva": "Dårlig sikt i krysset, biler kjører fort", + "hvor": "Krysset Tomterveien / Elvestien" + }, + "icon": "alert-triangle", + "labels": { + "epost": "E-post (valgfritt)", + "hva": "Hva så du", + "hvor": "Hvor var det" + }, + "road_description": "Trafikkomiteen tar meldingen din opp med kommunen eller den som eier veien. Du får vite om saken blir ordnet eller avvist.", + "road_icon": "lucide:route", + "road_title": "Hva skjer videre", + "who_description": "Trafikkomiteen leser meldingen din; haster det, kan du nå styret på post@tomtervel.no.", + "who_icon": "lucide:car", + "who_title": "Hvem leser dette" + } + }, + "question": "Hva vil du melde fra om?" + }, + "questions/naboer/index.yaml": { + "description": "Godt naboskap gjør hverdagen lettere. Trenger du en hånd, kan gi en, eller har noe å låne bort?", + "needs": { + "dele-utstyr": { + "button": "Legg ut oppslag", + "description": "Har du noe naboene kan få låne, eller trenger du å låne noe selv? Legg ut et oppslag så ser de andre på Tomter det.", + "encouragement": "Vi legger det ut så naboene kan se det.", + "form_description": "Skriv kort hva det gjelder, så folk skjønner det med en gang.", + "form_title": "Lån eller utlån", + "heading": "Lån ut eller lån av naboen", + "hints": { + "email": "ola@eksempel.no", + "har_eller_trenger": "", + "hva": "Hengerfeste til bil, tilhenger, stige", + "navn": "Ola Nordmann" + }, + "icon": "package", + "labels": { + "email": "E-post", + "har_eller_trenger": "Har du eller trenger du", + "hva": "Hva gjelder det", + "navn": "Navn" + }, + "road_description": "Oppslaget legges ut for naboene å se. Når det er ordnet eller ikke lenger aktuelt, tas det ned igjen.", + "road_icon": "lucide:clipboard-list", + "road_title": "Fra oppslag til avtale", + "who_description": "Styret legger ut oppslaget og kan nås på post@tomtervel.no i mellomtiden.", + "who_icon": "lucide:users", + "who_title": "Hvem ser dette" + }, + "kan-hjelpe": { + "button": "Meld deg", + "description": "Kan du stille opp med snømåking, en handletur eller skyss når en nabo trenger det? Meld deg, så blir du spurt når det passer.", + "encouragement": "Du blir bare kontaktet når noe passer for deg.", + "form_description": "Fortell kort hva du kan bidra med.", + "form_title": "Meld deg som hjelper", + "heading": "Bli spurt når en nabo trenger en hånd", + "hints": { + "email": "kari@eksempel.no", + "kan": "", + "navn": "Kari Nordmann", + "telefon": "123 45 678" + }, + "icon": "hand-helping", + "labels": { + "email": "E-post", + "kan": "Hva kan du hjelpe med", + "navn": "Navn", + "telefon": "Telefon" + }, + "road_description": "Du blir registrert som hjelper. Trekker du deg tilbake senere, er det bare å si fra.", + "road_icon": "lucide:user-check", + "road_title": "Fra påmelding til spørsmål", + "who_description": "Nabohjelpen har oversikten og ringer deg direkte når det er noe, eller nås på post@tomtervel.no.", + "who_icon": "lucide:heart-handshake", + "who_title": "Hvem ser dette" + }, + "trenger-hjelp": { + "button": "Be om hjelp", + "description": "Får du ikke til snømåking, en handletur eller skyss for tiden? Si fra, så finner vi en nabo som kan stille opp.", + "encouragement": "Vi finner en nabo som kan stille opp så fort som mulig.", + "form_description": "Skriv kort hva du trenger hjelp til.", + "form_title": "Be om en hånd", + "heading": "Få en nabo til å stille opp", + "hints": { + "adresse": "Tomterveien 12", + "email": "kari@eksempel.no", + "hva": "Måke snø på trappa, handle inn litt mat", + "navn": "Kari Nordmann", + "telefon": "123 45 678" + }, + "icon": "heart-handshake", + "labels": { + "adresse": "Adresse", + "email": "E-post", + "hva": "Hva trenger du hjelp til", + "navn": "Navn", + "telefon": "Telefon" + }, + "road_description": "Nabohjelpen finner en nabo som kan stille opp. Er det ordnet, eller om det ikke lot seg løse, får du beskjed.", + "road_icon": "lucide:hand-helping", + "road_title": "Fra melding til hjelp", + "who_description": "Nabohjelpen tar imot og finner noen til deg, eller ring post@tomtervel.no i mellomtiden.", + "who_icon": "lucide:users", + "who_title": "Hvem ser dette" + } + }, + "question": "Trenger du en hånd, eller kan du gi en?" + }, + "questions/nabohjelp/index.yaml": { + "buckets": { + "hjelpebehov": { + "description": "Naboer som trenger en hånd med snømåking, handling eller skyss – klare til å bli koblet med noen som kan hjelpe.", + "heading": "Naboer som trenger en hånd", + "moves": { + "formidlet->ikke_lost": "Meld ikke løst", + "formidlet->ordnet": "Meld ordnet", + "open->formidlet": "Koble til nabo", + "open->ikke_lost": "Meld ikke løst" + } + }, + "hjelpere": { + "description": "Naboer som har sagt de kan stille opp – klare til å bli spurt når noen trenger dem.", + "heading": "Naboer som kan stille opp", + "moves": { + "open->registrert": "Registrer som hjelper", + "open->trukket": "Meld trukket" + } + } + }, + "confirm": "Lagre", + "description": "Her kobler du naboer som trenger hjelp med dem som kan stille opp.", + "invited": { + "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", + "done": "Er inne", + "heading": "Sendte invitasjoner" + }, + "invites": { + "nabohjelp": { + "button": "Inviter", + "description": "De får en konto, plass i nabohjelpen, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i nabohjelpen", + "labels": { + "email": "E-post", + "name": "Navn" + } + } + }, + "links": {}, + "question": "Hvem trenger hjelp, og hvem kan stille opp?" + }, + "questions/received/anleggsfeil.yaml": { + "back": "Tilbake til forsiden", + "description": "Meldingen din om skaden er sendt til lekeplasskomiteen. De ser over det du har sendt.", + "next_description": "Lekeplasskomiteen ser på meldingen og tar tak i det som må fikses før noen skader seg.", + "next_heading": "Komiteen tar tak i det", + "question": "Hva skjer med meldingen min nå?", + "see_all": { + "button": "Se meldte feil", + "description": "Se hva andre har meldt inn av skader på lekeplassene og stien, og hvor langt det har kommet.", + "heading": "Se andre meldte feil" + } + }, + "questions/received/bli-medlem.yaml": { + "back": "Tilbake til forsiden", + "description": "Vi har mottatt innmeldingen din. Kasserer tar kontakt med betalingsinfo så snart medlemskapet er registrert.", + "next_description": "Kasserer går gjennom nye innmeldinger og sender deg kontonummer og beløp for kontingenten.", + "next_heading": "Kasserer sender deg betalingsinfo", + "question": "Hva skjer etter at jeg har meldt meg inn?" + }, + "questions/received/dele-utstyr.yaml": { + "back": "Tilbake til forsiden", + "description": "Takk for at du la ut noe naboene kan låne. Vi legger det ut på oppslaget så snart vi kan.", + "next_description": "En i styret legger utstyret ut på naboenes oppslag, så andre kan se det og spørre om å låne.", + "next_heading": "Styret legger det ut for deg", + "question": "Hva skjer med det jeg har lagt ut?", + "see_all": { + "button": "Se hva naboer deler", + "description": "Se hva andre i Tomter har lagt ut til utlån akkurat nå.", + "heading": "Se alt naboene deler" + } + }, + "questions/received/endre-medlemskap.yaml": { + "back": "Tilbake til forsiden", + "description": "Vi har mottatt beskjeden om at du har flyttet, byttet e-post eller vil melde deg ut.", + "next_description": "Kasserer oppdaterer medlemslista og gir deg beskjed hvis det er noe mer vi trenger fra deg.", + "next_heading": "Kasserer retter opp opplysningene", + "question": "Hva skjer nå med det jeg meldte inn?" + }, + "questions/received/hjelpe-til.yaml": { + "back": "Tilbake til forsiden", + "description": "Takk for at du vil hjelpe til. Arrangementskomiteen har fått beskjed og tar kontakt med deg.", + "next_description": "Du hører fra noen i komiteen når de vet hva de trenger hjelp til, gjerne noen dager før arrangementet eller dugnaden.", + "next_heading": "Komiteen tar kontakt", + "question": "Hva skjer nå som jeg har meldt meg?" + }, + "questions/received/horingsinnspill.yaml": { + "back": "Tilbake til forsiden", + "description": "Innspillet ditt er sendt til høringsinstansen i velet.", + "next_description": "De ser på om det kan tas med i velets høringssvar til kommunen, og gir beskjed om det blir med.", + "next_heading": "Innspillet ditt inn i vurderingen", + "question": "Hva skjer med det jeg har sagt om saken?" + }, + "questions/received/kan-hjelpe.yaml": { + "back": "Tilbake til forsiden", + "description": "Takk for at du sier fra at du kan stille opp. Nabohjelp-komiteen tar imot meldingen og legger deg inn på lista over naboer som kan hjelpe.", + "next_description": "Når en nabo trenger hjelp med noe du har sagt du kan bidra med, tar komiteen kontakt med deg direkte.", + "next_heading": "Du blir kontaktet ved behov", + "question": "Hva skjer nå som jeg har registrert meg?" + }, + "questions/received/komite.yaml": { + "back": "Tilbake til forsiden", + "description": "Takk for at du vil bidra! Styret har fått beskjed og ser på hvilken komité som passer best for deg.", + "next_description": "Noen fra styret tar kontakt for å høre mer om hva du liker å drive med, og finner en komité som passer.", + "next_heading": "Vi finner riktig komité til deg", + "question": "Hva skjer nå som jeg har meldt interesse for en komité?" + }, + "questions/received/trafikkfare.yaml": { + "back": "Tilbake til forsiden", + "description": "Meldingen din om trafikkfaren er sendt til komiteen for trafikk.", + "next_description": "Trafikkomiteen ser gjennom meldinger jevnlig og tar det videre med kommunen eller den som eier veien der det trengs.", + "next_heading": "Trafikkomiteen tar den videre", + "question": "Hva skjer med meldingen min nå?", + "see_all": { + "button": "Se andre trafikkfarer", + "description": "Se hva andre har meldt inn, og status på sakene.", + "heading": "Andre meldte trafikkfarer" + } + }, + "questions/received/trenger-hjelp.yaml": { + "back": "Til forsiden", + "description": "Meldingen din er sendt til nabohjelpa. Vi ser etter en nabo som kan stille opp.", + "next_description": "Vi tar kontakt med noen som kan hjelpe, så snart vi finner en som passer.", + "next_heading": "Vi finner en nabo til deg", + "question": "Hva skjer nå som jeg har bedt om hjelp?" + }, + "questions/status/anleggsfeil/index.yaml": { + "back": "Tilbake til meldinger", + "description": "Her ser du meldinger om ødelagt lekeutstyr og skader på elvestien, og hvor langt hver sak har kommet.", + "heading": "Status på meldte anleggsfeil", + "list_description": "Alle meldinger er åpne for alle å se.", + "question": "Hva skjer med det jeg har meldt fra om?" + }, + "questions/status/oppslag/index.yaml": { + "back": "Tilbake", + "description": "Her ser du alt som er sendt inn og hvor langt hvert oppslag har kommet.", + "heading": "Oversikt over det som er meldt inn", + "list_description": "Åpen liste – navn og detaljer vises ikke.", + "question": "Hva er sendt inn, og hvor langt har det kommet?" + }, + "questions/status/trafikkmeldinger/index.yaml": { + "back": "Tilbake", + "description": "Her ser du farlige steder i trafikken som er meldt inn, og hvor langt saken har kommet.", + "heading": "Trafikkmeldinger og status", + "list_description": "Alle meldinger som er sendt inn, og hvor de står nå.", + "question": "Er det meldt fra om noe farlig i trafikken der jeg bor?" + }, + "questions/styret/index.yaml": { + "buckets": { + "innspill": { + "description": "Her samles meninger fra beboerne om saker kommunen har på høring, klare til å tas med i velets svar.", + "heading": "Høringsinnspill fra beboerne", + "moves": {} + }, + "komitesoknader": { + "description": "Her ligger alle som har meldt seg til en komité og venter på en plass.", + "heading": "Søknader til komiteene", + "moves": { + "open->avslatt": "Avslå søknaden", + "open->plassert": "Sett i komité" + } + }, + "oppslag": { + "description": "Her ligger det naboer har å låne ut eller trenger å låne, klart til å legges ut.", + "heading": "Utstyr til utlån blant naboer", + "moves": { + "open->lagt_ut": "Legg ut oppslaget", + "open->tatt_ned": "Ta ned oppslaget" + } + }, + "trafikkmeldinger": { + "description": "Her samles meldinger om farlige steder i trafikken, klare til å tas videre med kommunen eller veieier.", + "heading": "Meldinger om trafikkfarer", + "moves": {} + } + }, + "confirm": "Lagre", + "description": "Herfra følger styret opp det som ikke hører under en enkelt komité: høringssvar, komitéplasser og oppslag fra naboer.", + "invited": { + "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", + "done": "Er inne", + "heading": "Sendte invitasjoner" + }, + "invites": { + "arrangementer": { + "button": "Inviter", + "description": "De får en konto, plass i Arrangementer, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i Arrangementer", + "labels": { + "email": "E-post", + "name": "Navn" + } + }, + "horingsinstans": { + "button": "Inviter", + "description": "De får en konto, plass i Høringsinstans, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i Høringsinstans", + "labels": { + "email": "E-post", + "name": "Navn" + } + }, + "kasserer": { + "button": "Inviter", + "description": "De får en konto, plass i kassereren, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i kassereren", + "labels": { + "email": "E-post", + "name": "Navn" + } + }, + "komiteer": { + "button": "Inviter", + "description": "De får en konto, plass i komiteene, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i komiteene", + "labels": { + "email": "E-post", + "name": "Navn" + } + }, + "lekeplasser": { + "button": "Inviter", + "description": "De får en konto, plass i Leke- og møteplasser, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i Leke- og møteplasser", + "labels": { + "email": "E-post", + "name": "Navn" + } + }, + "nabohjelp": { + "button": "Inviter", + "description": "De får en konto, plass i nabohjelpen, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i nabohjelpen", + "labels": { + "email": "E-post", + "name": "Navn" + } + }, + "styret": { + "button": "Inviter", + "description": "De får en konto, plass i styret, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i styret", + "labels": { + "email": "E-post", + "name": "Navn" + } + }, + "trafikk": { + "button": "Inviter", + "description": "De får en konto, plass i Trafikkforhold, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i Trafikkforhold", + "labels": { + "email": "E-post", + "name": "Navn" + } + } + }, + "links": {}, + "question": "Hva venter på styret akkurat nå?" + }, + "questions/trafikk/index.yaml": { + "buckets": { + "trafikkmeldinger": { + "description": "Farlige steder i trafikken som naboer har meldt inn.", + "heading": "Farlige steder i trafikken", + "moves": { + "open->avvist": "Avvis meldingen", + "open->tatt_opp": "Ta opp med kommunen", + "tatt_opp->avvist": "Avvis meldingen", + "tatt_opp->ordnet": "Meld ordnet" + } + } + }, + "confirm": "Lagre", + "description": "Her ser du meldinger om farlige steder i trafikken og følger dem opp overfor kommunen eller veieier.", + "invited": { + "description": "Hver invitasjon med lenken som ble laget. Marker som ferdig når personen er inne.", + "done": "Er inne", + "heading": "Sendte invitasjoner" + }, + "invites": { + "trafikk": { + "button": "Inviter", + "description": "De får en konto, plass i Trafikkforhold, og en engangslenke der de selv velger passord eller passnøkkel. Send lenken videre til dem.", + "form_title": "Hvem", + "heading": "Inviter noen inn i Trafikkforhold", + "labels": { + "email": "E-post", + "name": "Navn" + } + } + }, + "links": {}, + "question": "Hvilke trafikkfarer må følges opp?" + } +} \ No newline at end of file diff --git a/src/build.rs b/src/build.rs index 99d4c29..ff7f763 100644 --- a/src/build.rs +++ b/src/build.rs @@ -49,6 +49,14 @@ pub struct Plan { /// never disagree about what the committees are. #[serde(default)] pub groups: Vec, + /// Groups that may invite people into every group with a desk (the + /// board), besides each group inviting into itself. An inviting + /// group's desk gets one invite form per group it may fill (portal + /// creates the account and a one-time credential link) and a list + /// of the invites sent. This is onboarding from the content side: + /// no admin session, a member of the business vouching for a person. + #[serde(default)] + pub invites_by: Vec, pub needs: Vec, #[serde(default)] pub personas: Vec, @@ -63,6 +71,10 @@ fn english() -> String { pub struct GroupDef { pub id: String, pub label: String, + /// Groups that may invite people into this one, besides itself and + /// the plan's `invites_by`. + #[serde(default)] + pub invited_by: Vec, } #[derive(Debug, Deserialize)] @@ -190,6 +202,11 @@ impl Plan { bail!("group {:?}: id must be unique ASCII lowercase with - or _, and it needs a label", g.id); } } + for g in self.invites_by.iter().chain(self.groups.iter().flat_map(|g| g.invited_by.iter())) { + if !groups(self).contains(g) { + bail!("{g:?} may invite, but has no desk to invite from: it must handle, watch or move something"); + } + } let mut seen = BTreeSet::new(); for need in &self.needs { let at = |what: &str| format!("need {:?}: {what}", need.id); @@ -477,9 +494,29 @@ pub fn fallback_words(plan: &Plan) -> BTreeMap { .filter(|n| n.enters_as.as_deref() == Some(group.as_str())) .map(|n| (n.id.clone(), json!({ "heading": n.wants, "description": "", "button": "Open" }))) .collect(); + // One invite form per group this desk may fill, and the list of + // invites sent, when it may fill any. + let invites: Map = groups(plan) + .iter() + .filter(|g| inviters(plan, g).contains(&group)) + .map(|g| { + (g.clone(), json!({ + "heading": format!("Invite someone into {}", label_of(plan, g)), + "description": format!("They get an account, a place in {}, and a one-time link to set their own password or passkey.", label_of(plan, g)), + "button": "Invite", + "form_title": "Who", + "labels": { "name": "Name", "email": "Email" }, + })) + }) + .collect(); + let invited = if invites.is_empty() { + Value::Null + } else { + json!({ "heading": "Invitations sent", "description": "Each invite with the link it made. Mark it done when the person is in.", "done": "Is in" }) + }; words.insert( format!("questions/{group}/index.yaml"), - json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links }), + json!({ "question": "What needs doing?", "description": "", "confirm": "Confirm", "buckets": buckets, "links": links, "invites": invites, "invited": invited }), ); } // A public view per bucket the plan opens: a read-only list. @@ -567,6 +604,22 @@ fn groups(plan: &Plan) -> BTreeSet { .collect() } +/// The groups whose desks carry an invite form into `group`: itself, +/// the plan's `invites_by`, and its own `invited_by`, each of which +/// must have a desk to carry it on. +fn inviters(plan: &Plan, group: &str) -> BTreeSet { + let desks = groups(plan); + std::iter::once(group.to_string()) + .chain(plan.invites_by.iter().cloned()) + .chain(plan.groups.iter().filter(|g| g.id == group).flat_map(|g| g.invited_by.iter().cloned())) + .filter(|g| desks.contains(g)) + .collect() +} + +fn label_of(plan: &Plan, group: &str) -> String { + plan.groups.iter().find(|g| g.id == group).map(|g| g.label.clone()).unwrap_or_else(|| humanize(group)) +} + /// Buckets the public may read, with the needs that fill them. fn public_buckets(plan: &Plan) -> Vec<(&str, Vec<&PlanNeed>)> { plan.by_bucket().into_iter().filter(|(_, needs)| needs.iter().any(|n| n.watched_by.iter().any(|g| g == PUBLIC))).collect() @@ -655,6 +708,11 @@ pub const ICONS: &[&str] = &[ /// `lucide:` for a name on the list, otherwise nothing. pub fn icon_name(given: &str) -> Option { + // A lift token (see `lift_words`) has to reach the rendered file to + // be matched against the repo's own icon. + if given.starts_with('\u{1}') { + return Some(given.to_string()); + } let name = given.trim().trim_start_matches("lucide:"); ICONS.contains(&name).then(|| format!("lucide:{name}")) } @@ -861,6 +919,40 @@ pub fn render(plan: &Plan, words: &BTreeMap) -> anyhow::Result = inv.keys().collect(); + targets.sort(); + for target in targets { + let iw = &inv[target]; + alternatives.push(json!({ + "name": s(&iw["heading"]), + "description": s(&iw["description"]), + "action": format!("/{group}"), + "consequence": [s(&iw["button"])], + "invite": { "group": target }, + "features": [{ "name": s(&iw["form_title"]), "description": "", "requirements": [ + { "name": "name", "label": s(&iw["labels"]["name"]) }, + { "name": "email", "label": s(&iw["labels"]["email"]), "type": "email" }, + ]}], + })); + } + let lw = &w["invited"]; + alternatives.push(json!({ + "name": s(&lw["heading"]), + "description": s(&lw["description"]), + "action": format!("/{group}"), + "consequence": [s(&w["confirm"])], + "features": [{ "name": "", "description": "", "resource": { + "source": { "kind": "kv", "bucket": "portal_invites" }, + "requires_group": group, + "transitions": [{ "from": "open", "to": "done", "label": s(&lw["done"]) }], + }}], + })); + } files.insert(format!("questions/{group}/_section.yaml"), json!({ "qualifies": group })); files.insert( format!("questions/{group}/index.yaml"), @@ -975,7 +1067,7 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String { } else if plan.doors.iter().any(|d| door_path(d) == path) { "a page one kind of visitor reaches from the front door. `question` is what this page asks them; under `needs`, each entry is one alternative they can pick, with the form behind it." } else if path.ends_with("/index.yaml") { - "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." + "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." } else if path.contains("/received/") || path.ends_with("-sent.yaml") { "a page shown right after someone has sent a form. Say who reads it, when they answer, and what happens next." } else { @@ -1006,14 +1098,33 @@ fn words_prompt(plan: &Plan, path: &str, fallback: &Value) -> String { pub fn run(argv: &[String]) -> anyhow::Result<()> { let value = |flag: &str| argv.iter().position(|a| a == flag).and_then(|i| argv.get(i + 1)).cloned(); - let usage = "usage: portal-trainer build --plan --out [--llm 'cmd'] [--lint question_lint]"; + let usage = "usage: portal-trainer build --plan --out [--words-from ] [--words ] [--llm 'cmd'] [--lint question_lint]"; let plan_path = value("--plan").ok_or_else(|| anyhow!(usage))?; let out = std::path::PathBuf::from(value("--out").ok_or_else(|| anyhow!(usage))?); let plan: Plan = serde_yaml::from_str(&std::fs::read_to_string(&plan_path).with_context(|| format!("reading {plan_path}"))?) .with_context(|| format!("parsing {plan_path}"))?; plan.validate()?; - let mut words = fallback_words(&plan); + // Words, in layers: the fallbacks; what an existing repo already + // says (hand edits survive a rebuild); explicit overrides for new + // slots; and only then a model, for whatever is still fallback. + let mut words = match value("--words-from") { + Some(repo) => lift_words(&plan, Path::new(&repo))?, + None => fallback_words(&plan), + }; + if let Some(path) = value("--words") { + let given: BTreeMap = serde_json::from_str(&std::fs::read_to_string(&path).with_context(|| format!("reading {path}"))?) + .with_context(|| format!("parsing {path}"))?; + for (page, v) in given { + match words.get(&page) { + Some(base) => { + let merged = merge_words(base, &v); + words.insert(page, merged); + } + None => eprintln!("words: {page} is not a page of this plan, ignored"), + } + } + } if let Some(cmd) = value("--llm") { std::fs::create_dir_all(out.join("llm"))?; for (n, (path, fallback)) in words.clone().iter().enumerate() { @@ -1030,6 +1141,8 @@ pub fn run(argv: &[String]) -> anyhow::Result<()> { } } write_repo(&out, render(&plan, &words)?)?; + // 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()); if let Some(lint) = value("--lint") { @@ -1297,3 +1410,123 @@ mod tests { assert!(plan.validate().unwrap_err().to_string().contains("must agree")); } } + +/// The words a repo already carries, lifted back into the words map by +/// structure: the plan is rendered with a token in every word slot, +/// each rendered file is walked alongside the repo's file of the same +/// path, and wherever a token meets a string, that string is the word. +/// Pages the repo does not have, and slots it never had (a new invite +/// form), keep their fallback. Hand-edited wording survives a rebuild. +pub fn lift_words(plan: &Plan, repo: &Path) -> anyhow::Result> { + let fallback = fallback_words(plan); + let tokened: BTreeMap = + fallback.iter().map(|(path, v)| (path.clone(), tokenize(v, path, ""))).collect(); + let rendered = render(plan, &tokened)?; + let mut found: std::collections::HashMap = std::collections::HashMap::new(); + for (path, text) in &rendered { + let Ok(live) = std::fs::read_to_string(repo.join(path)) else { continue }; + let (Ok(a), Ok(b)) = ( + serde_yaml::from_str::(text), + serde_yaml::from_str::(&live), + ) else { + continue; + }; + collect_words(&a, &b, &mut found); + } + Ok(fallback.iter().map(|(path, v)| (path.clone(), untokenize(v, path, "", &found))).collect()) +} + +fn token(path: &str, pointer: &str) -> String { + format!("\u{1}{path}\u{2}{pointer}\u{3}") +} + +fn tokenize(v: &Value, path: &str, pointer: &str) -> Value { + match v { + Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), tokenize(x, path, &format!("{pointer}/{k}")))).collect()), + Value::String(_) => Value::String(token(path, pointer)), + other => other.clone(), + } +} + +fn untokenize(v: &Value, path: &str, pointer: &str, found: &std::collections::HashMap) -> Value { + match v { + Value::Object(m) => Value::Object(m.iter().map(|(k, x)| (k.clone(), untokenize(x, path, &format!("{pointer}/{k}"), found))).collect()), + Value::String(_) => match found.get(&token(path, pointer)) { + Some(word) => Value::String(word.clone()), + None => v.clone(), + }, + other => other.clone(), + } +} + +fn collect_words(a: &serde_yaml::Value, b: &serde_yaml::Value, found: &mut std::collections::HashMap) { + match (a, b) { + (serde_yaml::Value::Mapping(x), serde_yaml::Value::Mapping(y)) => { + for (k, va) in x { + if let Some(vb) = y.get(k) { + collect_words(va, vb, found); + } + } + } + (serde_yaml::Value::Sequence(x), serde_yaml::Value::Sequence(y)) => { + for (va, vb) in x.iter().zip(y) { + collect_words(va, vb, found); + } + } + (serde_yaml::Value::String(t), serde_yaml::Value::String(live)) if t.starts_with('\u{1}') => { + // An icon slot holds the bare name; the file carries `lucide:`. + let word = if t.ends_with("/icon\u{3}") { live.trim_start_matches("lucide:") } else { live.as_str() }; + found.insert(t.clone(), word.to_string()); + } + _ => {} + } +} + +#[cfg(test)] +mod invite_tests { + use super::*; + + const PLAN: &str = "business: Vel\ncontact: { name: S, email: s@v.example }\ngroups:\n - { id: trafikk, label: Trafikk }\ninvites_by: [styret]\nneeds:\n - id: melde\n who: a neighbour\n wants: a hazard seen to\n lands_in: farer\n handled_by: trafikk\n stages: [open, ordnet]\n done_when: [ordnet]\n max_fields: 3\n collects:\n - { name: hvor }\n - id: utlegg\n who: a committee member\n wants: money back\n enters_as: trafikk\n lands_in: utlegg\n handled_by: styret\n stages: [open, betalt, avvist]\n done_when: [betalt, avvist]\n max_fields: 3\n collects:\n - { name: belop }\n"; + + #[test] + fn inviting_desks_get_a_form_per_group_and_a_list_of_invites_sent() { + let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); + plan.validate().unwrap(); + let files = render(&plan, &fallback_words(&plan)).unwrap(); + // The board invites into both groups; trafikk only into itself. + let styret = &files["questions/styret/index.yaml"]; + assert_eq!(styret.matches("invite:").count(), 2, "{styret}"); + assert!(styret.contains("bucket: portal_invites")); + let trafikk = &files["questions/trafikk/index.yaml"]; + assert_eq!(trafikk.matches("invite:").count(), 1); + assert!(trafikk.contains("group: trafikk") && !trafikk.contains("group: styret")); + // The plan is refused when an inviter has no desk. + let bad: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [styret]", "invites_by: [nobody]")).unwrap(); + assert!(bad.validate().unwrap_err().to_string().contains("no desk")); + } + + #[test] + fn words_lift_back_out_of_a_rendered_repo_and_new_slots_keep_their_fallback() { + let plan: Plan = serde_yaml::from_str(PLAN).unwrap(); + let mut words = fallback_words(&plan); + // A hand-edited desk: Norwegian words in the live repo. + let desk = words.get_mut("questions/trafikk/index.yaml").unwrap(); + desk["question"] = json!("Hva må gjøres?"); + desk["buckets"]["farer"]["heading"] = json!("Meldte farer"); + desk["buckets"]["farer"]["moves"]["open->ordnet"] = json!("Ordnet"); + let dir = std::env::temp_dir().join(format!("lift-{}", std::process::id())); + write_repo(&dir, render(&plan, &words).unwrap()).unwrap(); + + let lifted = lift_words(&plan, &dir).unwrap(); + let desk = &lifted["questions/trafikk/index.yaml"]; + assert_eq!(desk["question"], "Hva må gjøres?"); + assert_eq!(desk["buckets"]["farer"]["heading"], "Meldte farer"); + assert_eq!(desk["buckets"]["farer"]["moves"]["open->ordnet"], "Ordnet"); + // A slot the repo never had keeps the fallback. + let plan2: Plan = serde_yaml::from_str(&PLAN.replace("invites_by: [styret]", "invites_by: [styret, trafikk]")).unwrap(); + let lifted2 = lift_words(&plan2, &dir).unwrap(); + assert_eq!(lifted2["questions/trafikk/index.yaml"]["question"], "Hva må gjøres?"); + assert!(lifted2["questions/trafikk/index.yaml"]["invites"]["styret"]["heading"].as_str().unwrap().starts_with("Invite someone")); + let _ = std::fs::remove_dir_all(&dir); + } +}