Skip to contentVibraUI
Data display

Sample data

Deterministic, realistic sample data behind a repository interface, so no block owns a mock array.

A block never owns a mock array: it reads db.<entity>, a Repository<T>, and the same page renders real rows once the store behind it changes. To swap one in, implement Repository<T> — list, get, create, update, remove, all — against your database or API and replace the entity module's exported repository (export const customers = myCustomerRepository); nothing above it changes, because those six methods are the whole contract. Everything is deterministic: each module generates its rows from seeded("<entity>") and REFERENCE_DATE alone, never Date.now() or Math.random(), so a server render and a client render agree and a row named by id in a test is the same row tomorrow. REFERENCE_DATE is also "now" for the blocks — relative timestamps are measured against it. Money is in minor units, named for it (mrrCents), and formatted with the format lib. Mutation is in-memory and process-local: a dev server keeps a created row until it restarts. createRepository copies the array it is handed, so the exported constant (CUSTOMERS) stays the pristine seed while the repository owns the live rows; all() and list() hand back a fresh array each call, but the rows in it are the store's own objects, so write through update and treat a row you were handed as read-only. core.ts holds the repository and the interfaces and re-exports generate.ts, which holds REFERENCE_DATE and the generator vocabulary — import everything from lib/sample-data or from ./core. Writing a new entity module is documented at the top of core.ts: a type, a generated array, a repository, then three lines in index.ts. People have faces: avatarFor(name), re-exported here from the avatars item, is a URL under /avatars, and that item's own route file draws DiceBear's Lorelei (CC0) for the name on request — line art, no photograph of anyone, forty bytes a row.

Install

npx shadcn@latest add @vibra/sample-data

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
REFERENCE_DATEDate2026-09-04T15:40:00.000ZThe one instant every generator and every block calls "now".
db{ <entity>: Repository<Row> }—The handle a block reads through — one key per entity module, each a Repository: db.customers.list({ page: 2 }) for a table, db.customers.all() for a chart. One key is not: db.fxRates is a record of daily rate series by currency pair, read whole and never written.
Repository<T>{ list(query?): Promise<Page<T>>; get(id): Promise<T | undefined>; create(input): Promise<Result<T>>; update(id, patch): Promise<Result<T>>; remove(id): Promise<Result<{ id: string }>>; all(): T[] }—The whole contract. all() is synchronous, for charts and aggregates; it and list() return a fresh array of the store's own rows, so write through update rather than editing a row you were handed.
createRepository()<T extends { id: string }>(rows: T[], options?: { searchKeys?: (keyof T)[]; filterKeys?: (keyof T)[] }) => Repository<T>—An in-memory repository over a copy of rows. create assigns rowId(prefix, n) ids from a per-repository counter, taking the prefix from the seed rows and padding to match, so the first row created on the 250 seeded customers is cus_0251 and an id the seed already used is stepped over. update ignores patch keys whose value is undefined; update and remove answer a not_found Result for an unknown id.
ListQuery{ page?: number; pageSize?: number; sort?: SortSpec; search?: string; filters?: Record<string, string | string[]> }page: 1, pageSize: 20list applies search (case-insensitive substring over searchKeys), then filters, then sort, then pagination. Filters are equality, or membership for an array, over filterKeys only, with values compared as strings; an empty string or an empty array means no constraint at all, because that is what a cleared filter menu hands back. A page or pageSize that is not a finite number falls back to its default.
SortSpec{ id: string; desc?: boolean }—id is a property name: strings sort by localeCompare, numbers and dates by subtraction. A null or undefined value compares as an empty string, so those rows lead ascending and trail descending; an unknown id leaves the order alone.
Page<T>{ rows: T[]; total: number; page: number; pageSize: number; pageCount: number }—page is 1-based and clamped into range, so a page past the end comes back as the last one; pageCount is ceil(total / pageSize).
Result<T>{ ok: true; data: T } | { ok: false; error: { code: string; message: string; field?: string } }—What every write answers, and what a block's server actions return.
seeded()(name: string) => () => number—A mulberry32 generator seeded from the name: the same name, the same sequence, forever.
seatUsage()() => { plan: Plan; members: number; invitations: number; used: number; limit: number | null }—The workspace's seats, counted one way for every page and section: members who are in or yet to join plus the open invitations — an open invitation holds a seat, a deactivated member none — against the seats WORKSPACE_PLAN (Team) includes; limit is null for a plan with unlimited seats. Read per call, so an invitation sent or revoked moves it. At REFERENCE_DATE it is 25 of 30.
rowId(), slugify(), domainFor(), emailFor()(prefix: string, n: number, width?: number) => string, and three string helperswidth: 4rowId("cus", 1) is "cus_0001"; domainFor("Blue Harbor Labs") is "blueharborlabs.example"; emailFor("Ada Lovelace", domain) is "ada.lovelace@domain". Accented letters fold to their base letters first, so emailFor("Tomás Rinaldi", domain) is "tomas.rinaldi@domain", not "tom.s.rinaldi".
pick(), pickWeighted(), intBetween(), chance(), shuffle()(rand: () => number, ...) => T | number | boolean | T[]—The generator vocabulary: one item from a list, one item from [value, weight] pairs, a whole number in an inclusive range, a weighted boolean, and a Fisher-Yates shuffle into a new array.
daysAgo(), daysAhead(), dateBetween()(days: number, from?: Date) => Date, and (rand, start: Date, end: Date) => Datefrom: REFERENCE_DATEEvery date in the data comes from these. Fractions are welcome, so daysAgo(1 / 24) is an hour ago; dateBetween returns a minute-aligned date inside the range, and start when end precedes it.
Customer{ id; name; email; company; plan: "free" | "starter" | "team" | "enterprise"; status: "active" | "trial" | "churned" | "suspended"; mrrCents; seats; country; createdAt; lastSeenAt; owner }—250 accounts, each with a distinct company. A free plan, a trial, and a churned account all bill 0; everyone else pays seats × the plan's per-seat price. owner is a Member id.
Session{ id; country; region: "Americas" | "EMEA" | "APAC"; sessions; p95Ms; errorRate; lat; lng; at }—One hour of traffic per country, over the 24 hours ending at REFERENCE_DATE. Longitude stands in for the time zone, so each country peaks in its own afternoon rather than all at once, and p95Ms rises with the load in the same row. lat/lng are the country's centroid, for a world map's markers.
Cohort{ id; week: Date; size; retention: number[] }—12 weekly signup cohorts, oldest first, each starting on a Monday. retention[w] is the share still active w weeks on, so retention[0] is 1 and the array is only as long as the cohort has lived — a week it has not reached yet is absent rather than reported as a loss — up to week 8.
Transaction{ id; kind: "charge" | "payout" | "refund" | "transfer" | "fee"; counterparty; avatarUrl?; amountCents; currency: "USD" | "EUR" | "GBP" | "JPY"; status: "completed" | "pending" | "scheduled" | "failed"; at: Date }—120 movements on the store's currency balances, ids txn_0001 on, statuses held at exactly 94 completed, 10 pending, 9 scheduled and 7 failed. amountCents is signed and in the currency's minor unit — whole yen for JPY, drawn anywhere in its range — with charges in and payouts, refunds and fees out. A transfer moves money between the dollar balance and another one, and is booked as two rows with the same instant and status: out of one, its counterparty the other balance, and into the other, worth the same at that day's close in db.fxRates — so no money appears or vanishes between balances. A charge or a refund is a customer's, billed in their country's currency, with their avatarFor face. A payout goes to a fictional bank account and sweeps a share of what its balance took in from customers since the one before, so a balance is paid out in proportion to its own sales. at is a Date like every other entity's: a scheduled row falls one to thirty days after REFERENCE_DATE, a pending one within the five days before it, and the rest across the 120 days before it.
db.fxRatesRecord<"EUR/USD" | "GBP/USD" | "USD/JPY" | "USD/CHF" | "EUR/GBP", { date: string; rate: number }[]>—365 daily rates per pair, oldest first, the last on REFERENCE_DATE's day. The four dollar pairs are seeded random walks with a daily σ of 0.4% from realistic levels, the euro's and the pound's sharing most of each day's shock, as they do; EUR/GBP is their cross, EUR/USD over GBP/USD, so a conversion through dollars and the pair's own quote never disagree, and it moves less than either dollar leg — about 0.35% a day. Rates are rounded to four places, two for the yen pair.
Member{ id; name; email; role: "owner" | "admin" | "member" | "viewer"; status: "active" | "invited" | "deactivated"; avatarUrl?; joinedAt; lastActiveAt; twoFactor }—A 24-person workspace with exactly one owner, and every member has a face: avatarUrl is avatarFor(name), drawn by the avatars item's own route — line art, no photograph, and no image host.
Order{ id; number; customerId; status; items: { productId; qty; unitCents }[]; totalCents; discountCents?; taxCents?; currency; placedAt; fulfilledAt?; paymentMethod: "card" | "bank" | "wallet" | "cash" | "voucher"; country; channel: "online" | "pos" }—The order book. Every seeded row came through the web store (channel "online", paid by card, bank or a digital wallet); a sale rung up at the till is written at runtime with channel "pos", paid by card, cash or voucher. totalCents is what was paid; a till sale also keeps the discountCents and taxCents that took the goods to it, and a walk-in has an empty customerId.
Workflow{ id; name; status: "active" | "paused" | "draft"; nodes: { id; kind: "trigger" | "action" | "branch" | "delay"; title; icon; summary; x; y }[]; edges: { from; to; label? }[]; runs: { id; startedAt: Date; durationMs; status: "succeeded" | "failed" | "running" }[] }—Eight automations drawn as node graphs, laid out left to right on a grid (x and y are pixels at 100%). A workflow that can run has one trigger; a branch's two ways out are labelled "yes" and "no"; icon is a lucide name from WORKFLOW_ICONS. Runs are oldest first, over the fourteen days before REFERENCE_DATE, and a draft has none.
Mail{ id; folder: "inbox" | "sent" | "drafts" | "archive" | "trash"; from: { name; email; avatarUrl }; to: string[]; subject; body: string[]; at; read; starred; labels: ("work" | "personal" | "finance" | "travel")[]; attachments: { name; sizeBytes }[] }—64 mails in the signed-in member's mailbox (the first member row), over the 30 days before REFERENCE_DATE: 38 in the inbox from teammates and customers' contacts, 10 sent, 5 drafts (unsigned), 7 archived and 4 in the trash. 19 unread — the newest of the inbox and two trashed unopened, never one the reader wrote — 6 starred and 13 with files. body is the mail paragraph by paragraph, greeting and sign-off included; ids are eml_0001 on.
Post{ id; author: { name; handle; avatarUrl }; at; text; media?: { kind: "link"; title; domain; mark: string } | { kind: "poll"; options: { label; votes }[]; voted: number | null } | { kind: "product"; productId } | { kind: "stat"; label; values: number[] }; likes; liked; shares }—36 posts on the team feed over the last 60 days, by active members (four by the signed-in one). No media is a picture: 8 link cards naming a logo-mark company, 5 polls (voted is the option the signed-in member picked, null until they vote — two already have), 7 products from db.products and 6 stats whose last value is the figure the text quotes; 10 carry nothing. Topic tags (#release, #support…) are in the text. ids are pst_0001 on.
Comment{ id; postId; author: { name; avatarUrl }; at; text }—140 replies spread over most posts, each within two days after its post and never after REFERENCE_DATE, oldest first within a post; what a reply says follows what the post carries. ids are cmt_0001 on.
Review{ id; productId; customerId; orderId; author: { name; avatarUrl }; rating: 1 | 2 | 3 | 4 | 5; title; body; at; verified; helpful }—442 reviews from the buyers of delivered orders: six in ten order lines that reached a customer — fulfilled, or fulfilled and sent back — were reviewed, half a day to 24 days after they arrived and never after REFERENCE_DATE, so orderId is an order of that customer that holds that product. A kept product scores mostly fours and fives, a returned one a one, a two or now and then a three, and a review of a return says how the return went. Every title and sentence comes from its department's pool in review-templates.ts, kept to the products it fits (a tote bag never fits true to size), with the product named as a buyer says it. author is the customer's name and avatarFor face; verified (86%) says the store tied the review to its order through the request that follows a delivery; helpful counts the readers who marked it so, more on older reviews and on warnings. Every product still sold has at least two. ids are rev_0001 on.

Dependencies

Source

lib/sample-data/index.ts
// The sample-data kit item. `core.ts` documents the pattern every entity
// module follows; each module below adds three lines here — its `export *`, its
// import, and its key on `db` — all kept in alphabetical order.
import { aiRequests } from "./ai-requests"
import { aiThreads } from "./ai-threads"
import { alerts } from "./alerts"
import { apiKeys } from "./api-keys"
import { appointments } from "./appointments"
import { articles } from "./articles"
import { auditEvents } from "./audit-events"
import { bookings } from "./bookings"
import { campaigns } from "./campaigns"
import { chats } from "./chats"
import { cohorts } from "./cohorts"
import { comments } from "./comments"
import { courses } from "./courses"
import { customers } from "./customers"
import { deals } from "./deals"
import { deployments } from "./deployments"
import { employees } from "./employees"
import { events } from "./events"
import { files } from "./files"
import { fxRates } from "./fx-rates"
import { generations } from "./generations"
import { holdings } from "./holdings"
import { incidents } from "./incidents"
import { integrations } from "./integrations"
import { invitations } from "./invitations"
import { invoices } from "./invoices"
import { leads } from "./leads"
import { leaves } from "./leaves"
import { listings } from "./listings"
import { logEntries } from "./log-entries"
import { mails } from "./mails"
import { members } from "./members"
import { messages } from "./messages"
import { notes } from "./notes"
import { notifications } from "./notifications"
import { orders } from "./orders"
import { paymentMethods } from "./payment-methods"
import { plans } from "./plans"
import { posts } from "./posts"
import { products } from "./products"
import { projects } from "./projects"
import { releases } from "./releases"
import { reviews } from "./reviews"
import { roles } from "./roles"
import { services } from "./services"
import { sessions } from "./sessions"
import { shipments } from "./shipments"
import { students } from "./students"
import { subscriptions } from "./subscriptions"
import { tasks } from "./tasks"
import { trades } from "./trades"
import { transactions } from "./transactions"
import { voiceJobs } from "./voice-jobs"
import { workflows } from "./workflows"

export * from "./ai-requests"
export * from "./ai-threads"
export * from "./alerts"
export * from "./api-keys"
export * from "./appointments"
export * from "./articles"
export * from "./audit-events"
export * from "./avatars"
export * from "./bookings"
export * from "./campaigns"
export * from "./chats"
export * from "./cohorts"
export * from "./comments"
export * from "./core"
export * from "./courses"
export * from "./customers"
export * from "./deals"
export * from "./deployments"
export * from "./employees"
export * from "./events"
export * from "./files"
export * from "./fx-rates"
export * from "./generations"
export * from "./holdings"
export * from "./incidents"
// The checks a server action reads its arguments through; not an entity.
export * from "./input"
export * from "./integrations"
export * from "./invitations"
export * from "./invoices"
export * from "./leads"
export * from "./leaves"
export * from "./listings"
export * from "./log-entries"
export * from "./mails"
export * from "./members"
export * from "./messages"
export * from "./notes"
export * from "./notifications"
export * from "./orders"
export * from "./payment-methods"
export * from "./plans"
export * from "./posts"
export * from "./products"
export * from "./projects"
export * from "./releases"
export * from "./reviews"
export * from "./roles"
// A selector over members, invitations and plans, not an entity: no key on `db`.
export * from "./seats"
export * from "./services"
export * from "./sessions"
export * from "./shipments"
export * from "./students"
export * from "./subscriptions"
export * from "./tasks"
export * from "./trades"
export * from "./transactions"
export * from "./voice-jobs"
export * from "./workflows"

/**
 * The one handle a block reads through: `db.customers.list({ page: 2 })` for a
 * table, `db.customers.all()` for a chart. Swap any entry for a repository
 * backed by a real store and the blocks above it keep working. One entry is
 * not a repository: `db.fxRates` is a year of daily rates per currency pair,
 * read whole by pair and never written, so it is held as the record it is.
 */
export const db = {
  aiRequests,
  aiThreads,
  alerts,
  apiKeys,
  appointments,
  articles,
  auditEvents,
  bookings,
  campaigns,
  chats,
  cohorts,
  comments,
  courses,
  customers,
  deals,
  deployments,
  employees,
  events,
  files,
  fxRates,
  generations,
  holdings,
  incidents,
  integrations,
  invitations,
  invoices,
  leads,
  leaves,
  listings,
  logEntries,
  mails,
  members,
  messages,
  notes,
  notifications,
  orders,
  paymentMethods,
  plans,
  posts,
  products,
  projects,
  releases,
  reviews,
  roles,
  services,
  sessions,
  shipments,
  students,
  subscriptions,
  tasks,
  trades,
  transactions,
  voiceJobs,
  workflows,
}
lib/sample-data/ai-requests.ts
import { REFERENCE_DATE, createRepository, dateBetween, daysAgo, intBetween, pick, pickWeighted, rowId, seeded } from "./core"

/**
 * One call to a hosted LLM, logged for usage and cost tracking. The providers
 * are fictional — Sparrowmere Labs, Galewright AI and Ternstead AI, and
 * Northwind's own models run in-house — and so are their models: the kit
 * names no real AI company, and no demo reads as anyone's price list.
 */
export type AiRequest = {
  id: string
  at: Date
  model: string
  provider: "sparrowmere" | "galewright" | "ternstead" | "northwind"
  promptTokens: number
  completionTokens: number
  latencyMs: number
  /** Cost in minor units — cents, not dollars. 0 for a request that failed. */
  costCents: number
  status: 200 | 429 | 500
  traceId: string
}

const COUNT = 3000

// Each provider's line-up, a current generation beside the one before it.
// Northwind's in-house family is the one the assistant pages offer.
const MODELS_BY_PROVIDER: Record<AiRequest["provider"], string[]> = {
  sparrowmere: ["sparrowmere-3", "sparrowmere-3-mini", "sparrowmere-2.5", "sparrowmere-2.5-mini"],
  galewright: ["galewright-pro", "galewright-ultra", "galewright-lite"],
  ternstead: ["ternstead-2-pro", "ternstead-2-lite", "ternstead-1-pro"],
  northwind: ["nw-large-2", "nw-fast-2", "nw-2"],
}

const PROVIDER_MIX: readonly (readonly [AiRequest["provider"], number])[] = [
  ["sparrowmere", 0.4],
  ["galewright", 0.35],
  ["ternstead", 0.15],
  ["northwind", 0.1],
]

const STATUS_MIX: readonly (readonly [AiRequest["status"], number])[] = [
  [200, 0.92],
  [429, 0.05],
  [500, 0.03],
]

/**
 * Cents per 1,000 tokens — illustrative, not a real price list. Galewright is
 * the dearest, Ternstead the cheapest of the three hosted, and the in-house
 * models cost little more than the machines they run on.
 */
const RATE_PER_1K: Record<AiRequest["provider"], { prompt: number; completion: number }> = {
  sparrowmere: { prompt: 50, completion: 150 },
  galewright: { prompt: 60, completion: 180 },
  ternstead: { prompt: 30, completion: 100 },
  northwind: { prompt: 5, completion: 8 },
}

const HEX = "0123456789abcdef"
function hex(rand: () => number, length: number): string {
  return Array.from({ length }, () => HEX[Math.floor(rand() * HEX.length)]).join("")
}

function generate(): AiRequest[] {
  const rand = seeded("aiRequests")
  return Array.from({ length: COUNT }, (_, index) => {
    const provider = pickWeighted(rand, PROVIDER_MIX)
    const status = pickWeighted(rand, STATUS_MIX)
    const promptTokens = intBetween(rand, 50, 4_000)
    // A throttled or failed call rarely produces much, if any, completion.
    const completionTokens = status === 200 ? intBetween(rand, 10, 2_000) : intBetween(rand, 0, 20)
    const rate = RATE_PER_1K[provider]
    const cost = Math.round((promptTokens / 1000) * rate.prompt + (completionTokens / 1000) * rate.completion)

    return {
      id: rowId("aiq", index + 1),
      at: dateBetween(rand, daysAgo(30), REFERENCE_DATE),
      model: pick(rand, MODELS_BY_PROVIDER[provider]),
      provider,
      promptTokens,
      completionTokens,
      latencyMs: 150 + completionTokens * intBetween(rand, 2, 6) + intBetween(rand, 0, 400),
      // Nobody is billed for a request that failed or was rate-limited.
      costCents: status === 200 ? cost : 0,
      status,
      traceId: `trace_${hex(rand, 12)}`,
    }
  })
}

/** The seeded request log. The repository below owns the rows a demo mutates. */
export const AI_REQUESTS: AiRequest[] = generate()

export const aiRequests = createRepository(AI_REQUESTS, {
  searchKeys: ["model", "traceId"],
  filterKeys: ["provider", "status"],
})
lib/sample-data/ai-threads.ts
import { chance, createRepository, daysAgo, intBetween, pickWeighted, rowId, seeded } from "./core"

/** One turn in a conversation with the assistant. */
export type AiMessage = {
  id: string
  role: "user" | "assistant"
  text: string
  at: Date
}

/**
 * A conversation with the assistant: a title for the rail, a model, and the
 * turns. The models are Northwind's own, run in-house: the default, the large
 * one and the fast one — fictional, like every AI name in the kit.
 */
export type AiThread = {
  id: string
  title: string
  model: "nw-2" | "nw-large-2" | "nw-fast-2"
  createdAt: Date
  /** When the last turn landed; the rail groups threads by it. */
  updatedAt: Date
  /** Alternating user and assistant turns, opening on the user and closing on the assistant. */
  messages: AiMessage[]
}

const COUNT = 12

const MODEL_MIX: readonly (readonly [AiThread["model"], number])[] = [
  ["nw-2", 0.6],
  ["nw-large-2", 0.25],
  ["nw-fast-2", 0.15],
]

// What a product team asks an assistant inside its own analytics workspace:
// the title the rail shows, the question as it was typed, and the answer.
const OPENERS: readonly { title: string; question: string; answer: string }[] = [
  {
    title: "Summarise last week's signups",
    question: "Can you summarise how signups moved last week and what drove the change?",
    answer:
      "Signups rose about 12% week over week, with most of the lift landing on Tuesday and Wednesday after the launch post went out. Organic search held steady, so the change is almost entirely the referral spike. If you want, I can break the week down by plan.",
  },
  {
    title: "Draft a trial-ending email",
    question: "Draft a short email to customers whose trial ends this week, nudging them to pick a plan.",
    answer:
      "Here is a draft you can adapt: subject “Your trial ends Friday — keep your dashboards”. The body thanks them, names the one feature they used most, and closes with a single link to the plans page. I kept it to four sentences so it reads on a phone.",
  },
  {
    title: "Explain the P95 latency spike",
    question: "Why did P95 latency jump on Monday afternoon?",
    answer:
      "The jump lines up with the 14:10 deploy of the events service: P95 climbed from 380 ms to 1.2 s and settled once the rollback landed at 15:05. Nothing in the traffic mix changed, so it reads as a regression rather than load. The deploy notes mention a new serialisation path, which is the first place I would look.",
  },
  {
    title: "SQL for weekly active workspaces",
    question: "Write a query that counts weekly active workspaces for the last 12 weeks.",
    answer:
      "Group events by workspace and by the Monday of the week they landed in, count the distinct workspaces per week, and keep the last twelve weeks. The trick is to truncate to the week before counting, not after, or a workspace active on two days is counted twice. I have written it to read from the events table with a 12-week window on the timestamp.",
  },
  {
    title: "Rename the onboarding steps",
    question: "Suggest clearer names for the four onboarding steps: Setup, Connect, Invite, Done.",
    answer:
      "Try “Name your workspace”, “Connect a data source”, “Invite your team” and “You’re all set”. Each one says what the reader does on that step rather than what the step is called, which is what a progress bar needs. The last one can stay a sentence, since it is a state and not a task.",
  },
  {
    title: "Compare the two pricing pages",
    question: "Compare the current pricing page with the draft: which one converts better and why?",
    answer:
      "The draft moves the plan comparison above the fold and drops the toggle for annual billing, which are the two changes most likely to matter. On the current page readers scroll past the plans to find the price, and the toggle hides the number half of them want. I would ship the draft’s layout and keep the annual toggle visible.",
  },
  {
    title: "Regex for order numbers",
    question: "What regex matches our order numbers like ORD-10432?",
    answer:
      "Use ^ORD-\\d{5}$ if the number is always five digits, or ^ORD-\\d{4,6}$ to leave room for growth. Anchor both ends so a longer string with an order number inside it does not match. If you are validating user input, trim whitespace first.",
  },
  {
    title: "Plan the Q4 roadmap doc",
    question: "Help me outline a one-page Q4 roadmap for the platform team.",
    answer:
      "Open with the three outcomes the quarter is for, then one line per project naming the outcome it serves, the owner and the month it lands. Keep risks as a short list at the bottom rather than woven into the projects, so a reader can find them. A page that fits on one screen gets read; the appendix can hold the rest.",
  },
  {
    title: "Plain-English release notes",
    question: "Translate the 2.4 release notes into plain English for the changelog.",
    answer:
      "Here is a plainer version: “Dashboards now load twice as fast, filters remember what you picked, and the export button works on every table.” The original names the caching layer and the state store, which nobody outside the team needs. Three sentences cover the three changes.",
  },
  {
    title: "Explain the retention cohort chart",
    question: "Explain what the retention cohort chart is showing, in one paragraph.",
    answer:
      "Each row is the set of workspaces that signed up in one month, and each cell is the share of them still active some weeks later. Reading down a column compares cohorts at the same age, which is where a product change shows up. The chart is saying that cohorts since May keep about ten points more of their users at week eight.",
  },
  {
    title: "Write a status page notice",
    question: "Write a short incident notice for the status page about the delayed exports.",
    answer:
      "“Exports are delayed. Since 09:20 UTC, CSV exports are taking up to 20 minutes to arrive. Dashboards and the API are unaffected. We have found the cause and expect exports to catch up within the hour; we will update this notice when they do.” Four sentences: what, since when, what still works, what happens next.",
  },
  {
    title: "Tidy a changelog entry",
    question: "Tidy this changelog entry: “fixed bug where the invoice mailer sent twice sometimes when retried”.",
    answer:
      "“Fixed: a retried invoice email could be sent twice.” Lead with the outcome, keep the tense consistent, and drop “sometimes” — a reader wants to know the bug is gone, not how often it struck. If it helps, add the version it shipped in.",
  },
]

const FOLLOW_UPS = [
  "Can you make that shorter?",
  "What would you change if the audience were customers rather than the team?",
  "Give me one more option.",
  "Which part of that is most likely to be wrong?",
  "Can you turn that into three bullet points?",
]

const FOLLOW_UP_ANSWERS = [
  "Shorter version: keep the first sentence, cut the second, and end on the action. That loses the reasoning but keeps the point, which is what a busy reader takes away.",
  "For customers, drop the internal names and say what changes for them. The structure stays the same; only the nouns move.",
  "One more: lead with the number, then the cause, then the next step. It reads more like a status line and less like a memo.",
  "The weakest part is the causal claim: the timing fits, but nothing here proves it. A quick check against the deploy log would settle it either way.",
  "As bullets: what changed, why it matters, what to do next. Each one a single line, and no bullet that needs the one above it to make sense.",
]

const HOUR = 1 / 24
const MINUTE = HOUR / 60

/**
 * When a thread opened, in days before now. The first three opened earlier
 * today, the next two yesterday, the rest over the last six weeks — so the
 * rail's three groups are all populated whichever way the seed falls.
 */
function openedDaysAgo(rand: () => number, index: number): number {
  if (index < 3) return intBetween(rand, 1, 12) * HOUR
  if (index < 5) return intBetween(rand, 26, 36) * HOUR
  return intBetween(rand, 3, 40) + rand()
}

function generate(): AiThread[] {
  const rand = seeded("ai-threads")

  return OPENERS.slice(0, COUNT).map((opener, index) => {
    const id = rowId("ait", index + 1)
    // At least an hour back, and the turns below add under forty minutes
    // between them, so no thread ever runs past "now".
    const openedAt = daysAgo(openedDaysAgo(rand, index))
    let at = openedAt
    let count = 0

    const turn = (role: AiMessage["role"], text: string, gapMinutes: number): AiMessage => {
      at = new Date(at.getTime() + gapMinutes * MINUTE * 86_400_000)
      count += 1
      return { id: `${id}-m${count}`, role, text, at }
    }

    const messages: AiMessage[] = [
      turn("user", opener.question, 0),
      turn("assistant", opener.answer, intBetween(rand, 1, 3)),
    ]

    // A follow-up or two, as a reader who got a useful answer tends to ask.
    const exchanges = chance(rand, 0.55) ? intBetween(rand, 1, 2) : 0
    for (let i = 0; i < exchanges; i += 1) {
      const which = intBetween(rand, 0, FOLLOW_UPS.length - 1)
      messages.push(turn("user", FOLLOW_UPS[which], intBetween(rand, 2, 15)))
      messages.push(turn("assistant", FOLLOW_UP_ANSWERS[which], intBetween(rand, 1, 3)))
    }

    return {
      id,
      title: opener.title,
      model: pickWeighted(rand, MODEL_MIX),
      createdAt: openedAt,
      updatedAt: messages[messages.length - 1].at,
      messages,
    }
  })
}

/** The seeded conversations. The repository below owns the rows the assistant page appends to. */
export const AI_THREADS: AiThread[] = generate()

export const aiThreads = createRepository(AI_THREADS, {
  searchKeys: ["title"],
  filterKeys: ["model"],
})
lib/sample-data/alerts.ts
import { REFERENCE_DATE, chance, createRepository, dateBetween, daysAgo, pick, pickWeighted, rowId, seeded } from "./core"

/** A monitoring rule and its current firing state. */
export type Alert = {
  id: string
  name: string
  condition: string
  /** Reuses the incident severity scale so the two read together on a dashboard. */
  severity: "sev1" | "sev2" | "sev3"
  status: "firing" | "resolved" | "silenced"
  firedAt?: Date
  /**
   * What the rule was doing when it was muted, so lifting a silence puts it
   * back rather than guessing — a resolved rule someone quietened must not
   * come back firing. Read it only while `status` is "silenced": resuming
   * leaves it behind as the record of the silence that was lifted.
   */
  silencedFrom?: "firing" | "resolved"
  /** When the silence runs out. Same rule: meaningful only while silenced. */
  silencedUntil?: Date
  channel: "chat" | "email" | "pager"
}

const COUNT = 30

const ALERT_DEFS: { name: string; condition: string; severity: Alert["severity"] }[] = [
  { name: "High CPU usage", condition: "CPU utilization > 90% for 5m", severity: "sev2" },
  { name: "Elevated error rate", condition: "5xx rate > 2% for 10m", severity: "sev1" },
  { name: "Slow API responses", condition: "p99 latency > 800ms for 5m", severity: "sev2" },
  { name: "Low disk space", condition: "disk usage > 85%", severity: "sev3" },
  { name: "Queue backlog growing", condition: "queue depth > 10,000 for 15m", severity: "sev2" },
  { name: "Memory pressure", condition: "memory utilization > 92% for 5m", severity: "sev2" },
  { name: "Database connections exhausted", condition: "connection pool usage > 95%", severity: "sev1" },
  { name: "Failed login spike", condition: "failed logins > 50 in 5m", severity: "sev3" },
  { name: "Certificate expiring soon", condition: "TLS certificate expires in < 14 days", severity: "sev3" },
  { name: "Deployment failure", condition: "deployment status = failed", severity: "sev2" },
  { name: "Webhook delivery failures", condition: "webhook failure rate > 5% for 10m", severity: "sev3" },
  { name: "Replication lag", condition: "database replica lag > 30s", severity: "sev2" },
]

const STATUS_MIX: readonly (readonly [Alert["status"], number])[] = [
  ["resolved", 0.55],
  ["silenced", 0.25],
  ["firing", 0.2],
]

const CHANNEL_MIX: readonly (readonly [Alert["channel"], number])[] = [
  ["chat", 0.55],
  ["pager", 0.28],
  ["email", 0.17],
]

// How recently a rule must have fired for it to still be the one going off,
// which is what a silence seeded into the store was muting.
const RECENT_FIRE_MS = 3 * 86_400_000

/** The longest a seeded silence can still have to run. */
const MAX_SILENCE_MS = 30 * 86_400_000

function firedAtFor(rand: () => number, status: Alert["status"]): Date | undefined {
  if (status === "firing") return dateBetween(rand, daysAgo(3), REFERENCE_DATE)
  if (status === "resolved") return chance(rand, 0.9) ? dateBetween(rand, daysAgo(90), daysAgo(1)) : undefined
  // Silenced: sometimes it fired before being muted, often it never has.
  return chance(rand, 0.3) ? dateBetween(rand, daysAgo(120), daysAgo(10)) : undefined
}

function generate(): Alert[] {
  const rand = seeded("alerts")
  return Array.from({ length: COUNT }, (_, index) => {
    const def = pick(rand, ALERT_DEFS)
    const status = pickWeighted(rand, STATUS_MIX)
    const firedAt = firedAtFor(rand, status)
    const silenced = status === "silenced"
    return {
      id: rowId("alr", index + 1),
      name: def.name,
      condition: def.condition,
      severity: def.severity,
      status,
      firedAt,
      // A silence in the store is one somebody started before the demo did, so
      // it carries the same two facts a silence started inside it does: what
      // the rule was doing, and how long the quiet lasts.
      silencedFrom: silenced
        ? firedAt && REFERENCE_DATE.getTime() - firedAt.getTime() <= RECENT_FIRE_MS
          ? ("firing" as const)
          : ("resolved" as const)
        : undefined,
      silencedUntil: silenced
        ? dateBetween(rand, REFERENCE_DATE, new Date(REFERENCE_DATE.getTime() + MAX_SILENCE_MS))
        : undefined,
      channel: pickWeighted(rand, CHANNEL_MIX),
    }
  })
}

/** The seeded alert rules. The repository below owns the rows a demo mutates. */
export const ALERTS: Alert[] = generate()

export const alerts = createRepository(ALERTS, {
  searchKeys: ["name", "condition"],
  filterKeys: ["severity", "status", "channel"],
})
lib/sample-data/api-keys.ts
import { REFERENCE_DATE, chance, createRepository, dateBetween, daysAgo, intBetween, pick, pickWeighted, rowId, seeded, shuffle } from "./core"
import { MEMBERS } from "./members"

/** A workspace API key, shown to the user only as its prefix once created. */
export type ApiKey = {
  id: string
  name: string
  prefix: string
  lastUsedAt?: Date
  createdAt: Date
  /** A Member id: whoever generated the key. */
  createdBy: string
  scopes: string[]
  status: "active" | "revoked"
}

const COUNT = 12

const NAME_POOL = [
  "Production Server", "CI Pipeline", "Local Development", "Workflow Automation",
  "Mobile App", "Analytics Export", "Staging Environment", "Backup Script",
  "Partner API - Blue Harbor", "Data Warehouse Sync", "Support Tooling", "Billing Reconciliation",
  "Monitoring Agent", "Internal Dashboard",
]

const SCOPE_POOL = [
  "read:customers", "write:customers", "read:invoices", "write:invoices",
  "read:analytics", "read:deployments", "write:deployments", "admin",
]

const STATUS_MIX: readonly (readonly [ApiKey["status"], number])[] = [
  ["active", 0.75],
  ["revoked", 0.25],
]

const TOKEN_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"

/** A short opaque token — the only part of the real key ever shown again. */
function token(rand: () => number, length: number): string {
  return Array.from({ length }, () => TOKEN_CHARS[Math.floor(rand() * TOKEN_CHARS.length)]).join("")
}

function generate(): ApiKey[] {
  const rand = seeded("apiKeys")
  const names = shuffle(rand, NAME_POOL).slice(0, COUNT)
  const creators = MEMBERS.filter((member) => member.status === "active" && member.role !== "viewer").map(
    (member) => member.id
  )

  return names.map((name, index) => {
    const status = pickWeighted(rand, STATUS_MIX)
    const ageDays = intBetween(rand, 5, 500)
    const createdAt = dateBetween(rand, daysAgo(ageDays), daysAgo(ageDays - 1))
    const lastUsedAt =
      status === "revoked"
        ? chance(rand, 0.7)
          ? dateBetween(rand, createdAt, daysAgo(30))
          : undefined
        : chance(rand, 0.85)
          ? dateBetween(rand, createdAt, REFERENCE_DATE)
          : undefined

    return {
      id: rowId("key", index + 1),
      name,
      prefix: `sk_live_${token(rand, 6)}`,
      lastUsedAt,
      createdAt,
      createdBy: pick(rand, creators),
      scopes: shuffle(rand, SCOPE_POOL).slice(0, intBetween(rand, 1, 4)),
      status,
    }
  })
}

/** The seeded API keys. The repository below owns the rows a demo mutates. */
export const API_KEYS: ApiKey[] = generate()

export const apiKeys = createRepository(API_KEYS, {
  searchKeys: ["name", "prefix"],
  filterKeys: ["status"],
})
lib/sample-data/appointments.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  daysAhead,
  emailFor,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
} from "./core"
import { avatarFor } from "./avatars"

/** One booked visit at the clinic: who, with whom, when, and what came of it. */
export type Appointment = {
  id: string
  patient: string
  /** A face for the patient — the kit's own avatar route for the name — */
  avatarUrl?: string
  email: string
  /** "Dr. Amara Osei" — written the way the schedule prints it. */
  doctor: string
  department: "Cardiology" | "Neurology" | "Oncology" | "Pediatrics" | "Orthopedics"
  at: Date
  durationMin: number
  status: "scheduled" | "checked_in" | "done" | "cancelled"
  /** What was done, when the visit was more than a consultation. */
  procedure?: string
  /** True when this visit was the patient's first at the clinic. */
  isNewPatient: boolean
}

const COUNT = 160

const FIRST_NAMES = [
  "Ana", "Bilal", "Clara", "Daniel", "Elif", "Femi", "Grace", "Hugo",
  "Ines", "Jonas", "Kaia", "Leon", "Mira", "Noah", "Olivia", "Pavel",
  "Rosa", "Samir", "Tessa", "Umar", "Vera", "Wren", "Yara", "Zane",
]

const LAST_NAMES = [
  "Almeida", "Baker", "Chen", "Dahl", "Ekwueme", "Fischer", "Garcia", "Hoffmann",
  "Ibrahim", "Jensen", "Kowalski", "Lindqvist", "Mendes", "Novak", "Okafor", "Petrova",
]

const DOMAINS = ["mail.example", "inbox.example", "post.example", "example.org"]

// Each doctor belongs to one department, so a visit's doctor and department
// never disagree; the list is what the schedule offers.
const DOCTORS: readonly (readonly [string, Appointment["department"]])[] = [
  ["Dr. Amara Osei", "Cardiology"],
  ["Dr. Tomas Lind", "Cardiology"],
  ["Dr. Priya Raman", "Neurology"],
  ["Dr. Elias Berg", "Neurology"],
  ["Dr. Hana Sato", "Oncology"],
  ["Dr. Marcus Reid", "Oncology"],
  ["Dr. Lucia Ferrer", "Pediatrics"],
  ["Dr. Kofi Mensah", "Pediatrics"],
  ["Dr. Ingrid Vos", "Orthopedics"],
  ["Dr. Rafael Duarte", "Orthopedics"],
]

// Three procedures a department books most, so the ranking on the dashboard
// has a head rather than twenty names with three visits each.
const PROCEDURES: Record<Appointment["department"], readonly string[]> = {
  Cardiology: ["Echocardiogram", "Stress test", "Holter fitting"],
  Neurology: ["EEG", "Nerve conduction study", "MRI review"],
  Oncology: ["Chemotherapy session", "Biopsy", "Port placement"],
  Pediatrics: ["Vaccination", "Growth assessment", "Hearing screen"],
  Orthopedics: ["Joint injection", "Cast fitting", "Physiotherapy review"],
}

const DURATIONS = [15, 30, 30, 45, 60, 90] as const

/**
 * Where a visit sits in time. The book runs six months back — enough for a
 * chart by month — and three weeks ahead. The two thirty-day windows behind
 * "now" are weighted close to each other, so a month-on-month delta reads
 * as growth rather than as a cliff, and today gets a few visits of its own so
 * a calendar opening on it has a list to show — three of them, brought forward
 * below, still ahead of "now" for the desk to check in.
 */
const WINDOW_MIX: readonly (readonly [[number, number], number])[] = [
  [[-180, -61], 0.24],
  [[-60, -31], 0.28],
  [[-30, -1], 0.22],
  [[0, 0], 0.04],
  [[1, 21], 0.22],
]

/** A clinic slot: a weekday between 08:00 and 16:45 UTC, on a quarter hour. */
function slotOn(rand: () => number, dayOffset: number): Date {
  const day = daysAhead(dayOffset)
  const base = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate())
  const hour = intBetween(rand, 8, 16)
  const quarter = intBetween(rand, 0, 3) * 15
  return new Date(base + hour * 3_600_000 + quarter * 60_000)
}

/** The last quarter hours of the reference day the desk can still fill: from 16:00 on. */
const LATE_FROM_MIN = 16 * 60
const QUARTER_MS = 15 * 60_000

/**
 * The end of the reference day, where a desk opening the book at 15:40 still
 * has patients to check in. The draw leaves nothing booked on it after then —
 * today's visits all fall in the morning — so the three visits nearest ahead
 * are brought forward onto it: the same patients, doctors, lengths and
 * procedures, only the day moved, each from 16:00 on a quarter hour and after
 * the one before it with the same doctor. They are still the first three rows
 * ahead of now, so no other row moves and no id changes.
 */
function bringForward(rows: Appointment[]): Appointment[] {
  const first = rows.findIndex((row) => row.at.getTime() > REFERENCE_DATE.getTime())
  if (first < 0) return rows
  const day = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())
  const freeFrom = new Map<string, number>()

  return rows.map((row, index) => {
    const slot = index - first
    if (slot < 0 || slot > 2) return row
    const earliest = day + (LATE_FROM_MIN + slot * 15) * 60_000
    const start = Math.ceil(Math.max(earliest, freeFrom.get(row.doctor) ?? 0) / QUARTER_MS) * QUARTER_MS
    freeFrom.set(row.doctor, start + row.durationMin * 60_000)
    return { ...row, at: new Date(start), status: "scheduled" as const }
  })
}

function generate(): Appointment[] {
  const rand = seeded("appointments")

  const drawn = Array.from({ length: COUNT }, (_, index) => {
    const [from, to] = pickWeighted(rand, WINDOW_MIX)
    let offset = intBetween(rand, from, to)
    // Clinics close at the weekend: nudge a Saturday or Sunday to Monday.
    const weekday = daysAhead(offset).getUTCDay()
    if (weekday === 6) offset += 2
    if (weekday === 0) offset += 1
    const at = slotOn(rand, offset)
    const past = at.getTime() <= REFERENCE_DATE.getTime()
    const today = daysAhead(offset).toISOString().slice(0, 10) === REFERENCE_DATE.toISOString().slice(0, 10)

    const [doctor, department] = pick(rand, DOCTORS)
    const status: Appointment["status"] = !past
      ? "scheduled"
      : today && chance(rand, 0.5)
        ? "checked_in"
        : chance(rand, 0.08)
          ? "cancelled"
          : "done"

    const name = `${pick(rand, FIRST_NAMES)} ${pick(rand, LAST_NAMES)}`

    return {
      id: rowId("apt", index + 1),
      patient: name,
      avatarUrl: avatarFor(name),
      email: emailFor(name, pick(rand, DOMAINS)),
      doctor,
      department,
      at,
      durationMin: pick(rand, DURATIONS),
      status,
      // Half the book is procedures; the rest are consultations.
      procedure: chance(rand, 0.5) ? pick(rand, PROCEDURES[department]) : undefined,
      isNewPatient: chance(rand, 0.22),
    }
  }).sort((a, b) => a.at.getTime() - b.at.getTime())
    .map((row, index) => ({ ...row, id: rowId("apt", index + 1) }))

  return bringForward(drawn)
}

/** The seeded book, oldest first. The repository below owns the rows the desk changes. */
export const APPOINTMENTS: Appointment[] = generate()

export const appointments = createRepository(APPOINTMENTS, {
  searchKeys: ["patient", "doctor", "email"],
  filterKeys: ["department", "status", "doctor"],
})
lib/sample-data/articles.ts
import { createRepository, dateBetween, daysAgo, intBetween, rowId, seeded, slugify } from "./core"

/** The six shelves the help centre is arranged on. */
export const ARTICLE_CATEGORIES = [
  "Getting started",
  "Billing",
  "Integrations",
  "Security",
  "Data & exports",
  "Troubleshooting",
] as const

export type ArticleCategory = (typeof ARTICLE_CATEGORIES)[number]

/** One help article: what it is called, where it is filed, and what it says. */
export type Article = {
  id: string
  /** From the title; the URL a help centre links each article at. */
  slug: string
  title: string
  category: ArticleCategory
  /** The line under the title in a list. */
  summary: string
  body: string
  updatedAt: Date
  minutesToRead: number
  /** Reads in the last 90 days, for ordering a "most read" list. */
  views: number
}

// The articles themselves, written out rather than generated: help text is the
// one thing a reader actually reads, and a sentence assembled from fragments
// reads like one. The generator below only decides when each was last touched
// and how often it has been opened.
const TOPICS: readonly { title: string; category: ArticleCategory; summary: string; body: string }[] = [
  {
    title: "Create your first workspace",
    category: "Getting started",
    summary: "Name a workspace, pick a region, and invite the people who will work in it.",
    body: "A workspace is the boundary everything else sits inside: members, data, billing and audit history all belong to exactly one. Choose the region closest to the people who will use it — the region fixes where rows are stored and cannot be changed afterwards. Once it exists, invite your team from Settings → Members; an invitation is good for fourteen days and can be resent or revoked at any point before it is accepted.",
  },
  {
    title: "Invite teammates and set their roles",
    category: "Getting started",
    summary: "The four roles, what each one may do, and how to change someone's later.",
    body: "Every member holds one of four roles. Owner is the single account that can delete the workspace or move billing; admin can do everything else, including managing members and API keys; member can read and write the data itself; viewer can read it and nothing more. Change a role from the members table — the change takes effect on that person's next request, and is written to the audit log with who made it.",
  },
  {
    title: "Import your existing data",
    category: "Getting started",
    summary: "Bring a CSV in, map its columns to fields, and check the preview before you commit.",
    body: "The importer reads a CSV with a header row and shows you the first five rows as it understood them. Columns are matched to fields by name where they line up, and anything it could not match is left for you to choose. Nothing is written until you confirm the preview, and a row that fails validation is reported with its line number rather than silently skipped.",
  },
  {
    title: "Set up your notification preferences",
    category: "Getting started",
    summary: "Choose which events reach you, and whether they arrive by email or in the app.",
    body: "Notifications are grouped by the thing they are about — deployments, billing, mentions, and weekly summaries. Each group can be switched on for email, for the in-app inbox, or for both. Preferences are per person, not per workspace, so the same choices follow you into every workspace you belong to.",
  },
  {
    title: "Understand your invoice",
    category: "Billing",
    summary: "What each line means, when it is charged, and how proration is worked out.",
    body: "An invoice covers one billing period and is issued on the first day of it. The subscription line is the plan's own price for the seats you held at the start of the period; usage lines are metered and billed a period in arrears. If you changed plans or seat count mid-period, the difference appears as a prorated line calculated to the day, not the hour.",
  },
  {
    title: "Change or cancel your plan",
    category: "Billing",
    summary: "Upgrades take effect immediately; downgrades wait for the period to end.",
    body: "An upgrade applies the moment you confirm it, and the remainder of the current period is prorated onto your next invoice. A downgrade is scheduled for the end of the period so you keep what you have already paid for. Cancelling behaves the same way: the workspace stays on its plan until the period ends, then moves to the free tier with its data intact and read-only where the free tier does not reach.",
  },
  {
    title: "Update your payment method",
    category: "Billing",
    summary: "Add a card, make it the default, and remove the one it replaced.",
    body: "Add the new card first from Billing → Payment methods, then mark it as the default; a workspace always keeps at least one card on file while it has an active subscription. Removing a card that is not the default is immediate. If a charge has already failed, adding a card does not retry it on its own — use Retry payment on the invoice itself.",
  },
  {
    title: "Add a VAT number and billing address",
    category: "Billing",
    summary: "Where tax details go, and when a change reaches the next invoice.",
    body: "Billing details live on the workspace, not on the person paying, so they survive a change of owner. A VAT number is validated against the registry for its country when you save it; an invalid one is rejected rather than stored. Details you change apply to the next invoice issued — an invoice already sent cannot be reissued with new tax details.",
  },
  {
    title: "Connect a chat workspace",
    category: "Integrations",
    summary: "Authorise the app, pick a default channel, and route events per project.",
    body: "Authorising the app installs it into the chat workspace you choose and posts nothing until you pick a default channel. Each project can then override that channel, which is how a noisy service ends up somewhere other than the room everyone reads. Disconnecting removes the app's token immediately; the messages it has already posted stay where they are.",
  },
  {
    title: "Create and rotate API keys",
    category: "Integrations",
    summary: "Scope a key to what it needs, and replace it without downtime.",
    body: "A key is shown once, at the moment it is created, and stored only as a hash afterwards — if you lose it, create another. Scope each key to the smallest set of permissions the integration needs. To rotate without downtime, create the replacement, deploy it, then revoke the old key once its last-used timestamp stops moving.",
  },
  {
    title: "Send events to a webhook",
    category: "Integrations",
    summary: "Register an endpoint, verify the signature, and read the delivery log.",
    body: "Register the endpoint and choose which event types it should receive. Every delivery carries a signature header computed from your endpoint's secret; verify it before you trust the payload. Failed deliveries are retried with exponential backoff for up to 24 hours, and every attempt — with its response code and body — is visible in the delivery log.",
  },
  {
    title: "Export to your data warehouse",
    category: "Integrations",
    summary: "Schedule a nightly sync into the warehouse your analysts already use.",
    body: "A warehouse sync writes one table per entity plus a manifest describing the run. Schedule it for a quiet hour in your own timezone; the first run is a full load and every run after it is incremental, keyed on the row's updated timestamp. A run that fails leaves the previous tables untouched, so a warehouse is never half-updated.",
  },
  {
    title: "Turn on two-factor authentication",
    category: "Security",
    summary: "Scan the setup code with an authenticator app, then confirm a six-digit code.",
    body: "Two-factor authentication asks for a code from your phone in addition to your password. Scan the setup code with any authenticator app, or type the secret in by hand if the camera is not available, then confirm the six digits it shows. Save your recovery codes somewhere other than the phone — they are the only way back in if you lose the device.",
  },
  {
    title: "Sign in with a passkey",
    category: "Security",
    summary: "What a passkey is, where it is stored, and how to add one to your account.",
    body: "A passkey replaces your password with a key pair your device holds. The private half never leaves the device and is unlocked by whatever already unlocks it — a fingerprint, a face, a PIN — so there is nothing to type and nothing a phishing page can capture. Add one from Settings → Security; you can hold several, one per device, and remove any of them without affecting the rest.",
  },
  {
    title: "Enforce single sign-on for your workspace",
    category: "Security",
    summary: "Point the workspace at your identity provider and require it for everyone.",
    body: "Single sign-on is configured per workspace against a SAML or OIDC provider. Test it with your own account before you enforce it: once enforcement is on, every member signs in through the provider and password sign-in stops working. The owner keeps a break-glass password login so a misconfigured provider cannot lock the workspace out entirely.",
  },
  {
    title: "Read the audit log",
    category: "Security",
    summary: "Who changed what, when, and from where — and how far back it goes.",
    body: "Every change to a member, a role, a key or a billing detail is written to the audit log with the actor, the resource, the fields that changed, and the address the request came from. Entries are immutable and retained for 90 days on Team and two years on Enterprise. Filter by actor or resource to narrow a search, and export the result as CSV for an auditor who cannot log in.",
  },
  {
    title: "Export a table as CSV",
    category: "Data & exports",
    summary: "Export what is on screen, filters and column choices included.",
    body: "An export takes the table exactly as you are looking at it: the same filters, the same sort, the same visible columns. Anything under fifty thousand rows downloads straight away; anything larger is prepared in the background and emailed to you as a link that expires after seven days. Dates are written in ISO 8601 and always in UTC.",
  },
  {
    title: "Schedule a recurring report",
    category: "Data & exports",
    summary: "Have the same view delivered by email every week or every month.",
    body: "Save a view first — its filters and columns are what the report will contain — then schedule it daily, weekly or monthly, and choose who receives it. Recipients do not need an account to open the attachment, so treat the schedule as a sharing decision. A scheduled report that errors twice in a row is paused and its owner told why.",
  },
  {
    title: "Request an export of your whole account",
    category: "Data & exports",
    summary: "One archive of everything the workspace holds, and how long it takes.",
    body: "An account export packages every entity the workspace holds as newline-delimited JSON, one file per entity, in a single archive. Only an owner can request one, and only one request runs at a time. Large workspaces take a few hours; you will get an email with a download link when it is ready, and the link is good for seven days.",
  },
  {
    title: "Delete data and understand retention",
    category: "Data & exports",
    summary: "What a delete removes now, what it removes later, and what it never removes.",
    body: "Deleting a row removes it from every view immediately and from backups within 35 days. Audit entries are the exception: they record that the deletion happened and are kept for the retention window of your plan. Deleting a workspace schedules the whole account for removal after 30 days, during which an owner can still restore it.",
  },
  {
    title: "A sign-in link says it has expired",
    category: "Troubleshooting",
    summary: "Why magic links expire, and what to do when yours already has.",
    body: "A sign-in link is good for fifteen minutes and for one use. A mail scanner that follows links before you do will spend it — which is why the second click reports an expired link even though you never used the first. Request a new one from the same page; if that keeps happening, ask your mail administrator to exclude our sending domain from link scanning.",
  },
  {
    title: "An import failed part way through",
    category: "Troubleshooting",
    summary: "How to read the error report, fix the file, and import the rest.",
    body: "An import is applied in batches, and a batch that fails is rolled back whole, so rows are never half-written. The error report names the line number and the field for every rejected row. Fix those lines in the original file, remove the lines that already imported, and run it again — repeated rows are matched on their external id and updated rather than duplicated.",
  },
  {
    title: "A webhook is not being delivered",
    category: "Troubleshooting",
    summary: "Work through the delivery log, the signature, and the response your endpoint sends.",
    body: "Start with the delivery log: it shows every attempt, the status code your endpoint returned, and the first kilobyte of its response. Anything other than a 2xx counts as a failure and is retried. The two common causes are a signature check comparing against the wrong secret after a rotation, and an endpoint that takes longer than ten seconds to answer — acknowledge first, then do the work.",
  },
  {
    title: "Charts show no data for a period",
    category: "Troubleshooting",
    summary: "Timezones, incomplete days, and filters that outlive the page you set them on.",
    body: "Three things usually explain an empty chart. The period may be measured in UTC while you are reading it in your own timezone, so the current day looks short. The most recent hour is still being aggregated and is deliberately left out. And a filter saved on a view stays on it — check the toolbar for a filter you set on a previous visit before concluding the data is missing.",
  },
]

function generate(): Article[] {
  const rand = seeded("articles")

  return TOPICS.map((topic, index) => ({
    id: rowId("art", index + 1),
    slug: slugify(topic.title),
    title: topic.title,
    category: topic.category,
    summary: topic.summary,
    body: topic.body,
    // Help text is revised, not written once: everything has been touched in
    // the last ten months, and nothing is stamped after "now".
    updatedAt: dateBetween(rand, daysAgo(300), daysAgo(2)),
    minutesToRead: intBetween(rand, 2, 8),
    views: intBetween(rand, 180, 9_400),
  }))
}

/** The seeded help centre. The repository below owns the rows a demo mutates. */
export const ARTICLES: Article[] = generate()

export const articles = createRepository(ARTICLES, {
  searchKeys: ["title", "summary", "category", "body"],
  filterKeys: ["category"],
})
lib/sample-data/audit-events.ts
import { REFERENCE_DATE, createRepository, dateBetween, daysAgo, intBetween, pick, pickWeighted, rowId, seeded, shuffle } from "./core"
import { MEMBERS } from "./members"

/** One line in the workspace's audit trail. */
export type AuditEvent = {
  id: string
  at: Date
  /** A Member id: whoever performed the action. */
  actor: string
  action: string
  resource: string
  resourceId: string
  ip: string
  diff?: { field: string; from: string; to: string }[]
}

const COUNT = 500

const RESOURCES = [
  "customer", "member", "invitation", "role", "api_key",
  "integration", "billing", "service", "deployment", "alert",
]

// A plausible-looking id for the log line — not asserted to resolve to a real
// row the way `actor` is; an audit trail routinely outlives the records it
// describes.
const RESOURCE_PREFIX: Record<string, string> = {
  customer: "cus",
  member: "mbr",
  invitation: "ivt",
  role: "rol",
  api_key: "key",
  integration: "itg",
  billing: "bil",
  service: "svc",
  deployment: "dpl",
  alert: "alr",
}

const ACTION_MIX: readonly (readonly [string, number])[] = [
  ["updated", 0.35],
  ["created", 0.25],
  ["deleted", 0.08],
  ["invited", 0.08],
  ["revoked", 0.06],
  ["enabled", 0.06],
  ["disabled", 0.06],
  ["exported", 0.06],
]

/** A field this kind of change might touch, and a few plausible values. */
const DIFF_FIELDS: { field: string; values: string[] }[] = [
  { field: "status", values: ["active", "suspended", "churned", "trial"] },
  { field: "role", values: ["member", "admin", "viewer", "owner"] },
  { field: "plan", values: ["free", "starter", "team", "enterprise"] },
  { field: "seats", values: ["5", "10", "25", "50"] },
  { field: "name", values: ["Untitled", "Renamed workspace", "Northwind", "Northwind Studio"] },
]

function buildDiff(rand: () => number): NonNullable<AuditEvent["diff"]> {
  const fields = shuffle(rand, DIFF_FIELDS).slice(0, intBetween(rand, 1, 2))
  return fields.map(({ field, values }) => {
    const [from, to] = shuffle(rand, values).slice(0, 2)
    return { field, from, to }
  })
}

function generate(): AuditEvent[] {
  const rand = seeded("auditEvents")
  const actors = MEMBERS.filter((member) => member.status === "active").map((member) => member.id)

  return Array.from({ length: COUNT }, (_, index) => {
    const resource = pick(rand, RESOURCES)
    const action = pickWeighted(rand, ACTION_MIX)

    return {
      id: rowId("aud", index + 1),
      at: dateBetween(rand, daysAgo(365), REFERENCE_DATE),
      actor: pick(rand, actors),
      action,
      resource,
      resourceId: rowId(RESOURCE_PREFIX[resource], intBetween(rand, 1, 200)),
      ip: `${intBetween(rand, 1, 255)}.${intBetween(rand, 0, 255)}.${intBetween(rand, 0, 255)}.${intBetween(rand, 1, 254)}`,
      diff: action === "updated" ? buildDiff(rand) : undefined,
    }
  })
}

/** The seeded audit trail. The repository below owns the rows a demo mutates. */
export const AUDIT_EVENTS: AuditEvent[] = generate()

export const auditEvents = createRepository(AUDIT_EVENTS, {
  searchKeys: ["action", "resource", "resourceId"],
  filterKeys: ["resource", "actor"],
})
lib/sample-data/avatars.ts
/**
 * A thin re-export. `avatarFor`, `avatarSeed` and `AVATAR_ROUTE` moved to
 * `@/lib/avatars` — the `avatars` kit item — so a marketing
 * section can draw a face without importing the store (spec of 2026-09-19).
 * Kept here, exporting the same names, because every sibling entity module
 * imports it by this relative path and another project shims this exact
 * module: do not change what it exports.
 */
export * from "../avatars"
lib/sample-data/bookings.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  daysAhead,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
} from "./core"
import { avatarFor } from "./avatars"

/**
 * A room in the house. Not an entity of its own: a hotel's room list is a
 * fixed inventory rather than rows that come and go, so it is a constant here
 * and a booking points at one by id.
 */
export type Room = {
  id: string
  /** The number on the door. */
  number: string
  type: "standard" | "deluxe" | "suite"
  floor: number
  beds: number
  /** The rack rate in minor units — what the room goes for before any discount. */
  rackRateCents: number
}

const FLOOR_PLAN: readonly (readonly [Room["type"], number, number, number])[] = [
  // type, rooms per floor, beds, rack rate in cents
  ["standard", 6, 1, 189_00],
  ["deluxe", 4, 2, 279_00],
  ["suite", 2, 2, 449_00],
]

/** The house: three floors of twelve, standard through suite. */
export const ROOMS: readonly Room[] = [1, 2, 3].flatMap((floor) => {
  let door = 0
  return FLOOR_PLAN.flatMap(([type, count, beds, rackRateCents]) =>
    Array.from({ length: count }, () => {
      door += 1
      const number = `${floor}${String(door).padStart(2, "0")}`
      return { id: `room-${number}`, number, type, floor, beds, rackRateCents }
    })
  )
})

/** A stay: who, which room, which nights, and what it came to. */
export type Booking = {
  id: string
  /** The reference a guest quotes at the desk. */
  reference: string
  guestName: string
  /** A face for the guest — the kit's own avatar route for the name. */
  guestAvatar: string
  /** A Room id. */
  roomId: string
  status: "confirmed" | "checked_in" | "checked_out" | "cancelled" | "no_show"
  /** Midnight UTC on the arrival day. */
  checkIn: Date
  /** Midnight UTC on the departure day; the guest is not in the room that night. */
  checkOut: Date
  nights: number
  adults: number
  children: number
  /** What this stay actually pays per night, in minor units. */
  ratePerNightCents: number
  /** `nights * ratePerNightCents`, in minor units. */
  totalCents: number
  currency: "USD"
  /**
   * The channel it came through: the hotel's own desk and site, a travel
   * marketplace, a booking site, a corporate account or the door. Kinds of
   * channel, never the companies that run them.
   */
  source: "direct" | "marketplace" | "booking_site" | "corporate" | "walk_in"
  bookedAt: Date
}

// The window the book covers, in days either side of the reference date: six
// weeks of history for last night's occupancy, six weeks of forward book for a
// month of availability.
const OPEN = -40
const CLOSE = 42

const FIRST_NAMES = [
  "Amara", "Bruno", "Cecile", "Dimitri", "Elif", "Finn", "Greta", "Hassan",
  "Ingrid", "Jonas", "Kaori", "Lucia", "Mateo", "Nadia", "Oskar", "Petra",
  "Quinn", "Rosa", "Samir", "Tanya", "Ulrich", "Vera", "Wren", "Yusuf",
]

const LAST_NAMES = [
  "Adeyemi", "Bertrand", "Castellano", "Delacroix", "Eriksen", "Fontaine",
  "Grunewald", "Hoffmann", "Ivanova", "Janssen", "Kowalski", "Lindqvist",
  "Moretti", "Nakamura", "Oyelaran", "Petrov", "Ramirez", "Sorensen",
  "Tanaka", "Ueda", "Vasquez", "Weber", "Yilmaz", "Zielinski",
]

/**
 * The nights a room sits empty between two stays: mostly none at all, which is
 * what puts occupancy in the seventies rather than at half the house.
 */
const GAP_MIX: readonly (readonly [number, number])[] = [
  [0, 0.45],
  [1, 0.3],
  [2, 0.15],
  [3, 0.1],
]

/** How long a stay runs, weighted the way a city hotel's book actually falls. */
const NIGHTS_MIX: readonly (readonly [number, number])[] = [
  [1, 0.32],
  [2, 0.28],
  [3, 0.19],
  [4, 0.11],
  [5, 0.06],
  [7, 0.04],
]

const SOURCE_MIX: readonly (readonly [Booking["source"], number])[] = [
  ["direct", 0.31],
  ["marketplace", 0.27],
  ["booking_site", 0.18],
  ["corporate", 0.15],
  ["walk_in", 0.09],
]

/** What each channel costs, as a share off the rack rate the room asks. */
const CHANNEL_DISCOUNT: Record<Booking["source"], number> = {
  direct: 0,
  marketplace: 0.15,
  booking_site: 0.17,
  corporate: 0.22,
  walk_in: 0.05,
}

/** Midnight UTC on the day `date` falls on — a night is a calendar day. */
function utcDay(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}

const TODAY = utcDay(REFERENCE_DATE)

const ROOM_BY_ID = new Map(ROOMS.map((room) => [room.id, room]))

/**
 * What a stay is, read off the calendar. A guest who left before today is
 * checked out; one who arrived earlier and has not left is in the house — due
 * out today included, because they are still in the room this morning; and one
 * arriving today or later is confirmed but not yet at the desk, which is what
 * makes an arrivals list something a front desk still has to work through. A
 * scattering of cancellations and no-shows sits over all of it.
 */
function statusFor(rand: () => number, checkIn: Date, checkOut: Date): Booking["status"] {
  if (chance(rand, 0.06)) return "cancelled"
  if (checkOut.getTime() < TODAY.getTime()) return chance(rand, 0.04) ? "no_show" : "checked_out"
  if (checkIn.getTime() >= TODAY.getTime()) return "confirmed"
  return "checked_in"
}

/**
 * The book, laid out room by room rather than booking by booking: each room is
 * filled from the start of the window with a stay, then a gap, then the next
 * stay, until the window runs out. Picking a room at random per booking would
 * put two guests in one room on the same night, and an in-house list that
 * names room 203 twice is not a hotel — it is a bug the picture hides.
 */
function generate(): Booking[] {
  const rand = seeded("bookings")

  const stays: { roomId: string; night: number; nights: number }[] = []
  for (const room of ROOMS) {
    // Each room opens a few days into the window, so the house does not turn
    // over all at once on the first morning.
    let night = OPEN + intBetween(rand, 0, 4)
    while (night <= CLOSE) {
      const nights = pickWeighted(rand, NIGHTS_MIX)
      stays.push({ roomId: room.id, night, nights })
      night += nights + pickWeighted(rand, GAP_MIX)
    }
  }
  // Ids run in arrival order, so bkg_0001 is the first guest through the door.
  stays.sort((a, b) => a.night - b.night || a.roomId.localeCompare(b.roomId))

  return stays.map((stay, index) => {
    const checkIn = utcDay(daysAhead(stay.night))
    const checkOut = daysAhead(stay.nights, checkIn)
    const room = ROOM_BY_ID.get(stay.roomId)!
    const source = pickWeighted(rand, SOURCE_MIX)
    const ratePerNightCents =
      Math.round((room.rackRateCents * (1 - CHANNEL_DISCOUNT[source])) / 100) * 100

    // Booked between six months out and the morning of arrival — a walk-in on
    // the day, a corporate block half a year ahead — but never later than
    // "now", because a stay still ahead of us was booked behind us.
    const bookedAt = dateBetween(
      rand,
      daysAgo(180, checkIn),
      new Date(Math.min(checkIn.getTime(), REFERENCE_DATE.getTime()))
    )

    const guestName = `${pick(rand, FIRST_NAMES)} ${pick(rand, LAST_NAMES)}`

    return {
      id: rowId("bkg", index + 1),
      reference: `NW-${String(index + 1).padStart(5, "0")}`,
      guestName,
      guestAvatar: avatarFor(guestName),
      roomId: room.id,
      status: statusFor(rand, checkIn, checkOut),
      checkIn,
      checkOut,
      nights: stay.nights,
      adults: intBetween(rand, 1, 2),
      children: chance(rand, 0.22) ? intBetween(rand, 1, 2) : 0,
      ratePerNightCents,
      totalCents: stay.nights * ratePerNightCents,
      currency: "USD" as const,
      source,
      bookedAt,
    }
  })
}

/** The seeded book. The repository below owns the rows the desk changes. */
export const BOOKINGS: Booking[] = generate()

export const bookings = createRepository(BOOKINGS, {
  searchKeys: ["guestName", "reference"],
  filterKeys: ["status", "source", "roomId"],
})
lib/sample-data/campaigns.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  intBetween,
  rowId,
  seeded,
  shuffle,
} from "./core"

/** A marketing push on one channel, tracked from spend through to conversions. */
export type Campaign = {
  id: string
  name: string
  channel: "search" | "social" | "email" | "referral" | "organic"
  /** Spend in minor units — cents, not dollars. */
  spendCents: number
  clicks: number
  conversions: number
  startedAt: Date
  endedAt?: Date
  status: "active" | "paused" | "completed"
}

const COUNT = 24

// A hand-written slate, each name already paired with the channel it runs on.
// A campaign is named for its idea, never for a platform someone else owns:
// the channel already says where it runs.
const CATALOG: readonly (readonly [string, Campaign["channel"]])[] = [
  ["Search — Brand Terms", "search"],
  ["Search — Competitor Terms", "search"],
  ["Search — Product Launch", "search"],
  ["Search — Holiday Push", "search"],
  ["Search — Retargeting", "search"],
  ["Founder Thought Leadership", "social"],
  ["Brand Awareness Series", "social"],
  ["Cart Abandoner Retargeting", "social"],
  ["Short-Form Product Teasers", "social"],
  ["Launch Day Announcement", "social"],
  ["Welcome Series Revamp", "email"],
  ["Quarterly Newsletter", "email"],
  ["Win-Back Campaign", "email"],
  ["Feature Announcement Blast", "email"],
  ["Customer Advocacy Digest", "email"],
  ["Partner Referral Program", "referral"],
  ["Customer Referral Bonus", "referral"],
  ["Affiliate Launch Push", "referral"],
  ["Reseller Incentive Drive", "referral"],
  ["Community Ambassador Program", "referral"],
  ["SEO Content Overhaul", "organic"],
  ["Blog Editorial Calendar", "organic"],
  ["Guest Post Outreach", "organic"],
  ["Documentation Refresh", "organic"],
]

const SPEND_RANGE_CENTS: Record<Campaign["channel"], readonly [number, number]> = {
  search: [200_000, 2_000_000],
  social: [100_000, 1_500_000],
  email: [5_000, 50_000],
  referral: [10_000, 200_000],
  // Organic is editorial time, not ad spend — a small tooling cost at most.
  organic: [0, 20_000],
}

function generate(): Campaign[] {
  const rand = seeded("campaigns")
  // The slate is already distinct; shuffle only so id order does not mirror
  // the channel grouping it was written in.
  const slate = shuffle(rand, CATALOG).slice(0, COUNT)

  return slate.map(([name, channel], index) => {
    const [minSpend, maxSpend] = SPEND_RANGE_CENTS[channel]
    const clicks = intBetween(rand, 200, 40_000)
    // Up to an 8% click-to-conversion rate.
    const conversions = intBetween(rand, 0, Math.floor(clicks * 0.08))
    const startedAt = dateBetween(rand, daysAgo(365), daysAgo(1))
    const hasEnded = chance(rand, 0.6)
    // A live campaign is occasionally paused rather than actively running.
    const status: Campaign["status"] = hasEnded ? "completed" : chance(rand, 0.15) ? "paused" : "active"

    return {
      id: rowId("cmp", index + 1),
      name,
      channel,
      spendCents: intBetween(rand, minSpend, maxSpend),
      clicks,
      conversions,
      startedAt,
      ...(hasEnded ? { endedAt: dateBetween(rand, startedAt, REFERENCE_DATE) } : {}),
      status,
    }
  })
}

/** The seeded campaign slate. The repository below owns the rows a demo mutates. */
export const CAMPAIGNS: Campaign[] = generate()

export const campaigns = createRepository(CAMPAIGNS, {
  searchKeys: ["name"],
  filterKeys: ["channel", "status"],
})
lib/sample-data/chats.ts
import {
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  intBetween,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { MEMBERS } from "./members"

/**
 * A conversation in the chat app: a direct message with one teammate, or a
 * named group. The signed-in member — `MEMBERS[0]`, the workspace owner — is
 * in every one of them, which is what makes the list theirs.
 */
export type Chat = {
  id: string
  kind: "dm" | "group"
  /** A group's own name; for a dm, the other member's name. */
  name: string
  /** Member ids, the signed-in member included. */
  memberIds: string[]
  /** Messages at the end of the thread the reader has not opened yet. */
  unread: number
  lastMessageAt: Date
  muted: boolean
}

const DM_COUNT = 9

const GROUP_NAMES = ["Design team", "Launch week", "Platform on-call", "Weekend trip", "Book club"]

function generate(): Chat[] {
  const rand = seeded("chats")
  const me = MEMBERS[0]
  // Only people still in the workspace write, and nobody chats with themselves.
  const others = MEMBERS.filter((member) => member.status === "active" && member.id !== me.id)
  const partners = shuffle(rand, others).slice(0, DM_COUNT)

  const dms: Chat[] = partners.map((partner, index) => ({
    id: rowId("cht", index + 1),
    kind: "dm",
    name: partner.name,
    memberIds: [me.id, partner.id],
    unread: chance(rand, 0.3) ? intBetween(rand, 1, 5) : 0,
    // Most threads moved this week; a couple went quiet a fortnight ago.
    lastMessageAt: dateBetween(rand, daysAgo(chance(rand, 0.75) ? 6 : 16), daysAgo(1 / 96)),
    muted: chance(rand, 0.1),
  }))

  const groups: Chat[] = GROUP_NAMES.map((name, index) => {
    const size = intBetween(rand, 3, 5)
    return {
      id: rowId("cht", DM_COUNT + index + 1),
      kind: "group",
      name,
      memberIds: [me.id, ...shuffle(rand, others).slice(0, size - 1).map((member) => member.id)],
      unread: chance(rand, 0.4) ? intBetween(rand, 1, 8) : 0,
      lastMessageAt: dateBetween(rand, daysAgo(chance(rand, 0.75) ? 6 : 16), daysAgo(1 / 48)),
      // A busy group is the one a reader mutes.
      muted: chance(rand, 0.4),
    }
  })

  return [...dms, ...groups]
}

/** The seeded chats. The repository below owns the rows the app opens and answers. */
export const CHATS: Chat[] = generate()

export const chats = createRepository(CHATS, {
  searchKeys: ["name"],
  filterKeys: ["kind", "muted"],
})
lib/sample-data/cohorts.ts
import { REFERENCE_DATE, createRepository, intBetween, rowId, seeded } from "./core"

/**
 * One week's signups, followed week by week. `retention[w]` is the share of the
 * cohort still active `w` weeks after it arrived, so `retention[0]` is always 1
 * and the array is only as long as the cohort has lived — a week it has not
 * reached yet is absent rather than reported as a loss.
 */
export type Cohort = {
  id: string
  /** Midnight UTC on the Monday the cohort signed up. */
  week: Date
  /** How many accounts opened that week. */
  size: number
  /** Weeks 0 through 8, each a share of `size` in (0, 1]. */
  retention: number[]
}

/** How many weekly cohorts are followed, and how far each one is followed. */
const COHORT_COUNT = 12
const MAX_WEEK = 8

const DAY_MS = 86_400_000

/** Midnight UTC on the Monday of the week `weeksAgo` before REFERENCE_DATE. */
function weekStart(weeksAgo: number): Date {
  const day = Date.UTC(
    REFERENCE_DATE.getUTCFullYear(),
    REFERENCE_DATE.getUTCMonth(),
    REFERENCE_DATE.getUTCDate()
  )
  // getUTCDay is Sunday-based; this shifts it so Monday is 0.
  const sinceMonday = (REFERENCE_DATE.getUTCDay() + 6) % 7
  return new Date(day - (sinceMonday + weeksAgo * 7) * DAY_MS)
}

/**
 * The share still active one week on from `held`. Churn is front-loaded — the
 * accounts that were never going to stay leave first — so the weekly keep rate
 * climbs from about two thirds in week one towards nine tenths by week eight.
 */
function keepRate(rand: () => number, week: number): number {
  const settled = Math.min(1, (week - 1) / (MAX_WEEK - 1))
  return 0.62 + settled * 0.28 + rand() * 0.06
}

function generate(): Cohort[] {
  const rand = seeded("cohorts")

  return Array.from({ length: COHORT_COUNT }, (_, index) => {
    const weeksAgo = COHORT_COUNT - 1 - index
    // A cohort can only be followed as far as the calendar has taken it.
    const followed = Math.min(weeksAgo, MAX_WEEK)
    const size = intBetween(rand, 120, 460) + index * 8

    const retention = [1]
    for (let week = 1; week <= followed; week++) {
      retention.push(Math.round(retention[week - 1] * keepRate(rand, week) * 10_000) / 10_000)
    }

    return {
      id: rowId("coh", index + 1),
      week: weekStart(weeksAgo),
      size,
      retention,
    }
  })
}

/** The seeded weekly cohorts, oldest first. */
export const COHORTS: Cohort[] = generate()

export const cohorts = createRepository(COHORTS, { searchKeys: [], filterKeys: [] })
lib/sample-data/comments.ts
import { REFERENCE_DATE, chance, createRepository, dateBetween, intBetween, pick, rowId, seeded, shuffle } from "./core"
import { avatarFor } from "./avatars"
import { MEMBERS } from "./members"
import { REPLIES } from "./post-templates"
import { POSTS, type Post } from "./posts"

/** A reply under a post on the team feed, from someone in the workspace. */
export type Comment = {
  id: string
  /** A Post id. */
  postId: string
  author: { name: string; avatarUrl: string }
  /** Always after the post it answers, never after now. */
  at: Date
  text: string
}

const COUNT = 140

const MINUTE_MS = 60_000
/** A thread goes quiet within two days of its post. */
const THREAD_MS = 2 * 24 * 60 * MINUTE_MS

/**
 * How many replies each post gets, adding up to `COUNT` exactly: a draw per
 * post — a few get none — scaled to the total, with the rounding error settled
 * on the busiest thread.
 */
function allocate(rand: () => number, posts: readonly Post[]): number[] {
  const raw = posts.map(() => (chance(rand, 0.12) ? 0 : intBetween(rand, 1, 8)))
  const total = raw.reduce((sum, value) => sum + value, 0)
  const counts = raw.map((value) => Math.round((value / total) * COUNT))
  const settled = counts.reduce((sum, value) => sum + value, 0)
  const busiest = counts.indexOf(Math.max(...counts))
  counts[busiest] += COUNT - settled
  return counts
}

/** The replies that fit a post: by its media, else by the first topic it names, then the ones that fit anything. */
function repliesFor(rand: () => number, post: Post): string[] {
  const topic = post.text.match(/#[a-z][a-z-]*/)?.[0] ?? ""
  const own = post.media ? REPLIES[post.media.kind] : (REPLIES.note[topic] ?? [])
  return [...shuffle(rand, own), ...shuffle(rand, REPLIES.any)]
}

function generate(): Comment[] {
  const rand = seeded("comments")
  const people = MEMBERS.filter((member) => member.status === "active")
  const counts = allocate(rand, POSTS)
  const rows: Comment[] = []

  POSTS.forEach((post, postIndex) => {
    const count = counts[postIndex]
    if (count === 0) return
    const replies = repliesFor(rand, post)
    const opens = post.at.getTime() + 5 * MINUTE_MS
    const closes = Math.min(post.at.getTime() + THREAD_MS, REFERENCE_DATE.getTime())
    const times = Array.from({ length: count }, () => dateBetween(rand, new Date(opens), new Date(closes)))
      .map((date) => date.getTime())
      .sort((a, b) => a - b)

    for (let index = 0; index < count; index += 1) {
      // Mostly other people; now and then the author answers their own thread.
      const answering = chance(rand, 0.15)
      const author = answering
        ? (people.find((member) => member.name === post.author.name) ?? pick(rand, people))
        : pick(rand, people.filter((member) => member.name !== post.author.name))
      rows.push({
        id: rowId("cmt", rows.length + 1),
        postId: post.id,
        author: { name: author.name, avatarUrl: avatarFor(author.name) },
        at: new Date(times[index]),
        text: replies[index % replies.length],
      })
    }
  })

  return rows
}

/** The seeded replies. The repository below owns the ones the feed adds. */
export const COMMENTS: Comment[] = generate()

export const comments = createRepository(COMMENTS, {
  searchKeys: ["text"],
  filterKeys: ["postId"],
})
lib/sample-data/core.ts
/**
 * Deterministic sample data behind a repository interface.
 *
 * No block owns a mock array. A block reads `db.<entity>` — a `Repository<T>` —
 * so the same page that renders seeded rows here renders real ones once a
 * consumer swaps the store: implement `Repository<T>` against a database or an
 * API and replace the entity module's exported repository. Nothing else in the
 * block changes, because `list` / `get` / `create` / `update` / `remove` /
 * `all` is the whole contract.
 *
 * Determinism is the other half. Every value comes from `seeded("<entity>")`
 * and `REFERENCE_DATE`; no module reads the clock or calls `Math.random`, so a
 * server render and a client render agree, snapshots hold, and a row referred
 * to by id in a test is the same row tomorrow. `REFERENCE_DATE` is "now" for
 * blocks too — a relative timestamp is measured against it, never the clock.
 *
 * This file holds the repository and the interfaces; `generate.ts` holds
 * `REFERENCE_DATE` and the generator vocabulary and is re-exported below, so an
 * entity module needs one import specifier: `from "./core"`.
 *
 * ## Writing an entity module
 *
 * One file per entity, `<entity>.ts`, exporting exactly three things:
 *
 * 1. the row type — `export type Customer = { id: string; ... }`;
 * 2. the generated array, a screaming-case constant — `export const CUSTOMERS`,
 *    built inside a `generate()` function that opens with
 *    `const rand = seeded("customers")` and uses only that generator, the
 *    helpers below, and word lists declared in the same file (realistic words:
 *    real given names, plausible company names and countries — never lorem);
 * 3. the repository — `export const customers = createRepository(CUSTOMERS, {
 *    searchKeys, filterKeys })`, naming the columns a table may search and
 *    filter on.
 *
 * Ids are `rowId("cus", i + 1)` → `cus_0001`, stable across runs, and the
 * prefix comes from the assigned table below. Money is in minor units, named
 * for it (`mrrCents`, `amountCents`), and formatted with
 * `@/lib/format`. Dates are `Date` objects at or before
 * `REFERENCE_DATE` unless the field is a future date by nature (an expiry, a
 * renewal). An entity may import another entity's array to reference its ids
 * (`customers.ts` reads `MEMBERS` to pick an owner) as long as the imports stay
 * one-directional — `index.ts` is the only place that knows about all of them.
 *
 * Finally add the module to `index.ts`: one `export *`, one import, one key on
 * `db`, all in alphabetical order.
 *
 * ## Id prefixes, one per entity module
 *
 * customers `cus` · members `mbr` · orders `ord` · invoices `inv` ·
 * subscriptions `sub` · products `prd` · plans `pln` · paymentMethods `pmt` ·
 * campaigns `cmp` · events `evt` · invitations `ivt` · roles `rol` ·
 * incidents `inc` · services `svc` · logEntries `log` · alerts `alr` ·
 * notifications `ntf` · apiKeys `key` · integrations `itg` ·
 * deployments `dpl` · aiRequests `aiq` · auditEvents `aud` · sessions `ses` ·
 * cohorts `coh` · tasks `tsk` · projects `prj` · deals `dea` · bookings `bkg` ·
 * shipments `shp` · articles `art` · releases `rel` · leads `lea` ·
 * employees `emp` · leaves `lve` · listings `lst` · courses `crs` ·
 * appointments `apt` · holdings `hld` · trades `trd` · files `fil` ·
 * notes `not` · chats `cht` · messages `msg` · aiThreads `ait` ·
 * generations `gen` · voiceJobs `voi` · transactions `txn` · students `stu` · workflows `wfl` ·
 * mails `eml` · posts `pst` · comments `cmt` · reviews `rev`
 *
 * `ID_PREFIXES` below is that table in code, and a test keeps it unique: take
 * your entity's prefix from it rather than inventing one.
 *
 * ## What the repository does with the rows
 *
 * `createRepository` copies the array it is given, so the exported constant
 * stays the pristine seed and the repository owns the live rows: a `create`
 * shows up in `customers.all()` and never in `CUSTOMERS`. `all()` and `list()`
 * hand back a fresh array each call, but the rows inside it are the store's own
 * objects — write through `update`, and treat a row you were handed as
 * read-only, because editing one edits the store without going through the
 * repository. Mutation is in-memory and process-local — a dev server keeps it
 * until it restarts, and every reader in that process sees it. That is enough
 * for demos and templates, and it is exactly the seam a real store slots into.
 */

import { rowId } from "./generate"

export * from "./generate"

/**
 * The id prefix assigned to each entity module, in the order the modules were
 * planned. One prefix per module, never reused: `cus_0001` says which table a
 * row came from at a glance, in a URL, and in a test.
 */
export const ID_PREFIXES = [
  ["customers", "cus"],
  ["members", "mbr"],
  ["orders", "ord"],
  ["invoices", "inv"],
  ["subscriptions", "sub"],
  ["products", "prd"],
  ["plans", "pln"],
  ["paymentMethods", "pmt"],
  ["campaigns", "cmp"],
  ["events", "evt"],
  ["invitations", "ivt"],
  ["roles", "rol"],
  ["incidents", "inc"],
  ["services", "svc"],
  ["logEntries", "log"],
  ["alerts", "alr"],
  ["notifications", "ntf"],
  ["apiKeys", "key"],
  ["integrations", "itg"],
  ["deployments", "dpl"],
  ["aiRequests", "aiq"],
  ["auditEvents", "aud"],
  ["sessions", "ses"],
  ["cohorts", "coh"],
  ["tasks", "tsk"],
  ["projects", "prj"],
  ["deals", "dea"],
  ["bookings", "bkg"],
  ["shipments", "shp"],
  ["articles", "art"],
  ["releases", "rel"],
  ["leads", "lea"],
  ["employees", "emp"],
  ["leaves", "lve"],
  ["listings", "lst"],
  ["courses", "crs"],
  ["appointments", "apt"],
  ["holdings", "hld"],
  ["trades", "trd"],
  ["files", "fil"],
  ["notes", "not"],
  ["chats", "cht"],
  ["messages", "msg"],
  ["aiThreads", "ait"],
  ["generations", "gen"],
  ["voiceJobs", "voi"],
  ["transactions", "txn"],
  ["students", "stu"],
  ["workflows", "wfl"],
  ["mails", "eml"],
  ["posts", "pst"],
  ["comments", "cmt"],
  ["reviews", "rev"],
] as const satisfies readonly (readonly [string, string])[]

export type SortSpec = { id: string; desc?: boolean }

export type ListQuery = {
  page?: number
  pageSize?: number
  sort?: SortSpec
  search?: string
  filters?: Record<string, string | string[]>
}

export type Page<T> = { rows: T[]; total: number; page: number; pageSize: number; pageCount: number }

export type Result<T> = { ok: true; data: T } | { ok: false; error: { code: string; message: string; field?: string } }

export type Repository<T extends { id: string }> = {
  list(query?: ListQuery): Promise<Page<T>>
  get(id: string): Promise<T | undefined>
  create(input: Omit<T, "id">): Promise<Result<T>>
  update(id: string, patch: Partial<T>): Promise<Result<T>>
  remove(id: string): Promise<Result<{ id: string }>>
  /** Synchronous, for charts and aggregates. A fresh array of the store's own rows. */
  all(): T[]
}

const DEFAULT_PAGE = 1
const DEFAULT_PAGE_SIZE = 20

const notFound = (id: string): Result<never> => ({
  ok: false,
  error: { code: "not_found", message: `No row with id "${id}"` },
})

/** A finite number, floored; anything else (NaN from an unparsed input) is the default. */
const counted = (value: number, fallback: number): number =>
  Number.isFinite(value) ? Math.floor(value) : fallback

/** The `cus` in `cus_0001`; `row` when the seed is empty or its ids are not prefixed. */
function prefixOf(rows: { id: string }[]): string {
  const [first] = rows
  if (!first || !first.id.includes("_")) return "row"
  return first.id.slice(0, first.id.indexOf("_"))
}

/**
 * An in-memory `Repository<T>` over a copy of `rows`.
 *
 * `list` applies, in order: `search` (case-insensitive substring over
 * `searchKeys`), `filters`, `sort`, then pagination.
 *
 * - Filters are equality, or membership when the value is an array, over
 *   `filterKeys` only, with every value compared as a string. An empty string
 *   or an empty array means no constraint at all, because that is what a
 *   cleared filter menu hands back and the reader means "all of them" by it.
 * - Sorting is `localeCompare` for strings and subtraction for numbers and
 *   dates. A `null` or `undefined` value compares as an empty string, so those
 *   rows lead in ascending order and trail in descending; an unknown `sort.id`
 *   leaves the order alone.
 * - `page` is 1-based and clamped into range; `pageSize` defaults to 20; a
 *   value that is not a finite number falls back to the default.
 */
export function createRepository<T extends { id: string }>(
  rows: T[],
  options: { searchKeys?: (keyof T)[]; filterKeys?: (keyof T)[] } = {}
): Repository<T> {
  const { searchKeys = [], filterKeys = [] } = options
  const store: T[] = [...rows]
  const prefix = prefixOf(store)
  let counter = store.length

  const matchesSearch = (row: T, search: string): boolean => {
    const needle = search.trim().toLowerCase()
    if (!needle) return true
    return searchKeys.some((key) => String(row[key] ?? "").toLowerCase().includes(needle))
  }

  const matchesFilters = (row: T, filters: Record<string, string | string[]>): boolean =>
    Object.entries(filters).every(([key, value]) => {
      if (!filterKeys.includes(key as keyof T)) return true
      const wanted = (Array.isArray(value) ? value : [value]).filter((entry) => entry !== "")
      // Nothing selected is not the same as "match nothing" — a cleared filter
      // menu hands back an empty array, and the reader means "all of them".
      if (!wanted.length) return true
      return wanted.includes(String(row[key as keyof T] ?? ""))
    })

  const compare = (a: T, b: T, key: keyof T): number => {
    const left = a[key]
    const right = b[key]
    if (left instanceof Date && right instanceof Date) return left.getTime() - right.getTime()
    if (typeof left === "number" && typeof right === "number") return left - right
    return String(left ?? "").localeCompare(String(right ?? ""))
  }

  const paginate = (query: ListQuery = {}): Page<T> => {
    const { page = DEFAULT_PAGE, pageSize = DEFAULT_PAGE_SIZE, sort, search = "", filters = {} } = query

    let matched = store.filter((row) => matchesSearch(row, search) && matchesFilters(row, filters))
    if (sort) {
      const key = sort.id as keyof T
      const direction = sort.desc ? -1 : 1
      // Array.prototype.sort is stable, so an unknown key (every comparison 0)
      // leaves the rows in the order the filters left them.
      matched = [...matched].sort((a, b) => direction * compare(a, b, key))
    }

    const total = matched.length
    const size = Math.max(1, counted(pageSize, DEFAULT_PAGE_SIZE))
    const pageCount = Math.ceil(total / size)
    const current = Math.min(Math.max(1, counted(page, DEFAULT_PAGE)), Math.max(1, pageCount))
    const start = (current - 1) * size
    return { rows: matched.slice(start, start + size), total, page: current, pageSize: size, pageCount }
  }

  return {
    async list(query) {
      return paginate(query)
    },
    async get(id) {
      return store.find((row) => row.id === id)
    },
    async create(input) {
      // Padded like a generated id, so a row added at runtime sits in the same
      // series as the seeded ones; the loop steps over an id the seed took.
      let id = rowId(prefix, ++counter)
      while (store.some((row) => row.id === id)) id = rowId(prefix, ++counter)
      const row = { ...input, id } as T
      store.push(row)
      return { ok: true, data: row }
    },
    async update(id, patch) {
      const index = store.findIndex((row) => row.id === id)
      if (index === -1) return notFound(id)
      // An undefined value means "not in this patch", not "erase the field" —
      // a form that leaves a control untouched sends exactly that.
      const defined = Object.entries(patch).filter(([, value]) => value !== undefined)
      // A new object rather than an in-place edit, so the seed array the
      // repository was built from keeps the row it started with.
      const next = { ...store[index], ...Object.fromEntries(defined), id } as T
      store[index] = next
      return { ok: true, data: next }
    },
    async remove(id) {
      const index = store.findIndex((row) => row.id === id)
      if (index === -1) return notFound(id)
      store.splice(index, 1)
      return { ok: true, data: { id } }
    },
    all() {
      return [...store]
    },
  }
}

/** FNV-1a: a string to a 32-bit seed, so a name alone fixes a sequence. */
function hashName(name: string): number {
  let hash = 2166136261
  for (let i = 0; i < name.length; i++) {
    hash ^= name.charCodeAt(i)
    hash = Math.imul(hash, 16777619)
  }
  return hash >>> 0
}

/** A mulberry32 generator seeded from `name`: the same name, the same sequence. */
export function seeded(name: string): () => number {
  let state = hashName(name)
  return () => {
    state = (state + 0x6d2b79f5) | 0
    let t = Math.imul(state ^ (state >>> 15), 1 | state)
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
  }
}
lib/sample-data/courses.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
} from "./core"
import { MEMBERS } from "./members"

/** One course in the academy's catalogue, and how far the signed-in learner is through it. */
export type Course = {
  id: string
  title: string
  category: "Web" | "Backend" | "Design" | "Data" | "Product"
  level: "intro" | "intermediate" | "advanced"
  /** Out of five, to one decimal, from 3.5 up: a catalogue keeps what it is proud of. */
  rating: number
  lessons: number
  /** How many learners have ever enrolled. */
  enrolled: number
  /** The share of enrolled learners who finished, 0–1. */
  completionRate: number
  /** A Member id: who teaches it. */
  instructorId: string
  /** The signed-in learner's own progress, 0–1; 0 is not started, 1 is done. */
  progress: number
  updatedAt: Date
}

// The catalogue is a list, not a random draw: a title and its category are
// written together, so "SQL for Analysts" can never land under Design.
const CATALOG: readonly (readonly [string, Course["category"]])[] = [
  ["Introduction to React", "Web"],
  ["TypeScript for Application Code", "Web"],
  ["CSS Layout Systems", "Web"],
  ["Shipping with Next.js", "Web"],
  ["Accessible Interfaces", "Web"],
  ["Node.js API Development", "Backend"],
  ["PostgreSQL Performance", "Backend"],
  ["Event-driven Architecture", "Backend"],
  ["Authentication from Scratch", "Backend"],
  ["Observability in Practice", "Backend"],
  ["Design Systems", "Design"],
  ["Interface Typography", "Design"],
  ["Prototyping Interactions", "Design"],
  ["Colour for Interfaces", "Design"],
  ["Motion Principles", "Design"],
  ["SQL for Analysts", "Data"],
  ["Data Modelling", "Data"],
  ["Statistics for Product Teams", "Data"],
  ["Dashboards That Get Read", "Data"],
  ["Building Data Pipelines", "Data"],
  ["Product Discovery", "Product"],
  ["Writing a Product Brief", "Product"],
  ["Roadmaps and Trade-offs", "Product"],
  ["Pricing and Packaging", "Product"],
]

const LEVEL_MIX: readonly (readonly [Course["level"], number])[] = [
  ["intro", 0.4],
  ["intermediate", 0.4],
  ["advanced", 0.2],
]

// Most of the catalogue the learner has not opened; a good third is under
// way and a handful is finished, so a progress column reads three ways.
const PROGRESS_MIX: readonly (readonly ["untouched" | "doing" | "done", number])[] = [
  ["untouched", 0.42],
  ["doing", 0.38],
  ["done", 0.2],
]

function generate(): Course[] {
  const rand = seeded("courses")
  const instructors = MEMBERS.filter((member) => member.status === "active").map((member) => member.id)

  return CATALOG.map(([title, category], index) => {
    const level = pickWeighted(rand, LEVEL_MIX)
    const state = pickWeighted(rand, PROGRESS_MIX)
    // Tenths, so the table's "60%" is the number the row carries.
    const progress =
      state === "untouched" ? 0 : state === "done" ? 1 : intBetween(rand, 1, 9) / 10
    const enrolled = intBetween(rand, 120, 2_400)

    return {
      id: rowId("crs", index + 1),
      title,
      category,
      level,
      rating: intBetween(rand, 35, 50) / 10,
      lessons: intBetween(rand, 8, 42),
      enrolled,
      // A harder course loses more people on the way through.
      completionRate: Math.round((level === "advanced" ? 0.42 : level === "intermediate" ? 0.58 : 0.7) * 100 + intBetween(rand, -8, 18)) / 100,
      instructorId: pick(rand, instructors),
      progress,
      // A course the learner is on was touched recently; the rest, whenever.
      updatedAt:
        state === "doing" && chance(rand, 0.8)
          ? dateBetween(rand, daysAgo(21), REFERENCE_DATE)
          : dateBetween(rand, daysAgo(240), REFERENCE_DATE),
    }
  })
}

/** The seeded catalogue. The repository below owns the rows a learner moves through. */
export const COURSES: Course[] = generate()

export const courses = createRepository(COURSES, {
  searchKeys: ["title"],
  filterKeys: ["category", "level", "instructorId"],
})
lib/sample-data/customers.ts
import {
  REFERENCE_DATE,
  createRepository,
  dateBetween,
  daysAgo,
  domainFor,
  emailFor,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { avatarFor } from "./avatars"
import { MEMBERS } from "./members"

/** A company account: the billing relationship, plus the person who signed up. */
export type Customer = {
  id: string
  name: string
  /** A face for the contact — the kit's own avatar route for the name — */
  avatarUrl?: string
  email: string
  company: string
  plan: "free" | "starter" | "team" | "enterprise"
  status: "active" | "trial" | "churned" | "suspended"
  /** Monthly recurring revenue in minor units — cents, not dollars. */
  mrrCents: number
  seats: number
  country: string
  createdAt: Date
  lastSeenAt: Date
  /** A `Member` id: the account's owner on our side. */
  owner: string
}

const COUNT = 250

const FIRST_NAMES = [
  "Ada", "Owen", "Mira", "Luis", "Nina", "Tobias", "Priya", "Jonas",
  "Elena", "Marcus", "Yuki", "Sofia", "Ivan", "Clara", "Hugo", "Anouk",
  "Diego", "Freya", "Omar", "Lena", "Noah", "Ines", "Rafael", "Maja",
]

const LAST_NAMES = [
  "Lovelace", "Hartley", "Okonkwo", "Ferreira", "Bauer", "Lindqvist", "Raman", "Novak",
  "Petrova", "Delacroix", "Tanaka", "Moreau", "Kovacs", "Nyman", "Silva", "Vermeer",
  "Castillo", "Andersen", "Weiss", "Bergman", "Duarte", "Fontaine", "Kowalski", "Marchetti",
]

const COMPANY_HEADS = [
  "Northwind", "Blue Harbor", "Meridian", "Lakeside", "Kestrel", "Ironwood", "Fathom",
  "Cobalt", "Alder", "Vantage", "Brightline", "Silverpine", "Harborview", "Quarry",
  "Lumen", "Trellis", "Wavelength", "Foxglove", "Beacon", "Granite",
]

const COMPANY_TAILS = [
  "Labs", "Systems", "Analytics", "Health", "Logistics", "Studio", "Retail",
  "Robotics", "Digital", "Group", "Software", "Partners", "Media", "Energy",
]

const COUNTRIES: readonly (readonly [string, number])[] = [
  ["United States", 0.3],
  ["United Kingdom", 0.11],
  ["Germany", 0.1],
  ["Canada", 0.08],
  ["France", 0.07],
  ["Netherlands", 0.06],
  ["Australia", 0.06],
  ["Sweden", 0.05],
  ["Spain", 0.04],
  ["Japan", 0.04],
  ["Brazil", 0.04],
  ["India", 0.03],
  ["Portugal", 0.01],
  ["Ireland", 0.01],
]

const PLAN_MIX: readonly (readonly [Customer["plan"], number])[] = [
  ["free", 0.34],
  ["starter", 0.31],
  ["team", 0.26],
  ["enterprise", 0.09],
]

/** Cents per seat per month. A free plan bills nothing, whatever the seat count. */
const PLAN_PRICE_CENTS: Record<Customer["plan"], number> = {
  free: 0,
  starter: 1_200,
  team: 2_400,
  enterprise: 4_800,
}

const SEAT_RANGE: Record<Customer["plan"], readonly [number, number]> = {
  free: [1, 3],
  starter: [3, 15],
  team: [10, 60],
  enterprise: [50, 400],
}

// Nobody trials the free plan — there is nothing to try — so the status mix
// depends on the plan the account is on.
const FREE_STATUS_MIX: readonly (readonly [Customer["status"], number])[] = [
  ["active", 0.7],
  ["churned", 0.25],
  ["suspended", 0.05],
]

const PAID_STATUS_MIX: readonly (readonly [Customer["status"], number])[] = [
  ["active", 0.72],
  ["trial", 0.12],
  ["churned", 0.12],
  ["suspended", 0.04],
]

function generate(): Customer[] {
  const rand = seeded("customers")
  // Every head/tail pairing, shuffled once and sliced: 280 companies for 250
  // accounts, so no two customers share a company (or, therefore, a domain).
  const companies = shuffle(
    rand,
    COMPANY_HEADS.flatMap((head) => COMPANY_TAILS.map((tail) => `${head} ${tail}`))
  ).slice(0, COUNT)
  const owners = MEMBERS.filter((member) => member.status === "active").map((member) => member.id)

  return companies.map((company, index) => {
    const name = `${pick(rand, FIRST_NAMES)} ${pick(rand, LAST_NAMES)}`
    const plan = pickWeighted(rand, PLAN_MIX)
    const status = pickWeighted(rand, plan === "free" ? FREE_STATUS_MIX : PAID_STATUS_MIX)
    const [minSeats, maxSeats] = SEAT_RANGE[plan]
    const seats = intBetween(rand, minSeats, maxSeats)

    // A churned account has had time to churn; a trial has not had time to age.
    const ageDays =
      status === "churned"
        ? intBetween(rand, 120, 900)
        : status === "trial"
          ? intBetween(rand, 1, 21)
          : intBetween(rand, 21, 900)
    // Somewhere inside that day, rather than on its edge: whole-day offsets
    // would stamp all 250 accounts with the same time as REFERENCE_DATE.
    const createdAt = dateBetween(rand, daysAgo(ageDays), daysAgo(ageDays - 1))
    const fortnight = daysAgo(14)
    const lastSeenAt =
      status === "churned"
        ? dateBetween(rand, createdAt, daysAgo(45))
        : status === "suspended"
          ? dateBetween(rand, createdAt, daysAgo(7))
          : // A live account was here recently — but never before it existed.
            dateBetween(rand, fortnight > createdAt ? fortnight : createdAt, REFERENCE_DATE)

    return {
      id: rowId("cus", index + 1),
      name,
      avatarUrl: avatarFor(name),
      email: emailFor(name, domainFor(company)),
      company,
      plan,
      status,
      // Trials and churned accounts bill nothing; everyone else pays per seat.
      mrrCents: plan === "free" || status === "trial" || status === "churned" ? 0 : seats * PLAN_PRICE_CENTS[plan],
      seats,
      country: pickWeighted(rand, COUNTRIES),
      createdAt,
      lastSeenAt,
      owner: pick(rand, owners),
    }
  })
}

/** The seeded customer list. The repository below owns the rows a demo mutates. */
export const CUSTOMERS: Customer[] = generate()

export const customers = createRepository(CUSTOMERS, {
  searchKeys: ["name", "email", "company", "country"],
  filterKeys: ["plan", "status", "country", "owner"],
})
lib/sample-data/deals.ts
import {
  REFERENCE_DATE,
  createRepository,
  dateBetween,
  daysAgo,
  daysAhead,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { CUSTOMERS } from "./customers"
import { MEMBERS } from "./members"

/** An opportunity on the pipeline: a company, a stage, a number and an owner. */
export type Deal = {
  id: string
  /** What the deal is for — "Blue Harbor Labs · Team, 40 seats". */
  name: string
  company: string
  stage: "lead" | "qualified" | "proposal" | "negotiation" | "won" | "lost"
  /** Contract value in minor units — cents, not dollars. */
  amountCents: number
  currency: "USD"
  /** A Member id: the rep who owns it. */
  owner: string
  /** A Customer id when the company is already on the books. */
  customerId?: string
  /** How likely it is to close, 0..100. A closed deal is 100 or 0. */
  probability: number
  source: "inbound" | "outbound" | "referral" | "partner" | "event"
  createdAt: Date
  lastActivityAt: Date
  /** Midnight UTC on the day it is forecast to close. */
  expectedCloseAt: Date
}

const COUNT = 64

const STAGE_MIX: readonly (readonly [Deal["stage"], number])[] = [
  ["lead", 0.24],
  ["qualified", 0.2],
  ["proposal", 0.16],
  ["negotiation", 0.12],
  ["won", 0.18],
  ["lost", 0.1],
]

const SOURCE_MIX: readonly (readonly [Deal["source"], number])[] = [
  ["inbound", 0.34],
  ["outbound", 0.24],
  ["referral", 0.18],
  ["partner", 0.14],
  ["event", 0.1],
]

/** The share of deals in a stage that close, as the forecast reads it. */
const PROBABILITY: Record<Deal["stage"], [number, number]> = {
  lead: [5, 20],
  qualified: [20, 45],
  proposal: [40, 65],
  negotiation: [60, 85],
  won: [100, 100],
  lost: [0, 0],
}

const PACKAGES: readonly (readonly [string, number, number])[] = [
  ["Starter", 1_200_00, 4_800_00],
  ["Team", 4_800_00, 24_000_00],
  ["Business", 18_000_00, 60_000_00],
  ["Enterprise", 48_000_00, 240_000_00],
]

/** Midnight UTC on the day `date` falls on — a forecast names a day, not an instant. */
function utcDay(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}

function generate(): Deal[] {
  const rand = seeded("deals")
  const reps = MEMBERS.filter((member) => member.status === "active" && member.role !== "viewer").map(
    (member) => member.id
  )
  // One company per deal, so a pipeline does not show the same logo four times.
  const accounts = shuffle(rand, CUSTOMERS).slice(0, COUNT)

  return Array.from({ length: COUNT }, (_, index) => {
    const stage = pickWeighted(rand, STAGE_MIX)
    const closed = stage === "won" || stage === "lost"
    const account = accounts[index]
    const [tier, low, high] = pick(rand, PACKAGES)
    const seats = intBetween(rand, 10, 400)

    const createdAt = dateBetween(rand, daysAgo(240), daysAgo(closed ? 20 : 2))
    const lastActivityAt = dateBetween(rand, createdAt, REFERENCE_DATE)
    // A closed deal closed behind us; an open one is forecast ahead of today.
    const expectedCloseAt = utcDay(
      closed ? lastActivityAt : daysAhead(intBetween(rand, 3, 120), REFERENCE_DATE)
    )
    const [minimum, maximum] = PROBABILITY[stage]

    return {
      id: rowId("dea", index + 1),
      name: `${account.company} · ${tier}, ${seats} seats`,
      company: account.company,
      stage,
      amountCents: Math.round(intBetween(rand, low, high) / 100_00) * 100_00,
      currency: "USD" as const,
      owner: pick(rand, reps),
      customerId: account.id,
      probability: intBetween(rand, minimum, maximum),
      source: pickWeighted(rand, SOURCE_MIX),
      createdAt,
      lastActivityAt,
      expectedCloseAt,
    }
  })
}

/** The seeded pipeline. The repository below owns the rows a board moves. */
export const DEALS: Deal[] = generate()

export const deals = createRepository(DEALS, {
  searchKeys: ["name", "company"],
  filterKeys: ["stage", "owner", "source"],
})
lib/sample-data/deployments.ts
import { REFERENCE_DATE, chance, createRepository, dateBetween, daysAgo, intBetween, pick, pickWeighted, rowId, seeded } from "./core"
import { MEMBERS } from "./members"

/** One build pushed through the pipeline. */
export type Deployment = {
  id: string
  sha: string
  branch: string
  environment: "production" | "staging" | "preview"
  status: "success" | "failed" | "running" | "cancelled"
  startedAt: Date
  durationSec: number
  /** A display name, not a Member id — some deploys are triggered by bots. */
  author: string
}

const COUNT = 200

const FEATURE_BRANCHES = [
  "feat/checkout-redesign", "fix/webhook-retry", "chore/upgrade-deps", "feat/api-rate-limits",
  "fix/date-timezone-bug", "feat/sso-login", "chore/ci-cache", "fix/memory-leak-worker",
  "feat/notification-center", "fix/pagination-off-by-one", "chore/rotate-secrets", "feat/audit-log-export",
]

// The machines that ship too, named for their job rather than a vendor's bot.
const BOT_AUTHORS = ["ci-bot", "deps-bot", "release-bot"]

const ENVIRONMENT_MIX: readonly (readonly [Deployment["environment"], number])[] = [
  ["production", 0.32],
  ["staging", 0.36],
  ["preview", 0.32],
]

const STATUS_MIX: readonly (readonly [Exclude<Deployment["status"], "running">, number])[] = [
  ["success", 0.84],
  ["failed", 0.1],
  ["cancelled", 0.06],
]

const SHA_CHARS = "0123456789abcdef"

function shortSha(rand: () => number): string {
  return Array.from({ length: 7 }, () => SHA_CHARS[Math.floor(rand() * SHA_CHARS.length)]).join("")
}

function generate(): Deployment[] {
  const rand = seeded("deployments")
  return Array.from({ length: COUNT }, (_, index) => {
    // A deployment can only still be "running" if it started moments ago —
    // one stuck mid-flight for months would read as a bug in the data.
    const isRunning = chance(rand, 0.015)
    const status: Deployment["status"] = isRunning ? "running" : pickWeighted(rand, STATUS_MIX)
    const startedAt = isRunning
      ? dateBetween(rand, daysAgo(20 / 1440), REFERENCE_DATE)
      : dateBetween(rand, daysAgo(180), daysAgo(1 / 48))
    const durationSec =
      status === "running"
        ? intBetween(rand, 5, 240)
        : status === "failed"
          ? intBetween(rand, 10, 180)
          : status === "cancelled"
            ? intBetween(rand, 5, 60)
            : intBetween(rand, 30, 600)

    return {
      id: rowId("dpl", index + 1),
      sha: shortSha(rand),
      branch: chance(rand, 0.5) ? "main" : pick(rand, FEATURE_BRANCHES),
      environment: pickWeighted(rand, ENVIRONMENT_MIX),
      status,
      startedAt,
      durationSec,
      // Free-text author: mostly a teammate's name, occasionally a bot.
      author: chance(rand, 0.85) ? pick(rand, MEMBERS).name : pick(rand, BOT_AUTHORS),
    }
  })
}

/** The seeded deployment history. The repository below owns the rows a demo mutates. */
export const DEPLOYMENTS: Deployment[] = generate()

export const deployments = createRepository(DEPLOYMENTS, {
  searchKeys: ["sha", "branch", "author"],
  filterKeys: ["environment", "status"],
})
lib/sample-data/employees.ts
import {
  createRepository,
  dateBetween,
  daysAgo,
  emailFor,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { avatarFor } from "./avatars"

/** Someone on the payroll: where they sit, what they do, and how the year is going. */
export type Employee = {
  id: string
  name: string
  email: string
  department: "Engineering" | "Design" | "Sales" | "Marketing" | "Finance" | "Support" | "People"
  role: string
  type: "fulltime" | "contract"
  status: "active" | "on_leave" | "remote"
  startedAt: Date
  /** The share of working days they were in, 0..1, over the last quarter. */
  attendanceRate: number
  /** The last review's score, 0..10. */
  kpi: number
  avatarUrl?: string
}

/** The company's own domain — the one the workspace brand is on. */
const COMPANY_DOMAIN = "northwind.example"

const COUNT = 96

const FIRST_NAMES = [
  "Ada", "Bastian", "Céline", "Dario", "Eleni", "Femi", "Gwen", "Hiro",
  "Isla", "Joaquín", "Kasia", "Leon", "Maren", "Nikolaj", "Orla", "Petra",
  "Rasmus", "Saoirse", "Tomás", "Uma", "Vera", "Wim", "Yara", "Zoltán",
]

const LAST_NAMES = [
  "Alvarez", "Bergström", "Chowdhury", "Delgado", "Engel", "Fontana", "Gruber", "Hoffmann",
  "Ibarra", "Joubert", "Kimura", "Lehmann", "Moreno", "Nilsson", "Okafor", "Pavlov",
  "Rinaldi", "Schäfer", "Takahashi", "Uribe", "Visser", "Wagner", "Yilmaz", "Zimmer",
]

const DEPARTMENT_MIX: readonly (readonly [Employee["department"], number])[] = [
  ["Engineering", 0.34],
  ["Design", 0.12],
  ["Sales", 0.16],
  ["Marketing", 0.1],
  ["Finance", 0.08],
  ["Support", 0.12],
  ["People", 0.08],
]

const ROLES: Record<Employee["department"], string[]> = {
  Engineering: ["Software engineer", "Senior engineer", "Staff engineer", "Engineering manager", "Site reliability engineer", "QA engineer"],
  Design: ["Product designer", "Senior designer", "Design lead", "UX researcher", "Brand designer"],
  Sales: ["Account executive", "Sales development rep", "Account manager", "Sales lead", "Solutions engineer"],
  Marketing: ["Content marketer", "Growth marketer", "Product marketer", "Marketing lead"],
  Finance: ["Accountant", "Financial analyst", "Controller", "Payroll specialist"],
  Support: ["Support engineer", "Support specialist", "Support lead", "Technical writer"],
  People: ["People partner", "Recruiter", "Office manager", "People operations lead"],
}

const TYPE_MIX: readonly (readonly [Employee["type"], number])[] = [
  ["fulltime", 0.82],
  ["contract", 0.18],
]

const STATUS_MIX: readonly (readonly [Employee["status"], number])[] = [
  ["active", 0.72],
  ["remote", 0.2],
  ["on_leave", 0.08],
]

function generate(): Employee[] {
  const rand = seeded("employees")
  // Every pairing, shuffled once, so no two colleagues share a name.
  const names = shuffle(
    rand,
    FIRST_NAMES.flatMap((first) => LAST_NAMES.map((last) => `${first} ${last}`))
  ).slice(0, COUNT)

  return names.map((name, index) => {
    const department = pickWeighted(rand, DEPARTMENT_MIX)
    const type = pickWeighted(rand, TYPE_MIX)
    const status = pickWeighted(rand, STATUS_MIX)
    // A contractor is a recent arrival more often than not.
    const startedDaysAgo = type === "contract" ? intBetween(rand, 10, 400) : intBetween(rand, 20, 2400)
    // Someone on leave has been out for part of the quarter.
    const attendance = status === "on_leave" ? intBetween(rand, 620, 840) : intBetween(rand, 840, 995)

    return {
      id: rowId("emp", index + 1),
      name,
      email: emailFor(name, COMPANY_DOMAIN),
      department,
      role: pick(rand, ROLES[department]),
      type,
      status,
      startedAt: dateBetween(rand, daysAgo(startedDaysAgo), daysAgo(startedDaysAgo - 1)),
      attendanceRate: attendance / 1000,
      kpi: intBetween(rand, 52, 98) / 10,
      avatarUrl: avatarFor(name),
    }
  })
}

/** The seeded payroll. The repository below owns the rows a page reads. */
export const EMPLOYEES: Employee[] = generate()

export const employees = createRepository(EMPLOYEES, {
  searchKeys: ["name", "email", "role"],
  filterKeys: ["department", "type", "status"],
})
lib/sample-data/events.ts
import {
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  daysAhead,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { MEMBERS } from "./members"

/** A calendar entry: a meeting, a deadline, a release, or someone's time off. */
export type Event = {
  id: string
  title: string
  startsAt: Date
  endsAt: Date
  allDay?: boolean
  kind: "meeting" | "deadline" | "release" | "ooo"
  /** Member ids. */
  attendees: string[]
  location?: string
}

const COUNT = 80

const KIND_MIX: readonly (readonly [Event["kind"], number])[] = [
  ["meeting", 0.5],
  ["deadline", 0.15],
  ["release", 0.15],
  ["ooo", 0.2],
]

const MEETING_TITLES = [
  "Weekly Sync",
  "Sprint Planning",
  "Design Review",
  "All-Hands",
  "Roadmap Review",
  "Customer Call",
  "Stakeholder Update",
  "Retro",
]

const DEADLINE_TITLES = [
  "Q3 Report Due",
  "Budget Submission Due",
  "Contract Renewal Due",
  "Compliance Review Due",
  "Launch Readiness Deadline",
  "Content Freeze",
  "Code Freeze",
  "Renewal Deadline",
]

const RELEASE_TITLES = [
  "v2.4 Release",
  "Mobile App Release",
  "API v3 Release",
  "Public Beta Release",
  "Feature Flag Rollout",
  "Platform Upgrade Release",
  "Security Patch Release",
]

// Where a meeting happens: a room, or a call — never a named product.
const LOCATIONS = ["Video call", "Phone call", "Conference Room A", "Conference Room B", "HQ — 4th Floor"]

function generate(): Event[] {
  const rand = seeded("events")
  // Nobody invited to a meeting has left, or been deactivated.
  const activeMembers = MEMBERS.filter((row) => row.status === "active")

  return Array.from({ length: COUNT }, (_, index) => {
    const kind = pickWeighted(rand, KIND_MIX)
    // Roughly four weeks either side of "now" — eight weeks in total.
    const startsAt = dateBetween(rand, daysAgo(28), daysAhead(28))

    let title: string
    let attendees: string[]
    let endsAt: Date
    let allDay: boolean | undefined = undefined
    let location: string | undefined = undefined

    if (kind === "meeting" && chance(rand, 0.2)) {
      // A 1:1 lists just the other person — "you" are the calendar owner.
      const withMember = pick(rand, activeMembers)
      title = `1:1 with ${withMember.name.split(" ")[0]}`
      attendees = [withMember.id]
      location = pick(rand, LOCATIONS)
      endsAt = daysAhead((intBetween(rand, 2, 8) * 15) / 1440, startsAt)
    } else if (kind === "meeting") {
      title = pick(rand, MEETING_TITLES)
      attendees = shuffle(rand, activeMembers)
        .slice(0, intBetween(rand, 2, 6))
        .map((row) => row.id)
      location = pick(rand, LOCATIONS)
      // 30-120 minutes, in 15-minute increments.
      endsAt = daysAhead((intBetween(rand, 2, 8) * 15) / 1440, startsAt)
    } else if (kind === "deadline") {
      title = pick(rand, DEADLINE_TITLES)
      attendees = shuffle(rand, activeMembers)
        .slice(0, intBetween(rand, 1, 3))
        .map((row) => row.id)
      allDay = true
      endsAt = startsAt
    } else if (kind === "release") {
      title = pick(rand, RELEASE_TITLES)
      attendees = shuffle(rand, activeMembers)
        .slice(0, intBetween(rand, 1, 3))
        .map((row) => row.id)
      location = pick(rand, LOCATIONS)
      // A one-to-six-hour rollout window.
      endsAt = daysAhead(intBetween(rand, 1, 6) / 24, startsAt)
    } else {
      const member = pick(rand, activeMembers)
      title = `Out of Office — ${member.name.split(" ")[0]}`
      attendees = [member.id]
      allDay = chance(rand, 0.6)
      endsAt = daysAhead(intBetween(rand, 1, 5), startsAt)
    }

    return {
      id: rowId("evt", index + 1),
      title,
      startsAt,
      endsAt,
      // Left unset rather than `false`, same as `Member.avatarUrl`.
      ...(allDay ? { allDay: true as const } : {}),
      kind,
      attendees,
      ...(location ? { location } : {}),
    }
  })
}

/** The seeded calendar. The repository below owns the rows a demo mutates. */
export const EVENTS: Event[] = generate()

export const events = createRepository(EVENTS, {
  searchKeys: ["title"],
  filterKeys: ["kind"],
})
lib/sample-data/files.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
} from "./core"
import { MEMBERS } from "./members"

/** One entry in the workspace's drive: a folder at the root, or a file in one or beside them. */
export type File = {
  id: string
  name: string
  kind: "folder" | "document" | "image" | "video" | "audio" | "archive" | "other"
  /** A folder weighs what is inside it. */
  sizeBytes: number
  /** A Member id: who put it there. */
  ownerId: string
  /** The folder it sits in — one of the folder rows — or undefined at the root. */
  parentId?: string
  modifiedAt: Date
  starred: boolean
}

const COUNT = 140

const FOLDERS = [
  "Design",
  "Documents",
  "Downloads",
  "Music",
  "Pictures",
  "Projects",
  "Contracts",
  "Invoices",
  "Marketing",
  "Recordings",
  "Archive",
  "Shared",
] as const

type FileKind = Exclude<File["kind"], "folder">

// Stems and extensions per kind; a name is one of each, and a stem that comes
// up twice in one folder takes a version suffix rather than colliding.
const NAMES: Record<FileKind, { stems: readonly string[]; extensions: readonly string[] }> = {
  document: {
    stems: ["project-proposal", "q3-budget", "onboarding-guide", "meeting-notes", "brand-guidelines", "roadmap-2026", "hiring-plan", "board-update", "pricing-sheet", "customer-interviews", "release-checklist", "vendor-contract", "handbook", "retro-notes"],
    extensions: ["pdf", "docx", "xlsx", "md", "pptx"],
  },
  image: {
    stems: ["hero-banner", "logo-dark", "logo-light", "icon-set", "team-offsite", "product-shot", "og-image", "wireframe", "screenshot", "chart-export", "app-icon", "cover"],
    extensions: ["png", "jpg", "svg", "webp"],
  },
  video: {
    stems: ["product-demo", "webinar-recording", "onboarding-walkthrough", "all-hands", "launch-teaser", "customer-story", "tutorial-01", "tutorial-02"],
    extensions: ["mp4", "mov"],
  },
  audio: {
    stems: ["podcast-ep-12", "voice-memo", "interview-raw", "standup-recording", "jingle", "call-notes"],
    extensions: ["mp3", "m4a", "wav"],
  },
  archive: {
    stems: ["assets-2026", "backup-08", "design-tokens", "site-export", "logs-week-35", "font-pack", "photos-offsite"],
    extensions: ["zip", "tar.gz"],
  },
  other: {
    stems: ["config", "dataset", "events-export", "schema", "seed-data", "translations", "sitemap"],
    extensions: ["yaml", "csv", "json", "sql", "xml"],
  },
}

const KIND_MIX: readonly (readonly [FileKind, number])[] = [
  ["document", 0.34],
  ["image", 0.24],
  ["video", 0.12],
  ["audio", 0.09],
  ["archive", 0.1],
  ["other", 0.11],
]

// Byte ranges per kind. Videos and archives carry the weight, so the drive's
// total lands in the tens of gigabytes rather than the hundreds of megabytes.
const SIZES: Record<FileKind, [number, number]> = {
  document: [24_000, 24_000_000],
  image: [120_000, 40_000_000],
  video: [200_000_000, 1_600_000_000],
  audio: [2_000_000, 90_000_000],
  archive: [30_000_000, 900_000_000],
  other: [1_000, 30_000_000],
}

// Where a kind tends to live; anything can also land at the root.
const HOMES: Record<FileKind, readonly (typeof FOLDERS)[number][]> = {
  document: ["Documents", "Contracts", "Invoices", "Projects", "Shared"],
  image: ["Design", "Pictures", "Marketing"],
  video: ["Marketing", "Recordings", "Shared"],
  audio: ["Music", "Recordings"],
  archive: ["Archive", "Downloads"],
  other: ["Downloads", "Projects", "Archive"],
}

function generate(): File[] {
  const rand = seeded("files")
  const owners = MEMBERS.filter((member) => member.status === "active").map((member) => member.id)

  const folders: File[] = FOLDERS.map((name, index) => ({
    id: rowId("fil", index + 1),
    name,
    kind: "folder",
    sizeBytes: 0,
    ownerId: pick(rand, owners),
    parentId: undefined,
    modifiedAt: daysAgo(400),
    starred: chance(rand, 0.2),
  }))
  const folderByName = new Map(folders.map((folder) => [folder.name, folder]))

  // Names taken so far per folder, so a second "hero-banner.png" in Design
  // becomes "hero-banner-v2.png" rather than a twin.
  const taken = new Map<string, Set<string>>()
  const uniqueName = (parent: string, stem: string, extension: string): string => {
    const names = taken.get(parent) ?? new Set<string>()
    taken.set(parent, names)
    let candidate = `${stem}.${extension}`
    for (let version = 2; names.has(candidate); version++) candidate = `${stem}-v${version}.${extension}`
    names.add(candidate)
    return candidate
  }

  const rows: File[] = Array.from({ length: COUNT - FOLDERS.length }, (_, index) => {
    const kind = pickWeighted(rand, KIND_MIX)
    const vocabulary = NAMES[kind]
    const home = chance(rand, 0.12) ? undefined : folderByName.get(pick(rand, HOMES[kind]))
    const [min, max] = SIZES[kind]
    return {
      id: rowId("fil", FOLDERS.length + index + 1),
      name: uniqueName(home?.id ?? "root", pick(rand, vocabulary.stems), pick(rand, vocabulary.extensions)),
      kind,
      sizeBytes: intBetween(rand, min, max),
      ownerId: pick(rand, owners),
      parentId: home?.id,
      modifiedAt: dateBetween(rand, daysAgo(180), REFERENCE_DATE),
      starred: chance(rand, 0.12),
    }
  })

  // A folder weighs what is in it and was touched when its newest file was.
  for (const folder of folders) {
    const inside = rows.filter((row) => row.parentId === folder.id)
    folder.sizeBytes = inside.reduce((sum, row) => sum + row.sizeBytes, 0)
    folder.modifiedAt = inside.reduce(
      (latest, row) => (row.modifiedAt.getTime() > latest.getTime() ? row.modifiedAt : latest),
      folder.modifiedAt
    )
  }

  return [...folders, ...rows]
}

/** The seeded drive, folders first. The repository below owns the rows a browser changes. */
export const FILES: File[] = generate()

export const files = createRepository(FILES, {
  searchKeys: ["name"],
  filterKeys: ["kind", "parentId", "ownerId", "starred"],
})
lib/sample-data/fx-rates.ts
import { REFERENCE_DATE, seeded } from "./core"

/** The currency pairs the store quotes, each written base/quote: EUR/USD is dollars per euro. */
export type FxPair = "EUR/USD" | "GBP/USD" | "USD/JPY" | "USD/CHF" | "EUR/GBP"

/** One day's closing rate. `date` is the UTC day, "2026-09-04". */
export type FxPoint = { date: string; rate: number }

/** The pairs in the order a picker lists them. */
export const FX_PAIRS: FxPair[] = ["EUR/USD", "GBP/USD", "USD/JPY", "USD/CHF", "EUR/GBP"]

/** A year of days, ending on REFERENCE_DATE's. */
const DAYS = 365

/** How far a rate moves in a day: a standard deviation of 0.4%, about what a major pair does. */
const DAILY_SIGMA = 0.004

/**
 * How closely the euro and the pound move against the dollar, day to day.
 * News that moves one moves the other, so EUR/USD and GBP/USD share most of a
 * day's shock — and the pound-per-euro between them moves less than either:
 * at 0.6, EUR/GBP's daily move is about 0.36%, as the real cross's is.
 */
const EUR_GBP_CORRELATION = 0.6

/** Where each dollar pair stood a year before REFERENCE_DATE — realistic levels, not quotes. */
const BASE: Record<Exclude<FxPair, "EUR/GBP">, number> = {
  "EUR/USD": 1.082,
  "GBP/USD": 1.265,
  "USD/JPY": 148.2,
  "USD/CHF": 0.881,
}

/** Four decimal places, two for a yen pair, which is how the pairs are quoted. */
const places = (pair: FxPair) => (pair.includes("JPY") ? 2 : 4)
const round = (value: number, digits: number) => Number(value.toFixed(digits))

/** A standard normal draw from two uniform ones (Box–Muller), on the module's own generator. */
function normal(rand: () => number): number {
  const u = Math.max(rand(), Number.EPSILON)
  const v = rand()
  return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v)
}

/**
 * A year of daily rates for each pair. The four dollar pairs are seeded random
 * walks — each day's rate is the day before's times e^(0.4% × a normal draw),
 * so a rate can never cross zero — with the pound's draw sharing the euro's
 * (EUR_GBP_CORRELATION); EUR/GBP is their cross, EUR/USD over GBP/USD, so the
 * pound-per-euro the chart shows is the one a conversion through dollars
 * arrives at.
 */
function generate(): Record<FxPair, FxPoint[]> {
  const rand = seeded("fx-rates")
  const last = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), REFERENCE_DATE.getUTCDate())
  const dates = Array.from({ length: DAYS }, (_, index) =>
    new Date(last - (DAYS - 1 - index) * 86_400_000).toISOString().slice(0, 10)
  )

  // Each pair's own daily draws — none on the first day, which is its base.
  const pairs = Object.keys(BASE) as (keyof typeof BASE)[]
  const shocks = {} as Record<keyof typeof BASE, number[]>
  for (const pair of pairs) shocks[pair] = dates.map((_, index) => (index === 0 ? 0 : normal(rand)))
  const own = Math.sqrt(1 - EUR_GBP_CORRELATION ** 2)
  shocks["GBP/USD"] = shocks["GBP/USD"].map(
    (draw, index) => EUR_GBP_CORRELATION * shocks["EUR/USD"][index] + own * draw
  )

  // Unrounded walks, so rounding a day never feeds into the next one.
  const walks = {} as Record<keyof typeof BASE, number[]>
  for (const pair of pairs) {
    let rate = BASE[pair]
    walks[pair] = shocks[pair].map((shock) => (rate *= Math.exp(DAILY_SIGMA * shock)))
  }

  const series = (pair: FxPair, values: number[]): FxPoint[] =>
    dates.map((date, index) => ({ date, rate: round(values[index], places(pair)) }))

  return {
    "EUR/USD": series("EUR/USD", walks["EUR/USD"]),
    "GBP/USD": series("GBP/USD", walks["GBP/USD"]),
    "USD/JPY": series("USD/JPY", walks["USD/JPY"]),
    "USD/CHF": series("USD/CHF", walks["USD/CHF"]),
    "EUR/GBP": series(
      "EUR/GBP",
      walks["EUR/USD"].map((eur, index) => eur / walks["GBP/USD"][index])
    ),
  }
}

/**
 * A year of daily rates per pair. Not a repository: a rate series is read
 * whole, by pair, for a chart or a conversion, and never written — so `db`
 * holds it as the record it is.
 */
export const FX_RATES: Record<FxPair, FxPoint[]> = generate()

export const fxRates = FX_RATES
lib/sample-data/generate.ts
/**
 * The vocabulary every entity module is written in: the reference instant, id
 * and string shaping, and the four ways to spend a seeded random number.
 *
 * Nothing here reads a clock or calls `Math.random` — every function that
 * varies takes the generator as its first argument, so the caller's
 * `seeded("<entity>")` decides the sequence. `core.ts` re-exports all of it, so
 * an entity module writes one import: `from "./core"`.
 */

/** The one instant every generator and every block calls "now". */
export const REFERENCE_DATE = new Date("2026-09-04T15:40:00.000Z")

/** `rowId("cus", 1)` → `"cus_0001"`, so ids sort the way rows were generated. */
export function rowId(prefix: string, n: number, width = 4): string {
  return `${prefix}_${String(n).padStart(width, "0")}`
}

/**
 * `"Blue Harbor Labs"` → `"blue-harbor-labs"`. An accented letter folds to its
 * base letter first — `"Tomás"` → `"tomas"`, not `"tom-s"` — by decomposing it
 * (NFD) and dropping the combining marks.
 */
export function slugify(value: string): string {
  return value
    .normalize("NFD")
    .replace(/\p{M}/gu, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
}

/**
 * `"Blue Harbor Labs"` → `"blueharborlabs.example"`: on the reserved `.example`
 * top-level domain, so no address in the sample world is one somebody owns.
 */
export function domainFor(company: string, tld = "example"): string {
  return `${slugify(company).replace(/-/g, "")}.${tld}`
}

/** `"Ada Lovelace"` → `"ada.lovelace@<domain>"`. */
export function emailFor(name: string, domain: string): string {
  return `${slugify(name).replace(/-/g, ".")}@${domain}`
}

/** One item from a non-empty list. */
export function pick<T>(rand: () => number, list: readonly T[]): T {
  return list[Math.floor(rand() * list.length)]
}

/** One item from `[value, weight]` pairs; a zero weight is never picked. */
export function pickWeighted<T>(rand: () => number, entries: readonly (readonly [T, number])[]): T {
  const total = entries.reduce((sum, [, weight]) => sum + weight, 0)
  let ticket = rand() * total
  for (const [value, weight] of entries) {
    ticket -= weight
    if (ticket < 0) return value
  }
  return entries[entries.length - 1][0]
}

/** A whole number in `[min, max]`, both ends included. */
export function intBetween(rand: () => number, min: number, max: number): number {
  return min + Math.floor(rand() * (max - min + 1))
}

/** True with probability `probability`; `chance(rand, 1)` always, `chance(rand, 0)` never. */
export function chance(rand: () => number, probability: number): boolean {
  return rand() < probability
}

const DAY_MS = 86_400_000

/** `days` before `from` — fractions welcome, so `daysAgo(1 / 24)` is an hour ago. */
export function daysAgo(days: number, from: Date = REFERENCE_DATE): Date {
  return new Date(from.getTime() - days * DAY_MS)
}

/** `days` after `from`, for the fields that are future dates by nature. */
export function daysAhead(days: number, from: Date = REFERENCE_DATE): Date {
  return new Date(from.getTime() + days * DAY_MS)
}

/** A minute-aligned date in `[start, end]`; `start` when `end` precedes it. */
export function dateBetween(rand: () => number, start: Date, end: Date): Date {
  const from = start.getTime()
  const to = end.getTime()
  if (to <= from) return new Date(from)
  const at = Math.floor((from + rand() * (to - from)) / 60_000) * 60_000
  return new Date(Math.min(Math.max(at, from), to))
}

/** A deterministic Fisher-Yates shuffle into a new array. */
export function shuffle<T>(rand: () => number, list: readonly T[]): T[] {
  const out = [...list]
  for (let i = out.length - 1; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1))
    ;[out[i], out[j]] = [out[j], out[i]]
  }
  return out
}
lib/sample-data/generations.ts
import { REFERENCE_DATE, chance, createRepository, dateBetween, daysAgo, intBetween, pick, rowId, seeded } from "./core"

/**
 * One picture the image studio made. There is no picture: the studio draws an
 * abstract from `seed` and `palette`, so a row carries the four colours the
 * drawing is painted in rather than a URL to a file that does not exist.
 */
export type Generation = {
  id: string
  prompt: string
  negative?: string
  style: "realistic" | "digital-art" | "watercolor" | "line-art"
  ratio: "1:1" | "4:3" | "16:9" | "9:16"
  quality: "standard" | "high"
  /** The whole number the drawing is derived from; the same seed draws the same picture. */
  seed: number
  /** Four `oklch()` strings from the seed — soft, light, never saturated. */
  palette: string[]
  createdAt: Date
}

const COUNT = 16

const STYLES: readonly Generation["style"][] = ["realistic", "digital-art", "watercolor", "line-art"]
const RATIOS: readonly Generation["ratio"][] = ["1:1", "4:3", "16:9", "9:16"]

// What people actually type into an image box: a subject, a place, a light.
const PROMPTS = [
  "A quiet harbour at dawn with fishing boats and low mist over the water",
  "An isometric illustration of a small data centre with glowing racks",
  "A watercolour map of a mountain village in early autumn",
  "Line drawing of a bicycle leaning against a stone wall",
  "A minimal poster of a lighthouse on a cliff at night",
  "A cosy reading nook with a window seat and rain outside",
  "Abstract flowing shapes in soft pastel colours for a landing page hero",
  "A tram crossing a bridge in a rainy city at blue hour",
  "Botanical study of a fern frond on cream paper",
  "A desert highway at sunset with long shadows and a single car",
  "A friendly robot watering plants in a greenhouse",
  "An overhead view of a tidy desk with a notebook and a cup of coffee",
  "A paper-cut style forest with layered hills and a crescent moon",
  "A futuristic train station with tall arched windows and morning light",
  "A still life of pears and a ceramic jug in morning light",
  "A lone tent under the northern lights on a frozen lake",
]

const NEGATIVES = ["text, watermark, blurry", "people, logos", "harsh shadows", "low contrast, noise"]

/**
 * Four soft colours from a seed: analogous hues a step apart, light enough to
 * sit on a card and never saturated past the point where a tint reads as a
 * status. The image studio's art and its `generate` action both paint from
 * this, so a picture made at runtime looks like one made by the seed.
 */
export function paletteFor(seed: number): string[] {
  const rand = seeded(`generation:${seed}`)
  const base = rand() * 360
  return Array.from({ length: 4 }, (_, index) => {
    const hue = Math.floor((base + index * (22 + rand() * 18)) % 360)
    const lightness = 0.62 + rand() * 0.26
    const chroma = 0.04 + rand() * 0.08
    return `oklch(${lightness.toFixed(2)} ${chroma.toFixed(3)} ${hue})`
  })
}

function generate(): Generation[] {
  const rand = seeded("generations")

  return PROMPTS.slice(0, COUNT).map((prompt, index) => {
    const seed = intBetween(rand, 1_000, 999_999)
    return {
      id: rowId("gen", index + 1),
      prompt,
      negative: chance(rand, 0.4) ? pick(rand, NEGATIVES) : undefined,
      // Every style meets every ratio once across the sixteen, so the gallery
      // shows each chip and each frame shape whichever way the seed falls.
      style: STYLES[index % STYLES.length],
      ratio: RATIOS[Math.floor(index / STYLES.length) % RATIOS.length],
      quality: chance(rand, 0.35) ? "high" : "standard",
      seed,
      palette: paletteFor(seed),
      createdAt: dateBetween(rand, daysAgo(21), REFERENCE_DATE),
    }
  })
}

/** The seeded gallery. The repository below owns the rows the studio adds to and removes from. */
export const GENERATIONS: Generation[] = generate()

export const generations = createRepository(GENERATIONS, {
  searchKeys: ["prompt"],
  filterKeys: ["style", "ratio", "quality"],
})
lib/sample-data/holdings.ts
import { createRepository, intBetween, rowId, seeded } from "./core"

/** One asset in one wallet: how much is held and what it is worth right now. */
export type Holding = {
  id: string
  asset: "Bitcoin" | "Ethereum" | "Solana" | "Avalanche" | "Tether" | "Toncoin"
  /** The ticker a trade names: BTC, ETH, SOL, AVAX, USDT, TON. */
  symbol: string
  amount: number
  /** The asset's price now, in cents; the same in every wallet that holds it. */
  priceCents: number
  /** The last day's move as a ratio: 0.031 is up 3.1%. */
  change24h: number
  wallet: "Main" | "Savings" | "Trading"
}

/**
 * What each asset is called, what it trades as, where its price sits and how
 * much of it a wallet tends to hold. The bands are the plausible ranges, and
 * the generator picks a point in each; the stablecoin is pinned at a dollar
 * and never moves.
 */
const ASSETS: readonly {
  asset: Holding["asset"]
  symbol: string
  priceCents: [number, number]
  amount: [number, number]
  /** Decimal places the amount is kept to. */
  precision: number
}[] = [
  { asset: "Bitcoin", symbol: "BTC", priceCents: [6_150_000, 6_850_000], amount: [0.2, 2.5], precision: 4 },
  { asset: "Ethereum", symbol: "ETH", priceCents: [240_000, 320_000], amount: [1, 20], precision: 3 },
  { asset: "Solana", symbol: "SOL", priceCents: [14_000, 19_000], amount: [20, 300], precision: 2 },
  { asset: "Avalanche", symbol: "AVAX", priceCents: [2_400, 3_600], amount: [50, 800], precision: 2 },
  { asset: "Tether", symbol: "USDT", priceCents: [100, 100], amount: [1_000, 25_000], precision: 2 },
  { asset: "Toncoin", symbol: "TON", priceCents: [500, 700], amount: [500, 5_000], precision: 1 },
]

// Twelve of the eighteen pairings: the main wallet holds a spread, savings
// keeps the two majors and the stablecoin, and the trading wallet turns over
// the four most traded.
const WALLETS: readonly (readonly [Holding["wallet"], readonly string[]])[] = [
  ["Main", ["BTC", "ETH", "SOL", "USDT", "TON"]],
  ["Savings", ["BTC", "ETH", "USDT"]],
  ["Trading", ["BTC", "ETH", "SOL", "AVAX"]],
]

function generate(): Holding[] {
  const rand = seeded("holdings")

  // One price and one day's move per asset, drawn first so every wallet that
  // holds the asset reads the same number.
  const quotes = new Map(
    ASSETS.map((entry) => [
      entry.symbol,
      {
        priceCents: intBetween(rand, entry.priceCents[0], entry.priceCents[1]),
        change24h: entry.symbol === "USDT" ? 0 : intBetween(rand, -60, 80) / 1000,
      },
    ])
  )

  const rows: Holding[] = []
  for (const [wallet, symbols] of WALLETS) {
    for (const symbol of symbols) {
      const entry = ASSETS.find((candidate) => candidate.symbol === symbol)!
      const quote = quotes.get(symbol)!
      const scale = 10 ** entry.precision
      const amount = Math.round((entry.amount[0] + rand() * (entry.amount[1] - entry.amount[0])) * scale) / scale
      rows.push({
        id: rowId("hld", rows.length + 1),
        asset: entry.asset,
        symbol,
        amount,
        priceCents: quote.priceCents,
        change24h: quote.change24h,
        wallet,
      })
    }
  }
  return rows
}

/** The seeded book of holdings. The repository below owns the rows a trade moves. */
export const HOLDINGS: Holding[] = generate()

export const holdings = createRepository(HOLDINGS, {
  searchKeys: ["asset", "symbol"],
  filterKeys: ["wallet", "symbol"],
})
lib/sample-data/incidents.ts
import { REFERENCE_DATE, chance, createRepository, dateBetween, daysAgo, daysAhead, intBetween, pick, pickWeighted, rowId, seeded, shuffle } from "./core"
import { MEMBERS } from "./members"
import { SERVICES } from "./services"

/** One entry in an incident's status-page timeline. */
type Update = { at: Date; status: Incident["status"]; message: string }

/** A status-page incident affecting one or more services. */
export type Incident = {
  id: string
  title: string
  severity: "sev1" | "sev2" | "sev3"
  status: "investigating" | "identified" | "monitoring" | "resolved"
  /** Service ids this incident affects. */
  serviceIds: string[]
  startedAt: Date
  resolvedAt?: Date
  updates: Update[]
  /** A Member id: the engineer running point on the incident. */
  commander: string
}

const COUNT = 40

const SEVERITY_MIX: readonly (readonly [Incident["severity"], number])[] = [
  ["sev3", 0.55],
  ["sev2", 0.32],
  ["sev1", 0.13],
]

const STAGE_ORDER: Incident["status"][] = ["investigating", "identified", "monitoring", "resolved"]

const TITLE_TEMPLATES: ((service: string) => string)[] = [
  (service) => `Elevated error rates on ${service}`,
  (service) => `${service} latency spike`,
  (service) => `${service} degraded performance`,
  (service) => `Intermittent timeouts calling ${service}`,
  (service) => `${service} returning 5xx responses`,
  (service) => `Connection failures affecting ${service}`,
  (service) => `${service} outage`,
]

const UPDATE_MESSAGES: Record<Incident["status"], string[]> = {
  investigating: [
    "We are investigating reports of an issue.",
    "Engineers are looking into elevated error rates.",
    "We are aware of the issue and beginning triage.",
  ],
  identified: [
    "We have identified the root cause and are working on a fix.",
    "The cause has been found; a fix is being rolled out.",
  ],
  monitoring: [
    "A fix has been deployed. We are monitoring the results.",
    "The fix is live and metrics are recovering. Monitoring closely.",
  ],
  resolved: ["This incident has been resolved.", "The issue is fully resolved. Thank you for your patience."],
}

/** One update per reached stage, starting at `startedAt`, each no earlier than the last. */
function buildUpdates(rand: () => number, startedAt: Date, finalStatus: Incident["status"], resolvedAt?: Date): Update[] {
  const stages = STAGE_ORDER.slice(0, STAGE_ORDER.indexOf(finalStatus) + 1)
  const endTime = resolvedAt ?? REFERENCE_DATE
  let previous = startedAt
  return stages.map((status, index) => {
    const at = index === 0 ? startedAt : dateBetween(rand, previous, endTime)
    previous = at
    return { at, status, message: pick(rand, UPDATE_MESSAGES[status]) }
  })
}

function generate(): Incident[] {
  const rand = seeded("incidents")
  const commanders = MEMBERS.filter((member) => member.status === "active" && member.role !== "viewer").map(
    (member) => member.id
  )

  return Array.from({ length: COUNT }, (_, index) => {
    const ageDays = intBetween(rand, 1, 180)
    const startedAt = dateBetween(rand, daysAgo(ageDays), daysAgo(ageDays - 1))
    // Only a recently-started incident has a chance of still being open —
    // one from six months ago has long since been resolved one way or another.
    const isRecent = ageDays <= 14
    const isResolved = isRecent ? chance(rand, 0.5) : true
    const status: Incident["status"] = isResolved
      ? "resolved"
      : pickWeighted(rand, [
          ["investigating", 0.4],
          ["identified", 0.35],
          ["monitoring", 0.25],
        ])
    const resolutionDeadline = new Date(Math.min(daysAhead(2, startedAt).getTime(), REFERENCE_DATE.getTime()))
    const resolvedAt = isResolved ? dateBetween(rand, startedAt, resolutionDeadline) : undefined

    const affectedServices = chance(rand, 0.8) ? [pick(rand, SERVICES)] : shuffle(rand, SERVICES).slice(0, 2)

    return {
      id: rowId("inc", index + 1),
      title: pick(rand, TITLE_TEMPLATES)(affectedServices[0].name),
      severity: pickWeighted(rand, SEVERITY_MIX),
      status,
      serviceIds: affectedServices.map((service) => service.id),
      startedAt,
      resolvedAt,
      updates: buildUpdates(rand, startedAt, status, resolvedAt),
      commander: pick(rand, commanders),
    }
  })
}

/** The seeded incident history. The repository below owns the rows a demo mutates. */
export const INCIDENTS: Incident[] = generate()

export const incidents = createRepository(INCIDENTS, {
  searchKeys: ["title"],
  filterKeys: ["severity", "status"],
})
lib/sample-data/input.ts
/**
 * What a server action may assume about its arguments: nothing.
 *
 * An action's id is in the client bundle, so anyone can call it with whatever
 * a request can carry — a string where an object belongs, `"constructor"`
 * where a stage belongs, `1e308` units, `"2026-02-31"`, five thousand file
 * names — and the store it writes is shared by everyone the server serves. The
 * type on a parameter is a promise the browser made, not one the action can
 * rely on. These read an argument as what the action needs, or say it is not,
 * so the action refuses with a `Result` instead of throwing, and never stores
 * a value no page was drawn for:
 *
 * - a vocabulary is checked by membership (`oneOf`), and a lookup keyed by the
 *   caller reads only a table's own keys (`ownKey`) — `PROBABILITY["constructor"]`
 *   is `Object`, not undefined;
 * - a number is finite, whole where the store's are whole, and bounded
 *   (`isWhole`, `isAmount`);
 * - a string is a string, trimmed and capped (`text`);
 * - a date is a real one, in a sane span (`calendarDay`, `instant`).
 */

import { REFERENCE_DATE } from "./generate"

/** A refusal: what the caller sent is not something this action takes. */
export type Refusal = { ok: false; error: { code: string; message: string; field?: string } }

/** The refusal for an argument of the wrong shape or out of range, naming the field when there is one. */
export function invalidInput(message: string, field?: string): Refusal {
  return { ok: false, error: field ? { code: "invalid_input", message, field } : { code: "invalid_input", message } }
}

/** A plain object — not null, an array, a Map, a Date or a FormData. */
export function isRecord(value: unknown): value is Record<string, unknown> {
  if (typeof value !== "object" || value === null || Array.isArray(value)) return false
  const prototype = Object.getPrototypeOf(value)
  return prototype === Object.prototype || prototype === null
}

/**
 * The fields of an object argument, or none: `fields(input)` is `input` when it
 * is a plain object and `{}` otherwise, so reading a field of a string, a
 * `null` or an array is `undefined` rather than a throw. Each field is still
 * whatever the caller sent — read it through the checks below.
 */
export function fields<T extends object>(value: T | null | undefined): Partial<T> {
  return isRecord(value) ? (value as Partial<T>) : {}
}

/** A FormData — what a form action is handed, and nothing else it can be sent. */
export function isForm(value: unknown): value is FormData {
  return typeof FormData !== "undefined" && value instanceof FormData
}

/** A form field as trimmed text: "" when the form has no such field, or holds a file there. */
export function formText(form: FormData, key: string): string {
  const value = form.get(key)
  return typeof value === "string" ? value.trim() : ""
}

/** `value` when it is a string, else "". Trim and cap it with `text` when it is going into the store. */
export function asString(value: unknown): string {
  return typeof value === "string" ? value : ""
}

/**
 * `value` trimmed, when it is a string no longer than `max` once trimmed;
 * `undefined` for anything else — a number, an object, a 900 KB string.
 */
export function text(value: unknown, max: number): string | undefined {
  if (typeof value !== "string") return undefined
  const trimmed = value.trim()
  return trimmed.length <= max ? trimmed : undefined
}

/** Whether `value` is one of `allowed`: a member of the list, never a key some object inherits. */
export function oneOf<T extends string | number>(value: unknown, allowed: readonly T[]): value is T {
  return (typeof value === "string" || typeof value === "number") && allowed.includes(value as T)
}

/** Whether `key` is one of `table`'s own keys — so `table[key]` is its entry, never `Object.prototype`'s. */
export function ownKey<K extends string>(table: Record<K, unknown>, key: unknown): key is K {
  return typeof key === "string" && Object.hasOwn(table, key)
}

/** A whole number from `min` to `max`, both included, and within what a double counts exactly. */
export function isWhole(value: unknown, min: number, max: number): value is number {
  return Number.isSafeInteger(value) && (value as number) >= min && (value as number) <= max
}

/** A finite number from `min` to `max`, both included. */
export function isAmount(value: unknown, min: number, max: number): value is number {
  return typeof value === "number" && Number.isFinite(value) && value >= min && value <= max
}

/**
 * A server-paged table's query as the server can answer it: every field of
 * `defaults`, taken from `value` when it is the same kind and in range, and
 * the default where it is not — so a crafted query reads as the table's own
 * view instead of throwing on `query.sort.id`. A number is a whole one from 1
 * (a page size at most `maxPageSize`); text runs to 200 characters; a filter
 * is a list of at most 50 short strings; a field named in `words` is one of
 * its words; and a sort — defaulted or not — is `{ id, desc }` with an id
 * the table may know. The table's own lookups still read only their own keys.
 */
export function tableQuery<Q extends object>(
  value: unknown,
  defaults: Q,
  rules: { words?: { [K in keyof Q]?: readonly string[] }; maxPageSize?: number } = {}
): Q {
  const sent = isRecord(value) ? value : {}
  const query: Record<string, unknown> = { ...(defaults as Record<string, unknown>) }
  const words = (rules.words ?? {}) as Record<string, readonly string[] | undefined>
  for (const key of new Set([...Object.keys(defaults), "sort"])) {
    const given = sent[key]
    const fallback = (defaults as Record<string, unknown>)[key]
    if (given === undefined) continue
    if (key === "sort") {
      if (isRecord(given) && typeof given.id === "string" && given.id.length <= 64) query.sort = { id: given.id, desc: given.desc === true }
    } else if (words[key]) {
      if (oneOf(given, words[key])) query[key] = given
    } else if (typeof fallback === "number") {
      if (isWhole(given, 1, key === "pageSize" ? (rules.maxPageSize ?? 100) : 1_000_000)) query[key] = given
    } else if (typeof fallback === "string") {
      if (typeof given === "string" && given.length <= 200) query[key] = given
    } else if (typeof fallback === "boolean") {
      if (typeof given === "boolean") query[key] = given
    } else if (Array.isArray(fallback)) {
      if (Array.isArray(given) && given.length <= 50 && given.every((entry) => typeof entry === "string" && entry.length <= 100)) query[key] = given
    }
  }
  return query as Q
}

/** The years a date in the store can fall in: none before the product, none past the century. */
const FIRST_YEAR = 1990
const LAST_YEAR = 2100

/**
 * A calendar day written `YYYY-MM-DD`, as the UTC midnight it names — when it
 * exists. `"2026-02-31"` does not (a `Date` would quietly roll it to March 3),
 * nor does a day outside `from`–`to` (by default 1990 to 2100), so a
 * `"9999-12-31"` or a `"+275760-09-12"` is refused rather than stored.
 */
export function calendarDay(value: unknown, span: { from?: Date; to?: Date } = {}): Date | undefined {
  if (typeof value !== "string") return undefined
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim())
  if (!match) return undefined
  const [year, month, day] = match.slice(1).map(Number)
  const date = new Date(Date.UTC(year, month - 1, day))
  if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return undefined
  return within(date, span) ? date : undefined
}

/**
 * An instant — a `Date`, or a string a `Date` reads — when it is a real one
 * inside `from`–`to` (by default 1990 to 2100). An Invalid Date, a year 9999
 * or a string that is not a date at all is `undefined`.
 */
export function instant(value: unknown, span: { from?: Date; to?: Date } = {}): Date | undefined {
  if (!(value instanceof Date) && typeof value !== "string") return undefined
  if (typeof value === "string" && value.length > 40) return undefined
  const date = new Date(value instanceof Date ? value.getTime() : value)
  return within(date, span) ? date : undefined
}

function within(date: Date, { from, to }: { from?: Date; to?: Date }): boolean {
  const time = date.getTime()
  if (Number.isNaN(time)) return false
  if (date.getUTCFullYear() < FIRST_YEAR || date.getUTCFullYear() > LAST_YEAR) return false
  return (!from || time >= from.getTime()) && (!to || time <= to.getTime())
}

/** `REFERENCE_DATE`'s year: the "this year" a card's expiry or a plan's renewal is measured from. */
export const REFERENCE_YEAR = REFERENCE_DATE.getUTCFullYear()
lib/sample-data/integrations.ts
import { createRepository, dateBetween, daysAgo, pickWeighted, rowId, seeded, shuffle } from "./core"

/** A service the workspace can connect — fictional, like every company in the kit. */
export type Integration = {
  id: string
  name: string
  provider: string
  category: string
  status: "connected" | "available" | "error"
  connectedAt?: Date
  description: string
}

const COUNT = 24

// The catalogue is fictional, like every company in the kit: services that
// read as real, one per line, each drawn with its category's icon wherever it
// is shown. It is the marketing sections' world too: a vendor they show
// (Duesbrook billing, Closemere CRM, Huddlemere team chat, Gatemere identity,
// Clickbrook events, Convoline live chat, Replyhouse help desk, Letterbrook
// campaigns, Silomere warehouse) is the same service here. Every name was
// checked before it shipped, by a web search and by fetching its own .com,
// .dev, .app, .ai, .co and .io; a coinage that turned out to be somebody's
// product is on the guard's denylist. The order is the seeded
// draw's, so keep it: a row's status and date come from its place in this list.
const INTEGRATION_DEFS: { name: string; provider: string; category: string; description: string }[] = [
  { name: "Huddlemere", provider: "huddlemere", category: "communication", description: "Send alerts and notifications to team channels." },
  { name: "Forkstead", provider: "forkstead", category: "developer-tools", description: "Sync repositories and trigger deployments on push." },
  { name: "Duesbrook", provider: "duesbrook", category: "payments", description: "Sync billing, invoices, and subscription data." },
  { name: "Visitmere", provider: "visitmere", category: "analytics", description: "Track usage and conversion events." },
  { name: "Hookmere", provider: "hookmere", category: "automation", description: "Connect to thousands of other apps without code." },
  { name: "Closemere", provider: "closemere", category: "crm", description: "Sync customer and deal data two ways." },
  { name: "Prospectmere", provider: "prospectmere", category: "crm", description: "Sync contacts, deals, and marketing activity." },
  { name: "Metricsmith", provider: "metricsmith", category: "observability", description: "Ship metrics, logs, and traces for monitoring." },
  { name: "Rotamere", provider: "rotamere", category: "observability", description: "Route alerts to on-call engineers." },
  { name: "Clickbrook", provider: "clickbrook", category: "analytics", description: "Collect and route event data to your tools." },
  { name: "Convoline", provider: "convoline", category: "support", description: "Sync conversations and customer context." },
  { name: "Replyhouse", provider: "replyhouse", category: "support", description: "Sync support tickets and customer history." },
  { name: "Pagemere", provider: "pagemere", category: "productivity", description: "Sync docs and databases with your workspace." },
  { name: "Trackmere", provider: "trackmere", category: "developer-tools", description: "Link issues and track engineering work." },
  { name: "Sprintmill", provider: "sprintmill", category: "developer-tools", description: "Sync issues and sprints with engineering." },
  { name: "Framemere", provider: "framemere", category: "design", description: "Embed and link design files in context." },
  { name: "Vaultbrook", provider: "vaultbrook", category: "storage", description: "Store and retrieve exported files and backups." },
  { name: "Sharestead", provider: "sharestead", category: "storage", description: "Export reports directly to shared drives." },
  { name: "Gatemere", provider: "gatemere", category: "security", description: "Single sign-on and provisioning for the workspace." },
  { name: "Signbrook", provider: "signbrook", category: "security", description: "Authenticate users with your own login flow." },
  { name: "Letterbrook", provider: "letterbrook", category: "marketing", description: "Sync audiences and campaign activity." },
  { name: "Pingstead", provider: "pingstead", category: "communication", description: "Send SMS and voice notifications." },
  { name: "Querystead", provider: "querystead", category: "analytics", description: "Explore product data in your BI tool." },
  { name: "Silomere", provider: "silomere", category: "data", description: "Warehouse raw events for custom analysis." },
]

const STATUS_MIX: readonly (readonly [Integration["status"], number])[] = [
  ["connected", 0.4],
  ["available", 0.5],
  ["error", 0.1],
]

function generate(): Integration[] {
  const rand = seeded("integrations")
  return shuffle(rand, INTEGRATION_DEFS)
    .slice(0, COUNT)
    .map((def, index) => {
      const status = pickWeighted(rand, STATUS_MIX)
      return {
        id: rowId("itg", index + 1),
        name: def.name,
        provider: def.provider,
        category: def.category,
        status,
        // "available" means never connected; the other two both imply it was.
        connectedAt: status === "available" ? undefined : dateBetween(rand, daysAgo(400), daysAgo(1)),
        description: def.description,
      }
    })
}

/** The seeded integration catalog. The repository below owns the rows a demo mutates. */
export const INTEGRATIONS: Integration[] = generate()

export const integrations = createRepository(INTEGRATIONS, {
  searchKeys: ["name", "provider", "description"],
  filterKeys: ["category", "status"],
})
lib/sample-data/invitations.ts
import { createRepository, dateBetween, daysAgo, daysAhead, emailFor, intBetween, pick, pickWeighted, rowId, seeded, shuffle } from "./core"
import { MEMBERS } from "./members"

/** A pending or resolved invite to join the workspace. */
export type Invitation = {
  id: string
  email: string
  /** The access level granted once accepted — never "owner"; there is only one. */
  role: "admin" | "member" | "viewer"
  /** A Member id: whoever sent the invitation. */
  invitedBy: string
  sentAt: Date
  /** Always a future date — daysAhead from REFERENCE_DATE, independent of status. */
  expiresAt: Date
  status: "pending" | "accepted" | "expired" | "revoked"
}

const COUNT = 15

const FIRST_NAMES = [
  "Talia", "Soren", "Bianca", "Kian", "Yara", "Milo", "Petra", "Idris",
  "Wren", "Anton", "Selin", "Cyrus", "Nadia", "Emrys", "Ottilie",
]

const LAST_NAMES = [
  "Marchetti", "Solheim", "Okafor", "Vasquez", "Lindberg", "Aoki", "Reyes", "Haugen",
  "Kobayashi", "Fontana", "Riis", "Beaumont", "Zeleny", "Iversen", "Cabral",
]

// Reserved example domains (RFC 2606), so no generated address can be a real person's.
const EMAIL_DOMAINS = ["example.com", "example.net", "example.org", "mail.example"]

const ROLE_MIX: readonly (readonly [Invitation["role"], number])[] = [
  ["member", 0.6],
  ["viewer", 0.25],
  ["admin", 0.15],
]

const STATUS_MIX: readonly (readonly [Invitation["status"], number])[] = [
  ["accepted", 0.45],
  ["pending", 0.25],
  ["expired", 0.2],
  ["revoked", 0.1],
]

function generate(): Invitation[] {
  const rand = seeded("invitations")
  // Every first/last pairing, shuffled once and sliced, so no two invitees share a name.
  const names = shuffle(rand, FIRST_NAMES.flatMap((first) => LAST_NAMES.map((last) => `${first} ${last}`))).slice(
    0,
    COUNT
  )
  const senders = MEMBERS.filter(
    (member) => member.status === "active" && (member.role === "owner" || member.role === "admin")
  ).map((member) => member.id)

  return names.map((name, index) => {
    const status = pickWeighted(rand, STATUS_MIX)
    // A still-pending invite was sent recently; anything else has had time to
    // be accepted, expire, or get revoked.
    const sentDaysAgo = status === "pending" ? intBetween(rand, 0, 10) : intBetween(rand, 5, 75)
    const sentAt = dateBetween(rand, daysAgo(sentDaysAgo), daysAgo(Math.max(sentDaysAgo - 1, 0)))

    return {
      id: rowId("ivt", index + 1),
      email: emailFor(name, pick(rand, EMAIL_DOMAINS)),
      role: pickWeighted(rand, ROLE_MIX),
      invitedBy: pick(rand, senders),
      sentAt,
      // An invitation's expiry is a future date by nature (core.ts's doc
      // comment calls this out explicitly): every row gets one, even an
      // already-expired or revoked invite, so the field always reads as
      // "when this link stops working," independent of `status`.
      expiresAt: daysAhead(intBetween(rand, 3, 21)),
      status,
    }
  })
}

/** The seeded invitation list. The repository below owns the rows a demo mutates. */
export const INVITATIONS: Invitation[] = generate()

export const invitations = createRepository(INVITATIONS, {
  searchKeys: ["email"],
  filterKeys: ["role", "status"],
})
lib/sample-data/invoices.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  daysAhead,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
} from "./core"
import { CUSTOMERS } from "./customers"

/** One charge on an invoice. */
export type InvoiceLineItem = { description: string; amountCents: number }

/** A billing document sent to a customer for their subscription. */
export type Invoice = {
  id: string
  number: string
  customerId: string
  status: "draft" | "open" | "paid" | "overdue" | "void"
  /** The sum of `lineItems`, in minor units. */
  amountCents: number
  issuedAt: Date
  /** Net terms put this after `issuedAt` — often still ahead of REFERENCE_DATE, by nature. */
  dueAt: Date
  paidAt?: Date
  lineItems: InvoiceLineItem[]
}

const COUNT = 300

// Status is derived from whether the due date has already passed (below),
// rather than rolled independently, so "overdue" only ever describes a real
// invoice whose due date is behind it.
const PAST_DUE_STATUS_MIX: readonly (readonly [Invoice["status"], number])[] = [
  ["paid", 0.72],
  ["overdue", 0.18],
  ["void", 0.1],
]

const UPCOMING_STATUS_MIX: readonly (readonly [Invoice["status"], number])[] = [
  ["open", 0.55],
  ["paid", 0.3],
  ["draft", 0.15],
]

/** "team" -> "Team". */
function capitalize(word: string): string {
  return word.length ? word[0].toUpperCase() + word.slice(1) : word
}

function generate(): Invoice[] {
  const rand = seeded("invoices")
  // A free, trial, or churned account is never billed, so it is never invoiced.
  const eligible = CUSTOMERS.filter((row) => row.mrrCents > 0)

  return Array.from({ length: COUNT }, (_, index) => {
    const customer = pick(rand, eligible)
    const seatWord = customer.seats === 1 ? "seat" : "seats"
    const lineItems: InvoiceLineItem[] = [
      { description: `${capitalize(customer.plan)} plan (${customer.seats} ${seatWord})`, amountCents: customer.mrrCents },
    ]
    if (chance(rand, 0.22)) lineItems.push({ description: "Additional usage", amountCents: intBetween(rand, 500, 8_000) })
    const amountCents = lineItems.reduce((sum, item) => sum + item.amountCents, 0)

    const issuedAt = dateBetween(rand, daysAgo(365), REFERENCE_DATE)
    const dueAt = daysAhead(intBetween(rand, 14, 30), issuedAt)
    const isPastDue = dueAt.getTime() < REFERENCE_DATE.getTime()
    const status = pickWeighted(rand, isPastDue ? PAST_DUE_STATUS_MIX : UPCOMING_STATUS_MIX)
    const paidAt = status === "paid" ? dateBetween(rand, issuedAt, isPastDue ? dueAt : REFERENCE_DATE) : undefined

    return {
      id: rowId("inv", index + 1),
      number: `INV-${100_000 + index + 1}`,
      customerId: customer.id,
      status,
      amountCents,
      issuedAt,
      dueAt,
      ...(paidAt ? { paidAt } : {}),
      lineItems,
    }
  })
}

/** The seeded billing history. The repository below owns the rows a demo mutates. */
export const INVOICES: Invoice[] = generate()

export const invoices = createRepository(INVOICES, {
  searchKeys: ["number", "customerId"],
  filterKeys: ["status", "customerId"],
})
lib/sample-data/leads.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  daysAhead,
  domainFor,
  emailFor,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { avatarFor } from "./avatars"
import { MEMBERS } from "./members"

/** A person who showed interest: where they came from, how far they have got, what they might be worth. */
export type Lead = {
  id: string
  name: string
  /** A face for the lead — the kit's own avatar route for the name — */
  avatarUrl?: string
  email: string
  company: string
  source: "website" | "referral" | "ads" | "social" | "event"
  status: "new" | "contacted" | "qualified" | "won" | "lost"
  /** What the deal would be worth, in minor units — cents, not dollars. */
  valueCents: number
  /** A Member id: the rep who is working it. */
  ownerId: string
  /**
   * When the rep next has to do something about it. Unset on a lead that was
   * never scheduled, `null` once the follow-up is done — the store's `update`
   * keeps `undefined` out of a patch, so an erase is written as null.
   */
  nextActionAt?: Date | null
  createdAt: Date
}

const COUNT = 120

const FIRST_NAMES = [
  "Amara", "Benedikt", "Chiara", "Daan", "Elif", "Fionn", "Gabriela", "Henrik",
  "Ingrid", "Jaro", "Kenji", "Leila", "Matteo", "Nell", "Oskar", "Paloma",
  "Quentin", "Rosa", "Samir", "Teodora", "Ulla", "Viktor", "Wren", "Zara",
]

const LAST_NAMES = [
  "Abernathy", "Bakker", "Castellanos", "Dahl", "Eze", "Fischer", "Grünewald", "Haddad",
  "Iversen", "Jiménez", "Kowal", "Lindgren", "Marais", "Nkemelu", "Olsen", "Pedersen",
  "Quist", "Rossi", "Sato", "Tremblay", "Ueda", "Varga", "Whitlock", "Ziegler",
]

const COMPANY_HEADS = [
  "Ashford", "Brightwater", "Caldera", "Driftwood", "Evergreen", "Fernhill", "Goldcrest",
  "Hollowell", "Ironbridge", "Juniper", "Kingfisher", "Larkspur", "Marlow", "Nightjar",
  "Oakridge", "Pinecrest", "Redwood", "Saltmarsh", "Thornfield", "Umber", "Vale", "Westbrook",
]

const COMPANY_TAILS = [
  "Consulting", "Freight", "Interactive", "Foods", "Ventures", "Clinics", "Outfitters",
  "Learning", "Capital", "Works", "Supply", "Hospitality", "Creative", "Mobility",
]

const SOURCE_MIX: readonly (readonly [Lead["source"], number])[] = [
  ["website", 0.34],
  ["referral", 0.2],
  ["ads", 0.18],
  ["social", 0.18],
  ["event", 0.1],
]

const STATUS_MIX: readonly (readonly [Lead["status"], number])[] = [
  ["new", 0.28],
  ["contacted", 0.26],
  ["qualified", 0.2],
  ["won", 0.16],
  ["lost", 0.1],
]

/** How much of the funnel is still waiting on the rep, by stage. */
const NEEDS_ACTION: Record<Lead["status"], number> = {
  new: 0.85,
  contacted: 0.7,
  qualified: 0.6,
  won: 0,
  lost: 0,
}

/** Minute-aligned, on the quarter hour between nine and six — the hours a rep books calls in. */
function officeHours(rand: () => number, day: Date): Date {
  const midnight = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate())
  return new Date(midnight + (9 * 60 + 15 * intBetween(rand, 0, 36)) * 60_000)
}

function generate(): Lead[] {
  const rand = seeded("leads")
  const reps = MEMBERS.filter((member) => member.status === "active" && member.role !== "viewer").map(
    (member) => member.id
  )
  // Every pairing, shuffled once, so no two leads share a name.
  const names = shuffle(
    rand,
    FIRST_NAMES.flatMap((first) => LAST_NAMES.map((last) => `${first} ${last}`))
  ).slice(0, COUNT)
  const companies = shuffle(
    rand,
    COMPANY_HEADS.flatMap((head) => COMPANY_TAILS.map((tail) => `${head} ${tail}`))
  ).slice(0, COUNT)

  return names.map((name, index) => {
    const status = pickWeighted(rand, STATUS_MIX)
    const closed = status === "won" || status === "lost"
    const company = companies[index]
    // A closed lead has had time to close; an open one may be a day old.
    const createdAt = dateBetween(rand, daysAgo(150), daysAgo(closed ? 14 : 1))
    // Mostly ahead of today, with a few that have slipped past it — a
    // follow-up list that is never overdue would have nothing to flag.
    const nextActionAt = chance(rand, NEEDS_ACTION[status])
      ? officeHours(rand, daysAhead(intBetween(rand, -3, 34), REFERENCE_DATE))
      : undefined

    return {
      id: rowId("lea", index + 1),
      name,
      avatarUrl: avatarFor(name),
      email: emailFor(name, domainFor(company)),
      company,
      source: pickWeighted(rand, SOURCE_MIX),
      status,
      valueCents: intBetween(rand, 15, 480) * 100_00,
      ownerId: pick(rand, reps),
      ...(nextActionAt ? { nextActionAt } : {}),
      createdAt,
    }
  })
}

/** The seeded funnel. The repository below owns the rows a page converts or ticks off. */
export const LEADS: Lead[] = generate()

export const leads = createRepository(LEADS, {
  searchKeys: ["name", "company", "email"],
  filterKeys: ["source", "status", "ownerId"],
})
lib/sample-data/leaves.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  daysAgo,
  daysAhead,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
} from "./core"
import { EMPLOYEES } from "./employees"

/** A request for time off: who, what kind, which days, and whether it was granted. */
export type Leave = {
  id: string
  /** An Employee id. */
  employeeId: string
  kind: "annual" | "sick" | "parental" | "unpaid"
  /** Midnight UTC on the first day away. */
  from: Date
  /** Midnight UTC on the last day away, `from` included. */
  to: Date
  status: "pending" | "approved" | "declined"
  note?: string
}

const COUNT = 40

const KIND_MIX: readonly (readonly [Leave["kind"], number])[] = [
  ["annual", 0.55],
  ["sick", 0.25],
  ["parental", 0.08],
  ["unpaid", 0.12],
]

const STATUS_MIX: readonly (readonly [Leave["status"], number])[] = [
  ["pending", 0.35],
  ["approved", 0.45],
  ["declined", 0.2],
]

/** How long a request runs, in days, by kind. */
const SPAN: Record<Leave["kind"], [number, number]> = {
  annual: [1, 10],
  sick: [1, 4],
  parental: [30, 90],
  unpaid: [2, 14],
}

const NOTES: Record<Leave["kind"], string[]> = {
  annual: ["Family holiday", "Long weekend away", "Wedding", "Moving house", "Visiting parents"],
  sick: ["Flu", "Dental surgery", "Migraine", "Back injury"],
  parental: ["Paternity leave", "Maternity leave", "Adoption leave"],
  unpaid: ["Sabbatical", "Study leave", "Travel", "Caring for a relative"],
}

/** Midnight UTC on the day `date` falls on — a request names days, not instants. */
function utcDay(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}

function generate(): Leave[] {
  const rand = seeded("leaves")

  return Array.from({ length: COUNT }, (_, index) => {
    const kind = pickWeighted(rand, KIND_MIX)
    const status = pickWeighted(rand, STATUS_MIX)
    const [least, most] = SPAN[kind]
    // A request still waiting is for days ahead; a decided one may be behind us.
    const from = utcDay(
      status === "pending"
        ? daysAhead(intBetween(rand, 2, 45), REFERENCE_DATE)
        : daysAgo(intBetween(rand, -30, 60), REFERENCE_DATE)
    )
    const to = utcDay(daysAhead(intBetween(rand, least, most) - 1, from))
    const note = chance(rand, 0.6) ? pick(rand, NOTES[kind]) : undefined

    return {
      id: rowId("lve", index + 1),
      employeeId: pick(rand, EMPLOYEES).id,
      kind,
      from,
      to,
      status,
      ...(note ? { note } : {}),
    }
  })
}

/** The seeded leave book. The repository below owns the rows a manager decides. */
export const LEAVES: Leave[] = generate()

export const leaves = createRepository(LEAVES, {
  searchKeys: ["note"],
  filterKeys: ["status", "kind", "employeeId"],
})
lib/sample-data/listings.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  daysAhead,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
} from "./core"
import { MEMBERS } from "./members"

/** A property on the books: what it is, where, what it asks, and how the market has answered. */
export type Listing = {
  id: string
  title: string
  kind: "house" | "apartment" | "villa" | "office"
  city: string
  country: string
  /** The asking price in minor units — cents, not dollars. */
  priceCents: number
  status: "listed" | "under_offer" | "sold" | "rented"
  beds: number
  baths: number
  sqm: number
  /** A Member id: the agent who holds it. */
  agentId: string
  listedAt: Date
  /** When it sold or let; only a sold or rented listing has one. */
  closedAt?: Date
  /** Page views since it was listed. */
  views: number
  /** Enquiries since it was listed. */
  enquiries: number
  /** The next booked viewing; only an open listing has one. */
  nextViewingAt?: Date
}

const COUNT = 60

const STREETS = [
  "Linden", "Harbour", "Maple", "Riverside", "Orchard", "Beacon", "Willow", "Granite",
  "Elm", "Meadow", "Lakeview", "Cedar", "Chapel", "Vineyard", "Marlow", "Hawthorn",
]

const SUFFIXES: Record<Listing["kind"], string[]> = {
  house: ["Lane", "Road", "Close", "Drive"],
  apartment: ["Court", "Lofts", "Residences", "Apartments"],
  villa: ["Villa", "Estate", "Gardens"],
  office: ["Exchange", "Tower", "Works", "Offices"],
}

const PLACES: readonly (readonly [string, string])[] = [
  ["Lisbon", "Portugal"],
  ["Porto", "Portugal"],
  ["Barcelona", "Spain"],
  ["Valencia", "Spain"],
  ["Amsterdam", "Netherlands"],
  ["Berlin", "Germany"],
  ["Munich", "Germany"],
  ["Copenhagen", "Denmark"],
  ["Dublin", "Ireland"],
  ["Edinburgh", "United Kingdom"],
  ["Lyon", "France"],
  ["Milan", "Italy"],
]

const KIND_MIX: readonly (readonly [Listing["kind"], number])[] = [
  ["house", 0.36],
  ["apartment", 0.34],
  ["villa", 0.14],
  ["office", 0.16],
]

const STATUS_MIX: readonly (readonly [Listing["status"], number])[] = [
  ["listed", 0.5],
  ["under_offer", 0.15],
  ["sold", 0.22],
  ["rented", 0.13],
]

/** Asking prices in whole thousands of dollars, by kind. */
const PRICE_THOUSANDS: Record<Listing["kind"], [number, number]> = {
  house: [320, 1_400],
  apartment: [180, 780],
  villa: [900, 3_600],
  office: [400, 2_200],
}

const BEDS: Record<Listing["kind"], [number, number]> = {
  house: [2, 6],
  apartment: [1, 4],
  villa: [3, 7],
  office: [0, 0],
}

const BATHS: Record<Listing["kind"], [number, number]> = {
  house: [1, 4],
  apartment: [1, 2],
  villa: [2, 5],
  office: [1, 4],
}

const SQM: Record<Listing["kind"], [number, number]> = {
  house: [90, 320],
  apartment: [45, 160],
  villa: [220, 640],
  office: [120, 900],
}

const DAY = 86_400_000

/** Minute-aligned, on the quarter hour between nine and six — when viewings are booked. */
function officeHours(rand: () => number, day: Date): Date {
  const midnight = Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate())
  return new Date(midnight + (9 * 60 + 15 * intBetween(rand, 0, 36)) * 60_000)
}

function generate(): Listing[] {
  const rand = seeded("listings")
  const agents = MEMBERS.filter((member) => member.status === "active" && member.role !== "viewer").map(
    (member) => member.id
  )

  return Array.from({ length: COUNT }, (_, index) => {
    const kind = pickWeighted(rand, KIND_MIX)
    const status = pickWeighted(rand, STATUS_MIX)
    const closed = status === "sold" || status === "rented"
    const [city, country] = pick(rand, PLACES)
    const [low, high] = PRICE_THOUSANDS[kind]

    // A closed listing was on the market long enough to close; an open one may
    // have gone up this week.
    const listedAt = dateBetween(rand, daysAgo(240), daysAgo(closed ? 20 : 2))
    const closedAt = closed ? dateBetween(rand, daysAhead(5, listedAt), REFERENCE_DATE) : undefined
    // Interest accrues while the listing is live, at a pace of its own.
    const liveDays = Math.max(1, Math.round(((closedAt ?? REFERENCE_DATE).getTime() - listedAt.getTime()) / DAY))
    const views = Math.max(20, Math.round(liveDays * intBetween(rand, 4, 22)))
    const enquiries = Math.round(views * intBetween(rand, 20, 70) / 1000)
    const nextViewingAt =
      !closed && chance(rand, 0.5) ? officeHours(rand, daysAhead(intBetween(rand, 1, 14))) : undefined

    return {
      id: rowId("lst", index + 1),
      title: `${intBetween(rand, 1, 240)} ${pick(rand, STREETS)} ${pick(rand, SUFFIXES[kind])}`,
      kind,
      city,
      country,
      priceCents: intBetween(rand, low, high) * 1_000_00,
      status,
      beds: intBetween(rand, BEDS[kind][0], BEDS[kind][1]),
      baths: intBetween(rand, BATHS[kind][0], BATHS[kind][1]),
      sqm: intBetween(rand, SQM[kind][0], SQM[kind][1]),
      agentId: pick(rand, agents),
      listedAt,
      ...(closedAt ? { closedAt } : {}),
      views,
      enquiries,
      ...(nextViewingAt ? { nextViewingAt } : {}),
    }
  })
}

/** The seeded book. The repository below owns the rows a page reads. */
export const LISTINGS: Listing[] = generate()

export const listings = createRepository(LISTINGS, {
  searchKeys: ["title", "city"],
  filterKeys: ["kind", "status", "agentId", "city"],
})
lib/sample-data/log-entries.ts
import { REFERENCE_DATE, chance, createRepository, dateBetween, daysAgo, intBetween, pick, pickWeighted, rowId, seeded } from "./core"

/** One line of application log output. */
export type LogEntry = {
  id: string
  at: Date
  level: "debug" | "info" | "warn" | "error"
  service: string
  message: string
  requestId?: string
  durationMs?: number
}

const COUNT = 2000

// Internal service names for the log stream. Deliberately not imported from
// services.ts: a log line's `service` is an implementation detail (which
// worker emitted it), not the public-status entity a status page shows.
const SERVICE_NAMES = [
  "api-gateway",
  "auth-service",
  "billing-worker",
  "notifications-queue",
  "search-indexer",
  "webhook-dispatcher",
  "file-storage",
  "scheduler",
]

const LEVEL_MIX: readonly (readonly [LogEntry["level"], number])[] = [
  ["debug", 0.15],
  ["info", 0.7],
  ["warn", 0.1],
  ["error", 0.05],
]

const MESSAGES: Record<LogEntry["level"], string[]> = {
  debug: [
    "cache miss, fetching from origin",
    "computed response payload",
    "acquired connection from pool",
    "skipping optional middleware",
  ],
  info: [
    "request completed",
    "job finished successfully",
    "user session refreshed",
    "scheduled task started",
    "webhook delivered",
    "cache warmed",
  ],
  warn: [
    "retrying request after timeout",
    "rate limit approaching for client",
    "slow query detected",
    "connection pool nearing capacity",
  ],
  error: [
    "unhandled exception in request handler",
    "database connection failed",
    "upstream request failed with 502",
    "failed to acquire lock",
    "message processing failed after retries",
  ],
}

function generate(): LogEntry[] {
  const rand = seeded("logEntries")
  return Array.from({ length: COUNT }, (_, index) => {
    const level = pickWeighted(rand, LEVEL_MIX)
    // A debug line is rarely worth a request id; everything louder usually gets one.
    const hasRequest = level !== "debug" && chance(rand, 0.85)
    return {
      id: rowId("log", index + 1),
      at: dateBetween(rand, daysAgo(14), REFERENCE_DATE),
      level,
      service: pick(rand, SERVICE_NAMES),
      message: pick(rand, MESSAGES[level]),
      requestId: hasRequest ? `req_${intBetween(rand, 100_000, 999_999)}` : undefined,
      durationMs: hasRequest ? intBetween(rand, 4, level === "error" ? 6_000 : 1_400) : undefined,
    }
  })
}

/** The seeded log stream. The repository below owns the rows a demo mutates. */
export const LOG_ENTRIES: LogEntry[] = generate()

export const logEntries = createRepository(LOG_ENTRIES, {
  searchKeys: ["message", "service"],
  filterKeys: ["level", "service"],
})
lib/sample-data/mail-templates-customers.ts
/**
 * The mail between the reader and customers' contacts, both ways — the copy
 * `mail-templates.ts` gathers for the generator. Every name in it is filled in
 * from the rows the mail is between.
 */
import type { MailTemplate } from "./mail-templates"

/** From a customer's contact to the reader. */
export const FROM_CUSTOMER: MailTemplate[] = [
  {
    subject: (c) => `Renewal quote for ${c.company}`,
    labels: ["finance"],
    files: ["renewal-quote.pdf"],
    body: (c) => [
      `Hi ${c.me},`,
      `Our renewal comes up at the end of next month, and finance wants the quote in writing before they approve it. The one attached is last year's; could you send the updated version for ${c.company}?`,
      "We plan to add about ten seats in the new year, so a price for those would help too.",
      `Best, ${c.other}`,
    ],
  },
  {
    subject: () => "A question about seat pricing",
    labels: ["finance"],
    body: (c) => [
      `Hello ${c.me},`,
      "When someone leaves our team and we remove their seat halfway through the month, is the unused part credited on the next invoice, or does it only change from the next cycle?",
      "We want to tidy up our seats before the renewal.",
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "Invoice charged twice",
    labels: ["finance"],
    files: ["card-statement.pdf"],
    body: (c) => [
      `Hi ${c.me},`,
      `Our card was charged twice for this month's invoice, a few minutes apart. Both charges are on the statement for ${c.company}, attached.`,
      "Could you refund the second one, or tell me who to talk to about it?",
      `Thank you, ${c.other}`,
    ],
  },
  {
    subject: () => "Single sign-on rollout next week",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.me},`,
      `We are turning on single sign-on for everyone at ${c.company} next Tuesday. Is there anything we should do on your side first, or does the setting apply to people who are already signed in?`,
      "Our IT lead would like a short call on Monday if you have half an hour.",
      `Best, ${c.other}`,
    ],
  },
  {
    subject: () => "Warehouse export keeps timing out",
    labels: ["work"],
    files: ["export-log.txt"],
    body: (c) => [
      `Hi ${c.me},`,
      "The nightly export to our warehouse has timed out three nights in a row. Last night's log is attached; it stops at the same table each time.",
      "Nothing has changed on our side since the start of the month. Can you see anything from yours?",
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "Thanks for the onboarding session",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.me},`,
      "Thank you for walking the team through the setup yesterday. Everyone left with their workspace connected, which has never happened in one sitting before.",
      "We will try the scheduled reports next week and come back with questions.",
      `Best, ${c.other}`,
    ],
  },
  {
    subject: () => "Can we move our quarterly review?",
    labels: ["work"],
    body: (c) => [
      `Hello ${c.me},`,
      "Our quarterly review now clashes with our board meeting. Could we move it to the following week?",
      "Any afternoon works for us. The agenda can stay as it is.",
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "Webhook signatures failing since Tuesday",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.me},`,
      "Since Tuesday about one in five webhooks from your side fails our signature check. The payloads look fine; it is only the check.",
      "Did anything change in how the header is built? We have paused the retries on our side for now.",
      c.other,
    ],
  },
  {
    subject: () => "Visiting your London office",
    labels: ["travel", "work"],
    body: (c) => [
      `Hi ${c.me},`,
      `Two of us from ${c.company} will be in London in a fortnight. Would you have an hour to meet? We would like to show you how we use the dashboards day to day.`,
      "We are flexible on the day. Mornings are better for us.",
      `Best, ${c.other}`,
    ],
  },
  {
    subject: () => "A second workspace for our new office",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.me},`,
      `${c.company} is opening an office in Toronto, and the team there wants its own workspace. Can it sit under our current contract, or is it a separate account?`,
      "We would like the two to share one sign-in.",
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "Feedback on the new dashboards",
    labels: ["work"],
    body: (c) => [
      `Hello ${c.me},`,
      "The team has used the new dashboards for two weeks. The overview is a clear improvement; people open it every morning now.",
      "One request: the date range resets when we switch pages. It would help if it stayed where we set it.",
      `Best, ${c.other}`,
    ],
  },
  {
    subject: (c) => `Purchase order for ${c.company}`,
    labels: ["finance"],
    files: ["purchase-order.pdf"],
    body: (c) => [
      `Hi ${c.me},`,
      "Our procurement team needs a purchase order number on every invoice from now on. This year's order is attached.",
      "Could you add the number to the next invoice and to the renewal?",
      `Thanks, ${c.other}`,
    ],
  },
]

/** From the reader to a customer's contact. */
export const TO_CUSTOMER: MailTemplate[] = [
  {
    subject: (c) => `Re: Renewal quote for ${c.company}`,
    labels: ["finance"],
    files: ["renewal-quote-updated.pdf"],
    body: (c) => [
      `Hi ${c.other},`,
      `The updated quote for ${c.company} is attached. It keeps this year's price per seat and adds the ten seats you mentioned at the same rate.`,
      "It is valid until the end of next month. Tell me if finance needs it in another format.",
      `Best, ${c.me}`,
    ],
  },
  {
    subject: () => "Notes from our call",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.other},`,
      "Thanks for the time today. To sum up: we look into the export timeouts first, and you will have an answer on the webhook signatures by Friday.",
      "I will send a short update on both at the end of the week, whatever we find.",
      `Best, ${c.me}`,
    ],
  },
  {
    subject: () => "About the double charge",
    labels: ["finance"],
    body: (c) => [
      `Hi ${c.other},`,
      "You are right, the second charge was our mistake. It has been refunded and should reach your card within five working days.",
      "Sorry for the trouble.",
      `Best, ${c.me}`,
    ],
  },
  {
    subject: () => "Moving the quarterly review",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.other},`,
      "No problem at all. I have moved the review to the following Wednesday at 2 PM and sent a new invite.",
      c.me,
    ],
  },
  {
    subject: () => "Your export timeouts",
    labels: ["work"],
    files: ["export-fix-notes.pdf"],
    body: (c) => [
      `Hi ${c.other},`,
      "We found the cause of the export timeouts: one table had grown past the batch size we allowed for. The fix goes out tonight; the notes are attached.",
      "Tomorrow's export should run in its usual time. I will check it myself in the morning.",
      `Best, ${c.me}`,
    ],
  },
  {
    subject: () => "Meeting in London",
    labels: ["travel", "work"],
    body: (c) => [
      `Hi ${c.other},`,
      `It would be good to meet the ${c.company} team. Tuesday morning works for us; our office is five minutes from the station.`,
      "I will send an invite with the address.",
      `Best, ${c.me}`,
    ],
  },
]
lib/sample-data/mail-templates-team.ts
/**
 * The mail between the reader and her teammates, both ways — the copy
 * `mail-templates.ts` gathers for the generator. Every name in it is filled in
 * from the rows the mail is between.
 */
import type { MailTemplate } from "./mail-templates"

/** From a teammate to the reader. */
export const FROM_TEAM: MailTemplate[] = [
  {
    subject: () => "Staging deploy for 4.2 is out",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.me},`,
      "The 4.2 build went out to staging at nine. The seat-count job and the new export queue are both in it, and nothing has shown up in the error log so far.",
      `${c.teammate} is running the regression pass this afternoon. If it comes back clean, I would like to ship to production on Thursday morning.`,
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "Design review moved to Thursday",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.me},`,
      "The design review for the new onboarding flow has moved from Wednesday to Thursday at 2 PM, same room. The prototype link has not changed.",
      `Could you bring the numbers on where people drop out of the setup step? ${c.teammate} wants to decide on the checklist before the sprint closes.`,
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "The seat-count job ran long again",
    labels: ["work"],
    body: (c) => [
      `${c.me},`,
      "The nightly seat-count job took 47 minutes last night, against about 20 a month ago. It finished, but it ran into the start of the invoice mailer.",
      "I think the new audit trail writes are the cause. I have a branch that batches them; it needs a review before we try it on staging.",
      c.other,
    ],
  },
  {
    subject: () => "Draft of the retention write-up",
    labels: ["work"],
    files: ["retention-draft.pdf"],
    body: (c) => [
      `Hi ${c.me},`,
      "Attached is the first draft of the retention write-up. The short version: week-four retention is up two points, almost all of it from workspaces that finished the setup checklist.",
      "Two open questions are marked in the margin. Comments by Friday would let me send it with the board pack on Monday.",
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "On-call rota for next week",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.me},`,
      `The rota for next week has a gap on Tuesday night. ${c.teammate} is out, and I would rather not double anyone up after the last release.`,
      "Could you take it, or should I ask the platform team to cover?",
      c.other,
    ],
  },
  {
    subject: () => "Offsite in Lisbon: flights and hotel",
    labels: ["travel", "work"],
    files: ["offsite-itinerary.pdf"],
    body: (c) => [
      `Hi ${c.me},`,
      "Flights for the Lisbon offsite are booked for everyone who confirmed. The itinerary is attached, with the hotel address and the times for each day.",
      "We land on the Tuesday afternoon and the first session starts on Wednesday at 9:30. Dinner that evening is at the place by the river we used last year.",
      `Tell me if anything on the list is wrong. ${c.other}`,
    ],
  },
  {
    subject: () => "Train times for the Berlin workshop",
    labels: ["travel"],
    body: (c) => [
      `${c.me},`,
      "The workshop in Berlin starts at 10, so the 6:40 train gets us there with an hour to spare. The later one cuts it too close.",
      "I have held two seats in the quiet coach. If you would rather go the night before, say so today and I will change the booking.",
      c.other,
    ],
  },
  {
    subject: () => "Expense report for sign-off",
    labels: ["finance"],
    files: ["expense-report.xlsx"],
    body: (c) => [
      `Hi ${c.me},`,
      "My expenses from the customer visit in Rotterdam are attached for sign-off: two nights of hotel, the train there and back, and one dinner.",
      "The total is under the trip budget. The receipts are on the second tab of the sheet.",
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "Book club: the next pick",
    labels: ["personal"],
    body: (c) => [
      `Hi ${c.me},`,
      "The vote for the next book closed last night. The history of the shipping container won by two votes, so that is what we are reading.",
      "We meet on the last Thursday of the month, after work, in the small kitchen.",
      c.other,
    ],
  },
  {
    subject: () => "Lunch on Friday?",
    labels: ["personal"],
    body: (c) => [
      `${c.me},`,
      `A few of us are going to the noodle place on the corner on Friday at 12:30. ${c.teammate} is coming too.`,
      "Shall I add you to the booking?",
      c.other,
    ],
  },
  {
    subject: () => "Budget sign-off for the analytics pilot",
    labels: ["finance", "work"],
    files: ["analytics-pilot-contract.pdf"],
    body: (c) => [
      `Hi ${c.me},`,
      "The analytics pilot needs your sign-off before the vendor will hold the price. It is a three-month term, cancellable with a month's notice.",
      "The cost fits inside this quarter's tooling budget with about a tenth left over. The contract is attached.",
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "Notes from the support retro",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.me},`,
      "Notes from this morning's support retro, in short: first responses are faster, but tickets about exports still bounce between teams.",
      `We agreed to route anything about exports to ${c.teammate} for the next two weeks and look at the numbers again after that.`,
      c.other,
    ],
  },
  {
    subject: () => "Pricing page copy for review",
    labels: ["work"],
    files: ["pricing-page-copy.docx"],
    body: (c) => [
      `Hi ${c.me},`,
      "The new copy for the pricing page is attached, with the plan names unchanged and the strikethrough prices gone.",
      "I would like to publish it with Thursday's release. Could you read the enterprise section in particular? That is the one sales asked about.",
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "Customer interviews this week",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.me},`,
      "I have five customer interviews booked this week, all about the scheduled reports. Two of them are on the enterprise plan.",
      "If you want to sit in on one, Thursday at 11 is the most interesting. I will share the notes either way.",
      c.other,
    ],
  },
  {
    subject: () => "New card for the tooling subscriptions",
    labels: ["finance"],
    body: (c) => [
      `${c.me},`,
      "The company card on the design and analytics tools expires at the end of the month. Seven subscriptions still use it.",
      "Finance has issued the new card. Can I move them over this week, or do you want to review the list first?",
      c.other,
    ],
  },
  {
    subject: () => "Tickets for the product conference",
    labels: ["travel", "work"],
    body: (c) => [
      `Hi ${c.me},`,
      "Early-bird tickets for the product conference close on Friday. Three of us would like to go, and the talks on pricing look worth it.",
      "The total with the hotel is inside the travel budget. Shall I book?",
      `Thanks, ${c.other}`,
    ],
  },
  {
    subject: () => "Drinks on Friday for the release",
    labels: ["personal"],
    body: (c) => [
      `Hi ${c.me},`,
      `We are having a drink on Friday after work to mark the release. ${c.teammate} has booked the back room at the place on the corner.`,
      "Partners are welcome. No speeches, promise.",
      c.other,
    ],
  },
  {
    subject: () => "Access to the billing settings",
    labels: ["work", "finance"],
    body: (c) => [
      `Hi ${c.me},`,
      "Could you give me access to the billing settings? I need to download last quarter's invoices for the audit.",
      "Read-only would be enough, if that is easier.",
      `Thanks, ${c.other}`,
    ],
  },
]

/** From the reader to a teammate. */
export const TO_TEAM: MailTemplate[] = [
  {
    subject: () => "Agenda for Thursday's review",
    labels: ["work"],
    files: ["review-agenda.pdf"],
    body: (c) => [
      `Hi ${c.other},`,
      "The agenda for Thursday's design review is attached. We start with the onboarding checklist, then the pricing page, and keep the last twenty minutes for questions.",
      "If you want anything added, reply by Wednesday noon.",
      `Thanks, ${c.me}`,
    ],
  },
  {
    subject: () => "Hotel rooms for the offsite",
    labels: ["travel"],
    body: (c) => [
      `Hi ${c.other},`,
      "I have held twelve rooms at the hotel for the offsite, Tuesday to Friday. The hold runs out at the end of next week.",
      "Can you confirm who is coming from your team, so I can release the rest?",
      c.me,
    ],
  },
  {
    subject: () => "Welcome to the team",
    labels: ["personal"],
    body: (c) => [
      `Hi ${c.other},`,
      "Welcome to Northwind. Your laptop is on your desk and your accounts are set up; the first week's schedule is pinned in the team channel.",
      "Lunch on Monday is on us. I will come and find you at noon.",
      c.me,
    ],
  },
  {
    subject: () => "Covering the Tuesday rota",
    labels: ["work"],
    body: (c) => [
      `Hi ${c.other},`,
      "I can take the Tuesday night on-call. Put me on the rota and we will swap back later in the month.",
      c.me,
    ],
  },
  {
    subject: () => "Numbers for the retention write-up",
    labels: ["work"],
    files: ["retention-by-cohort.xlsx"],
    body: (c) => [
      `Hi ${c.other},`,
      "The numbers you asked for are attached: week-four retention by signup month, split by whether the workspace finished setup.",
      "The last two months are still settling, so treat them as provisional.",
      c.me,
    ],
  },
  {
    subject: () => "Your expenses are approved",
    labels: ["finance"],
    body: (c) => [
      `Hi ${c.other},`,
      "Your expenses from the Rotterdam trip are approved and will be paid with this month's salary.",
      `Thanks, ${c.me}`,
    ],
  },
]
lib/sample-data/mail-templates.ts
/**
 * What the mailbox says: the mails a workspace owner gets from teammates and
 * customers, and the ones she writes back. The vocabulary `mails.ts` is
 * written in, kept apart so the generator reads as a generator. Every line is
 * a sentence a real team would send — specific, plain, no exclamation marks —
 * and every name in it is filled in from the rows the mail is between. The
 * copy itself lives beside this file, split by who is on the other end:
 * `mail-templates-team.ts` and `mail-templates-customers.ts`.
 */
import { FROM_CUSTOMER, TO_CUSTOMER } from "./mail-templates-customers"
import { FROM_TEAM, TO_TEAM } from "./mail-templates-team"
import type { MailLabel } from "./mails"

/** The people a template names: the reader, whoever is on the other end, their company, and a teammate. */
export type MailContext = { me: string; other: string; company: string; teammate: string }

export type MailTemplate = {
  subject: (c: MailContext) => string
  body: (c: MailContext) => string[]
  labels: readonly MailLabel[]
  /** Files the mail carries when it is one of the mailbox's mails with attachments. */
  files?: readonly string[]
}

export const MAIL_TEMPLATES = {
  fromTeam: FROM_TEAM,
  fromCustomer: FROM_CUSTOMER,
  toTeam: TO_TEAM,
  toCustomer: TO_CUSTOMER,
} as const
lib/sample-data/mails.ts
import { chance, createRepository, dateBetween, daysAgo, intBetween, pick, rowId, seeded, shuffle } from "./core"
import { avatarFor } from "./avatars"
import { CUSTOMERS, type Customer } from "./customers"
import { MAIL_TEMPLATES, type MailContext, type MailTemplate } from "./mail-templates"
import { MEMBERS, type Member } from "./members"

export type MailFolder = "inbox" | "sent" | "drafts" | "archive" | "trash"
export type MailLabel = "work" | "personal" | "finance" | "travel"

/** Whoever a mail is from: a name, the address it came from, and a face. */
export type MailPerson = { name: string; email: string; avatarUrl: string }

export type MailAttachment = { name: string; sizeBytes: number }

/**
 * One mail in the signed-in member's mailbox — `MEMBERS[0]`, the workspace
 * owner, the one reader every folder is generated around. What came in is
 * from a teammate or a customer's contact; what went out is from the reader.
 */
export type Mail = {
  id: string
  folder: MailFolder
  from: MailPerson
  /** Addresses, the main recipient first. */
  to: string[]
  subject: string
  /** The body, paragraph by paragraph, greeting and sign-off included. */
  body: string[]
  at: Date
  read: boolean
  starred: boolean
  labels: MailLabel[]
  attachments: MailAttachment[]
}

const COUNT = 64

/**
 * Exact counts rather than weighted draws, so the folders add up the same way
 * every time: six in ten in the inbox, the rest spread over what the reader
 * wrote, filed or threw away.
 */
const FOLDER_COUNTS: readonly (readonly [MailFolder, number])[] = [
  ["inbox", 38],
  ["sent", 10],
  ["drafts", 5],
  ["archive", 7],
  ["trash", 4],
]

// About three in ten unread: the reader is behind on the newest of the inbox,
// and two mails went into the trash unopened. Nothing the reader wrote is
// ever unread.
const UNREAD: Partial<Record<MailFolder, number>> = { inbox: 17, trash: 2 }
const STARRED: Partial<Record<MailFolder, number>> = { inbox: 4, archive: 1, sent: 1 }
/** The inbox mails the unread are drawn from: the newest, never last month's. */
const UNREAD_POOL = 26
/** One in five carries a file; never one in the trash. */
const WITH_FILES = 13

/** How far back each folder reaches, in days: the inbox is mostly the last fortnight, drafts are recent. */
const WINDOW: Record<MailFolder, readonly [number, number]> = {
  inbox: [1 / 48, 14],
  sent: [1 / 24, 30],
  drafts: [1 / 24, 5],
  archive: [3, 30],
  trash: [1, 30],
}

/** A file's weight by its extension: a spreadsheet is lighter than a signed contract. */
const SIZES: Record<string, readonly [number, number]> = {
  pdf: [80_000, 4_200_000],
  xlsx: [18_000, 900_000],
  docx: [30_000, 1_800_000],
  txt: [2_000, 180_000],
}

const firstName = (name: string) => name.split(" ")[0]

function generate(): Mail[] {
  const rand = seeded("mails")
  const me = MEMBERS[0]
  const reader: MailPerson = { name: me.name, email: me.email, avatarUrl: avatarFor(me.name) }
  // Only people still in the workspace write, and customers whose accounts are live.
  const team = MEMBERS.filter((member) => member.status === "active" && member.id !== me.id)
  const buyers = CUSTOMERS.filter((customer) => customer.status === "active" || customer.status === "trial")

  const folders = shuffle(
    rand,
    FOLDER_COUNTS.flatMap(([folder, count]) => Array.from({ length: count }, () => folder))
  )
  const filed = new Set(
    shuffle(
      rand,
      folders.flatMap((folder, index) => (folder === "trash" ? [] : [index]))
    ).slice(0, WITH_FILES)
  )

  // Each pool walks its own shuffle, so a subject repeats only once its pool
  // has been used up — and then from somebody else.
  const decks = new Map<string, { deck: MailTemplate[]; next: number }>()
  const draw = (kind: keyof typeof MAIL_TEMPLATES, withFiles: boolean): MailTemplate => {
    const key = `${kind}:${withFiles}`
    let entry = decks.get(key)
    if (!entry) {
      const pool = MAIL_TEMPLATES[kind].filter((template) => Boolean(template.files) === withFiles)
      entry = { deck: shuffle(rand, pool), next: 0 }
      decks.set(key, entry)
    }
    const template = entry.deck[entry.next % entry.deck.length]
    entry.next += 1
    return template
  }

  const rows = folders.map((folder, index): Mail => {
    const withFiles = filed.has(index)
    const [near, far] = WINDOW[folder]
    const at = dateBetween(rand, daysAgo(far), daysAgo(near))
    const outgoing = folder === "sent" || folder === "drafts"
    const withCustomer = chance(rand, outgoing ? 0.5 : 0.45)
    const person: Member | Customer = withCustomer ? pick(rand, buyers) : pick(rand, team)
    // A teammate the mail mentions or copies in: never the person it is with.
    const others = team.filter((member) => member.email !== person.email)
    const teammate = pick(rand, others)
    const kind = outgoing
      ? withCustomer ? "toCustomer" : "toTeam"
      : withCustomer ? "fromCustomer" : "fromTeam"
    const template = draw(kind, withFiles)
    const context: MailContext = {
      me: firstName(me.name),
      other: firstName(person.name),
      company: "company" in person ? person.company : "Northwind",
      teammate: firstName(teammate.name),
    }
    const body = template.body(context)
    // A teammate is copied in on a fifth of the mail between colleagues.
    const cc = !withCustomer && chance(rand, 0.2) ? [teammate.email] : []
    const attachments = withFiles
      ? (template.files ?? []).map((name) => {
          const [min, max] = SIZES[name.slice(name.lastIndexOf(".") + 1)] ?? SIZES.pdf
          return { name, sizeBytes: intBetween(rand, min, max) }
        })
      : []

    return {
      id: rowId("eml", index + 1),
      folder,
      from: outgoing ? reader : { name: person.name, email: person.email, avatarUrl: avatarFor(person.name) },
      to: outgoing ? [person.email, ...cc] : [me.email, ...cc],
      subject: template.subject(context),
      // A draft is a mail nobody has signed yet.
      body: folder === "drafts" ? body.slice(0, -1) : body,
      at,
      read: true,
      starred: false,
      labels: [...template.labels],
      attachments,
    }
  })

  // The unread and the starred are drawn once every row exists, so the counts
  // are exact rather than hoped for.
  const newest = (folder: MailFolder) =>
    rows.filter((row) => row.folder === folder).sort((a, b) => b.at.getTime() - a.at.getTime())
  for (const [folder, count] of Object.entries(UNREAD) as [MailFolder, number][]) {
    for (const row of shuffle(rand, newest(folder).slice(0, UNREAD_POOL)).slice(0, count)) row.read = false
  }
  for (const [folder, count] of Object.entries(STARRED) as [MailFolder, number][]) {
    for (const row of shuffle(rand, newest(folder)).slice(0, count)) row.starred = true
  }

  return rows.slice(0, COUNT)
}

/** The seeded mailbox. The repository below owns the rows the mail app files, stars and sends. */
export const MAILS: Mail[] = generate()

export const mails = createRepository(MAILS, {
  searchKeys: ["subject"],
  filterKeys: ["folder", "read", "starred"],
})
lib/sample-data/members.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  emailFor,
  intBetween,
  pickWeighted,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { avatarFor } from "./avatars"

/** A person in the workspace this sample data describes. */
export type Member = {
  id: string
  name: string
  email: string
  role: "owner" | "admin" | "member" | "viewer"
  status: "active" | "invited" | "deactivated"
  avatarUrl?: string
  joinedAt: Date
  lastActiveAt: Date
  twoFactor: boolean
}

/** The workspace's own domain: every member is on it, no customer is. */
const TEAM_DOMAIN = "vibra.example"

const COUNT = 24

const FIRST_NAMES = [
  "Aisha", "Bruno", "Camille", "Dmitri", "Esther", "Farid", "Greta", "Hassan",
  "Iris", "Kwame", "Lior", "Magda", "Nadia", "Ola", "Rafik", "Sonia",
]

const LAST_NAMES = [
  "Adeyemi", "Brandt", "Costa", "Dubois", "Eriksen", "Farrow", "Gallo", "Halvorsen",
  "Imani", "Jansen", "Keller", "Larsen", "Meyer", "Nakamura", "Ortiz", "Pereira",
]

// The workspace has one owner (the first row), so the rest share out the
// remaining roles: a few admins, mostly members, a handful of read-only seats.
const ROLE_MIX: readonly (readonly [Member["role"], number])[] = [
  ["admin", 0.22],
  ["member", 0.6],
  ["viewer", 0.18],
]

const STATUS_MIX: readonly (readonly [Member["status"], number])[] = [
  ["active", 0.75],
  ["invited", 0.17],
  ["deactivated", 0.08],
]

function generate(): Member[] {
  const rand = seeded("members")
  // Every pairing, shuffled once, so no two teammates share a name.
  const names = shuffle(
    rand,
    FIRST_NAMES.flatMap((first) => LAST_NAMES.map((last) => `${first} ${last}`))
  ).slice(0, COUNT)

  return names.map((name, index) => {
    const founder = index === 0
    const role = founder ? "owner" : pickWeighted(rand, ROLE_MIX)
    const status = founder ? "active" : pickWeighted(rand, STATUS_MIX)

    const joinedDaysAgo = founder
      ? 1180
      : status === "invited"
        ? intBetween(rand, 1, 21)
        : status === "deactivated"
          ? intBetween(rand, 180, 1100)
          : intBetween(rand, 30, 1100)
    // Somewhere inside that day, rather than on its edge: whole-day offsets
    // would stamp every row with the same time as REFERENCE_DATE.
    const joinedAt = dateBetween(rand, daysAgo(joinedDaysAgo), daysAgo(joinedDaysAgo - 1))

    const lastActiveAt =
      status === "invited"
        ? // An invitation nobody has accepted yet: there is nothing to show.
          joinedAt
        : status === "deactivated"
          ? dateBetween(rand, joinedAt, daysAgo(60))
          : dateBetween(rand, daysAgo(10), REFERENCE_DATE)

    return {
      id: rowId("mbr", index + 1),
      name,
      email: emailFor(name, TEAM_DOMAIN),
      role,
      status,
      // Drawn by the kit's own avatar route from the name — a face for
      // everyone, and nothing here links to an image host.
      avatarUrl: avatarFor(name),
      joinedAt,
      lastActiveAt,
      twoFactor: status === "invited" ? false : role === "owner" || role === "admin" || chance(rand, 0.55),
    }
  })
}

/** The seeded team directory. The repository below owns the rows a demo mutates. */
export const MEMBERS: Member[] = generate()

export const members = createRepository(MEMBERS, {
  searchKeys: ["name", "email"],
  filterKeys: ["role", "status"],
})
lib/sample-data/messages.ts
import { chance, createRepository, intBetween, pick, rowId, seeded, shuffle } from "./core"
import { CHATS, type Chat } from "./chats"
import { MEMBERS } from "./members"

/** One line of a chat, with how far it got: sent, delivered, or read by the other side. */
export type Message = {
  id: string
  /** A Chat id. */
  chatId: string
  /** A Member id: whoever wrote it. */
  fromId: string
  text: string
  at: Date
  status: "sent" | "delivered" | "read"
}

const COUNT = 300

const MINUTE_MS = 60_000

// What teammates on an analytics product write to each other. Three registers,
// so a thread opens, carries on and closes the way a real one does; the openers
// and closers are walked by chat so no two threads start or end alike.
const OPENERS = [
  "Morning — did the staging deploy go out?",
  "Quick one: can you look at the webhook retries when you have a minute?",
  "Are we still on for the design review on Thursday?",
  "I pushed the funnel export branch, ready for a look.",
  "Heads up, the events endpoint is close to its rate limit again.",
  "Do you have the numbers for the renewal call?",
  "Has anyone seen the seat-count job finish today?",
  "Lunch at 12:30? I want to talk through the pricing page.",
  "Is the sandbox workspace ready for Fathom to try?",
  "Who is on support this week? The rota note has a gap.",
  "Can we move standup to 9:30 tomorrow?",
  "The retention export is done — want to see it before it ships?",
  "Anyone free to pair on the SSO loop this afternoon?",
  "The changelog draft is up. Two paragraphs, no surprises.",
]

const REPLIES = [
  "Yes, it went out an hour ago. Nothing in the error log so far.",
  "On it. I think it is the retry backoff, not the queue.",
  "Still on. I moved it to the big room.",
  "Looking now — first pass looks good, one comment on the CSV header.",
  "Saw that. I raised the limit on the staging cluster for now.",
  "Usage is up 40% quarter on quarter. Sending the deck.",
  "It finished at 09:12, took twice as long as yesterday though.",
  "Works for me. Bring the copy doc.",
  "Sounds good, thanks.",
  "Can you add that to the standup notes?",
  "Agreed. Let us ship it Monday.",
  "I will write it up and share the link here.",
  "That matches what I saw in the trace.",
  "Perfect, thank you.",
  "Let me check with the platform team first.",
  "Which workspace was that on? I only see it on Fathom's.",
  "The dashboard totals disagree with the API by about 2%; I think it is the sampling.",
  "Pushed a fix for the timezone on scheduled reports.",
  "Could you review the seat-count PR before standup?",
  "I would keep the plan names and drop the strikethrough prices.",
  "The alert fired at 03:10 and nobody was paged — rota gap again.",
  "Adding it to the retro notes now.",
  "Do we have a customer who would try the sandbox workspace?",
  "Lumen is near the seat cap, worth raising on the renewal call.",
  "I moved the design review to Thursday, same room.",
  "Fine by me. Let us do it after the release.",
  "One more thing: the webhook page never says the payload is signed.",
  "Yes — I will bring the enterprise comparison.",
  "The export queue drained overnight, all green now.",
  "Thanks, that is exactly what I needed.",
]

const CLOSERS = [
  "Great, see you then.",
  "Done. Closing the ticket.",
  "Thanks — that unblocks me.",
  "Talk tomorrow.",
  "Merged. Thank you both.",
  "I will pick it up first thing.",
  "Perfect. Have a good evening.",
  "Sent the link. Shout if it does not open.",
  "Booked. See everyone Thursday.",
  "Great work today, all.",
  "Noted — adding it to the retro.",
  "Sorted. Off for the day.",
  "Cheers, that is all from me.",
  "Deal. Monday it is.",
]

/**
 * How many messages each chat holds, adding up to `COUNT` exactly: a draw per
 * chat, scaled to the total, with the rounding error settled on the last one.
 */
function allocate(rand: () => number, chats: readonly Chat[]): number[] {
  const raw = chats.map(() => intBetween(rand, 10, 34))
  const total = raw.reduce((sum, value) => sum + value, 0)
  const counts = raw.map((value) => Math.max(1, Math.round((value / total) * COUNT)))
  const settled = counts.reduce((sum, value) => sum + value, 0)
  counts[counts.length - 1] += COUNT - settled
  return counts
}

function generate(): Message[] {
  const rand = seeded("messages")
  const me = MEMBERS[0].id
  const counts = allocate(rand, CHATS)
  const rows: Message[] = []

  CHATS.forEach((chat, chatIndex) => {
    const count = counts[chatIndex]
    const others = chat.memberIds.filter((id) => id !== me)
    // Each thread walks its own shuffle of the replies, so a line repeats only
    // once every thirty messages rather than whenever the draw lands on it.
    const deck = shuffle(rand, REPLIES)

    // Timestamps are laid down backwards from the chat's own lastMessageAt, so
    // the thread ends exactly where the list says it does and never after now.
    const times: number[] = new Array(count)
    times[count - 1] = chat.lastMessageAt.getTime()
    for (let index = count - 2; index >= 0; index -= 1) {
      // A reply lands within the hour most of the time; now and then the
      // thread sleeps overnight.
      const gap = chance(rand, 0.8) ? intBetween(rand, 2, 55) : intBetween(rand, 300, 900)
      times[index] = times[index + 1] - gap * MINUTE_MS
    }

    for (let index = 0; index < count; index += 1) {
      const inUnreadTail = index >= count - chat.unread
      // The unread tail is what the other side wrote since the reader last
      // opened the thread, so it is never the reader's own.
      const fromMe = inUnreadTail ? false : chance(rand, 0.48)
      const fromId = fromMe ? me : pick(rand, others)
      const last = index === count - 1
      const text =
        index === 0
          ? OPENERS[chatIndex % OPENERS.length]
          : last && !inUnreadTail
            ? CLOSERS[chatIndex % CLOSERS.length]
            : deck[index % deck.length]

      let status: Message["status"]
      if (fromMe) {
        // The reader's last word may still be on its way; everything earlier
        // has been read by the other side.
        status = last ? (chance(rand, 0.5) ? "delivered" : "read") : "read"
      } else {
        status = inUnreadTail ? "delivered" : "read"
      }

      rows.push({
        id: rowId("msg", rows.length + 1),
        chatId: chat.id,
        fromId,
        text,
        at: new Date(times[index]),
        status,
      })
    }
  })

  return rows
}

/** The seeded messages. The repository below owns the rows the composer adds to. */
export const MESSAGES: Message[] = generate()

export const messages = createRepository(MESSAGES, {
  searchKeys: ["text"],
  filterKeys: ["chatId", "fromId", "status"],
})
lib/sample-data/notes.ts
import { chance, createRepository, daysAgo, rowId, seeded } from "./core"

/** The five tints a note can carry on its top band; the same tones the tiles use. */
export type NoteTone = "info" | "success" | "warning" | "danger" | "neutral"

/** One line of a checklist note. */
export type ChecklistItem = { id: string; text: string; done: boolean }

/** A note on the board: prose or a checklist, labelled, pinned or filed away. */
export type Note = {
  id: string
  title: string
  body: string
  /** Words from the notes app's own label vocabulary. */
  labels: string[]
  /** Set on a checklist note; a prose note has none. */
  checklist?: ChecklistItem[]
  pinned: boolean
  archived: boolean
  /** The tint of the card's top band. A note without one sits plain on the sheet. */
  tone?: NoteTone
  updatedAt: Date
}

/** The five tints, walked in turn by the notes that carry one. */
const TONES: NoteTone[] = ["info", "success", "warning", "danger", "neutral"]

const MINUTE_MS = 60_000

/**
 * What the notes say, and what they are filed under. Written out rather than
 * assembled from parts because eighteen notes that read like notes are the
 * whole point of the page; the generator decides only what is drawn around
 * them — the tint, the dates, and which boxes are ticked. The labels are the
 * app's own vocabulary — Personal, Work, Ideas, Meetings, Planning, Reading
 * and Travel — which `projects-notes` names beside a tone dot in its rail, so a
 * note's labels always resolve to something on screen.
 */
const SEEDS: { title: string; labels: string[]; body: string; checklist?: string[] }[] = [
  {
    title: "Launch checklist — events API v2",
    labels: ["Work", "Planning"],
    body: "",
    checklist: [
      "Freeze the schema and tag the release",
      "Run the migration against staging",
      "Update the SDK quickstart",
      "Announce in the changelog",
      "Retire the v1 endpoints after 30 days",
    ],
  },
  {
    title: "Onboarding call with Wavelength Health",
    labels: ["Meetings"],
    body: "They want the funnel report emailed weekly and a sandbox workspace for their clinics. Follow up on SSO before the seats renew.",
  },
  {
    title: "Q4 planning themes",
    labels: ["Planning", "Work"],
    body: "Three bets: cut time-to-first-event to under ten minutes, make the retention view exportable, and ship the seat-count job before the enterprise renewals land.",
  },
  {
    title: "Reading list",
    labels: ["Reading"],
    body: "",
    checklist: [
      "Rivers of Records, chapter 11",
      "The On-Call Field Guide — alerting on SLOs",
      "Six Weeks at a Time, the betting table",
      "Talking to Customers Every Week",
    ],
  },
  {
    title: "Standup notes",
    labels: ["Meetings", "Work"],
    body: "Webhook retries are firing twice on the staging cluster. Rate limit on the events endpoint goes to 5k per minute on Monday. Design review moved to Thursday.",
  },
  {
    title: "Pricing page copy",
    labels: ["Work", "Ideas"],
    body: "Lead with the outcome, not the feature: “See every signup, from the first click to the first invoice.” Keep the plan names, drop the strikethrough prices.",
  },
  {
    title: "Berlin offsite",
    labels: ["Travel", "Planning"],
    body: "",
    checklist: [
      "Book the meeting room for both days",
      "Confirm the dietary list",
      "Draft the retrospective agenda",
      "Order the whiteboards",
    ],
  },
  {
    title: "Ideas parking lot",
    labels: ["Ideas"],
    body: "A weekly digest email built from the saved reports. Annotations on the traffic chart that anyone can leave. A one-line CLI that tails events from a workspace.",
  },
  {
    title: "Support rota — September",
    labels: ["Work"],
    body: "Week 1 Aisha, week 2 Bruno, week 3 Camille, week 4 Dmitri. Swap by writing in the channel, not by editing this note.",
  },
  {
    title: "Grocery run",
    labels: ["Personal"],
    body: "",
    checklist: ["Oat milk", "Coffee beans", "Rice", "Lemons", "Olive oil", "Eggs"],
  },
  {
    title: "Interview loop feedback",
    labels: ["Meetings"],
    body: "Strong on systems design, unsure on the pairing exercise. Ask the second round to go deeper on incident handling before we decide.",
  },
  {
    title: "Home renovation",
    labels: ["Personal", "Planning"],
    body: "",
    checklist: [
      "Get two quotes for the kitchen floor",
      "Pick the paint for the hallway",
      "Fix the bathroom tap",
      "Measure for the bookshelves",
    ],
  },
  {
    title: "Talk outline — observability on a budget",
    labels: ["Ideas", "Work"],
    body: "Open with the bill. Show what a sampled trace still tells you. Close with the three alerts worth paging on and the dozen that are not.",
  },
  {
    title: "Renewal call prep — Lumen Analytics",
    labels: ["Meetings", "Planning"],
    body: "Usage is up 40% quarter on quarter and they are near the seat cap. Bring the enterprise comparison and the SSO timeline.",
  },
  {
    title: "Weekend trip",
    labels: ["Travel", "Personal"],
    body: "",
    checklist: ["Tent and pegs", "Camping stove", "Water filter", "Map of the trail", "First-aid kit"],
  },
  {
    title: "Retrospective — August incident",
    labels: ["Work", "Meetings"],
    body: "The alert fired, nobody was paged: the rota had a gap. Action: the rota is checked by the deploy job, and a gap blocks the release.",
  },
  {
    title: "Docs to rewrite",
    labels: ["Work"],
    body: "The quickstart still shows the v1 SDK. The webhook page never says the payload is signed. Retention needs a worked example.",
  },
  {
    title: "Gift ideas",
    labels: ["Personal", "Ideas"],
    body: "A good kitchen knife, the trail-running shoes she mentioned, or a weekend away once the launch is out of the way.",
  },
]

function generate(): Note[] {
  const rand = seeded("notes")

  return SEEDS.map((seed, index) => {
    const id = rowId("not", index + 1)
    const checklist = seed.checklist?.map((text, line) => ({
      id: `${id}-c${line + 1}`,
      text,
      done: chance(rand, 0.4),
    }))
    // How long since the note was touched: most are days old, a few have sat
    // for weeks — a square-law draw rather than a flat one, so the board reads
    // as something in use. Minute-aligned, like every other date in the data.
    const ageDays = 60 * rand() ** 2
    const updatedAt = new Date(Math.floor(daysAgo(ageDays).getTime() / MINUTE_MS) * MINUTE_MS)
    // The rules, not draws: a note untouched for a month has been filed away,
    // and the ones touched in the last day are the ones pinned to the top.
    const archived = ageDays > 30
    const pinned = !archived && ageDays < 1

    return {
      id,
      title: seed.title,
      body: seed.body,
      labels: seed.labels,
      ...(checklist ? { checklist } : {}),
      pinned,
      archived,
      // About half the notes carry a tint, walking the five tones in turn so a
      // board of eighteen shows every one rather than whichever the draw favours.
      ...(chance(rand, 0.55) ? { tone: TONES[index % TONES.length] } : {}),
      updatedAt,
    }
  })
}

/** The seeded notes. The repository below owns the rows the app edits. */
export const NOTES: Note[] = generate()

export const notes = createRepository(NOTES, {
  searchKeys: ["title", "body"],
  filterKeys: ["archived", "pinned", "tone"],
})
lib/sample-data/notifications.ts
import { REFERENCE_DATE, chance, createRepository, dateBetween, daysAgo, pick, pickWeighted, rowId, seeded } from "./core"

/** One entry in a member's notification inbox. */
export type Notification = {
  id: string
  title: string
  description?: string
  kind: "mention" | "assignment" | "billing" | "system" | "security"
  at: Date
  read: boolean
  href?: string
}

const COUNT = 40

const FIRST_NAMES = ["Priya", "Marco", "Elin", "Tariq", "Naledi", "Jasper", "Renata", "Kofi"]

const KIND_MIX: readonly (readonly [Notification["kind"], number])[] = [
  ["mention", 0.3],
  ["assignment", 0.25],
  ["system", 0.25],
  ["billing", 0.12],
  ["security", 0.08],
]

type Built = { title: string; description?: string; href?: string }

/** Title, description, and link per kind — a teammate's name where one fits. */
const BUILDERS: Record<Notification["kind"], (rand: () => number) => Built> = {
  mention: (rand) => ({
    title: `${pick(rand, FIRST_NAMES)} mentioned you in a comment`,
    description: "Tap to see the full thread.",
    href: "/comments",
  }),
  assignment: (rand) => ({
    title: `${pick(rand, FIRST_NAMES)} assigned you a task`,
    description: "Added to your queue just now.",
    href: "/projects/tasks",
  }),
  billing: () => ({
    title: "Your invoice is ready",
    description: "This month's invoice has been generated.",
    href: "/settings/billing",
  }),
  system: () => ({
    title: "Scheduled maintenance completed",
    description: "All systems are back to normal.",
    href: "/status",
  }),
  security: () => ({
    title: "New sign-in from an unrecognized device",
    description: "Review your recent account activity.",
    href: "/settings/security",
  }),
}

function generate(): Notification[] {
  const rand = seeded("notifications")
  return Array.from({ length: COUNT }, (_, index) => {
    const kind = pickWeighted(rand, KIND_MIX)
    const at = dateBetween(rand, daysAgo(30), REFERENCE_DATE)
    const daysOld = (REFERENCE_DATE.getTime() - at.getTime()) / 86_400_000
    const built = BUILDERS[kind](rand)
    return {
      id: rowId("ntf", index + 1),
      title: built.title,
      description: built.description,
      kind,
      at,
      // Anything older than a couple of days has almost certainly been seen.
      read: daysOld > 2 ? chance(rand, 0.9) : chance(rand, 0.35),
      href: built.href,
    }
  })
}

/** The seeded notification inbox. The repository below owns the rows a demo mutates. */
export const NOTIFICATIONS: Notification[] = generate()

export const notifications = createRepository(NOTIFICATIONS, {
  searchKeys: ["title", "description"],
  filterKeys: ["kind", "read"],
})
lib/sample-data/orders.ts
import {
  REFERENCE_DATE,
  createRepository,
  dateBetween,
  daysAgo,
  daysAhead,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { CUSTOMERS } from "./customers"
import { PRODUCTS } from "./products"

/** One line of an order: a product, a quantity, and the price charged for it. */
export type OrderItem = { productId: string; qty: number; unitCents: number }

/** A purchase placed by a customer, fulfilled from the product catalog. */
export type Order = {
  id: string
  number: string
  /** A Customer id — "" for a walk-in the till sold to without a name. */
  customerId: string
  status: "pending" | "paid" | "fulfilled" | "refunded" | "cancelled"
  items: OrderItem[]
  /**
   * What the customer paid, in minor units: the sum of every item's
   * `qty * unitCents`, less `discountCents` and plus `taxCents` where a sale
   * carries them — a web order carries neither, a till sale both.
   */
  totalCents: number
  /** Taken off the goods by a discount code at the till, in minor units. */
  discountCents?: number
  /** Sales tax charged at the till, in minor units. */
  taxCents?: number
  currency: "USD"
  placedAt: Date
  fulfilledAt?: Date
  /**
   * How it was paid. The web store takes card, bank transfer and a digital
   * wallet; the till in the shop takes card, cash and a gift voucher.
   */
  paymentMethod: "card" | "bank" | "wallet" | "cash" | "voucher"
  country: string
  /** Where it was placed: the web store, or the till in the shop. */
  channel: "online" | "pos"
}

const COUNT = 400

const STATUS_MIX: readonly (readonly [Order["status"], number])[] = [
  ["pending", 0.08],
  ["paid", 0.15],
  ["fulfilled", 0.65],
  ["refunded", 0.05],
  ["cancelled", 0.07],
]

const PAYMENT_MIX: readonly (readonly [Order["paymentMethod"], number])[] = [
  ["card", 0.7],
  ["wallet", 0.2],
  ["bank", 0.1],
]

function generate(): Order[] {
  const rand = seeded("orders")

  return Array.from({ length: COUNT }, (_, index) => {
    const customer = pick(rand, CUSTOMERS)
    const itemCount = intBetween(rand, 1, 4)
    // Distinct products per order — a real cart does not list the same SKU twice.
    // Up to 2 of a line, not 3: with real anchor prices (averaging ~$94, up
    // from a department-wide random draw), keeping the old 1–3 range put the
    // average order past $500 — a business-order quantity, not a shopper's.
    // Tuning the quantity (never the price) back into a believable $120–$400
    // AOV is what this range does; it draws exactly as many times as before,
    // so which products, statuses, and dates land where does not shift.
    const items: OrderItem[] = shuffle(rand, PRODUCTS)
      .slice(0, itemCount)
      .map((product) => ({ productId: product.id, qty: intBetween(rand, 1, 2), unitCents: product.priceCents }))
    const totalCents = items.reduce((sum, item) => sum + item.qty * item.unitCents, 0)

    const status = pickWeighted(rand, STATUS_MIX)
    const placedAt = dateBetween(rand, daysAgo(180), REFERENCE_DATE)
    // Fulfilled within a week of placing it, but never later than "now".
    const fulfillBy = daysAhead(7, placedAt)
    const fulfillCap = fulfillBy.getTime() < REFERENCE_DATE.getTime() ? fulfillBy : REFERENCE_DATE

    return {
      id: rowId("ord", index + 1),
      number: `ORD-${100_000 + index + 1}`,
      customerId: customer.id,
      status,
      items,
      totalCents,
      currency: "USD" as const,
      placedAt,
      ...(status === "fulfilled" || status === "refunded"
        ? { fulfilledAt: dateBetween(rand, placedAt, fulfillCap) }
        : {}),
      paymentMethod: pickWeighted(rand, PAYMENT_MIX),
      // An order ships to the customer who placed it.
      country: customer.country,
      // The seeded book is the web store's; a sale rung up at the till is
      // written at runtime, marked "pos".
      channel: "online" as const,
    }
  })
}

/** The seeded order history. The repository below owns the rows a demo mutates. */
export const ORDERS: Order[] = generate()

export const orders = createRepository(ORDERS, {
  searchKeys: ["number", "customerId"],
  filterKeys: ["status", "paymentMethod", "country", "channel"],
})
lib/sample-data/payment-methods.ts
import { REFERENCE_DATE, createRepository, intBetween, pick, pickWeighted, rowId, seeded } from "./core"
import { CUSTOMERS } from "./customers"

/** A card on file for a paying customer. */
export type PaymentMethod = {
  id: string
  customerId: string
  brand: "visa" | "mastercard" | "amex"
  last4: string
  expMonth: number
  expYear: number
  default: boolean
}

const COUNT = 120

const BRAND_MIX: readonly (readonly [PaymentMethod["brand"], number])[] = [
  ["visa", 0.55],
  ["mastercard", 0.35],
  ["amex", 0.1],
]

function generate(): PaymentMethod[] {
  const rand = seeded("paymentMethods")
  // The free plan has nothing to bill, so it has nothing on file.
  const eligible = CUSTOMERS.filter((row) => row.plan !== "free").map((row) => row.id)
  const referenceYear = REFERENCE_DATE.getUTCFullYear()

  const seenCustomer = new Set<string>()
  return Array.from({ length: COUNT }, (_, index) => {
    const customerId = pick(rand, eligible)
    // The first card generated for a customer is their default; every later
    // one for the same customer is not.
    const isDefault = !seenCustomer.has(customerId)
    seenCustomer.add(customerId)

    return {
      id: rowId("pmt", index + 1),
      customerId,
      brand: pickWeighted(rand, BRAND_MIX),
      last4: String(intBetween(rand, 0, 9999)).padStart(4, "0"),
      expMonth: intBetween(rand, 1, 12),
      // A handful of cards are already expired — nobody has gotten around to updating them.
      expYear: intBetween(rand, referenceYear - 1, referenceYear + 5),
      default: isDefault,
    }
  })
}

/** The seeded wallet. The repository below owns the rows a demo mutates. */
export const PAYMENT_METHODS: PaymentMethod[] = generate()

export const paymentMethods = createRepository(PAYMENT_METHODS, {
  searchKeys: ["last4"],
  filterKeys: ["customerId", "brand", "default"],
})
lib/sample-data/plans.ts
import { createRepository, intBetween, rowId, seeded } from "./core"

/** A pricing tier: what a subscription's `planId` points at. */
export type Plan = {
  id: string
  name: string
  priceMonthlyCents: number
  priceYearlyCents: number
  seatsIncluded: number
  features: string[]
  popular?: boolean
}

// A real pricing page, not a random one — tiers, prices and feature lists are
// business decisions. `generate()` still runs it through `seeded("plans")` to
// derive the one number that *is* a range: the yearly discount.
const CATALOG: readonly {
  name: string
  priceMonthlyCents: number
  seatsIncluded: number
  features: string[]
  popular?: boolean
}[] = [
  {
    name: "Free",
    priceMonthlyCents: 0,
    seatsIncluded: 3,
    features: ["Up to 3 seats", "Community support", "1 GB storage"],
  },
  {
    name: "Starter",
    priceMonthlyCents: 1_900,
    seatsIncluded: 10,
    features: ["Up to 10 seats", "Email support", "10 GB storage", "Basic analytics"],
  },
  {
    name: "Team",
    priceMonthlyCents: 4_900,
    // 30, not 25: the sample workspace is on Team, and with every open
    // invitation holding a seat (seatUsage) it holds 25 — at 25 it would be
    // full at REFERENCE_DATE, with nobody left to invite.
    seatsIncluded: 30,
    features: ["Up to 30 seats", "Priority support", "100 GB storage", "Advanced analytics", "Custom roles"],
    popular: true,
  },
  {
    name: "Enterprise",
    priceMonthlyCents: 14_900,
    seatsIncluded: 100,
    features: [
      "Unlimited seats",
      "Dedicated support",
      "Unlimited storage",
      "Advanced analytics",
      "Custom roles",
      "SSO & SAML",
      "Audit logs",
    ],
  },
]

function generate(): Plan[] {
  const rand = seeded("plans")

  return CATALOG.map((plan, index) => {
    // An annual plan discounts 15-20% off twelve months, rounded to the dollar.
    const priceYearlyCents =
      plan.priceMonthlyCents === 0
        ? 0
        : Math.round((plan.priceMonthlyCents * 12 * (100 - intBetween(rand, 15, 20))) / 100 / 100) * 100

    return {
      id: rowId("pln", index + 1),
      name: plan.name,
      priceMonthlyCents: plan.priceMonthlyCents,
      priceYearlyCents,
      seatsIncluded: plan.seatsIncluded,
      features: plan.features,
      // Left unset rather than `false` on the other tiers, same as
      // `Member.avatarUrl` — it is a flag, not a tri-state.
      ...(plan.popular ? { popular: true as const } : {}),
    }
  })
}

/** The four pricing tiers. The repository below owns the rows a demo mutates. */
export const PLANS: Plan[] = generate()

export const plans = createRepository(PLANS, {
  searchKeys: ["name"],
})
lib/sample-data/post-templates.ts
/**
 * What the team feed says: the posts `posts.ts` is written from, one list per
 * kind of media, and the replies `comments.ts` answers them with. Every post
 * is a thing a product team would share with itself — a number, a link, a
 * question, a product — in plain sentences, with a topic tag or two so the
 * feed has something trending. No post carries a picture: a link is a card
 * with a company's mark, a product is its drawing.
 */
/** A link card: `mark` names one of logo-mark's companies, as a plain string the store needs no component for. */
export type LinkTemplate = { mark: string; domain: string; title: string; text: string }

export type PollTemplate = { text: string; options: readonly string[] }

/** A figure a teammate shares: where the series starts and ends, and the sentence that quotes its last point. */
export type StatTemplate = {
  label: string
  from: number
  to: number
  /** Decimal places the series is kept to. */
  places: number
  text: (last: string) => string
}

export const LINKS: readonly LinkTemplate[] = [
  {
    mark: "Lumen",
    domain: "lumen.example",
    title: "How Lumen moved its reporting onto event pipelines",
    text: "Lumen's write-up on moving their reporting onto event pipelines is the clearest account of backfills I have read. Worth ten minutes before the #analytics review.",
  },
  {
    mark: "Kestrel",
    domain: "kestrel.example",
    title: "Kestrel's notes on usage-based pricing",
    text: "Kestrel published their notes on moving to usage-based pricing, including what they got wrong in the first quarter. Relevant to the #pricing work.",
  },
  {
    mark: "Meridian",
    domain: "meridian.example",
    title: "What Meridian learned from 400 onboarding calls",
    text: "Meridian went through 400 onboarding calls and wrote down what actually made people stay. Most of it matches our #onboarding numbers.",
  },
  {
    mark: "Alder",
    domain: "alder.studio",
    title: "Designing tables people can scan",
    text: "Good piece from Alder on tables people can scan: right-aligned figures, fewer lines, one accent. Sharing it for the #design critique.",
  },
  {
    mark: "Tidewater",
    domain: "tidewater.example",
    title: "Tidewater's review of a four-hour outage",
    text: "Tidewater's review of their outage is a model of how to write one: a timeline, the decisions, no blame. Adding it to the #support reading list.",
  },
  {
    mark: "Copperline",
    domain: "copperline.example",
    title: "The case for fewer dashboards",
    text: "Copperline argues most teams need fewer dashboards and better defaults. I agree with about half of it; curious what the #analytics people think.",
  },
  {
    mark: "Solstice",
    domain: "solstice.example",
    title: "Hiring a first support engineer",
    text: "Solstice wrote about hiring their first support engineer and what the role looked like a year later. Useful for our #hiring plan.",
  },
  {
    mark: "Fieldstone",
    domain: "fieldstone.example",
    title: "A checklist for a quiet release day",
    text: "Fieldstone's release-day checklist is short and sensible. We already do most of it; the rollback drill is the part worth copying for the next #release.",
  },
  {
    mark: "Brightwater",
    domain: "brightwater.example",
    title: "Reports that run themselves: a Brightwater case study",
    text: "Brightwater wrote a case study about running their weekly reports on our product. Nice to read the #customers side of it in their own words.",
  },
  {
    mark: "Northgate",
    domain: "northgate.example",
    title: "Keeping an API stable for five years",
    text: "Northgate on keeping an API stable for five years: versioning, deprecation notices, and what they would never do again. Good background for the #api discussion.",
  },
]

export const POLLS: readonly PollTemplate[] = [
  { text: "Where should the next #offsite be? Voting closes on Friday.", options: ["Lisbon", "Copenhagen", "Dublin"] },
  { text: "Which day suits the fortnightly #design critique?", options: ["Tuesday", "Wednesday", "Thursday"] },
  { text: "Standup is moving. Which slot works for your mornings? #team", options: ["9:15", "9:30", "10:00"] },
  {
    text: "What should the next #release focus on?",
    options: ["Faster exports", "Saved views", "Phone layouts", "Audit log search"],
  },
  { text: "When should the #support rota hand over?", options: ["9:00", "13:00", "17:00"] },
]

export const STATS: readonly StatTemplate[] = [
  {
    label: "Weekly active workspaces",
    from: 1_180,
    to: 1_420,
    places: 0,
    text: (last) => `Weekly active workspaces ended the week at ${last}, the highest yet. #analytics`,
  },
  {
    label: "Median first response, minutes",
    from: 38,
    to: 14,
    places: 0,
    text: (last) => `Median first response is down to ${last} minutes. Thank you, support rota. #support`,
  },
  {
    label: "Signups per week",
    from: 236,
    to: 312,
    places: 0,
    text: (last) => `${last} signups last week. The new setup checklist is doing its job. #onboarding`,
  },
  {
    label: "Exports finished on time, %",
    from: 91,
    to: 99.2,
    places: 1,
    text: (last) => `${last}% of exports finished on time last week, the best run since we started counting. #release`,
  },
  {
    label: "Scheduled reports sent",
    from: 3_100,
    to: 5_450,
    places: 0,
    text: (last) => `${last} scheduled reports went out last week. Customers are setting them and forgetting them. #customers`,
  },
  {
    label: "Open support tickets",
    from: 84,
    to: 41,
    places: 0,
    text: (last) => `Open tickets are down to ${last}, half of where they were twelve weeks ago. #support`,
  },
]

/** What a product post says around the product's own name. */
export const PRODUCT_LINES: readonly ((name: string) => string)[] = [
  (name) => `${name} is back in stock and ships from Monday. #store`,
  (name) => `Best seller in the #store this week: the ${name}.`,
  (name) => `New in the #store: ${name}. The first batch goes out on Thursday.`,
  (name) => `The ${name} made it into the office refit, ordered through our own #store.`,
  (name) => `Heads up for the #store team: the ${name} is down to its last few units.`,
  (name) => `Customers keep asking about the ${name}. It is in the #store at the usual price.`,
  (name) => `Three orders for the ${name} came in from one office this morning. #store #customers`,
]

/** Posts with nothing attached; `name` is the teammate a post is about. Each opens on a topic tag of its own, which is how its replies are found. */
export const NOTES: readonly ((name: string) => string)[] = [
  () => "Shipped the new setup checklist to every workspace this morning. Thanks to everyone who tested it. #onboarding #release",
  () => "Reminder: the #design critique is in the big room at 2 PM. Bring one thing you would cut.",
  (name) => `Welcome ${name} to the support team. Say hello when you see them in the kitchen. #hiring`,
  () => "The seat-count job now runs in eleven minutes instead of forty-seven. Batching the audit writes did it. #release",
  () => "Fire drill on Thursday at 11 for anyone in the #office. It takes ten minutes.",
  () => "Wrote up the retention numbers. Short version: people who finish setup stay. The full doc is in the drive. #analytics",
  () => "Two customers asked for saved views this week alone. Adding it to the list for planning. #customers",
  () => "The incident review for Tuesday's export delay is published. No blame, three fixes, all merged. #support",
  () => "Offsite agenda is final. Wednesday is strategy, Thursday is building, Friday is off. #offsite",
  (name) => `Thank you ${name} for covering my #oncall shifts while I was away. Back on Monday.`,
  () => "Pairing on the single sign-on flow this afternoon if anyone wants to watch. #api",
]

/**
 * What people say under a post: by the media it carries, or — for a post
 * with none — by the first topic it names. `any` fits under anything, and
 * tops up a thread that outgrows its own list.
 */
export const REPLIES = {
  link: [
    "Good read. The part about backfills matches what we saw last quarter.",
    "Saving this for Thursday's review.",
    "Their point about defaults is the one I keep coming back to.",
    "Shared it with the platform team.",
    "Did they say how long the move took?",
    "Worth a slot in the next reading group.",
    "Bookmarked. The second half is the useful part.",
  ],
  poll: [
    "Voted. Anything but a Monday.",
    "Voted, and happy with whichever wins.",
    "Can we close it on Friday so people can plan?",
    "Mine is in.",
    "Does it go to a second round if it is close?",
    "Voted for the second one, but the first is fine too.",
    "Glad we are asking instead of guessing.",
  ],
  product: [
    "Ordered two for the design room.",
    "Is there a version in dark grey?",
    "We have one in the Rotterdam office and it has held up well.",
    "How long is delivery to Dublin?",
    "Putting this on the office refit list.",
    "The reviews on this one are good.",
    "Good to see it back.",
  ],
  stat: [
    "Is that including trial workspaces?",
    "Great trend. What changed in week six?",
    "Can we add this to the Monday dashboard?",
    "The dip in the middle was the holiday week.",
    "Would love the same chart split by plan.",
    "Let us see if it holds next week.",
    "Worth mentioning in the board update.",
  ],
  note: {
    "#onboarding": [
      "The checklist already saved me a support ticket.",
      "Can we see how many workspaces finish it after a week?",
      "Two customers mentioned it on calls today.",
    ],
    "#design": ["I will bring the pricing page.", "Can we keep it to an hour this time?", "See you there."],
    "#hiring": ["Welcome aboard.", "Great to have you on the team.", "Say hi if you need anything."],
    "#release": ["Great fix.", "No alerts overnight, for the record.", "That frees up the whole night window."],
    "#office": ["Thanks for the heads-up.", "Good to know, I will move my 11:00.", "Will the lifts be off during it?"],
    "#analytics": ["Which cohort moved the most?", "Can we share this with the sales team?", "The chart on page two says it all."],
    "#customers": ["Two of mine asked too.", "Adding my notes to the ticket.", "One of my accounts asked last month."],
    "#support": [
      "Thanks for keeping it blameless.",
      "Good write-up. The second fix is the one I would have missed.",
      "Clear and short. More of these, please.",
    ],
    "#offsite": ["Friday off is the best part of this agenda.", "Booked my train.", "Can we have the building day in one room?"],
    "#oncall": ["Welcome back.", "Any time.", "Quiet week, nothing to hand over."],
    "#api": ["I will drop by after three.", "Record it if you can.", "Which provider are we testing first?"],
  } as Record<string, readonly string[]>,
  any: ["Thanks for sharing.", "Great work, all.", "Noted, thanks.", "Good to know.", "Agreed.", "Thanks for writing this up."],
} as const
lib/sample-data/posts.ts

import { chance, createRepository, dateBetween, daysAgo, intBetween, pick, rowId, seeded, shuffle } from "./core"
import { avatarFor } from "./avatars"
import { MEMBERS } from "./members"
import { LINKS, NOTES, POLLS, PRODUCT_LINES, STATS } from "./post-templates"
import { PRODUCTS } from "./products"

/** Who wrote a post: a teammate, their face, and the handle the feed prints in mono. */
export type PostAuthor = { name: string; handle: string; avatarUrl: string }

export type PostPollOption = { label: string; votes: number }

/**
 * What a post carries besides its text — never a picture. A link is a card
 * with a company's mark — the name of one of logo-mark's companies, kept as a
 * plain string so the store depends on no component, and checked by the page
 * that draws it; a poll is its options and their votes, and which of
 * them the signed-in member picked (`null` until they vote, once); a product
 * is a row of `db.products`, drawn and priced from the catalogue when it is
 * shown; a stat is a small series a sparkline can draw, its last point the
 * figure the text quotes.
 */
export type PostMedia =
  | { kind: "link"; title: string; domain: string; mark: string }
  | { kind: "poll"; options: PostPollOption[]; voted: number | null }
  | { kind: "product"; productId: string }
  | { kind: "stat"; label: string; values: number[] }

/** One post on the team's feed. `liked` is whether the signed-in member likes it; `likes` counts them in. */
export type Post = {
  id: string
  author: PostAuthor
  at: Date
  text: string
  media?: PostMedia
  likes: number
  liked: boolean
  shares: number
}

const COUNT = 36

/** Exact counts, so every kind of media is on the feed and a third of the posts carry none. */
const MEDIA_COUNTS: readonly (readonly [PostMedia["kind"] | "none", number])[] = [
  ["link", 8],
  ["poll", 5],
  ["product", 7],
  ["stat", 6],
  ["none", 10],
]

/** Points in a stat's series: a quarter of weeks. */
const WEEKS = 12

/** `Sonia Keller` → `sonia.keller`: the handle a person is known by on the feed. */
export function handleFor(name: string): string {
  return name
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .toLowerCase()
    .replace(/[^a-z]+/g, ".")
    .replace(/^\.+|\.+$/g, "")
}

/**
 * A series from `from` to exactly `to`: the straight line between them with a
 * little noise on every point but the ends, so the figure the text quotes is
 * the one the sparkline finishes on.
 */
function series(rand: () => number, from: number, to: number, places: number): number[] {
  const scale = 10 ** places
  const wobble = Math.max(Math.abs(to - from) * 0.08, 1 / scale)
  return Array.from({ length: WEEKS }, (_, index) => {
    if (index === 0) return from
    if (index === WEEKS - 1) return to
    const line = from + ((to - from) * index) / (WEEKS - 1)
    const value = line + (rand() * 2 - 1) * wobble
    return Math.max(0, Math.round(value * scale) / scale)
  })
}

const number = new Intl.NumberFormat("en-US", { maximumFractionDigits: 1 })

function generate(): Post[] {
  const rand = seeded("posts")
  const me = MEMBERS[0]
  const active = MEMBERS.filter((member) => member.status === "active")
  const others = active.filter((member) => member.id !== me.id)
  const stock = PRODUCTS.filter((product) => product.status === "active")

  const kinds = shuffle(
    rand,
    MEDIA_COUNTS.flatMap(([kind, count]) => Array.from({ length: count }, () => kind))
  ).slice(0, COUNT)
  // Each list is walked in its own order, so no two posts say the same thing.
  const links = shuffle(rand, LINKS)
  const polls = shuffle(rand, POLLS)
  const stats = shuffle(rand, STATS)
  const lines = shuffle(rand, PRODUCT_LINES)
  const notes = shuffle(rand, NOTES)
  const products = shuffle(rand, stock)
  const walked = { link: 0, poll: 0, product: 0, stat: 0, none: 0 }
  // The signed-in member writes a handful; everyone else the rest.
  const mine = new Set(shuffle(rand, kinds.map((_, index) => index)).slice(0, 4))

  return kinds.map((kind, index): Post => {
    const author = mine.has(index) ? me : pick(rand, others)
    const n = walked[kind]++
    let text: string
    let media: PostMedia | undefined

    if (kind === "link") {
      const link = links[n % links.length]
      text = link.text
      media = { kind, title: link.title, domain: link.domain, mark: link.mark }
    } else if (kind === "poll") {
      const poll = polls[n % polls.length]
      const options = poll.options.map((label) => ({ label, votes: intBetween(rand, 1, 14) }))
      // The reader has answered two of the polls already; their vote is in the count.
      const voted = n < 2 ? intBetween(rand, 0, options.length - 1) : null
      if (voted !== null) options[voted].votes += 1
      text = poll.text
      media = { kind, options, voted }
    } else if (kind === "product") {
      const product = products[n % products.length]
      text = lines[n % lines.length](product.name)
      media = { kind, productId: product.id }
    } else if (kind === "stat") {
      const stat = stats[n % stats.length]
      const values = series(rand, stat.from, stat.to, stat.places)
      text = stat.text(number.format(values[values.length - 1]))
      media = { kind, label: stat.label, values }
    } else {
      const subject = pick(rand, others.filter((member) => member.id !== author.id))
      text = notes[n % notes.length](subject.name.split(" ")[0])
    }

    // Most of the feed is the last fortnight; the rest reaches back two months.
    const at = dateBetween(rand, daysAgo(chance(rand, 0.55) ? 14 : 60), daysAgo(1 / 24))
    const liked = chance(rand, 0.3)
    const likes = intBetween(rand, 0, 42) + (liked ? 1 : 0)
    return {
      id: rowId("pst", index + 1),
      author: { name: author.name, handle: handleFor(author.name), avatarUrl: avatarFor(author.name) },
      at,
      text,
      ...(media ? { media } : {}),
      likes,
      liked,
      shares: intBetween(rand, 0, 9),
    }
  })
}

/** The seeded feed. The repository below owns the posts the feed adds, likes and votes on. */
export const POSTS: Post[] = generate()

export const posts = createRepository(POSTS, {
  searchKeys: ["text"],
})
lib/sample-data/products.ts
import { createRepository, intBetween, pick, pickWeighted, rowId, seeded, shuffle } from "./core"

/** Something we sell or stock: the catalog row both orders and inventory read. */
export type Product = {
  id: string
  sku: string
  name: string
  category: string
  /** List price in minor units — cents, not dollars. */
  priceCents: number
  stock: number
  reorderPoint: number
  supplier: string
  status: "active" | "archived"
}

const COUNT = 60

// A hand-written catalog, not a word-combinator: real product names read
// oddly when built from an adjective/noun cross product ("Standing Mug").
const CATALOG: readonly (readonly [string, string])[] = [
  ["Wireless Mouse", "Electronics"],
  ["Mechanical Keyboard", "Electronics"],
  ["USB-C Hub", "Electronics"],
  ["27-inch Monitor", "Electronics"],
  ["Noise-Cancelling Headphones", "Electronics"],
  ["Webcam HD", "Electronics"],
  ["Portable SSD 1TB", "Electronics"],
  ["Bluetooth Speaker", "Electronics"],
  ["Smart Power Strip", "Electronics"],
  ["Laptop Stand", "Electronics"],
  ["Ergonomic Office Chair", "Office"],
  ["Standing Desk", "Office"],
  ["Desk Organizer", "Office"],
  ["Whiteboard 4x6", "Office"],
  ["Sticky Notes Pack", "Office"],
  ["Ballpoint Pen Set", "Office"],
  ["Paper Shredder", "Office"],
  ["Filing Cabinet", "Office"],
  ["Desk Lamp LED", "Office"],
  ["Cable Management Tray", "Office"],
  ["Bookshelf 5-Tier", "Furniture"],
  ["Accent Armchair", "Furniture"],
  ["Coffee Table", "Furniture"],
  ["Storage Ottoman", "Furniture"],
  ["Floating Wall Shelf", "Furniture"],
  ["TV Stand", "Furniture"],
  ["Bar Stool Set", "Furniture"],
  ["Console Table", "Furniture"],
  ["Room Divider Screen", "Furniture"],
  ["Corner Desk", "Furniture"],
  ["Merino Wool Sweater", "Apparel"],
  ["Rain Jacket", "Apparel"],
  ["Cotton T-Shirt Pack", "Apparel"],
  ["Running Shoes", "Apparel"],
  ["Wool Beanie", "Apparel"],
  ["Canvas Tote Bag", "Apparel"],
  ["Leather Belt", "Apparel"],
  ["Denim Jacket", "Apparel"],
  ["Fleece Vest", "Apparel"],
  ["Ankle Socks 6-Pack", "Apparel"],
  ["Ceramic Mug Set", "Home"],
  ["Cast Iron Skillet", "Home"],
  ["Bamboo Cutting Board", "Home"],
  ["Aroma Diffuser", "Home"],
  ["Cotton Bath Towel Set", "Home"],
  ["Non-Stick Baking Sheet", "Home"],
  ["Glass Food Containers", "Home"],
  ["Throw Blanket", "Home"],
  ["Stainless Steel Kettle", "Home"],
  ["Knife Block Set", "Home"],
  ["Camping Tent 4-Person", "Outdoor"],
  ["Insulated Water Bottle", "Outdoor"],
  ["Trekking Poles", "Outdoor"],
  ["Portable Grill", "Outdoor"],
  ["Sleeping Bag", "Outdoor"],
  ["Hiking Backpack 40L", "Outdoor"],
  ["Folding Camp Chair", "Outdoor"],
  ["Solar Lantern", "Outdoor"],
  ["Cooler 30-Quart", "Outdoor"],
  ["Hammock with Stand", "Outdoor"],
]

const CATEGORY_CODE: Record<string, string> = {
  Electronics: "ELEC",
  Office: "OFFC",
  Furniture: "FURN",
  Apparel: "APRL",
  Home: "HOME",
  Outdoor: "OUTD",
}

/**
 * Hand-set list prices, keyed by name rather than drawn from a per-category
 * range — a mid-market shopper reads a charm price ($18, $329.99), never
 * arbitrary cents ($229.82). Each department keeps one ending convention
 * across every row in it: whole dollars everywhere except Home, which prints
 * .99. `entities-a.test.ts` holds both invariants plus a five-row spot check.
 */
const PRICE_CENTS: Record<string, number> = {
  // Electronics — whole dollars.
  "Wireless Mouse": 2_900,
  "Mechanical Keyboard": 8_900,
  "USB-C Hub": 3_900,
  "27-inch Monitor": 32_900,
  "Noise-Cancelling Headphones": 17_900,
  "Webcam HD": 5_900,
  "Portable SSD 1TB": 9_900,
  "Bluetooth Speaker": 6_900,
  "Smart Power Strip": 3_900,
  "Laptop Stand": 3_400,

  // Office — whole dollars.
  "Ergonomic Office Chair": 17_900,
  "Standing Desk": 54_900,
  "Desk Organizer": 1_900,
  "Whiteboard 4x6": 7_900,
  "Sticky Notes Pack": 500,
  "Ballpoint Pen Set": 1_800,
  "Paper Shredder": 5_900,
  "Filing Cabinet": 11_900,
  "Desk Lamp LED": 2_900,
  "Cable Management Tray": 1_400,

  // Furniture — whole dollars.
  "Bookshelf 5-Tier": 14_900,
  "Accent Armchair": 32_900,
  "Coffee Table": 21_900,
  "Storage Ottoman": 8_900,
  "Floating Wall Shelf": 3_900,
  "TV Stand": 18_900,
  "Bar Stool Set": 15_900,
  "Console Table": 19_900,
  "Room Divider Screen": 12_900,
  "Corner Desk": 24_900,

  // Apparel — whole dollars.
  "Merino Wool Sweater": 8_900,
  "Rain Jacket": 9_900,
  "Cotton T-Shirt Pack": 2_900,
  "Running Shoes": 7_900,
  "Wool Beanie": 1_900,
  "Canvas Tote Bag": 2_400,
  "Leather Belt": 3_400,
  "Denim Jacket": 6_900,
  "Fleece Vest": 4_900,
  "Ankle Socks 6-Pack": 1_600,

  // Home — .99 endings.
  "Ceramic Mug Set": 2_499,
  "Cast Iron Skillet": 4_499,
  "Bamboo Cutting Board": 1_999,
  "Aroma Diffuser": 2_999,
  "Cotton Bath Towel Set": 3_999,
  "Non-Stick Baking Sheet": 1_799,
  "Glass Food Containers": 2_799,
  "Throw Blanket": 3_499,
  "Stainless Steel Kettle": 2_999,
  "Knife Block Set": 5_999,

  // Outdoor — whole dollars.
  "Camping Tent 4-Person": 27_900,
  "Insulated Water Bottle": 2_700,
  "Trekking Poles": 5_900,
  "Portable Grill": 11_900,
  "Sleeping Bag": 8_900,
  "Hiking Backpack 40L": 14_900,
  "Folding Camp Chair": 3_900,
  "Solar Lantern": 1_900,
  "Cooler 30-Quart": 9_900,
  "Hammock with Stand": 11_900,
}

const SUPPLIERS = [
  "Northstar Distribution",
  "Harborline Wholesale",
  "Crestpoint Supply Co.",
  "Meridian Trading",
  "Ashgrove Imports",
  "Blackfen Logistics",
  "Silverline Vendors",
  "Cobalt Sourcing",
  "Fernwood Supply",
  "Ridgeback Distribution",
  "Amberlane Goods",
  "Thistledown Trading",
]

const STATUS_MIX: readonly (readonly [Product["status"], number])[] = [
  ["active", 0.88],
  ["archived", 0.12],
]

function generate(): Product[] {
  const rand = seeded("products")
  // The catalog is already distinct; shuffle only so id order does not
  // mirror the category grouping it was written in.
  const catalog = shuffle(rand, CATALOG).slice(0, COUNT)

  return catalog.map(([name, category], index) => {
    const status = pickWeighted(rand, STATUS_MIX)
    // A random price used to draw here. The draw stays, discarded, so every
    // field below that reads the generator next — stock, reorderPoint,
    // supplier, and the next row's status — keeps the value it had before the
    // random price became a hand-set anchor.
    rand()

    return {
      id: rowId("prd", index + 1),
      sku: `${CATEGORY_CODE[category]}-${String(index + 1).padStart(4, "0")}`,
      name,
      category,
      priceCents: PRICE_CENTS[name],
      // An archived product is no longer stocked, whatever it held before.
      stock: status === "archived" ? 0 : intBetween(rand, 0, 400),
      reorderPoint: intBetween(rand, 10, 60),
      supplier: pick(rand, SUPPLIERS),
      status,
    }
  })
}

/** The seeded catalog. The repository below owns the rows a demo mutates. */
export const PRODUCTS: Product[] = generate()

export const products = createRepository(PRODUCTS, {
  searchKeys: ["name", "sku", "supplier"],
  filterKeys: ["category", "status", "supplier"],
})
lib/sample-data/projects.ts
import { REFERENCE_DATE, createRepository, daysAgo, daysAhead, intBetween, pick, pickWeighted, rowId, seeded } from "./core"
import { MEMBERS } from "./members"

/** A named piece of work with a lead, a window and a share of it done. */
export type Project = {
  id: string
  name: string
  /** The three-letter code a task's title is prefixed with on a board. */
  code: string
  status: "planning" | "active" | "at_risk" | "shipped"
  /** A Member id: whoever runs it. */
  lead: string
  /** Midnight UTC on the day the work opened. */
  startAt: Date
  /** Midnight UTC on the day it is due to land. */
  endAt: Date
  /** Share of the work finished, 0..100. */
  progress: number
}

const NAMES: readonly (readonly [string, string])[] = [
  ["Atlas migration", "ATL"],
  ["Billing rewrite", "BIL"],
  ["Checkout redesign", "CHK"],
  ["Data warehouse", "DWH"],
  ["Edge caching", "EDG"],
  ["Fraud scoring", "FRD"],
  ["Guest onboarding", "GST"],
  ["Insights API", "INS"],
  ["Mobile parity", "MOB"],
  ["Partner portal", "PTL"],
  ["Search relevance", "SRC"],
  ["Trust centre", "TRS"],
]

const STATUS_MIX: readonly (readonly [Project["status"], number])[] = [
  ["active", 0.45],
  ["at_risk", 0.2],
  ["planning", 0.15],
  ["shipped", 0.2],
]

/** Midnight UTC on the day `date` falls on — a project's window is whole days. */
function utcDay(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}

/**
 * How far along a project reads, given what it is: a shipped one is finished,
 * a planned one has barely started, and the two in flight are somewhere in
 * between — with the one at risk behind where the calendar says it should be.
 */
function progressFor(rand: () => number, status: Project["status"]): number {
  if (status === "shipped") return 100
  if (status === "planning") return intBetween(rand, 0, 10)
  if (status === "at_risk") return intBetween(rand, 15, 55)
  return intBetween(rand, 30, 85)
}

function generate(): Project[] {
  const rand = seeded("projects")
  const leads = MEMBERS.filter((member) => member.status === "active" && member.role !== "viewer").map(
    (member) => member.id
  )

  return NAMES.map(([name, code], index) => {
    const status = pickWeighted(rand, STATUS_MIX)
    // Every project opened at least forty days ago, so a shipped one — which
    // landed inside the last month — always closes after it opened.
    const startAt = utcDay(daysAgo(intBetween(rand, 40, 200)))
    const endAt = utcDay(
      status === "shipped"
        ? daysAgo(intBetween(rand, 2, 30))
        : // Everything still running runs out ahead of the reference date,
          // which is what puts a bar across "today" on a timeline.
          daysAhead(intBetween(rand, 20, 150), REFERENCE_DATE)
    )

    return {
      id: rowId("prj", index + 1),
      name,
      code,
      status,
      lead: pick(rand, leads),
      startAt,
      endAt,
      progress: progressFor(rand, status),
    }
  })
}

/** The seeded project list. The repository below owns the rows a demo mutates. */
export const PROJECTS: Project[] = generate()

export const projects = createRepository(PROJECTS, {
  searchKeys: ["name", "code"],
  filterKeys: ["status", "lead"],
})
lib/sample-data/releases.ts
import { createRepository, daysAgo, intBetween, rowId, seeded } from "./core"

/** The four kinds of line a release note carries. */
export type ReleaseTag = "new" | "improved" | "fixed" | "removed"

/** One line of a release: what changed, and which kind of change it was. */
export type ReleaseEntry = { tag: ReleaseTag; text: string }

/** A shipped version of the product. */
export type Release = {
  id: string
  /** Semver, unique, newest first in the array. */
  version: string
  date: Date
  title: string
  summary: string
  /** Every tag its entries use, in the order the tags are declared. */
  tags: ReleaseTag[]
  entries: ReleaseEntry[]
  channel: "stable" | "beta"
}

const TAG_ORDER: readonly ReleaseTag[] = ["new", "improved", "fixed", "removed"]

// Written out rather than generated: a release note is prose a reader reads
// line by line, and an assembled sentence reads like one. The generator below
// only dates them and derives each release's tag set from its own entries.
const NOTES: readonly {
  version: string
  title: string
  summary: string
  channel: Release["channel"]
  entries: ReleaseEntry[]
}[] = [
  {
    version: "4.3.0",
    title: "Passkeys, and a faster orders table",
    summary: "Sign in without a password, and page through a hundred thousand orders without waiting.",
    channel: "stable",
    entries: [
      { tag: "new", text: "Passkeys can now be registered from Settings → Security, one per device." },
      { tag: "improved", text: "The orders table pages on the server, so a large workspace opens in under a second." },
      { tag: "fixed", text: "A saved filter no longer survives a change of workspace." },
    ],
  },
  {
    version: "4.2.1",
    title: "Invoice rounding",
    summary: "A rounding fix for prorated lines, and two smaller corrections.",
    channel: "stable",
    entries: [
      { tag: "fixed", text: "Prorated lines round to the currency's minor unit instead of to the cent." },
      { tag: "fixed", text: "The billing period shown on an invoice honours the workspace timezone." },
    ],
  },
  {
    version: "4.2.0",
    title: "Scheduled reports",
    summary: "Have a saved view delivered by email, daily, weekly or monthly.",
    channel: "stable",
    entries: [
      { tag: "new", text: "Schedule any saved view as a recurring CSV report." },
      { tag: "new", text: "Recipients outside the workspace can be added to a schedule." },
      { tag: "improved", text: "Exports now write dates in ISO 8601, always in UTC." },
    ],
  },
  {
    version: "4.1.0",
    title: "Webhook delivery log",
    summary: "Every attempt, its status code and its response body, kept for seven days.",
    channel: "stable",
    entries: [
      { tag: "new", text: "A delivery log for every webhook endpoint, with replay." },
      { tag: "improved", text: "Failed deliveries retry with exponential backoff for 24 hours." },
      { tag: "fixed", text: "Signature headers are computed over the raw body, not the parsed one." },
    ],
  },
  {
    version: "4.0.0",
    title: "The new shell",
    summary: "A rebuilt navigation, a command palette, and one breaking change to the API.",
    channel: "stable",
    entries: [
      { tag: "new", text: "A command palette on ⌘K reaches every page and every recent record." },
      { tag: "improved", text: "The sidebar collapses to icons and remembers the choice per device." },
      { tag: "removed", text: "The v1 REST endpoints are gone; v2 has been the default since 3.4." },
    ],
  },
  {
    version: "3.9.2",
    title: "Import stability",
    summary: "Large imports no longer time out at the mapping step.",
    channel: "stable",
    entries: [
      { tag: "fixed", text: "A CSV over 20 MB is parsed in chunks rather than in one pass." },
      { tag: "fixed", text: "Quoted fields containing newlines are read as one value." },
    ],
  },
  {
    version: "3.9.0",
    title: "Warehouse sync",
    summary: "Nightly incremental loads into your data warehouse.",
    channel: "stable",
    entries: [
      { tag: "new", text: "Warehouse sync writes one table per entity plus a run manifest." },
      { tag: "new", text: "A first run loads everything; every run after it is incremental." },
      { tag: "improved", text: "Sync failures leave the previous tables untouched." },
    ],
  },
  {
    version: "3.8.0",
    title: "Roles you can shape",
    summary: "Custom roles with per-resource permissions, and an audit entry for every change.",
    channel: "stable",
    entries: [
      { tag: "new", text: "Custom roles can be built from read, write, delete and admin per resource." },
      { tag: "improved", text: "The audit log records the fields a role change actually moved." },
      { tag: "fixed", text: "A member demoted mid-session loses access on their next request." },
    ],
  },
  {
    version: "3.7.1",
    title: "Chart legends",
    summary: "Legends stay readable at narrow widths.",
    channel: "beta",
    entries: [
      { tag: "improved", text: "Series names wrap instead of being clipped under 480px." },
      { tag: "fixed", text: "A single-series chart no longer draws an empty legend row." },
    ],
  },
  {
    version: "3.7.0",
    title: "Fulfillment stages",
    summary: "Track a parcel from picking to delivered, with exceptions raised where they happen.",
    channel: "stable",
    entries: [
      { tag: "new", text: "Shipments carry four stages, each stamped as it is reached." },
      { tag: "new", text: "An exception on a parcel is shown on the order it belongs to." },
      { tag: "improved", text: "Carrier performance is broken out by service level." },
    ],
  },
  {
    version: "3.6.0",
    title: "Single sign-on",
    summary: "SAML and OIDC, with a break-glass login for the owner.",
    channel: "stable",
    entries: [
      { tag: "new", text: "SAML and OIDC providers can be configured per workspace." },
      { tag: "new", text: "Enforcement can be tested on one account before it is turned on for everyone." },
      { tag: "fixed", text: "An invitation accepted through SSO keeps the role it was sent with." },
    ],
  },
  {
    version: "3.5.0",
    title: "Two-factor authentication",
    summary: "Time-based codes, recovery codes, and a setup flow that fits on a phone.",
    channel: "stable",
    entries: [
      { tag: "new", text: "Time-based one-time codes from any authenticator app." },
      { tag: "new", text: "Ten single-use recovery codes, downloadable once." },
      { tag: "improved", text: "The setup secret can be typed by hand where a camera is not available." },
    ],
  },
  {
    version: "3.4.1",
    title: "Timezone corrections",
    summary: "Two places that read the server's clock instead of the workspace's.",
    channel: "stable",
    entries: [
      { tag: "fixed", text: "Daily aggregates bucket on the workspace timezone, not the server's." },
      { tag: "fixed", text: "The activity feed groups by the reader's own day boundaries." },
    ],
  },
  {
    version: "3.4.0",
    title: "API v2",
    summary: "Cursor pagination, sparse fieldsets, and a deprecation clock on v1.",
    channel: "stable",
    entries: [
      { tag: "new", text: "v2 endpoints page by cursor and accept sparse fieldsets." },
      { tag: "improved", text: "Rate limits are reported on every response, not only on a 429." },
      { tag: "removed", text: "v1 is deprecated and will be removed in the next major version." },
    ],
  },
]

function generate(): Release[] {
  const rand = seeded("releases")
  // Newest first: the most recent shipped a few days ago, and each older one
  // steps further back, so the array is already in the order a changelog reads.
  let daysBack = intBetween(rand, 3, 9)

  return NOTES.map((note, index) => {
    if (index > 0) daysBack += intBetween(rand, 12, 40)

    return {
      id: rowId("rel", index + 1),
      version: note.version,
      // Whole days, so a release is dated rather than timed.
      date: new Date(Date.UTC(...dayParts(daysAgo(daysBack)))),
      title: note.title,
      summary: note.summary,
      tags: TAG_ORDER.filter((tag) => note.entries.some((entry) => entry.tag === tag)),
      entries: note.entries,
      channel: note.channel,
    }
  })
}

/** A date's UTC year, month and day, for a midnight-UTC rebuild of it. */
function dayParts(at: Date): [number, number, number] {
  return [at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate()]
}

/** The seeded release history, newest first. The repository owns the live rows. */
export const RELEASES: Release[] = generate()

export const releases = createRepository(RELEASES, {
  searchKeys: ["version", "title", "summary"],
  filterKeys: ["channel"],
})
lib/sample-data/review-templates.ts
/**
 * What buyers write: the titles and sentences `reviews.ts` composes every
 * review from, one pool per department, so a review of a kettle talks about
 * a kettle and a review of a tent about a wet night. A line that only fits
 * some products says which with `only` (or `not`), a pattern over the
 * product's name — "fits true to size" is never said of a tote bag. A line
 * that makes a claim says what it is `about` ("size", "colour", "weight",
 * "bulk"), and a review makes one claim about each, its title included, so
 * "fits true to size" never meets "runs a half size small". Plain sentences:
 * no exclamation marks, no emoji, no brand but the store's.
 *
 * Tokens: `{item}` is the product named plainly ("wireless mouse");
 * `{It}`/`{they}`/`{them}` are its pronouns and `{has}` and `{s}` its verbs,
 * so "{It} {has} held up" reads "They have held up" of a pair of shoes.
 */

/** A line; or one kept to the products `only` matches, or away from those `not` matches, and what it makes a claim `about`. */
export type ReviewLine = string | { text: string; only?: RegExp; not?: RegExp; about?: string }

export type ReviewPool = {
  /** Titles for a four or a five, a three, and a one or a two. */
  titles: { high: readonly ReviewLine[]; mid: readonly ReviewLine[]; low: readonly ReviewLine[] }
  /** What a pleased buyer says. */
  praise: readonly ReviewLine[]
  /** What any buyer notices: sizes, set-up, materials, packaging. */
  detail: readonly ReviewLine[]
  /** The small complaints a satisfied buyer still mentions. */
  gripe: readonly ReviewLine[]
  /** What went wrong for a buyer who was not satisfied. */
  fault: readonly ReviewLine[]
}

const WIRELESS = /mouse|keyboard|headphones|speaker/i
const BUILT = /chair|standing desk|cabinet|whiteboard|shredder/i
const SUPPLIES = /pen|notes/i
const KITCHEN = /mug|skillet|board|baking|containers|kettle|knife/i
const TEXTILE = /towel|blanket/i
const WORN = /bag|belt|shoes/i

export const REVIEW_POOLS: Record<string, ReviewPool> = {
  Electronics: {
    titles: {
      high: ["Works with everything on my desk", "Set up in five minutes", "Solid build for the price", "Better than the one it replaced", "Does exactly what it says", "A month in and no complaints", "Quietly excellent", "Worth paying a little more"],
      mid: ["Good, with one catch", "Does the job", "Fine once it is set up", "Decent, not remarkable", "Mixed feelings"],
      low: ["Stopped working in week two", "Not what the listing suggests", "Could not rely on it", "Went back the same week", "Too flimsy for daily use"],
    },
    praise: [
      { text: "Plugged the {item} in and {they} worked straight away, with no drivers to hunt for.", not: /stand|headphones|speaker/i },
      "In use eight hours a day and {they} {has} not missed a beat.",
      "Feels far better made than the price suggests.",
      "A clear step up from the cheaper one I had before.",
      { text: "Paired with my laptop and my phone without any fuss.", only: WIRELESS },
      { text: "The picture is sharp and the colours look true.", only: /monitor|webcam/i },
      { text: "Sound is full and clear, even at low volume.", only: /headphones|speaker/i },
      { text: "Raised my screen to eye level, and my neck has thanked me since.", only: /stand/i },
      "Bought a second {item} for the office after a week with the first.",
    ],
    detail: [
      { text: "The cable is long enough to reach from the back of the desk.", not: /stand/i },
      { text: "Took one firmware update out of the box, which ran on its own.", only: /mouse|keyboard|headphones|speaker|webcam|monitor/i },
      "The finish picks up fingerprints, but a cloth sorts that out.",
      "Packed in recycled card with no plastic, which I appreciated.",
      "Slightly heavier than I expected, which keeps {them} from sliding around.",
      "The manual is short but answered the questions I had.",
    ],
    gripe: [
      { text: "The status light is brighter than it needs to be at night.", not: /stand/i },
      { text: "Pairing a second device is fiddlier than it should be.", only: WIRELESS },
      { text: "Runs a little warm after a few hours of use.", not: /stand/i },
      "The instructions skip a step that took me a while to work out.",
      "Only one colour on offer, and it shows dust.",
    ],
    fault: [
      { text: "Kept dropping the connection after nine days.", only: /mouse|keyboard|headphones|speaker|hub|webcam/i },
      { text: "One of the ports was dead on arrival.", only: /hub|strip|monitor|ssd/i },
      { text: "Made a faint buzzing noise from the first day.", not: /stand|mouse/i },
      { text: "Stopped powering on after the second week.", not: /stand/i },
      { text: "Wobbled under the weight of my laptop from day one.", only: /stand/i },
      "Nothing like the build quality the listing describes.",
    ],
  },
  Office: {
    titles: {
      high: ["Made my desk a better place to work", "Tidy, sturdy and simple", "Exactly what the office needed", { text: "Assembly was painless", only: BUILT }, "Holds up to daily use", "Good value for a team order", "Would order again"],
      mid: ["Does the job, just", "Fine for the price", "Good but not for everyone", "Almost there", "Took some getting used to"],
      low: ["Arrived damaged", "Not built for daily use", { text: "Parts missing from the box", not: /notes/i }, "Sent it back", "Disappointing quality"],
    },
    praise: [
      "Ordered the {item} for our office and the team noticed the difference within a week.",
      { text: "Assembly took twenty minutes with the tools in the box.", only: BUILT },
      { text: "Sturdy enough that I stopped thinking about {them} after the first day.", not: SUPPLIES },
      { text: "My desk has never been this tidy.", only: /organizer|tray|cabinet/i },
      { text: "Looks smarter than the price suggests next to our older furniture.", only: /chair|standing desk|cabinet|lamp|whiteboard/i },
      "Holds up to a whole team using {them} every day.",
      { text: "Writes smoothly and never skips.", only: /pen/i },
      { text: "The notes stay put on a monitor for days.", only: /notes/i },
      { text: "Adjusts in seconds, and the settings stay where I leave them.", only: /chair|standing desk|lamp/i },
      "Good value next to what our old office supplier charged.",
    ],
    detail: [
      { text: "The finish is matte, so it does not show smudges.", not: SUPPLIES },
      { text: "All the screws were labelled and bagged by step.", only: /chair|standing desk|cabinet|whiteboard/i },
      { text: "Heavier than it looks, which helps it stay put.", not: SUPPLIES },
      "The colour is a shade darker than the photos.",
      { text: "Fits under a standard desk with room to spare.", only: /cabinet|shredder|tray/i },
      { text: "Came in one box that one person can carry.", not: SUPPLIES },
      { text: "The pack is bigger than I expected, so it will last us a while.", only: SUPPLIES },
    ],
    gripe: [
      { text: "One of the bolts needed a second tighten after a week.", only: /chair|standing desk|cabinet|whiteboard/i },
      { text: "The edges could be finished more smoothly.", not: SUPPLIES },
      { text: "The instructions are drawings only, which slowed me down.", only: BUILT },
      { text: "A little louder in use than I expected.", only: /shredder|standing desk/i },
      "The packaging used more plastic than it needed.",
      "Would have liked a second colour to choose from.",
    ],
    fault: [
      { text: "Arrived with a cracked corner and a dent in the side.", not: SUPPLIES },
      { text: "Two of the holes did not line up, so it never sat level.", only: /chair|standing desk|cabinet|whiteboard/i },
      { text: "The mechanism jammed within the first month.", only: /chair|standing desk|shredder|cabinet|lamp/i },
      { text: "Ran dry far sooner than any I have bought before.", only: /pen/i },
      { text: "The adhesive gave up within an hour.", only: /notes/i },
      { text: "Missing parts, and the replacements took two weeks to arrive.", not: /notes/i },
      "Not worth the price once it was in use.",
    ],
  },
  Furniture: {
    titles: {
      high: ["Looks better than the pictures", "Solid feel at a fair price", "The room finally feels finished", "Easy to build, easy to like", "Sturdy and good looking", "Fits the space perfectly", "Better than expected"],
      mid: ["Nice, but check the size", "Good once assembled", "Fine for the money", "Pretty but not perfect", "Took two of us to build"],
      low: ["Damaged in delivery", "Not the colour shown", "Wobbly and cheap", "Did not fit together", "Returned after a week"],
    },
    praise: [
      "The {item} looks better in our home than it did in the photos.",
      "Solid, heavy and not a wobble anywhere.",
      "Two of us built it in under an hour.",
      "The finish matches our oak floor almost exactly.",
      "Guests keep asking where we found the {item}.",
      "Feels like it will last for years, not months.",
      "Just the right size for the space we had.",
    ],
    detail: [
      "Measure your doorway first; the box is large.",
      { text: "The legs come with felt pads, so the floor is safe.", only: /table|armchair|stool|ottoman|desk|stand/i },
      "The wood grain varies a little from piece to piece.",
      "Needed two people for the last step of assembly.",
      { text: "Wall fixings are included, but check they suit your walls.", only: /shelf/i },
      "The colour reads warmer in daylight than under lamps.",
    ],
    gripe: [
      "A couple of the screw caps do not quite sit flush.",
      { text: "The back panel is thinner than the rest of the piece.", only: /bookshelf|stand|console|desk/i },
      "The assembly instructions were printed very small.",
      "Took nearly two weeks to arrive.",
      "Scratches more easily than I would like.",
    ],
    fault: [
      "Arrived with a split along one panel.",
      "Leans to one side however it is assembled.",
      "The colour was nothing like the listing, far more orange.",
      "Two of the pre-drilled holes were in the wrong place.",
    ],
  },
  Apparel: {
    titles: {
      high: [{ text: "Fits true to size", not: /bag/i, about: "size" }, "My new everyday favourite", { text: "Soft, well made and fits well", not: WORN }, { text: "Held up after many washes", not: WORN }, { text: "Comfortable from the first wear", not: /bag/i }, "Great quality for the price", "Bought a second one", { text: "Carries everything I need", only: /bag/i }],
      mid: [{ text: "Runs a little small", not: /bag/i, about: "size" }, { text: "Nice fabric, odd fit", not: WORN, about: "size" }, "Good, not great", { text: "Comfortable but shrank a bit", not: WORN }, "Fine for the price"],
      low: [{ text: "Fell apart after two washes", not: WORN }, { text: "Nothing like the size chart", not: /bag/i, about: "size" }, "Colour faded fast", "Returned for a refund", { text: "Uncomfortable from day one", not: /bag/i }, { text: "Came apart within a month", only: WORN }],
    },
    praise: [
      { text: "The {item} fit{s} true to size, so order your usual.", not: /bag/i, about: "size" },
      { text: "Soft from the first wear and still soft after a dozen washes.", not: WORN },
      { text: "Warm enough for cold mornings without feeling heavy.", only: /sweater|jacket|beanie|vest/i, about: "weight" },
      "The stitching is neat and nothing has come loose.",
      { text: "I have worn the {item} almost every day since {they} arrived.", not: /bag/i },
      { text: "I have carried the {item} every day since {they} arrived.", only: /bag/i },
      { text: "The colour is exactly what the photos show.", about: "colour" },
      { text: "Comfortable enough that I forget I have {them} on.", not: /bag/i },
      { text: "Holds a laptop and a day's shopping without straining.", only: /bag/i },
    ],
    detail: [
      { text: "I am usually between sizes and the larger one was right.", not: /bag|belt/i, about: "size" },
      { text: "Wash cold and hang to dry; mine has kept its shape.", not: WORN },
      { text: "The fabric is a little heavier than I expected.", not: /belt|shoes/i, about: "weight" },
      { text: "Came folded in paper rather than plastic.", not: /belt|shoes/i },
      { text: "The colour is slightly darker in person.", about: "colour" },
      { text: "Came in a recycled box with no plastic.", only: /belt|shoes/i },
      { text: "The leather softened nicely after a week of wear.", only: /belt/i },
      { text: "The sole grips well on wet pavement.", only: /shoes/i },
    ],
    gripe: [
      { text: "Shrank a touch on the first wash.", not: WORN },
      { text: "The label scratches until you cut it out.", not: WORN },
      { text: "Pilling started after a month of wear.", not: WORN },
      { text: "Runs a half size small.", only: /shoes|socks|jacket|sweater|vest/i, about: "size" },
      { text: "The handles dig in when it is full.", only: /bag/i },
      { text: "The holes are spaced a little far apart.", only: /belt/i },
      { text: "The laces are shorter than they should be.", only: /shoes/i },
      "Took longer to arrive than the estimate said.",
    ],
    fault: [
      { text: "A seam came apart after the second wash.", not: WORN },
      { text: "The size chart was off by a full size.", not: /bag/i, about: "size" },
      { text: "The colour ran into the rest of my laundry.", not: WORN },
      { text: "Felt scratchy and uncomfortable from the first wear.", not: WORN },
      "Faded noticeably within a month.",
      { text: "A strap tore away from the seam within a month.", only: /bag/i },
      { text: "The buckle came loose within a month.", only: /belt/i },
      { text: "The sole started to peel away after a few runs.", only: /shoes/i },
    ],
  },
  Home: {
    titles: {
      high: ["Used every day since it arrived", "Well made and good looking", "Better than the expensive one", "Lovely quality", "Exactly what I hoped for", "Great gift", "Worth every cent"],
      mid: ["Nice, but needs care", "Good with a small flaw", "Fine for everyday", "Pretty but impractical", "Almost perfect"],
      low: ["Damaged on arrival", "Not as pictured", "Wore out quickly", "Went straight back", "Poor quality"],
    },
    praise: [
      "We use the {item} every day and {they} still look{s} new.",
      { text: "Heavier and better made than the ones we had before.", about: "weight" },
      { text: "Looks good on the counter, so it never goes back in the cupboard.", only: KITCHEN },
      { text: "Heats evenly with no hot spots.", only: /skillet|kettle|baking/i },
      { text: "Soft, thick and warm from the first use.", only: TEXTILE },
      { text: "Fills the room with a gentle scent within minutes.", only: /diffuser/i },
      "Bought the {item} as a gift and liked it so much I ordered another for us.",
      "Good value next to what the department stores charge.",
    ],
    detail: [
      { text: "Hand wash is best, even where the box says dishwasher safe.", only: KITCHEN },
      "Arrived double boxed with every piece wrapped.",
      "Slightly smaller than I pictured, so check the measurements.",
      "The colour is a touch warmer than the photos.",
      "Came with a short care card that is worth keeping.",
    ],
    gripe: [
      { text: "The handle gets warm faster than I would like.", only: /skillet|kettle/i },
      { text: "Sheds a little for the first few washes.", only: TEXTILE },
      "Marks more easily than I expected.",
      { text: "A little lighter than it looks in the pictures.", about: "weight" },
      "Wish it came in more colours.",
    ],
    fault: [
      { text: "One piece arrived chipped.", only: /mug|containers/i },
      { text: "The coating started flaking within a month.", only: /baking|skillet/i },
      { text: "Frayed along one edge after the first wash.", only: TEXTILE },
      { text: "Stopped working after three weeks.", only: /diffuser|kettle/i },
      { text: "Warped the first time we used it.", only: /board|baking/i },
      "Nothing like the quality shown in the photos.",
      "Showed wear within a few weeks of normal use.",
    ],
  },
  Outdoor: {
    titles: {
      high: ["Ready for the next trip", "Survived a wet weekend", { text: "Light, tough and simple", about: "weight" }, "Great for car camping", "Held up on a week-long hike", "Worth packing", "Better than my old kit"],
      mid: ["Good for fair weather", "Fine for short trips", { text: "Heavier than expected", about: "weight" }, "Decent value", "Works, with caveats"],
      low: ["Failed on the first trip", "Broke on the second outing", "Not trail ready", "Returned after one outing", "Poorly made"],
    },
    praise: [
      "Took the {item} on a four-day trip and {they} held up to everything.",
      { text: "Packs down smaller than I expected.", only: /tent|sleeping|chair|hammock|poles/i, about: "bulk" },
      { text: "Survived two nights of steady rain without a problem.", only: /tent|backpack|sleeping|hammock/i },
      { text: "Keeps drinks cold through a full day in the sun.", only: /bottle|cooler/i },
      { text: "Set up in ten minutes on my own.", only: /tent|hammock|grill|chair/i },
      { text: "Light enough that I stopped noticing {them} on the trail.", only: /poles|backpack|bottle|lantern|sleeping/i, about: "weight" },
      "Well made, with stitching and fittings that look built to last.",
      "Worth every cent for the weekends we spend outside.",
    ],
    detail: [
      { text: "Practise the set-up at home before your first trip.", only: /tent|hammock|grill/i },
      { text: "The carry bag is snug, so fold carefully.", only: /tent|sleeping|hammock|chair|poles/i },
      { text: "Heavier than the listing suggests, but sturdy.", about: "weight" },
      "The colour is brighter in person.",
      "Rinse and dry {them} fully before storing.",
    ],
    gripe: [
      { text: "The zips catch now and then.", only: /tent|backpack|sleeping/i },
      { text: "Takes two people to pack away neatly.", only: /tent|hammock/i },
      { text: "A bit bulky for backpacking.", about: "bulk" },
      { text: "The lid is stiff to open with cold hands.", only: /bottle|cooler/i },
      { text: "The instructions were not much help.", not: /bottle|poles/i },
      "Scuffs show up easily on the finish.",
    ],
    fault: [
      { text: "Leaked at the seams on the first wet night.", only: /tent|backpack|sleeping/i },
      { text: "Leaked into my bag on the first day out.", only: /bottle|cooler/i },
      { text: "A joint snapped on the second outing.", only: /poles|chair|hammock|grill|tent/i },
      { text: "Stopped holding a charge after a week.", only: /lantern/i },
      "A clasp broke off during the first trip.",
      { text: "Far heavier than advertised, so it stayed home.", about: "weight" },
    ],
  },
}

/** What a buyer who sent the product back adds, in any department. */
export const RETURN_LINES: readonly string[] = [
  "Sent {them} back within the 30 days and the refund arrived the following week.",
  "Returned {them} with the label from the box, and the refund came through quickly.",
  "The return was easy, which is the one good thing I can say.",
  "Returned for a refund, which was processed without any questions.",
]
lib/sample-data/reviews.ts
import { REFERENCE_DATE, chance, createRepository, dateBetween, pickWeighted, rowId, seeded, shuffle } from "./core"
import { avatarFor } from "./avatars"
import { CUSTOMERS } from "./customers"
import { ORDERS, type Order } from "./orders"
import { PRODUCTS } from "./products"
import { RETURN_LINES, REVIEW_POOLS, type ReviewLine, type ReviewPool } from "./review-templates"

/** What a buyer wrote about a product that reached them: the stars, a title and a few sentences. */
export type Review = {
  id: string
  /** A Product id: what the review is of. */
  productId: string
  /** A Customer id: who wrote it — the customer whose order brought the product. */
  customerId: string
  /** An Order id: the delivered order that holds the product. */
  orderId: string
  author: { name: string; avatarUrl: string }
  /** One to five stars. */
  rating: number
  title: string
  body: string
  /** After the order reached the buyer, and never after now. */
  at: Date
  /**
   * The store tied the review to its order: it was written through the review
   * request that follows a delivery. One left on the product page instead
   * carries no badge, though the buyer is the same.
   */
  verified: boolean
  /** How many readers marked it helpful. */
  helpful: number
}

const DAY_MS = 86_400_000

/** The share of delivered order lines a buyer goes on to review. */
const RESPONSE = 0.6

/** What a buyer who kept the product gives it, by share of buyers. */
const KEPT: readonly (readonly [number, number])[] = [
  [5, 0.56],
  [4, 0.28],
  [3, 0.1],
  [2, 0.04],
  [1, 0.02],
]

/** What a buyer who sent it back gives it: a one or a two, now and then a three for a fit that was not theirs. */
const RETURNED: readonly (readonly [number, number])[] = [
  [1, 0.45],
  [2, 0.35],
  [3, 0.2],
]

/** The share of reviews written through the review request. */
const VERIFIED = 0.86

/** A buyer writes at least half a day after the parcel arrives, and within this many days of it. */
const WINDOW_DAYS = 24

/** The sentences a review of each score is made of, and the one it may add. */
const PLAN: Record<number, { lines: (keyof Omit<ReviewPool, "titles">)[]; extra?: [keyof Omit<ReviewPool, "titles">, number] }> = {
  5: { lines: ["praise", "praise"], extra: ["detail", 0.5] },
  4: { lines: ["praise", "detail"], extra: ["gripe", 0.5] },
  3: { lines: ["praise", "gripe"], extra: ["detail", 0.5] },
  2: { lines: ["gripe", "fault"] },
  1: { lines: ["fault", "fault"], extra: ["gripe", 0.4] },
}

/** "Portable SSD 1TB" → "portable SSD": the name as a buyer says it, without the spec at its end. */
function itemOf(name: string): string {
  const words = name.split(" ")
  while (words.length > 1 && /\d|^(HD|LED)$/.test(words[words.length - 1])) words.pop()
  return words
    .map((word) => (/^[A-Z0-9-]+$/.test(word) ? word : /^[A-Z]-/.test(word) ? word[0] + word.slice(1).toLowerCase() : word.toLowerCase()))
    .join(" ")
}

/** A line's tokens filled for one product: its name, its pronouns and its verbs. */
function fill(text: string, item: string): string {
  const plural = /[^s]s$/.test(item)
  const tokens: Record<string, string> = {
    item,
    It: plural ? "They" : "It",
    they: plural ? "they" : "it",
    them: plural ? "them" : "it",
    has: plural ? "have" : "has",
    s: plural ? "" : "s",
  }
  return text.replace(/\{(\w+)\}/g, (token, key: string) => tokens[key] ?? token)
}

const fits = (line: ReviewLine, name: string) =>
  typeof line === "string" || ((!line.only || line.only.test(name)) && (!line.not || !line.not.test(name)))

const textOf = (line: ReviewLine) => (typeof line === "string" ? line : line.text)
const aboutOf = (line: ReviewLine) => (typeof line === "string" ? undefined : line.about)

/**
 * Deals a pool's lines to one product at a time, from its own shuffled run of
 * the lines that fit it, so its reviews repeat a sentence only once they have
 * used every one. A line about something the review has already made a claim
 * about is passed over for the next; none is dealt when every line is.
 */
function dealer(rand: () => number) {
  const decks = new Map<string, { lines: ReviewLine[]; next: number }>()
  return (productId: string, name: string, key: string, lines: readonly ReviewLine[], claimed: ReadonlySet<string>): ReviewLine | null => {
    const id = `${productId}/${key}`
    let deck = decks.get(id)
    if (!deck) {
      deck = { lines: shuffle(rand, lines.filter((line) => fits(line, name))), next: 0 }
      decks.set(id, deck)
    }
    for (let tries = 0; tries < deck.lines.length; tries++) {
      const line = deck.lines[deck.next++ % deck.lines.length]
      const about = aboutOf(line)
      if (!about || !claimed.has(about)) return line
    }
    return null
  }
}

function generate(): Review[] {
  const rand = seeded("reviews")
  const deal = dealer(rand)
  const customers = new Map(CUSTOMERS.map((customer) => [customer.id, customer]))
  const products = new Map(PRODUCTS.map((product) => [product.id, product]))
  const rows: Review[] = []

  const reached = (order: Order) => (order.status === "fulfilled" || order.status === "refunded") && order.fulfilledAt
  for (const order of ORDERS.filter(reached)) {
    const delivered = order.fulfilledAt!.getTime()
    const opens = delivered + DAY_MS / 2
    const closes = Math.min(delivered + WINDOW_DAYS * DAY_MS, REFERENCE_DATE.getTime())
    // A parcel that arrived in the last few hours has not been written about yet.
    if (closes <= opens) continue
    const customer = customers.get(order.customerId)
    if (!customer) continue
    const returned = order.status === "refunded"

    for (const line of order.items) {
      if (!chance(rand, RESPONSE)) continue
      const product = products.get(line.productId)!
      const pool = REVIEW_POOLS[product.category]
      const item = itemOf(product.name)
      const rating = pickWeighted(rand, returned ? RETURNED : KEPT)
      const at = dateBetween(rand, new Date(opens), new Date(closes))
      // What the review has made a claim about, so it never contradicts itself.
      const claimed = new Set<string>()
      const take = (key: string, lines: readonly ReviewLine[]) => {
        const dealt = deal(product.id, product.name, key, lines, claimed)
        if (!dealt) return null
        const about = aboutOf(dealt)
        if (about) claimed.add(about)
        return fill(textOf(dealt), item)
      }
      const say = (key: keyof Omit<ReviewPool, "titles">) => take(key, pool[key])

      const plan = PLAN[rating]
      const sentences = plan.lines.map(say)
      if (plan.extra && chance(rand, plan.extra[1])) sentences.push(say(plan.extra[0]))
      if (returned) sentences.push(fill(RETURN_LINES[Math.floor(rand() * RETURN_LINES.length)], item))
      const tier = rating >= 4 ? "high" : rating === 3 ? "mid" : "low"
      // A title pool always holds lines that claim nothing, so one is always dealt.
      const title = take(`title-${tier}`, pool.titles[tier])!
      const written = sentences.filter((sentence) => sentence !== null)

      // Older reviews have had more readers, and a warning is voted up more than praise.
      const age = (REFERENCE_DATE.getTime() - at.getTime()) / DAY_MS
      const reach = 3 + age / 6 + (rating <= 2 ? 6 : 0) + (written.length > 2 ? 2 : 0)

      rows.push({
        id: rowId("rev", rows.length + 1),
        productId: product.id,
        customerId: customer.id,
        orderId: order.id,
        author: { name: customer.name, avatarUrl: customer.avatarUrl ?? avatarFor(customer.name) },
        rating,
        title,
        body: [...new Set(written)].join(" "),
        at,
        verified: chance(rand, VERIFIED),
        helpful: Math.floor(rand() * rand() * reach),
      })
    }
  }

  return rows
}

/** The seeded reviews, in the order the book delivered them. The repository below owns the ones a demo adds. */
export const REVIEWS: Review[] = generate()

export const reviews = createRepository(REVIEWS, {
  searchKeys: ["title", "body"],
  filterKeys: ["productId", "customerId", "rating", "verified"],
})
lib/sample-data/roles.ts
import { createRepository, intBetween, rowId, seeded } from "./core"
import { MEMBERS } from "./members"

type Verb = "read" | "write" | "delete" | "admin"

/** A named permission set members can be assigned in the workspace. */
export type Role = {
  id: string
  name: string
  description: string
  permissions: Record<string, Verb[]>
  members: number
}

const COUNT = 5

const RESOURCES = ["billing", "customers", "deployments", "integrations", "settings", "api_keys"] as const

const OWNER_PERMISSIONS: Record<string, Verb[]> = Object.fromEntries(
  RESOURCES.map((resource) => [resource, ["read", "write", "delete", "admin"]])
)

const ADMIN_PERMISSIONS: Record<string, Verb[]> = {
  billing: ["read", "write"],
  customers: ["read", "write", "delete"],
  deployments: ["read", "write", "delete"],
  integrations: ["read", "write", "delete"],
  settings: ["read", "write", "admin"],
  api_keys: ["read", "write", "delete"],
}

const MEMBER_PERMISSIONS: Record<string, Verb[]> = {
  billing: [],
  customers: ["read", "write"],
  deployments: ["read", "write"],
  integrations: ["read"],
  settings: ["read"],
  api_keys: ["read"],
}

const VIEWER_PERMISSIONS: Record<string, Verb[]> = {
  billing: [],
  customers: ["read"],
  deployments: ["read"],
  integrations: ["read"],
  settings: ["read"],
  api_keys: [],
}

const BILLING_PERMISSIONS: Record<string, Verb[]> = {
  billing: ["read", "write", "admin"],
  customers: ["read"],
  deployments: [],
  integrations: [],
  settings: [],
  api_keys: [],
}

// Four of the five roles line up 1:1 with a Member["role"] value, so their
// headcount is read straight off the team directory instead of rolled — the
// numbers on this page and the members page can never disagree. "Billing" is
// a cross-cutting role no Member field tracks, so it gets its own small draw.
const ROLE_DEFS: {
  name: string
  description: string
  permissions: Record<string, Verb[]>
  memberRole?: (typeof MEMBERS)[number]["role"]
}[] = [
  {
    name: "Owner",
    description: "Full control over billing, members, and workspace settings.",
    permissions: OWNER_PERMISSIONS,
    memberRole: "owner",
  },
  {
    name: "Admin",
    description: "Manages members, integrations, deployments, and workspace configuration.",
    permissions: ADMIN_PERMISSIONS,
    memberRole: "admin",
  },
  {
    name: "Member",
    description: "Creates and edits customers and deployments day to day.",
    permissions: MEMBER_PERMISSIONS,
    memberRole: "member",
  },
  {
    name: "Viewer",
    description: "Read-only access to dashboards, customers, and reports.",
    permissions: VIEWER_PERMISSIONS,
    memberRole: "viewer",
  },
  {
    name: "Billing",
    description: "Manages invoices and payment methods without touching the product.",
    permissions: BILLING_PERMISSIONS,
  },
]

function generate(): Role[] {
  const rand = seeded("roles")
  return ROLE_DEFS.slice(0, COUNT).map((def, index) => ({
    id: rowId("rol", index + 1),
    name: def.name,
    description: def.description,
    permissions: def.permissions,
    members: def.memberRole
      ? MEMBERS.filter((member) => member.role === def.memberRole).length
      : intBetween(rand, 2, 5),
  }))
}

/** The seeded RBAC roles. The repository below owns the rows a demo mutates. */
export const ROLES: Role[] = generate()

export const roles = createRepository(ROLES, {
  searchKeys: ["name", "description"],
  filterKeys: ["name"],
})
lib/sample-data/seats.ts
// The workspace's seats, counted one way for every page and section that
// shows them. Not an entity: a selector over three of them, so it has no key
// on `db` and reads the repositories directly (members.ts and invitations.ts
// import each other's rows, so this sits apart from both).
import { invitations } from "./invitations"
import { members } from "./members"
import { plans, type Plan } from "./plans"

/** The plan the sample workspace — Northwind Analytics — is on. */
export const WORKSPACE_PLAN = "Team"

export type SeatUsage = {
  /** The plan the seats are counted against. */
  plan: Plan
  /** Members holding a seat: in the workspace, or invited and yet to join — everyone but the deactivated. */
  members: number
  /** Invitations still open; each holds the seat it offers until it is accepted, revoked or lapses. */
  invitations: number
  /** Seats held: `members` plus `invitations`. */
  used: number
  /** The seats the plan includes, or null for a plan whose features say "Unlimited seats". */
  limit: number | null
}

/**
 * How many of the workspace's seats are held, and how many its plan includes.
 * An open invitation holds a seat, as a member does who is in or yet to join;
 * a deactivated member holds none. Read per call, never held: sending or
 * revoking an invitation moves the count.
 */
export function seatUsage(): SeatUsage {
  const plan = plans.all().find((entry) => entry.name === WORKSPACE_PLAN)
  if (!plan) throw new Error(`the workspace is on the ${WORKSPACE_PLAN} plan, which db.plans does not have`)
  const holding = members.all().filter((member) => member.status !== "deactivated").length
  const open = invitations.all().filter((invitation) => invitation.status === "pending").length
  const unlimited = plan.features.some((feature) => /^unlimited seats$/i.test(feature.trim()))
  return { plan, members: holding, invitations: open, used: holding + open, limit: unlimited ? null : plan.seatsIncluded }
}
lib/sample-data/services.ts
import { chance, createRepository, intBetween, pick, pickWeighted, rowId, seeded } from "./core"

/** A status-page entry: one piece of the product with its own uptime history. */
export type Service = {
  id: string
  name: string
  status: "operational" | "degraded" | "partial_outage" | "major_outage" | "maintenance"
  /** 90 daily uptime percentages, oldest first, ending on REFERENCE_DATE. */
  uptime90d: number[]
  region: string
}

const COUNT = 8

const SERVICE_NAMES = [
  "API",
  "Web Dashboard",
  "Authentication",
  "Billing",
  "Webhooks",
  "Notifications",
  "File Storage",
  "Search",
]

const REGIONS = ["us-east-1", "us-west-2", "eu-west-1", "ap-southeast-1"]

const STATUS_MIX: readonly (readonly [Service["status"], number])[] = [
  ["operational", 0.72],
  ["degraded", 0.12],
  ["partial_outage", 0.06],
  ["major_outage", 0.02],
  ["maintenance", 0.08],
]

/** A non-operational status dips the most recent day or two; most days sit near 100. */
function uptimeSeries(rand: () => number, status: Service["status"]): number[] {
  return Array.from({ length: 90 }, (_, day) => {
    const daysFromToday = 89 - day
    const inIncidentWindow = status !== "operational" && status !== "maintenance" && daysFromToday <= 1
    const dip = inIncidentWindow ? intBetween(rand, 8, 45) : chance(rand, 0.06) ? intBetween(rand, 1, 6) : 0
    const jitter = rand() * 0.4
    return Math.max(0, Math.min(100, Math.round((100 - dip - jitter) * 100) / 100))
  })
}

function generate(): Service[] {
  const rand = seeded("services")
  return SERVICE_NAMES.slice(0, COUNT).map((name, index) => {
    const status = pickWeighted(rand, STATUS_MIX)
    return {
      id: rowId("svc", index + 1),
      name,
      status,
      uptime90d: uptimeSeries(rand, status),
      region: pick(rand, REGIONS),
    }
  })
}

/** The seeded status-page services. The repository below owns the rows a demo mutates. */
export const SERVICES: Service[] = generate()

export const services = createRepository(SERVICES, {
  searchKeys: ["name", "region"],
  filterKeys: ["status", "region"],
})
lib/sample-data/sessions.ts
import { REFERENCE_DATE, chance, createRepository, rowId, seeded } from "./core"

/**
 * One hour of traffic from one country: how many sessions it sent, how slow the
 * slowest twentieth of them were, and how many failed — plus where the country
 * sits, so a map can put a marker on it.
 */
export type Session = {
  id: string
  country: string
  region: "Americas" | "EMEA" | "APAC"
  /** Sessions opened during this hour. */
  sessions: number
  /** The 95th percentile response time for the hour, in milliseconds. */
  p95Ms: number
  /** The share of requests that failed, 0..1. */
  errorRate: number
  lat: number
  lng: number
  /** The hour this reading covers, on the hour, ending at REFERENCE_DATE. */
  at: Date
}

/** How many hourly readings each country carries. */
const HOURS = 24

const HOUR_MS = 3_600_000

type Place = {
  country: string
  region: Session["region"]
  lat: number
  lng: number
  /** Relative traffic weight; the busiest country is 100. */
  weight: number
}

// Real countries at their own centroids, so a marker lands on the land it names.
const PLACES: readonly Place[] = [
  { country: "United States", region: "Americas", lat: 37.09, lng: -95.71, weight: 100 },
  { country: "Canada", region: "Americas", lat: 56.13, lng: -106.35, weight: 22 },
  { country: "Mexico", region: "Americas", lat: 23.63, lng: -102.55, weight: 17 },
  { country: "Brazil", region: "Americas", lat: -14.24, lng: -51.93, weight: 29 },
  { country: "Colombia", region: "Americas", lat: 4.57, lng: -74.3, weight: 9 },
  { country: "Chile", region: "Americas", lat: -35.68, lng: -71.54, weight: 7 },
  { country: "Argentina", region: "Americas", lat: -38.42, lng: -63.62, weight: 11 },
  { country: "United Kingdom", region: "EMEA", lat: 55.38, lng: -3.44, weight: 41 },
  { country: "Ireland", region: "EMEA", lat: 53.14, lng: -7.69, weight: 8 },
  { country: "France", region: "EMEA", lat: 46.23, lng: 2.21, weight: 26 },
  { country: "Germany", region: "EMEA", lat: 51.17, lng: 10.45, weight: 34 },
  { country: "Netherlands", region: "EMEA", lat: 52.13, lng: 5.29, weight: 13 },
  { country: "Spain", region: "EMEA", lat: 40.46, lng: -3.75, weight: 16 },
  { country: "Italy", region: "EMEA", lat: 41.87, lng: 12.57, weight: 14 },
  { country: "Poland", region: "EMEA", lat: 51.92, lng: 19.15, weight: 11 },
  { country: "Sweden", region: "EMEA", lat: 60.13, lng: 18.64, weight: 9 },
  { country: "Portugal", region: "EMEA", lat: 39.4, lng: -8.22, weight: 6 },
  { country: "Nigeria", region: "EMEA", lat: 9.08, lng: 8.68, weight: 7 },
  { country: "Kenya", region: "EMEA", lat: -0.02, lng: 37.91, weight: 4 },
  { country: "South Africa", region: "EMEA", lat: -30.56, lng: 22.94, weight: 8 },
  { country: "United Arab Emirates", region: "EMEA", lat: 23.42, lng: 53.85, weight: 9 },
  { country: "India", region: "APAC", lat: 20.59, lng: 78.96, weight: 37 },
  { country: "Singapore", region: "APAC", lat: 1.35, lng: 103.82, weight: 12 },
  { country: "Indonesia", region: "APAC", lat: -0.79, lng: 113.92, weight: 11 },
  { country: "Philippines", region: "APAC", lat: 12.88, lng: 121.77, weight: 8 },
  { country: "South Korea", region: "APAC", lat: 35.91, lng: 127.77, weight: 14 },
  { country: "Japan", region: "APAC", lat: 36.2, lng: 138.25, weight: 27 },
  { country: "Australia", region: "APAC", lat: -25.27, lng: 133.78, weight: 20 },
  { country: "Thailand", region: "APAC", lat: 15.87, lng: 100.99, weight: 9 },
]

/** Sessions at the busiest hour of the busiest country. */
const PEAK = 9

/** What each region's stack answers in, before load and jitter. */
const BASE_P95: Record<Session["region"], number> = { Americas: 210, EMEA: 260, APAC: 330 }

/** The hour a country's own afternoon peaks, in local time. */
const PEAK_HOUR = 15

/**
 * How busy a country is at a given UTC hour, 0.05 at its own small hours and 1
 * at its own afternoon. Longitude stands in for the time zone — a degree is
 * four minutes — which is what keeps Tokyo and Chicago from rushing together.
 */
function diurnal(utcHour: number, lng: number): number {
  const local = (((utcHour + lng / 15) % 24) + 24) % 24
  const phase = (2 * Math.PI * (local - PEAK_HOUR)) / 24
  return 0.05 + 0.95 * ((1 + Math.cos(phase)) / 2)
}

/** Midnight-aligned to the hour, so every reading covers a whole clock hour. */
function hourStart(hoursAgo: number): Date {
  const at = new Date(REFERENCE_DATE)
  at.setUTCMinutes(0, 0, 0)
  return new Date(at.getTime() - hoursAgo * HOUR_MS)
}

function generate(): Session[] {
  const rand = seeded("sessions")
  const rows: Session[] = []

  for (const place of PLACES) {
    for (let back = HOURS - 1; back >= 0; back--) {
      const at = hourStart(back)
      const load = diurnal(at.getUTCHours(), place.lng)
      const sessions = Math.max(
        1,
        Math.round(place.weight * PEAK * load * (0.85 + rand() * 0.3))
      )
      // Latency follows the load rather than floating free: a busy hour is a
      // slow hour, which is the whole reason a live page plots both.
      const p95Ms = Math.round(
        BASE_P95[place.region] * (0.85 + rand() * 0.35) + sessions * 0.06
      )
      // Most hours are quiet; one in twenty carries a spike, which is what
      // gives an error-rate panel something to be about.
      const errorRate = 0.002 + rand() * 0.011 + (chance(rand, 0.05) ? rand() * 0.06 : 0)

      rows.push({
        id: rowId("ses", rows.length + 1),
        country: place.country,
        region: place.region,
        sessions,
        p95Ms,
        errorRate: Math.round(errorRate * 10_000) / 10_000,
        lat: place.lat,
        lng: place.lng,
        at,
      })
    }
  }

  return rows
}

/** The seeded hourly traffic. The repository below owns the rows a demo mutates. */
export const SESSIONS: Session[] = generate()

export const sessions = createRepository(SESSIONS, {
  searchKeys: ["country", "region"],
  filterKeys: ["country", "region"],
})
lib/sample-data/shipments.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAhead,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
} from "./core"
import { ORDERS } from "./orders"

/** The four stages a parcel moves through, in the order it moves through them. */
export const SHIPMENT_STAGES = ["picking", "packing", "shipped", "delivered"] as const

export type ShipmentStageName = (typeof SHIPMENT_STAGES)[number]

/** One stage of a shipment. `at` is set once the stage has actually happened. */
export type ShipmentStage = { name: ShipmentStageName; at?: Date }

/** Something that stopped a parcel, and what the warehouse has to do about it. */
export type ShipmentException = {
  code: "address" | "customs" | "damaged" | "delayed" | "missing_label"
  message: string
  raisedAt: Date
}

/** A parcel leaving the warehouse against an order. */
export type Shipment = {
  id: string
  /** An Order id. */
  orderId: string
  /** That order's own number, so a row reads without a second lookup. */
  orderNumber: string
  carrier: string
  service: "standard" | "express" | "overnight"
  /** The tracking number the carrier issued, once the parcel is with them. */
  tracking?: string
  /** The order's destination country. */
  destination: string
  /** All four stages, always; the ones already reached carry a timestamp. */
  stages: ShipmentStage[]
  /** The stage the parcel is on now — the last one with a timestamp. */
  stage: ShipmentStageName
  weightGrams: number
  /** Only ever on a parcel that has not been delivered. */
  exception?: ShipmentException
}

const COUNT = 320

const CARRIERS: readonly (readonly [string, number])[] = [
  ["Meridian Freight", 0.34],
  ["Harbor Line", 0.26],
  ["Kestrel Courier", 0.22],
  ["Northgate Post", 0.18],
]

const SERVICE_MIX: readonly (readonly [Shipment["service"], number])[] = [
  ["standard", 0.62],
  ["express", 0.28],
  ["overnight", 0.1],
]

// How often a parcel stops moving altogether, and where it stops. A parcel is
// not stuck at random: a label that never printed and an address that will not
// resolve both bite early, and a customs hold bites once the carrier has it.
// Kept low on purpose: an unstuck parcel is delivered within a week, so the
// stuck ones pile up across the whole window and a rate that reads as modest
// per order still leaves them the majority of what is in flight.
const STUCK_RATE = 0.12

// How far back an open exception can go. Anything older than this has been
// resolved, re-sent or written off — an operations page holds the work that is
// still open, not a parcel that has been sitting at a hub since the spring.
const STUCK_WINDOW_DAYS = 28

const STUCK_AT: readonly (readonly [ShipmentStageName, number])[] = [
  ["picking", 0.18],
  ["packing", 0.3],
  ["shipped", 0.52],
]

const EXCEPTIONS: readonly ShipmentException["code"][] = [
  "address",
  "customs",
  "damaged",
  "delayed",
  "missing_label",
]

const EXCEPTION_MESSAGES: Record<ShipmentException["code"], string> = {
  address: "The street number is missing from the delivery address.",
  customs: "Held at customs pending a commercial invoice.",
  damaged: "The outer box was damaged in transit and is being repacked.",
  delayed: "Sitting at the sorting hub past its promised handover.",
  missing_label: "The carrier label failed to print and has not been reissued.",
}

// How long each stage takes, at the low and high end, in hours. Picking starts
// from the moment the order was placed.
const STAGE_HOURS: Record<ShipmentStageName, readonly [number, number]> = {
  picking: [1, 20],
  packing: [1, 10],
  shipped: [2, 30],
  delivered: [24, 120],
}

const HOUR_MS = 3_600_000

/** A tracking number in the shape a carrier hands back: four letters, nine digits. */
function trackingFor(rand: () => number, carrier: string): string {
  const prefix = carrier
    .split(" ")
    .map((word) => word.slice(0, 2).toUpperCase())
    .join("")
    .slice(0, 4)
  return `${prefix}${String(intBetween(rand, 100_000_000, 999_999_999))}`
}

function generate(): Shipment[] {
  const rand = seeded("shipments")
  // Only an order that was actually paid for leaves the warehouse; a cancelled
  // or still-pending order has no parcel behind it. Newest first and cut to
  // COUNT, so the book is the recent work an operations page is about rather
  // than a thin slice of half a year.
  const shippable = ORDERS.filter(
    (order) => order.status !== "cancelled" && order.status !== "pending"
  )
    .sort((a, b) => b.placedAt.getTime() - a.placedAt.getTime())
    .slice(0, COUNT)

  // Guards a shift in STATUS_MIX, orders' own COUNT, or the seed: `shippable`
  // sits comfortably above COUNT today, but nothing pins that margin, and
  // `shippable[index]` past its end would throw at module scope, inside the
  // shared store barrel every block imports through.
  const rows = Math.min(COUNT, shippable.length)

  return Array.from({ length: rows }, (_, index) => {
    // Walked rather than picked at random, so no two shipments share an order.
    const order = shippable[index]
    const carrier = pickWeighted(rand, CARRIERS)

    // A parcel's stage is where the clock has got to, not a draw of its own:
    // each stage takes its own span after the one before it, starting from the
    // order date, and the parcel is on the last stage whose stamp has actually
    // passed. So an order placed months ago is with the customer and only a
    // recent one is still in the warehouse — unless it is stuck, which is the
    // one thing that stops the walk early and is what an exception records.
    // Drawn for every parcel so the generator's stream does not depend on the
    // order's date, and then only allowed to bite inside the recent window.
    const unlucky = chance(rand, STUCK_RATE)
    const recent =
      order.placedAt.getTime() >= REFERENCE_DATE.getTime() - STUCK_WINDOW_DAYS * 24 * HOUR_MS
    const stuck = unlucky && recent
    const stopAt = stuck ? SHIPMENT_STAGES.indexOf(pickWeighted(rand, STUCK_AT)) : Infinity

    let at = order.placedAt
    let reached = 0
    const stages: ShipmentStage[] = SHIPMENT_STAGES.map((name, position) => {
      const [low, high] = STAGE_HOURS[name]
      const next = new Date(at.getTime() + intBetween(rand, low, high) * HOUR_MS)
      // Past the stage it stopped on, or past now: not reached, no stamp. The
      // first stage is always stamped, clamped to now, because a parcel that
      // exists at all has been picked up off the shelf.
      if (position > stopAt || (position > 0 && next.getTime() > REFERENCE_DATE.getTime())) {
        return { name }
      }
      at = next.getTime() > REFERENCE_DATE.getTime() ? REFERENCE_DATE : next
      reached = position
      return { name, at }
    })

    const stage = SHIPMENT_STAGES[reached]
    const shippedAt = stages[SHIPMENT_STAGES.indexOf("shipped")].at
    const code = pick(rand, EXCEPTIONS)
    const raisedFrom = stages[reached].at ?? order.placedAt

    return {
      id: rowId("shp", index + 1),
      orderId: order.id,
      orderNumber: order.number,
      carrier,
      service: pickWeighted(rand, SERVICE_MIX),
      // A tracking number exists once the carrier has the parcel, not before.
      ...(shippedAt ? { tracking: trackingFor(rand, carrier) } : {}),
      destination: order.country,
      stages,
      stage,
      weightGrams: intBetween(rand, 220, 8_400),
      // Only ever on a parcel that is still ours: a delivered one has no
      // problem left to record.
      ...(stuck && stage !== "delivered"
        ? {
            exception: {
              code,
              message: EXCEPTION_MESSAGES[code],
              raisedAt: dateBetween(
                rand,
                raisedFrom,
                // The exception was raised after the parcel reached the stage it
                // is stuck on, and no later than now.
                daysAhead(1, raisedFrom).getTime() > REFERENCE_DATE.getTime()
                  ? REFERENCE_DATE
                  : daysAhead(1, raisedFrom)
              ),
            },
          }
        : {}),
    }
  })
}

/** The seeded shipment book. The repository below owns the rows a demo mutates. */
export const SHIPMENTS: Shipment[] = generate()

export const shipments = createRepository(SHIPMENTS, {
  searchKeys: ["orderNumber", "tracking", "carrier"],
  filterKeys: ["stage", "carrier", "service", "destination"],
})
lib/sample-data/students.ts
import {
  REFERENCE_DATE,
  createRepository,
  dateBetween,
  daysAgo,
  emailFor,
  pick,
  pickWeighted,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { avatarFor } from "./avatars"
import { COURSES, type Course } from "./courses"

/** One place a student holds on a course, and how far through it they are. */
export type StudentEnrolment = {
  /** A Course id. */
  courseId: string
  /** How much of the course they have done, as a whole percentage, 0–100. */
  progress: number
  /** When they took the place. */
  startedAt: Date
}

/**
 * A learner on the academy's roster. `Course.enrolled` stays each course's own
 * lifetime figure; the places here are the learners the academy is looking
 * after now, so a course never has more of them than it has ever enrolled.
 */
export type Student = {
  id: string
  name: string
  email: string
  avatarUrl: string
  enrolled: StudentEnrolment[]
  lastActiveAt: Date
  /** A graduate has finished every course they hold; paused is away for three weeks or more. */
  status: "active" | "paused" | "graduated"
}

const COUNT = 80

const FIRST_NAMES = [
  "Adaeze", "Bram", "Carmen", "Devika", "Emeka", "Fatima", "Georgia", "Hyun",
  "Idris", "Julia", "Kasper", "Linnea", "Malik", "Nora", "Obinna", "Pilar",
  "Ravi", "Signe", "Theo", "Valentina",
]

const LAST_NAMES = [
  "Achebe", "Bianchi", "Carvalho", "Dang", "Eklund", "Farouk", "Gomez", "Horvath",
  "Iyer", "Juarez", "Kuznetsov", "Laurent", "Mensah", "Nguyen", "Pires", "Rahman",
  "Sandoval", "Tiwari", "Vogel", "Walsh",
]

// Reserved example domains (RFC 2606), so no generated address can be a real person's.
const EMAIL_DOMAINS = ["example.com", "example.net", "example.org", "mail.example"]

const STATUS_MIX: readonly (readonly [Student["status"], number])[] = [
  ["active", 0.7],
  ["paused", 0.2],
  ["graduated", 0.1],
]

// Most learners hold one or two places; a keen few carry four.
const PLACES_MIX: readonly (readonly [number, number])[] = [
  [1, 0.3],
  [2, 0.35],
  [3, 0.22],
  [4, 0.13],
]

const DAY_MS = 86_400_000

/** Added to every course's own figure when drawing places, so the smallest course is not left empty. */
const WEIGHT_FLOOR = 400

/** When the student was last in, by status: this fortnight, three weeks or more ago, or since they finished. */
function lastSeen(rand: () => number, status: Student["status"]): Date {
  if (status === "active") return dateBetween(rand, daysAgo(14), REFERENCE_DATE)
  if (status === "paused") return dateBetween(rand, daysAgo(120), daysAgo(21))
  return dateBetween(rand, daysAgo(90), daysAgo(3))
}

/**
 * `count` different courses, drawn without replacement and weighted by each
 * course's own enrolment figure, so the popular courses carry more of the
 * roster — on a floor, so a small course still has someone on it. A course
 * already holding as many students as it has ever enrolled is passed over:
 * the roster is a part of that figure, never more than it.
 */
function drawCourses(rand: () => number, count: number, held: Map<string, number>): Course[] {
  const open = COURSES.filter((course) => (held.get(course.id) ?? 0) < course.enrolled)
  const chosen: Course[] = []
  while (chosen.length < count && chosen.length < open.length) {
    const left = open.filter((course) => !chosen.includes(course))
    chosen.push(pickWeighted(rand, left.map((course) => [course, WEIGHT_FLOOR + course.enrolled] as const)))
  }
  return chosen
}

/**
 * How far a student has got: they learn at a pace of their own while they are
 * active, so progress is the days between taking the place and last being seen
 * times that pace, in whole percent. A place taken and never opened reads 0.
 */
function progressOn(rand: () => number, startedAt: Date, seen: Date): number {
  if (rand() < 0.08) return 0
  const days = (seen.getTime() - startedAt.getTime()) / DAY_MS
  const pace = 0.25 + rand() * 1.1
  return Math.min(100, Math.max(1, Math.round(days * pace)))
}

function generate(): Student[] {
  const rand = seeded("students")
  // Every pairing, shuffled once, so no two students share a name.
  const names = shuffle(
    rand,
    FIRST_NAMES.flatMap((first) => LAST_NAMES.map((last) => `${first} ${last}`))
  ).slice(0, COUNT)
  const held = new Map<string, number>()

  const rows = names.map((name, index) => {
    const status = pickWeighted(rand, STATUS_MIX)
    const seen = lastSeen(rand, status)
    const courses = drawCourses(rand, pickWeighted(rand, PLACES_MIX), held)

    const enrolled = courses.map((course) => {
      held.set(course.id, (held.get(course.id) ?? 0) + 1)
      // A graduate took their places long enough ago to have finished them.
      const startedAt =
        status === "graduated" ? dateBetween(rand, daysAgo(320), daysAgo(120)) : dateBetween(rand, daysAgo(180), seen)
      const progress = status === "graduated" ? 100 : progressOn(rand, startedAt, seen)
      return { courseId: course.id, progress, startedAt }
    })

    // Anyone still studying has something left to finish: a learner who has
    // done every course they hold is a graduate, and the status says so.
    if (status !== "graduated" && enrolled.every((place) => place.progress === 100)) {
      const last = enrolled[enrolled.length - 1]
      last.progress = 40 + Math.floor(rand() * 56)
    }

    return {
      id: rowId("stu", index + 1),
      name,
      email: emailFor(name, pick(rand, EMAIL_DOMAINS)),
      avatarUrl: avatarFor(name),
      enrolled,
      lastActiveAt: seen,
      status,
    }
  })

  return rows
}

/** The seeded roster. The repository below owns the rows an enrolment changes. */
export const STUDENTS: Student[] = generate()

export const students = createRepository(STUDENTS, {
  searchKeys: ["name", "email"],
  filterKeys: ["status"],
})
lib/sample-data/subscriptions.ts
import { REFERENCE_DATE, chance, createRepository, daysAhead, rowId, seeded } from "./core"
import { CUSTOMERS, type Customer } from "./customers"
import { PLANS } from "./plans"

/** The billing relationship behind a `Customer`: one subscription per account. */
export type Subscription = {
  id: string
  customerId: string
  planId: string
  status: "trialing" | "active" | "past_due" | "cancelled"
  mrrCents: number
  startedAt: Date
  renewsAt: Date
  cancelledAt?: Date
  seats: number
}

const DAY_MS = 86_400_000

// `Plan.name` and `Customer.plan` are written in the same four tiers, so a
// subscription's plan is the customer's own tier, looked up by name.
const PLAN_ID_BY_TIER: Record<Customer["plan"], string> = {
  free: PLANS.find((plan) => plan.name === "Free")!.id,
  starter: PLANS.find((plan) => plan.name === "Starter")!.id,
  team: PLANS.find((plan) => plan.name === "Team")!.id,
  enterprise: PLANS.find((plan) => plan.name === "Enterprise")!.id,
}

/** The next multiple of `cycleDays` after `startedAt` that lands past `REFERENCE_DATE`. */
function nextRenewal(startedAt: Date, cycleDays: number): Date {
  const cycleMs = cycleDays * DAY_MS
  const elapsed = REFERENCE_DATE.getTime() - startedAt.getTime()
  const cyclesElapsed = Math.floor(elapsed / cycleMs) + 1
  return daysAhead(cyclesElapsed * cycleDays, startedAt)
}

function generate(): Subscription[] {
  const rand = seeded("subscriptions")

  return CUSTOMERS.map((customer, index) => {
    // A subscription mirrors the account it bills: same tier, same seats,
    // same MRR (already zeroed for free/trial/churned by customers.ts).
    const status: Subscription["status"] =
      customer.status === "churned"
        ? "cancelled"
        : customer.status === "suspended"
          ? "past_due"
          : customer.status === "trial"
            ? "trialing"
            : // A live account occasionally has a payment failure without losing access yet.
              chance(rand, 0.06)
              ? "past_due"
              : "active"

    const cycleDays = chance(rand, 0.75) ? 30 : 365
    const startedAt = customer.createdAt

    return {
      id: rowId("sub", index + 1),
      customerId: customer.id,
      planId: PLAN_ID_BY_TIER[customer.plan],
      status,
      mrrCents: customer.mrrCents,
      startedAt,
      renewsAt: nextRenewal(startedAt, cycleDays),
      // A cancelled subscription stopped the day the account went quiet.
      ...(status === "cancelled" ? { cancelledAt: customer.lastSeenAt } : {}),
      seats: customer.seats,
    }
  })
}

/** One subscription per customer. The repository below owns the rows a demo mutates. */
export const SUBSCRIPTIONS: Subscription[] = generate()

export const subscriptions = createRepository(SUBSCRIPTIONS, {
  searchKeys: ["customerId"],
  filterKeys: ["status", "planId", "customerId"],
})
lib/sample-data/tasks.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  daysAhead,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { MEMBERS } from "./members"
import { PROJECTS } from "./projects"

/** One unit of work on a board: a column, an owner, an estimate and a window. */
export type Task = {
  id: string
  title: string
  /** The column a board puts it in. */
  status: "backlog" | "todo" | "in_progress" | "review" | "done"
  priority: "urgent" | "high" | "medium" | "low" | "none"
  /** A Member id: whoever has it. */
  assignee: string
  /** A Project id. */
  projectId: string
  /** The estimate, on the Fibonacci scale teams actually use. */
  points: number
  /** Midnight UTC on the day work is meant to start. */
  startAt: Date
  /** Midnight UTC on the day it is due. */
  dueAt: Date
  createdAt: Date
  updatedAt: Date
  labels: string[]
}

const COUNT = 72

const STATUS_MIX: readonly (readonly [Task["status"], number])[] = [
  ["backlog", 0.24],
  ["todo", 0.2],
  ["in_progress", 0.18],
  ["review", 0.14],
  ["done", 0.24],
]

const PRIORITY_MIX: readonly (readonly [Task["priority"], number])[] = [
  ["urgent", 0.08],
  ["high", 0.22],
  ["medium", 0.38],
  ["low", 0.22],
  ["none", 0.1],
]

const POINTS = [1, 2, 3, 5, 8] as const

const LABELS = ["api", "backend", "billing", "design", "docs", "infra", "mobile", "perf", "security", "web"]

const VERBS = [
  "Add",
  "Audit",
  "Backfill",
  "Cache",
  "Document",
  "Harden",
  "Instrument",
  "Migrate",
  "Rate-limit",
  "Refactor",
  "Retire",
  "Split",
  "Throttle",
  "Version",
]

const OBJECTS = [
  "the checkout webhook",
  "the invoice mailer",
  "the search index",
  "the seat-count job",
  "the session store",
  "the settings API",
  "the signup funnel",
  "the sync worker",
  "the usage rollup",
  "the webhook retries",
  "the audit trail",
  "the export queue",
  "the price ladder",
  "the onboarding tour",
  "the refund flow",
]

/**
 * Where a task's dates sit relative to the reference date, given its column.
 * `start` is an offset in days — negative is behind us — and `span` is how long
 * the work runs, so a column's bars land where a reader expects them on a
 * timeline: the backlog ahead of today, the finished work behind it.
 */
const WINDOW: Record<Task["status"], { start: [number, number]; span: [number, number] }> = {
  backlog: { start: [4, 30], span: [5, 21] },
  todo: { start: [-2, 9], span: [3, 14] },
  in_progress: { start: [-12, -2], span: [6, 20] },
  review: { start: [-20, -6], span: [8, 24] },
  done: { start: [-90, -20], span: [3, 18] },
}

/** Midnight UTC on the day `date` falls on — a task's window is whole days. */
function utcDay(date: Date): Date {
  return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}

function generate(): Task[] {
  const rand = seeded("tasks")
  const assignees = MEMBERS.filter((member) => member.status === "active").map((member) => member.id)
  // A shipped project takes no new work, so a board never opens on a column of
  // tasks nobody is going to do.
  const open = PROJECTS.filter((project) => project.status !== "shipped")

  return Array.from({ length: COUNT }, (_, index) => {
    const status = pickWeighted(rand, STATUS_MIX)
    const project = pick(rand, status === "done" ? PROJECTS : open)
    const { start, span } = WINDOW[status]
    const startAt = utcDay(daysAhead(intBetween(rand, start[0], start[1])))
    const dueAt = utcDay(daysAhead(intBetween(rand, span[0], span[1]), startAt))

    // Created before the work opened; touched since, but never after "now".
    const createdAt = dateBetween(rand, daysAgo(120), new Date(Math.min(startAt.getTime(), REFERENCE_DATE.getTime())))
    const updatedAt = dateBetween(rand, createdAt, REFERENCE_DATE)

    // The draws stay in this order: the rows every block and test reads are
    // the ones this sequence has always produced.
    const title = `${pick(rand, VERBS)} ${pick(rand, OBJECTS)}`
    const priority = pickWeighted(rand, PRIORITY_MIX)
    const assignee = pick(rand, assignees)
    const points = pick(rand, POINTS)
    const labels = shuffle(rand, LABELS).slice(0, chance(rand, 0.35) ? 2 : 1)
    // A star is the label "starred" (the to-do list reads it): an open urgent
    // task carries one, so the list opens with a few starred rows rather than
    // none, and starring anything else is the reader's own act.
    if (priority === "urgent" && status !== "done") labels.push("starred")

    return {
      id: rowId("tsk", index + 1),
      title,
      status,
      priority,
      assignee,
      projectId: project.id,
      points,
      startAt,
      dueAt,
      createdAt,
      updatedAt,
      labels,
    }
  })
}

/** The seeded backlog. The repository below owns the rows a board moves. */
export const TASKS: Task[] = generate()

export const tasks = createRepository(TASKS, {
  searchKeys: ["title"],
  filterKeys: ["status", "priority", "assignee", "projectId"],
})
lib/sample-data/trades.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  pick,
  pickWeighted,
  rowId,
  seeded,
} from "./core"
import { HOLDINGS } from "./holdings"

/** One movement of an asset: bought or sold for cash, or sent to or received from another wallet. */
export type Trade = {
  id: string
  /** The ticker, one a holding carries. */
  symbol: string
  kind: "buy" | "sell" | "send" | "receive"
  amount: number
  /** The asset's price when the trade was made, in cents. */
  priceCents: number
  at: Date
  status: "settled" | "pending"
}

const COUNT = 80

// Seven months of activity, for a chart by month; the last few trades land
// in the past two days, so the ledger always opens on something unsettled.
const WINDOW_DAYS = 210
const RECENT = 5

const KIND_MIX: readonly (readonly [Trade["kind"], number])[] = [
  ["buy", 0.38],
  ["sell", 0.24],
  ["send", 0.2],
  ["receive", 0.18],
]

function generate(): Trade[] {
  const rand = seeded("trades")
  // One quote per symbol; a holding in a second wallet carries the same price.
  const quotes = new Map<string, { priceCents: number; amount: number; precision: number }>()
  for (const holding of HOLDINGS) {
    if (quotes.has(holding.symbol)) continue
    const decimals = String(holding.amount).split(".")[1]?.length ?? 0
    quotes.set(holding.symbol, { priceCents: holding.priceCents, amount: holding.amount, precision: decimals })
  }
  const symbols = [...quotes.keys()]

  const rows = Array.from({ length: COUNT }, (_, index) => {
    const symbol = pick(rand, symbols)
    const quote = quotes.get(symbol)!
    const recent = index >= COUNT - RECENT
    const at = recent
      ? dateBetween(rand, daysAgo(2), REFERENCE_DATE)
      : dateBetween(rand, daysAgo(WINDOW_DAYS), daysAgo(2))
    // The further back a trade sits, the further its price could have been
    // from today's; a stablecoin never moves.
    const age = (REFERENCE_DATE.getTime() - at.getTime()) / (WINDOW_DAYS * 86_400_000)
    const drift = symbol === "USDT" ? 0 : (rand() * 2 - 1) * 0.3 * age
    const scale = 10 ** Math.max(quote.precision, 2)
    const amount = Math.round(quote.amount * (0.02 + rand() * 0.38) * scale) / scale

    return {
      symbol,
      kind: pickWeighted(rand, KIND_MIX),
      amount: Math.max(amount, 1 / scale),
      priceCents: Math.max(1, Math.round(quote.priceCents * (1 + drift))),
      at,
      // Only the newest trades can still be unsettled.
      status: recent && chance(rand, 0.5) ? ("pending" as const) : ("settled" as const),
    }
  }).sort((a, b) => a.at.getTime() - b.at.getTime())

  // Whatever the draw, the very last trade is still clearing.
  rows[rows.length - 1].status = "pending"

  return rows.map((row, index) => ({ ...row, id: rowId("trd", index + 1) }))
}

/** The seeded ledger, oldest first. The repository below owns the rows a new order joins. */
export const TRADES: Trade[] = generate()

export const trades = createRepository(TRADES, {
  searchKeys: ["symbol"],
  filterKeys: ["kind", "symbol", "status"],
})
lib/sample-data/transactions.ts
import {
  REFERENCE_DATE,
  chance,
  createRepository,
  dateBetween,
  daysAgo,
  daysAhead,
  intBetween,
  pick,
  pickWeighted,
  rowId,
  seeded,
  shuffle,
} from "./core"
import { avatarFor } from "./avatars"
import { CUSTOMERS } from "./customers"
import { FX_RATES } from "./fx-rates"

/** A currency the store holds a balance in. */
export type Currency = "USD" | "EUR" | "GBP" | "JPY"

/**
 * One movement on a currency balance: money in, money out, or money moved
 * between balances. A move between balances is booked as two rows — out of
 * one, into the other, the same instant and status — so no money appears or
 * vanishes between the cards.
 */
export type Transaction = {
  id: string
  kind: "charge" | "payout" | "refund" | "transfer" | "fee"
  /** Who the money came from or went to: a customer, a bank account, a fee, another balance. */
  counterparty: string
  /** A face — people counterparties only: the customer behind a charge or a refund. */
  avatarUrl?: string
  /**
   * Signed, in the minor unit of `currency` — cents, pence, or whole yen,
   * since the yen has no minor unit. A charge is in (+); a payout, a refund
   * and a fee are out (−); a transfer's out leg is −, its in leg +, the two
   * worth the same at that day's close in `db.fxRates`.
   */
  amountCents: number
  currency: Currency
  /** A scheduled row is booked for a day ahead of now; every other row happened on or before it. */
  status: "completed" | "pending" | "scheduled" | "failed"
  /** When it happened — or, for a scheduled row, the day it is booked for, after REFERENCE_DATE. */
  at: Date
}

const COUNT = 120

const STATUS_MIX: readonly (readonly [Transaction["status"], number])[] = [
  ["completed", 0.78],
  ["pending", 0.08],
  ["scheduled", 0.08],
  ["failed", 0.06],
]

/**
 * The mix as whole rows — each share of COUNT floored, and the rows left over
 * handed to the largest remainders: 94 completed, 10 pending, 9 scheduled and
 * 7 failed. Exact rather than drawn, so a ledger of 120 rows holds the mix it
 * is written to hold instead of wandering off it by chance.
 */
function statusCounts(): (readonly [Transaction["status"], number])[] {
  const counts = STATUS_MIX.map(([status, share]) => ({ status, exact: share * COUNT, rows: Math.floor(share * COUNT) }))
  let left = COUNT - counts.reduce((sum, entry) => sum + entry.rows, 0)
  for (const entry of [...counts].sort((a, b) => b.exact - Math.floor(b.exact) - (a.exact - Math.floor(a.exact)))) {
    if (left === 0) break
    entry.rows += 1
    left -= 1
  }
  return counts.map((entry) => [entry.status, entry.rows] as const)
}

/** What happens: customers pay and are refunded, the store is paid out, fees are taken, balances are topped up. */
const KIND_MIX: readonly (readonly [Transaction["kind"], number])[] = [
  ["charge", 0.52],
  ["payout", 0.14],
  ["refund", 0.1],
  ["fee", 0.14],
  ["transfer", 0.08],
]

/** What is booked ahead: nobody schedules a customer's charge, so only money going out or moving. */
const SCHEDULED_MIX: readonly (readonly [Transaction["kind"], number])[] = [
  ["payout", 0.6],
  ["transfer", 0.25],
  ["fee", 0.15],
]

/** The currency a customer is billed in, by the country they are in. */
const BILLING: Record<string, Currency> = {
  "United Kingdom": "GBP",
  Germany: "EUR",
  France: "EUR",
  Netherlands: "EUR",
  Spain: "EUR",
  Portugal: "EUR",
  Ireland: "EUR",
  Sweden: "EUR",
  Japan: "JPY",
}

/** Where the store's own money goes: payouts and fees, most of them in dollars. */
const HOUSE_MIX: readonly (readonly [Currency, number])[] = [
  ["USD", 0.5],
  ["EUR", 0.26],
  ["GBP", 0.16],
  ["JPY", 0.08],
]

/** The balances a transfer tops up from the dollar one, or sweeps back into it. */
const TRANSFER_MIX: readonly (readonly [Exclude<Currency, "USD">, number])[] = [
  ["EUR", 0.5],
  ["GBP", 0.32],
  ["JPY", 0.18],
]

/** The bank account each balance pays out to — fictional banks, the last four digits only. */
const PAYOUT_ACCOUNTS: Record<Currency, string> = {
  USD: "Harbor Trust ··4402",
  EUR: "Meridian Bank ··0917",
  GBP: "Keystone Savings ··2210",
  JPY: "Kestrel Trust ··5531",
}

const FEES = ["Card processing fees", "Payout fee", "Currency conversion fee"]

/** How many minor units a major unit holds: a hundred cents, or one yen. */
const MINOR: Record<Currency, number> = { USD: 100, EUR: 100, GBP: 100, JPY: 1 }

/** Roughly what a major unit is worth in dollars, so a yen amount lands at a yen scale. */
const SCALE: Record<Currency, number> = { USD: 1, EUR: 1, GBP: 1, JPY: 150 }

/** Major-unit ranges of what customers pay and are refunded, and of a fee, before the currency's scale. */
const RANGE: Record<"charge" | "refund" | "fee", readonly [number, number]> = {
  charge: [60, 2_400],
  refund: [30, 900],
  fee: [5, 180],
}

/** A transfer's size in the smaller balance's own major units, so a yen top-up is sized for a yen balance. */
const TRANSFER_RANGE: Record<Exclude<Currency, "USD">, readonly [number, number]> = {
  EUR: [400, 3_500],
  GBP: [300, 2_800],
  JPY: [50_000, 450_000],
}

/** The least a payout sweeps, in major units: less is not worth the bank's fee. */
const MIN_PAYOUT: Record<Currency, number> = { USD: 250, EUR: 250, GBP: 200, JPY: 30_000 }

/** A range in the currency's own major units, drawn in its minor unit: with cents where it has them, whole yen where not. */
function minorBetween(rand: () => number, [low, high]: readonly [number, number], currency: Currency): number {
  if (MINOR[currency] === 1) return intBetween(rand, low, high)
  return intBetween(rand, low, high) * MINOR[currency] + intBetween(rand, 0, 99)
}

/** A dollar-sized range drawn in `currency`: a yen amount lands at a yen scale, anywhere in it — not on a ¥150 grid. */
function drawn(rand: () => number, [low, high]: readonly [number, number], currency: Currency): number {
  return minorBetween(rand, [low * SCALE[currency], high * SCALE[currency]], currency)
}

/** Dollars per unit of `currency` on the UTC day of `at`: that day's close, or the latest for a day still ahead. */
function dollarsPer(currency: Currency, at: Date): number {
  if (currency === "USD") return 1
  const series = FX_RATES[currency === "JPY" ? "USD/JPY" : currency === "EUR" ? "EUR/USD" : "GBP/USD"]
  const day = at.toISOString().slice(0, 10)
  const close = (series.find((point) => point.date === day) ?? series[series.length - 1]).rate
  return currency === "JPY" ? 1 / close : close
}

/**
 * When a row falls: a scheduled one is booked one to thirty days ahead; a
 * pending one is a few days old at most, because a charge settles within the
 * week; the rest fall across the last 120 days.
 */
function dateFor(rand: () => number, status: Transaction["status"]): Date {
  if (status === "scheduled") return dateBetween(rand, daysAhead(1), daysAhead(30))
  if (status === "pending") return dateBetween(rand, daysAgo(5), REFERENCE_DATE)
  return dateBetween(rand, daysAgo(120), REFERENCE_DATE)
}

type Row = Omit<Transaction, "id">
/** A payout's share of what its balance took in, sized once every row before it is known. */
type Sweep = { row: Row; share: number }

/**
 * The ledger as events — a transfer is one event and two rows — drawn per
 * status until that status has its rows, then shuffled into the order the
 * ids follow. Every amount is drawn here except a payout's: a payout sweeps
 * a share of what its balance took in since the one before, so it is sized
 * afterwards, in date order.
 */
function generate(): Transaction[] {
  const rand = seeded("transactions")
  const plan: { status: Transaction["status"]; kind: Transaction["kind"] }[] = []
  for (const [status, rows] of statusCounts()) {
    const mix = status === "scheduled" ? SCHEDULED_MIX : KIND_MIX
    for (let left = rows; left > 0; ) {
      let kind = pickWeighted(rand, mix)
      // Two rows to a transfer, so one is drawn only while two remain.
      if (kind === "transfer" && left < 2) kind = pickWeighted(rand, mix.filter(([entry]) => entry !== "transfer"))
      plan.push({ status, kind })
      left -= kind === "transfer" ? 2 : 1
    }
  }

  const sweeps: Sweep[] = []
  const events = shuffle(rand, plan).map(({ status, kind }): Row[] => {
    const at = dateFor(rand, status)
    if (kind === "charge" || kind === "refund") {
      const customer = pick(rand, CUSTOMERS)
      const currency = BILLING[customer.country] ?? "USD"
      const size = drawn(rand, RANGE[kind], currency)
      const amountCents = kind === "charge" ? size : -size
      return [{ kind, counterparty: customer.name, avatarUrl: avatarFor(customer.name), amountCents, currency, status, at }]
    }
    if (kind === "fee") {
      const currency = pickWeighted(rand, HOUSE_MIX)
      return [{ kind, counterparty: pick(rand, FEES), amountCents: -drawn(rand, RANGE.fee, currency), currency, status, at }]
    }
    if (kind === "payout") {
      const currency = pickWeighted(rand, HOUSE_MIX)
      const row: Row = { kind, counterparty: PAYOUT_ACCOUNTS[currency], amountCents: 0, currency, status, at }
      sweeps.push({ row, share: 0.6 + rand() * 0.35 })
      return [row]
    }
    // A transfer tops a balance up from the dollar one or sends money back to
    // it, sized in the smaller balance's units; the dollar leg is its worth
    // at that day's close.
    const other = pickWeighted(rand, TRANSFER_MIX)
    const otherMinor = minorBetween(rand, TRANSFER_RANGE[other], other)
    const dollarMinor = Math.round((otherMinor / MINOR[other]) * dollarsPer(other, at) * 100)
    const legs = { USD: dollarMinor, [other]: otherMinor } as Record<Currency, number>
    const [from, to]: Currency[] = chance(rand, 0.5) ? ["USD", other] : [other, "USD"]
    return [
      { kind, counterparty: `${to} balance`, amountCents: -legs[from], currency: from, status, at },
      { kind, counterparty: `${from} balance`, amountCents: legs[to], currency: to, status, at },
    ]
  })

  // What each balance has taken in from customers and not yet swept, walked
  // in date order: a payout that went — or is going, or is booked — takes its
  // share of it; one that failed takes nothing, and the money waits for the next.
  const unswept: Record<Currency, number> = { USD: 0, EUR: 0, GBP: 0, JPY: 0 }
  const sized = new Set(sweeps.map((sweep) => sweep.row))
  const share = new Map(sweeps.map((sweep) => [sweep.row, sweep.share]))
  for (const row of events.flat().sort((a, b) => a.at.getTime() - b.at.getTime())) {
    if (sized.has(row)) {
      const amount = Math.max(MIN_PAYOUT[row.currency] * MINOR[row.currency], Math.round(share.get(row)! * unswept[row.currency]))
      row.amountCents = -amount
      if (row.status !== "failed") unswept[row.currency] = Math.max(0, unswept[row.currency] - amount)
    } else if ((row.kind === "charge" || row.kind === "refund") && row.status === "completed") {
      unswept[row.currency] += row.amountCents
    }
  }

  return events.flat().map((row, index) => ({ id: rowId("txn", index + 1), ...row }))
}

/** The seeded ledger. The repository below owns the rows a demo reads. */
export const TRANSACTIONS: Transaction[] = generate()

export const transactions = createRepository(TRANSACTIONS, {
  searchKeys: ["counterparty"],
  filterKeys: ["kind", "currency", "status"],
})
lib/sample-data/voice-jobs.ts
import { REFERENCE_DATE, createRepository, daysAgo, intBetween, pick, rowId, seeded } from "./core"

/** One piece of text turned into speech: the script, the voice and the knobs it was read with. */
export type VoiceJob = {
  id: string
  /** The script, at most 2,000 characters. */
  text: string
  voice: "Ada" | "Kai" | "Noor" | "Sol" | "Yuki"
  /** Playback rate, 0.5 (half speed) to 2 (double). */
  speed: number
  /** Semitones above or below the voice's own pitch, -6 to 6. */
  pitch: number
  /** How closely the read sticks to the voice, 0 (expressive) to 1 (steady). */
  stability: number
  /** How long the audio runs, in seconds — a rule over the script and the speed. */
  durationSec: number
  status: "done" | "queued"
  createdAt: Date
}

const COUNT = 20

const VOICES: readonly VoiceJob["voice"][] = ["Ada", "Kai", "Noor", "Sol", "Yuki"]

// The rates a reader actually picks: mostly normal, a notch either side.
const SPEEDS = [0.8, 0.9, 1, 1, 1, 1.1, 1.25, 1.5]

/** Spoken words per second at normal speed — the pace of a clear narrator. */
const WORDS_PER_SECOND = 2.5

// The things a product team sends to a voice: announcements, phone lines,
// narration, reminders. Every one is a script somebody would actually read.
const SCRIPTS = [
  "Welcome to Northwind. Your dashboards are ready, and your first report will arrive on Monday morning.",
  "Thanks for calling Northwind support. Our team is available Monday to Friday, nine to six, Central European Time.",
  "Your export has finished and is waiting in the downloads folder. Open it whenever you are ready.",
  "This week the team shipped faster filters, a redesigned invoice page and a fix for the double email on retries.",
  "Press one to check the status of an order. Press two to speak with a member of the team.",
  "Good morning. You have three tasks due today and one review waiting for your comment.",
  "The maintenance window opens at midnight and closes at two. Dashboards stay readable; exports pause until it ends.",
  "Your workspace has grown past ten seats. Upgrade to the Team plan to keep every seat active.",
  "Chapter one. The harbour was quiet at dawn, and the boats sat low in the water with their nets folded.",
  "New sign-in from an unrecognised device in Lisbon. If this was you, no action is needed.",
  "Signups rose twelve percent week over week, driven by the launch post and a steady referral stream.",
  "Reminder: your trial ends on Friday. Pick a plan to keep the dashboards you have built.",
  "Thank you for your order. It has been packed and will leave the warehouse this afternoon.",
  "The incident is resolved. Exports have caught up, and no data was lost during the delay.",
  "Meeting notes, Tuesday. We agreed to move the pricing toggle above the fold and to test it for two weeks.",
  "Hello and welcome to the product tour. In the next two minutes we will set up your first data source.",
  "Your invoice for September is ready. The total is unchanged from last month.",
  "Tip of the day: press the slash key anywhere in the app to open the command palette.",
  "The quarterly review starts in five minutes in the main room. Bring the retention numbers.",
  "Recording ends here. Thanks for listening, and see you in the next update.",
]

/**
 * How long a script runs at a speed: the words in it at 2.5 a second, slower
 * at a lower rate and faster at a higher one, to a tenth of a second. The
 * voice page's `synthesize` action reads the same rule, so a job made at
 * runtime is timed exactly like a seeded one.
 */
export function durationFor(text: string, speed: number): number {
  const words = text.trim().split(/\s+/).filter(Boolean).length
  if (words === 0) return 0
  return Math.round((words / WORDS_PER_SECOND / speed) * 10) / 10
}

const HOUR = 1 / 24

function generate(): VoiceJob[] {
  const rand = seeded("voice-jobs")

  return SCRIPTS.slice(0, COUNT).map((text, index) => {
    // The two newest jobs are still in the queue; everything older has rendered.
    const hoursAgo = index < 2 ? 0.1 + index * 0.3 : intBetween(rand, 2, 30 * 24)
    const speed = pick(rand, SPEEDS)
    return {
      id: rowId("voi", index + 1),
      text,
      voice: pick(rand, VOICES),
      speed,
      pitch: intBetween(rand, -3, 3),
      stability: Math.round(rand() * 100) / 100,
      durationSec: durationFor(text, speed),
      status: hoursAgo < 1 ? "queued" : "done",
      createdAt: daysAgo(hoursAgo * HOUR, REFERENCE_DATE),
    }
  })
}

/** The seeded history. The repository below owns the rows the voice page prepends to. */
export const VOICE_JOBS: VoiceJob[] = generate()

export const voiceJobs = createRepository(VOICE_JOBS, {
  searchKeys: ["text"],
  filterKeys: ["voice", "status"],
})
lib/sample-data/workflows.ts
import { REFERENCE_DATE, chance, createRepository, dateBetween, daysAgo, intBetween, rowId, seeded } from "./core"

/**
 * The lucide icons a step may wear, by name. The page that draws a canvas
 * holds one component per name in a fixed map, so a step can only name an
 * icon that map has — this list is that contract.
 */
export const WORKFLOW_ICONS = [
  "building-2",
  "calendar-clock",
  "chart-line",
  "clipboard-list",
  "credit-card",
  "file-text",
  "flag",
  "gauge",
  "hourglass",
  "list-todo",
  "mail",
  "message-circle",
  "message-square",
  "package",
  "receipt",
  "rotate-cw",
  "shopping-cart",
  "split",
  "tag",
  "ticket",
  "user-check",
  "user-plus",
  "users",
] as const

export type WorkflowIcon = (typeof WORKFLOW_ICONS)[number]

/** One step on a canvas: what starts the flow, what it does, where it forks and where it waits. */
export type WorkflowNode = {
  id: string
  kind: "trigger" | "action" | "branch" | "delay"
  title: string
  /** A lucide icon name from WORKFLOW_ICONS. */
  icon: WorkflowIcon
  /** One line: which app the step works in, and on what. */
  summary: string
  /** The step's top-left corner on the plane, in pixels at 100%. */
  x: number
  y: number
}

/** A connection from one step to the next; a branch's two ways out are labelled "yes" and "no". */
export type WorkflowEdge = { from: string; to: string; label?: string }

/** One time the workflow ran, start to finish. */
export type WorkflowRun = {
  id: string
  startedAt: Date
  durationMs: number
  status: "succeeded" | "failed" | "running"
}

/** An automation: a trigger and the steps it sets off, drawn as a graph, with the runs it has made. */
export type Workflow = {
  id: string
  name: string
  status: "active" | "paused" | "draft"
  nodes: WorkflowNode[]
  edges: WorkflowEdge[]
  /** Oldest first. */
  runs: WorkflowRun[]
}

const COUNT = 8

/**
 * The grid the steps sit on: a column every 280px and a row every 104px, so a
 * step 216px wide and 76px tall leaves room for an edge and its label between
 * columns. A row may be a half — the step a branch leaves from sits between
 * its two ways out.
 */
const COLUMN = 280
const ROW = 104

/** A step as written below: its place on the grid by column and row. */
type Step = Omit<WorkflowNode, "x" | "y"> & { at: readonly [column: number, row: number] }

type Blueprint = {
  name: string
  status: Workflow["status"]
  steps: Step[]
  edges: WorkflowEdge[]
  /** How many runs a live workflow makes over the fortnight, and on what schedule a timed one runs. */
  runs: { count: [number, number]; failRate: number } | { mondaysAt: number } | null
}

// Real automations for the sample store, written by hand: a word combinator
// cannot draw a graph that means anything. Every app is Northwind's own or a
// fictional tool — "Team chat", "Mail", "Enrich" — never a third party's.
const BLUEPRINTS: readonly Blueprint[] = [
  {
    name: "Announce new orders",
    status: "active",
    steps: [
      { id: "trigger", kind: "trigger", title: "Order placed", icon: "shopping-cart", summary: "Store · a paid order comes in", at: [0, 0.5] },
      { id: "size", kind: "branch", title: "Over $500?", icon: "split", summary: "Checks the order total before tax", at: [1, 0.5] },
      { id: "big", kind: "action", title: "Post to #big-orders", icon: "message-square", summary: "Team chat · number, customer and total", at: [2, 0] },
      { id: "all", kind: "action", title: "Post to #orders", icon: "message-square", summary: "Team chat · one line per order", at: [2, 1] },
    ],
    edges: [
      { from: "trigger", to: "size" },
      { from: "size", to: "big", label: "yes" },
      { from: "size", to: "all", label: "no" },
    ],
    runs: { count: [12, 16], failRate: 0.06 },
  },
  {
    name: "Recover failed payments",
    status: "active",
    steps: [
      { id: "trigger", kind: "trigger", title: "Payment failed", icon: "credit-card", summary: "Billing · a card charge is declined", at: [0, 0.5] },
      { id: "email", kind: "action", title: "Email the customer", icon: "mail", summary: "Mail · the update-your-card note", at: [1, 0.5] },
      { id: "wait", kind: "delay", title: "Wait 3 days", icon: "hourglass", summary: "Gives the customer time to update", at: [2, 0.5] },
      { id: "check", kind: "branch", title: "Card updated?", icon: "split", summary: "Billing · reads the card on file", at: [3, 0.5] },
      { id: "retry", kind: "action", title: "Retry the charge", icon: "rotate-cw", summary: "Billing · charges the new card", at: [4, 0] },
      { id: "flag", kind: "action", title: "Flag for billing", icon: "flag", summary: "Adds a task for the billing team", at: [4, 1] },
    ],
    edges: [
      { from: "trigger", to: "email" },
      { from: "email", to: "wait" },
      { from: "wait", to: "check" },
      { from: "check", to: "retry", label: "yes" },
      { from: "check", to: "flag", label: "no" },
    ],
    runs: { count: [4, 7], failRate: 0.2 },
  },
  {
    name: "Route new leads",
    status: "active",
    steps: [
      { id: "trigger", kind: "trigger", title: "Lead created", icon: "user-plus", summary: "CRM · a form fill or an import", at: [0, 0.5] },
      { id: "enrich", kind: "action", title: "Enrich the company", icon: "building-2", summary: "Enrich · size, industry and country", at: [1, 0.5] },
      { id: "size", kind: "branch", title: "500 or more staff?", icon: "split", summary: "Reads the enriched headcount", at: [2, 0.5] },
      { id: "owner", kind: "action", title: "Assign an account exec", icon: "user-check", summary: "CRM · the enterprise desk takes it", at: [3, 0] },
      { id: "rotation", kind: "action", title: "Assign by rotation", icon: "users", summary: "CRM · next rep in the queue", at: [3, 1] },
      { id: "announce", kind: "action", title: "Post to #sales", icon: "message-square", summary: "Team chat · the lead and its owner", at: [4, 0.5] },
    ],
    edges: [
      { from: "trigger", to: "enrich" },
      { from: "enrich", to: "size" },
      { from: "size", to: "owner", label: "yes" },
      { from: "size", to: "rotation", label: "no" },
      { from: "owner", to: "announce" },
      { from: "rotation", to: "announce" },
    ],
    runs: { count: [8, 11], failRate: 0.1 },
  },
  {
    name: "Reorder low stock",
    status: "active",
    steps: [
      { id: "trigger", kind: "trigger", title: "Stock below reorder point", icon: "package", summary: "Inventory · a count drops past the line", at: [0, 0.5] },
      { id: "order", kind: "action", title: "Draft a purchase order", icon: "file-text", summary: "Inventory · to the product's supplier", at: [1, 0.5] },
      { id: "supplier", kind: "action", title: "Email the supplier", icon: "mail", summary: "Mail · the order as a PDF", at: [2, 0] },
      { id: "announce", kind: "action", title: "Post to #inventory", icon: "message-square", summary: "Team chat · what was ordered", at: [2, 1] },
    ],
    edges: [
      { from: "trigger", to: "order" },
      { from: "order", to: "supplier" },
      { from: "order", to: "announce" },
    ],
    runs: { count: [3, 5], failRate: 0.1 },
  },
  {
    name: "Welcome new customers",
    status: "active",
    steps: [
      { id: "trigger", kind: "trigger", title: "Customer signed up", icon: "user-plus", summary: "Store · a new account is created", at: [0, 0.5] },
      { id: "email", kind: "action", title: "Send the welcome email", icon: "mail", summary: "Mail · from the founders", at: [1, 0.5] },
      { id: "wait", kind: "delay", title: "Wait 2 days", icon: "hourglass", summary: "Leaves room for a first order", at: [2, 0.5] },
      { id: "check", kind: "branch", title: "Placed an order?", icon: "split", summary: "Store · reads the order book", at: [3, 0.5] },
      { id: "tag", kind: "action", title: "Tag as a buyer", icon: "tag", summary: "CRM · moves them to the buyer list", at: [4, 0] },
      { id: "code", kind: "action", title: "Send the WELCOME10 code", icon: "ticket", summary: "Mail · 10% off a first order", at: [4, 1] },
    ],
    edges: [
      { from: "trigger", to: "email" },
      { from: "email", to: "wait" },
      { from: "wait", to: "check" },
      { from: "check", to: "tag", label: "yes" },
      { from: "check", to: "code", label: "no" },
    ],
    runs: { count: [6, 9], failRate: 0.05 },
  },
  {
    name: "Weekly sales digest",
    status: "paused",
    steps: [
      { id: "trigger", kind: "trigger", title: "Every Monday at 08:00", icon: "calendar-clock", summary: "Schedule · UTC", at: [0, 0.5] },
      { id: "compile", kind: "action", title: "Compile last week's sales", icon: "chart-line", summary: "Store · revenue, orders and returns", at: [1, 0.5] },
      { id: "email", kind: "action", title: "Email the leadership list", icon: "mail", summary: "Mail · the digest as a table", at: [2, 0] },
      { id: "announce", kind: "action", title: "Post to #general", icon: "message-square", summary: "Team chat · the three headline numbers", at: [2, 1] },
    ],
    edges: [
      { from: "trigger", to: "compile" },
      { from: "compile", to: "email" },
      { from: "compile", to: "announce" },
    ],
    runs: { mondaysAt: 8 },
  },
  {
    name: "Refund follow-up",
    status: "draft",
    steps: [
      { id: "trigger", kind: "trigger", title: "Refund issued", icon: "receipt", summary: "Billing · an order is refunded", at: [0, 0.5] },
      { id: "reason", kind: "action", title: "Log the refund reason", icon: "clipboard-list", summary: "CRM · on the customer's record", at: [1, 0] },
      { id: "wait", kind: "delay", title: "Wait 7 days", icon: "hourglass", summary: "Lets the refund land first", at: [1, 1] },
      { id: "survey", kind: "action", title: "Ask how it went", icon: "message-circle", summary: "Mail · a two-question survey", at: [2, 1] },
    ],
    edges: [
      { from: "trigger", to: "reason" },
      { from: "trigger", to: "wait" },
      { from: "wait", to: "survey" },
    ],
    runs: null,
  },
  {
    name: "Churn-risk alert",
    status: "draft",
    // Half-built: the steps are drawn, the trigger that would start them is not.
    steps: [
      { id: "score", kind: "action", title: "Score the account", icon: "gauge", summary: "CRM · usage, seats and tickets", at: [0, 0.5] },
      { id: "check", kind: "branch", title: "Score below 40?", icon: "split", summary: "Reads the health score", at: [1, 0.5] },
      { id: "task", kind: "action", title: "Open a task for the owner", icon: "list-todo", summary: "CRM · due in two days", at: [2, 0] },
      { id: "log", kind: "action", title: "Log the score", icon: "clipboard-list", summary: "CRM · the account's health history", at: [2, 1] },
    ],
    edges: [
      { from: "score", to: "check" },
      { from: "check", to: "task", label: "yes" },
      { from: "check", to: "log", label: "no" },
    ],
    runs: null,
  },
]

/** Every Monday at `hour` UTC in the fortnight before now, oldest first. */
function mondaysAt(hour: number): Date[] {
  const from = daysAgo(14).getTime()
  const out: Date[] = []
  for (let day = 14; day >= 0; day--) {
    const at = daysAgo(day)
    const slot = Date.UTC(at.getUTCFullYear(), at.getUTCMonth(), at.getUTCDate(), hour)
    if (new Date(slot).getUTCDay() === 1 && slot >= from && slot <= REFERENCE_DATE.getTime()) out.push(new Date(slot))
  }
  return out
}

function generate(): Workflow[] {
  const rand = seeded("workflows")
  let runNumber = 0

  return BLUEPRINTS.slice(0, COUNT).map((blueprint, index) => {
    const nodes: WorkflowNode[] = blueprint.steps.map(({ at: [column, row], ...step }) => ({
      ...step,
      x: column * COLUMN,
      y: row * ROW,
    }))

    // A timed workflow ran on its schedule until it was paused; an event
    // workflow ran whenever its trigger fired, at any hour of the fortnight.
    const plan = blueprint.runs
    const starts =
      plan === null
        ? []
        : "mondaysAt" in plan
          ? mondaysAt(plan.mondaysAt)
          : Array.from({ length: intBetween(rand, plan.count[0], plan.count[1]) }, () =>
              dateBetween(rand, daysAgo(14), REFERENCE_DATE)
            ).sort((a, b) => a.getTime() - b.getTime())
    const failRate = plan && "failRate" in plan ? plan.failRate : 0.05

    const runs: WorkflowRun[] = starts.map((startedAt) => ({
      id: rowId("run", ++runNumber, 5),
      startedAt,
      // Work, not waiting: a step takes a few hundred milliseconds to call
      // its app, and a delay step's wait is not the run's own time.
      durationMs: nodes.filter((node) => node.kind !== "delay").length * intBetween(rand, 240, 900),
      status: chance(rand, failRate) ? "failed" : "succeeded",
    }))

    return {
      id: rowId("wfl", index + 1),
      name: blueprint.name,
      status: blueprint.status,
      nodes,
      edges: blueprint.edges.map((edge) => ({ ...edge })),
      runs,
    }
  })
}

/** The seeded automations. The repository below owns the rows a canvas changes. */
export const WORKFLOWS: Workflow[] = generate()

export const workflows = createRepository(WORKFLOWS, {
  searchKeys: ["name"],
  filterKeys: ["status"],
})