From 766f5a0fa9f6b20f38934be615e275f31a14de95 Mon Sep 17 00:00:00 2001 From: EllAchE <26192612+EllAchE@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:02:58 -0400 Subject: [PATCH 1/4] Add sized sample-event profiles and a deterministic roster generator A seven-speaker demo cannot show what the review queue, the agenda grid or the speaker gallery do under load. These two pure modules are the input to that: three size profiles (small/medium/large, medium keeping the `demo` slug as the default) and an index-addressed generator for the people and proposals that fill them. Nothing is wired up yet. --- db/seeds/event-sizes.ts | 106 ++++++++++++ db/seeds/generated-roster.ts | 301 +++++++++++++++++++++++++++++++++++ 2 files changed, 407 insertions(+) create mode 100644 db/seeds/event-sizes.ts create mode 100644 db/seeds/generated-roster.ts diff --git a/db/seeds/event-sizes.ts b/db/seeds/event-sizes.ts new file mode 100644 index 00000000..95c59f77 --- /dev/null +++ b/db/seeds/event-sizes.ts @@ -0,0 +1,106 @@ +/** + * How big the sample conferences are. + * + * A seven-speaker demo is honest about the product's *shape* and dishonest about its *load*. Every + * screen that has to cope with a real call for papers — the review queue, the agenda grid, the + * speaker gallery, the assignment spread across a reviewer pool — looks effortless on fourteen + * proposals and is the entire job on four hundred. So the seed ships one event at each of three + * scales instead of one event at whichever scale was convenient, and the middle one is what a + * visitor lands on. + * + * `demo` is the medium event and keeps its slug: it is the default sample event, it is what + * `lib/demo-entry-links.ts` points at, and it is the one with the hand-authored narrative core. + * `demo-small` and `demo-large` are the same conference wound down and up, so a reader comparing + * them is looking at scale rather than at two unrelated fixtures. + * + * ## Headshot slots + * + * Portraits come from the deterministic generator in `roman-profile-art.ts`, which has + * `ROMAN_SPEAKER_HEADSHOT_CAPACITY` (600) distinct slots. Each event draws a contiguous range, and + * the ranges must not overlap or two speakers in different events end up with the same face — which + * looks like a bug in the generator and is not one. The offsets below are spaced with room to grow: + * `first-settlement` holds 0-12 (the thirteen hand-authored `ROMAN_PROFILE_ART` entries), and the + * assertions in `event-sizes.test.ts` fail if a future edit lets two ranges touch. + */ + +export type EventSizeKey = 'small' | 'medium' | 'large'; + +export type EventSize = { + key: EventSizeKey; + slug: string; + name: string; + tagline: string; + /** Confirmed speakers on the published roster — one generated person per speaker. */ + speakers: number; + /** Proposals in the call, across every status. Roughly two per speaker, as real calls run. */ + submissions: number; + /** Concurrent rooms. Enough that the accepted talks fit the grid without double-booking. */ + rooms: number; + days: number; + /** Reviewers on the programme committee, sized so nobody carries an absurd queue. */ + reviewers: number; + /** First `roman-profile-art` slot this event draws from. Ranges must not overlap. */ + headshotSlotOffset: number; +}; + +export const EVENT_SIZES: Record = { + small: { + key: 'small', + slug: 'demo-small', + name: 'Cicero Forum: Provincial Assembly', + tagline: 'A single-track day for one province, sized like a community conference.', + speakers: 8, + submissions: 18, + rooms: 2, + days: 1, + reviewers: 2, + headshotSlotOffset: 100, + }, + medium: { + key: 'medium', + slug: 'demo', + name: 'Cicero Forum 2026', + tagline: 'The default sample event: a two-day, multi-track conference.', + speakers: 45, + submissions: 96, + rooms: 5, + days: 2, + reviewers: 6, + headshotSlotOffset: 13, + }, + large: { + key: 'large', + slug: 'demo-large', + name: 'Cicero Forum: Imperial Congress', + tagline: 'Three days, ten rooms, and a call for papers big enough to hurt.', + speakers: 180, + submissions: 384, + rooms: 10, + days: 3, + reviewers: 18, + headshotSlotOffset: 200, + }, +}; + +/** The size a visitor lands on when nobody has asked for one. */ +export const DEFAULT_EVENT_SIZE: EventSizeKey = 'medium'; + +/** The two events seeded beside the default, in the order they are created. */ +export const SIBLING_EVENT_SIZES: EventSize[] = [EVENT_SIZES.small, EVENT_SIZES.large]; + +export const ALL_EVENT_SIZES: EventSize[] = [ + EVENT_SIZES.small, + EVENT_SIZES.medium, + EVENT_SIZES.large, +]; + +/** Every sample-event slug the seed creates, default first. */ +export const SIZED_EVENT_SLUGS: string[] = [ + EVENT_SIZES[DEFAULT_EVENT_SIZE].slug, + ...SIBLING_EVENT_SIZES.map((size) => size.slug), +]; + +/** The mail domain a generated speaker for this event gets. Reserved by RFC 2606, so undeliverable. */ +export function generatedEmailDomain(size: EventSize): string { + return `${size.slug}.example`; +} diff --git a/db/seeds/generated-roster.ts b/db/seeds/generated-roster.ts new file mode 100644 index 00000000..2ce4f434 --- /dev/null +++ b/db/seeds/generated-roster.ts @@ -0,0 +1,301 @@ +/** + * The people and proposals that make a sample event big. + * + * Nobody is going to hand-write four hundred abstracts, and a sample event that only has fourteen + * of them cannot show what the product is for: a review queue, an agenda grid and an assignment + * spread only become interesting under load. So the bulk of a sized event is generated here, while + * the hand-authored core in `seed.ts` stays exactly as it is — a reader still meets Vitruvius and + * Cornelia first, and the crowd behind them is filler that reads like a conference rather than like + * `Speaker 214`. + * + * ## Everything is a pure function of an index + * + * There is no randomness. Slot `i` always produces the same person, the same email and the same + * portrait, which is what makes a reseed non-destructive in the ways that matter: the same speaker + * keeps their face and their sign-in address, a screenshot taken last week still matches, and the + * invariant tests can assert on concrete output instead of on a shape. `Math.random()` here would + * cost all of that and buy nothing — the names are already varied enough at the sizes we seed. + * + * Names are drawn by mixed-radix decomposition of the index rather than by hashing, so uniqueness + * is a property of the arithmetic and not a hope: distinct indices give distinct + * `(nomen, cognomen)` pairs for the first `NOMINA.length * COGNOMINA.length` people of each gender, + * which is an order of magnitude more than the largest event seeds. `generated-roster.test.ts` + * asserts it at the sizes actually used. + * + * ## Addresses + * + * Generated speakers live on `.example`. That is not decoration: `lib/demo-access.ts` + * will only ever print an on-screen magic link for an address at an RFC 2606 / 6761 reserved + * domain, because no mailbox can exist behind one. Moving these people to a domain that could + * receive mail would quietly turn a demo convenience into an account-takeover path, so the domain + * is derived in `event-sizes.ts` and never spelled by hand here. + */ + +export type SpeakerGender = 'man' | 'woman'; + +export type GeneratedSpeaker = { + email: string; + name: string; + gender: SpeakerGender; + title: string; + organization: string; + bio: string; + pronouns?: string; +}; + +export type GeneratedProposal = { + email: string; + title: string; + abstract: string; + takeaways: string; + level: string; + status: 'submitted' | 'under_review' | 'accepted' | 'declined' | 'waitlisted'; + /** Index into the event's track list, modulo its length. */ + trackIndex: number; + /** Index into the event's format list, modulo its length. */ + formatIndex: number; + daysAgo: number; +}; + +const PRAENOMINA = [ + 'Gaius', 'Lucius', 'Marcus', 'Publius', 'Quintus', 'Titus', 'Tiberius', 'Sextus', 'Aulus', + 'Decimus', 'Gnaeus', 'Spurius', 'Manius', 'Servius', 'Appius', 'Numerius', 'Vibius', +] as const; + +/** All end in `-ius`, so the feminine form is a rule rather than a second table. */ +const NOMINA = [ + 'Aemilius', 'Antonius', 'Aurelius', 'Caecilius', 'Calpurnius', 'Cassius', 'Claudius', + 'Cornelius', 'Curtius', 'Decius', 'Domitius', 'Duilius', 'Fabius', 'Flavius', 'Fulvius', + 'Furius', 'Gellius', 'Horatius', 'Hostilius', 'Julius', 'Junius', 'Licinius', 'Livius', + 'Lucretius', 'Manlius', 'Marcius', 'Memmius', 'Minucius', 'Mucius', 'Naevius', 'Octavius', + 'Papirius', 'Pompeius', 'Postumius', 'Quinctius', 'Rutilius', 'Sempronius', 'Servilius', + 'Sulpicius', 'Terentius', 'Valerius', 'Veturius', 'Vipsanius', +] as const; + +const MALE_COGNOMINA = [ + 'Agricola', 'Ahenobarbus', 'Balbus', 'Brutus', 'Caepio', 'Celsus', 'Cinna', 'Cotta', 'Crassus', + 'Crispus', 'Dentatus', 'Drusus', 'Faustus', 'Flaccus', 'Galba', 'Gracchus', 'Longinus', 'Lupus', + 'Macer', 'Magnus', 'Marcellus', 'Maximus', 'Nerva', 'Niger', 'Paullus', 'Pictor', 'Piso', + 'Priscus', 'Pulcher', 'Regulus', 'Rufus', 'Sabinus', 'Scaevola', 'Scipio', 'Severus', 'Silanus', + 'Strabo', 'Tubero', 'Varus', 'Vespillo', +] as const; + +/** A separate table rather than a derived form: mechanical `-us` to `-a` produces bad Latin. */ +const FEMALE_COGNOMINA = [ + 'Agrippina', 'Balbina', 'Celsa', 'Crispina', 'Domitilla', 'Drusilla', 'Fausta', 'Flaccilla', + 'Galeria', 'Gratiana', 'Hostilia', 'Justina', 'Lepida', 'Longina', 'Lucilla', 'Marcella', + 'Marciana', 'Matidia', 'Maxima', 'Messalina', 'Nigrina', 'Paulina', 'Plancina', 'Plotina', + 'Prisca', 'Procula', 'Pulchra', 'Quarta', 'Regilla', 'Rufina', 'Sabina', 'Secunda', 'Serena', + 'Severa', 'Silana', 'Tertia', 'Tranquilla', 'Valeriana', 'Verula', 'Vibiana', +] as const; + +const TITLES = [ + 'Aqueduct engineer', 'Public works surveyor', 'Grain supply administrator', 'Legal advocate', + 'Archive keeper', 'Rhetoric instructor', 'Mint superintendent', 'Harbour master', 'Census clerk', + 'Military engineer', 'Road commissioner', 'Treasury auditor', 'Provincial administrator', + 'Medical practitioner', 'Cartographer', 'Master shipwright', +] as const; + +const ORGANIZATIONS = [ + 'Office of Public Works', 'Ostia Harbour Authority', 'Grain Board', 'Public Libraries', + 'Rhetoric School of Rhodes', 'Provincial Assembly of Baetica', 'Colonia Narbo Martius', + 'Aqueduct Commission', 'Treasury of Saturn', 'Legion Engineering Corps', 'Guild of Shipwrights', + 'Census Office', 'Via Appia Commission', 'Library of Alexandria', +] as const; + +const BIO_FOCUS = [ + 'measurement, tolerance, and what happens when neither is written down', + 'the last mile of a supply chain, where most of the losses actually are', + 'why maintenance is the first budget cut and the most expensive one', + 'making a public record legible to the people it describes', + 'the gap between what a plan says and what the site allows', + 'training replacements before they are urgently needed', + 'work that only gets noticed when it stops', + 'costing a decision honestly before anyone is committed to it', + 'the difference between a rule and a rule that is followed', + 'scheduling around weather, festivals, and the people who ignore both', + 'keeping a service running while it is being rebuilt', + 'inspection access as a design constraint rather than an afterthought', +] as const; + +const BIO_CLOSERS = [ + 'Prefers a small change that survives to a large one that does not.', + 'Keeps notes obsessively and shares them.', + 'Has opinions about drainage and will share them unprompted.', + 'Believes most disasters were legible in the paperwork first.', + 'Would rather be corrected early than right late.', + 'Learned all of this the expensive way.', + 'Argues for boring solutions in public and in writing.', + 'Thinks the interesting part of any project is the handover.', +] as const; + +const TOPICS = [ + 'aqueduct maintenance', 'grain convoy scheduling', 'road survey tolerances', 'harbour dredging', + 'census data quality', 'court calendar backlogs', 'apprenticeship pipelines', + 'concrete curing in winter', 'provincial tax appeals', 'archive indexing', 'signal relay latency', + 'bridge load testing', 'quarry logistics', 'water quality testing', 'public bath heating', + 'fire brigade response times', 'granary pest control', 'coin die wear', + 'legionary field hospitals', 'ferry timetables', 'boundary dispute mediation', + 'sewer inspection access', 'timber seasoning', 'olive press throughput', 'letter courier routing', + 'mosaic workshop scheduling', 'amphitheatre crowd flow', 'lighthouse keeping rotas', + 'well drilling records', 'milestone placement', 'ration accounting', 'shipwreck salvage', + 'contract arbitration', 'stone transport barges', 'aqueduct settling tanks', + 'street lighting trials', 'market weights and measures', 'garrison supply forecasting', + 'drainage in reclaimed marsh', 'public notice boards', +] as const; + +/** Each shape takes the topic verbatim, so no shape may need it capitalised mid-sentence. */ +const TITLE_SHAPES = [ + (topic: string) => `Lessons from ten years of ${topic}`, + (topic: string) => `What we got wrong about ${topic}`, + (topic: string) => `A practical guide to ${topic}`, + (topic: string) => `Scaling ${topic} beyond one city`, + (topic: string) => `Measuring ${topic} without guesswork`, + (topic: string) => `The hidden cost of ${topic}`, + (topic: string) => `Rebuilding ${topic} after a failure`, + (topic: string) => `Who actually owns ${topic}`, + (topic: string) => `Automating the tedious parts of ${topic}`, + (topic: string) => `When ${topic} meets a shrinking budget`, + (topic: string) => `Teaching ${topic} to people who just arrived`, + (topic: string) => `The case against how we do ${topic}`, +] as const; + +const ABSTRACT_OPENERS = [ + 'A field report on what actually changed in', + 'Hard numbers, and some honest failures, from', + 'A working method for', + 'Three years of measurements on', + 'What the records show about', + 'An argument, with evidence, about', + 'A postmortem on', + 'The cheapest reliable approach we have found to', +] as const; + +const ABSTRACT_CLOSERS = [ + 'Expect specifics rather than principles.', + 'You should leave with a checklist you can use next week.', + 'Includes the numbers, the sources, and the parts that did not work.', + 'Aimed at people who have to make this decision with an incomplete budget.', + 'Assumes you have done this before and are tired of the usual advice.', + 'Every claim here is one you can check against the public record.', + 'The method is boring on purpose; that is the point.', + 'Bring your own constraints and we will work through them.', +] as const; + +const TAKEAWAY_LINES = [ + 'Write the tolerance down before anyone starts', + 'Budget for inspection, not just for construction', + 'Measure the thing you actually care about', + 'Plan the handover on the first day', + 'The cheap option is cheap until the second failure', + 'Publish the numbers even when they are bad', + 'Design for the person who maintains it', + 'Agree who decides before you need a decision', + 'Small reversible changes beat large irreversible ones', + 'Most surprises were visible in the paperwork', + 'Train two people, not one', + 'Test under the load you will actually see', +] as const; + +const LEVELS = ['Beginner', 'Intermediate', 'Advanced'] as const; + +const NON_ACCEPTED: GeneratedProposal['status'][] = [ + 'under_review', 'submitted', 'declined', 'waitlisted', 'under_review', 'declined', +]; + +function feminineNomen(nomen: string): string { + return nomen.replace(/us$/, 'a'); +} + +function pick(pool: readonly T[], index: number): T { + return pool[index % pool.length]!; +} + +/** + * The person at slot `index`. Genders alternate so a roster of any length stays balanced, and each + * gender walks its own name tables independently — which is why the two never collide on an email. + */ +export function generatedSpeakerAt(index: number, domain: string): GeneratedSpeaker { + const gender: SpeakerGender = index % 2 === 0 ? 'woman' : 'man'; + const within = Math.floor(index / 2); + + const nomenIndex = within % NOMINA.length; + const carry = Math.floor(within / NOMINA.length); + const nomen = NOMINA[nomenIndex]!; + + const cognomina = gender === 'woman' ? FEMALE_COGNOMINA : MALE_COGNOMINA; + const cognomen = cognomina[(carry + nomenIndex) % cognomina.length]!; + + const family = gender === 'woman' ? feminineNomen(nomen) : nomen; + const name = + gender === 'woman' + ? `${family} ${cognomen}` + : `${pick(PRAENOMINA, within + carry)} ${family} ${cognomen}`; + + const title = pick(TITLES, index * 5 + 1); + const organization = pick(ORGANIZATIONS, index * 3 + 2); + + return { + email: `${cognomen}.${family}@${domain}`.toLowerCase(), + name, + gender, + title, + organization, + bio: `${title} at ${organization}, working on ${pick(BIO_FOCUS, index * 7)}. ${pick(BIO_CLOSERS, index * 3)}`, + ...(gender === 'woman' ? { pronouns: 'she/her' } : {}), + }; +} + +/** `count` distinct people on `domain`, starting from slot `startIndex`. */ +export function generateSpeakers( + count: number, + options: { domain: string; startIndex?: number }, +): GeneratedSpeaker[] { + const start = options.startIndex ?? 0; + return Array.from({ length: count }, (_, offset) => + generatedSpeakerAt(start + offset, options.domain), + ); +} + +/** + * One proposal per slot. The first `acceptedCount` are accepted and belong to distinct speakers in + * roster order, so every confirmed speaker has exactly one talk to be scheduled into; the rest are + * the queue that makes a review screen worth looking at, spread back over the same people. + */ +export function generateProposals( + speakers: readonly GeneratedSpeaker[], + options: { count: number; acceptedCount: number; titleOffset?: number }, +): GeneratedProposal[] { + const titleOffset = options.titleOffset ?? 0; + + return Array.from({ length: options.count }, (_, index) => { + const accepted = index < options.acceptedCount; + const speaker = speakers[index % speakers.length]!; + const slot = titleOffset + index; + + // Each pass through the topic table advances the shape by five, and five is coprime with the + // twelve shapes — so a topic never repeats with the same framing until every pairing is used. + const topicIndex = slot % TOPICS.length; + const topic = TOPICS[topicIndex]!; + const shape = + TITLE_SHAPES[ + (Math.floor(slot / TOPICS.length) * 5 + topicIndex) % TITLE_SHAPES.length + ]!; + + return { + email: speaker.email, + title: shape(topic), + abstract: `${pick(ABSTRACT_OPENERS, slot * 3)} ${topic}. ${pick(ABSTRACT_CLOSERS, slot * 5 + 1)}`, + takeaways: [ + pick(TAKEAWAY_LINES, slot), + pick(TAKEAWAY_LINES, slot * 2 + 1), + pick(TAKEAWAY_LINES, slot * 3 + 5), + ].join('\n'), + level: pick(LEVELS, slot + 1), + status: accepted ? 'accepted' : pick(NON_ACCEPTED, index), + trackIndex: slot, + formatIndex: slot * 2 + (accepted ? 0 : 1), + daysAgo: 34 - (slot % 30), + }; + }); +} From a36445fa88fafc6caaf30a434882c2098bd7dc46 Mon Sep 17 00:00:00 2001 From: EllAchE <26192612+EllAchE@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:08:12 -0400 Subject: [PATCH 2/4] Seed the sample events at three sizes, with the default at medium `demo` keeps its slug and its hand-authored core and is filled out to the medium profile: 45 speakers, 96 proposals, a two-day grid across five rooms, and a review round with enough volume that the queue is worth sorting. The generated programme runs in two rooms the hand-written placements never touch, so the narrative agenda is untouched. `demo-small` and `demo-large` are the same conference at the other two scales, built from the shared form helpers rather than a third hand-written copy of the built-in field list. Generated identities are swept by domain on reseed, because the size profiles change and a name-by-name delete would strand accounts the next insert then collides with. --- db/seed.ts | 79 +++++++++- db/seeds/sized-demo.ts | 286 +++++++++++++++++++++++++++++++++++++ db/seeds/sized-roster.ts | 301 +++++++++++++++++++++++++++++++++++++++ lib/demo-access.ts | 8 +- 4 files changed, 670 insertions(+), 4 deletions(-) create mode 100644 db/seeds/sized-demo.ts create mode 100644 db/seeds/sized-roster.ts diff --git a/db/seed.ts b/db/seed.ts index 36dbeb64..eda6c509 100644 --- a/db/seed.ts +++ b/db/seed.ts @@ -1,4 +1,4 @@ -import { eq, inArray } from 'drizzle-orm'; +import { eq, inArray, like, or } from 'drizzle-orm'; import { requireEventWindow } from '../lib/event-dates'; import { newIcsUid } from '../lib/ics'; import { ensureDefaultTemplates } from '../lib/services/comms'; @@ -9,9 +9,12 @@ import { import { splitPersonName } from '../lib/person-name'; import type { RomanSpeakerHeadshotGender } from '../lib/roman-speaker-headshots'; import { getDb } from './client'; +import { EVENT_SIZES, SIBLING_EVENT_SIZES, generatedEmailDomain } from './seeds/event-sizes'; import { seedFirstSettlement } from './seeds/first-settlement'; import { removeEventFiles, seedProfileArt } from './seeds/profile-art-store'; import { ROMAN_PROFILE_ART } from './seeds/roman-profile-art'; +import { seedSizedDemo } from './seeds/sized-demo'; +import { seedSizedRoster } from './seeds/sized-roster'; import { emailLog, event, @@ -157,6 +160,25 @@ await db.delete(user).where( ), ); +/** + * The generated crowd is swept by domain rather than by a list, because the list is a function of + * the size profiles and those change. Shrinking `large` from 180 speakers to 120 with a name-by-name + * delete would leave sixty accounts behind that no event references and no later run ever collects — + * and the next run's insert would collide with them on `email`. + * + * Safe after the event sweep above and not before: these accounts own nothing, but their + * submissions and participant rows only disappear when the events they belong to do. + */ +await db + .delete(user) + .where( + or( + ...[EVENT_SIZES.medium, ...SIBLING_EVENT_SIZES].map((size) => + like(user.email, `%@${generatedEmailDomain(size)}`), + ), + ), + ); + // --------------------------------------------------------------------------- // People and the event // --------------------------------------------------------------------------- @@ -254,6 +276,10 @@ const rooms = await db { eventId: demo.id, name: 'Outer Peristyle', capacity: 600, floor: 'Ground', position: 0 }, { eventId: demo.id, name: 'Basilica Gallery', capacity: 180, floor: 'Ground', position: 1 }, { eventId: demo.id, name: 'Villa Workshop', capacity: 60, floor: 'Lower level', position: 2 }, + // The two rooms the generated programme runs in. The hand-written placements below stay in the + // three above, which is what keeps the two halves of the agenda from ever colliding. + { eventId: demo.id, name: 'East Garden Room', capacity: 120, floor: 'Ground', position: 3 }, + { eventId: demo.id, name: 'Atrium Studio', capacity: 90, floor: 'Lower level', position: 4 }, ]) .returning(); @@ -1503,13 +1529,60 @@ await db.insert(emailLog).values([ }, ]); +// --------------------------------------------------------------------------- +// Scale. Everything above is hand-written and is what a reader meets first; this brings the same +// event up to the medium size profile, which is what makes the review queue, the agenda grid and +// the speaker gallery worth looking at. `demo` stays the default sample event — the small and +// large siblings below exist to be compared against it, not to replace it. +// --------------------------------------------------------------------------- + +const filler = await seedSizedRoster(db, { + eventId: demo.id, + size: EVENT_SIZES.medium, + organizerUserId: organizer.id, + formId: cfp.id, + timezone: TIMEZONE, + tracks, + formats, + rooms, + personas, + days: [at(day1, 0), at(day2, 0)], + now, + existing: { + speakers: SPEAKER_EMAILS.length, + submissions: submissions.length, + sessions: scheduled.length, + }, + // The three rooms the hand-written placements above use. + reservedRooms: 3, + review: { + roundId: rounds[0].id, + reviewerUserIds: reviewers.map((reviewer) => reviewer.id), + criteria: criteriaByRound.get(rounds[0].id)!, + }, +}); + +const siblings = []; +for (const size of SIBLING_EVENT_SIZES) { + siblings.push(await seedSizedDemo(db, { size, organizerUserId: organizer.id, now })); +} + const firstSettlement = await seedFirstSettlement(db, organizer.id, now); console.log( - `Seeded /${SLUG}: ${submissions.length} submissions, ${uniqueAccepted.length} speakers, ` + - `${scheduled.length} scheduled sessions, ${tasks.length + scopedTasks.length} tasks. ` + + `Seeded /${SLUG} (${EVENT_SIZES.medium.key}): ` + + `${submissions.length + filler.submissions} submissions, ` + + `${uniqueAccepted.length + filler.speakers} speakers, ` + + `${scheduled.length + filler.scheduledSessions} scheduled sessions, ` + + `${tasks.length + scopedTasks.length} tasks. ` + `Sign in as ${organizer.email} and read the link at /organizer/mail.`, ); +for (const sibling of siblings) { + console.log( + `Seeded /${sibling.slug} (${sibling.size}): ${sibling.submissions} submissions, ` + + `${sibling.speakers} speakers, ${sibling.scheduledSessions} scheduled sessions.`, + ); +} console.log( `Seeded /${firstSettlement.slug}: ${firstSettlement.submissions} submissions, ` + `${firstSettlement.speakers} speakers, ${firstSettlement.scheduledSessions} scheduled sessions, ` + diff --git a/db/seeds/sized-demo.ts b/db/seeds/sized-demo.ts new file mode 100644 index 00000000..3f1a370d --- /dev/null +++ b/db/seeds/sized-demo.ts @@ -0,0 +1,286 @@ +import { requireEventWindow } from '../../lib/event-dates'; +import { splitPersonName } from '../../lib/person-name'; +import { seedBuiltinFields, seedRoles } from '../../lib/services/forms'; +import type { Database } from '../client'; +import { + event, + form, + formField, + formParticipantRole, + membership, + persona, + reviewRound, + room, + scorecardCriterion, + sessionFormat, + track, + user, +} from '../schema'; +import type { EventSize } from './event-sizes'; +import { generatedEmailDomain } from './event-sizes'; +import { generatedSpeakerAt } from './generated-roster'; +import { seedSizedRoster } from './sized-roster'; + +/** + * The small and large sample events. + * + * `demo` is the one with the hand-authored narrative, and it stays the default. These two exist so + * that "does this screen still work at four hundred proposals" is a question you can answer by + * opening a URL rather than by writing a load fixture — and so that the honest answer to "how does + * it feel for a twenty-person meetup" is also one click away. Same conference, three scales. + * + * ## Why the form is not written out by hand + * + * Both existing seeds spell their CFP fields inline, which is how one of them came to be missing a + * built-in and hard-failed the first time an organizer pressed Publish — see the note on + * `db/seed-form-invariants.test.ts`. This one calls `seedBuiltinFields` and `seedRoles`, the same + * helpers `createForm` uses, so the invariant holds by construction instead of by a regex watching + * the source. A third hand-written copy of that list is exactly the drift those tests exist to + * catch. + */ + +const DAY = 86_400_000; + +const TRACK_NAMES = [ + { name: 'Infrastructure', color: 'lapis' }, + { name: 'Governance', color: 'vermilion' }, + { name: 'Knowledge & Communication', color: 'verdigris' }, + { name: 'Logistics & Operations', color: 'ochre' }, +] as const; + +const ROOM_NAMES = [ + 'Forum Hall', 'Basilica Gallery', 'East Garden Room', 'Atrium Studio', 'Curia Annexe', + 'Portico Room', 'Tabularium', 'Aqueduct Room', 'Lower Cloister', 'Marble Court', +] as const; + +export type SizedDemoResult = { + slug: string; + size: EventSize['key']; + speakers: number; + submissions: number; + scheduledSessions: number; +}; + +export async function seedSizedDemo( + db: Database, + params: { size: EventSize; organizerUserId: string; now: Date }, +): Promise { + const { size, organizerUserId, now } = params; + const timezone = 'America/Los_Angeles'; + const domain = generatedEmailDomain(size); + + // Deliberately not six weeks out like `demo`: three sample events landing on the same dates would + // make every "what is coming up" surface look like one conference triple-booked with itself. + const firstDay = new Date(now.getTime() + (size.key === 'small' ? 21 : 70) * DAY); + firstDay.setUTCHours(0, 0, 0, 0); + const days = Array.from({ length: size.days }, (_, index) => new Date(firstDay.getTime() + index * DAY)); + const lastDay = days[days.length - 1]!; + + const isoDate = (date: Date) => date.toISOString().slice(0, 10); + /** Wall-clock in the event's UTC-8 zone, matching `db/seed.ts`. */ + const at = (day: Date, hour: number) => new Date(day.getTime() + (hour + 8) * 3_600_000); + const ago = (offset: number) => new Date(now.getTime() - offset * DAY); + + const window = requireEventWindow( + timezone, + `${isoDate(firstDay)}T09:00`, + `${isoDate(lastDay)}T17:00`, + ); + + const [created] = await db + .insert(event) + .values({ + slug: size.slug, + name: size.name, + tagline: size.tagline, + descriptionMarkdown: + `The ${size.key} sample event: ${size.speakers} speakers, ${size.submissions} proposals, ` + + `${size.rooms} rooms over ${size.days} day${size.days === 1 ? '' : 's'}. It exists beside ` + + 'the default `demo` event so the same screens can be read at a different scale. Everything ' + + 'here is generated and editable — break it freely.', + eventType: 'Conference', + timezone: window.timezone, + startsAt: window.startsAt, + endsAt: window.endsAt, + startsOn: window.startsOn, + endsOn: window.endsOn, + venueName: 'The Getty Villa', + venueAddress: '17985 Pacific Coast Highway, Pacific Palisades, CA', + ownerUserId: organizerUserId, + }) + .returning(); + + // Generated at a high index so no reviewer can collide with a speaker on the same domain: the + // name tables are walked by index, and these two ranges never meet. + const reviewerPeople = Array.from({ length: size.reviewers }, (_, index) => + generatedSpeakerAt(1000 + index, domain), + ); + const reviewerUsers = await db + .insert(user) + .values( + reviewerPeople.map((person) => ({ + email: person.email, + name: person.name, + ...splitPersonName(person.name), + })), + ) + .returning(); + + await db.insert(membership).values([ + { userId: organizerUserId, eventId: created.id, role: 'organizer' as const }, + ...reviewerUsers.map((reviewer) => ({ + userId: reviewer.id, + eventId: created.id, + role: 'reviewer' as const, + })), + ]); + + const tracks = await db + .insert(track) + .values( + TRACK_NAMES.slice(0, size.key === 'small' ? 2 : 4).map((entry, index) => ({ + eventId: created.id, + name: entry.name, + color: entry.color, + position: index, + })), + ) + .returning(); + + const rooms = await db + .insert(room) + .values( + Array.from({ length: size.rooms }, (_, index) => ({ + eventId: created.id, + name: ROOM_NAMES[index % ROOM_NAMES.length]!, + capacity: index === 0 ? 600 : 120, + floor: index < 2 ? 'Ground' : 'Lower level', + position: index, + })), + ) + .returning(); + + const formats = await db + .insert(sessionFormat) + .values([ + { eventId: created.id, name: 'Keynote', durationMinutes: 45, position: 0 }, + { eventId: created.id, name: 'Talk', durationMinutes: 30, position: 1 }, + { eventId: created.id, name: 'Workshop', durationMinutes: 90, position: 2 }, + ]) + .returning(); + + const personas = await db + .insert(persona) + .values([ + { + eventId: created.id, + name: 'Public works engineer', + description: 'Builds and maintains the city', + position: 0, + }, + { + eventId: created.id, + name: 'Civic leader', + description: 'Makes policy and coordinates institutions', + position: 1, + }, + ]) + .returning(); + + const [cfp] = await db + .insert(form) + .values({ + eventId: created.id, + kind: 'cfp', + targetType: 'abstract', + collectsParticipants: true, + name: `${size.name} — main call`, + externalTitle: `${size.name} call for speakers`, + pageHeading: 'Speak with us', + showWelcome: true, + slug: 'speak', + status: 'open', + maxParticipants: 4, + introMarkdown: + 'Practical talks rooted in Roman infrastructure, governance, knowledge, or logistics. ' + + 'Show the work rather than the legend.', + closesAt: new Date(firstDay.getTime() - 14 * DAY), + }) + .returning(); + + await db.insert(formField).values(seedBuiltinFields(cfp.id)); + await db.insert(formParticipantRole).values(seedRoles(cfp.id)); + + const [round] = await db + .insert(reviewRound) + .values({ + eventId: created.id, + name: 'First pass', + position: 0, + status: 'open', + blindUntilClose: true, + opensAt: ago(30), + closesAt: new Date(now.getTime() + 7 * DAY), + }) + .returning(); + + const criteria = await db + .insert(scorecardCriterion) + .values([ + { + reviewRoundId: round.id, + label: 'Relevance', + description: 'Does this matter to the audience we are convening?', + weight: 2, + maxScore: 5, + position: 0, + }, + { + reviewRoundId: round.id, + label: 'Depth', + description: 'Is there something here you cannot get from a blog post?', + weight: 2, + maxScore: 5, + position: 1, + }, + { + reviewRoundId: round.id, + label: 'Speaker readiness', + description: 'Evidence they can deliver it well.', + weight: 1, + maxScore: 5, + position: 2, + }, + ]) + .returning(); + + const filled = await seedSizedRoster(db, { + eventId: created.id, + size, + organizerUserId, + formId: cfp.id, + timezone, + tracks, + formats, + rooms, + personas, + days: days.map((day) => at(day, 0)), + now, + // Nothing is hand-written on these events, so the generator supplies the whole programme. + existing: { speakers: 0, submissions: 0, sessions: 0 }, + reservedRooms: 0, + review: { + roundId: round.id, + reviewerUserIds: reviewerUsers.map((reviewer) => reviewer.id), + criteria, + }, + }); + + return { + slug: size.slug, + size: size.key, + speakers: filled.speakers, + submissions: filled.submissions, + scheduledSessions: filled.scheduledSessions, + }; +} diff --git a/db/seeds/sized-roster.ts b/db/seeds/sized-roster.ts new file mode 100644 index 00000000..69ead492 --- /dev/null +++ b/db/seeds/sized-roster.ts @@ -0,0 +1,301 @@ +import { eq } from 'drizzle-orm'; +import { newIcsUid } from '../../lib/ics'; +import { splitPersonName } from '../../lib/person-name'; +import type { Database } from '../client'; +import { + event, + participant, + participantRole, + reviewAssignment, + scheduledSession, + score, + submission, + user, +} from '../schema'; +import type { EventSize } from './event-sizes'; +import { generatedEmailDomain } from './event-sizes'; +import { generateProposals, generateSpeakers } from './generated-roster'; +import { seedProfileArt } from './profile-art-store'; + +/** + * Fills a sample event out to its size. + * + * The hand-authored core of an event — Vitruvius, Cornelia, the fourteen proposals somebody + * actually wrote — is what makes the demo readable. This is what makes it *load-bearing*: the rest + * of the roster, the rest of the call, the rest of the grid. It is additive by design, so the + * narrative fixtures above it keep their refs, their reviews and their agenda placements exactly as + * they were, and a reader who only ever looks at the first screen sees no difference. + * + * Used by `db/seed.ts` to bring `demo` up to medium, and by `db/seeds/sized-demo.ts` to build the + * small and large siblings from an otherwise bare event. + * + * ## Why the generated sessions get their own rooms + * + * Placing them anywhere in the grid would mean checking each candidate slot against the + * hand-authored placements, which have deliberately awkward starts and a ninety-minute workshop. + * Reserving the first `reservedRooms` rooms for the hand-written agenda makes overlap impossible by + * construction instead of by a predicate that has to stay correct as the fixtures are edited — and + * an organizer looking at the board still sees one continuous programme. + */ + +/** 30-minute slots across a conference day, with an hour out for lunch. */ +const SLOT_MINUTES = 30; +const SLOTS_PER_DAY = [ + 9 * 60, 9 * 60 + 30, 10 * 60, 10 * 60 + 30, 11 * 60, 11 * 60 + 30, + 13 * 60, 13 * 60 + 30, 14 * 60, 14 * 60 + 30, 15 * 60, 15 * 60 + 30, + 16 * 60, 16 * 60 + 30, +]; + +export type SizedRosterResult = { + speakers: number; + submissions: number; + scheduledSessions: number; +}; + +export type SizedRosterParams = { + eventId: string; + size: EventSize; + organizerUserId: string; + formId: string; + timezone: string; + tracks: readonly { id: string }[]; + formats: readonly { id: string; durationMinutes: number; name: string }[]; + rooms: readonly { id: string }[]; + personas: readonly { id: string }[]; + /** + * One instant per conference day: local midnight, expressed in UTC. Slot times below are added + * as wall-clock minutes, so passing a bare UTC midnight for an event that is not on UTC would + * schedule the whole programme at the wrong hour. + */ + days: readonly Date[]; + now: Date; + /** Speakers, proposals and sessions the hand-authored fixtures already created. */ + existing: { speakers: number; submissions: number; sessions: number }; + /** Rooms the hand-authored agenda already occupies. Generated sessions never touch them. */ + reservedRooms: number; + /** Optional: put the generated queue in front of reviewers so the review screen has volume. */ + review?: { + roundId: string; + reviewerUserIds: readonly string[]; + criteria: readonly { id: string; maxScore: number }[]; + }; +}; + +/** Deterministic spread, so the sorted review queue means something. Mirrors `seed.ts`. */ +function scoreFor(seed: number, max: number): number { + return ((seed * 7919) % max) + 1; +} + +export async function seedSizedRoster( + db: Database, + params: SizedRosterParams, +): Promise { + const { size, existing } = params; + const speakerCount = size.speakers - existing.speakers; + const proposalCount = size.submissions - existing.submissions; + + if (speakerCount <= 0 || proposalCount <= 0) { + throw new Error( + `Size "${size.key}" is smaller than the fixtures already on ${size.slug}: ` + + `${size.speakers} speakers / ${size.submissions} proposals requested, ` + + `${existing.speakers} / ${existing.submissions} already seeded.`, + ); + } + + const openRooms = params.rooms.slice(params.reservedRooms); + const capacity = openRooms.length * params.days.length * SLOTS_PER_DAY.length; + if (capacity < speakerCount) { + throw new Error( + `Size "${size.key}" cannot be scheduled: ${speakerCount} accepted talks need more than the ` + + `${capacity} slots in ${openRooms.length} rooms over ${params.days.length} days.`, + ); + } + + const speakers = generateSpeakers(speakerCount, { + domain: generatedEmailDomain(size), + startIndex: existing.speakers, + }); + + // One accepted proposal per generated speaker, so every one of them has a talk on the grid and + // the public roster length is exactly what the size profile promises. + const proposals = generateProposals(speakers, { + count: proposalCount, + acceptedCount: speakerCount, + titleOffset: existing.submissions, + }); + + const speakerUsers = await db + .insert(user) + .values( + speakers.map((speaker) => ({ + email: speaker.email, + name: speaker.name, + ...splitPersonName(speaker.name), + })), + ) + .returning(); + const userByEmail = new Map(speakerUsers.map((row) => [row.email, row])); + + const profileArt = await seedProfileArt(db, { + eventId: params.eventId, + uploadedByUserId: params.organizerUserId, + speakerKeys: speakers.map((speaker) => speaker.email), + slotOffset: size.headshotSlotOffset + existing.speakers, + gender: (email) => speakers.find((speaker) => speaker.email === email)?.gender, + }); + + const generatedParticipants = await db + .insert(participant) + .values( + speakers.map((speaker) => ({ + eventId: params.eventId, + userId: userByEmail.get(speaker.email)!.id, + displayName: speaker.name, + pronouns: speaker.pronouns ?? null, + jobTitle: speaker.title, + company: speaker.organization, + bioMarkdown: speaker.bio, + // Same reason as the hand-authored roster: the schema default is `invited`, which the + // public read model excludes, and a gallery of invited speakers renders empty. + headshotFileId: profileArt.get(speaker.email)!, + timezone: params.timezone, + workflowStatus: 'confirmed' as const, + links: [{ label: 'Website', url: 'https://example.com' }], + })), + ) + .returning(); + const participantByUser = new Map(generatedParticipants.map((row) => [row.userId, row])); + + const ago = (days: number) => new Date(params.now.getTime() - days * 86_400_000); + const talkFormat = params.formats.find((format) => format.name === 'Talk') ?? params.formats[0]!; + + const generatedSubmissions = await db + .insert(submission) + .values( + proposals.map((proposal, index) => ({ + eventId: params.eventId, + formId: params.formId, + ref: existing.submissions + index + 1, + submitterUserId: userByEmail.get(proposal.email)!.id, + title: proposal.title, + descriptionMarkdown: proposal.abstract, + // Accepted talks all run 30 minutes so they tile the grid without overlapping. + formatId: + proposal.status === 'accepted' + ? talkFormat.id + : params.formats[proposal.formatIndex % params.formats.length]!.id, + trackId: params.tracks[proposal.trackIndex % params.tracks.length]!.id, + level: proposal.level, + personaId: params.personas[index % params.personas.length]!.id, + status: proposal.status, + answers: { takeaways: proposal.takeaways, given_before: false }, + submittedAt: ago(proposal.daysAgo), + decidedAt: ['accepted', 'declined', 'waitlisted'].includes(proposal.status) ? ago(4) : null, + decisionNote: + proposal.status === 'declined' + ? 'A good proposal that lost to a stronger one in the same track.' + : null, + createdAt: ago(proposal.daysAgo), + })), + ) + .returning(); + + await db.insert(participantRole).values( + generatedSubmissions.map((row) => ({ + submissionId: row.id, + participantId: participantByUser.get(row.submitterUserId)!.id, + kind: 'speaker' as const, + isPrimary: true, + })), + ); + + // --------------------------------------------------------------------------- + // Agenda + // --------------------------------------------------------------------------- + + const acceptedGenerated = generatedSubmissions.filter((row) => row.status === 'accepted'); + const scheduled = await db + .insert(scheduledSession) + .values( + acceptedGenerated.map((row, index) => { + // Walk rooms fastest, then slots, then days: the first day fills before the second, and + // each slot reads as a parallel track rather than as one room used back to back. + const roomIndex = index % openRooms.length; + const slotIndex = Math.floor(index / openRooms.length) % SLOTS_PER_DAY.length; + const dayIndex = Math.floor(index / (openRooms.length * SLOTS_PER_DAY.length)); + const start = new Date( + params.days[dayIndex]!.getTime() + SLOTS_PER_DAY[slotIndex]! * 60_000, + ); + + return { + eventId: params.eventId, + submissionId: row.id, + ref: existing.sessions + index + 1, + title: row.title, + descriptionMarkdown: row.descriptionMarkdown, + roomId: openRooms[roomIndex]!.id, + trackId: row.trackId, + formatId: row.formatId, + startsAt: start, + endsAt: new Date(start.getTime() + SLOT_MINUTES * 60_000), + status: 'published' as const, + icsUid: newIcsUid(), + }; + }), + ) + .returning(); + + // --------------------------------------------------------------------------- + // Reviews. A queue of a hundred untouched proposals reads as a broken screen rather than a busy + // one, so everything that reached a reviewer carries assignments and scores. + // --------------------------------------------------------------------------- + + if (params.review && params.review.reviewerUserIds.length > 0) { + const { roundId, reviewerUserIds, criteria } = params.review; + const reviewable = generatedSubmissions.filter((row) => row.status !== 'submitted'); + + const assignments = await db + .insert(reviewAssignment) + .values( + reviewable.flatMap((row, index) => + // Two reviewers each, walked round-robin so the load spreads evenly over the panel. + [0, 1].map((offset) => ({ + reviewRoundId: roundId, + submissionId: row.id, + reviewerUserId: reviewerUserIds[(index * 2 + offset) % reviewerUserIds.length]!, + status: 'completed' as const, + comment: + 'Well scoped, and the evidence is checkable. Would want the demonstration tightened.', + completedAt: ago(6), + })), + ), + ) + .returning(); + + if (assignments.length > 0 && criteria.length > 0) { + await db.insert(score).values( + assignments.flatMap((assignment, index) => + criteria.map((criterion, position) => ({ + reviewAssignmentId: assignment.id, + criterionId: criterion.id, + value: scoreFor(index + position * 3 + 1, criterion.maxScore), + })), + ), + ); + } + } + + await db + .update(event) + .set({ + submissionSeq: existing.submissions + generatedSubmissions.length, + sessionSeq: existing.sessions + scheduled.length, + }) + .where(eq(event.id, params.eventId)); + + return { + speakers: speakers.length, + submissions: generatedSubmissions.length, + scheduledSessions: scheduled.length, + }; +} diff --git a/lib/demo-access.ts b/lib/demo-access.ts index 6673326c..dc9b4252 100644 --- a/lib/demo-access.ts +++ b/lib/demo-access.ts @@ -52,7 +52,13 @@ import { undeliverableRecipient } from './mail/config'; * greylist — an auth bypass triggerable by a stranger with a bounce. */ -const DEFAULT_DEMO_EVENT_SLUGS = ['demo', 'first-settlement'] as const; +/** + * Every event the seed creates. `demo-small` and `demo-large` are the same conference at other + * scales, and they are listed here for condition 4 above rather than as a convenience: a speaker + * generated onto one of them holds a membership, and an omission here would read that membership as + * "this identity has been let into a real event" and quietly close the demo sign-in for everybody. + */ +const DEFAULT_DEMO_EVENT_SLUGS = ['demo', 'demo-small', 'demo-large', 'first-settlement'] as const; /** Which events count as the demo. `DEMO_EVENT_SLUGS` overrides it for a differently seeded clone. */ export function demoEventSlugs(): string[] { From 4519aae73050b34055a4d645619573929860189a Mon Sep 17 00:00:00 2001 From: EllAchE <26192612+EllAchE@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:10:49 -0400 Subject: [PATCH 3/4] Cover the sized seeds with tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two properties that matter are not enforced by any type: portrait ranges must not overlap between events, and generated addresses must be unique and undeliverable. Both fail in ways that only show up on a screen or halfway through a seed run, so they are asserted directly. Extends the existing publication invariants over `sized-roster.ts` — a gallery of forty invited speakers is exactly as empty as a gallery of seven. --- db/seed-publication-invariants.test.ts | 29 +++++- db/seeds/event-sizes.test.ts | 89 +++++++++++++++++ db/seeds/generated-roster.test.ts | 132 +++++++++++++++++++++++++ lib/demo-access.test.ts | 2 +- 4 files changed, 248 insertions(+), 4 deletions(-) create mode 100644 db/seeds/event-sizes.test.ts create mode 100644 db/seeds/generated-roster.test.ts diff --git a/db/seed-publication-invariants.test.ts b/db/seed-publication-invariants.test.ts index cfe90ca8..a51a6632 100644 --- a/db/seed-publication-invariants.test.ts +++ b/db/seed-publication-invariants.test.ts @@ -6,16 +6,27 @@ function source(path: string): string { return readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8'); } +/** + * `sized-roster.ts` writes a second roster onto the same events — the generated crowd that brings + * each sample event up to its size profile. It is held to the same two invariants, because a + * gallery of forty invited speakers is exactly as empty as a gallery of seven. + */ +const SEEDS = [ + './seed.ts', + './seeds/first-settlement.ts', + './seeds/sized-roster.ts', +] as const; + function participantInsert(path: string): string { const match = source(path).match( - /const participants = await db\s+\.insert\(participant\)[\s\S]*?\.returning\(\);/, + /const \w+ = await db\s+\.insert\(participant\)[\s\S]*?\.returning\(\);/, ); expect(match, `${path} should contain its participant fixture insert`).not.toBeNull(); return match![0]; } describe('public speaker seed invariants', () => { - it.each(['./seed.ts', './seeds/first-settlement.ts'])( + it.each(SEEDS)( 'marks the public profiles in %s as confirmed', (path) => { expect(participantInsert(path)).toContain("workflowStatus: 'confirmed' as const"); @@ -28,13 +39,25 @@ describe('public speaker seed invariants', () => { * `speakerHeadshotPath` answers null for it, and the roster quietly renders initials. Assert the * wiring, since no type is going to. */ - it.each(['./seed.ts', './seeds/first-settlement.ts'])( + it.each(SEEDS)( 'gives the public profiles in %s a generated headshot', (path) => { expect(participantInsert(path)).toMatch(/headshotFileId: profileArt\.get\(/); }, ); + /** + * The sized siblings build their call for speakers from the shared helpers rather than a third + * hand-written copy of the built-in field list — the drift `seed-form-invariants.test.ts` exists + * to catch. Assert they keep reaching for the helpers instead of spelling the fields out. + */ + it('builds the sized sibling CFP from the shared form helpers', () => { + const sizedDemo = source('./seeds/sized-demo.ts'); + expect(sizedDemo).toContain('seedBuiltinFields(cfp.id)'); + expect(sizedDemo).toContain('seedRoles(cfp.id)'); + expect(sizedDemo).not.toMatch(/builtinKey: '/); + }); + it('keeps the public bundle gated to confirmed participants', () => { const publicQueries = source('../app/embed/queries.ts'); expect(publicQueries).toContain("eq(participant.workflowStatus, 'confirmed')"); diff --git a/db/seeds/event-sizes.test.ts b/db/seeds/event-sizes.test.ts new file mode 100644 index 00000000..28d120aa --- /dev/null +++ b/db/seeds/event-sizes.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { ROMAN_SPEAKER_HEADSHOT_CAPACITY } from '../../lib/roman-speaker-headshots'; +import { + ALL_EVENT_SIZES, + DEFAULT_EVENT_SIZE, + EVENT_SIZES, + SIZED_EVENT_SLUGS, + generatedEmailDomain, +} from './event-sizes'; +import { ROMAN_PROFILE_ART } from './roman-profile-art'; + +/** + * These are the assertions that keep the three sizes from quietly interfering with each other. Each + * one has a failure mode that is invisible on the seeded database and only shows up on a screen: + * overlapping portrait ranges look like a bug in the generator, and an event whose accepted talks + * outnumber its slots throws in the middle of a seed run that has already written half an event. + */ + +describe('event size profiles', () => { + it('keeps the default sample event on the `demo` slug', () => { + expect(EVENT_SIZES[DEFAULT_EVENT_SIZE].slug).toBe('demo'); + expect(SIZED_EVENT_SLUGS[0]).toBe('demo'); + }); + + it('grows monotonically, so the three are actually comparable', () => { + const speakers = ALL_EVENT_SIZES.map((size) => size.speakers); + const submissions = ALL_EVENT_SIZES.map((size) => size.submissions); + expect(speakers).toEqual([...speakers].sort((a, b) => a - b)); + expect(submissions).toEqual([...submissions].sort((a, b) => a - b)); + }); + + it('asks for more proposals than it accepts, or the review queue is empty', () => { + for (const size of ALL_EVENT_SIZES) { + expect(size.submissions).toBeGreaterThan(size.speakers); + } + }); + + /** + * `first-settlement` holds slots 0 to 12. Two events sharing a slot hand two different people the + * same face, which reads as a broken generator rather than as a seed collision. + */ + it('draws non-overlapping portrait ranges that fit the generator', () => { + const ranges = [ + { key: 'first-settlement', from: 0, to: ROMAN_PROFILE_ART.length - 1 }, + ...ALL_EVENT_SIZES.map((size) => ({ + key: size.key, + from: size.headshotSlotOffset, + to: size.headshotSlotOffset + size.speakers - 1, + })), + ].sort((a, b) => a.from - b.from); + + for (const range of ranges) { + expect(range.to).toBeLessThan(ROMAN_SPEAKER_HEADSHOT_CAPACITY); + } + for (const [index, range] of ranges.slice(1).entries()) { + const previous = ranges[index]!; + expect({ after: previous.key, key: range.key, clear: range.from > previous.to }).toEqual({ + after: previous.key, + key: range.key, + clear: true, + }); + } + }); + + /** + * `sized-roster.ts` schedules into 14 half-hour slots a day. `demo` reserves its first three rooms + * for the hand-written agenda, so only the rooms past those count towards its capacity. + */ + it('has room on the grid for every accepted talk', () => { + const SLOTS_PER_DAY = 14; + const reserved: Record = { medium: 3 }; + for (const size of ALL_EVENT_SIZES) { + const openRooms = size.rooms - (reserved[size.key] ?? 0); + expect({ key: size.key, fits: openRooms * size.days * SLOTS_PER_DAY >= size.speakers }).toEqual( + { key: size.key, fits: true }, + ); + } + }); + + it('puts generated identities on a domain nothing can be delivered to', () => { + for (const size of ALL_EVENT_SIZES) { + expect(generatedEmailDomain(size).endsWith('.example')).toBe(true); + } + }); + + it('gives every size a distinct slug', () => { + expect(new Set(SIZED_EVENT_SLUGS).size).toBe(SIZED_EVENT_SLUGS.length); + }); +}); diff --git a/db/seeds/generated-roster.test.ts b/db/seeds/generated-roster.test.ts new file mode 100644 index 00000000..c6e34837 --- /dev/null +++ b/db/seeds/generated-roster.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from 'vitest'; +import { undeliverableRecipient } from '../../lib/mail/config'; +import { ALL_EVENT_SIZES, EVENT_SIZES, generatedEmailDomain } from './event-sizes'; +import { generateProposals, generateSpeakers, generatedSpeakerAt } from './generated-roster'; + +/** + * The generator's whole job is to be boring in exactly two ways: the same index always gives the + * same person, and no two indices give the same email. Both are load-bearing and neither is + * enforced by a type — a collision surfaces as a unique-constraint failure halfway through a seed + * run, and a drift surfaces as every screenshot in the docs going stale at once. + * + * The largest size is the one worth asserting at: it is where the name tables wrap. + */ + +const LARGE = EVENT_SIZES.large; +const DOMAIN = generatedEmailDomain(LARGE); + +describe('generated speakers', () => { + it('is a pure function of the index', () => { + for (const index of [0, 1, 7, 42, 179]) { + expect(generatedSpeakerAt(index, DOMAIN)).toEqual(generatedSpeakerAt(index, DOMAIN)); + } + }); + + it('gives every speaker at the largest size a distinct address', () => { + const emails = generateSpeakers(LARGE.speakers, { domain: DOMAIN }).map((s) => s.email); + expect(new Set(emails).size).toBe(LARGE.speakers); + }); + + /** The reviewer block in `sized-demo.ts` starts at 1000; it must not reach back into the roster. */ + it('keeps the reviewer index block clear of the speaker block', () => { + const speakers = generateSpeakers(LARGE.speakers, { domain: DOMAIN }); + const reviewers = Array.from({ length: LARGE.reviewers }, (_, i) => + generatedSpeakerAt(1000 + i, DOMAIN), + ); + const all = [...speakers, ...reviewers].map((person) => person.email); + expect(new Set(all).size).toBe(all.length); + }); + + it('gives distinct names, not just distinct addresses', () => { + const names = generateSpeakers(LARGE.speakers, { domain: DOMAIN }).map((s) => s.name); + expect(new Set(names).size).toBe(LARGE.speakers); + }); + + /** + * `lib/demo-access.ts` will only print an on-screen magic link for an address no mailbox can exist + * behind. Moving these people to a deliverable domain would turn that convenience into a way to + * take over an account, so the property is asserted rather than left to the domain constant. + */ + it('puts every generated person at a reserved domain', () => { + for (const size of ALL_EVENT_SIZES) { + const speakers = generateSpeakers(size.speakers, { domain: generatedEmailDomain(size) }); + expect(speakers.every((speaker) => undeliverableRecipient(speaker.email))).toBe(true); + } + }); + + it('stays close to an even gender split at every size', () => { + for (const size of ALL_EVENT_SIZES) { + const speakers = generateSpeakers(size.speakers, { domain: generatedEmailDomain(size) }); + const women = speakers.filter((speaker) => speaker.gender === 'woman').length; + expect(Math.abs(women - speakers.length / 2)).toBeLessThanOrEqual(1); + } + }); + + it('fills in the profile fields the public roster renders', () => { + for (const speaker of generateSpeakers(24, { domain: DOMAIN })) { + expect(speaker.title.length).toBeGreaterThan(0); + expect(speaker.organization.length).toBeGreaterThan(0); + expect(speaker.bio.length).toBeGreaterThan(40); + } + }); + + /** `startIndex` is how `demo` continues past its seven hand-authored speakers. */ + it('offsets cleanly from a start index', () => { + const offset = generateSpeakers(5, { domain: DOMAIN, startIndex: 7 }); + const full = generateSpeakers(12, { domain: DOMAIN }); + expect(offset).toEqual(full.slice(7)); + }); +}); + +describe('generated proposals', () => { + const speakers = generateSpeakers(LARGE.speakers, { domain: DOMAIN }); + const proposals = generateProposals(speakers, { + count: LARGE.submissions, + acceptedCount: LARGE.speakers, + }); + + it('accepts exactly one proposal per speaker', () => { + const accepted = proposals.filter((proposal) => proposal.status === 'accepted'); + expect(accepted).toHaveLength(LARGE.speakers); + expect(new Set(accepted.map((proposal) => proposal.email)).size).toBe(LARGE.speakers); + }); + + /** + * A programme with the same title twice makes the agenda look like a rendering bug. Topics and + * framings are paired by coprime stride precisely so this holds up to 480 proposals. + */ + it('gives every proposal at the largest size a distinct title', () => { + expect(new Set(proposals.map((proposal) => proposal.title)).size).toBe(LARGE.submissions); + }); + + it('leaves a real queue behind the accepted set', () => { + const statuses = new Set(proposals.map((proposal) => proposal.status)); + expect(statuses.has('under_review')).toBe(true); + expect(statuses.has('declined')).toBe(true); + expect(statuses.has('waitlisted')).toBe(true); + }); + + it('only ever names a speaker that exists', () => { + const known = new Set(speakers.map((speaker) => speaker.email)); + expect(proposals.every((proposal) => known.has(proposal.email))).toBe(true); + }); + + it('is a pure function of its inputs', () => { + expect( + generateProposals(speakers, { count: 20, acceptedCount: 8, titleOffset: 14 }), + ).toEqual(generateProposals(speakers, { count: 20, acceptedCount: 8, titleOffset: 14 })); + }); + + /** `demo` starts its generated proposals after the fourteen hand-written ones. */ + it('does not repeat a hand-authored slot when offset', () => { + const offsetTitles = generateProposals(speakers, { + count: 30, + acceptedCount: 10, + titleOffset: 14, + }).map((proposal) => proposal.title); + const baseTitles = generateProposals(speakers, { count: 14, acceptedCount: 7 }).map( + (proposal) => proposal.title, + ); + expect(offsetTitles.filter((title) => baseTitles.includes(title))).toEqual([]); + }); +}); diff --git a/lib/demo-access.test.ts b/lib/demo-access.test.ts index cf44a2e9..725849ec 100644 --- a/lib/demo-access.test.ts +++ b/lib/demo-access.test.ts @@ -10,7 +10,7 @@ import { membershipsAreDemoOnly, } from './demo-access'; -const DEMO = ['demo', 'first-settlement']; +const DEMO = ['demo', 'demo-small', 'demo-large', 'first-settlement']; afterEach(() => { vi.unstubAllEnvs(); From bb0d0ce36e16271a66597f7de0ee0fc26052c4b5 Mon Sep 17 00:00:00 2001 From: EllAchE <26192612+EllAchE@users.noreply.github.com> Date: Mon, 17 Aug 2026 01:13:09 -0400 Subject: [PATCH 4/4] Document the three sample-event sizes README described two seeded events; there are now four, and which one is the default matters to anyone opening the app for the first time. Also extends the architecture note on undeliverable demo identities to name the generated domains, since that property is what the on-screen magic link hangs from. --- README.md | 31 +++++++++++++++++++++---------- docs/02-architecture.md | 6 ++++-- 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a0a90925..a304b8bf 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,26 @@ docker compose exec app npm run db:seed machine below uses `bun`, because `bun.lock` is the lockfile — `npm install` would ignore it and resolve a different tree.) -The seed creates two idempotent cases: - -- **Cicero Forum** — a fictional Roman-themed conference with 14 submissions mid-review, 7 - historically inspired speakers, 4 tracks, 3 rooms, and a two-day agenda with gaps still in it. -- **The First Settlement** — a Roman Senate-themed programme inspired by the sessions of - 13–16 January 27 BCE, with motions, consular review, a partly scheduled agenda, and outstanding - speaker tasks. - -Run it twice and you get the same two events, not four. +The seed creates four idempotent cases. The first three are one conference at three scales, so you +can see what a screen does under load without writing a fixture for it: + +- **Cicero Forum** (`/demo`) — **the default sample event**, at the medium size: 96 submissions + mid-review, 45 speakers, 4 tracks, 5 rooms, and a two-day agenda with gaps still in it. The first + 14 proposals and 7 speakers are hand-written and are what you meet first; the rest is generated, + and is what gives the review queue and the agenda grid some weight. +- **Provincial Assembly** (`/demo-small`) — the same conference sized like a meetup: 18 submissions, + 8 speakers, one day, two rooms. +- **Imperial Congress** (`/demo-large`) — and sized like a large one: 384 submissions, 180 speakers, + three days, ten rooms. Open this when the question is whether a list paginates, a grid stays + readable, or a query falls over. +- **The First Settlement** (`/first-settlement`) — a Roman Senate-themed programme inspired by the + sessions of 13–16 January 27 BCE, with motions, consular review, a partly scheduled agenda, and + outstanding speaker tasks. + +Every generated speaker gets a procedurally drawn portrait and an address on an IANA-reserved +domain (`@demo-large.example` and friends), so nothing the seed writes can receive mail. + +Run it twice and you get the same four events, not eight. ## Local development @@ -110,7 +121,7 @@ have their own optional settings. Apply migrations before serving a new applicat | `bun run db:check` | Validate Drizzle migration snapshots | | `bun run db:migrate` | Apply migrations | | `bun run db:migrate:remote` | Apply migrations to a deployment target; ignores `.env`, rejects localhost | -| `bun run db:seed` | Seed both demo conferences (idempotent) | +| `bun run db:seed` | Seed all four demo conferences, `demo` included (idempotent) | | `bun run db:seed:first-settlement` | [Plan or seed only the Roman demo](docs/first-settlement-seed.md) | | `bun run cf:deploy` | Build and deploy to Cloudflare Workers | diff --git a/docs/02-architecture.md b/docs/02-architecture.md index f9e8b6b5..54bab6df 100644 --- a/docs/02-architecture.md +++ b/docs/02-architecture.md @@ -295,8 +295,10 @@ a magic link is handing out a session for whatever address was typed into the bo transport those two are in direct conflict, which is what kept the deployment on `log`. The resolution is that the demo identities are undeliverable *by construction*, rather than delivery -being off for everyone. Both seeds are built entirely from IANA-reserved domains -(`organizer@example.com`, the senate at `@first-settlement.example`), and `sendMail` routes any +being off for everyone. Every seeded event is built entirely from IANA-reserved domains +(`organizer@example.com`, the senate at `@first-settlement.example`, and the generated rosters that +fill the three sized sample events at `@demo.example`, `@demo-small.example` and +`@demo-large.example`), and `sendMail` routes any recipient at a reserved domain to the log transport whatever else is configured — real addresses in the same run still get real mail, and the provider is never asked to bounce six hundred fictional senators. An on-screen link for such an address therefore cannot lock a real person out of anything,