Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

11.3 kB Ruw bestand
/**
 * 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;
}