diff --git a/app/src/app/speakers/page.tsx b/app/src/app/speakers/page.tsx
index 3dab6ad..a08077a 100644
--- a/app/src/app/speakers/page.tsx
+++ b/app/src/app/speakers/page.tsx
@@ -59,7 +59,7 @@ function getTalksOfSpeaker(
talks: TalkWithEventCtx[],
speaker: SpeakerWithTalkIds,
) {
- return talks.filter((talk) => speaker.talkIds.includes(talk.id));
+ return talks.filter((talk) => talk.speakerIds.includes(speaker.id));
}
export default async function SpeakersPage() {
@@ -88,7 +88,7 @@ export default async function SpeakersPage() {
{getTalksOfSpeaker(talks, speaker).map((talk) => (
-
, "select">,
+ now = new Date(),
+) {
+ // One relation supplies both the directory and its public talk links.
+ const rows = await database
+ .select({
+ profile: profilesTable,
+ image: imagesTable,
+ talk: talksTable,
+ event: {
+ id: eventsTable.id,
+ name: eventsTable.name,
+ slug: eventsTable.slug,
+ startDate: eventsTable.startDate,
+ },
+ })
+ .from(profilesTable)
+ .innerJoin(
+ talkSpeakersTable,
+ eq(profilesTable.id, talkSpeakersTable.speakerId),
+ )
+ .innerJoin(talksTable, eq(talkSpeakersTable.talkId, talksTable.id))
+ .innerJoin(eventTalksTable, eq(talksTable.id, eventTalksTable.talkId))
+ .innerJoin(eventsTable, eq(eventTalksTable.eventId, eventsTable.id))
+ .leftJoin(imagesTable, eq(profilesTable.image, imagesTable.id))
+ .where(and(eq(eventsTable.isDraft, false), lte(eventsTable.endDate, now)))
+ .orderBy(
+ asc(profilesTable.name),
+ asc(profilesTable.id),
+ desc(eventsTable.startDate),
+ asc(eventsTable.id),
+ asc(talksTable.id),
+ );
+
+ const speakers = new Map<
+ string,
+ {
+ profile: typeof profilesTable.$inferSelect;
+ image: typeof imagesTable.$inferSelect | null;
+ talkIds: string[];
+ }
+ >();
+ const talks = new Map<
+ string,
+ {
+ id: string;
+ title: string;
+ description: string;
+ speakerIds: string[];
+ eventId: string;
+ eventName: string;
+ eventSlug: string;
+ eventStart: Date;
+ }
+ >();
+
+ for (const row of rows) {
+ let speaker = speakers.get(row.profile.id);
+ if (!speaker) {
+ speaker = { profile: row.profile, image: row.image, talkIds: [] };
+ speakers.set(row.profile.id, speaker);
+ }
+ if (!speaker.talkIds.includes(row.talk.id))
+ speaker.talkIds.push(row.talk.id);
+ // A talk may be presented at more than one event, with several speakers.
+ const appearanceId = `${row.event.id}:${row.talk.id}`;
+ let talk = talks.get(appearanceId);
+ if (!talk) {
+ talk = {
+ id: row.talk.id,
+ title: row.talk.title,
+ description: row.talk.description,
+ speakerIds: [],
+ eventId: row.event.id,
+ eventName: row.event.name,
+ eventSlug: row.event.slug,
+ eventStart: row.event.startDate,
+ };
+ talks.set(appearanceId, talk);
+ }
+ talk.speakerIds.push(row.profile.id);
+ }
+ return {
+ speakers: [...speakers.values()],
+ talks: [...talks.values()].sort(
+ (a, b) =>
+ b.eventStart.getTime() - a.eventStart.getTime() ||
+ a.eventId.localeCompare(b.eventId) ||
+ a.id.localeCompare(b.id),
+ ),
+ };
+}
diff --git a/app/src/lib/speakers.ts b/app/src/lib/speakers.ts
index 9cac08b..487d9fc 100644
--- a/app/src/lib/speakers.ts
+++ b/app/src/lib/speakers.ts
@@ -1,141 +1,39 @@
-import { eq, and } from "drizzle-orm";
import { db } from "./db";
-import {
- profilesTable,
- imagesTable,
- talksTable,
- talkSpeakersTable,
- eventTalksTable,
- eventsTable,
-} from "./schema";
-import { Profile, getSocialUrls } from "./profiles";
+import { type Profile, getSocialUrls } from "./profiles";
import { signImage } from "./image-signing";
+import { getSpeakerDirectory } from "./speaker-directory";
-export type Talk = {
- id: string;
- title: string;
- description: string | null;
- speakerId: string;
- eventId: string;
-};
-
-export type TalkWithEventCtx = Talk & {
- eventName: string;
- eventSlug: string;
- eventStart: Date;
-};
-
-export type SpeakerWithTalkIds = Profile & {
- talkIds: string[];
-};
+export type TalkWithEventCtx = Awaited<
+ ReturnType
+>["talks"][number];
+export type SpeakerWithTalkIds = Profile & { talkIds: string[] };
export async function getSpeakersWithTalks(): Promise<{
speakers: SpeakerWithTalkIds[];
talks: TalkWithEventCtx[];
}> {
- // Get all speakers (profiles that have talks)
- const speakersQuery = await db
- .select({
- profile: profilesTable,
- image: imagesTable,
- talkId: talkSpeakersTable.talkId,
- })
- .from(profilesTable)
- .leftJoin(imagesTable, eq(profilesTable.image, imagesTable.id))
- .innerJoin(
- talkSpeakersTable,
- eq(profilesTable.id, talkSpeakersTable.speakerId),
- )
- .where(eq(profilesTable.profileType, "member"));
-
- // Get all talks with event context
- const talksQuery = await db
- .select({
- talk: talksTable,
- event: eventsTable,
- speakerId: talkSpeakersTable.speakerId,
- })
- .from(talksTable)
- .innerJoin(talkSpeakersTable, eq(talksTable.id, talkSpeakersTable.talkId))
- .innerJoin(eventTalksTable, eq(talksTable.id, eventTalksTable.talkId))
- .innerJoin(eventsTable, eq(eventTalksTable.eventId, eventsTable.id))
- .where(eq(eventsTable.isDraft, false));
-
- // Group speakers and their talk IDs
- const speakersMap = new Map<
- string,
- {
- profile: any;
- image: any;
- talkIds: string[];
- }
- >();
-
- speakersQuery.forEach((row) => {
- const speakerId = row.profile.id;
- if (!speakersMap.has(speakerId)) {
- speakersMap.set(speakerId, {
- profile: row.profile,
- image: row.image,
- talkIds: [],
- });
- }
- speakersMap.get(speakerId)!.talkIds.push(row.talkId);
- });
-
- // Convert to final format and presign image URLs
- const speakersWithPresignedImages = await Promise.all(
- Array.from(speakersMap.values()).map(
- async (speakerData): Promise => {
- const profile = speakerData.profile;
- const image = speakerData.image || {
- url: "/hero-image-rocket.png",
- alt: `${profile.name} profile picture`,
- placeholder: null,
- width: 400,
- height: 400,
- };
-
- // Sign the image
- const signedImage = await signImage(image);
-
- return {
- id: profile.id,
- name: profile.name,
- image: signedImage,
- title: profile.title,
- bio: profile.bio,
- type: profile.profileType,
- socials: getSocialUrls({
- twitterHandle: profile.twitterHandle,
- linkedinHandle: profile.linkedinHandle,
- blueskyHandle: profile.blueskyHandle,
- }),
- talkIds: speakerData.talkIds,
- };
- },
+ const directory = await getSpeakerDirectory(db);
+ const speakers = await Promise.all(
+ directory.speakers.map(
+ async ({ profile, image, talkIds }): Promise => ({
+ id: profile.id,
+ name: profile.name,
+ image: await signImage(
+ image ?? {
+ url: "/hero-image-rocket.png",
+ alt: `${profile.name} profile picture`,
+ placeholder: null,
+ width: 400,
+ height: 400,
+ },
+ ),
+ title: profile.title,
+ bio: profile.bio,
+ type: profile.profileType,
+ socials: getSocialUrls(profile),
+ talkIds,
+ }),
),
);
-
- // Deduplicate talks (since a talk can have multiple speakers, it appears multiple times in talksQuery)
- const talksMap = new Map();
-
- talksQuery.forEach((row) => {
- if (!talksMap.has(row.talk.id)) {
- talksMap.set(row.talk.id, {
- id: row.talk.id,
- title: row.talk.title,
- description: row.talk.description,
- speakerId: row.speakerId, // This will be the first speaker found, but it's not used in the UI
- eventId: row.event.id,
- eventName: row.event.name,
- eventSlug: row.event.slug,
- eventStart: row.event.startDate,
- });
- }
- });
-
- const talks: TalkWithEventCtx[] = Array.from(talksMap.values());
-
- return { speakers: speakersWithPresignedImages, talks };
+ return { speakers, talks: directory.talks };
}
diff --git a/app/tests/speaker-directory.test.ts b/app/tests/speaker-directory.test.ts
new file mode 100644
index 0000000..1e9a2c7
--- /dev/null
+++ b/app/tests/speaker-directory.test.ts
@@ -0,0 +1,147 @@
+import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
+import { PGlite } from "@electric-sql/pglite";
+import { eq } from "drizzle-orm";
+import { drizzle } from "drizzle-orm/pglite";
+import { generateDrizzleJson, generateMigration } from "drizzle-kit/api";
+import { getSpeakerDirectory } from "../src/lib/speaker-directory";
+import * as schema from "../src/lib/schema";
+
+const client = new PGlite();
+const db = drizzle(client);
+const now = new Date("2026-09-21T12:00:00Z");
+beforeAll(async () => {
+ await client.exec("CREATE SCHEMA IF NOT EXISTS neon_auth");
+ for (const statement of await generateMigration(
+ generateDrizzleJson({}),
+ generateDrizzleJson(schema),
+ ))
+ await client.exec(statement);
+});
+beforeEach(async () => {
+ await client.exec("TRUNCATE events, profiles, talks, images CASCADE");
+});
+afterAll(async () => {
+ await client.close();
+});
+
+async function profile(
+ name: string,
+ profileType: "organizer" | "member" = "member",
+) {
+ const [row] = await db
+ .insert(schema.profilesTable)
+ .values({ name, profileType, title: "Developer", bio: "" })
+ .returning();
+ return row;
+}
+async function event(
+ slug: string,
+ isDraft = false,
+ endDate = new Date("2026-09-20T12:00:00Z"),
+) {
+ const [row] = await db
+ .insert(schema.eventsTable)
+ .values({
+ slug,
+ name: slug,
+ isDraft,
+ startDate: new Date("2026-09-19T12:00:00Z"),
+ endDate,
+ tagline: "",
+ attendeeLimit: 0,
+ })
+ .returning();
+ return row;
+}
+async function talk(speakerIds: string[], eventIds: string[]) {
+ const [row] = await db
+ .insert(schema.talksTable)
+ .values({ title: "A shared talk", description: "" })
+ .returning();
+ await db
+ .insert(schema.talkSpeakersTable)
+ .values(speakerIds.map((speakerId) => ({ speakerId, talkId: row.id })));
+ if (eventIds.length)
+ await db
+ .insert(schema.eventTalksTable)
+ .values(eventIds.map((eventId) => ({ eventId, talkId: row.id })));
+ return row;
+}
+
+test("includes organizers who spoke; excludes draft-only, future, active and unattached talks", async () => {
+ const organizer = await profile("Organizer", "organizer");
+ const member = await profile("Member");
+ const draftSpeaker = await profile("Draft only");
+ const futureSpeaker = await profile("Future only");
+ const liveSpeaker = await profile("Still speaking");
+ const orphan = await profile("Unattached");
+ const publicEvent = await event("public");
+ await talk([organizer.id, member.id], [publicEvent.id]);
+ await talk([draftSpeaker.id], [(await event("draft", true)).id]);
+ await talk(
+ [futureSpeaker.id],
+ [(await event("future", false, new Date("2027-01-01"))).id],
+ );
+ await talk(
+ [liveSpeaker.id],
+ [(await event("live", false, new Date(now.getTime() + 1))).id],
+ );
+ await talk([orphan.id], []);
+ const result = await getSpeakerDirectory(db, now);
+ expect(result.speakers.map((s) => s.profile.name)).toEqual([
+ "Member",
+ "Organizer",
+ ]);
+ expect(result.talks).toHaveLength(1);
+ expect(result.talks[0].speakerIds).toEqual([member.id, organizer.id]);
+ expect(result.talks[0].eventSlug).toBe("public");
+});
+
+test("retains repeat appearances and co-speakers without duplicate profile talk IDs or hidden links", async () => {
+ const a = await profile("A");
+ const b = await profile("B");
+ const first = await event("first");
+ const second = await event("second", false, now);
+ const hidden = await event("hidden", true);
+ const shared = await talk([a.id, b.id], [first.id, second.id, hidden.id]);
+ await talk([a.id], [hidden.id]);
+ const result = await getSpeakerDirectory(db, now);
+ expect(result.speakers).toHaveLength(2);
+ expect(result.speakers.map((s) => s.talkIds)).toEqual([
+ [shared.id],
+ [shared.id],
+ ]);
+ expect(result.talks.map((t) => t.eventSlug).sort()).toEqual([
+ "first",
+ "second",
+ ]);
+ expect(
+ result.talks.every((t) => t.speakerIds.join() === [a.id, b.id].join()),
+ ).toBe(true);
+});
+
+test("returns an empty directory when there are no public past talks", async () => {
+ expect(await getSpeakerDirectory(db, now)).toEqual({
+ speakers: [],
+ talks: [],
+ });
+});
+
+test("orders every speaker's appearances newest first, including shared talks", async () => {
+ const a = await profile("A");
+ const b = await profile("B");
+ const older = await event("older");
+ const newer = await event("newer");
+ await db
+ .update(schema.eventsTable)
+ .set({ startDate: new Date("2026-09-18T12:00:00Z") })
+ .where(eq(schema.eventsTable.id, older.id));
+ await talk([a.id, b.id], [older.id]);
+ await talk([b.id], [newer.id]);
+ const result = await getSpeakerDirectory(db, now);
+ expect(
+ result.talks
+ .filter((talk) => talk.speakerIds.includes(b.id))
+ .map((talk) => talk.eventSlug),
+ ).toEqual(["newer", "older"]);
+});