Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
/**
* The selling points, and which four of them a visitor is shown.
*
* This is the only part of the landing page that varies. The headline, the
* lede, the screenshot and its caption, the prices and the closing line are
* written once and stay put — they are the page's argument, and an argument has
* to hold together to be worth reading. What rotates is the middle: which four
* reasons, out of all the true ones, this visitor gets.
*
* That scope is deliberate and was arrived at the hard way. A page whose
* headline and lede also rotated could no longer be written as one thing; every
* line had to survive being read beside every other, so each of them ended up
* saying everything and selling nothing. Selling points do not have that
* problem. They are a list by nature, four out of nine reads exactly like four
* out of nine, and which four is a real question nobody can answer from taste.
*
* Each point is its own parameter. There is deliberately nothing here saying
* what a point is *like* — no tone, no category, no coordinates. Any such
* scheme would be one person's theory of what matters, fixed in advance, and
* the model could then only ever discover what that theory already allowed.
* Whether two points do the same job is a question for the data, and
* `server/src/voice/` answers it: the covariance over which points were shown
* together, against how the people who saw them went on to behave, is what
* relates them.
*
* Two rules for editing this file:
*
* 1. An id is a permanent name for one point. Rewriting the text under an
* existing id silently merges two different claims into one parameter and
* poisons the history behind it. Changed wording means a new id; the old id
* stops being drawn and ages out of the window on its own. A faithful
* translation is the same claim in another language and keeps its id — but
* changing what a point *claims* means a new id, in every language at once.
* 2. Everything here has to be true of the product as shipped. The page is
* eight lines from a demo anyone can open, and the whole argument is that
* this thing does not lie to you about your data.
*
* The heading sells and the paragraph proves. That order matters: a heading
* that only states a mechanism makes the reader do the work of caring, and a
* paragraph that only enthuses gives them nothing to check.
*/
import type { Locale } from './locale.js';
export interface VoicePoint {
/** Permanent. See rule 1 above. */
id: string;
/** What the reader gets. Read on its own, it should be worth something. */
heading: Record<Locale, string>;
/** How it works, and the doubt the heading raises. */
text: Record<Locale, string>;
}
/** How many of them one page shows. */
export const VOICE_PER_PAGE = 4;
export const VOICE_POOL: VoicePoint[] = [
{
id: 'p.totals',
heading: {
en: 'You never have to re-check a total',
nl: 'Een totaal hoef je nooit na te rekenen',
},
text: {
en:
'Every total is worked out from the rows at the moment you look at it, not typed in once ' +
'and left behind. That is also why you can filter and sort on one — and why sorting by ' +
'the biggest order reaches every row the view matches, not just the two hundred your ' +
'browser has loaded.',
nl:
'Elk totaal wordt uitgerekend uit de rijen op het moment dat je kijkt, niet één keer ' +
'ingetypt en achtergelaten. Daarom kun je er ook op filteren en sorteren — en daarom ' +
'bereikt sorteren op de grootste order elke rij die bij de weergave hoort, niet alleen ' +
'de tweehonderd die je browser geladen had.',
},
},
{
id: 'p.handover',
heading: {
en: 'Nobody walks off with the only set of keys',
nl: 'Niemand loopt weg met de enige sleutelbos',
},
text: {
en:
'Invite the next person as an admin, send them the link however you already talk, and go. ' +
'Wellformed refuses to let the last admin leave until somebody else can get back in, so a ' +
'workspace is never left locked with the business inside it.',
nl:
'Nodig de volgende persoon uit als beheerder, stuur de link via waar jullie toch al ' +
'praten, en ga. Wellformed weigert de laatste beheerder te laten vertrekken tot iemand ' +
'anders er weer in kan, dus een werkruimte blijft nooit op slot achter met de zaak erin.',
},
},
{
id: 'p.audit',
heading: {
en: 'You can always find out who changed it',
nl: 'Je kunt altijd achterhalen wie het veranderd heeft',
},
text: {
en:
'Who, what, when, and the old value beside the new one. New rows, deletions, a column ' +
'added, somebody’s access changed — written down as it happens and impossible to take ' +
'back out afterwards. Undoing something is itself a change everyone can see.',
nl:
'Wie, wat, wanneer, en de oude waarde naast de nieuwe. Nieuwe rijen, verwijderingen, een ' +
'kolom erbij, iemands toegang gewijzigd — vastgelegd terwijl het gebeurt en achteraf ' +
'onmogelijk weg te poetsen. Iets ongedaan maken is zelf een wijziging die iedereen kan ' +
'zien.',
},
},
{
id: 'p.export',
heading: {
en: 'You can walk away with everything, any day',
nl: 'Je kunt elke dag met alles vertrekken',
},
text: {
en:
'A workspace is one SQLite file. Backing up is copying it, leaving is downloading it, and ' +
'Python, R, Excel and a dozen free viewers already read the format. There is no export to ' +
'request and no lock-in to argue about.',
nl:
'Een werkruimte is één SQLite-bestand. Back-uppen is het kopiëren, vertrekken is het ' +
'downloaden, en Python, R, Excel en een dozijn gratis viewers lezen het formaat al. Er is ' +
'geen export om aan te vragen en geen lock-in om over te twisten.',
},
},
{
id: 'p.import',
heading: {
en: 'Start with the spreadsheet you already have',
nl: 'Begin met de spreadsheet die je al hebt',
},
text: {
en:
'Drop in the .csv, .xlsx or .ods. It skips the title somebody typed above the table, the ' +
'blank rows under it, and the columns nobody ever filled in — then shows you which row it ' +
'took for the headers, so you can correct it when it guesses wrong. Nothing is written ' +
'until you agree.',
nl:
'Zet de .csv, .xlsx of .ods erin. Hij slaat de titel over die iemand boven de tabel ' +
'typte, de lege rijen eronder, en de kolommen die nooit iemand invulde — en laat dan zien ' +
'welke rij hij voor de kopjes aanzag, zodat je het verbetert als hij mis gokt. Er wordt ' +
'niets geschreven tot jij akkoord gaat.',
},
},
{
id: 'p.links',
heading: {
en: 'Your data cannot come apart',
nl: 'Je data kan niet uit elkaar raken',
},
text: {
en:
'An order names a customer instead of repeating it in a column that slowly stops ' +
'matching, so fixing the address once fixes every order. Delete a customer that orders ' +
'still point at and it refuses, and tells you which orders — rather than leaving you rows ' +
'pointing at nobody.',
nl:
'Een order noemt een klant in plaats van hem te herhalen in een kolom die langzaam niet ' +
'meer klopt, dus het adres één keer verbeteren verbetert elke order. Verwijder een klant ' +
'waar orders nog naar wijzen en hij weigert, en vertelt je welke orders — in plaats van ' +
'rijen achter te laten die naar niemand wijzen.',
},
},
{
id: 'p.views',
heading: {
en: 'Everyone gets their own view of the same data',
nl: 'Iedereen zijn eigen weergave van dezelfde data',
},
text: {
en:
'The filtered, sorted arrangement somebody needs every Monday is saved as a view over the ' +
'same rows, not as another tab that starts drifting the day it is copied. One set of ' +
'numbers, and as many windows onto it as there are people.',
nl:
'De gefilterde, gesorteerde opstelling die iemand elke maandag nodig heeft wordt ' +
'opgeslagen als weergave over dezelfde rijen, niet als nóg een tabblad dat gaat afdrijven ' +
'op de dag dat het gekopieerd wordt. Eén stel cijfers, en zoveel vensters erop als er ' +
'mensen zijn.',
},
},
{
id: 'p.sql',
heading: {
en: 'You can check any number it shows you',
nl: 'Elk getal dat hij laat zien kun je controleren',
},
text: {
en:
'Along the bottom of every screen is the exact statement the server ran to produce what ' +
'you are looking at. Most people will never read it. It is there because software asking ' +
'to hold your business should be able to show its working on the day somebody finally ' +
'asks.',
nl:
'Onderaan elk scherm staat het exacte statement dat de server uitvoerde voor wat je nu ' +
'ziet. De meeste mensen zullen het nooit lezen. Het staat er omdat software die vraagt om ' +
'je zaak te mogen bewaren, moet kunnen voorrekenen wat ze doet op de dag dat iemand er ' +
'eindelijk om vraagt.',
},
},
{
id: 'p.perworkspace',
heading: {
en: 'Add everyone, pay once',
nl: 'Voeg iedereen toe, betaal één keer',
},
text: {
en:
'The price is per workspace rather than per person, so the colleague who opens it twice a ' +
'year and the accountant checking one figure both get in. Nobody is kept out of the data ' +
'to keep the bill down — which is exactly how the private copy on somebody’s laptop gets ' +
'started.',
nl:
'De prijs is per werkruimte in plaats van per persoon, dus de collega die hem twee keer ' +
'per jaar opent en de accountant die één cijfer controleert komen er allebei in. Niemand ' +
'wordt buiten de data gehouden om de rekening laag te houden — en precies zo begint de ' +
'privékopie op iemands laptop.',
},
},
];
const BY_ID = new Map(VOICE_POOL.map((point) => [point.id, point]));
export function voicePoint(id: string): VoicePoint | undefined {
return BY_ID.get(id);
}
/** What the server hands the page: which points to show, and what to call the visit. */
export interface VoiceAssignment {
/** The impression id, quoted back at signup so the reward finds its way home. */
id: string;
/** Point ids, in no particular order. */
items: string[];
}
/**
* Turn a list of ids into the four points to render.
*
* Tolerant on purpose. A browser holding yesterday's bundle can be handed an id
* it has never heard of, and half a page of selling points is a far worse
* outcome than one that quietly falls back. The fallback is deterministic
* rather than random, so a stale client at least stays consistent with itself
* between reloads.
*/
export function resolveVoice(items: string[]): VoicePoint[] {
const chosen = items
.map((id) => BY_ID.get(id))
.filter((point): point is VoicePoint => point !== undefined)
.slice(0, VOICE_PER_PAGE);
for (const fallback of VOICE_POOL) {
if (chosen.length >= VOICE_PER_PAGE) break;
if (!chosen.includes(fallback)) chosen.push(fallback);
}
return chosen;
}