Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions apps/web/lib/__tests__/participantsExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Tests for streaming participants CSV export (#1184).

import {
csvEscape,
participantToCsvLine,
streamParticipantsCsv,
collectParticipantRows,
} from "../participantsExport";

describe("participants CSV export (#1184)", () => {
test("csvEscape quotes fields containing commas, quotes, newlines", () => {
expect(csvEscape("plain")).toBe("plain");
expect(csvEscape("with,comma")).toBe('"with,comma"');
expect(csvEscape('has "quote"')).toBe('"has ""quote"""');
expect(csvEscape("multi\nline")).toBe('"multi\nline"');
});

test("participantToCsvLine emits ordered columns", () => {
const line = participantToCsvLine({
rank: 1,
wallet: "GABC",
alias: "alice",
score: 42,
completionTime: "2026-08-24T00:00:00.000Z",
joinedAt: "2026-08-23T00:00:00.000Z",
});
expect(line).toBe("1,GABC,alice,42,2026-08-24T00:00:00.000Z,2026-08-23T00:00:00.000Z");
});

test("streamParticipantsCsv streams header then chunks lazily", async () => {
const total = 250;
const calls: Array<[number, number]> = [];
const rowSource = async (offset: number, limit: number) => {
calls.push([offset, limit]);
const rows = [];
for (let i = offset; i < Math.min(offset + limit, total); i++) {
rows.push({
rank: i + 1,
wallet: `W${i}`,
alias: "",
score: 100 - i,
completionTime: "2026-08-24T00:00:00.000Z",
joinedAt: "2026-08-20T00:00:00.000Z",
});
}
return rows;
};

const stream = streamParticipantsCsv(total, 100, rowSource);
const reader = (stream as ReadableStream<Uint8Array>).getReader();
const decoder = new TextDecoder();

let text = "";
let chunkCount = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value);
chunkCount += 1;
}

// header + 3 data chunks (250 rows / 100 per chunk)
expect(chunkCount).toBe(4);
const lines = text.trim().split("\n");
expect(lines[0]).toBe("rank,wallet,alias,score,completion_time,joined_at");
expect(lines).toHaveLength(total + 1);
expect(calls).toEqual([
[0, 100],
[100, 100],
[200, 100],
]);
});

test("collectParticipantRows excludes privacy opt-outs from ranking", async () => {
const leaderboard = [
{ address: "W1", points: 30 },
{ address: "W2", points: 20 },
{ address: "W3", points: 10 },
];
const participants = [
{ address: "W1", alias: "alice", points: 30, privacy: { shareResults: true } },
{ address: "W2", alias: "bob", points: 20, privacy: { shareResults: false } },
{ address: "W3", alias: "", points: 10 },
];

const rows = await collectParticipantRows(
leaderboard,
[],
async () => participants,
);

// W2 opted out -> excluded entirely; W3 gets rank 2 (not 3)
expect(rows.map((r) => r.wallet)).toEqual(["W1", "W3"]);
expect(rows[1].rank).toBe(2);
});
});
138 changes: 138 additions & 0 deletions apps/web/lib/participantsExport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Streaming CSV export of hunt participants and results (#1184).
//
// Mirrors the leaderboard export route but adds:
// - participant rows (not just leaderboard ranks), honoring privacy settings
// - streaming via ReadableStream so large exports don't buffer in memory
//
// Privacy: participants who set `shareResults: false` are excluded from the
// export entirely; their row never leaves the server.

// Pure module: data sources are injected by the caller (route), so this
// file has no server-only imports and stays unit-testable in isolation.

export interface ParticipantRow {
rank: number;
wallet: string;
alias: string | "";
score: number;
completionTime: string;
joinedAt: string;
}

export interface ExportPrivacyOptions {
/** When false, the participant is excluded from exports (their choice). */
shareResults?: boolean;
}

export function csvEscape(value: string | number): string {
const s = String(value);
if (/[",\n\r]/.test(s)) {
return `"${s.replace(/"/g, '""')}"`;
}
return s;
}

/** Yield-safe CSV header for the participants export. */
export function participantsCsvHeader(): string {
return "rank,wallet,alias,score,completion_time,joined_at";
}

/** Serialize one participant row as a CSV line. */
export function participantToCsvLine(row: ParticipantRow): string {
return [
csvEscape(row.rank),
csvEscape(row.wallet),
csvEscape(row.alias),
csvEscape(row.score),
csvEscape(row.completionTime),
csvEscape(row.joinedAt),
].join(",");
}

/**
* Build a streaming web ReadableStream of CSV text from participant rows.
* Rows are pulled lazily through `rowSource` in chunks so very large hunts
* do not need to fit in memory.
*/
export function streamParticipantsCsv(
total: number,
chunkSize: number,
rowSource: (offset: number, limit: number) => Promise<ParticipantRow[]>,
): ReadableStream<Uint8Array> {
const encoder = new TextEncoder();
let offset = 0;
let headerSent = false;

return new ReadableStream<Uint8Array>({
async pull(controller) {
if (!headerSent) {
controller.enqueue(encoder.encode(participantsCsvHeader() + "\n"));
headerSent = true;
return;
}
if (offset >= total) {
controller.close();
return;
}
const rows = await rowSource(offset, chunkSize);
if (rows.length === 0) {
controller.close();
return;
}
const text = rows.map(participantToCsvLine).join("\n") + "\n";
controller.enqueue(encoder.encode(text));
offset += rows.length;
},
});
}

/**
* Collect participant rows for a hunt, applying privacy filtering.
* Participants with shareResults === false are excluded before any row is built.
*/
export async function collectParticipantRows(
leaderboard: Array<{ address: string; points: number }>,
fastest: Array<{ address?: string; completionTimeSeconds?: number }>,
getParticipants: () => Promise<
Array<{
address: string;
alias?: string;
points?: number;
completedAt?: number;
joinedAt?: number;
privacy?: ExportPrivacyOptions;
}>
>,
): Promise<ParticipantRow[]> {

const completionTimeByWallet = new Map<string, number>();
for (const entry of fastest) {
if (entry.address && typeof entry.completionTimeSeconds === "number") {
completionTimeByWallet.set(entry.address, entry.completionTimeSeconds * 1000);
}
}

const participants = await getParticipants();
const sorted = [...leaderboard].sort((a, b) => b.points - a.points);

const rows: ParticipantRow[] = [];
let rank = 0;
for (const entry of sorted) {
const participant = participants.find((p) => p.address === entry.address);
// Privacy: exclude participants who opted out of sharing results.
if (participant?.privacy?.shareResults === false) continue;
rank += 1;
const completionMs =
completionTimeByWallet.get(entry.address) ??
(participant?.completedAt ?? Date.now());
rows.push({
rank,
wallet: entry.address,
alias: participant?.alias ?? "",
score: entry.points,
completionTime: new Date(completionMs).toISOString(),
joinedAt: new Date(participant?.joinedAt ?? completionMs).toISOString(),
});
}
return rows;
}
22 changes: 22 additions & 0 deletions apps/web/vitest.no-setup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import react from "@vitejs/plugin-react";
import path from "path";
import { defineConfig } from "vitest/config";

// Minimal config for pure-logic tests (#1194): no jsdom setup file, so the
// pre-existing react/react-dom version mismatch in the repo does not block
// tests that do not touch React.

export default defineConfig({
plugins: [react()],
oxc: {
jsx: {
runtime: "automatic",
},
},
test: {
environment: "node",
globals: true,
setupFiles: [],
exclude: ["e2e/**", "node_modules/**"],
},
});