fix: linting

This commit is contained in:
Daniel Dietzler
2026-08-19 14:53:56 +02:00
parent bb9dbc3abe
commit 888edda54a
123 changed files with 1034 additions and 963 deletions
@@ -58,7 +58,7 @@ function parseArgs(): Config {
const args = process.argv.slice(2);
const get = (flag: string, def: string) => {
const i = args.indexOf(flag);
return i >= 0 && args[i + 1] ? args[i + 1] : def;
return i !== -1 && args[i + 1] ? args[i + 1] : def;
};
return {
@@ -194,7 +194,7 @@ class Metrics {
const errorGroups = new Map<string, number>();
for (const m of this.data) {
if (!m.error) continue;
if (!m.error) {continue;}
// Normalize "retry in Xms" to make messages comparable
const normalized = m.error.replace(/retry in \d+ms/, 'retry');
const key = `${m.endpoint}: ${normalized}`;
@@ -205,7 +205,7 @@ class Metrics {
console.log(`\n${'-'.repeat(70)}`);
console.log('ERROR BREAKDOWN');
console.log('-'.repeat(70));
const sorted = [...errorGroups.entries()].sort((a, b) => b[1] - a[1]);
const sorted = [...errorGroups].sort((a, b) => b[1] - a[1]);
for (const [err, count] of sorted.slice(0, 10)) {
console.log(` ${String(count).padStart(5)} ${err}`);
}
@@ -229,12 +229,12 @@ async function timedFetch(
const res = await fetch(url, init);
metrics.record({ endpoint, status: res.status, latencyMs: performance.now() - start });
return res;
} catch (e) {
} catch (error) {
metrics.record({
endpoint,
status: 0,
latencyMs: performance.now() - start,
error: e instanceof Error ? e.message : 'Network error',
error: error instanceof Error ? error.message : 'Network error',
});
return null;
}
@@ -245,22 +245,18 @@ async function authenticate(baseUrl: string, password: string): Promise<string>
const me = (await meRes.json()) as { authenticated: boolean; needsSetup?: boolean };
let res: Response;
if (me.needsSetup) {
res = await fetch(`${baseUrl}/api/auth/setup`, {
res = me.needsSetup ? (await fetch(`${baseUrl}/api/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
} else {
res = await fetch(`${baseUrl}/api/auth/password-login`, {
})) : (await fetch(`${baseUrl}/api/auth/password-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
});
}
}));
const cookie = res.headers.get('set-cookie')?.split(';')[0];
if (!cookie) throw new Error('Authentication failed — check password');
const cookie = res.headers.get('set-cookie')?.split(';', 1)[0];
if (!cookie) {throw new Error('Authentication failed — check password');}
return cookie;
}
@@ -366,13 +362,13 @@ async function createSurvey(baseUrl: string, sessionCookie: string): Promise<{ s
headers,
body: JSON.stringify({ slug }),
});
if (!slugRes.ok) throw new Error(`Set slug failed: ${await slugRes.text()}`);
if (!slugRes.ok) {throw new Error(`Set slug failed: ${await slugRes.text()}`);}
const pubRes = await fetch(`${baseUrl}/api/surveys/${surveyId}/publish`, {
method: 'PUT',
headers,
});
if (!pubRes.ok) throw new Error(`Publish failed: ${await pubRes.text()}`);
if (!pubRes.ok) {throw new Error(`Publish failed: ${await pubRes.text()}`);}
console.log(`Survey created: ${slug} (${imported.questions.length} questions)`);
return { slug, questions: imported.questions, surveyId };
@@ -387,7 +383,7 @@ async function fetchActualRespondentCount(
const res = await fetch(`${baseUrl}/api/surveys/${surveyId}/results`, {
headers: { Cookie: sessionCookie },
});
if (!res.ok) return null;
if (!res.ok) {return null;}
const data = (await res.json()) as { respondentCounts?: { total: number; completed: number } };
return data.respondentCounts ?? null;
} catch {
@@ -436,29 +432,33 @@ function generateAnswer(q: Question): { questionId: string; value: string; other
const count = Math.min(1 + Math.floor(Math.random() * 3), options.length);
const shuffled = [...options].sort(() => Math.random() - 0.5);
const selected = shuffled.slice(0, count).map((o) => o.value);
if (q.has_other && Math.random() < 0.1) selected.push('Other');
if (q.has_other && Math.random() < 0.1) {selected.push('Other');}
return {
...base,
value: selected.join(','),
...(selected.includes('Other') ? { otherText: 'Custom option' } : {}),
...(selected.includes('Other') && { otherText: 'Custom option' }),
};
}
case 'dropdown':
case 'dropdown': {
return { ...base, value: options.length > 0 ? randomItem(options).value : 'option1' };
case 'text':
}
case 'text': {
return { ...base, value: randomItem(NAMES) };
case 'textarea':
}
case 'textarea': {
return { ...base, value: randomItem(COMMENTS) };
case 'email':
return { ...base, value: `user${Math.floor(Math.random() * 100000)}@example.com` };
}
case 'email': {
return { ...base, value: `user${Math.floor(Math.random() * 100_000)}@example.com` };
}
case 'number': {
let min = 0,
max = 100;
if (q.config) {
try {
const cfg = JSON.parse(q.config);
if (cfg.min != null) min = cfg.min;
if (cfg.max != null) max = cfg.max;
if (cfg.min != null) {min = cfg.min;}
if (cfg.max != null) {max = cfg.max;}
} catch {
// Malformed config JSON — keep the default min/max.
}
@@ -470,21 +470,24 @@ function generateAnswer(q: Question): { questionId: string; value: string; other
if (q.config) {
try {
const cfg = JSON.parse(q.config);
if (cfg.scaleMax) scaleMax = cfg.scaleMax;
if (cfg.scaleMax) {scaleMax = cfg.scaleMax;}
} catch {
// Malformed config JSON — keep the default scaleMax.
}
}
return { ...base, value: String(1 + Math.floor(Math.random() * scaleMax)) };
}
case 'nps':
case 'nps': {
return { ...base, value: String(Math.floor(Math.random() * 11)) };
case 'likert':
}
case 'likert': {
return { ...base, value: randomItem(LIKERT) };
default:
}
default: {
return { ...base, value: 'test' };
}
}
}
// WebSocket client using the `ws` package for real Error objects and
// Cookie header support (Node's built-in WebSocket doesn't let you set
@@ -523,10 +526,10 @@ function createWsClient(url: string, metrics: Metrics, initialCookie?: string):
};
function connect() {
if (closed) return;
if (closed) {return;}
const start = performance.now();
const opts: ConstructorParameters<typeof WebSocketClass>[2] = {};
if (cookie) opts.headers = { Cookie: cookie };
if (cookie) {opts.headers = { Cookie: cookie };}
ws = new WebSocketClass(url, opts);
// Capture Set-Cookie from the upgrade response so reconnects reuse the
@@ -556,8 +559,8 @@ function createWsClient(url: string, metrics: Metrics, initialCookie?: string):
const req = pending.get(msg.requestId);
if (req) {
pending.delete(msg.requestId);
if (msg.error) req.reject(new Error(msg.error));
else req.resolve(msg.data);
if (msg.error) {req.reject(new Error(msg.error));}
else {req.resolve(msg.data);}
}
}
// push events (counts, stats, results) are ignored by respondent clients
@@ -590,10 +593,10 @@ function createWsClient(url: string, metrics: Metrics, initialCookie?: string):
});
ws.on('close', (code: number, reason: Buffer) => {
for (const [, req] of pending) req.reject(new Error('WebSocket closed'));
for (const [, req] of pending) {req.reject(new Error('WebSocket closed'));}
pending.clear();
if (closed) return;
if (closed) {return;}
const wasConnected = everConnected;
everConnected = false;
@@ -699,7 +702,7 @@ type UserProfile = {
function createUserProfile(totalQuestions: number): UserProfile {
const isFast = Math.random() < 0.4;
const minDelay = isFast ? 500 : 3000;
const maxDelay = isFast ? 3000 : 15000;
const maxDelay = isFast ? 3000 : 15_000;
const willAbandon = Math.random() < 0.25;
const abandonAt = willAbandon ? 1 + Math.floor(Math.random() * (totalQuestions - 1)) : null;
@@ -767,9 +770,9 @@ async function simulateUser(
// Fetch survey metadata over HTTP first, exactly as the frontend does.
const surveyRes = await timedFetch(metrics, 'GET /api/s/:slug', `${config.baseUrl}/api/s/${slug}`);
if (!surveyRes || !surveyRes.ok) return outcome('load');
if (!surveyRes || !surveyRes.ok) {return outcome('load');}
await surveyRes.json();
if (isExpired()) return outcome('none', { abandoned: true });
if (isExpired()) {return outcome('none', { abandoned: true });}
// WS is the only data path: no HTTP fallback, because per-request cookie auth
// would reduce the very server capacity this test measures.
@@ -778,7 +781,7 @@ async function simulateUser(
const waitForConnect = async (client: WsClient) => {
// Wait up to ~13s to match the frontend's 3 retries × 3s delay + initial attempt
for (let i = 0; i < 130; i++) {
if (client.connected) return true;
if (client.connected) {return true;}
await new Promise((r) => setTimeout(r, 100));
}
return false;
@@ -834,14 +837,16 @@ async function simulateUser(
const MAX_FLUSH_FAILURES = 5;
const clearInactivity = () => {
if (inactivityTimer) {
if (!inactivityTimer) {
return;
}
clearTimeout(inactivityTimer);
inactivityTimer = null;
}
};
const saveBatchWs = async (batch: PendingSave[]): Promise<boolean> => {
if (!wsClient || !wsClient.connected) return false;
if (!wsClient || !wsClient.connected) {return false;}
try {
await wsClient.request('submit-answers', { answers: batch });
return true;
@@ -851,8 +856,8 @@ async function simulateUser(
};
const flushBuffer = async (): Promise<boolean> => {
if (flushing) return flushing;
if (buffer.size === 0) return true;
if (flushing) {return flushing;}
if (buffer.size === 0) {return true;}
clearInactivity();
const batch = [...buffer.values()];
buffer.clear();
@@ -865,7 +870,7 @@ async function simulateUser(
consecutiveFailures++;
// Restore unsent answers so the next flush retries them
for (const item of batch) {
if (!buffer.has(item.questionId)) buffer.set(item.questionId, item);
if (!buffer.has(item.questionId)) {buffer.set(item.questionId, item);}
}
}
return success;
@@ -894,13 +899,13 @@ async function simulateUser(
// ms on answer, so the backend can populate per-question timing analytics.
const answerQuestions = async (from: number, to: number) => {
for (let i = from; i < to; i++) {
if (isExpired()) break;
if (profile.abandonAt !== null && questionsAnswered >= profile.abandonAt) break;
if (consecutiveFailures >= MAX_FLUSH_FAILURES) break;
if (isExpired()) {break;}
if (profile.abandonAt !== null && questionsAnswered >= profile.abandonAt) {break;}
if (consecutiveFailures >= MAX_FLUSH_FAILURES) {break;}
const shownAt = Date.now();
await randomDelay(profile.minDelay, profile.maxDelay);
if (isExpired()) break;
if (isExpired()) {break;}
const answer = generateAnswer(questions[i]);
bufferAnswer({ ...answer, answerMs: Date.now() - shownAt });
@@ -908,9 +913,9 @@ async function simulateUser(
}
// Final flush — retry a few times in case WS is still reconnecting.
for (let attempt = 0; attempt < 3; attempt++) {
if (buffer.size === 0) break;
if (buffer.size === 0) {break;}
const ok = await flushBuffer();
if (ok) break;
if (ok) {break;}
await new Promise((r) => setTimeout(r, BACKOFF_DELAYS[Math.min(attempt, BACKOFF_DELAYS.length - 1)]));
}
};
@@ -925,7 +930,7 @@ async function simulateUser(
// server recognises the same respondent instead of creating a new one.
wsClient.close();
wsClient = null;
await randomDelay(5000, 20000);
await randomDelay(5000, 20_000);
if (!isExpired()) {
wsClient = createWsClient(wsUrl, metrics, respondentCookie);
@@ -1053,7 +1058,7 @@ async function main() {
metrics.report(actualCounts);
}
main().catch((e) => {
console.error('Fatal error:', e);
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
@@ -156,32 +156,41 @@ function generateAnswer(
fillLevel: 'short' | 'medium' | 'long',
): { value: string; otherText: string | null } {
switch (question.type) {
case 'text':
case 'text': {
return { value: 'John Doe', otherText: null };
case 'email':
}
case 'email': {
return { value: 'user@example.com', otherText: null };
}
case 'textarea': {
const base = 'This is a response about my experience. ';
const repeats = fillLevel === 'short' ? 1 : fillLevel === 'medium' ? 5 : 20;
return { value: base.repeat(repeats), otherText: null };
}
case 'radio':
case 'dropdown':
case 'dropdown': {
return { value: 'opt2', otherText: null };
case 'checkbox':
}
case 'checkbox': {
return { value: 'opt1,opt3', otherText: null };
case 'rating':
}
case 'rating': {
return { value: '4', otherText: null };
case 'nps':
}
case 'nps': {
return { value: '8', otherText: null };
case 'number':
}
case 'number': {
return { value: '42', otherText: null };
case 'likert':
}
case 'likert': {
return { value: 'Agree', otherText: null };
default:
}
default: {
return { value: 'test', otherText: null };
}
}
}
function generateRespondentState(
questions: QuestionRow[],
@@ -203,7 +212,7 @@ function generateTallies(questions: QuestionRow[], uniqueValuesPerQuestion: numb
const tallies = new Map<string, AnswerTally[]>();
const choiceTypes = new Set(['radio', 'checkbox', 'dropdown', 'rating', 'nps', 'likert']);
for (const q of questions) {
if (!choiceTypes.has(q.type)) continue;
if (!choiceTypes.has(q.type)) {continue;}
const tally: AnswerTally[] = [];
for (let i = 0; i < uniqueValuesPerQuestion; i++) {
tally.push({ value: `value_${i}`, otherText: null, count: Math.floor(Math.random() * 1000) });
@@ -214,20 +223,20 @@ function generateTallies(questions: QuestionRow[], uniqueValuesPerQuestion: numb
}
function measure<T>(label: string, setup: () => T): { label: string; result: T; bytes: number } {
if (global.gc) global.gc();
if (globalThis.gc) {globalThis.gc();}
const before = process.memoryUsage().heapUsed;
const result = setup();
if (global.gc) global.gc();
if (globalThis.gc) {globalThis.gc();}
const after = process.memoryUsage().heapUsed;
return { label, result, bytes: after - before };
}
function fmt(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024) {return `${bytes} B`;}
if (bytes < 1024 * 1024) {return `${(bytes / 1024).toFixed(1)} KB`;}
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
@@ -244,7 +253,7 @@ const SCENARIOS: Scenario[] = [
{ name: 'Large (50q, long textareas)', questionCount: 50, sectionCount: 10, fillLevel: 'long' },
];
const CONCURRENT_COUNTS = [100, 1000, 5000, 10000];
const CONCURRENT_COUNTS = [100, 1000, 5000, 10_000];
function profileScenario(scenario: Scenario) {
console.log(`\n${'='.repeat(70)}`);
@@ -276,7 +285,7 @@ function profileScenario(scenario: Scenario) {
console.log(` ${fmt(singleRespondent.bytes)}`);
console.log(`\n Concurrent respondent scaling:`);
console.log(` ${'Users'.padEnd(10)} ${'Total cache'.padEnd(15)} ${'Per-user avg'.padEnd(15)} ${'% of 100MB'}`);
console.log(` ${'Users'.padEnd(10)} ${'Total cache'.padEnd(15)} ${'Per-user avg'.padEnd(15)} % of 100MB`);
for (const count of CONCURRENT_COUNTS) {
const concurrent = measure(`${count} concurrent`, () => {
@@ -307,7 +316,7 @@ console.log('Survey DO Memory Profiler');
console.log(`Node ${process.version} | V8 heap used: ${fmt(process.memoryUsage().heapUsed)}`);
console.log(`Budget: 128 MB total DO memory (assume ~100 MB usable after runtime overhead)`);
if (!global.gc) {
if (!globalThis.gc) {
console.log('\n⚠ For accurate measurements, run with: node --expose-gc --import tsx scripts/memory-profile.ts');
}
@@ -38,9 +38,11 @@ export const ROLE_HIERARCHY: Record<string, number> = { admin: 3, editor: 2, vie
*/
export const MAX_ANSWER_MS = 24 * 60 * 60 * 1000;
/** Returns null for missing/invalid input so callers store NULL rather than poisoning aggregates. */
/**
Returns null for missing/invalid input so callers store NULL rather than poisoning aggregates.
*/
export function clampAnswerMs(raw: unknown): number | null {
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) return null;
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0) {return null;}
return Math.min(Math.floor(raw), MAX_ANSWER_MS);
}
@@ -67,9 +69,11 @@ export const COMPLETION_TIME_BUCKETS: ReadonlyArray<{
{ label: '>1h', minSeconds: 3600, maxSeconds: null },
];
/** Nearest-rank percentile; `sorted` MUST already be ascending. Null when empty. */
/**
Nearest-rank percentile; `sorted` MUST already be ascending. Null when empty.
*/
export function percentile(sorted: number[], p: number): number | null {
if (sorted.length === 0) return null;
if (sorted.length === 0) {return null;}
const idx = Math.min(sorted.length - 1, Math.max(0, Math.floor((p / 100) * (sorted.length - 1))));
return sorted[idx];
}
+1 -1
View File
@@ -5,7 +5,7 @@ export function getCookie(
name: string,
): string | undefined {
const header = request.headers.get('Cookie') ?? '';
const match = header.match(new RegExp(`(?:^|;\\s*)${name}=([^;]+)`));
const match = header.match(new RegExp(String.raw`(?:^|;\s*)${name}=([^;]+)`));
return match?.[1];
}
+5 -3
View File
@@ -63,7 +63,9 @@ export interface AnswersTable {
answer: string;
other_text: string | null;
answered_at: string;
/** Milliseconds the respondent spent on this question before committing. */
/**
Milliseconds the respondent spent on this question before committing.
*/
answer_ms: number | null;
}
@@ -126,8 +128,8 @@ export interface DbConfig {
}
export function detectDbType(url?: string): DbType {
if (!url) return 'sqlite';
if (url.startsWith('postgres://') || url.startsWith('postgresql://')) return 'postgres';
if (!url) {return 'sqlite';}
if (url.startsWith('postgres://') || url.startsWith('postgresql://')) {return 'postgres';}
return 'sqlite';
}
@@ -43,16 +43,20 @@ export class SurveyCache {
*/
private _respondentState = new Map<string, RespondentState>();
/** Debounce flag for scheduled broadcasts — shared between ws-handler and survey-do */
/**
Debounce flag for scheduled broadcasts — shared between ws-handler and survey-do
*/
readonly broadcastScheduled = { value: false };
/** Fast-tier alarm tick counter, held in memory rather than DO storage (see SLOW_TICKS_PER_CYCLE). */
/**
Fast-tier alarm tick counter, held in memory rather than DO storage (see SLOW_TICKS_PER_CYCLE).
*/
fastTick = 0;
constructor(private sql: SqlStorage) {}
hasRespondent(id: string): boolean {
if (this._respondentState.has(id)) return true;
if (this._respondentState.has(id)) {return true;}
const row = this.sql.exec('SELECT 1 FROM respondents WHERE id = ? LIMIT 1', id).toArray()[0];
return !!row;
}
@@ -61,14 +65,16 @@ export class SurveyCache {
this._respondentState.set(id, { isComplete: false, hasSubmitted: false, choiceAnswers: new Map() });
}
/** Callers needing text answers must query SQL — only choice answers are cached. */
/**
Callers needing text answers must query SQL — only choice answers are cached.
*/
getCachedRespondent(id: string): RespondentState | undefined {
return this._respondentState.get(id);
}
setAnswer(respondentId: string, questionId: string, value: string, otherText: string | null): void {
const state = this._respondentState.get(respondentId);
if (!state) return;
if (!state) {return;}
state.hasSubmitted = true;
if (this.choiceQuestionIds.has(questionId)) {
state.choiceAnswers.set(questionId, { value, otherText });
@@ -77,7 +83,7 @@ export class SurveyCache {
markRespondentComplete(id: string): void {
const state = this._respondentState.get(id);
if (state) state.isComplete = true;
if (state) {state.isComplete = true;}
}
removeRespondent(id: string): void {
@@ -85,15 +91,15 @@ export class SurveyCache {
}
get survey(): SurveyRow {
if (this._survey) return this._survey;
if (this._survey) {return this._survey;}
const rows = this.sql.exec('SELECT * FROM surveys LIMIT 1').toArray();
if (rows.length === 0) throw new ServiceError('Survey not found', 404);
if (rows.length === 0) {throw new ServiceError('Survey not found', 404);}
this._survey = rows[0] as unknown as SurveyRow;
return this._survey;
}
get sections(): SectionRow[] {
if (this._sections) return this._sections;
if (this._sections) {return this._sections;}
this._sections = this.sql
.exec('SELECT * FROM survey_sections ORDER BY sort_order')
.toArray() as unknown as SectionRow[];
@@ -101,7 +107,7 @@ export class SurveyCache {
}
get questions(): QuestionRow[] {
if (this._questions) return this._questions;
if (this._questions) {return this._questions;}
this._questions = this.sql
.exec('SELECT * FROM survey_questions ORDER BY sort_order')
.toArray() as unknown as QuestionRow[];
@@ -109,13 +115,13 @@ export class SurveyCache {
}
get choiceQuestionIds(): Set<string> {
if (this._choiceQuestionIds) return this._choiceQuestionIds;
if (this._choiceQuestionIds) {return this._choiceQuestionIds;}
this._choiceQuestionIds = new Set(this.questions.filter((q) => CHOICE_TYPES.has(q.type)).map((q) => q.id));
return this._choiceQuestionIds;
}
get counters(): { total: number; completed: number } {
if (this._counters) return this._counters;
if (this._counters) {return this._counters;}
const row = this.sql
.exec(`SELECT COUNT(*) as total, SUM(CASE WHEN is_complete = 1 THEN 1 ELSE 0 END) as completed FROM respondents`)
.toArray()[0];
@@ -124,10 +130,10 @@ export class SurveyCache {
}
get tallies(): Map<string, AnswerTally[]> {
if (this._tallies) return this._tallies;
if (this._tallies) {return this._tallies;}
const choiceIds = this.questions.filter((q) => CHOICE_TYPES.has(q.type)).map((q) => q.id);
this._tallies = new Map();
if (choiceIds.length === 0) return this._tallies;
if (choiceIds.length === 0) {return this._tallies;}
const placeholders = choiceIds.map(() => '?').join(',');
const rows = this.sql
@@ -142,7 +148,7 @@ export class SurveyCache {
for (const row of rows) {
const qId = row.question_id as string;
if (!this._tallies.has(qId)) this._tallies.set(qId, []);
if (!this._tallies.has(qId)) {this._tallies.set(qId, []);}
this._tallies.get(qId)!.push({
value: row.answer as string,
otherText: (row.other_text as string) || null,
@@ -153,7 +159,7 @@ export class SurveyCache {
}
get hasSurvey(): boolean {
if (this._survey) return true;
if (this._survey) {return true;}
const rows = this.sql.exec('SELECT id FROM surveys LIMIT 1').toArray();
return rows.length > 0;
}
@@ -172,11 +178,11 @@ export class SurveyCache {
}
incrementTotal(): void {
if (this._counters) this._counters.total++;
if (this._counters) {this._counters.total++;}
}
incrementCompleted(): void {
if (this._counters) this._counters.completed++;
if (this._counters) {this._counters.completed++;}
}
/**
@@ -187,7 +193,7 @@ export class SurveyCache {
* and the live charts silently drift.
*/
updateTalliesOnCompletion(respondentId: string): void {
if (!this._tallies) return;
if (!this._tallies) {return;}
const state = this._respondentState.get(respondentId);
if (!state) {
this._tallies = null;
@@ -195,7 +201,7 @@ export class SurveyCache {
}
for (const [qId, ans] of state.choiceAnswers) {
if (!this._tallies.has(qId)) this._tallies.set(qId, []);
if (!this._tallies.has(qId)) {this._tallies.set(qId, []);}
const qTallies = this._tallies.get(qId)!;
const existing = qTallies.find((t) => t.value === ans.value && t.otherText === ans.otherText);
if (existing) {
@@ -71,8 +71,9 @@ export async function execute(op: string, data: Record<string, unknown>, ctx: Co
return undefined;
}
case 'get-results':
case 'get-results': {
return ctx.respondentService.getResults(surveyId);
}
case 'get-timeline': {
const raw = data.granularity;
@@ -80,14 +81,17 @@ export async function execute(op: string, data: Record<string, unknown>, ctx: Co
return ctx.respondentService.getTimeline(surveyId, granularity);
}
case 'get-completion-times':
case 'get-completion-times': {
return ctx.respondentService.getCompletionTimes(surveyId);
}
case 'get-question-timings':
case 'get-question-timings': {
return ctx.respondentService.getQuestionTimings(surveyId);
}
case 'get-dropoff':
case 'get-dropoff': {
return ctx.respondentService.getDropoff(surveyId);
}
case 'list-respondents': {
const offset = Number(data.offset ?? 0);
@@ -95,8 +99,9 @@ export async function execute(op: string, data: Record<string, unknown>, ctx: Co
return ctx.respondentService.listRespondents(surveyId, offset, limit);
}
case 'get-respondent':
case 'get-respondent': {
return ctx.respondentService.getRespondentDetail(surveyId, data.respondentId as string);
}
case 'delete-respondent': {
await ctx.respondentService.deleteRespondent(surveyId, data.respondentId as string);
@@ -117,10 +122,12 @@ export async function execute(op: string, data: Record<string, unknown>, ctx: Co
});
}
case 'export-definition':
case 'export-definition': {
return ctx.surveyService.exportDefinition(surveyId);
}
default:
default: {
return null;
}
}
}
@@ -1,4 +1,6 @@
/** Table names must match the main Database interface so the shared Kysely queries work unchanged. */
/**
Table names must match the main Database interface so the shared Kysely queries work unchanged.
*/
const SCHEMA = `
CREATE TABLE IF NOT EXISTS surveys (
@@ -76,7 +78,9 @@ CREATE TABLE IF NOT EXISTS answers (
const initializedInstances = new WeakSet<SqlStorage>();
/** Additive migrations, replayed on every boot — there is no version table, so each must be safe to re-run. */
/**
Additive migrations, replayed on every boot — there is no version table, so each must be safe to re-run.
*/
const MIGRATIONS: string[] = ['ALTER TABLE answers ADD COLUMN answer_ms INTEGER'];
function isAlreadyAppliedError(e: unknown): boolean {
@@ -89,14 +93,14 @@ function applySchema(sql: SqlStorage): void {
for (const stmt of MIGRATIONS) {
try {
sql.exec(stmt);
} catch (e) {
if (!isAlreadyAppliedError(e)) throw e;
} catch (error) {
if (!isAlreadyAppliedError(error)) {throw error;}
}
}
}
export function ensureSchema(sql: SqlStorage): void {
if (initializedInstances.has(sql)) return;
if (initializedInstances.has(sql)) {return;}
applySchema(sql);
initializedInstances.add(sql);
}
@@ -45,11 +45,11 @@ export class SurveyDO extends DurableObject {
try {
return await this.handleHttp(request, url);
} catch (e) {
if (e instanceof ServiceError) {
return Response.json({ error: e.message }, { status: e.status });
} catch (error) {
if (error instanceof ServiceError) {
return Response.json({ error: error.message }, { status: error.status });
}
console.error('SurveyDO error:', e);
console.error('SurveyDO error:', error);
return Response.json({ error: 'Internal error' }, { status: 500 });
}
}
@@ -115,7 +115,7 @@ export class SurveyDO extends DurableObject {
const [client, server] = Object.values(pair);
const tags: string[] = [type, `role:${verifiedRole}`];
if (respondentId) tags.push(`rid:${respondentId}`);
if (respondentId) {tags.push(`rid:${respondentId}`);}
this.ctx.acceptWebSocket(server, tags);
const counts = getPresenceCounts(this.ctx);
@@ -123,13 +123,13 @@ export class SurveyDO extends DurableObject {
scheduleBroadcast(this.ctx, this.cache.broadcastScheduled);
const headers = new Headers();
if (setCookieHeader) headers.set('Set-Cookie', setCookieHeader);
if (setCookieHeader) {headers.set('Set-Cookie', setCookieHeader);}
return new Response(null, { status: 101, webSocket: client, headers });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
if (typeof message !== 'string') return;
if (typeof message !== 'string') {return;}
const services = {
survey: this.surveyService,
respondent: this.respondentService,
@@ -155,8 +155,8 @@ export class SurveyDO extends DurableObject {
this.cache.fastTick = 0;
if (this.ctx.getWebSockets('viewer').length > 0 && this.cache.hasSurvey) {
// Fire-and-forget: we don't block the next fast broadcast on analytics.
broadcastSlowAnalytics(this.ctx, this.cache.survey.id, this.respondentService).catch((e) => {
console.error('slow analytics broadcast failed:', e);
broadcastSlowAnalytics(this.ctx, this.cache.survey.id, this.respondentService).catch((error) => {
console.error('slow analytics broadcast failed:', error);
});
}
}
@@ -175,25 +175,25 @@ export class SurveyDO extends DurableObject {
const method = request.method;
// Init (called by API worker on survey creation)
if (method === 'POST' && path === '/init') return this.handleInit(request);
if (method === 'POST' && path === '/init') {return this.handleInit(request);}
// Survey CRUD (stays HTTP for D1 catalog sync)
if ((path === '/' || path === '') && method === 'GET') return this.handleGetSurvey();
if ((path === '/' || path === '') && method === 'PUT') return this.handleUpdateSurvey(request);
if ((path === '/' || path === '') && method === 'DELETE') return this.handleDeleteSurvey();
if (method === 'PUT' && path === '/publish') return this.handlePublish();
if (method === 'PUT' && path === '/unpublish') return this.handleUnpublish();
if (method === 'PUT' && path === '/archive') return this.handleArchive();
if (method === 'PUT' && path === '/unarchive') return this.handleUnarchive();
if (method === 'POST' && path === '/duplicate') return this.handleDuplicate();
if ((path === '/' || path === '') && method === 'GET') {return this.handleGetSurvey();}
if ((path === '/' || path === '') && method === 'PUT') {return this.handleUpdateSurvey(request);}
if ((path === '/' || path === '') && method === 'DELETE') {return this.handleDeleteSurvey();}
if (method === 'PUT' && path === '/publish') {return this.handlePublish();}
if (method === 'PUT' && path === '/unpublish') {return this.handleUnpublish();}
if (method === 'PUT' && path === '/archive') {return this.handleArchive();}
if (method === 'PUT' && path === '/unarchive') {return this.handleUnarchive();}
if (method === 'POST' && path === '/duplicate') {return this.handleDuplicate();}
if (method === 'GET' && path === '/results/export') return this.handleExportResults(url);
if (method === 'GET' && path === '/results/export') {return this.handleExportResults(url);}
// Public respondent (cookie-setting endpoints stay HTTP)
if (method === 'GET' && path === '/public') return this.handleGetPublicSurvey(request);
if (method === 'GET' && path === '/public/resume') return this.handleResume(request);
if (method === 'POST' && path === '/public/answers/batch') return this.handleSubmitAnswers(request);
if (method === 'POST' && path === '/public/complete') return this.handleComplete(request);
if (method === 'GET' && path === '/public') {return this.handleGetPublicSurvey(request);}
if (method === 'GET' && path === '/public/resume') {return this.handleResume(request);}
if (method === 'POST' && path === '/public/answers/batch') {return this.handleSubmitAnswers(request);}
if (method === 'POST' && path === '/public/complete') {return this.handleComplete(request);}
return this.handleSelfHostedFallback(method, path, request, url);
}
@@ -298,7 +298,9 @@ export class SurveyDO extends DurableObject {
try {
ws.close(1000, 'Survey deleted');
} catch {
/* ignore */
/*
ignore
*/
}
}
// deleteAll() drops the SQLite tables too, and this same live instance serves
@@ -446,7 +448,7 @@ export class SurveyDO extends DurableObject {
return Response.json({ error: 'Authentication required' }, { status: 403 });
}
const respondentId = request.headers.get('X-Respondent-Id');
if (!respondentId) return Response.json({ error: 'No respondent cookie' }, { status: 400 });
if (!respondentId) {return Response.json({ error: 'No respondent cookie' }, { status: 400 });}
const { answers } = (await request.json()) as {
answers: Array<{ questionId: string; value: string; otherText?: string; answerMs?: number }>;
};
@@ -467,7 +469,7 @@ export class SurveyDO extends DurableObject {
return Response.json({ error: 'Authentication required' }, { status: 403 });
}
const respondentId = request.headers.get('X-Respondent-Id');
if (!respondentId) return Response.json({ error: 'No respondent cookie' }, { status: 400 });
if (!respondentId) {return Response.json({ error: 'No respondent cookie' }, { status: 400 });}
const completed = await this.respondentService.complete(survey.slug!, respondentId, survey);
if (completed) {
this.cache.incrementCompleted();
@@ -481,7 +483,7 @@ export class SurveyDO extends DurableObject {
const route = this.matchFallbackRoute(method, path, url);
if (!route) {
// Live results has transport-specific ETag handling
if (method === 'GET' && path === '/results/live') return this.handleLiveResults(request);
if (method === 'GET' && path === '/results/live') {return this.handleLiveResults(request);}
return new Response('Not found', { status: 404 });
}
@@ -489,7 +491,7 @@ export class SurveyDO extends DurableObject {
const data = { ...route.params, ...body };
const result = await execute(route.op, data, this.commandContext());
if (result === undefined) return new Response(null, { status: 204 });
if (result === undefined) {return new Response(null, { status: 204 });}
return Response.json(result, { status: route.status ?? 200 });
}
@@ -499,36 +501,36 @@ export class SurveyDO extends DurableObject {
url: URL,
): { op: string; params: Record<string, unknown>; status?: number } | null {
// Sections — reorder before /:id pattern
if (method === 'POST' && path === '/sections') return { op: 'create-section', params: {}, status: 201 };
if (method === 'PUT' && path === '/sections/reorder') return { op: 'reorder-sections', params: {} };
if (method === 'POST' && path === '/sections') {return { op: 'create-section', params: {}, status: 201 };}
if (method === 'PUT' && path === '/sections/reorder') {return { op: 'reorder-sections', params: {} };}
let match = path.match(/^\/sections\/([^/]+)$/);
if (match && method === 'PUT') return { op: 'update-section', params: { id: match[1] } };
if (match && method === 'DELETE') return { op: 'delete-section', params: { id: match[1] } };
if (match && method === 'PUT') {return { op: 'update-section', params: { id: match[1] } };}
if (match && method === 'DELETE') {return { op: 'delete-section', params: { id: match[1] } };}
match = path.match(/^\/sections\/([^/]+)\/questions\/reorder$/);
if (match && method === 'PUT') return { op: 'reorder-questions', params: { sectionId: match[1] } };
if (match && method === 'PUT') {return { op: 'reorder-questions', params: { sectionId: match[1] } };}
match = path.match(/^\/sections\/([^/]+)\/questions$/);
if (match && method === 'POST') return { op: 'create-question', params: { sectionId: match[1] }, status: 201 };
if (match && method === 'POST') {return { op: 'create-question', params: { sectionId: match[1] }, status: 201 };}
match = path.match(/^\/questions\/([^/]+)$/);
if (match && method === 'PUT') return { op: 'update-question', params: { id: match[1] } };
if (match && method === 'DELETE') return { op: 'delete-question', params: { id: match[1] } };
if (match && method === 'PUT') {return { op: 'update-question', params: { id: match[1] } };}
if (match && method === 'DELETE') {return { op: 'delete-question', params: { id: match[1] } };}
if (method === 'GET' && path === '/results') return { op: 'get-results', params: {} };
if (method === 'GET' && path === '/results') {return { op: 'get-results', params: {} };}
if (method === 'GET' && path === '/results/timeline')
return { op: 'get-timeline', params: { granularity: url.searchParams.get('granularity') } };
if (method === 'GET' && path === '/results/completion-times') return { op: 'get-completion-times', params: {} };
if (method === 'GET' && path === '/results/question-timings') return { op: 'get-question-timings', params: {} };
if (method === 'GET' && path === '/results/dropoff') return { op: 'get-dropoff', params: {} };
{return { op: 'get-timeline', params: { granularity: url.searchParams.get('granularity') } };}
if (method === 'GET' && path === '/results/completion-times') {return { op: 'get-completion-times', params: {} };}
if (method === 'GET' && path === '/results/question-timings') {return { op: 'get-question-timings', params: {} };}
if (method === 'GET' && path === '/results/dropoff') {return { op: 'get-dropoff', params: {} };}
if (method === 'GET' && path === '/results/respondents')
return {
{return {
op: 'list-respondents',
params: { offset: url.searchParams.get('offset'), limit: url.searchParams.get('limit') },
};
};}
match = path.match(/^\/results\/respondents\/([^/]+)$/);
if (match && method === 'GET') return { op: 'get-respondent', params: { respondentId: match[1] } };
if (match && method === 'DELETE') return { op: 'delete-respondent', params: { respondentId: match[1] } };
if (match && method === 'GET') {return { op: 'get-respondent', params: { respondentId: match[1] } };}
if (match && method === 'DELETE') {return { op: 'delete-respondent', params: { respondentId: match[1] } };}
if (method === 'GET' && path === '/results/search')
return {
{return {
op: 'search-answers',
params: {
query: url.searchParams.get('q') ?? '',
@@ -536,9 +538,9 @@ export class SurveyDO extends DurableObject {
offset: url.searchParams.get('offset'),
limit: url.searchParams.get('limit'),
},
};
};}
if (method === 'GET' && path === '/definition') return { op: 'export-definition', params: {} };
if (method === 'GET' && path === '/definition') {return { op: 'export-definition', params: {} };}
return null;
}
@@ -547,7 +549,7 @@ export class SurveyDO extends DurableObject {
const presence = getPresenceCounts(this.ctx).data;
const results = await this.respondentService.getLiveResults(this.cache.survey.id, presence);
const etag = `"${results.respondentCounts.completed}-${results.respondentCounts.total}"`;
if (request.headers.get('If-None-Match') === etag) return new Response(null, { status: 304 });
if (request.headers.get('If-None-Match') === etag) {return new Response(null, { status: 304 });}
const response = Response.json(results);
response.headers.set('ETag', etag);
response.headers.set('Cache-Control', 'private, no-cache');
@@ -564,16 +566,16 @@ export class SurveyDO extends DurableObject {
private internalPath(pathname: string): string {
const surveyMatch = pathname.match(SURVEY_ID_PATTERN);
if (surveyMatch) return surveyMatch[2] || '/';
if (surveyMatch) {return surveyMatch[2] || '/';}
const publicMatch = pathname.match(PUBLIC_PATTERN);
if (publicMatch) return '/public' + (publicMatch[2] || '');
if (publicMatch) {return '/public' + (publicMatch[2] || '');}
return pathname;
}
private passwordGateAllows(request: Request): boolean {
const survey = this.cache.survey;
if (!survey.password_hash) return true;
if (request.headers.get('X-Authenticated') !== 'true') return false;
if (!survey.password_hash) {return true;}
if (request.headers.get('X-Authenticated') !== 'true') {return false;}
return fingerprintMatchesPassword(request.headers.get('X-Survey-Pw-Fp'), survey.password_hash);
}
@@ -33,7 +33,7 @@ export function getPresenceCounts(ctx: DurableObjectState): {
export function broadcastToViewers(ctx: DurableObjectState, cache: SurveyCache): void {
const viewers = ctx.getWebSockets('viewer');
if (viewers.length === 0) return;
if (viewers.length === 0) {return;}
const counts = getPresenceCounts(ctx);
const counters = cache.counters;
@@ -67,7 +67,7 @@ export function broadcastToViewers(ctx: DurableObjectState, cache: SurveyCache):
}
export function scheduleBroadcast(ctx: DurableObjectState, scheduled: { value: boolean }): void {
if (scheduled.value) return;
if (scheduled.value) {return;}
scheduled.value = true;
ctx.storage.setAlarm(Date.now() + BROADCAST_INTERVAL_MS);
}
@@ -78,7 +78,7 @@ export async function broadcastSlowAnalytics(
respondents: RespondentService,
): Promise<void> {
const viewers = ctx.getWebSockets('viewer');
if (viewers.length === 0) return;
if (viewers.length === 0) {return;}
const [timeline, dropoff, completionTimes, questionTimings] = await Promise.all([
respondents.getTimeline(surveyId, 'minute'),
@@ -71,7 +71,7 @@ const OP_ROLES: Record<string, MinRole> = {
function getWsRole(ws: WebSocket, ctx: DurableObjectState): string {
for (const tag of ctx.getTags(ws)) {
if (tag.startsWith('role:')) return tag.slice(5);
if (tag.startsWith('role:')) {return tag.slice(5);}
}
return 'public';
}
@@ -82,7 +82,7 @@ function hasMinRole(ws: WebSocket, ctx: DurableObjectState, minRole: string): bo
function getWsRespondentId(ws: WebSocket, ctx: DurableObjectState): string | null {
for (const tag of ctx.getTags(ws)) {
if (tag.startsWith('rid:')) return tag.slice(4);
if (tag.startsWith('rid:')) {return tag.slice(4);}
}
return null;
}
@@ -128,8 +128,8 @@ export async function dispatch(
try {
const result = await handleOp(ws, op as keyof WsOperations, d, services, cache, ctx);
respond(ws, requestId, op, result);
} catch (e) {
const message = e instanceof ServiceError ? e.message : e instanceof Error ? e.message : 'Internal error';
} catch (error) {
const message = (error instanceof ServiceError) || (error instanceof Error) ? error.message : 'Internal error';
respondError(ws, requestId, op, message);
}
}
@@ -150,22 +150,24 @@ async function handleOp(
return { ...detail, survey: toClientSurvey(detail.survey) };
}
case 'get-results':
case 'get-results': {
return {
respondentCounts: cache.counters,
results: cache.buildAggregatedResults(),
};
}
case 'get-live-results':
case 'get-live-results': {
return {
respondentCounts: cache.counters,
results: cache.buildAggregatedResults(),
liveCounts: getPresenceCounts(ctx).data,
};
}
case 'get-public-survey': {
const survey = cache.survey;
if (survey.status !== 'published') throw new ServiceError('Survey not found', 404);
if (survey.status !== 'published') {throw new ServiceError('Survey not found', 404);}
const { password_hash: _, ...safeSurvey } = survey;
return { survey: safeSurvey, sections: cache.sections, questions: cache.questions };
}
@@ -174,7 +176,7 @@ async function handleOp(
// A cached respondent with hasSubmitted=false provably has zero answers, so we
// can skip SQL; anything else must read SQL (only choice answers are cached).
const respondentId = getWsRespondentId(ws, ctx);
if (!respondentId) throw new ServiceError('Not a respondent connection', 401);
if (!respondentId) {throw new ServiceError('Not a respondent connection', 401);}
const cachedState = cache.getCachedRespondent(respondentId);
if (cachedState && !cachedState.hasSubmitted) {
@@ -189,7 +191,7 @@ async function handleOp(
for (const row of answerRows) {
answers[row.question_id] = {
value: row.answer,
...(row.other_text ? { otherText: row.other_text } : {}),
...(row.other_text && { otherText: row.other_text }),
};
}
@@ -207,7 +209,7 @@ async function handleOp(
const questions = cache.questions;
let lastAnsweredIndex = -1;
for (let i = 0; i < questions.length; i++) {
if (questions[i].id in answers) lastAnsweredIndex = i;
if (questions[i].id in answers) {lastAnsweredIndex = i;}
}
const nextQuestionIndex = Math.max(0, lastAnsweredIndex);
@@ -216,7 +218,7 @@ async function handleOp(
case 'submit-answers': {
const respondentId = getWsRespondentId(ws, ctx);
if (!respondentId) throw new ServiceError('Not a respondent connection', 401);
if (!respondentId) {throw new ServiceError('Not a respondent connection', 401);}
const survey = cache.survey;
if (survey.closes_at && new Date(survey.closes_at) < new Date()) {
@@ -232,10 +234,10 @@ async function handleOp(
const row = ctx.storage.sql
.exec('SELECT is_complete FROM respondents WHERE id = ? LIMIT 1', respondentId)
.toArray()[0] as { is_complete: number } | undefined;
if (!row) throw new ServiceError('Respondent not found', 404);
if (!row) {throw new ServiceError('Respondent not found', 404);}
isComplete = row.is_complete === 1;
}
if (isComplete) throw new ServiceError('Survey already completed', 409);
if (isComplete) {throw new ServiceError('Survey already completed', 409);}
const answers = (
data as {
@@ -249,7 +251,7 @@ async function handleOp(
const questionMap = new Map(cache.questions.map((cq) => [cq.id, cq]));
for (const a of answers) {
const cq = questionMap.get(a.questionId);
if (!cq) throw new ServiceError(`Invalid question ID: ${a.questionId}`, 400);
if (!cq) {throw new ServiceError(`Invalid question ID: ${a.questionId}`, 400);}
const spec: QuestionSpec = {
type: cq.type,
required: cq.required === 1,
@@ -259,7 +261,7 @@ async function handleOp(
config: cq.config ? JSON.parse(cq.config) : undefined,
};
const error = validateAnswer(spec, a.value, a.otherText);
if (error) throw new ServiceError(error, 400);
if (error) {throw new ServiceError(error, 400);}
}
// answer_ms is clamped via the shared helper so it stays in lockstep with the
@@ -289,7 +291,7 @@ async function handleOp(
// bump counters or rewrite completed_at — hence the 0→1 gated UPDATE and the
// `transitioned` check around every cache mutation.
const respondentId = getWsRespondentId(ws, ctx);
if (!respondentId) throw new ServiceError('Not a respondent connection', 401);
if (!respondentId) {throw new ServiceError('Not a respondent connection', 401);}
const cursor = ctx.storage.sql.exec(
'UPDATE respondents SET is_complete = 1, completed_at = ? WHERE id = ? AND is_complete = 0',
@@ -308,9 +310,9 @@ async function handleOp(
}
default: {
const opStr = String(op);
const opStr = op;
const result = await execute(opStr, data, cmdCtx);
if (result === null) throw new ServiceError(`Unknown operation: ${opStr}`, 400);
if (result === null) {throw new ServiceError(`Unknown operation: ${opStr}`, 400);}
return result ?? {};
}
}
+27 -27
View File
@@ -40,9 +40,9 @@ function registerAllRoutes(router: ReturnType<typeof AutoRouter>) {
// checks in itty-router. This custom format handler detects Response-like objects by
// checking for the `status` and `headers` properties instead of using instanceof.
const nodeFormat = (value: unknown) => {
if (value === undefined || value === null) return value;
if (typeof value === 'object' && 'status' in (value as object) && 'headers' in (value as object)) return value;
return new Response(JSON.stringify(value), { headers: { 'content-type': 'application/json; charset=utf-8' } });
if (value === undefined || value === null) {return value;}
if (typeof value === 'object' && 'status' in (value as object) && 'headers' in (value as object)) {return value;}
return Response.json(value, { headers: { 'content-type': 'application/json; charset=utf-8' } });
};
export function createRouter(ctx: AppContext) {
@@ -118,7 +118,7 @@ const slugCache = new Map<string, CachedSlugEntry>();
const INTERNAL_HEADERS = ['X-WS-Role', 'X-Respondent-Id', 'X-Authenticated', 'X-Survey-Pw-Fp'];
function stripInternalHeaders(headers: Headers): void {
for (const h of INTERNAL_HEADERS) headers.delete(h);
for (const h of INTERNAL_HEADERS) {headers.delete(h);}
}
async function slugToRow(db: D1Database, slug: string): Promise<{ id: string } | null> {
@@ -133,7 +133,7 @@ async function slugToRow(db: D1Database, slug: string): Promise<{ id: string } |
slugCache.set(slug, { id: row.id, cachedAt: Date.now() });
if (slugCache.size > MAX_SLUG_CACHE_ENTRIES) {
const oldestSlug = slugCache.keys().next().value;
if (oldestSlug) slugCache.delete(oldestSlug);
if (oldestSlug) {slugCache.delete(oldestSlug);}
}
}
return row;
@@ -145,8 +145,8 @@ async function slugToRow(db: D1Database, slug: string): Promise<{ id: string } |
* the TTL to expire.
*/
function invalidateSlugCacheBySurveyId(surveyId: string): void {
for (const [slug, entry] of slugCache.entries()) {
if (entry.id === surveyId) slugCache.delete(slug);
for (const [slug, entry] of slugCache) {
if (entry.id === surveyId) {slugCache.delete(slug);}
}
}
@@ -175,11 +175,11 @@ const CATALOG_SYNC_COLUMNS = new Set([
async function syncCatalog(response: Response, db: D1Database, surveyId: string): Promise<void> {
const syncHeader = response.headers.get('X-Catalog-Sync');
if (!syncHeader) return;
if (!syncHeader) {return;}
try {
const fields = JSON.parse(syncHeader) as Record<string, unknown>;
const keys = Object.keys(fields).filter((k) => CATALOG_SYNC_COLUMNS.has(k));
if (keys.length === 0) return;
if (keys.length === 0) {return;}
const setClauses = keys.map((k) => `${k} = ?`).join(', ');
const values = [...keys.map((k) => fields[k]), surveyId];
await db
@@ -227,11 +227,11 @@ function matchDORoute(method: string, pathname: string): { surveyId?: string; sl
const surveyMatch = pathname.match(SURVEY_ID_PATTERN);
if (surveyMatch) {
const id = surveyMatch[1];
if (id === 'import') return null; // POST /api/surveys/import
if (id === 'import') {return null;} // POST /api/surveys/import
// Tags live in D1, not in the DO — let itty-router handle them
const subPath = surveyMatch[2] || '';
if (subPath === '/tags') return null;
if (subPath === '/init') return null;
if (subPath === '/tags') {return null;}
if (subPath === '/init') {return null;}
return { surveyId: id };
}
@@ -239,7 +239,7 @@ function matchDORoute(method: string, pathname: string): { surveyId?: string; sl
if (publicMatch) {
const slug = publicMatch[1];
const subPath = publicMatch[2] || '';
if (subPath === '/auth' || subPath === '/reset') return null;
if (subPath === '/auth' || subPath === '/reset') {return null;}
return { slug };
}
@@ -271,8 +271,8 @@ export default {
if (surveyData?.id) {
try {
await initDO(env, surveyData.id, body.survey ? body : { survey: surveyData });
} catch (e) {
console.error('Rolling back survey creation after DO init failure:', e);
} catch (error) {
console.error('Rolling back survey creation after DO init failure:', error);
await env.DB.prepare('DELETE FROM surveys WHERE id = ?').bind(surveyData.id).run();
return Response.json({ error: 'Failed to initialize survey storage. Please try again.' }, { status: 503 });
}
@@ -296,7 +296,7 @@ export default {
surveyId = doMatch.surveyId;
} else if (doMatch.slug) {
const row = await slugToRow(env.DB, doMatch.slug);
if (!row) return Response.json({ error: 'Survey not found' }, { status: 404 });
if (!row) {return Response.json({ error: 'Survey not found' }, { status: 404 });}
surveyId = row.id;
} else {
return router.fetch(request, env, ctx);
@@ -312,7 +312,7 @@ export default {
// (always-current) survey.password_hash and rejects with 403 when needed.
if (!isPublicRoute) {
const authResult = await authenticateRequest(request, config);
if (authResult instanceof Response) return authResult;
if (authResult instanceof Response) {return authResult;}
const subPath = pathname.match(SURVEY_ID_PATTERN)?.[2] || '/';
const requiredRole = getRequiredDORole(method, subPath);
@@ -331,7 +331,7 @@ export default {
let wsRole = 'public';
if (wsType === 'viewer' || wsType === 'editor') {
const authResult = await authenticateRequest(request, config);
if (authResult instanceof Response) return authResult;
if (authResult instanceof Response) {return authResult;}
const minRole = wsType === 'editor' ? 'editor' : 'viewer';
if (!checkRole(authResult, minRole)) {
return Response.json({ error: 'Insufficient permissions' }, { status: 403 });
@@ -347,7 +347,7 @@ export default {
// Set from the verified rid cookie only. Lets the DO tag the connection
// so respondent messages don't each need re-authenticating.
const respondentId = getRespondentId(request, doMatch.slug);
if (respondentId) wsHeaders.set('X-Respondent-Id', respondentId);
if (respondentId) {wsHeaders.set('X-Respondent-Id', respondentId);}
// Verified unconditionally — the DO decides whether a password is
// actually required, from its own current password_hash.
@@ -356,7 +356,7 @@ export default {
? await verifySurveyPasswordTokenSignature(token, surveyId, config.passwordSecret)
: { valid: false as const };
wsHeaders.set('X-Authenticated', pw.valid ? 'true' : 'false');
if (pw.valid && pw.fingerprint) wsHeaders.set('X-Survey-Pw-Fp', pw.fingerprint);
if (pw.valid && pw.fingerprint) {wsHeaders.set('X-Survey-Pw-Fp', pw.fingerprint);}
}
return stub.fetch(new Request(request.url, { method: request.method, headers: wsHeaders }));
@@ -393,14 +393,14 @@ export default {
// Set from the verified rid cookie only, never from a client header.
const slug = doMatch.slug!;
const respondentId = getRespondentId(request, slug);
if (respondentId) doHeaders.set('X-Respondent-Id', respondentId);
if (respondentId) {doHeaders.set('X-Respondent-Id', respondentId);}
const token = getCookie(request, `spw_${slug}`);
const pw = token
? await verifySurveyPasswordTokenSignature(token, surveyId, config.passwordSecret)
: { valid: false as const };
doHeaders.set('X-Authenticated', pw.valid ? 'true' : 'false');
if (pw.valid && pw.fingerprint) doHeaders.set('X-Survey-Pw-Fp', pw.fingerprint);
if (pw.valid && pw.fingerprint) {doHeaders.set('X-Survey-Pw-Fp', pw.fingerprint);}
}
const doRequest = new Request(request.url, {
@@ -503,15 +503,15 @@ function checkRole(role: UserRole, minRole: UserRole): boolean {
function allowedCredentialedOrigin(request: Request, config: import('./config').AppConfig): string | null {
const origin = request.headers.get('Origin');
if (!origin) return null;
if (!origin) {return null;}
try {
if (origin === new URL(request.url).origin) return origin;
if (origin === new URL(request.url).origin) {return origin;}
} catch {
// Unparseable request URL — fall through to the configured origin check.
}
if (config.oidc.redirectUri) {
try {
if (origin === new URL(config.oidc.redirectUri).origin) return origin;
if (origin === new URL(config.oidc.redirectUri).origin) {return origin;}
} catch {
// Malformed redirect URI — treat as no configured origin.
}
@@ -526,8 +526,8 @@ function getRequiredDORole(method: string, subPath: string): UserRole {
}
return 'editor';
}
if (method !== 'GET' && method !== 'HEAD') return 'editor';
if (subPath === '/definition') return 'editor';
if (method !== 'GET' && method !== 'HEAD') {return 'editor';}
if (subPath === '/definition') {return 'editor';}
return 'viewer';
}
@@ -18,7 +18,7 @@ export function authMiddleware(ctx: AppContext): (request: AuthenticatedRequest)
return;
}
if (!path.startsWith('/api/')) return;
if (!path.startsWith('/api/')) {return;}
const token = getCookie(request, SESSION_COOKIE_NAME);
if (!token) {
@@ -36,7 +36,7 @@ export function authMiddleware(ctx: AppContext): (request: AuthenticatedRequest)
}
export function requireRole(user: UserInfo | undefined, minRole: UserRole): void {
if (!user) throw new ServiceError('Authentication required', 401);
if (!user) {throw new ServiceError('Authentication required', 401);}
if ((ROLE_HIERARCHY[user.role] ?? 0) < (ROLE_HIERARCHY[minRole] ?? 0)) {
throw new ServiceError('Insufficient permissions', 403);
@@ -37,20 +37,20 @@ function splitSqlStatements(script: string): string[] {
if (ch === '-' && next === '-') {
// Stop at the newline, don't consume it — line numbers must stay right.
while (i < script.length && script[i] !== '\n') i++;
while (i < script.length && script[i] !== '\n') {i++;}
continue;
}
if (ch === '/' && next === '*') {
i += 2;
while (i < script.length && !(script[i] === '*' && script[i + 1] === '/')) i++;
while (i < script.length && !(script[i] === '*' && script[i + 1] === '/')) {i++;}
i += 2;
continue;
}
if (ch === ';') {
const trimmed = current.trim();
if (trimmed) statements.push(trimmed);
if (trimmed) {statements.push(trimmed);}
current = '';
i++;
continue;
@@ -61,7 +61,7 @@ function splitSqlStatements(script: string): string[] {
}
const trailing = current.trim();
if (trailing) statements.push(trailing);
if (trailing) {statements.push(trailing);}
return statements;
}
@@ -112,8 +112,8 @@ export class RespondentRepository {
])
.execute();
return rows.map((r) => ({
question_id: String(r.question_id),
question_text: String(r.question_text),
question_id: r.question_id,
question_text: r.question_text,
question_sort: Number(r.question_sort),
answer_ms: Number(r.answer_ms),
}));
@@ -200,7 +200,7 @@ export class AnswerRepository {
answer_ms: number | null;
}>,
): Promise<void> {
if (answers.length === 0) return;
if (answers.length === 0) {return;}
// Hand-written multi-row INSERT ... ON CONFLICT: one round-trip regardless
// of batch size, where a per-answer upsert would cost N round-trips.
@@ -210,10 +210,12 @@ export class AnswerRepository {
sql`(${a.respondent_id}, ${a.question_id}, ${a.answer}, ${a.other_text}, ${a.answered_at}, ${a.answer_ms})`,
),
);
await sql`INSERT INTO answers (respondent_id, question_id, answer, other_text, answered_at, answer_ms)
await sql`
INSERT INTO answers (respondent_id, question_id, answer, other_text, answered_at, answer_ms)
VALUES ${values}
ON CONFLICT (respondent_id, question_id)
DO UPDATE SET answer = excluded.answer, other_text = excluded.other_text, answered_at = excluded.answered_at, answer_ms = excluded.answer_ms`.execute(
DO UPDATE SET answer = excluded.answer, other_text = excluded.other_text, answered_at = excluded.answered_at, answer_ms = excluded.answer_ms
`.execute(
this.db,
);
}
@@ -322,7 +324,7 @@ export class AnswerRepository {
results: Array<{ respondent_id: string; question_id: string; question_text: string; answer: string }>;
total: number;
}> {
const escaped = query.replace(/[%_]/g, (ch) => `\\${ch}`);
const escaped = query.replaceAll(/[%_]/g, (ch) => `\\${ch}`);
const likeQuery = `%${escaped}%`;
let baseQb = this.db
@@ -52,7 +52,7 @@ export class SurveyRepository {
}
async update(id: string, fields: Partial<Omit<import('../db').SurveyRow, 'id' | 'created_at'>>): Promise<void> {
if (Object.keys(fields).length === 0) return;
if (Object.keys(fields).length === 0) {return;}
await this.db.updateTable('surveys').set(fields).where('id', '=', id).execute();
}
@@ -83,7 +83,7 @@ export class SectionRepository {
}
async update(id: string, fields: Partial<Omit<import('../db').SectionRow, 'id' | 'survey_id'>>): Promise<void> {
if (Object.keys(fields).length === 0) return;
if (Object.keys(fields).length === 0) {return;}
await this.db.updateTable('survey_sections').set(fields).where('id', '=', id).execute();
}
@@ -135,7 +135,7 @@ export class QuestionRepository {
}
async update(id: string, fields: Partial<Omit<import('../db').QuestionRow, 'id' | 'survey_id'>>): Promise<void> {
if (Object.keys(fields).length === 0) return;
if (Object.keys(fields).length === 0) {return;}
await this.db.updateTable('survey_questions').set(fields).where('id', '=', id).execute();
}
@@ -20,7 +20,7 @@ export class TagRepository {
}
async update(id: string, fields: Partial<Omit<import('../db').TagRow, 'id' | 'created_at'>>): Promise<void> {
if (Object.keys(fields).length === 0) return;
if (Object.keys(fields).length === 0) {return;}
await this.db.updateTable('tags').set(fields).where('id', '=', id).execute();
}
@@ -53,7 +53,7 @@ export function registerAuthRoutes(router: AppRouter) {
}
const body = (await request.json()) as { password?: string };
if (!body.password) throw new ServiceError('Password is required', 400);
if (!body.password) {throw new ServiceError('Password is required', 400);}
await authService.setupAdmin(body.password);
@@ -61,7 +61,7 @@ export function registerAuthRoutes(router: AppRouter) {
const sessionToken = await authService.createSessionToken(user);
const secure = ctx.config.cookieSecure ? 'Secure; ' : '';
return new Response(JSON.stringify({ success: true }), {
return Response.json({ success: true }, {
status: 201,
headers: {
'Content-Type': 'application/json',
@@ -78,12 +78,12 @@ export function registerAuthRoutes(router: AppRouter) {
}
const body = (await request.json()) as { password?: string };
if (!body.password) throw new ServiceError('Password is required', 400);
if (!body.password) {throw new ServiceError('Password is required', 400);}
const user = await authService.passwordLogin(body.password);
const sessionToken = await authService.createSessionToken(user);
const secure = ctx.config.cookieSecure ? 'Secure; ' : '';
return new Response(JSON.stringify({ success: true, user }), {
return Response.json({ success: true, user }, {
status: 200,
headers: {
'Content-Type': 'application/json',
@@ -135,11 +135,11 @@ export function registerAuthRoutes(router: AppRouter) {
const returnedState = url.searchParams.get('state');
const error = url.searchParams.get('error');
if (error) throw new ServiceError('Authentication failed', 400);
if (!code || !returnedState) throw new ServiceError('Missing code or state', 400);
if (error) {throw new ServiceError('Authentication failed', 400);}
if (!code || !returnedState) {throw new ServiceError('Missing code or state', 400);}
const stateCookie = getCookie(request, AUTH_STATE_COOKIE_NAME);
if (!stateCookie) throw new ServiceError('Missing auth state cookie', 400);
if (!stateCookie) {throw new ServiceError('Missing auth state cookie', 400);}
let stateData: { state: string; nonce: string; returnTo: string };
try {
@@ -147,7 +147,7 @@ export function registerAuthRoutes(router: AppRouter) {
} catch {
throw new ServiceError('Invalid auth state', 400);
}
if (stateData.state !== returnedState) throw new ServiceError('State mismatch', 400);
if (stateData.state !== returnedState) {throw new ServiceError('State mismatch', 400);}
const ctx = getContext(request);
const authService = new AuthService(ctx.config, ctx.db);
@@ -14,7 +14,7 @@ async function validatePasswordSession(
survey: SurveyRow,
ctx: AppContext,
): Promise<void> {
if (!survey.password_hash) return;
if (!survey.password_hash) {return;}
const token = getCookie(request, `spw_${slug}`);
if (!token || !(await verifySurveyPasswordToken(token, survey.id, survey.password_hash, ctx.config.passwordSecret))) {
@@ -3,8 +3,12 @@
* strips the same prefixes — one module so the two can't drift.
*/
/** /api/surveys/:surveyId(/...)? */
/**
/api/surveys/:surveyId(/...)?
*/
export const SURVEY_ID_PATTERN = /^\/api\/surveys\/([^/]+)(\/.*)?$/;
/** /api/s/:slug(/...)? */
/**
/api/s/:slug(/...)?
*/
export const PUBLIC_PATTERN = /^\/api\/s\/([^/]+)(\/.*)?$/;
+4 -4
View File
@@ -45,8 +45,8 @@ async function main() {
const migrationsDir = join(__dirname, '..', 'migrations');
try {
await runMigrations(db, migrationsDir);
} catch (e) {
console.error('Migration error:', e);
} catch (error) {
console.error('Migration error:', error);
}
const ctx: AppContext = { db, config };
@@ -113,7 +113,7 @@ async function main() {
});
}
main().catch((e) => {
console.error('Failed to start server:', e);
main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});
@@ -88,7 +88,7 @@ export class AuthService {
return cachedOidcConfig.data;
}
const res = await fetch(`${this.config.oidc.issuer}/.well-known/openid-configuration`);
if (!res.ok) throw new ServiceError('Failed to fetch OIDC configuration', 500);
if (!res.ok) {throw new ServiceError('Failed to fetch OIDC configuration', 500);}
const data = (await res.json()) as OidcConfig;
cachedOidcConfig = { data, fetchedAt: Date.now() };
return data;
@@ -100,7 +100,7 @@ export class AuthService {
}
const config = await this.getOidcConfig();
const res = await fetch(config.jwks_uri);
if (!res.ok) throw new ServiceError('Failed to fetch JWKS', 500);
if (!res.ok) {throw new ServiceError('Failed to fetch JWKS', 500);}
const data = (await res.json()) as { keys: Array<JsonWebKey & { kid?: string; alg?: string }> };
cachedJwks = { data, fetchedAt: Date.now() };
return data;
@@ -141,11 +141,11 @@ export class AuthService {
private async fetchUserInfo(accessToken: string): Promise<Record<string, unknown> | null> {
try {
const config = await this.getOidcConfig();
if (!config.userinfo_endpoint) return null;
if (!config.userinfo_endpoint) {return null;}
const res = await fetch(config.userinfo_endpoint, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
if (!res.ok) {return null;}
return (await res.json()) as Record<string, unknown>;
} catch {
return null;
@@ -154,9 +154,9 @@ export class AuthService {
async validateIdToken(idToken: string, nonce: string, accessToken?: string): Promise<UserInfo> {
const parts = idToken.split('.');
if (parts.length !== 3) throw new ServiceError('Invalid ID token format', 400);
if (parts.length !== 3) {throw new ServiceError('Invalid ID token format', 400);}
const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/'))) as {
const header = JSON.parse(atob(parts[0].replaceAll('-', '+').replaceAll('_', '/'))) as {
kid?: string;
alg?: string;
};
@@ -165,16 +165,16 @@ export class AuthService {
throw new ServiceError(`Unsupported token algorithm: ${header.alg}`, 400);
}
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as Record<string, unknown>;
const payload = JSON.parse(atob(parts[1].replaceAll('-', '+').replaceAll('_', '/'))) as Record<string, unknown>;
if (payload.iss !== this.config.oidc.issuer) throw new ServiceError('Invalid issuer', 400);
if (payload.iss !== this.config.oidc.issuer) {throw new ServiceError('Invalid issuer', 400);}
if (
payload.aud !== this.config.oidc.clientId &&
!(Array.isArray(payload.aud) && (payload.aud as string[]).includes(this.config.oidc.clientId))
) {
throw new ServiceError('Invalid audience', 400);
}
if (payload.nonce !== nonce) throw new ServiceError('Invalid nonce', 400);
if (payload.nonce !== nonce) {throw new ServiceError('Invalid nonce', 400);}
if (typeof payload.exp !== 'number' || payload.exp < Date.now() / 1000) {
throw new ServiceError('Token expired', 400);
}
@@ -188,7 +188,7 @@ export class AuthService {
jwks = await this.getJwks();
key = jwks.keys.find((k) => k.kid === header.kid);
}
if (!key) throw new ServiceError('No matching signing key found', 400);
if (!key) {throw new ServiceError('No matching signing key found', 400);}
const signingKey = await crypto.subtle.importKey(
'jwk',
@@ -201,11 +201,11 @@ export class AuthService {
const signatureValid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
signingKey,
Uint8Array.from(atob(parts[2].replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)),
Uint8Array.from(atob(parts[2].replaceAll('-', '+').replaceAll('_', '/')), (c) => c.charCodeAt(0)),
new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
);
if (!signatureValid) throw new ServiceError('Invalid token signature', 400);
if (!signatureValid) {throw new ServiceError('Invalid token signature', 400);}
let claims: Record<string, unknown> = payload;
if (accessToken) {
@@ -239,14 +239,14 @@ export class AuthService {
}
if (Array.isArray(value)) {
if (value.includes(this.config.oidc.roleMapAdmin)) return 'admin';
if (value.includes(this.config.oidc.roleMapEditor)) return 'editor';
if (value.includes(this.config.oidc.roleMapAdmin)) {return 'admin';}
if (value.includes(this.config.oidc.roleMapEditor)) {return 'editor';}
return 'viewer';
}
if (typeof value === 'string') {
if (value === this.config.oidc.roleMapAdmin) return 'admin';
if (value === this.config.oidc.roleMapEditor) return 'editor';
if (value === this.config.oidc.roleMapAdmin) {return 'admin';}
if (value === this.config.oidc.roleMapEditor) {return 'editor';}
}
return 'viewer';
@@ -277,8 +277,8 @@ export class AuthService {
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${header}.${payload}`));
const signature = btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '');
return `${header}.${payload}.${signature}`;
@@ -68,7 +68,7 @@ function validateRow(table: string, row: unknown, index: number): Record<string,
throw new Error(`Backup ${table}[${index}]: row must be an object`);
}
const record = row as Record<string, unknown>;
const allowed = new Set(TABLE_COLUMNS[table] ?? []);
const allowed = new Set(TABLE_COLUMNS[table]);
for (const key of Object.keys(record)) {
if (!allowed.has(key)) {
throw new Error(`Backup ${table}[${index}]: unknown column '${key}'`);
@@ -140,7 +140,7 @@ export class BackupService {
for (const table of REQUIRED_TABLES) {
if (!Array.isArray(backup.data[table])) {
throw new Error(`Backup is missing required table: ${table}`);
throw new TypeError(`Backup is missing required table: ${table}`);
}
}
@@ -31,7 +31,7 @@ function countsMessage(room: SurveyRoom): string {
}
function scheduleBroadcast(room: SurveyRoom) {
if (room.broadcastTimer) return;
if (room.broadcastTimer) {return;}
room.broadcastTimer = setTimeout(() => {
room.broadcastTimer = undefined;
const msg = countsMessage(room);
@@ -46,11 +46,13 @@ function scheduleBroadcast(room: SurveyRoom) {
}
function cleanupRoom(slug: string, room: SurveyRoom) {
if (room.viewers.size === 0 && room.respondents.size === 0) {
if (!(room.viewers.size === 0 && room.respondents.size === 0)) {
return;
}
clearTimeout(room.broadcastTimer);
rooms.delete(slug);
}
}
export function handlePresenceUpgrade(ws: WebSocket, slug: string, type: 'viewer' | 'respondent'): void {
const room = getRoom(slug);
@@ -44,13 +44,15 @@ export class RespondentService {
}
private async checkResponseLimit(survey: SurveyRow): Promise<void> {
if (survey.max_responses) {
if (!survey.max_responses) {
return;
}
const counts = await this.respondents.countBySurveyId(survey.id);
if (counts.completed >= survey.max_responses) {
throw new ServiceError('This survey has reached its maximum number of responses', 403);
}
}
}
async resume(
slug: string,
@@ -90,7 +92,7 @@ export class RespondentService {
for (const row of answerRows) {
answersMap[row.question_id] = {
value: row.answer,
...(row.other_text ? { otherText: row.other_text } : {}),
...(row.other_text && { otherText: row.other_text }),
};
}
@@ -138,7 +140,7 @@ export class RespondentService {
const questionMap = new Map(surveyQuestions.map((sq) => [sq.id, sq]));
for (const input of inputs) {
const sq = questionMap.get(input.questionId);
if (!sq) throw new ServiceError(`Invalid question ID: ${input.questionId}`, 400);
if (!sq) {throw new ServiceError(`Invalid question ID: ${input.questionId}`, 400);}
const spec: QuestionSpec = {
type: sq.type,
required: sq.required === 1,
@@ -148,7 +150,7 @@ export class RespondentService {
config: sq.config ? JSON.parse(sq.config) : undefined,
};
const error = validateAnswer(spec, input.value, input.otherText);
if (error) throw new ServiceError(error, 400);
if (error) {throw new ServiceError(error, 400);}
}
const now = new Date().toISOString();
@@ -277,7 +279,7 @@ export class RespondentService {
const headers = ['respondent_id', 'completed_at'];
for (const col of questionColumns) {
headers.push(this.csvSafe(col.text));
if (col.hasOther) headers.push(this.csvSafe(col.text) + '_other');
if (col.hasOther) {headers.push(this.csvSafe(col.text) + '_other');}
}
const stream = new ReadableStream<Uint8Array>({
@@ -288,7 +290,7 @@ export class RespondentService {
for (const col of questionColumns) {
const answer = entry.answers.get(col.id);
row.push(`"${this.csvSafe(answer?.value ?? '')}"`);
if (col.hasOther) row.push(`"${this.csvSafe(answer?.otherText ?? '')}"`);
if (col.hasOther) {row.push(`"${this.csvSafe(answer?.otherText ?? '')}"`);}
}
controller.enqueue(encoder.encode(row.join(',') + '\n'));
}
@@ -304,7 +306,7 @@ export class RespondentService {
granularity: 'minute' | 'hour' | 'day',
): Promise<Array<{ period: string; started: number; completed: number }>> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
if (!survey) {throw new ServiceError('Survey not found', 404);}
return this.respondents.getTimelineData(surveyId, granularity);
}
@@ -324,10 +326,10 @@ export class RespondentService {
}>
> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
if (!survey) {throw new ServiceError('Survey not found', 404);}
const rows = await this.respondents.getAnswerDurationsByQuestion(surveyId);
if (rows.length === 0) return [];
if (rows.length === 0) {return [];}
const byQ = new Map<string, { questionId: string; questionText: string; sort: number; durations: number[] }>();
for (const r of rows) {
@@ -372,7 +374,7 @@ export class RespondentService {
buckets: Array<{ label: string; minSeconds: number; maxSeconds: number | null; count: number }>;
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
if (!survey) {throw new ServiceError('Survey not found', 404);}
const durations = await this.respondents.getCompletionDurationsSeconds(surveyId);
const count = durations.length;
@@ -400,7 +402,7 @@ export class RespondentService {
p25: percentile(sorted, 25),
p75: percentile(sorted, 75),
min: sorted[0],
max: sorted[sorted.length - 1],
max: sorted.at(-1),
buckets,
};
}
@@ -415,7 +417,7 @@ export class RespondentService {
}>
> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
if (!survey) {throw new ServiceError('Survey not found', 404);}
const counts = await this.respondents.countBySurveyId(surveyId);
const dropoffData = await this.answers.getDropoffData(surveyId);
@@ -447,7 +449,7 @@ export class RespondentService {
total: number;
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
if (!survey) {throw new ServiceError('Survey not found', 404);}
const data = await this.respondents.listBySurveyId(surveyId, offset, limit);
return {
@@ -508,7 +510,7 @@ export class RespondentService {
limit: number;
}> {
const survey = await this.surveys.getById(surveyId);
if (!survey) throw new ServiceError('Survey not found', 404);
if (!survey) {throw new ServiceError('Survey not found', 404);}
if (!query || query.trim().length < 2) {
throw new ServiceError('Search query must be at least 2 characters', 400);
@@ -560,7 +562,7 @@ export class RespondentService {
// exfiltrates adjacent rows when an admin opens the export. The leading
// apostrophe defangs it (OWASP) and is stripped from the rendered cell.
const safe = /^[=+\-@\t\r]/.test(value) ? `'${value}` : value;
return safe.replace(/"/g, '""');
return safe.replaceAll('"', '""');
}
private async createNewRespondent(surveyId: string, ipAddress: string): Promise<ResumeResult> {
@@ -182,7 +182,7 @@ export class SurveyService {
async updateSurvey(id: string, input: UpdateSurveyInput, existing?: SurveyRow): Promise<SurveyRow> {
if (!existing) {
existing = (await this.surveys.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Survey not found', 404);
if (!existing) {throw new ServiceError('Survey not found', 404);}
}
const fields: Record<string, unknown> = { updated_at: new Date().toISOString() };
@@ -209,16 +209,16 @@ export class SurveyService {
}
}
if (input.description !== undefined) fields.description = input.description?.trim() ?? null;
if (input.welcome_title !== undefined) fields.welcome_title = input.welcome_title?.trim() ?? null;
if (input.welcome_description !== undefined) fields.welcome_description = input.welcome_description?.trim() ?? null;
if (input.thank_you_title !== undefined) fields.thank_you_title = input.thank_you_title?.trim() ?? null;
if (input.description !== undefined) {fields.description = input.description?.trim() ?? null;}
if (input.welcome_title !== undefined) {fields.welcome_title = input.welcome_title?.trim() ?? null;}
if (input.welcome_description !== undefined) {fields.welcome_description = input.welcome_description?.trim() ?? null;}
if (input.thank_you_title !== undefined) {fields.thank_you_title = input.thank_you_title?.trim() ?? null;}
if (input.thank_you_description !== undefined)
fields.thank_you_description = input.thank_you_description?.trim() ?? null;
if (input.closes_at !== undefined) fields.closes_at = input.closes_at ?? null;
if (input.max_responses !== undefined) fields.max_responses = input.max_responses ?? null;
if (input.randomize_questions !== undefined) fields.randomize_questions = input.randomize_questions ? 1 : 0;
if (input.randomize_options !== undefined) fields.randomize_options = input.randomize_options ? 1 : 0;
{fields.thank_you_description = input.thank_you_description?.trim() ?? null;}
if (input.closes_at !== undefined) {fields.closes_at = input.closes_at ?? null;}
if (input.max_responses !== undefined) {fields.max_responses = input.max_responses ?? null;}
if (input.randomize_questions !== undefined) {fields.randomize_questions = input.randomize_questions ? 1 : 0;}
if (input.randomize_options !== undefined) {fields.randomize_options = input.randomize_options ? 1 : 0;}
if (input.password !== undefined) {
if (input.password && input.password.length > 0) {
@@ -237,12 +237,12 @@ export class SurveyService {
async deleteSurvey(id: string): Promise<void> {
const existing = await this.surveys.getById(id);
if (!existing) throw new ServiceError('Survey not found', 404);
if (!existing) {throw new ServiceError('Survey not found', 404);}
await this.surveys.delete(id);
}
async publishSurvey(id: string, details?: SurveyWithDetails): Promise<SurveyRow> {
if (!details) details = await this.getSurvey(id);
if (!details) {details = await this.getSurvey(id);}
const { survey, sections, questions } = details;
if (!survey.slug) {
@@ -267,7 +267,7 @@ export class SurveyService {
async unpublishSurvey(id: string, existing?: SurveyRow): Promise<SurveyRow> {
if (!existing) {
existing = (await this.surveys.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Survey not found', 404);
if (!existing) {throw new ServiceError('Survey not found', 404);}
}
await this.surveys.update(id, {
@@ -298,7 +298,7 @@ export class SurveyService {
async updateSection(id: string, input: UpdateSectionInput, existing?: SectionRow): Promise<SectionRow> {
if (!existing) {
existing = (await this.sections.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Section not found', 404);
if (!existing) {throw new ServiceError('Section not found', 404);}
}
const fields: Record<string, unknown> = {};
@@ -308,9 +308,9 @@ export class SurveyService {
}
fields.title = input.title.trim();
}
if (input.description !== undefined) fields.description = input.description?.trim() ?? null;
if (input.description !== undefined) {fields.description = input.description?.trim() ?? null;}
if (Object.keys(fields).length === 0) return existing;
if (Object.keys(fields).length === 0) {return existing;}
await this.sections.update(id, fields);
return { ...existing, ...fields } as SectionRow;
@@ -328,7 +328,7 @@ export class SurveyService {
async createQuestion(sectionId: string, input: CreateQuestionInput, section?: SectionRow): Promise<QuestionRow> {
if (!section) {
section = (await this.sections.getById(sectionId)) ?? undefined;
if (!section) throw new ServiceError('Section not found', 404);
if (!section) {throw new ServiceError('Section not found', 404);}
}
if (!input.text?.trim()) {
@@ -352,7 +352,7 @@ export class SurveyService {
description: input.description?.trim() ?? null,
type: input.type,
options: input.options ? JSON.stringify(input.options) : null,
required: input.required !== false ? 1 : 0,
required: input.required === false ? 0 : 1,
has_other: input.has_other ? 1 : 0,
other_prompt: input.other_prompt?.trim() ?? null,
max_length: input.max_length ?? null,
@@ -369,7 +369,7 @@ export class SurveyService {
async updateQuestion(id: string, input: UpdateQuestionInput, existing?: QuestionRow): Promise<QuestionRow> {
if (!existing) {
existing = (await this.questions.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Question not found', 404);
if (!existing) {throw new ServiceError('Question not found', 404);}
}
const fields: Record<string, unknown> = {};
@@ -396,13 +396,13 @@ export class SurveyService {
fields.type = input.type;
}
if (input.options !== undefined) fields.options = input.options ? JSON.stringify(input.options) : null;
if (input.description !== undefined) fields.description = input.description?.trim() ?? null;
if (input.required !== undefined) fields.required = input.required ? 1 : 0;
if (input.has_other !== undefined) fields.has_other = input.has_other ? 1 : 0;
if (input.other_prompt !== undefined) fields.other_prompt = input.other_prompt?.trim() ?? null;
if (input.max_length !== undefined) fields.max_length = input.max_length;
if (input.placeholder !== undefined) fields.placeholder = input.placeholder?.trim() ?? null;
if (input.options !== undefined) {fields.options = input.options ? JSON.stringify(input.options) : null;}
if (input.description !== undefined) {fields.description = input.description?.trim() ?? null;}
if (input.required !== undefined) {fields.required = input.required ? 1 : 0;}
if (input.has_other !== undefined) {fields.has_other = input.has_other ? 1 : 0;}
if (input.other_prompt !== undefined) {fields.other_prompt = input.other_prompt?.trim() ?? null;}
if (input.max_length !== undefined) {fields.max_length = input.max_length;}
if (input.placeholder !== undefined) {fields.placeholder = input.placeholder?.trim() ?? null;}
if (input.conditional !== undefined) {
fields.conditional = input.conditional ? JSON.stringify(input.conditional) : null;
}
@@ -410,7 +410,7 @@ export class SurveyService {
fields.config = input.config ? JSON.stringify(input.config) : null;
}
if (Object.keys(fields).length === 0) return existing;
if (Object.keys(fields).length === 0) {return existing;}
await this.questions.update(id, fields);
return { ...existing, ...fields } as QuestionRow;
@@ -495,7 +495,7 @@ export class SurveyService {
async archiveSurvey(id: string, existing?: SurveyRow): Promise<SurveyRow> {
if (!existing) {
existing = (await this.surveys.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Survey not found', 404);
if (!existing) {throw new ServiceError('Survey not found', 404);}
}
const now = new Date().toISOString();
await this.surveys.update(id, { archived_at: now, updated_at: now });
@@ -505,7 +505,7 @@ export class SurveyService {
async unarchiveSurvey(id: string, existing?: SurveyRow): Promise<SurveyRow> {
if (!existing) {
existing = (await this.surveys.getById(id)) ?? undefined;
if (!existing) throw new ServiceError('Survey not found', 404);
if (!existing) {throw new ServiceError('Survey not found', 404);}
}
const now = new Date().toISOString();
await this.surveys.update(id, { archived_at: null, updated_at: now });
@@ -513,7 +513,7 @@ export class SurveyService {
}
async exportDefinition(id: string, details?: SurveyWithDetails): Promise<SurveyDefinition> {
if (!details) details = await this.getSurvey(id);
if (!details) {details = await this.getSurvey(id);}
const { survey, sections, questions } = details;
return {
version: 1,
@@ -9,7 +9,7 @@ export class TagService {
}
async createTag(input: { name: string; color?: string }): Promise<TagRow> {
if (!input.name?.trim()) throw new ServiceError('Tag name is required', 400);
if (!input.name?.trim()) {throw new ServiceError('Tag name is required', 400);}
const tag: TagRow = {
id: crypto.randomUUID(),
name: input.name.trim(),
@@ -22,20 +22,20 @@ export class TagService {
async updateTag(id: string, input: { name?: string; color?: string | null }): Promise<TagRow> {
const existing = await this.tags.getById(id);
if (!existing) throw new ServiceError('Tag not found', 404);
if (!existing) {throw new ServiceError('Tag not found', 404);}
const fields: Partial<Omit<TagRow, 'id' | 'created_at'>> = {};
if (input.name !== undefined) {
if (!input.name.trim()) throw new ServiceError('Tag name cannot be empty', 400);
if (!input.name.trim()) {throw new ServiceError('Tag name cannot be empty', 400);}
fields.name = input.name.trim();
}
if (input.color !== undefined) fields.color = input.color;
if (input.color !== undefined) {fields.color = input.color;}
await this.tags.update(id, fields);
return { ...existing, ...fields };
}
async deleteTag(id: string): Promise<void> {
const existing = await this.tags.getById(id);
if (!existing) throw new ServiceError('Tag not found', 404);
if (!existing) {throw new ServiceError('Tag not found', 404);}
await this.tags.delete(id);
}
@@ -1,7 +1,7 @@
import { PBKDF2_ITERATIONS } from '../constants';
export function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
if (a.length !== b.length) {return false;}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
@@ -10,7 +10,7 @@ export function constantTimeEqual(a: string, b: string): boolean {
}
export async function hashPassword(password: string): Promise<string> {
if (!password) throw new Error('Password cannot be empty');
if (!password) {throw new Error('Password cannot be empty');}
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
const hash = await crypto.subtle.deriveBits(
@@ -24,8 +24,8 @@ export async function hashPassword(password: string): Promise<string> {
}
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [saltB64, hashB64] = stored.split(':');
if (!saltB64 || !hashB64) return false;
const [saltB64, hashB64] = stored.split(':', 2);
if (!saltB64 || !hashB64) {return false;}
const salt = Uint8Array.from(atob(saltB64), (c: string) => c.charCodeAt(0));
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
const hash = await crypto.subtle.deriveBits(
@@ -38,7 +38,7 @@ export async function verifyPassword(password: string, stored: string): Promise<
}
export async function signToken(data: string, secret: string): Promise<string> {
if (!secret) throw new Error('Signing secret is not configured');
if (!secret) {throw new Error('Signing secret is not configured');}
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
@@ -56,13 +56,13 @@ export async function verifyToken(data: string, token: string, secret: string):
}
export function passwordFingerprint(passwordHash: string | null | undefined): string {
if (!passwordHash) return 'none';
let h1 = 0x811c9dc5;
let h2 = 0x01000193;
if (!passwordHash) {return 'none';}
let h1 = 0x81_1C_9D_C5;
let h2 = 0x01_00_01_93;
for (let i = 0; i < passwordHash.length; i++) {
const c = passwordHash.charCodeAt(i);
h1 = Math.imul(h1 ^ c, 0x01000193) >>> 0;
h2 = Math.imul(h2 ^ c, 0x85ebca6b) >>> 0;
h1 = Math.imul(h1 ^ c, 0x01_00_01_93) >>> 0;
h2 = Math.imul(h2 ^ c, 0x85_EB_CA_6B) >>> 0;
}
return h1.toString(36) + h2.toString(36);
}
@@ -8,15 +8,15 @@
import type { UserInfo } from '../services/auth.service';
function b64urlToBytes(s: string): Uint8Array {
return Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
return Uint8Array.from(atob(s.replaceAll('-', '+').replaceAll('_', '/')), (c) => c.charCodeAt(0));
}
export async function verifySessionToken(token: string, sessionSecret: string): Promise<UserInfo | null> {
try {
if (!sessionSecret) return null;
if (!sessionSecret) {return null;}
const parts = token.split('.');
if (parts.length !== 3) return null;
if (parts.length !== 3) {return null;}
const key = await crypto.subtle.importKey(
'raw',
@@ -32,12 +32,12 @@ export async function verifySessionToken(token: string, sessionSecret: string):
b64urlToBytes(parts[2]),
new TextEncoder().encode(`${parts[0]}.${parts[1]}`),
);
if (!valid) return null;
if (!valid) {return null;}
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))) as Record<string, unknown>;
const payload = JSON.parse(atob(parts[1].replaceAll('-', '+').replaceAll('_', '/'))) as Record<string, unknown>;
if (typeof payload.exp !== 'number' || payload.exp < Date.now() / 1000) return null;
if (typeof payload.sub !== 'string') return null;
if (typeof payload.exp !== 'number' || payload.exp < Date.now() / 1000) {return null;}
if (typeof payload.sub !== 'string') {return null;}
return {
sub: payload.sub,
@@ -28,9 +28,9 @@ interface ParsedToken {
function parse(token: string): ParsedToken | null {
const parts = token.split('.');
if (parts.length !== 3) return null;
if (parts.length !== 3) {return null;}
const exp = Number(parts[0]);
if (!Number.isInteger(exp) || !parts[1] || !parts[2]) return null;
if (!Number.isInteger(exp) || !parts[1] || !parts[2]) {return null;}
return { exp, fp: parts[1], sig: parts[2] };
}
@@ -40,8 +40,8 @@ export async function verifySurveyPasswordTokenSignature(
secret: string,
): Promise<{ valid: boolean; fingerprint?: string }> {
const parsed = parse(token);
if (!parsed) return { valid: false };
if (parsed.exp <= nowSeconds()) return { valid: false };
if (!parsed) {return { valid: false };}
if (parsed.exp <= nowSeconds()) {return { valid: false };}
const ok = await verifyToken(signedPayload(surveyId, parsed.exp, parsed.fp), parsed.sig, secret);
return ok ? { valid: true, fingerprint: parsed.fp } : { valid: false };
}
@@ -29,7 +29,7 @@ export async function authedRequest(path: string, options?: RequestInit): Promis
}
export async function getAdminCookie(): Promise<string> {
if (adminCookie) return adminCookie;
if (adminCookie) {return adminCookie;}
const meRes = await request('/api/auth/me');
const me = (await meRes.json()) as { needsSetup?: boolean };
@@ -41,7 +41,7 @@ export async function getAdminCookie(): Promise<string> {
});
const setCookie = setupRes.headers.get('set-cookie');
if (setCookie) {
adminCookie = setCookie.split(';')[0];
adminCookie = setCookie.split(';', 1)[0];
return adminCookie;
}
}
@@ -52,10 +52,10 @@ export async function getAdminCookie(): Promise<string> {
});
const setCookie = loginRes.headers.get('set-cookie');
if (setCookie) {
adminCookie = setCookie.split(';')[0];
adminCookie = setCookie.split(';', 1)[0];
}
if (!adminCookie) throw new Error('Failed to authenticate for integration tests');
if (!adminCookie) {throw new Error('Failed to authenticate for integration tests');}
return adminCookie;
}
@@ -77,7 +77,7 @@ export async function createPublishedSurvey(options?: {
method: 'PUT',
body: JSON.stringify({
slug,
...(options?.password ? { password: options.password } : {}),
...(options?.password && { password: options.password }),
}),
});
@@ -148,8 +148,8 @@ export async function createCookieForRole(role: 'admin' | 'editor' | 'viewer'):
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${header}.${payload}`));
const signature = btoa(String.fromCharCode(...new Uint8Array(sig)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replaceAll('+', '-')
.replaceAll('/', '_')
.replace(/=+$/, '');
return `${SESSION_COOKIE_NAME}=${header}.${payload}.${signature}`;
@@ -30,7 +30,7 @@ describe('Respondent Flow', () => {
it('submits batch answers and completes', async () => {
const resumeRes = await request(`/api/s/${slug}/resume`);
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0] ?? '';
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
const batchRes = await request(`/api/s/${slug}/answers/batch`, {
method: 'POST',
@@ -58,7 +58,7 @@ describe('Respondent Flow', () => {
it('rejects answers for questions not in survey', async () => {
const resumeRes = await request(`/api/s/${slug}/resume`);
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0] ?? '';
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
const res = await request(`/api/s/${slug}/answers/batch`, {
method: 'POST',
@@ -105,7 +105,7 @@ describe('Survey Password Protection', () => {
body: JSON.stringify({ password: 'survey-pass-1234' }),
});
expect(authRes.status).toBe(204);
const pwCookie = authRes.headers.get('set-cookie')?.split(';')[0] ?? '';
const pwCookie = authRes.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
const surveyRes = await request(`/api/s/${slug}`, { cookie: pwCookie });
const data = (await surveyRes.json()) as { questions: unknown[] };
@@ -14,7 +14,7 @@ describe('Results API', () => {
questionIds = survey.questionIds;
const resumeRes = await request(`/api/s/${slug}/resume`);
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0] ?? '';
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0] ?? '';
await request(`/api/s/${slug}/answers/batch`, {
method: 'POST',
@@ -17,7 +17,7 @@ describe('Survey creation validation', () => {
it('rejects whitespace-only title', async () => {
const res = await authedRequest('/api/surveys', {
method: 'POST',
body: JSON.stringify({ title: ' ' }),
body: JSON.stringify({ title: ' '.repeat(3) }),
});
expect(res.status).toBe(400);
});
@@ -122,7 +122,7 @@ describe('Question creation validation', () => {
it('rejects whitespace-only question text', async () => {
const res = await authedRequest(`/api/surveys/${surveyId}/sections/${sectionId}/questions`, {
method: 'POST',
body: JSON.stringify({ text: ' ', type: 'text' }),
body: JSON.stringify({ text: ' '.repeat(3), type: 'text' }),
});
expect(res.status).toBe(400);
});
+2 -1
View File
@@ -13,7 +13,8 @@
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"types": ["./worker-configuration.d.ts", "@cloudflare/workers-types"]
"types": ["./worker-configuration.d.ts", "@cloudflare/workers-types"],
"rootDir": "../../../"
},
"include": ["worker-configuration.d.ts", "src/**/*.ts", "../shared/**/*.ts"]
}
@@ -3,8 +3,8 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.integration.ts'],
testTimeout: 15000,
hookTimeout: 15000,
testTimeout: 15_000,
hookTimeout: 15_000,
sequence: {
concurrent: false,
},
+2 -2
View File
@@ -147,8 +147,8 @@ test.describe.serial('OIDC auth', () => {
if (disco.ok) {
oidcAvailable = true;
}
} catch (err) {
console.log('Failed to start OIDC server:', err);
} catch (error) {
console.log('Failed to start OIDC server:', error);
}
});
+7 -7
View File
@@ -4,7 +4,7 @@ const TEST_PASSWORD = process.env.TEST_PASSWORD || 'e2e-test-password-12345';
let sessionCookie: string | null = null;
export async function ensureAuth(): Promise<string> {
if (sessionCookie) return sessionCookie;
if (sessionCookie) {return sessionCookie;}
const meRes = await fetch(`${API}/api/auth/me`);
const me = (await meRes.json()) as { authenticated: boolean; needsSetup?: boolean };
@@ -17,7 +17,7 @@ export async function ensureAuth(): Promise<string> {
});
const cookie = setupRes.headers.get('set-cookie');
if (cookie) {
sessionCookie = cookie.split(';')[0];
sessionCookie = cookie.split(';', 1)[0];
return sessionCookie;
}
}
@@ -29,7 +29,7 @@ export async function ensureAuth(): Promise<string> {
});
const cookie = loginRes.headers.get('set-cookie');
if (cookie) {
sessionCookie = cookie.split(';')[0];
sessionCookie = cookie.split(';', 1)[0];
}
if (!sessionCookie) {
@@ -52,7 +52,7 @@ export async function apiPost(path: string, body?: unknown, retries = 3) {
const res = await fetch(`${API}${path}`, {
method: 'POST',
headers: getAuthHeaders(),
...(body ? { body: JSON.stringify(body) } : {}),
...(body && { body: JSON.stringify(body) }),
});
if (res.status === 503 && attempt < retries - 1) {
// Retry on 503 (transient DO initialization failure)
@@ -63,7 +63,7 @@ export async function apiPost(path: string, body?: unknown, retries = 3) {
const text = await res.text();
throw new Error(`POST ${path} failed (${res.status}): ${text}`);
}
if (res.status === 204) return undefined;
if (res.status === 204) {return undefined;}
return res.json();
}
}
@@ -73,13 +73,13 @@ export async function apiPut(path: string, body?: unknown) {
const res = await fetch(`${API}${path}`, {
method: 'PUT',
headers: getAuthHeaders(),
...(body ? { body: JSON.stringify(body) } : {}),
...(body && { body: JSON.stringify(body) }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`PUT ${path} failed (${res.status}): ${text}`);
}
if (res.status === 204) return undefined;
if (res.status === 204) {return undefined;}
return res.json();
}
+6 -6
View File
@@ -44,7 +44,7 @@ const TEST_USERS: Record<string, TestUser> = {
const findAccount: FindAccount = async (_ctx, id) => {
const entry = Object.entries(TEST_USERS).find(([, u]) => u.claims.sub === id);
if (!entry) return undefined;
if (!entry) {return undefined;}
const [, user] = entry;
return {
accountId: user.claims.sub,
@@ -56,7 +56,7 @@ const findAccount: FindAccount = async (_ctx, id) => {
function authenticateUser(login: string, password: string) {
const user = TEST_USERS[login];
if (!user || user.password !== password) return undefined;
if (!user || user.password !== password) {return undefined;}
return { accountId: user.claims.sub };
}
@@ -146,8 +146,8 @@ export async function startOidcServer(): Promise<{
}
}
if (ctx.method === 'POST' && url.pathname.match(/^\/interaction\/[^/]+\/login$/)) {
const uid = url.pathname.split('/')[2];
if (ctx.method === 'POST' && /^\/interaction\/[^/]+\/login$/.test(url.pathname)) {
const uid = url.pathname.split('/', 3)[2];
const body = await readBody(ctx.req);
const params = new URLSearchParams(body);
const login = params.get('login') ?? '';
@@ -177,7 +177,7 @@ export async function startOidcServer(): Promise<{
return;
}
if (ctx.method === 'POST' && url.pathname.match(/^\/interaction\/[^/]+\/confirm$/)) {
if (ctx.method === 'POST' && /^\/interaction\/[^/]+\/confirm$/.test(url.pathname)) {
try {
const interactionDetails = await provider.interactionDetails(ctx.req, ctx.res);
const {
@@ -262,7 +262,7 @@ function readBody(req: import('node:http').IncomingMessage): Promise<string> {
}
// Allow running standalone: `npx tsx e2e/oidc-server.ts`
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/'))) {
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replaceAll('\\', '/'))) {
startOidcServer().then(() => console.log('OIDC server started, press Ctrl+C to stop'));
}
@@ -69,8 +69,8 @@ async function createSimpleSurvey(opts?: {
await apiPut(`/api/surveys/${survey.id}`, {
slug,
...(opts?.closesAt ? { closes_at: opts.closesAt } : {}),
...(opts?.maxResponses !== undefined ? { max_responses: opts.maxResponses } : {}),
...(opts?.closesAt && { closes_at: opts.closesAt }),
...((opts?.maxResponses !== undefined) && { max_responses: opts.maxResponses }),
});
if (publish) {
@@ -103,7 +103,7 @@ test.describe('Create page with survey templates', () => {
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10000 });
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10_000 });
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
const url = page.url();
@@ -128,7 +128,7 @@ test.describe('Create page saves sections (blank survey)', () => {
await page.getByRole('button', { name: 'Save' }).click();
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10000 });
await page.waitForURL(/\/edit\/[a-f0-9-]+/, { timeout: 10_000 });
await expect(page.getByText('Edit Survey')).toBeVisible({ timeout: 5000 });
await page.getByText('Add section').click();
@@ -412,7 +412,7 @@ test.describe('Bulk option paste', () => {
await waitForTransition(page);
// OptionListEditor renders one input per option.
const optionInputs = page.locator('.space-y-1\\.5 input[placeholder="Option label..."]');
const optionInputs = page.locator(String.raw`.space-y-1\.5 input[placeholder="Option label..."]`);
await expect(optionInputs).toHaveCount(4, { timeout: 3000 });
await expect(optionInputs.nth(0)).toHaveValue('Red');
await expect(optionInputs.nth(1)).toHaveValue('Green');
+25 -25
View File
@@ -170,14 +170,14 @@ test.describe('Respondent flow', () => {
await page.getByText('Blue', { exact: true }).click();
await page.click('button:has-text("Submit")');
await expect(page.locator('h1')).toContainText('Thank you', { timeout: 10000 });
await expect(page.locator('h1')).toContainText('Thank you', { timeout: 10_000 });
expect(errors).toEqual([]);
});
test('results show the respondent data', async () => {
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0];
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0];
expect(ridCookie).toBeTruthy();
await fetch(`${API}/api/s/${slug}/answers/batch`, {
@@ -203,9 +203,9 @@ test.describe('Respondent flow', () => {
test('a client-supplied X-Respondent-Id header is ignored (creates a new respondent)', async () => {
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0];
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0];
expect(ridCookie).toBeTruthy();
const legitId = ridCookie!.split('=')[1];
const legitId = ridCookie!.split('=', 2)[1];
// The API worker must strip client-supplied X-Respondent-Id before forwarding
// to the DO, so a forged header must yield a brand-new respondent id.
@@ -213,9 +213,9 @@ test.describe('Respondent flow', () => {
headers: { 'X-Respondent-Id': legitId },
});
expect(forgedRes.ok).toBe(true);
const forgedCookie = forgedRes.headers.get('set-cookie')?.split(';')[0];
const forgedCookie = forgedRes.headers.get('set-cookie')?.split(';', 1)[0];
expect(forgedCookie).toBeTruthy();
const forgedId = forgedCookie!.split('=')[1];
const forgedId = forgedCookie!.split('=', 2)[1];
expect(forgedId).not.toBe(legitId);
});
});
@@ -231,13 +231,13 @@ test.describe('WebSocket protocol', () => {
test('WebSocket connects and receives counts', async ({ page }) => {
await page.addInitScript(() => {
const origWS = window.WebSocket;
window.WebSocket = class extends origWS {
const origWS = WebSocket;
globalThis.WebSocket = class extends origWS {
constructor(url: string | URL, protocols?: string | string[]) {
super(url, protocols);
this.addEventListener('message', (e) => {
(window as any).__wsMessages = (window as any).__wsMessages || [];
(window as any).__wsMessages.push(e.data);
(globalThis as any).__wsMessages = (globalThis as any).__wsMessages || [];
(globalThis as any).__wsMessages.push(e.data);
});
}
} as any;
@@ -247,7 +247,7 @@ test.describe('WebSocket protocol', () => {
// Wait for a "counts" push to arrive instead of blindly sleeping 3s.
await page.waitForFunction(
() =>
((window as any).__wsMessages || [])
((globalThis as any).__wsMessages || [])
.map((m: string) => {
try {
return JSON.parse(m);
@@ -259,7 +259,7 @@ test.describe('WebSocket protocol', () => {
{ timeout: 10_000 },
);
const messages = await page.evaluate(() => (window as any).__wsMessages || []);
const messages = await page.evaluate(() => (globalThis as any).__wsMessages || []);
const parsed = messages.map((m: string) => JSON.parse(m));
const countsMsgs = parsed.filter((m: any) => m.type === 'push' && m.event === 'counts');
expect(countsMsgs.length).toBeGreaterThan(0);
@@ -293,7 +293,7 @@ test.describe('Editor save flow', () => {
await page.waitForTimeout(500);
await page.click('button:has-text("Save")');
await page.waitForURL('**/edit/**', { timeout: 10000 });
await page.waitForURL('**/edit/**', { timeout: 10_000 });
await expect(page.locator('h1')).toContainText('Edit Survey');
@@ -407,7 +407,7 @@ test.describe('Analytics endpoints', () => {
surveyId = result.surveyId;
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0];
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0];
if (ridCookie) {
await fetch(`${API}/api/s/${slug}/answers/batch`, {
method: 'POST',
@@ -520,7 +520,7 @@ test.describe('Respondent reset', () => {
test('reset clears respondent cookie', async () => {
const resumeRes = await fetch(`${API}/api/s/${slug}/resume`);
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';')[0];
const ridCookie = resumeRes.headers.get('set-cookie')?.split(';', 1)[0];
expect(ridCookie).toBeTruthy();
const resetRes = await fetch(`${API}/api/s/${slug}/reset`, {
@@ -548,9 +548,9 @@ test.describe('WebSocket typed operations', () => {
const ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/s/${slug}/ws?type=editor`,
);
ws.onopen = () => {
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'request', requestId: 'r1', op: 'get-survey', data: {} }));
};
});
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'response' && msg.requestId === 'r1') {
@@ -559,7 +559,7 @@ test.describe('WebSocket typed operations', () => {
}
};
ws.onerror = () => reject(new Error('WS error'));
setTimeout(() => reject(new Error('timeout')), 10000);
setTimeout(() => reject(new Error('timeout')), 10_000);
});
},
{ slug },
@@ -583,7 +583,7 @@ test.describe('WebSocket typed operations', () => {
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/s/${slug}/ws?type=editor`,
);
const responses: Record<string, unknown> = {};
ws.onopen = () => {
ws.addEventListener('open', () => {
ws.send(
JSON.stringify({
type: 'request',
@@ -592,7 +592,7 @@ test.describe('WebSocket typed operations', () => {
data: { title: 'WS Created Section' },
}),
);
};
});
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'response') {
@@ -614,7 +614,7 @@ test.describe('WebSocket typed operations', () => {
}
};
ws.onerror = () => reject(new Error('WS error'));
setTimeout(() => reject(new Error('timeout')), 10000);
setTimeout(() => reject(new Error('timeout')), 10_000);
});
},
{ slug },
@@ -632,9 +632,9 @@ test.describe('WebSocket typed operations', () => {
const ws = new WebSocket(
`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/api/s/${slug}/ws?type=viewer`,
);
ws.onopen = () => {
ws.addEventListener('open', () => {
ws.send(JSON.stringify({ type: 'request', requestId: 'r1', op: 'get-results', data: {} }));
};
});
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'response' && msg.requestId === 'r1') {
@@ -643,7 +643,7 @@ test.describe('WebSocket typed operations', () => {
}
};
ws.onerror = () => reject(new Error('WS error'));
setTimeout(() => reject(new Error('timeout')), 10000);
setTimeout(() => reject(new Error('timeout')), 10_000);
});
},
{ slug },
@@ -665,7 +665,7 @@ test.describe('WebSocket typed operations', () => {
);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'push') events.push(msg);
if (msg.type === 'push') {events.push(msg);}
};
ws.onerror = () => reject(new Error('WS error'));
setTimeout(() => {
@@ -250,11 +250,11 @@ test.describe('Full survey with all 10 question types', () => {
test('results page renders with stats and question results', async ({ page }) => {
await page.goto(`/results/${setup.surveyId}`);
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Completed')).toBeVisible();
await expect(page.getByText('Completion')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Pick a color' })).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('heading', { name: 'Pick a color' })).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('button', { name: 'CSV' })).toBeVisible();
await expect(page.getByRole('button', { name: 'JSON' })).toBeVisible();
@@ -287,7 +287,7 @@ test.describe('Survey duplication', () => {
headers: getAuthHeaders(),
});
data = await res.json();
if (data.sections?.length > 0) break;
if (data.sections?.length > 0) {break;}
await new Promise((r) => setTimeout(r, 1000));
}
expect(data.sections.length).toBe(1);
@@ -39,8 +39,8 @@ async function createSurveyWithQuestion(opts: {
await apiPut(`/api/surveys/${survey.id}`, {
slug: opts.slug,
...(opts.maxResponses !== undefined ? { max_responses: opts.maxResponses } : {}),
...(opts.closesAt ? { closes_at: opts.closesAt } : {}),
...((opts.maxResponses !== undefined) && { max_responses: opts.maxResponses }),
...(opts.closesAt && { closes_at: opts.closesAt }),
});
await apiPut(`/api/surveys/${survey.id}/publish`);
@@ -92,7 +92,7 @@ test.describe('Survey with max_responses limit', () => {
await expect(
page.locator('text=maximum').or(page.locator('text=closed')).or(page.locator('text=Failed to load survey')),
).toBeVisible({
timeout: 10000,
timeout: 10_000,
});
});
});
@@ -121,7 +121,7 @@ test.describe.serial('Survey with past closes_at date', () => {
await page.goto(`/s/${setup.slug}`);
await expect(page.locator('text=closed').or(page.locator('text=Failed to load survey'))).toBeVisible({
timeout: 10000,
timeout: 10_000,
});
});
});
@@ -109,12 +109,12 @@ test.describe.serial('Results page features', () => {
test('overview tab shows stats cards and question results', async ({ page }) => {
await page.goto(`/results/${setup.surveyId}`);
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
await expect(page.getByText('Completed')).toBeVisible();
await expect(page.getByText('Completion')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Favorite color' })).toBeVisible({
timeout: 10000,
timeout: 10_000,
});
await expect(page.getByRole('heading', { name: 'Your name' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Rate our service' })).toBeVisible();
@@ -125,7 +125,7 @@ test.describe.serial('Results page features', () => {
test('responses tab shows respondent list', async ({ page }) => {
await page.goto(`/results/${setup.surveyId}`);
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Responses' }).click();
@@ -140,7 +140,7 @@ test.describe.serial('Results page features', () => {
test('search tab finds text answers', async ({ page }) => {
await page.goto(`/results/${setup.surveyId}`);
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Search' }).click();
@@ -158,7 +158,7 @@ test.describe.serial('Results page features', () => {
test('clicking a search result expands the full respondent detail with match highlight', async ({ page }) => {
await page.goto(`/results/${setup.surveyId}`);
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Search' }).click();
await page.getByPlaceholder('Search answers...').fill('Alice');
await expect(page.getByText('Alice Johnson')).toBeVisible({ timeout: 5000 });
@@ -180,7 +180,7 @@ test.describe.serial('Results page features', () => {
test('completion-time and question-timing charts appear on overview', async ({ page }) => {
await page.goto(`/results/${setup.surveyId}`);
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
await expect(page.getByRole('heading', { name: 'Time to Complete' })).toBeVisible({ timeout: 5000 });
await expect(page.getByRole('heading', { name: 'Time per Question' })).toBeVisible();
@@ -188,10 +188,10 @@ test.describe.serial('Results page features', () => {
test('CSV export triggers download', async ({ page }) => {
await page.goto(`/results/${setup.surveyId}`);
await expect(page.getByText('Total')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('Total')).toBeVisible({ timeout: 10_000 });
// Set up download listener before clicking
const downloadPromise = page.waitForEvent('download', { timeout: 10000 });
const downloadPromise = page.waitForEvent('download', { timeout: 10_000 });
await page.getByRole('button', { name: 'CSV' }).click();
-46
View File
@@ -1,46 +0,0 @@
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import ts from 'typescript-eslint';
export default [
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
...svelte.configs.prettier,
{
languageOptions: {
globals: {
...globals.browser,
...globals.node,
},
},
},
{
files: ['**/*.svelte', '**/*.svelte.ts'],
languageOptions: {
parserOptions: {
parser: ts.parser,
},
},
rules: {
'svelte/no-navigation-without-resolve': 'off',
},
},
{
// Frontend-centric defaults don't apply to the backend/e2e trees; keep
// the core TS checks and drop the rest rather than add a second config.
files: ['backend/**/*.ts', 'e2e/**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
},
},
{
ignores: ['build/', '.svelte-kit/', 'dist/', 'backend/dist/', 'backend/.wrangler/', 'backend/.svelte-kit/'],
},
];
+1 -1
View File
@@ -4,7 +4,7 @@ export default defineConfig({
testDir: './e2e',
testMatch: '*.e2e.ts',
timeout: 30_000,
expect: { timeout: 5_000 },
expect: { timeout: 5000 },
fullyParallel: false,
// Retries in CI only, so local runs surface real regressions instead of
// masking them.
@@ -4,7 +4,7 @@
* otherwise a human-readable error string.
*/
const LIKERT_VALUES = ['Strongly Disagree', 'Disagree', 'Neutral', 'Agree', 'Strongly Agree'];
const LIKERT_VALUES = new Set(['Strongly Disagree', 'Disagree', 'Neutral', 'Agree', 'Strongly Agree']);
/**
* Hard cap independent of a question's configured maxLength: without it a
@@ -51,7 +51,7 @@ function wordCount(text: string): number {
export function validateAnswer(question: QuestionSpec, value: string, otherText?: string): string | null {
// The declared type doesn't stop a hand-crafted request sending a JSON
// number, which would throw on .trim() and surface as a server 500.
if (typeof value !== 'string') value = value == null ? '' : String(value);
if (typeof value !== 'string') {value = value == null ? '' : value;}
// Absolute length ceiling, enforced before any per-type logic so it applies
// even to types/configs that would otherwise accept unbounded input.
@@ -65,41 +65,51 @@ export function validateAnswer(question: QuestionSpec, value: string, otherText?
if (question.required && trimmed === '') {
return 'This question is required';
}
if (trimmed === '') return null;
if (trimmed === '') {return null;}
switch (question.type) {
case 'text':
case 'textarea':
case 'textarea': {
return validateText(trimmed, question, cfg);
}
case 'email':
case 'email': {
return validateEmail(trimmed, cfg);
}
case 'number':
case 'number': {
return validateNumber(trimmed, cfg);
}
case 'rating':
case 'rating': {
return validateRating(trimmed, cfg);
}
case 'nps':
case 'nps': {
return validateNps(trimmed);
}
case 'likert':
return LIKERT_VALUES.includes(trimmed) ? null : 'Please select a valid option';
case 'likert': {
return LIKERT_VALUES.has(trimmed) ? null : 'Please select a valid option';
}
case 'radio':
case 'radio': {
return validateRadio(trimmed, question, otherText);
}
case 'checkbox':
case 'checkbox': {
return validateCheckbox(trimmed, question, otherText, cfg);
}
case 'dropdown':
case 'dropdown': {
return validateDropdown(trimmed, question);
}
default:
default: {
return null;
}
}
}
function validateText(value: string, question: QuestionSpec, cfg: NonNullable<QuestionSpec['config']>): string | null {
const maxLen = question.maxLength;
@@ -132,7 +142,7 @@ function validateEmail(value: string, cfg: NonNullable<QuestionSpec['config']>):
return 'Please enter a valid email address';
}
if (cfg.allowedDomains && cfg.allowedDomains.length > 0) {
const domain = value.split('@')[1]?.toLowerCase();
const domain = value.split('@', 2)[1]?.toLowerCase();
const allowed = cfg.allowedDomains.map((d) => d.toLowerCase());
if (!allowed.includes(domain)) {
return `Email must be from: ${cfg.allowedDomains.join(', ')}`;
@@ -184,7 +194,7 @@ function validateNps(value: string): string | null {
function validateRadio(value: string, question: QuestionSpec, otherText?: string): string | null {
const validValues = new Set((question.options ?? []).map((o) => o.value));
if (question.hasOther) validValues.add('Other');
if (question.hasOther) {validValues.add('Other');}
if (!validValues.has(value)) {
return 'Please select a valid option';
}
@@ -212,7 +222,7 @@ function validateCheckbox(
}
const validValues = new Set((question.options ?? []).map((o) => o.value));
if (question.hasOther) validValues.add('Other');
if (question.hasOther) {validValues.add('Other');}
for (const v of selected) {
if (!validValues.has(v)) {
return `Invalid selection: ${v}`;
+15 -5
View File
@@ -88,7 +88,9 @@ export interface CreateQuestionInput {
config?: Record<string, unknown>;
}
/** Server rejects a larger submit-answers batch with a 400, so the client chunks its flush to match. */
/**
Server rejects a larger submit-answers batch with a 400, so the client chunks its flush to match.
*/
export const BATCH_ANSWER_LIMIT = 20;
export interface UpdateQuestionInput {
@@ -115,7 +117,9 @@ export interface AnswerInput {
questionId: string;
value: string;
otherText?: string;
/** Client-measured ms spent on this question before committing. */
/**
Client-measured ms spent on this question before committing.
*/
answerMs?: number;
}
@@ -320,7 +324,9 @@ export interface WsPushEvents {
analytics: SlowAnalyticsPayload;
}
/** Client → Server */
/**
Client → Server
*/
export type WsRequestMessage = {
[K in keyof WsOperations]: {
type: 'request';
@@ -330,7 +336,9 @@ export type WsRequestMessage = {
};
}[keyof WsOperations];
/** Server → Client (response to a request) */
/**
Server → Client (response to a request)
*/
export type WsResponseMessage =
| {
[K in keyof WsOperations]: {
@@ -347,7 +355,9 @@ export type WsResponseMessage =
error: string;
};
/** Server → Client (unsolicited push) */
/**
Server → Client (unsolicited push)
*/
export type WsPushMessage = {
[K in keyof WsPushEvents]: {
type: 'push';
@@ -12,7 +12,7 @@ function q(overrides: Partial<QuestionSpec> & { type: string }): QuestionSpec {
describe('required check', () => {
it('rejects empty value on required question', () => {
expect(validateAnswer(q({ type: 'text', required: true }), '')).toBe('This question is required');
expect(validateAnswer(q({ type: 'text', required: true }), ' ')).toBe('This question is required');
expect(validateAnswer(q({ type: 'text', required: true }), ' '.repeat(3))).toBe('This question is required');
});
it('accepts empty value on optional question', () => {
@@ -58,8 +58,8 @@ describe('text validation', () => {
});
it('validates against custom pattern', () => {
expect(validateAnswer(q({ type: 'text', config: { pattern: '^\\d{3}-\\d{4}$' } }), '123-4567')).toBeNull();
expect(validateAnswer(q({ type: 'text', config: { pattern: '^\\d{3}-\\d{4}$' } }), 'abc')).toBe(
expect(validateAnswer(q({ type: 'text', config: { pattern: String.raw`^\d{3}-\d{4}$` } }), '123-4567')).toBeNull();
expect(validateAnswer(q({ type: 'text', config: { pattern: String.raw`^\d{3}-\d{4}$` } }), 'abc')).toBe(
'Answer does not match the required format',
);
});
+6 -6
View File
@@ -17,7 +17,7 @@ interface AuthState {
export async function getMe(): Promise<AuthState> {
try {
const res = await fetch('/api/auth/me', { credentials: 'include' });
if (!res.ok) return { authenticated: false };
if (!res.ok) {return { authenticated: false };}
return res.json() as Promise<AuthState>;
} catch {
return { authenticated: false };
@@ -29,7 +29,7 @@ export async function setup(password: string, setupToken?: string): Promise<void
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(setupToken ? { 'X-Setup-Token': setupToken } : {}),
...(setupToken && { 'X-Setup-Token': setupToken }),
},
body: JSON.stringify({ password }),
credentials: 'include',
@@ -54,12 +54,12 @@ export async function passwordLogin(password: string): Promise<void> {
}
export function oidcLogin(returnTo?: string): void {
const url = new URL('/api/auth/login', window.location.origin);
if (returnTo) url.searchParams.set('returnTo', returnTo);
window.location.href = url.toString();
const url = new URL('/api/auth/login', location.origin);
if (returnTo) {url.searchParams.set('returnTo', returnTo);}
location.assign(url.toString());
}
export async function logout(): Promise<void> {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/';
location.assign('/');
}
+15 -13
View File
@@ -6,7 +6,9 @@ interface PendingSave {
questionId: string;
value: string;
otherText?: string;
/** Client-measured milliseconds spent on this question before committing. */
/**
Client-measured milliseconds spent on this question before committing.
*/
answerMs?: number;
}
@@ -54,7 +56,7 @@ export function createApiClient(slug: string) {
// text field would count and typing "Hello" would flush after 4 characters.
const isNew = !answerBuffer.has(data.questionId);
answerBuffer.set(data.questionId, data);
if (isNew) unflushedCount++;
if (isNew) {unflushedCount++;}
resetInactivityTimer();
if (unflushedCount >= FLUSH_THRESHOLD) {
@@ -71,7 +73,7 @@ export function createApiClient(slug: string) {
while (!wsClient?.connected && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 100));
}
if (!wsClient?.connected) return false;
if (!wsClient?.connected) {return false;}
}
try {
await wsClient.request('submit-answers', { answers });
@@ -90,10 +92,9 @@ export function createApiClient(slug: string) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ answers }),
credentials: 'same-origin',
});
if (res.ok) return true;
if (res.status < 500) return false;
if (res.ok) {return true;}
if (res.status < 500) {return false;}
} catch {
// network error, retry
}
@@ -105,7 +106,7 @@ export function createApiClient(slug: string) {
}
async function flushBuffer(): Promise<boolean> {
if (answerBuffer.size === 0) return true;
if (answerBuffer.size === 0) {return true;}
if (inactivityTimer !== null) {
clearTimeout(inactivityTimer);
@@ -121,7 +122,7 @@ export function createApiClient(slug: string) {
for (let i = 0; i < all.length; i += BATCH_ANSWER_LIMIT) {
const chunk = all.slice(i, i + BATCH_ANSWER_LIMIT);
const ok = await saveBatch(chunk);
if (!ok) failed.push(...chunk);
if (!ok) {failed.push(...chunk);}
}
const success = failed.length === 0;
if (success) {
@@ -148,7 +149,7 @@ export function createApiClient(slug: string) {
// Page unload path — sendBeacon is HTTP-only. This is the ONE place where
// HTTP is used in ws mode, since the WebSocket can't reliably finish
// pending sends during unload.
if (answerBuffer.size === 0) return;
if (answerBuffer.size === 0) {return;}
if (inactivityTimer !== null) {
clearTimeout(inactivityTimer);
@@ -194,7 +195,7 @@ export function createApiClient(slug: string) {
}
mode = 'http';
const res = await fetch(`${base}/resume`, { credentials: 'same-origin' });
const res = await fetch(`${base}/resume`);
if (!res.ok) {
throw new Error(`Failed to load survey (${res.status})`);
}
@@ -211,7 +212,6 @@ export function createApiClient(slug: string) {
}
const res = await fetch(`${base}/complete`, {
method: 'POST',
credentials: 'same-origin',
});
if (!res.ok) {
throw new Error(`Failed to submit survey (${res.status})`);
@@ -231,11 +231,13 @@ export function createApiClient(slug: string) {
}
function destroy(): void {
if (inactivityTimer !== null) {
if (inactivityTimer === null) {
return;
}
clearTimeout(inactivityTimer);
inactivityTimer = null;
}
}
return {
bufferAnswer,
@@ -11,13 +11,13 @@ export async function request<T>(url: string, options?: RequestInit): Promise<T>
if (!res.ok) {
if (res.status === 401) {
// Reload to trigger the layout auth check, which shows the login screen
window.location.reload();
location.reload();
throw new Error('Authentication required');
}
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error((body as { error?: string }).error ?? `Request failed (${res.status})`);
}
if (res.status === 204) return undefined as T;
if (res.status === 204) {return undefined as T;}
return res.json() as Promise<T>;
}
@@ -16,10 +16,10 @@ export async function listSurveysPaginated(opts: {
limit?: number;
}): Promise<{ surveys: Survey[]; total: number }> {
const params = new URLSearchParams();
if (opts.includeArchived) params.set('archived', 'true');
if (opts.search) params.set('search', opts.search);
if (opts.offset) params.set('offset', String(opts.offset));
if (opts.limit) params.set('limit', String(opts.limit));
if (opts.includeArchived) {params.set('archived', 'true');}
if (opts.search) {params.set('search', opts.search);}
if (opts.offset) {params.set('offset', String(opts.offset));}
if (opts.limit) {params.set('limit', String(opts.limit));}
const data = await request<{ surveys: Array<Record<string, unknown>>; total: number }>(`/api/surveys?${params}`);
return { surveys: data.surveys.map(surveyFromApi), total: data.total };
}
@@ -118,9 +118,9 @@ export async function searchAnswers(
}),
() => {
const params = new URLSearchParams({ q: query });
if (questionId) params.set('questionId', questionId);
if (pagination?.offset) params.set('offset', String(pagination.offset));
if (pagination?.limit) params.set('limit', String(pagination.limit));
if (questionId) {params.set('questionId', questionId);}
if (pagination?.offset) {params.set('offset', String(pagination.offset));}
if (pagination?.limit) {params.set('limit', String(pagination.limit));}
return request(`/api/surveys/${id}/results/search?${params}`);
},
);
@@ -164,7 +164,7 @@ export async function getLiveResults(id: string): Promise<LiveResults | null> {
if (!res.ok) {
if (res.status === 401) {
window.location.reload();
location.reload();
throw new Error('Authentication required');
}
const body = await res.json().catch(() => ({ error: res.statusText }));
@@ -197,7 +197,7 @@ export async function deleteRespondent(surveyId: string, respondentId: string):
// Export stays HTTP — binary file download
export async function exportResults(id: string, format: 'csv' | 'json'): Promise<void> {
const res = await fetch(`/api/surveys/${id}/results/export?format=${format}`);
if (!res.ok) throw new Error('Export failed');
if (!res.ok) {throw new Error('Export failed');}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
+25 -17
View File
@@ -5,7 +5,9 @@ export interface SurveyWsClient {
on<K extends keyof WsPushEvents>(event: K, callback: (data: WsPushEvents[K]) => void): () => void;
/** Fires immediately with the current state on subscribe, then on each change. */
/**
Fires immediately with the current state on subscribe, then on each change.
*/
onConnectionChange(callback: (state: 'connecting' | 'open' | 'closed' | 'failed') => void): () => void;
close(): void;
@@ -16,8 +18,8 @@ export interface SurveyWsClient {
let requestCounter = 0;
export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent' | 'editor'): SurveyWsClient {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${window.location.host}/api/s/${slug}/ws?type=${type}`;
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${protocol}//${location.host}/api/s/${slug}/ws?type=${type}`;
let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
@@ -30,21 +32,21 @@ export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent'
const stateListeners = new Set<(s: ConnState) => void>();
function setState(next: ConnState) {
state = next;
for (const cb of stateListeners) cb(next);
for (const cb of stateListeners) {cb(next);}
}
const pushListeners = new Map<string, Array<(data: unknown) => void>>();
const pendingRequests = new Map<string, { resolve: (data: unknown) => void; reject: (error: Error) => void }>();
function connect() {
if (closed) return;
if (closed) {return;}
setState('connecting');
ws = new WebSocket(url);
ws.onopen = () => {
ws.addEventListener('open', () => {
failures = 0;
setState('open');
};
});
ws.onmessage = (event) => {
try {
@@ -65,7 +67,7 @@ export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent'
if (msg.type === 'push' && msg.event) {
const cbs = pushListeners.get(msg.event);
if (cbs) for (const cb of cbs) cb(msg.data);
if (cbs) {for (const cb of cbs) {cb(msg.data);}}
return;
}
} catch {
@@ -73,13 +75,13 @@ export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent'
}
};
ws.onclose = () => {
ws.addEventListener('close', () => {
for (const [, pending] of pendingRequests) {
pending.reject(new Error('WebSocket closed'));
}
pendingRequests.clear();
if (closed) return;
if (closed) {return;}
failures++;
if (failures < MAX_FAILURES) {
setState('closed');
@@ -87,7 +89,7 @@ export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent'
} else {
setState('failed');
}
};
});
ws.onerror = () => {
ws?.close();
@@ -157,14 +159,14 @@ export function createSurveyWsClient(slug: string, type: 'viewer' | 'respondent'
on<K extends keyof WsPushEvents>(event: K, callback: (data: WsPushEvents[K]) => void): () => void {
const key = event as string;
if (!pushListeners.has(key)) pushListeners.set(key, []);
if (!pushListeners.has(key)) {pushListeners.set(key, []);}
const cb = callback as (data: unknown) => void;
pushListeners.get(key)!.push(cb);
return () => {
const list = pushListeners.get(key);
if (list) {
const idx = list.indexOf(cb);
if (idx >= 0) list.splice(idx, 1);
if (idx !== -1) {list.splice(idx, 1);}
}
};
},
@@ -196,11 +198,13 @@ type WsType = 'viewer' | 'respondent' | 'editor';
const connectionsBySlug = new Map<string, SurveyWsClient>(); // key: `${slug}:${type}`
const connectionsBySurveyId = new Map<string, SurveyWsClient>();
/** Get or create a WS client for a survey */
/**
Get or create a WS client for a survey
*/
export function getSurveyWs(slug: string, type: WsType): SurveyWsClient {
const key = `${slug}:${type}`;
const existing = connectionsBySlug.get(key);
if (existing?.connected) return existing;
if (existing?.connected) {return existing;}
existing?.close();
const client = createSurveyWsClient(slug, type);
@@ -208,13 +212,17 @@ export function getSurveyWs(slug: string, type: WsType): SurveyWsClient {
return client;
}
/** Get an existing WS client by survey ID (admin flows only) */
/**
Get an existing WS client by survey ID (admin flows only)
*/
export function getWsClientById(surveyId: string): SurveyWsClient | undefined {
const conn = connectionsBySurveyId.get(surveyId);
return conn?.connected ? conn : undefined;
}
/** Get an existing WS client by slug and type */
/**
Get an existing WS client by slug and type
*/
export function getWsClientBySlug(slug: string, type: WsType = 'respondent'): SurveyWsClient | undefined {
const conn = connectionsBySlug.get(`${slug}:${type}`);
return conn?.connected ? conn : undefined;
@@ -18,14 +18,14 @@
async function handlePasswordLogin(e: Event) {
e.preventDefault();
if (!password.trim()) return;
if (!password.trim()) {return;}
submitting = true;
error = null;
try {
await passwordLogin(password);
await refreshAuth();
} catch (err) {
error = err instanceof Error ? err.message : 'Login failed';
} catch (error_) {
error = error_ instanceof Error ? error_.message : 'Login failed';
}
submitting = false;
}
@@ -34,7 +34,7 @@
<div class="flex min-h-screen flex-col items-center justify-center p-6">
<div class="w-full max-w-sm">
<div class="mb-8 flex flex-col items-center gap-3 text-center">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-gray-800">
<div class="flex size-14 items-center justify-center rounded-full bg-gray-800">
<Icon icon={mdiLock} size="28" class="text-gray-400" />
</div>
<h1 class="text-xl font-bold">Sign in to FUTO Surveys</h1>
@@ -73,7 +73,7 @@
</div>
{/if}
<Button variant="outline" onclick={() => oidcLogin(window.location.pathname)}>
<Button variant="outline" onclick={() => oidcLogin(location.pathname)}>
<Icon icon={mdiOpenInNew} size="16" />
Sign in with SSO
</Button>
@@ -35,8 +35,8 @@
try {
await setup(password, requiresToken ? setupToken.trim() : undefined);
await refreshAuth();
} catch (err) {
error = err instanceof Error ? err.message : 'Setup failed';
} catch (error_) {
error = error_ instanceof Error ? error_.message : 'Setup failed';
}
submitting = false;
}
@@ -45,7 +45,7 @@
<div class="flex min-h-screen flex-col items-center justify-center p-6">
<div class="w-full max-w-sm">
<div class="mb-8 flex flex-col items-center gap-3 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-gray-800">
<div class="flex size-16 items-center justify-center rounded-full bg-gray-800">
<Icon icon={mdiShieldLock} size="32" class="text-immich-primary" />
</div>
<h1 class="text-2xl font-bold">Welcome to FUTO Surveys</h1>
@@ -27,7 +27,7 @@
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (lines.length < 2) return;
if (lines.length < 2) {return;}
onSubmit(lines.map((l) => ({ label: l, value: l })));
}
</script>
@@ -54,7 +54,7 @@
<div class="mt-4 flex items-center justify-between">
<span class="text-xs text-gray-500">
{lineCount} option{lineCount !== 1 ? 's' : ''}
{lineCount} option{lineCount === 1 ? '' : 's'}
</span>
<div class="flex gap-2">
<Button variant="outline" onclick={onClose}>Cancel</Button>
@@ -27,7 +27,7 @@
}
function removeOption(index: number) {
if (options.length <= 2) return;
if (options.length <= 2) {return;}
onChange(options.filter((_, i) => i !== index));
tick().then(() => {
const focusIdx = Math.min(index, options.length - 2);
@@ -47,7 +47,7 @@
function moveOption(index: number, direction: 'up' | 'down') {
const target = direction === 'up' ? index - 1 : index + 1;
if (target < 0 || target >= options.length) return;
if (target < 0 || target >= options.length) {return;}
const updated = [...options];
[updated[index], updated[target]] = [updated[target], updated[index]];
onChange(updated);
@@ -99,21 +99,21 @@
class="flex items-center gap-0.5 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100"
>
<button
class="rounded p-1 text-gray-500 hover:bg-gray-800 hover:text-gray-300 disabled:pointer-events-none disabled:opacity-0"
class="rounded-sm p-1 text-gray-500 hover:bg-gray-800 hover:text-gray-300 disabled:pointer-events-none disabled:opacity-0"
disabled={i === 0}
onclick={() => moveOption(i, 'up')}
>
<Icon icon={mdiArrowUp} size="14" />
</button>
<button
class="rounded p-1 text-gray-500 hover:bg-gray-800 hover:text-gray-300 disabled:pointer-events-none disabled:opacity-0"
class="rounded-sm p-1 text-gray-500 hover:bg-gray-800 hover:text-gray-300 disabled:pointer-events-none disabled:opacity-0"
disabled={i === options.length - 1}
onclick={() => moveOption(i, 'down')}
>
<Icon icon={mdiArrowDown} size="14" />
</button>
<button
class="rounded p-1 text-gray-500 hover:bg-red-500/10 hover:text-red-400 disabled:pointer-events-none disabled:opacity-0"
class="rounded-sm p-1 text-gray-500 hover:bg-red-500/10 hover:text-red-400 disabled:pointer-events-none disabled:opacity-0"
disabled={options.length <= 2}
onclick={() => removeOption(i)}
>
@@ -24,7 +24,7 @@
});
function handleDownload() {
if (!dataUrl) return;
if (!dataUrl) {return;}
const a = document.createElement('a');
a.href = dataUrl;
a.download = 'survey-qr-code.png';
@@ -32,7 +32,7 @@
}
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
if (e.target === e.currentTarget) {onClose();}
}
</script>
@@ -79,10 +79,10 @@
type LogicConditionType = 'skipped' | 'equals' | 'notEquals' | 'anyOf';
const initialLogicType: LogicConditionType = (() => {
const saved = question.config?.skipConditionType;
if (saved === 'skipped' || saved === 'equals' || saved === 'notEquals' || saved === 'anyOf') return saved;
if (saved === 'skipped' || saved === 'equals' || saved === 'notEquals' || saved === 'anyOf') {return saved;}
// Back-compat: infer from which value field is populated if an older
// survey was saved before skipConditionType was persisted.
if (question.config?.skipConditionValues) return 'anyOf';
if (question.config?.skipConditionValues) {return 'anyOf';}
return 'equals';
})();
let logicConditionType = $state<LogicConditionType>(initialLogicType);
@@ -105,28 +105,34 @@
updated.hasOther = false;
}
switch (newType) {
case 'rating':
case 'rating': {
updated.config = { scaleMax: 5 };
updated.options = [];
break;
case 'nps':
}
case 'nps': {
updated.config = { scaleMax: 10 };
updated.options = [];
break;
case 'number':
}
case 'number': {
updated.config = { min: 0, max: 100 };
updated.options = [];
break;
case 'likert':
}
case 'likert': {
updated.config = {};
updated.options = [];
break;
case 'dropdown':
}
case 'dropdown': {
// keep options
break;
default:
}
default: {
break;
}
}
onChange(updated);
}
@@ -157,7 +163,7 @@
</span>
{#if question.required}
<span
class="bg-immich-primary-10 text-immich-primary shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase"
class="bg-immich-primary-10 text-immich-primary shrink-0 rounded-sm px-1.5 py-0.5 text-[10px] font-medium tracking-wider uppercase"
>
Required
</span>
@@ -171,12 +177,12 @@
</div>
{#if expanded}
<div class="border-t border-gray-200 px-4 pt-4 pb-4 dark:border-gray-700/60">
<div class="border-t border-gray-200 p-4 dark:border-gray-700/60">
<div class="mb-4 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500">Question {index + 1}</span>
<div class="flex items-center gap-0.5">
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
disabled={index === 0}
onclick={() => onMove('up')}
title="Move up"
@@ -184,7 +190,7 @@
<Icon icon={mdiArrowUp} size="16" />
</button>
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
disabled={index === total - 1}
onclick={() => onMove('down')}
title="Move down"
@@ -192,7 +198,7 @@
<Icon icon={mdiArrowDown} size="16" />
</button>
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
onclick={onDuplicate}
title="Duplicate"
>
@@ -214,7 +220,7 @@
</div>
{:else}
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-red-500/10 hover:text-red-400"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-red-500/10 hover:text-red-400"
onclick={() => (question.text ? (confirmingDelete = true) : onDelete())}
title="Delete"
>
@@ -625,7 +631,7 @@
...question.config,
integerOnly: (e.target as HTMLInputElement).checked || undefined,
})}
class="rounded"
class="rounded-sm"
/>
Whole numbers only
</label>
@@ -70,14 +70,14 @@
expandedQuestionIndex = newIndex;
tick().then(() => {
questionEditorRefs[newIndex]?.focusText();
const el = document.querySelector(`[data-question-index="${index}-${newIndex}"]`);
const el = document.querySelector(`[data-question-index="${CSS.escape(index)}-${CSS.escape(newIndex)}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
function addFromTemplate(templateId: string) {
const template = questionTemplates.find((t) => t.id === templateId);
if (!template) return;
if (!template) {return;}
const newQ: BuilderQuestion = {
...template.question,
id: '',
@@ -89,7 +89,7 @@
showTemplates = false;
tick().then(() => {
questionEditorRefs[newIndex]?.focusText();
const el = document.querySelector(`[data-question-index="${index}-${newIndex}"]`);
const el = document.querySelector(`[data-question-index="${CSS.escape(index)}-${CSS.escape(newIndex)}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
@@ -111,8 +111,8 @@
function deleteQuestion(qIndex: number) {
onChange({ ...section, questions: section.questions.filter((_, i) => i !== qIndex) });
if (expandedQuestionIndex === qIndex) expandedQuestionIndex = null;
else if (expandedQuestionIndex !== null && expandedQuestionIndex > qIndex) expandedQuestionIndex--;
if (expandedQuestionIndex === qIndex) {expandedQuestionIndex = null;}
else if (expandedQuestionIndex !== null && expandedQuestionIndex > qIndex) {expandedQuestionIndex--;}
}
function moveQuestion(qIndex: number, direction: 'up' | 'down') {
@@ -156,26 +156,26 @@
</div>
<span class="text-sm font-semibold text-gray-400">Section {index + 1}</span>
<span class="rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500 dark:bg-gray-800">
{section.questions.length} question{section.questions.length !== 1 ? 's' : ''}
{section.questions.length} question{section.questions.length === 1 ? '' : 's'}
</span>
</div>
<div class="flex items-center gap-0.5">
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
disabled={index === 0}
onclick={() => onMove('up')}
>
<Icon icon={mdiArrowUp} size="16" />
</button>
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
disabled={index === total - 1}
onclick={() => onMove('down')}
>
<Icon icon={mdiArrowDown} size="16" />
</button>
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
onclick={onDuplicate}
title="Duplicate section"
>
@@ -184,7 +184,7 @@
{#if confirmingDelete}
<div class="ml-2 flex items-center gap-2 rounded-md bg-red-500/10 px-3 py-1.5">
<span class="text-xs text-red-400"
>Delete section & {section.questions.length} question{section.questions.length !== 1 ? 's' : ''}?</span
>Delete section & {section.questions.length} question{section.questions.length === 1 ? '' : 's'}?</span
>
<button
class="text-xs font-medium text-red-400 hover:text-red-300"
@@ -199,7 +199,7 @@
</div>
{:else}
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-red-500/10 hover:text-red-400"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-red-500/10 hover:text-red-400"
onclick={handleDeleteSection}
>
<Icon icon={mdiDelete} size="16" />
@@ -299,7 +299,7 @@
<div
class="absolute top-full left-0 z-30 mt-1 w-56 rounded-lg border border-gray-600 bg-gray-900 py-1 shadow-xl"
>
{#each [...templatesByCategory.entries()] as [category, templates] (category)}
{#each [...templatesByCategory] as [category, templates] (category)}
<div class="px-3 pt-2 pb-1 text-[10px] font-semibold tracking-wider text-gray-500 uppercase">
{category}
</div>
@@ -12,7 +12,7 @@
const error = $derived(value ? validateSlug(value) : null);
function handleInput(e: Event) {
const raw = (e.target as HTMLInputElement).value.toLowerCase().replace(/[^a-z0-9-]/g, '');
const raw = (e.target as HTMLInputElement).value.toLowerCase().replaceAll(/[^a-z0-9-]/g, '');
onChange(raw);
}
</script>
@@ -57,7 +57,7 @@
let localThankYouTitle = $state(survey.thankYouTitle ?? '');
let localThankYouDescription = $state(survey.thankYouDescription ?? '');
let localClosesAt = $state(survey.closesAt ?? '');
let localMaxResponses = $state(survey.maxResponses != null ? String(survey.maxResponses) : '');
let localMaxResponses = $state(survey.maxResponses == null ? '' : String(survey.maxResponses));
let localRandomizeQuestions = $state(survey.randomizeQuestions ?? false);
let localRandomizeOptions = $state(survey.randomizeOptions ?? false);
let localPasswordEnabled = $state(survey.hasPassword ?? false);
@@ -96,7 +96,7 @@
}
function pushSnapshot() {
if (isRestoring) return;
if (isRestoring) {return;}
clearTimeout(snapshotTimer);
snapshotTimer = setTimeout(() => {
const snap = takeSnapshot();
@@ -106,11 +106,11 @@
}
function undo() {
if (undoStack.length === 0) return;
if (undoStack.length === 0) {return;}
isRestoring = true;
const current = takeSnapshot();
redoStack = [...redoStack, current];
const snap = undoStack[undoStack.length - 1];
const snap = undoStack.at(-1);
undoStack = undoStack.slice(0, -1);
localTitle = snap.title;
localDescription = snap.description;
@@ -122,11 +122,11 @@
}
function redo() {
if (redoStack.length === 0) return;
if (redoStack.length === 0) {return;}
isRestoring = true;
const current = takeSnapshot();
undoStack = [...undoStack, current];
const snap = redoStack[redoStack.length - 1];
const snap = redoStack.at(-1);
redoStack = redoStack.slice(0, -1);
localTitle = snap.title;
localDescription = snap.description;
@@ -200,7 +200,7 @@
const newSection = createDefaultSection(localSections.length);
localSections = [...localSections, newSection];
tick().then(() => {
const el = document.querySelector(`[data-section-index="${localSections.length - 1}"]`);
const el = document.querySelector(`[data-section-index="${CSS.escape(localSections.length - 1)}"]`);
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
@@ -270,8 +270,8 @@
setTimeout(() => {
success = false;
}, 2500);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save';
} catch (error_) {
error = error_ instanceof Error ? error_.message : 'Failed to save';
}
}
@@ -284,8 +284,8 @@
try {
await handleSave();
await onPublish();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to publish';
} catch (error_) {
error = error_ instanceof Error ? error_.message : 'Failed to publish';
}
}
@@ -293,8 +293,8 @@
error = null;
try {
await onUnpublish();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to unpublish';
} catch (error_) {
error = error_ instanceof Error ? error_.message : 'Failed to unpublish';
}
}
@@ -335,7 +335,7 @@
</div>
{/if}
<div class="bg-light/80 sticky top-0 z-20 border-b border-gray-200 backdrop-blur-md dark:border-gray-800">
<div class="sticky top-0 z-20 border-b border-gray-200 bg-light/80 backdrop-blur-md dark:border-gray-800">
<div class="mx-auto flex max-w-3xl items-center justify-between px-6 py-3">
<div class="flex items-center gap-3">
<h1 class="text-lg font-semibold tracking-tight">
@@ -346,13 +346,13 @@
<Icon icon={mdiClockOutline} size="13" />
{estimatedTime}
<Icon icon={mdiCircleSmall} size="12" />
{totalQuestions} question{totalQuestions !== 1 ? 's' : ''}
{totalQuestions} question{totalQuestions === 1 ? '' : 's'}
</span>
{/if}
</div>
<div class="flex items-center gap-2">
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
disabled={undoStack.length === 0}
onclick={undo}
title="Undo (Ctrl+Z)"
@@ -360,7 +360,7 @@
<Icon icon={mdiUndo} size="18" />
</button>
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200 disabled:opacity-30"
disabled={redoStack.length === 0}
onclick={redo}
title="Redo (Ctrl+Shift+Z)"
@@ -88,7 +88,7 @@
</div>
<div
class="relative h-[667px] w-[375px] [transform:translateZ(0)] overflow-hidden rounded-[2.5rem] border-4 border-gray-600 bg-gray-950 shadow-2xl"
class="relative h-[667px] w-[375px] transform-[translateZ(0)] overflow-hidden rounded-[2.5rem] border-4 border-gray-600 bg-gray-950 shadow-2xl"
>
<div class="flex h-full flex-col overflow-y-auto">
{#if phase === 'welcome'}
@@ -7,7 +7,7 @@ function parseBulkPaste(text: string): { label: string; value: string }[] | null
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
if (lines.length < 2) return null;
if (lines.length < 2) {return null;}
return lines.map((l) => ({ label: l, value: l }));
}
@@ -27,13 +27,13 @@
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
if (!file) {return;}
const reader = new FileReader();
reader.onload = (e) => {
reader.addEventListener('load', (e) => {
jsonText = (e.target?.result as string) ?? '';
parseError = null;
};
});
reader.readAsText(file);
}
@@ -28,7 +28,7 @@
onclick={() => onSelect(tag.id)}
>
{#if tag.color}
<span class="mr-1 inline-block h-2 w-2 rounded-full" style="background-color: {tag.color}"></span>
<span class="mr-1 inline-block size-2 rounded-full" style="background-color: {tag.color}"></span>
{/if}
{tag.name}
</button>
@@ -25,7 +25,7 @@
</span>
<span class="text-sm text-gray-300">{auth.user.name || auth.user.email}</span>
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
onclick={() => logout()}
title="Log out"
>
@@ -22,7 +22,7 @@
const optionLabels = $derived.by(() => {
const map: Record<string, string> = {};
for (const opt of question.options ?? []) map[opt.value] = opt.label;
for (const opt of question.options ?? []) {map[opt.value] = opt.label;}
return map;
});
@@ -14,11 +14,11 @@
let chart: ChartType | undefined;
function formatDuration(seconds: number | null): string {
if (seconds === null || !Number.isFinite(seconds)) return '–';
if (seconds < 60) return `${seconds}s`;
if (seconds === null || !Number.isFinite(seconds)) {return '–';}
if (seconds < 60) {return `${seconds}s`;}
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
if (mins < 60) return secs === 0 ? `${mins}m` : `${mins}m ${secs}s`;
if (mins < 60) {return secs === 0 ? `${mins}m` : `${mins}m ${secs}s`;}
const hours = Math.floor(mins / 60);
const remMin = mins % 60;
return remMin === 0 ? `${hours}h` : `${hours}h ${remMin}m`;
@@ -32,7 +32,7 @@
}
const stats = $derived.by<StatEntry[]>(() => {
if (data.count === 0) return [{ label: 'Sample', value: '0' }];
if (data.count === 0) {return [{ label: 'Sample', value: '0' }];}
return [
{ label: 'Sample', value: String(data.count), hint: 'Number of completed responses in the distribution' },
{
@@ -48,10 +48,10 @@
});
const medianBucketIndex = $derived.by(() => {
if (data.median === null) return -1;
if (data.median === null) {return -1;}
for (let i = 0; i < data.buckets.length; i++) {
const b = data.buckets[i];
if (data.median >= b.minSeconds && (b.maxSeconds === null || data.median < b.maxSeconds)) return i;
if (data.median >= b.minSeconds && (b.maxSeconds === null || data.median < b.maxSeconds)) {return i;}
}
return -1;
});
@@ -15,24 +15,24 @@
const startingCohort = $derived(data[0]?.respondentsReached ?? data[0]?.respondentsAnswered ?? 0);
function widthPct(value: number): number {
if (startingCohort <= 0) return 0;
if (startingCohort <= 0) {return 0;}
return Math.max(1, Math.min(100, (value / startingCohort) * 100));
}
function severityClass(rate: number): string {
if (rate >= 30) return 'bg-red-500/70';
if (rate >= 15) return 'bg-amber-500/70';
if (rate >= 30) {return 'bg-red-500/70';}
if (rate >= 15) {return 'bg-amber-500/70';}
return 'bg-emerald-500/70';
}
function severityText(rate: number): string {
if (rate >= 30) return 'text-red-400';
if (rate >= 15) return 'text-amber-400';
if (rate >= 30) {return 'text-red-400';}
if (rate >= 15) {return 'text-amber-400';}
return 'text-emerald-400';
}
function retainedPct(row: DropoffDataPoint): number {
if (startingCohort <= 0) return 0;
if (startingCohort <= 0) {return 0;}
return Math.round((row.respondentsReached / startingCohort) * 100);
}
@@ -25,11 +25,11 @@
const filteredEntries = $derived.by<EmailEntry[]>(() => {
const q = search.trim().toLowerCase();
return summary.entries.filter((e) => {
if (filter === 'corporate' && e.kind !== 'corporate') return false;
if (filter === 'free' && e.kind !== 'free') return false;
if (filter === 'disposable' && e.kind !== 'disposable') return false;
if (filter === 'role' && !e.isRoleBased) return false;
if (q && !e.normalized.includes(q) && !e.raw.toLowerCase().includes(q)) return false;
if (filter === 'corporate' && e.kind !== 'corporate') {return false;}
if (filter === 'free' && e.kind !== 'free') {return false;}
if (filter === 'disposable' && e.kind !== 'disposable') {return false;}
if (filter === 'role' && !e.isRoleBased) {return false;}
if (q && !e.normalized.includes(q) && !e.raw.toLowerCase().includes(q)) {return false;}
return true;
});
});
@@ -102,16 +102,16 @@
const maxDomainCount = $derived(Math.max(1, ...summary.topDomains.map((d) => d.count)));
function domainColor(kind: EmailKind): string {
if (kind === 'corporate') return 'bg-emerald-500/70';
if (kind === 'free') return 'bg-blue-500/70';
if (kind === 'corporate') {return 'bg-emerald-500/70';}
if (kind === 'free') {return 'bg-blue-500/70';}
return 'bg-red-500/70';
}
function filterCount(kind: FilterKind): number {
if (kind === 'all') return summary.unique;
if (kind === 'corporate') return summary.entries.filter((e) => e.kind === 'corporate').length;
if (kind === 'free') return summary.entries.filter((e) => e.kind === 'free').length;
if (kind === 'disposable') return summary.entries.filter((e) => e.kind === 'disposable').length;
if (kind === 'all') {return summary.unique;}
if (kind === 'corporate') {return summary.entries.filter((e) => e.kind === 'corporate').length;}
if (kind === 'free') {return summary.entries.filter((e) => e.kind === 'free').length;}
if (kind === 'disposable') {return summary.entries.filter((e) => e.kind === 'disposable').length;}
return summary.entries.filter((e) => e.isRoleBased).length;
}
@@ -120,7 +120,7 @@
await navigator.clipboard.writeText(text);
copied = which;
setTimeout(() => {
if (copied === which) copied = null;
if (copied === which) {copied = null;}
}, 1500);
} catch {
// ignore clipboard errors — user can select manually
@@ -152,20 +152,25 @@
function filterChipClass(key: FilterKind): string {
const active = filter === key;
if (!active) return 'border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-300 dark:border-gray-700';
if (!active) {return 'border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-300 dark:border-gray-700';}
switch (key) {
case 'corporate':
case 'corporate': {
return 'border-emerald-500/60 bg-emerald-500/10 text-emerald-400';
case 'free':
}
case 'free': {
return 'border-blue-500/60 bg-blue-500/10 text-blue-400';
case 'disposable':
}
case 'disposable': {
return 'border-red-500/60 bg-red-500/10 text-red-400';
case 'role':
}
case 'role': {
return 'border-amber-500/60 bg-amber-500/10 text-amber-400';
default:
}
default: {
return 'border-gray-500 bg-gray-500/10 text-gray-200';
}
}
}
</script>
<div class="space-y-5">
@@ -68,7 +68,7 @@
<div>
<div class="text-[10px] font-medium tracking-wider text-gray-500 uppercase">Mean</div>
<div class="mt-0.5 text-2xl font-bold text-gray-200 tabular-nums">
{stats.mean !== null ? stats.mean.toFixed(2) : '–'}
{stats.mean === null ? '–' : stats.mean.toFixed(2)}
</div>
<div class="text-[11px] text-gray-500">1–5 scale</div>
</div>
@@ -84,7 +84,7 @@
{#if stats.total > 0}
<div class="relative">
<div class="pointer-events-none absolute top-0 bottom-0 left-1/2 z-10 w-px bg-gray-500/50"></div>
<div class="pointer-events-none absolute inset-y-0 left-1/2 z-10 w-px bg-gray-500/50"></div>
<div class="flex h-8 w-full overflow-hidden rounded-md bg-gray-200/30 dark:bg-gray-800/40">
<div style="width: {leftPad}%" class="shrink-0"></div>
@@ -139,13 +139,13 @@
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-gray-400">
<span class="flex items-center gap-1.5"
><span class="h-2 w-2 rounded-sm bg-red-600/85"></span>Strongly Disagree</span
><span class="size-2 rounded-sm bg-red-600/85"></span>Strongly Disagree</span
>
<span class="flex items-center gap-1.5"><span class="h-2 w-2 rounded-sm bg-red-400/85"></span>Disagree</span>
<span class="flex items-center gap-1.5"><span class="h-2 w-2 rounded-sm bg-gray-400/60"></span>Neutral</span>
<span class="flex items-center gap-1.5"><span class="h-2 w-2 rounded-sm bg-green-400/85"></span>Agree</span>
<span class="flex items-center gap-1.5"><span class="size-2 rounded-sm bg-red-400/85"></span>Disagree</span>
<span class="flex items-center gap-1.5"><span class="size-2 rounded-sm bg-gray-400/60"></span>Neutral</span>
<span class="flex items-center gap-1.5"><span class="size-2 rounded-sm bg-green-400/85"></span>Agree</span>
<span class="flex items-center gap-1.5"
><span class="h-2 w-2 rounded-sm bg-green-600/85"></span>Strongly Agree</span
><span class="size-2 rounded-sm bg-green-600/85"></span>Strongly Agree</span
>
</div>
</div>
@@ -9,7 +9,7 @@
</script>
<div class="flex items-center gap-2 rounded-full border border-gray-300 px-3 py-1.5 text-xs dark:border-gray-600">
<span class="pulse-dot inline-block h-2 w-2 rounded-full bg-green-500"></span>
<span class="pulse-dot inline-block size-2 rounded-full bg-green-500"></span>
{#if counts.activeRespondents > 0}
<span class="text-gray-400">
<span class="font-medium text-green-400">{counts.activeRespondents}</span> responding
@@ -16,16 +16,16 @@
const maxBucket = $derived(Math.max(1, ...dist.map((d) => d.count)));
function scoreColour(score: number | null): string {
if (score === null) return 'text-gray-400';
if (score >= 50) return 'text-green-400';
if (score >= 0) return 'text-emerald-400';
if (score >= -50) return 'text-amber-400';
if (score === null) {return 'text-gray-400';}
if (score >= 50) {return 'text-green-400';}
if (score >= 0) {return 'text-emerald-400';}
if (score >= -50) {return 'text-amber-400';}
return 'text-red-400';
}
function bucketColour(score: number): string {
if (score <= 6) return 'bg-red-500/70';
if (score <= 8) return 'bg-amber-500/70';
if (score <= 6) {return 'bg-red-500/70';}
if (score <= 8) {return 'bg-amber-500/70';}
return 'bg-green-500/70';
}
</script>
@@ -15,7 +15,7 @@
const maxBucket = $derived(Math.max(1, ...buckets.map((b) => b.count)));
function fmt(n: number | null, digits = 1): string {
if (n === null) return '–';
if (n === null) {return '–';}
return Number.isInteger(n) ? String(n) : n.toFixed(digits);
}
@@ -67,7 +67,7 @@
for (const card of statCards) {
const label = card.querySelector('.uppercase')?.textContent?.trim() ?? '';
const value = card.querySelector('.text-2xl')?.textContent?.trim() ?? '';
if (label && value) stats.push(`${label}: ${value}`);
if (label && value) {stats.push(`${label}: ${value}`);}
}
if (stats.length > 0) {
addText(stats.join(' • '), 11, false, [156, 163, 175]);
@@ -78,7 +78,7 @@
const chartContainers = element.querySelectorAll('.rounded-xl');
for (const container of chartContainers) {
const heading = container.querySelector('h3')?.textContent?.trim();
if (!heading) continue;
if (!heading) {continue;}
addSpace(3);
addText(heading, 13, true);
@@ -113,7 +113,7 @@
}
// Text answers (non-chart questions)
const answerCards = container.querySelectorAll('.bg-gray-100, .dark\\:bg-gray-800');
const answerCards = container.querySelectorAll(String.raw`.bg-gray-100, .dark\:bg-gray-800`);
if (answerCards.length > 0 && !canvas) {
for (const card of Array.from(answerCards).slice(0, 15)) {
const text = card.querySelector('p')?.textContent?.trim();
@@ -128,8 +128,8 @@
}
pdf.save(filename);
} catch (e) {
error = e instanceof Error ? e.message : 'PDF export failed';
} catch (error_) {
error = error_ instanceof Error ? error_.message : 'PDF export failed';
setTimeout(() => (error = null), 4000);
}
exporting = false;
@@ -147,7 +147,7 @@
</button>
{#if error}
<span
class="absolute top-full right-0 mt-1 rounded bg-red-600 px-2 py-1 text-xs whitespace-nowrap text-white shadow-lg"
class="absolute top-full right-0 mt-1 rounded-sm bg-red-600 px-2 py-1 text-xs whitespace-nowrap text-white shadow-lg"
>{error}</span
>
{/if}
@@ -12,7 +12,9 @@
interface Props {
question: SurveyQuestion;
answers: AnswerData[];
/** Total number of completed respondents for this survey (denominator for % calculations). */
/**
Total number of completed respondents for this survey (denominator for % calculations).
*/
totalResponses: number;
onViewAllResponses?: () => void;
}
@@ -44,7 +46,7 @@
<div class="rounded-xl border border-gray-300 p-5 dark:border-gray-700">
<div class="mb-4">
<div class="mb-1 flex items-start justify-between gap-3">
<h3 class="text-base leading-snug font-semibold">{question.text}</h3>
<h3 class="text-base/snug font-semibold">{question.text}</h3>
<span
class="shrink-0 rounded-full border border-gray-700 px-2 py-0.5 text-[10px] tracking-wider text-gray-500 uppercase"
>
@@ -8,10 +8,10 @@
let { data }: Props = $props();
function formatDuration(ms: number | null): string {
if (ms === null || !Number.isFinite(ms)) return '–';
if (ms < 1000) return `${ms}ms`;
if (ms === null || !Number.isFinite(ms)) {return '–';}
if (ms < 1000) {return `${ms}ms`;}
const secs = Math.round(ms / 100) / 10;
if (secs < 60) return `${secs}s`;
if (secs < 60) {return `${secs}s`;}
const mins = Math.floor(secs / 60);
const remSec = Math.round(secs - mins * 60);
return remSec === 0 ? `${mins}m` : `${mins}m ${remSec}s`;
@@ -25,12 +25,12 @@
*/
const xMax = $derived.by(() => {
const p95s = data.map((q) => q.p95Ms ?? 0).filter((v) => v > 0);
if (p95s.length === 0) return 1;
if (p95s.length === 0) {return 1;}
return Math.max(...p95s) * 1.05;
});
function xPct(ms: number | null): number {
if (ms === null || !Number.isFinite(ms) || xMax <= 0) return 0;
if (ms === null || !Number.isFinite(ms) || xMax <= 0) {return 0;}
return Math.max(0, Math.min(100, (ms / xMax) * 100));
}
@@ -40,12 +40,12 @@
* actual max.
*/
const ticks = $derived.by(() => {
if (xMax <= 0) return [] as Array<{ ms: number; label: string }>;
if (xMax <= 0) {return [] as Array<{ ms: number; label: string }>;}
const targetTicks = 5;
const rough = xMax / targetTicks;
const pow = Math.pow(10, Math.floor(Math.log10(rough)));
const candidates = [1, 2, 5, 10].map((m) => m * pow);
const step = candidates.find((c) => c >= rough) ?? candidates[candidates.length - 1];
const step = candidates.find((c) => c >= rough) ?? candidates.at(-1);
const out: Array<{ ms: number; label: string }> = [];
for (let v = 0; v <= xMax; v += step) {
out.push({ ms: v, label: formatDuration(Math.round(v)) });
@@ -82,7 +82,7 @@
n={row.sampleSize} · median {formatDuration(row.medianMs)}
</span>
</div>
<div class="relative h-5 rounded bg-gray-800/30">
<div class="relative h-5 rounded-sm bg-gray-800/30">
{#if row.sampleSize > 0}
{@const p5 = xPct(row.p5Ms)}
{@const p25 = xPct(row.p25Ms)}
@@ -96,7 +96,7 @@
<div class="absolute top-1/2 h-2 w-px -translate-y-1/2 bg-purple-400/50" style="left: {p5}%"></div>
<div class="absolute top-1/2 h-2 w-px -translate-y-1/2 bg-purple-400/50" style="left: {p95}%"></div>
<div
class="absolute top-1/2 h-3 -translate-y-1/2 rounded bg-purple-500/60"
class="absolute top-1/2 h-3 -translate-y-1/2 rounded-sm bg-purple-500/60"
style="left: {p25}%; width: {Math.max(0.5, p75 - p25)}%"
></div>
<div
@@ -115,7 +115,7 @@
</div>
<div class="relative mt-2 ml-9 h-4 sm:mr-20">
<div class="absolute top-0 right-0 left-0 h-px bg-gray-700"></div>
<div class="absolute inset-x-0 top-0 h-px bg-gray-700"></div>
{#each ticks as t (t.ms)}
<div class="absolute top-0 -translate-x-1/2" style="left: {xPct(t.ms)}%">
<div class="h-1 w-px bg-gray-700"></div>
@@ -30,7 +30,7 @@
);
function renderStars(mean: number | null): { full: number; half: boolean; empty: number } {
if (mean === null) return { full: 0, half: false, empty: scaleMax };
if (mean === null) {return { full: 0, half: false, empty: scaleMax };}
const full = Math.floor(mean);
const half = mean - full >= 0.25 && mean - full < 0.75;
const fullShown = half ? full : Math.round(mean);
@@ -52,7 +52,7 @@
<div class="text-[10px] font-medium tracking-wider text-gray-500 uppercase">Average</div>
<div class="mt-0.5 flex items-baseline gap-2">
<span class="text-3xl font-bold text-gray-200 tabular-nums">
{stats.mean !== null ? stats.mean.toFixed(1) : '–'}
{stats.mean === null ? '–' : stats.mean.toFixed(1)}
</span>
<span class="text-sm text-gray-500">/ {scaleMax}</span>
</div>
@@ -51,7 +51,7 @@
async function handleDelete(event: MouseEvent, respondentId: string) {
event.stopPropagation();
if (!confirm('Delete this response and all its answers? This cannot be undone.')) return;
if (!confirm('Delete this response and all its answers? This cannot be undone.')) {return;}
deletingId = respondentId;
try {
await deleteRespondent(surveyId, respondentId);
@@ -14,16 +14,20 @@
function toneClass(tone?: Stat['tone']): string {
switch (tone) {
case 'positive':
case 'positive': {
return 'text-green-400';
case 'negative':
}
case 'negative': {
return 'text-red-400';
case 'warning':
}
case 'warning': {
return 'text-amber-400';
default:
}
default: {
return 'text-gray-200';
}
}
}
</script>
<div class="flex flex-wrap items-baseline gap-x-6 gap-y-2">
@@ -24,13 +24,15 @@
count: number;
}
/** Deduped so the sample below never shows the same answer twice. */
/**
Deduped so the sample below never shows the same answer twice.
*/
const uniqueResponses = $derived.by<SampleEntry[]>(() => {
const counts: Record<string, number> = {};
const order: string[] = [];
for (const a of answers) {
const text = a.value.trim();
if (!text) continue;
if (!text) {continue;}
if (counts[text] === undefined) {
counts[text] = 0;
order.push(text);
@@ -57,7 +57,7 @@
totalResults = data.total;
currentOffset = offset;
} catch {
if (offset === 0) results = [];
if (offset === 0) {results = [];}
}
loading = false;
}
@@ -87,7 +87,7 @@
selectedMatchQuestionId = result.questionId;
detailError = null;
// If we already have this respondent loaded, just update the highlight
if (selectedDetail?.id === result.respondentId) return;
if (selectedDetail?.id === result.respondentId) {return;}
selectedDetail = null;
loadingDetail = true;
try {
@@ -160,7 +160,7 @@
<Icon icon={expanded ? mdiChevronUp : mdiChevronDown} size="16" class="mt-0.5 shrink-0 text-gray-500" />
</button>
{#if expanded}
<div class="border-t border-gray-200 px-3 pt-3 pb-3 dark:border-gray-700">
<div class="border-t border-gray-200 p-3 dark:border-gray-700">
{#if loadingDetail}
<p class="py-4 text-center text-xs text-gray-500">Loading full response…</p>
{:else if detailError}
@@ -21,8 +21,8 @@
* how SQLite stored them.
*/
function parsePeriod(period: string, g: Granularity): Date {
if (g === 'day') return new Date(`${period}T00:00:00Z`);
if (g === 'hour') return new Date(`${period}:00:00Z`);
if (g === 'day') {return new Date(`${period}T00:00:00Z`);}
if (g === 'hour') {return new Date(`${period}:00:00Z`);}
return new Date(`${period}:00Z`);
}
@@ -33,14 +33,14 @@
const day = pad(d.getUTCDate());
const h = pad(d.getUTCHours());
const min = pad(d.getUTCMinutes());
if (g === 'day') return `${y}-${m}-${day}`;
if (g === 'hour') return `${y}-${m}-${day}T${h}`;
if (g === 'day') {return `${y}-${m}-${day}`;}
if (g === 'hour') {return `${y}-${m}-${day}T${h}`;}
return `${y}-${m}-${day}T${h}:${min}`;
}
function stepMs(g: Granularity): number {
if (g === 'day') return 24 * 60 * 60 * 1000;
if (g === 'hour') return 60 * 60 * 1000;
if (g === 'day') {return 24 * 60 * 60 * 1000;}
if (g === 'hour') {return 60 * 60 * 1000;}
return 60 * 1000;
}
@@ -50,10 +50,10 @@
* runs up to the present.
*/
function fillGaps(points: TimelineDataPoint[], g: Granularity): TimelineDataPoint[] {
if (points.length === 0) return [];
if (points.length === 0) {return [];}
const step = stepMs(g);
const firstReal = parsePeriod(points[0].period, g).getTime();
const lastReal = parsePeriod(points[points.length - 1].period, g).getTime();
const lastReal = parsePeriod(points.at(-1).period, g).getTime();
// Round "now" down to the current bucket boundary, UTC-aligned to the UNIX
// epoch — matches how SQLite groups periods via substr on the ISO timestamp.
@@ -67,7 +67,7 @@
const head = span > maxPoints ? tail - (maxPoints - 1) * step : firstReal;
const byKey: Record<string, TimelineDataPoint> = {};
for (const p of points) byKey[p.period] = p;
for (const p of points) {byKey[p.period] = p;}
const filled: TimelineDataPoint[] = [];
for (let t = head; t <= tail; t += step) {
@@ -83,13 +83,13 @@
function tickLabel(period: string, g: Granularity): string {
const d = parsePeriod(period, g);
const pad = (n: number) => n.toString().padStart(2, '0');
if (g === 'minute') return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
if (g === 'hour') return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}h`;
if (g === 'minute') {return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;}
if (g === 'hour') {return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}h`;}
return `${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
}
$effect(() => {
if (!canvas || filledData.length === 0) return;
if (!canvas || filledData.length === 0) {return;}
const { isDark, textColor, gridColor } = getChartColors();
const startedColor = isDark ? 'rgb(96, 165, 250)' : 'rgb(59, 130, 246)';
@@ -259,7 +259,7 @@ describe('computeTextStats', () => {
it('counts blank, short and long buckets', () => {
const answers: AnswerData[] = [
{ value: ' ', count: 2 }, // blank
{ value: ' '.repeat(3), count: 2 }, // blank
{ value: 'hi', count: 1 }, // short (<10)
{ value: 'a'.repeat(50), count: 1 }, // medium (not counted)
{ value: 'a'.repeat(150), count: 3 }, // long (>100)
@@ -493,7 +493,7 @@ describe('bucketNumbers', () => {
it('includes the maximum value in the last bucket', () => {
const values = Array.from({ length: 100 }, (_, i) => i);
const buckets = bucketNumbers(values);
const last = buckets[buckets.length - 1];
const last = buckets.at(-1);
expect(last.rangeEnd).toBe(99);
expect(last.count).toBeGreaterThan(0);
});
@@ -22,11 +22,11 @@ export function computeNps(answers: AnswerData[]): NpsStats {
detractors = 0;
for (const a of answers) {
const score = Number(a.value);
if (Number.isNaN(score)) continue;
if (Number.isNaN(score)) {continue;}
total += a.count;
if (score >= 9) promoters += a.count;
else if (score >= 7) passives += a.count;
else detractors += a.count;
if (score >= 9) {promoters += a.count;}
else if (score >= 7) {passives += a.count;}
else {detractors += a.count;}
}
const npsScore = total > 0 ? Math.round(((promoters - detractors) / total) * 100) : null;
const pPct = total > 0 ? (promoters / total) * 100 : 0;
@@ -36,10 +36,10 @@ export function computeNps(answers: AnswerData[]): NpsStats {
}
export function npsLabel(npsScore: number | null): string {
if (npsScore === null) return '';
if (npsScore >= 50) return 'Excellent';
if (npsScore >= 0) return 'Good';
if (npsScore >= -50) return 'Needs improvement';
if (npsScore === null) {return '';}
if (npsScore >= 50) {return 'Excellent';}
if (npsScore >= 0) {return 'Good';}
if (npsScore >= -50) {return 'Needs improvement';}
return 'Critical';
}
@@ -47,7 +47,7 @@ export function npsDistribution(answers: AnswerData[]): Array<{ score: number; c
const dist = Array.from({ length: 11 }, (_, i) => ({ score: i, count: 0 }));
for (const a of answers) {
const n = Number(a.value);
if (Number.isInteger(n) && n >= 0 && n <= 10) dist[n].count += a.count;
if (Number.isInteger(n) && n >= 0 && n <= 10) {dist[n].count += a.count;}
}
return dist;
}
@@ -96,13 +96,13 @@ export interface LikertStats {
export function computeLikert(answers: AnswerData[]): LikertStats {
const counts: Record<string, number> = {};
for (const v of LIKERT_VALUES) counts[v] = 0;
for (const v of LIKERT_VALUES) {counts[v] = 0;}
let total = 0;
let sum = 0;
for (const a of answers) {
const idx = LIKERT_VALUES.indexOf(a.value as (typeof LIKERT_VALUES)[number]);
if (idx >= 0) {
if (idx !== -1) {
counts[a.value] += a.count;
total += a.count;
sum += (idx + 1) * a.count;
@@ -139,7 +139,7 @@ export function computeNumber(answers: AnswerData[]): NumberStats {
for (const a of answers) {
const n = Number(a.value);
if (Number.isFinite(n)) {
for (let i = 0; i < a.count; i++) values.push(n);
for (let i = 0; i < a.count; i++) {values.push(n);}
}
}
values.sort((a, b) => a - b);
@@ -160,7 +160,9 @@ export function computeNumber(answers: AnswerData[]): NumberStats {
};
}
/** Auto-bucket numeric values into ~10 bins using Sturges-like rules. */
/**
Auto-bucket numeric values into ~10 bins using Sturges-like rules.
*/
export interface NumberBucket {
label: string;
rangeStart: number;
@@ -169,9 +171,9 @@ export interface NumberBucket {
}
export function bucketNumbers(values: number[], maxBuckets = 10): NumberBucket[] {
if (values.length === 0) return [];
if (values.length === 0) {return [];}
const min = values[0];
const max = values[values.length - 1];
const max = values.at(-1);
if (min === max) {
return [{ label: `${min}`, rangeStart: min, rangeEnd: min, count: values.length }];
}
@@ -199,18 +201,14 @@ export function bucketNumbers(values: number[], maxBuckets = 10): NumberBucket[]
for (const v of values) {
// last bucket is inclusive on both ends
let idx: number;
if (v === max) {
idx = bucketCount - 1;
} else {
idx = Math.min(bucketCount - 1, Math.floor((v - min) / bucketSize));
}
idx = v === max ? bucketCount - 1 : Math.min(bucketCount - 1, Math.floor((v - min) / bucketSize));
buckets[idx].count++;
}
return buckets;
}
function formatNumber(n: number): string {
if (Number.isInteger(n)) return String(n);
if (Number.isInteger(n)) {return String(n);}
return n.toFixed(1);
}
@@ -319,7 +317,7 @@ const STOPWORDS = new Set([
function tokenize(text: string): string[] {
return text
.toLowerCase()
.replace(/[^\w\s'-]/g, ' ')
.replaceAll(/[^\w\s'-]/g, ' ')
.split(/\s+/)
.filter((w) => w.length > 1 && !STOPWORDS.has(w));
}
@@ -329,7 +327,9 @@ export interface NgramEntry {
count: number;
}
/** N-grams rather than a word cloud: counts stay explicit and phrases are preserved. */
/**
N-grams rather than a word cloud: counts stay explicit and phrases are preserved.
*/
export function computeNgrams(answers: AnswerData[], limit = 20): NgramEntry[] {
const phrases = new Map<string, number>();
@@ -351,7 +351,7 @@ export function computeNgrams(answers: AnswerData[], limit = 20): NgramEntry[] {
}
}
return [...phrases.entries()]
return [...phrases]
.map(([phrase, count]) => ({ phrase, count }))
.filter((e) => e.count >= 2 || e.phrase.split(' ').length === 1)
.sort((a, b) => b.count - a.count)
@@ -377,9 +377,9 @@ export function computeTextStats(answers: AnswerData[]): TextStats {
const trimmed = a.value.trim();
total += a.count;
totalChars += trimmed.length * a.count;
if (trimmed.length === 0) blankCount += a.count;
else if (trimmed.length < 10) shortCount += a.count;
else if (trimmed.length > 100) longCount += a.count;
if (trimmed.length === 0) {blankCount += a.count;}
else if (trimmed.length < 10) {shortCount += a.count;}
else if (trimmed.length > 100) {longCount += a.count;}
}
return {
total,
@@ -497,33 +497,37 @@ export type EmailKind = 'corporate' | 'free' | 'disposable';
export function normalizeEmail(raw: string): { normalized: string; local: string; domain: string } | null {
const trimmed = raw.trim().toLowerCase();
const at = trimmed.lastIndexOf('@');
if (at <= 0 || at >= trimmed.length - 1) return null;
if (at <= 0 || at >= trimmed.length - 1) {return null;}
let local = trimmed.slice(0, at);
const domain = trimmed.slice(at + 1);
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return null;
if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) {return null;}
if (domain === 'gmail.com' || domain === 'googlemail.com') {
const plus = local.indexOf('+');
if (plus >= 0) local = local.slice(0, plus);
if (plus !== -1) {local = local.slice(0, plus);}
local = local.replaceAll('.', '');
}
return { normalized: `${local}@${domain}`, local, domain };
}
export function classifyDomain(domain: string): EmailKind {
if (DISPOSABLE_EMAIL_DOMAINS.has(domain)) return 'disposable';
if (FREE_EMAIL_DOMAINS.has(domain)) return 'free';
if (DISPOSABLE_EMAIL_DOMAINS.has(domain)) {return 'disposable';}
if (FREE_EMAIL_DOMAINS.has(domain)) {return 'free';}
return 'corporate';
}
export interface EmailEntry {
/** Original casing as submitted (first seen). */
/**
Original casing as submitted (first seen).
*/
raw: string;
normalized: string;
local: string;
domain: string;
kind: EmailKind;
isRoleBased: boolean;
/** How many respondents submitted this (deduped) address. */
/**
How many respondents submitted this (deduped) address.
*/
count: number;
}
@@ -580,13 +584,13 @@ export function computeEmailSummary(answers: AnswerData[]): EmailSummary {
let corporateCount = 0;
let roleBasedCount = 0;
for (const e of entries) {
if (e.kind === 'disposable') disposableCount += e.count;
else if (e.kind === 'free') freeCount += e.count;
else corporateCount += e.count;
if (e.isRoleBased) roleBasedCount += e.count;
if (e.kind === 'disposable') {disposableCount += e.count;}
else if (e.kind === 'free') {freeCount += e.count;}
else {corporateCount += e.count;}
if (e.isRoleBased) {roleBasedCount += e.count;}
}
const topDomains = [...domains.entries()]
const topDomains = [...domains]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([domain, count]) => ({ domain, count, kind: classifyDomain(domain) }));
@@ -658,7 +662,7 @@ export function computeCheckboxStats(answers: AnswerData[]): CheckboxStats {
}
}
const perOptionArr = [...perOption.entries()]
const perOptionArr = [...perOption]
.map(([value, count]) => ({
value,
count,
@@ -675,7 +679,7 @@ export function computeCheckboxStats(answers: AnswerData[]): CheckboxStats {
}
export function computeDropoffRate(reached: number, answered: number): number {
if (reached === 0) return 0;
if (reached === 0) {return 0;}
return Math.round(((reached - answered) / reached) * 100);
}
@@ -1,5 +1,5 @@
export function getChartColors() {
const isDark = typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches;
const isDark = typeof window !== 'undefined' && globalThis.matchMedia('(prefers-color-scheme: dark)').matches;
return {
textColor: isDark ? 'rgb(156, 163, 175)' : 'rgb(107, 114, 128)',
gridColor: isDark ? 'rgb(55, 65, 81)' : 'rgb(229, 231, 235)',
@@ -32,7 +32,7 @@
href={twitterUrl}
target="_blank"
rel="noopener noreferrer"
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
title="Share on X / Twitter"
>
<Icon icon={mdiTwitter} size="16" />
@@ -41,20 +41,20 @@
href={linkedinUrl}
target="_blank"
rel="noopener noreferrer"
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
title="Share on LinkedIn"
>
<Icon icon={mdiLinkedin} size="16" />
</a>
<a
href={emailUrl}
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
title="Share via email"
>
<Icon icon={mdiEmail} size="16" />
</a>
<button
class="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
class="rounded-sm p-1.5 text-gray-400 transition-colors hover:bg-gray-800 hover:text-gray-200"
onclick={copyLink}
title="Copy link"
>
@@ -8,8 +8,8 @@
let { slug }: Props = $props();
async function startOver() {
await fetch(`/api/s/${slug}/reset`, { method: 'POST', credentials: 'same-origin' });
window.location.reload();
await fetch(`/api/s/${slug}/reset`, { method: 'POST'});
location.reload();
}
</script>
@@ -48,7 +48,7 @@
aria-checked={selectedValues.has(option.value)}
>
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md {selectedValues.has(option.value)
class="flex size-6 shrink-0 items-center justify-center rounded-md {selectedValues.has(option.value)
? 'bg-immich-primary text-white'
: 'border-2 border-gray-300 dark:border-gray-500'}"
>
@@ -68,7 +68,7 @@
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
>
<button
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md {selectedValues.has('Other')
class="flex size-6 shrink-0 items-center justify-center rounded-md {selectedValues.has('Other')
? 'bg-immich-primary text-white'
: 'border-2 border-gray-300 dark:border-gray-500'}"
onclick={() => toggle('Other')}
@@ -83,11 +83,11 @@
placeholder={question.otherPrompt ?? 'Other...'}
bind:value={otherText}
onfocus={() => {
if (!selectedValues.has('Other')) toggle('Other');
if (!selectedValues.has('Other')) {toggle('Other');}
}}
oninput={() => {
const vals = [...selectedValues];
if (!vals.includes('Other')) vals.push('Other');
if (!vals.includes('Other')) {vals.push('Other');}
onAnswer(vals.join(','), otherText);
}}
/>
@@ -22,7 +22,7 @@
<div class="flex flex-col gap-2 sm:flex-row sm:gap-1.5">
{#each labels as label (label)}
<button
class="flex-1 rounded-lg border-2 px-3 py-3 text-sm font-medium transition-all duration-150
class="flex-1 rounded-lg border-2 p-3 text-sm font-medium transition-all duration-150
{answer?.value === label
? 'border-immich-primary bg-immich-primary-10 text-immich-primary'
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
@@ -10,17 +10,17 @@
let { question, answer, onAnswer }: Props = $props();
const selectedValue = $derived(answer?.value !== undefined ? Number(answer.value) : -1);
const selectedValue = $derived(answer?.value === undefined ? -1 : Number(answer.value));
function getColor(n: number, isSelected: boolean): string {
if (isSelected) {
if (n <= 6) return 'bg-red-500 text-white border-red-500';
if (n <= 8) return 'bg-amber-500 text-white border-amber-500';
if (n <= 6) {return 'bg-red-500 text-white border-red-500';}
if (n <= 8) {return 'bg-amber-500 text-white border-amber-500';}
return 'bg-green-500 text-white border-green-500';
}
if (n <= 6) return 'border-red-300 text-red-400 hover:bg-red-500/10 dark:border-red-700 dark:text-red-400';
if (n <= 6) {return 'border-red-300 text-red-400 hover:bg-red-500/10 dark:border-red-700 dark:text-red-400';}
if (n <= 8)
return 'border-amber-300 text-amber-500 hover:bg-amber-500/10 dark:border-amber-600 dark:text-amber-400';
{return 'border-amber-300 text-amber-500 hover:bg-amber-500/10 dark:border-amber-600 dark:text-amber-400';}
return 'border-green-300 text-green-500 hover:bg-green-500/10 dark:border-green-700 dark:text-green-400';
}
</script>
@@ -17,14 +17,14 @@
const max = $derived(question.config?.max);
const hasHint = $derived(min !== undefined || max !== undefined);
const hintText = $derived(
[min !== undefined ? `Min: ${min}` : '', max !== undefined ? `Max: ${max}` : ''].filter(Boolean).join(', '),
[min === undefined ? '' : `Min: ${min}`, max === undefined ? '' : `Max: ${max}`].filter(Boolean).join(', '),
);
// `bind:value` on a type="number" input yields a number, but answers are
// strings end to end (validateAnswer/server call value.trim()). Coerce here so
// a number never leaks into the answer pipeline.
const { handleInput } = useDebouncedAnswer(
() => String(numberValue ?? ''),
() => (numberValue ?? ''),
(v) => onAnswer(v),
);
</script>
@@ -25,13 +25,13 @@
async function handleSubmit(e: Event) {
e.preventDefault();
if (!password.trim()) return;
if (!password.trim()) {return;}
submitting = true;
error = null;
try {
await onSubmit(password);
} catch (err) {
error = err instanceof Error ? err.message : 'Incorrect password';
} catch (error_) {
error = error_ instanceof Error ? error_.message : 'Incorrect password';
}
submitting = false;
}
@@ -46,7 +46,7 @@
>
<div class="w-full max-w-sm">
<div class="mb-6 flex flex-col items-center gap-3 text-center">
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-gray-800">
<div class="flex size-14 items-center justify-center rounded-full bg-gray-800">
<Icon icon={mdiLock} size="28" class="text-gray-400" />
</div>
{#if surveyTitle}
@@ -37,7 +37,7 @@
let autoNextTimer: ReturnType<typeof setTimeout> | undefined;
function handleRadioAnswer(value: string, otherText?: string) {
if (!question) return;
if (!question) {return;}
onAnswer(value, otherText);
validationError = null;
if (value !== 'Other' || !question.hasOther) {
@@ -52,7 +52,7 @@
}
async function handleNext() {
if (!question) return;
if (!question) {return;}
// Enter/Next can fire inside the 300ms debounce window: flush first and let
// the value reach the `answer` prop, or we validate the stale one.
preFlush?.flushPending?.();
@@ -70,10 +70,10 @@
const TAGS_WITH_NATIVE_ENTER_BEHAVIOUR = new Set(['TEXTAREA', 'BUTTON', 'A']);
function handleKeydown(e: KeyboardEvent) {
if (e.key !== 'Enter' || e.shiftKey) return;
if (e.key !== 'Enter' || e.shiftKey) {return;}
const target = e.target as HTMLElement | null;
if (!target) return;
if (TAGS_WITH_NATIVE_ENTER_BEHAVIOUR.has(target.tagName)) return;
if (!target) {return;}
if (TAGS_WITH_NATIVE_ENTER_BEHAVIOUR.has(target.tagName)) {return;}
e.preventDefault();
handleNext();
}
@@ -127,7 +127,7 @@
</div>
<div
class="bg-light fixed bottom-0 left-0 z-40 flex w-full justify-center border-t border-gray-200 px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:pt-4 dark:border-gray-800"
class="fixed bottom-0 left-0 z-40 flex w-full justify-center border-t border-gray-200 bg-light px-4 pt-3 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-6 sm:pt-4 dark:border-gray-800"
>
<div class="flex w-full max-w-[640px] items-center gap-3">
{#if canGoBack}
@@ -24,11 +24,11 @@
}
function handleKeydown(e: KeyboardEvent) {
if (!['ArrowUp', 'ArrowDown'].includes(e.key)) return;
if (!['ArrowUp', 'ArrowDown'].includes(e.key)) {return;}
e.preventDefault();
const allValues = visibleOptions.map((o) => o.value);
if (question.hasOther) allValues.push('Other');
if (question.hasOther) {allValues.push('Other');}
const currentIdx = answer?.value ? allValues.indexOf(answer.value) : -1;
let nextIdx: number;
@@ -59,7 +59,7 @@
aria-checked={answer?.value === option.value}
>
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full {answer?.value === option.value
class="flex size-6 shrink-0 items-center justify-center rounded-full {answer?.value === option.value
? 'bg-immich-primary text-white'
: 'border-2 border-gray-300 dark:border-gray-500'}"
>
@@ -79,7 +79,7 @@
: 'border-gray-200 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-400'}"
>
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full {answer?.value === 'Other'
class="flex size-6 shrink-0 items-center justify-center rounded-full {answer?.value === 'Other'
? 'bg-immich-primary text-white'
: 'border-2 border-gray-300 dark:border-gray-500'}"
>
@@ -17,7 +17,7 @@
</script>
{#if loader.error && loader.engine?.currentQuestion}
<div class="fixed top-2 left-1/2 z-[100] w-full max-w-lg -translate-x-1/2 px-4">
<div class="fixed top-2 left-1/2 z-100 w-full max-w-lg -translate-x-1/2 px-4">
<div class="flex items-center justify-between gap-3 rounded-lg bg-red-600 px-4 py-3 text-sm text-white shadow-lg">
<p>{loader.error}</p>
<button onclick={() => loader.dismissError()} class="shrink-0 font-semibold hover:underline">Dismiss</button>
@@ -30,7 +30,7 @@
{#if embedded}
<p class="text-gray-400">Loading...</p>
{:else}
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-600 border-t-blue-400"></div>
<div class="size-8 animate-spin rounded-full border-2 border-gray-600 border-t-blue-400"></div>
<p class="text-sm text-gray-400">Loading survey...</p>
{/if}
</div>
@@ -41,7 +41,7 @@
<div class="text-center">
<p class="text-lg text-red-400">{loader.error}</p>
{#if showReloadOnError}
<button class="mt-4 text-sm text-gray-400 hover:underline" onclick={() => window.location.reload()}>
<button class="mt-4 text-sm text-gray-400 hover:underline" onclick={() => location.reload()}>
Try again
</button>
{/if}
@@ -69,7 +69,7 @@
<div class="text-center">
<p class="text-gray-400">Something went wrong loading this survey.</p>
{#if showReloadOnError}
<button class="mt-4 text-sm text-blue-400 hover:underline" onclick={() => window.location.reload()}>
<button class="mt-4 text-sm text-blue-400 hover:underline" onclick={() => location.reload()}>
Try again
</button>
{/if}
@@ -27,27 +27,27 @@
// Resuming mid-survey: every section up to the current question counts as
// already seen, so its header isn't re-shown. A fresh start yields an empty set.
function buildInitialSeenSections(): string[] {
if (engine.currentIndex === 0 && Object.keys(engine.answers).length === 0) return [];
if (engine.currentIndex === 0 && Object.keys(engine.answers).length === 0) {return [];}
const currentQ = engine.currentQuestion;
if (!currentQ) return [];
if (!currentQ) {return [];}
const sorted = [...sections].sort((a, b) => a.sortOrder - b.sortOrder);
const currentSection = sorted.find((s) => s.id === currentQ.section_id);
if (!currentSection) return [];
if (!currentSection) {return [];}
return sorted.filter((s) => s.sortOrder <= currentSection.sortOrder).map((s) => s.id);
}
let seenSectionIds = new SvelteSet<string>(buildInitialSeenSections());
const reducedMotion = typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const reducedMotion = typeof window !== 'undefined' && globalThis.matchMedia('(prefers-reduced-motion: reduce)').matches;
const flyDuration = reducedMotion ? 0 : 300;
const sortedSections = $derived([...sections].sort((a, b) => a.sortOrder - b.sortOrder));
const currentSectionHeader = $derived.by(() => {
const q = engine.currentQuestion;
if (!q) return null;
if (!q) {return null;}
const section = sortedSections.find((s) => s.id === q.section_id);
if (!section) return null;
if (seenSectionIds.has(section.id) || dismissedSections.has(section.id)) return null;
if (!section) {return null;}
if (seenSectionIds.has(section.id) || dismissedSections.has(section.id)) {return null;}
return section;
});
@@ -63,7 +63,7 @@
}
function handleNext() {
if (transitioning) return;
if (transitioning) {return;}
transitioning = true;
direction = 1;
@@ -88,7 +88,7 @@
}
function handleBack() {
if (transitioning) return;
if (transitioning) {return;}
transitioning = true;
direction = -1;
@@ -96,7 +96,6 @@
if (showingSectionHeader && backHeader) {
dismissedSections.delete(backHeader.id);
seenSectionIds.delete(backHeader.id);
engine.previous();
} else {
const currentQ = engine.currentQuestion;
if (currentQ) {
@@ -105,8 +104,8 @@
seenSectionIds.delete(section.id);
}
}
engine.previous();
}
engine.previous();
announce(`Question ${engine.currentIndex + 1} of ${engine.totalQuestions}`);
window.scrollTo(0, 0);

Some files were not shown because too many files have changed in this diff Show More