worldwide todo list: public todo list of 68 real, sourced problems (planet to street) at /projects/worldwide-todo. worldtodo schema (categories, tasks, change_requests), idempotent seed on deploy, public add/edit/delete requests with Turnstile + salted daily-rotating IP-hash rate limit, owner-only review queue (approve, edit & approve, reject) with live updates over /ws/worldtodo(-owner), JSON API, detail pages with OSM map and history; listed in projects, header, /start and sitemap
Some checks failed
Deploy production / deploy (push) Failing after 9s

This commit is contained in:
2026-10-26 01:25:59 +00:00
parent 0ee981a96b
commit 5e812271e1
58 changed files with 47689 additions and 9 deletions

View File

@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Worldwide todo list">
<defs>
<linearGradient id="wt-sea" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#38bdf8"/>
<stop offset="1" stop-color="#2563eb"/>
</linearGradient>
<linearGradient id="wt-land" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#4ade80"/>
<stop offset="1" stop-color="#16a34a"/>
</linearGradient>
<linearGradient id="wt-check" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#fbbf24"/>
<stop offset="1" stop-color="#ea580c"/>
</linearGradient>
<clipPath id="wt-globe"><circle cx="28" cy="30" r="22"/></clipPath>
</defs>
<circle cx="28" cy="30" r="22" fill="url(#wt-sea)"/>
<g clip-path="url(#wt-globe)" fill="url(#wt-land)">
<path d="M10 18c5-4 11-5 15-3 3 2 1 6-2 7-3 1-3 5 0 7 2 2 1 6-3 6-4 0-7-3-9-7-2-3-3-7-1-10z"/>
<path d="M33 11c5 0 10 2 13 6 1 2-1 4-4 4-3 0-5 2-4 5 1 3 5 3 7 5 2 3 0 8-4 10-3 2-6 0-6-3 0-3-3-4-4-7-1-3 2-5 1-8-1-3-4-4-3-7 1-3 2-5 4-5z"/>
</g>
<circle cx="28" cy="30" r="22" fill="none" stroke="#0f172a" stroke-opacity=".25" stroke-width="1.5"/>
<rect x="34" y="34" width="26" height="26" rx="7" fill="url(#wt-check)" stroke="#fff" stroke-width="2.5"/>
<path d="M40.5 47.5l4.5 4.5 9-10" fill="none" stroke="#fff" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -21,6 +21,7 @@
import Globe2 from 'lucide-svelte/icons/globe-2';
import Languages from 'lucide-svelte/icons/languages';
import ListChecks from 'lucide-svelte/icons/list-checks';
import ListTodo from 'lucide-svelte/icons/list-todo';
import Music2 from 'lucide-svelte/icons/music-2';
import PawPrint from 'lucide-svelte/icons/paw-print';
import Trophy from 'lucide-svelte/icons/trophy';
@@ -40,6 +41,7 @@
'cs prices': CandlestickChart,
'house tracker': House,
lists: ListChecks,
'worldwide todo list': ListTodo,
health: HeartPulse,
books: BookOpen,
stats: ChartLine,

View File

@@ -6,6 +6,13 @@ export const projects = [
href: 'https://esports.vash.software',
external: true
},
{
name: 'worldwide todo list',
subtitle:
'real problems, planet to street, as a public todo list anyone can suggest changes to',
href: '/projects/worldwide-todo',
external: false
},
{
name: 'osu! map finder',
subtitle: 'find osu! maps for Spotify songs/albums/playlists',

View File

@@ -95,6 +95,11 @@ const publicPages: Record<string, { title: string; description: string }> = {
title: 'Pokémon locations',
description: 'Every Pokémon location by region, with the Pokémon you can find there.'
},
'/projects/worldwide-todo': {
title: 'The worldwide todo list',
description:
'A public todo list of real, sourced problems, from planet-scale to a single street. Anyone can suggest additions, edits and removals.'
},
'/projects/animaldex': {
title: 'Animaldex',
description: 'Find and add neighborhood animals on a map.'

View File

@@ -30,4 +30,5 @@ export * from './stats';
export * from './pubg-map';
export * from './ai';
export * from './movies';
export * from './worldtodo';
export * from './osu-goals';

View File

@@ -27,3 +27,4 @@ export const dataholdersSchema = pgSchema('dataholders');
export const statsSchema = pgSchema('stats');
export const aiSchema = pgSchema('ai');
export const moviesSchema = pgSchema('movies');
export const worldtodoSchema = pgSchema('worldtodo');

View File

@@ -0,0 +1,124 @@
import { sql } from 'drizzle-orm';
import {
bigint,
check,
customType,
date,
doublePrecision,
index,
jsonb,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { worldtodoSchema } from './schemas';
import { timestamptz } from './helpers';
// The Worldwide Todo List (/projects/worldwide-todo): a public list of real problems phrased
// as todos. Anyone can *request* an add, edit or delete (change_requests); nothing changes
// until the owner approves it in the queue. No submitter data is stored beyond an optional
// display name: no IP, email or user agent (rate limiting is in memory, see
// #lib/server/worldtodo/limit). Seeded by scripts/seed-worldtodo.ts from data/worldtodo-seed.json.
// Kept in sync with #lib/worldtodo/shared (a unit test checks it); the schema folder is copied
// into the app image on its own for the scripts, so it must not import from outside it.
const WORLDTODO_SCALES = ['planet', 'region', 'country', 'city', 'street'] as const;
const WORLDTODO_STATUSES = ['open', 'in_progress', 'done'] as const;
const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
const tsvector = customType<{ data: string }>({ dataType: () => 'tsvector' });
export const worldtodoScale = worldtodoSchema.enum('scale', WORLDTODO_SCALES);
export const worldtodoStatus = worldtodoSchema.enum('status', WORLDTODO_STATUSES);
export const worldtodoRequestKind = worldtodoSchema.enum('req_kind', WORLDTODO_REQUEST_KINDS);
export const worldtodoRequestStatus = worldtodoSchema.enum(
'req_status',
WORLDTODO_REQUEST_STATUSES
);
export const worldtodoCategories = worldtodoSchema.table('categories', {
slug: text('slug').primaryKey(),
label: text('label').notNull(),
/** Icon key in #lib/worldtodo/icons (a lucide icon). */
icon: text('icon').notNull(),
/** Hex accent for the icon tile only. */
colour: text('colour').notNull()
});
export const worldtodoTasks = worldtodoSchema.table(
'tasks',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
slug: text('slug').notNull(),
title: text('title').notNull(),
description: text('description').notNull(),
scale: worldtodoScale('scale').notNull(),
category: text('category')
.notNull()
.references(() => worldtodoCategories.slug),
locationName: text('location_name').notNull(),
/** ISO 3166-1 alpha-2; null for planet-scale or multinational tasks. */
countryCode: text('country_code'),
lat: doublePrecision('lat'),
lon: doublePrecision('lon'),
status: worldtodoStatus('status').notNull().default('open'),
sourceUrls: text('source_urls').array().notNull(),
/** Date of the main source as given ('2026-09', '2026-10-21'). */
sourceDate: text('source_date'),
confidence: text('confidence').notNull().default('high'),
/** Owner-facing caveat, also shown subtly on the detail page. */
verificationNote: text('verification_note'),
/** When the facts were last checked. */
asOf: date('as_of').notNull(),
createdAt: timestamptz('created_at').notNull().defaultNow(),
updatedAt: timestamptz('updated_at').notNull().defaultNow(),
/** Soft delete (approved delete requests); hard purge is a manual owner action. */
deletedAt: timestamptz('deleted_at'),
search: tsvector('search').generatedAlwaysAs(
sql`to_tsvector('simple', title || ' ' || description || ' ' || location_name)`
)
},
(t) => [
uniqueIndex('tasks_slug_key').on(t.slug),
index('tasks_search_idx').using('gin', t.search),
index('tasks_scale_status_idx')
.on(t.scale, t.status)
.where(sql`deleted_at IS NULL`),
check('tasks_title_check', sql`char_length(${t.title}) BETWEEN 5 AND 140`),
check('tasks_description_check', sql`char_length(${t.description}) <= 2000`),
check('tasks_lat_check', sql`${t.lat} BETWEEN -90 AND 90`),
check('tasks_lon_check', sql`${t.lon} BETWEEN -180 AND 180`),
check('tasks_sources_check', sql`cardinality(${t.sourceUrls}) >= 1`),
check('tasks_confidence_check', sql`${t.confidence} IN ('high','medium')`)
]
);
export const worldtodoChangeRequests = worldtodoSchema.table(
'change_requests',
{
id: bigint('id', { mode: 'number' }).primaryKey().generatedByDefaultAsIdentity(),
kind: worldtodoRequestKind('kind').notNull(),
/** Null for 'add'. */
taskId: bigint('task_id', { mode: 'number' }).references(() => worldtodoTasks.id),
/** add: full task fields; edit: only changed fields; delete: {}. Whitelisted keys only. */
payload: jsonb('payload').$type<Record<string, unknown>>().notNull().default({}),
/** What the owner actually applied (differs from payload after "edit & approve"). */
applied: jsonb('applied').$type<Record<string, unknown>>(),
/** The task's updated_at when the request was filed, to flag stale requests. */
taskUpdatedAt: timestamptz('task_updated_at'),
reason: text('reason').notNull(),
/** Optional, the ONLY submitter info stored; shown nowhere except the owner queue. */
displayName: text('display_name'),
status: worldtodoRequestStatus('status').notNull().default('pending'),
ownerNote: text('owner_note'),
createdAt: timestamptz('created_at').notNull().defaultNow(),
decidedAt: timestamptz('decided_at')
},
(t) => [
index('change_requests_status_idx').on(t.status, t.createdAt),
index('change_requests_task_idx').on(t.taskId, t.status),
check('change_requests_reason_check', sql`char_length(${t.reason}) BETWEEN 10 AND 1000`),
check('change_requests_display_name_check', sql`char_length(${t.displayName}) <= 40`),
check('change_requests_task_check', sql`((${t.kind} = 'add') = (${t.taskId} IS NULL))`)
]
);

View File

@@ -32,7 +32,10 @@ export const ownerOnlyPrefixes = [
'/trackers/ai/usage',
'/lists/db',
// Spotify OAuth for the listening tracker (connect + callback 404 for everyone else).
'/api/spotify'
'/api/spotify',
// Worldwide todo list: the list is public, the review queue and its API are mine only.
'/projects/worldwide-todo/queue',
'/projects/worldwide-todo/api/queue'
] as const;
/**

View File

@@ -0,0 +1,33 @@
import type { TaskFields } from '#lib/worldtodo/shared.js';
import { publishQueue, publishTask } from './live';
import * as store from './store';
import { plain } from './validate';
// Owner decisions (queue actions, API, direct edits). Applied in one transaction in the
// store, then published on the realtime channels.
export const ownerNote = (v: unknown) =>
plain(typeof v === 'string' ? v : '', true).slice(0, 1000) || null;
async function publishApplied(applied: store.Applied) {
const found = applied.type === 'task.deleted' ? null : await store.getTask(applied.slug);
publishTask(applied.type, applied.slug, found?.task ?? null);
publishQueue(await store.pendingTotal());
}
export async function approveRequest(id: number, note: unknown, tweaked: TaskFields | null = null) {
const applied = await store.approve(id, ownerNote(note), tweaked);
await publishApplied(applied);
return applied;
}
export async function rejectRequest(id: number, note: unknown) {
await store.reject(id, ownerNote(note));
publishQueue(await store.pendingTotal());
}
export async function editTaskDirectly(slug: string, fields: TaskFields, note: unknown) {
const applied = await store.ownerEdit(slug, fields, ownerNote(note));
await publishApplied(applied);
return applied;
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest';
import { PER_DAY, PER_HOUR, createLimiter } from './limit';
describe('worldtodo rate limiter', () => {
it('allows 5 per hour, then answers with a Retry-After', () => {
let t = Date.UTC(2026, 9, 26, 10, 0, 0);
const l = createLimiter(() => t);
for (let i = 0; i < PER_HOUR; i++) expect(l.hit('1.2.3.4')).toBeNull();
const wait = l.hit('1.2.3.4');
expect(wait).toBeGreaterThan(3500);
expect(wait).toBeLessThanOrEqual(3600);
expect(l.hit('5.6.7.8')).toBeNull(); // other clients unaffected
t += 3_600_001;
expect(l.hit('1.2.3.4')).toBeNull();
});
it('caps at 20 per day', () => {
let t = Date.UTC(2026, 9, 26, 0, 30, 0);
const l = createLimiter(() => t);
for (let i = 0; i < PER_DAY; i++) {
expect(l.hit('9.9.9.9')).toBeNull();
t += 13 * 60_000; // ~4.6 per hour, under the hourly cap
}
expect(l.hit('9.9.9.9')).not.toBeNull();
});
it('never keeps the raw IP and rotates the salt every UTC day', () => {
let t = Date.UTC(2026, 9, 26, 23, 59, 0);
const l = createLimiter(() => t);
const k1 = l.keyOf('1.2.3.4');
expect(k1).not.toContain('1.2.3.4');
expect(l.keyOf('1.2.3.4')).toBe(k1);
t += 120_000; // next UTC day
expect(l.keyOf('1.2.3.4')).not.toBe(k1);
expect(l.size()).toBe(0);
});
});

View File

@@ -0,0 +1,66 @@
import { createHmac, randomBytes } from 'node:crypto';
// Rate limiting for public change requests: 5 per hour and 20 per day per client.
// The client key is an HMAC of the IP address with a random salt that lives only in this
// process's memory and rotates every UTC day, so a key can't be linked to an IP (or to
// yesterday's key) and nothing is ever written to Postgres or logs. Counters are per process
// and reset on restart, like the site's other limiters.
export const PER_HOUR = 5;
export const PER_DAY = 20;
const HOUR = 3_600_000;
const DAY = 86_400_000;
const MAX_KEYS = 50_000;
type State = { day: string; salt: Buffer; hits: Map<string, number[]> };
export function createLimiter(now: () => number = Date.now) {
let state: State | null = null;
const current = () => {
const day = new Date(now()).toISOString().slice(0, 10);
if (!state || state.day !== day) state = { day, salt: randomBytes(32), hits: new Map() };
return state;
};
const keyOf = (ip: string) =>
createHmac('sha256', current().salt).update(ip).digest('base64url').slice(0, 22);
return {
/** The salted hash for an IP (exposed for tests; never stored). */
keyOf,
/**
* Count one attempt. Returns null when allowed, or the seconds to wait (Retry-After).
* Blocked attempts are not counted.
*/
hit(ip: string): number | null {
const s = current();
const key = keyOf(ip);
const t = now();
const list = (s.hits.get(key) ?? []).filter((x) => t - x < DAY);
const lastHour = list.filter((x) => t - x < HOUR);
if (lastHour.length >= PER_HOUR)
return Math.max(1, Math.ceil((lastHour[0] + HOUR - t) / 1000));
if (list.length >= PER_DAY) return Math.max(1, Math.ceil((list[0] + DAY - t) / 1000));
list.push(t);
if (!s.hits.has(key) && s.hits.size >= MAX_KEYS) s.hits.delete(s.hits.keys().next().value!);
s.hits.set(key, list);
return null;
},
/** Number of tracked keys (tests). */
size: () => current().hits.size
};
}
export const limiter = createLimiter();
/** The client IP behind Cloudflare/Caddy, same order as server.js and the listening stream. */
export function clientIp(request: Request, getClientAddress: () => string): string {
const h = request.headers;
const forwarded =
h.get('cf-connecting-ip') ?? h.get('x-forwarded-for')?.split(',')[0]?.trim() ?? '';
if (forwarded) return forwarded;
try {
return getClientAddress();
} catch {
return 'unknown';
}
}

View File

@@ -0,0 +1,86 @@
import { registerChannel, type Socket } from '#lib/server/ws/hub.js';
import { auth } from '#lib/server/auth.js';
import { isOwner } from '#lib/server/owner.js';
import type { LiveEvent, PublicTask } from '#lib/worldtodo/shared.js';
// Realtime for the Worldwide Todo List on the site WebSocket hub:
// - `worldtodo` (public, read-only): every approved change, as
// { type: 'task.added' | 'task.updated' | 'task.deleted', slug, task | null, at }.
// - `worldtodo-owner` (owner session only): { type: 'queue.changed', pending } when a request
// is filed or decided, so the queue page and the header badge refresh without polling.
// Clients send nothing; any message closes the socket. Pages fall back to polling (60 s)
// when the socket can't connect (e.g. `vite dev`, which has no Bun.serve).
const MAX_PER_IP = 8;
const publicSockets = new Set<Socket>();
const ownerSockets = new Set<Socket>();
const perIp = new Map<string, number>();
function track(set: Set<Socket>) {
return {
open(ws: Socket) {
set.add(ws);
},
message(ws: Socket) {
ws.close(1008, 'read only');
},
close(ws: Socket) {
set.delete(ws);
const n = (perIp.get(ws.data.ip) ?? 1) - 1;
if (n <= 0) perIp.delete(ws.data.ip);
else perIp.set(ws.data.ip, n);
}
};
}
function admit(ip: string): Response | null {
const n = perIp.get(ip) ?? 0;
if (n >= MAX_PER_IP) return new Response('Too Many Connections', { status: 429 });
perIp.set(ip, n + 1);
return null;
}
let registered = false;
export function registerWorldtodoChannels() {
if (registered) return;
registered = true;
registerChannel('worldtodo', {
async authorize(_request, _url, ip) {
return admit(ip) ?? {};
},
...track(publicSockets)
});
registerChannel('worldtodo-owner', {
async authorize(request, _url, ip) {
const session = await auth.api.getSession({ headers: request.headers });
if (!isOwner({ locals: { user: session?.user ?? null } as App.Locals }))
return new Response('Not Found', { status: 404 });
return admit(ip) ?? {};
},
...track(ownerSockets)
});
}
function send(set: Set<Socket>, msg: unknown) {
const text = JSON.stringify(msg);
for (const ws of set) {
try {
ws.send(text);
} catch {
/* closing socket */
}
}
}
export function publishTask(type: LiveEvent['type'], slug: string, task: PublicTask | null) {
send(publicSockets, {
type,
slug,
task: type === 'task.deleted' ? null : task,
at: new Date().toISOString()
} satisfies LiveEvent);
}
export function publishQueue(pending: number) {
send(ownerSockets, { type: 'queue.changed', pending });
}

View File

@@ -0,0 +1,116 @@
import { changedFields, type TaskFields } from '#lib/worldtodo/shared.js';
import { limiter } from './limit';
import { publishQueue } from './live';
import * as store from './store';
import { turnstileConfigured, verifyTurnstile } from './turnstile';
import { looksLikeBot, parseRequest, type FieldErrors } from './validate';
// Filing a public change request (shared by the form action and the JSON API). Order:
// bot checks → validation (no network, so a typo never burns a Turnstile token or a rate
// slot) → rate limit (before Siteverify, so floods never reach Cloudflare) → global queue
// cap → Turnstile → insert. Nothing about the submitter is stored except the optional name.
export const MAX_PENDING = 200;
export type SubmitResult =
| { ok: true; id: number }
| { ok: false; status: number; message: string; errors?: FieldErrors; retryAfter?: number };
export async function submitRequest(opts: {
input: Record<string, unknown>;
honeypot: unknown;
startedAt: unknown;
token: string;
ip: string;
now?: number;
}): Promise<SubmitResult> {
if (!turnstileConfigured())
return { ok: false, status: 503, message: 'Requests are switched off right now.' };
if (looksLikeBot(opts.honeypot, opts.startedAt, opts.now))
return {
ok: false,
status: 400,
message: 'That was a bit quick. Wait a few seconds and send it again.'
};
// API edits may be partial, so validate them on top of the task as it is now.
let base: Record<string, unknown> | undefined;
if (opts.input.kind === 'edit' && typeof opts.input.task === 'string' && opts.input.task.trim()) {
const current = await store.getTask(opts.input.task.trim());
if (current) base = { ...store.currentFields(current.task) };
}
const parsed = parseRequest(opts.input, base);
if (!parsed.ok)
return {
ok: false,
status: 400,
message: 'Check the highlighted fields.',
errors: parsed.errors
};
const req = parsed.value;
let taskId: number | null = null;
let taskUpdatedAt: string | null = null;
let payload: Record<string, unknown> = {};
if (req.kind === 'add') {
if (!(await store.categoryExists(req.fields!.category)))
return {
ok: false,
status: 400,
message: 'Check the highlighted fields.',
errors: { category: 'Pick a category' }
};
payload = { ...req.fields! };
} else {
const found = await store.getTask(req.taskSlug);
if (!found) return { ok: false, status: 404, message: 'That task does not exist (anymore).' };
taskId = found.task.id;
taskUpdatedAt = found.task.updatedAt;
if (req.kind === 'edit') {
const change = changedFields(store.currentFields(found.task), req.fields as TaskFields);
if (!Object.keys(change).length)
return { ok: false, status: 400, message: 'Nothing changed: edit at least one field.' };
if (change.category && !(await store.categoryExists(change.category)))
return {
ok: false,
status: 400,
message: 'Check the highlighted fields.',
errors: { category: 'Pick a category' }
};
payload = change as Record<string, unknown>;
}
}
const wait = limiter.hit(opts.ip);
if (wait !== null)
return {
ok: false,
status: 429,
message: `You've sent a lot of requests. Try again in ${Math.ceil(wait / 60)} min.`,
retryAfter: wait
};
if ((await store.pendingTotal()) >= MAX_PENDING)
return {
ok: false,
status: 503,
message: 'The review queue is full right now. Please try again in a day or two.',
retryAfter: 86_400
};
if (!(await verifyTurnstile(opts.token)))
return {
ok: false,
status: 400,
message: 'The anti-bot check failed or expired. Try it again.',
errors: { turnstile: 'Complete the check' }
};
const row = await store.insertRequest({
kind: req.kind,
taskId,
taskUpdatedAt,
payload,
reason: req.reason,
displayName: req.displayName
});
publishQueue(await store.pendingTotal());
return { ok: true, id: row.id };
}

View File

@@ -0,0 +1,555 @@
import type { TransactionSql } from 'postgres';
// jsonb values are sent as JSON text with a ::jsonb cast: drizzle swaps the shared client's
// json serializers for pass-through ones, so sql.json() would send a raw object.
import { client as sql } from '#lib/server/db/index.js';
import {
EDITABLE_FIELDS,
RESERVED_SLUGS,
changedFields,
type Category,
type PublicTask,
type RequestKind,
type RequestStatus,
type TaskFields
} from '#lib/worldtodo/shared.js';
// Postgres access for the Worldwide Todo List (schema worldtodo). Hand-written SQL on the
// shared postgres.js pool; every write that applies a request runs in one transaction.
const TASK_COLUMNS = sql`
t.id, t.slug, t.title, t.description, t.scale::text as scale, t.category,
t.location_name, t.country_code, t.lat, t.lon, t.status::text as status, t.source_urls,
t.source_date, t.confidence, t.verification_note, t.as_of::text as as_of,
t.created_at, t.updated_at, t.deleted_at`;
type Row = Record<string, unknown>;
// The shared client returns timestamptz as Postgres text ("2026-10-26 01:13:36.1+00") because
// drizzle swaps in pass-through parsers, so normalise to ISO here.
function toDate(v: unknown): Date | null {
if (v instanceof Date) return v;
if (!v) return null;
const d = new Date(
String(v)
.replace(' ', 'T')
.replace(/([+-]\d\d)$/, '$1:00')
);
return Number.isNaN(d.getTime()) ? null : d;
}
const iso = (v: unknown) => toDate(v)?.toISOString() ?? '';
export function toTask(r: Row): PublicTask {
return {
id: Number(r.id),
slug: String(r.slug),
title: String(r.title),
description: String(r.description),
scale: r.scale as PublicTask['scale'],
category: String(r.category),
locationName: String(r.location_name),
countryCode: (r.country_code as string | null) ?? null,
lat: r.lat === null || r.lat === undefined ? null : Number(r.lat),
lon: r.lon === null || r.lon === undefined ? null : Number(r.lon),
status: r.status as PublicTask['status'],
sourceUrls: (r.source_urls as string[]) ?? [],
sourceDate: (r.source_date as string | null) ?? null,
confidence: r.confidence === 'medium' ? 'medium' : 'high',
verificationNote: (r.verification_note as string | null) ?? null,
asOf: String(r.as_of),
createdAt: iso(r.created_at),
updatedAt: iso(r.updated_at),
...(r.pending !== undefined ? { pending: Number(r.pending) } : {})
};
}
export async function listCategories(): Promise<Category[]> {
const rows = await sql`select slug, label, icon, colour from worldtodo.categories order by label`;
return rows.map((r) => ({
slug: r.slug,
label: r.label,
icon: r.icon,
colour: r.colour
}));
}
export type Sort = 'updated' | 'scale' | 'title';
export type ListFilter = {
scale?: string;
category?: string;
/** 'todo' (open + in progress, the default), 'open', 'in_progress', 'done' or 'all'. */
status?: string;
q?: string;
};
function likeEscape(s: string) {
return s.replace(/[\\%_]/g, (c) => `\\${c}`);
}
function whereFor(f: ListFilter, opts: { skipScale?: boolean } = {}) {
const parts = [sql`t.deleted_at is null`];
if (f.scale && !opts.skipScale) parts.push(sql`t.scale = ${f.scale}::worldtodo.scale`);
if (f.category) parts.push(sql`t.category = ${f.category}`);
const status = f.status || 'todo';
if (status === 'todo') parts.push(sql`t.status <> 'done'`);
else if (status !== 'all') parts.push(sql`t.status = ${status}::worldtodo.status`);
const q = (f.q ?? '').trim().slice(0, 100);
if (q) {
const like = `%${likeEscape(q)}%`;
parts.push(
sql`(t.search @@ websearch_to_tsquery('simple', ${q}) or t.title ilike ${like}
or t.location_name ilike ${like} or t.description ilike ${like})`
);
}
return parts.reduce((acc, p, i) => (i === 0 ? p : sql`${acc} and ${p}`));
}
const SCALE_ORDER = sql`array_position(array['planet','region','country','city','street']::worldtodo.scale[], t.scale)`;
function orderFor(sort: Sort) {
if (sort === 'title') return sql`lower(t.title) asc, t.id asc`;
if (sort === 'scale') return sql`${SCALE_ORDER} asc, lower(t.title) asc, t.id asc`;
return sql`t.updated_at desc, t.id desc`;
}
function cursorKey(sort: Sort, r: Row): unknown[] {
if (sort === 'title') return [String(r.title).toLowerCase(), Number(r.id)];
if (sort === 'scale') return [Number(r.scale_pos), String(r.title).toLowerCase(), Number(r.id)];
return [iso(r.updated_at), Number(r.id)];
}
function afterCursor(sort: Sort, cursor: string | undefined) {
if (!cursor) return null;
try {
const k = JSON.parse(Buffer.from(cursor, 'base64url').toString()) as unknown[];
if (sort === 'title' && k.length === 2)
return sql`(lower(t.title), t.id) > (${String(k[0])}, ${Number(k[1])})`;
if (sort === 'scale' && k.length === 3)
return sql`(${SCALE_ORDER}, lower(t.title), t.id) > (${Number(k[0])}, ${String(k[1])}, ${Number(k[2])})`;
if (sort === 'updated' && k.length === 2)
return sql`(t.updated_at, t.id) < (${String(k[0])}::timestamptz, ${Number(k[1])})`;
} catch {
/* bad cursor: start from the top */
}
return null;
}
/** Paged list (keyset cursor), excludes deleted tasks; each task carries its pending count. */
export async function listTasks(
f: ListFilter,
sort: Sort = 'updated',
limit = 50,
cursor?: string
): Promise<{ tasks: PublicTask[]; nextCursor: string | null }> {
const after = afterCursor(sort, cursor);
const where = after ? sql`${whereFor(f)} and ${after}` : whereFor(f);
const rows = await sql`
select ${TASK_COLUMNS}, ${SCALE_ORDER} as scale_pos,
(select count(*) from worldtodo.change_requests r
where r.task_id = t.id and r.status = 'pending')::int as pending
from worldtodo.tasks t
where ${where}
order by ${orderFor(sort)}
limit ${limit + 1}`;
const more = rows.length > limit;
const page = rows.slice(0, limit);
return {
tasks: page.map(toTask),
nextCursor:
more && page.length
? Buffer.from(JSON.stringify(cursorKey(sort, page.at(-1)!))).toString('base64url')
: null
};
}
/** Counts for the hero (per status) and the scale strip (per scale, other filters applied). */
export async function counts(f: ListFilter) {
const [byStatus, byScale] = await Promise.all([
sql`select status::text as status, count(*)::int as n from worldtodo.tasks
where deleted_at is null group by status`,
sql`select t.scale::text as scale, count(*)::int as n from worldtodo.tasks t
where ${whereFor(f, { skipScale: true })} group by t.scale`
]);
const status: Record<string, number> = { open: 0, in_progress: 0, done: 0 };
for (const r of byStatus) status[r.status] = r.n;
const scale: Record<string, number> = {};
for (const r of byScale) scale[r.scale] = r.n;
return { status, scale };
}
export async function getTask(slug: string, includeDeleted = false) {
const [row] = await sql`
select ${TASK_COLUMNS},
(select count(*) from worldtodo.change_requests r
where r.task_id = t.id and r.status = 'pending')::int as pending
from worldtodo.tasks t
where t.slug = ${slug} ${includeDeleted ? sql`` : sql`and t.deleted_at is null`}`;
if (!row) return null;
return { task: toTask(row), deletedAt: row.deleted_at ? iso(row.deleted_at) : null };
}
/** Summary of a request that is safe to show publicly: never the reason or display name. */
export function publicSummary(kind: RequestKind, payload: Record<string, unknown> | null) {
if (kind === 'delete') return 'Request to remove this task';
if (kind === 'add') return 'New task';
const keys = Object.keys(payload ?? {}).filter((k) =>
(EDITABLE_FIELDS as readonly string[]).includes(k)
);
const names = keys.map((k) => k.replace('_urls', 's').replace('_', ' '));
return names.length ? `Changes ${names.join(', ')}` : 'Edit';
}
export async function taskActivity(taskId: number) {
const rows = await sql`
select id, kind::text as kind, status::text as status, payload, applied, owner_note,
created_at, decided_at
from worldtodo.change_requests
where task_id = ${taskId} and status in ('approved', 'pending')
order by coalesce(decided_at, created_at) desc
limit 100`;
const history = rows
.filter((r) => r.status === 'approved')
.map((r) => ({
id: Number(r.id),
kind: r.kind as RequestKind,
summary: publicSummary(r.kind, (r.applied ?? r.payload) as Record<string, unknown>),
ownerNote: (r.owner_note as string | null) ?? null,
at: iso(r.decided_at ?? r.created_at)
}));
const pending = rows
.filter((r) => r.status === 'pending')
.map((r) => ({
id: Number(r.id),
kind: r.kind as RequestKind,
summary: publicSummary(r.kind, r.payload as Record<string, unknown>),
at: iso(r.created_at)
}));
return { history, pending };
}
/** The add request that created a task (if any), for the "added" line in its history. */
export async function pendingTotal(): Promise<number> {
const [r] =
await sql`select count(*)::int as n from worldtodo.change_requests where status = 'pending'`;
return r.n;
}
export async function categoryExists(slug: string) {
const [r] = await sql`select 1 from worldtodo.categories where slug = ${slug}`;
return !!r;
}
export async function insertRequest(input: {
kind: RequestKind;
taskId: number | null;
taskUpdatedAt: string | null;
payload: Record<string, unknown>;
reason: string;
displayName: string | null;
}) {
const [r] = await sql`
insert into worldtodo.change_requests
(kind, task_id, task_updated_at, payload, reason, display_name)
values (${input.kind}::worldtodo.req_kind, ${input.taskId}, ${input.taskUpdatedAt},
${JSON.stringify(input.payload)}::jsonb, ${input.reason}, ${input.displayName})
returning id, created_at`;
return { id: Number(r.id), createdAt: iso(r.created_at) };
}
export async function requestStatus(id: number) {
const [r] = await sql`
select r.id, r.kind::text as kind, r.status::text as status, r.created_at, r.decided_at,
r.owner_note, t.slug, t.title, t.deleted_at
from worldtodo.change_requests r
left join worldtodo.tasks t on t.id = coalesce(r.task_id, (r.applied->>'_task_id')::bigint)
where r.id = ${id}`;
if (!r) return null;
return {
id: Number(r.id),
kind: r.kind as RequestKind,
status: r.status as RequestStatus,
createdAt: iso(r.created_at),
decidedAt: r.decided_at ? iso(r.decided_at) : null,
ownerNote: r.status === 'pending' ? null : ((r.owner_note as string | null) ?? null),
// Only link a task the public can already see (an approved add, or the edited task).
task:
r.slug && !r.deleted_at && (r.kind !== 'add' || r.status === 'approved')
? { slug: String(r.slug), title: String(r.title) }
: null
};
}
export type QueueItem = {
id: number;
kind: RequestKind;
status: RequestStatus;
payload: Record<string, unknown>;
applied: Record<string, unknown> | null;
reason: string;
displayName: string | null;
ownerNote: string | null;
createdAt: string;
decidedAt: string | null;
task: PublicTask | null;
taskDeleted: boolean;
stale: boolean;
similar: { slug: string; title: string; similarity: number }[];
};
async function similarTitles(title: string, excludeId: number | null) {
if (!title) return [];
try {
const rows = await sql`
select slug, title, public.similarity(title, ${title}) as s
from worldtodo.tasks
where deleted_at is null and public.similarity(title, ${title}) > 0.4
${excludeId ? sql`and id <> ${excludeId}` : sql``}
order by s desc limit 3`;
return rows.map((r) => ({
slug: String(r.slug),
title: String(r.title),
similarity: Number(r.s)
}));
} catch {
return []; // pg_trgm is optional
}
}
export async function queue(status: RequestStatus, limit = 100): Promise<QueueItem[]> {
const order = status === 'pending' ? sql`r.created_at asc` : sql`r.decided_at desc nulls last`;
// Request columns are aliased: TASK_COLUMNS brings its own id, status and created_at.
const rows = await sql`
select r.id as req_id, r.kind::text as kind, r.status::text as req_status, r.payload,
r.applied, r.reason, r.display_name, r.owner_note, r.created_at as req_created_at,
r.decided_at, r.task_updated_at, ${TASK_COLUMNS}, t.id as t_id
from worldtodo.change_requests r
left join worldtodo.tasks t on t.id = coalesce(r.task_id, (r.applied->>'_task_id')::bigint)
where r.status = ${status}::worldtodo.req_status
order by ${order}
limit ${limit}`;
return Promise.all(
rows.map(async (r) => {
const task = r.t_id ? toTask({ ...r, id: r.t_id }) : null;
const deleted = !!r.deleted_at;
const stale =
r.kind !== 'add' &&
(deleted ||
!!(
r.task_updated_at &&
r.updated_at &&
(toDate(r.updated_at)?.getTime() ?? 0) > (toDate(r.task_updated_at)?.getTime() ?? 0) + 1
));
const payload = (r.payload ?? {}) as Record<string, unknown>;
const title = String(payload.title ?? '');
return {
id: Number(r.req_id),
kind: r.kind as RequestKind,
status: r.req_status as RequestStatus,
payload,
applied: (r.applied as Record<string, unknown> | null) ?? null,
reason: String(r.reason),
displayName: (r.display_name as string | null) ?? null,
ownerNote: (r.owner_note as string | null) ?? null,
createdAt: iso(r.req_created_at),
decidedAt: r.decided_at ? iso(r.decided_at) : null,
task,
taskDeleted: deleted,
stale: status === 'pending' && stale,
similar:
status === 'pending' && title && r.kind !== 'delete'
? await similarTitles(title, task?.id ?? null)
: []
} satisfies QueueItem;
})
);
}
export class DecisionError extends Error {
constructor(
message: string,
public status = 400
) {
super(message);
}
}
function slugBase(title: string) {
return (
title
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 70)
.replace(/-+$/g, '') || 'task'
);
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type Tx = TransactionSql<{}>;
async function uniqueTaskSlug(tx: Tx, title: string) {
const base = slugBase(title);
let slug = RESERVED_SLUGS.has(base) ? `${base}-2` : base;
let n = RESERVED_SLUGS.has(base) ? 3 : 2;
while ((await tx`select 1 from worldtodo.tasks where slug = ${slug}`).length)
slug = `${base}-${n++}`;
return slug;
}
/** Full field set for an add (payload) with defaults. */
function addFields(p: Record<string, unknown>): TaskFields {
return {
title: String(p.title ?? ''),
description: String(p.description ?? ''),
scale: p.scale as TaskFields['scale'],
category: String(p.category ?? ''),
location_name: String(p.location_name ?? ''),
country_code: (p.country_code as string | null) ?? null,
lat: (p.lat as number | null) ?? null,
lon: (p.lon as number | null) ?? null,
status: (p.status as TaskFields['status']) ?? 'open',
source_urls: (p.source_urls as string[]) ?? []
};
}
const today = () => new Date().toISOString().slice(0, 10);
export type Applied = { type: 'task.added' | 'task.updated' | 'task.deleted'; slug: string };
/**
* Approve a pending request: apply the add, edit or delete, set updated_at, mark the request
* approved with the optional note, all in one transaction. `tweaked` (edit & approve) replaces
* the payload: for an add the full field set, for an edit the proposed field set (diffed here).
*/
export async function approve(
id: number,
note: string | null,
tweaked: TaskFields | null
): Promise<Applied> {
return sql.begin(async (tx) => {
const [req] = await tx`
select id, kind::text as kind, status::text as status, task_id, payload
from worldtodo.change_requests where id = ${id} for update`;
if (!req) throw new DecisionError('Request not found', 404);
if (req.status !== 'pending') throw new DecisionError('This request was already decided');
const payload = (req.payload ?? {}) as Record<string, unknown>;
if (req.kind === 'add') {
const fields = tweaked ?? addFields(payload);
const [cat] = await tx`select 1 from worldtodo.categories where slug = ${fields.category}`;
if (!cat) throw new DecisionError('Unknown category');
const slug = await uniqueTaskSlug(tx, fields.title);
const [task] = await tx`
insert into worldtodo.tasks (slug, title, description, scale, category, location_name,
country_code, lat, lon, status, source_urls, as_of, confidence)
values (${slug}, ${fields.title}, ${fields.description}, ${fields.scale}::worldtodo.scale,
${fields.category}, ${fields.location_name}, ${fields.country_code}, ${fields.lat},
${fields.lon}, ${fields.status}::worldtodo.status, ${fields.source_urls},
${today()}, 'medium')
returning id`;
await tx`
update worldtodo.change_requests
set status = 'approved', decided_at = now(), owner_note = ${note},
applied = ${JSON.stringify({ ...fields, _task_id: Number(task.id) })}::jsonb
where id = ${id}`;
return { type: 'task.added', slug } satisfies Applied;
}
const [row] = await tx`
select ${TASK_COLUMNS} from worldtodo.tasks t where t.id = ${req.task_id} for update`;
if (!row) throw new DecisionError('The task no longer exists', 409);
const current = toTask(row);
if (req.kind === 'delete') {
if (!row.deleted_at)
await tx`update worldtodo.tasks set deleted_at = now(), updated_at = now() where id = ${current.id}`;
await tx`
update worldtodo.change_requests
set status = 'approved', decided_at = now(), owner_note = ${note}, applied = '{}'::jsonb
where id = ${id}`;
return { type: 'task.deleted', slug: current.slug } satisfies Applied;
}
// edit
if (row.deleted_at) throw new DecisionError('The task was removed; reject this edit', 409);
const proposed = tweaked ?? { ...currentFields(current), ...pick(payload) };
const change = changedFields(currentFields(current), proposed);
if (!Object.keys(change).length) throw new DecisionError('Nothing would change');
if (change.category) {
const [cat] = await tx`select 1 from worldtodo.categories where slug = ${change.category}`;
if (!cat) throw new DecisionError('Unknown category');
}
const next = { ...currentFields(current), ...change };
await tx`
update worldtodo.tasks set
title = ${next.title}, description = ${next.description},
scale = ${next.scale}::worldtodo.scale, category = ${next.category},
location_name = ${next.location_name}, country_code = ${next.country_code},
lat = ${next.lat}, lon = ${next.lon}, status = ${next.status}::worldtodo.status,
source_urls = ${next.source_urls}, as_of = ${today()}, updated_at = now()
where id = ${current.id}`;
await tx`
update worldtodo.change_requests
set status = 'approved', decided_at = now(), owner_note = ${note},
applied = ${JSON.stringify(change)}::jsonb
where id = ${id}`;
return { type: 'task.updated', slug: current.slug } satisfies Applied;
}) as Promise<Applied>;
}
export async function reject(id: number, note: string | null) {
const rows = await sql`
update worldtodo.change_requests
set status = 'rejected', decided_at = now(), owner_note = ${note}
where id = ${id} and status = 'pending'
returning id`;
if (!rows.length) {
const [r] =
await sql`select status::text as status from worldtodo.change_requests where id = ${id}`;
throw new DecisionError(
r ? 'This request was already decided' : 'Request not found',
r ? 400 : 404
);
}
}
/** Direct owner edit: applied at once and logged as an auto-approved request. */
export async function ownerEdit(slug: string, proposed: TaskFields, note: string | null) {
const found = await getTask(slug);
if (!found) throw new DecisionError('Task not found', 404);
const change = changedFields(currentFields(found.task), proposed);
if (!Object.keys(change).length) throw new DecisionError('Nothing would change');
const req = await insertRequest({
kind: 'edit',
taskId: found.task.id,
taskUpdatedAt: found.task.updatedAt,
payload: change as Record<string, unknown>,
reason: 'Direct edit by the owner.',
displayName: null
});
return approve(req.id, note ?? 'Direct owner edit', null);
}
export function currentFields(t: PublicTask): TaskFields {
return {
title: t.title,
description: t.description,
scale: t.scale,
category: t.category,
location_name: t.locationName,
country_code: t.countryCode,
lat: t.lat,
lon: t.lon,
status: t.status,
source_urls: t.sourceUrls
};
}
/** Whitelist payload keys to the editable fields. */
export function pick(p: Record<string, unknown>): Partial<TaskFields> {
const out: Record<string, unknown> = {};
for (const k of EDITABLE_FIELDS) if (k in p) out[k] = p[k];
return out as Partial<TaskFields>;
}
export async function sitemapSlugs() {
const rows =
await sql`select slug, updated_at from worldtodo.tasks where deleted_at is null order by id`;
return rows.map((r) => ({ slug: String(r.slug), updatedAt: iso(r.updated_at) }));
}

View File

@@ -0,0 +1,32 @@
import { TURNSTILE_SECRET_KEY } from '$app/env/private';
import { PUBLIC_TURNSTILE_SITE_KEY } from '$app/env/public';
// Cloudflare Turnstile on the request form, checked server-side through Siteverify.
// Widget "stanrunge.dev worldwide todo list" (managed mode, domain stanrunge.dev).
// Without both keys, request submission is switched off and the form says so.
const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
export const turnstileSiteKey = () => PUBLIC_TURNSTILE_SITE_KEY || '';
export const turnstileConfigured = () => !!(TURNSTILE_SECRET_KEY && PUBLIC_TURNSTILE_SITE_KEY);
export async function verifyTurnstile(
token: string,
fetcher: typeof fetch = fetch
): Promise<boolean> {
if (!turnstileConfigured() || !token || token.length > 2048) return false;
try {
const body = new URLSearchParams({ secret: TURNSTILE_SECRET_KEY, response: token });
const res = await fetcher(SITEVERIFY, {
method: 'POST',
body,
signal: AbortSignal.timeout(8000)
});
if (!res.ok) return false;
const data = (await res.json()) as { success?: boolean };
return data.success === true;
} catch (e) {
console.error('[worldtodo] turnstile siteverify failed', (e as Error).message);
return false;
}
}

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest';
import { looksLikeBot, normaliseUrl, parseRequest, plain } from './validate';
const add = {
kind: 'add',
title: 'Fix the <b>broken</b> streetlights on Main Street',
description: 'Half the lights have been out since August.\n\n<script>x</script>Please fix.',
scale: 'street',
category: 'safety',
location_name: 'Main Street, Gouda',
country_code: 'nl',
lat: '52.01',
lon: '4.71',
status: 'open',
source_urls: ['https://example.org/news', '', 'https://example.org/news'],
reason: 'I walk here every evening and it is dark.',
display_name: ''
};
describe('worldtodo request validation', () => {
it('accepts a valid add, strips HTML and normalises', () => {
const r = parseRequest(add);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.fields?.title).toBe('Fix the broken streetlights on Main Street');
expect(r.value.fields?.description).not.toContain('<');
expect(r.value.fields?.country_code).toBe('NL');
expect(r.value.fields?.lat).toBe(52.01);
expect(r.value.fields?.source_urls).toEqual(['https://example.org/news']);
expect(r.value.displayName).toBeNull();
});
it('requires sources for add and edit, and a reason of 10+ chars', () => {
const r = parseRequest({ ...add, source_urls: [], reason: 'short' });
expect(r.ok).toBe(false);
if (r.ok) return;
expect(r.errors.source_urls).toBeTruthy();
expect(r.errors.reason).toBeTruthy();
});
it('rejects non-http URLs, too many URLs and overlong URLs', () => {
expect(normaliseUrl('javascript:alert(1)')).toBeNull();
expect(normaliseUrl('ftp://example.org/x')).toBeNull();
expect(normaliseUrl(`https://example.org/${'a'.repeat(500)}`)).toBeNull();
expect(normaliseUrl('https://example.org/ok')).toBe('https://example.org/ok');
const many = Array.from({ length: 6 }, (_, i) => `https://e${i}.org/`);
const r = parseRequest({ ...add, source_urls: many });
expect(r.ok).toBe(false);
});
it('needs both coordinates or neither, in range', () => {
expect(parseRequest({ ...add, lon: '' }).ok).toBe(false);
expect(parseRequest({ ...add, lat: '91' }).ok).toBe(false);
expect(parseRequest({ ...add, lat: '', lon: '' }).ok).toBe(true);
});
it('delete needs only a task and a reason', () => {
const r = parseRequest({
kind: 'delete',
task: 'some-task',
reason: 'It was fixed last week.'
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.value.fields).toBeNull();
expect(parseRequest({ kind: 'delete', reason: 'It was fixed last week.' }).ok).toBe(false);
});
it('caps the display name at 40 characters', () => {
expect(parseRequest({ ...add, display_name: 'x'.repeat(41) }).ok).toBe(false);
const r = parseRequest({ ...add, display_name: ' <i>Anna</i> ' });
expect(r.ok && r.value.displayName).toBe('Anna');
});
it('flags the honeypot and too-fast submissions', () => {
const now = 1_000_000;
expect(looksLikeBot('', now - 10_000, now)).toBe(false);
expect(looksLikeBot('http://spam', now - 10_000, now)).toBe(true);
expect(looksLikeBot('', now - 1000, now)).toBe(true);
expect(looksLikeBot('', 'garbage', now)).toBe(true);
});
it('plain() keeps paragraphs but drops control characters', () => {
expect(plain('a\u0000b\r\n\r\n\r\nc', true)).toBe('a b\n\nc');
expect(plain(' one\ntwo ')).toBe('one two');
});
it('add without the optional keys defaults them (JSON API)', () => {
const { country_code, lat, lon, status, display_name, ...minimal } = add;
void [country_code, lat, lon, status, display_name];
const r = parseRequest(minimal);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.fields).toMatchObject({
country_code: null,
lat: null,
lon: null,
status: 'open'
});
});
it('a partial edit is validated on top of the current task', () => {
const base = {
title: 'Fix the broken streetlights on Main Street',
description: 'Out since August.',
scale: 'street',
category: 'safety',
location_name: 'Main Street, Gouda',
country_code: 'NL',
lat: 52.01,
lon: 4.71,
status: 'open',
source_urls: ['https://example.org/news']
};
const r = parseRequest(
{
kind: 'edit',
task: 'main-street',
status: 'in_progress',
reason: 'Works started on Monday.'
},
base
);
expect(r.ok).toBe(true);
if (r.ok) expect(r.value.fields).toMatchObject({ ...base, status: 'in_progress' });
});
});

View File

@@ -0,0 +1,247 @@
import { z } from 'zod';
import {
WORLDTODO_REQUEST_KINDS,
WORLDTODO_SCALES,
WORLDTODO_STATUSES,
type RequestKind,
type TaskFields
} from '#lib/worldtodo/shared.js';
// Validation for public change requests (form action and JSON API share it). Everything is
// plain text: HTML tags and control characters are stripped, Markdown is never rendered.
// Source URLs must be http(s) and are stored as given (never fetched on submit).
export const MAX_URLS = 5;
export const MAX_URL_LENGTH = 500;
/** Minimum time between opening the form and submitting it (cheap bot filter). */
export const MIN_FILL_MS = 3000;
/** Plain text: no tags, no control characters, trimmed. `multiline` keeps line breaks. */
export function plain(value: unknown, multiline = false): string {
if (typeof value !== 'string') return '';
let s = value.replace(/<[^>]*>?/g, '').replace(/[<>]/g, '');
s = s.replace(/\r\n?/g, '\n');
// eslint-disable-next-line no-control-regex
s = s.replace(multiline ? /[\u0000-\u0009\u000b-\u001f\u007f]/g : /[\u0000-\u001f\u007f]/g, ' ');
s = multiline
? s
.split('\n')
.map((l) => l.replace(/[ \t]+/g, ' ').trim())
.join('\n')
.replace(/\n{3,}/g, '\n\n')
: s.replace(/\s+/g, ' ');
return s.trim();
}
export function normaliseUrl(raw: string): string | null {
const s = raw.trim();
if (!s || s.length > MAX_URL_LENGTH) return null;
try {
const u = new URL(s);
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
if (!u.hostname.includes('.')) return null;
return s;
} catch {
return null;
}
}
const optionalNumber = (min: number, max: number, label: string) =>
z.union([z.string(), z.number(), z.null(), z.undefined()]).transform((v, ctx) => {
if (v === null || v === undefined || (typeof v === 'string' && v.trim() === '')) return null;
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n) || n < min || n > max) {
ctx.addIssue({ code: 'custom', message: `${label} must be between ${min} and ${max}` });
return z.NEVER;
}
return Math.round(n * 1e6) / 1e6;
});
export const fieldsSchema = z
.object({
title: z
.string({ message: 'Add a title' })
.transform((v) => plain(v))
.pipe(
z
.string()
.min(5, 'Title needs at least 5 characters')
.max(140, 'Title can be at most 140 characters')
),
description: z
.string({ message: 'Add a description' })
.transform((v) => plain(v, true))
.pipe(
z
.string()
.min(10, 'Description needs at least 10 characters')
.max(2000, 'Description can be at most 2000 characters')
),
scale: z.enum(WORLDTODO_SCALES, { message: 'Pick a scale' }),
category: z
.string({ message: 'Pick a category' })
.trim()
.regex(/^[a-z][a-z0-9-]{1,30}$/, 'Pick a category'),
location_name: z
.string({ message: 'Add a location' })
.transform((v) => plain(v))
.pipe(
z
.string()
.min(2, 'Location needs at least 2 characters')
.max(120, 'Location can be at most 120 characters')
),
country_code: z.union([z.string(), z.null(), z.undefined()]).transform((v, ctx) => {
const s = (v ?? '').trim().toUpperCase();
if (!s) return null;
if (!/^[A-Z]{2}$/.test(s)) {
ctx.addIssue({ code: 'custom', message: 'Country code is two letters, e.g. NL' });
return z.NEVER;
}
return s;
}),
lat: optionalNumber(-90, 90, 'Latitude'),
lon: optionalNumber(-180, 180, 'Longitude'),
status: z.enum(WORLDTODO_STATUSES, { message: 'Pick a status' }),
source_urls: z.array(z.string()).transform((list, ctx) => {
const filled = list.map((s) => s.trim()).filter(Boolean);
if (filled.length > MAX_URLS) {
ctx.addIssue({ code: 'custom', message: `At most ${MAX_URLS} source links` });
return z.NEVER;
}
const out: string[] = [];
for (const raw of filled) {
const url = normaliseUrl(raw);
if (!url) {
ctx.addIssue({
code: 'custom',
message: `Source links must be http(s) URLs of at most ${MAX_URL_LENGTH} characters`
});
return z.NEVER;
}
if (!out.includes(url)) out.push(url);
}
if (!out.length) {
ctx.addIssue({ code: 'custom', message: 'Add at least one source link' });
return z.NEVER;
}
return out;
})
})
.superRefine((v, ctx) => {
if ((v.lat === null) !== (v.lon === null))
ctx.addIssue({ code: 'custom', path: ['lat'], message: 'Give both latitude and longitude' });
});
const metaSchema = z.object({
kind: z.enum(WORLDTODO_REQUEST_KINDS, { message: 'Unknown request type' }),
task: z.string().trim().max(120).optional().default(''),
reason: z
.string()
.transform((v) => plain(v, true))
.pipe(
z
.string()
.min(10, 'Reason needs at least 10 characters')
.max(1000, 'Reason can be at most 1000 characters')
),
display_name: z
.union([z.string(), z.null(), z.undefined()])
.transform((v) => plain(v ?? '').slice(0, 200))
.pipe(z.string().max(40, 'Display name can be at most 40 characters'))
.transform((v) => v || null)
});
export type ParsedRequest = {
kind: RequestKind;
taskSlug: string;
fields: TaskFields | null;
reason: string;
displayName: string | null;
};
export type FieldErrors = Record<string, string>;
/** Raw input (from FormData or JSON) → a validated request, or field errors. */
export function parseRequest(
input: Record<string, unknown>,
base?: Record<string, unknown>
): { ok: true; value: ParsedRequest } | { ok: false; errors: FieldErrors } {
const errors: FieldErrors = {};
// Optional keys are defaulted here: zod 4 treats a missing key in front of a transform as
// required.
const meta = metaSchema.safeParse({
...input,
task: input.task ?? '',
display_name: input.display_name ?? ''
});
if (!meta.success)
for (const issue of meta.error.issues)
errors[String(issue.path[0] ?? 'form')] ??= issue.message;
const kind = meta.success ? meta.data.kind : (input.kind as RequestKind);
if ((kind === 'edit' || kind === 'delete') && !(meta.success && meta.data.task))
errors.task ??= 'Which task is this about?';
let fields: TaskFields | null = null;
if (kind === 'add' || kind === 'edit') {
// Edits may send only the fields that change: the rest comes from the current task.
// Optional keys are defaulted for the same zod 4 reason as above.
const merged = { ...(kind === 'edit' ? base : undefined), ...input };
const urls = merged.source_urls;
const parsed = fieldsSchema.safeParse({
...merged,
country_code: merged.country_code ?? '',
lat: merged.lat ?? '',
lon: merged.lon ?? '',
status: merged.status ?? (kind === 'add' ? 'open' : undefined),
source_urls: Array.isArray(urls) ? urls : typeof urls === 'string' ? [urls] : []
});
if (parsed.success) fields = parsed.data;
else
for (const issue of parsed.error.issues)
errors[String(issue.path[0] ?? 'form')] ??= issue.message;
}
if (Object.keys(errors).length || !meta.success) return { ok: false, errors };
return {
ok: true,
value: {
kind: meta.data.kind,
taskSlug: meta.data.task,
fields,
reason: meta.data.reason,
displayName: meta.data.display_name
}
};
}
/** FormData → the raw input record parseRequest takes. */
export function formInput(form: FormData): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const key of [
'kind',
'task',
'title',
'description',
'scale',
'category',
'location_name',
'country_code',
'lat',
'lon',
'status',
'reason',
'display_name'
]) {
const v = form.get(key);
if (typeof v === 'string') out[key] = v;
}
out.source_urls = form.getAll('source_urls').filter((v): v is string => typeof v === 'string');
return out;
}
/** Honeypot filled in, or submitted faster than a person could. */
export function looksLikeBot(honeypot: unknown, startedAt: unknown, now = Date.now()): boolean {
if (typeof honeypot === 'string' && honeypot.trim()) return true;
const started = Number(startedAt);
if (!Number.isFinite(started) || started <= 0) return true;
return now - started < MIN_FILL_MS;
}

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import type { Component } from 'svelte';
// A small coloured square with a white icon (decorative; always next to a text label).
let {
icon: Icon,
background,
size = 'md'
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: Component<any>;
background: string;
size?: 'sm' | 'md' | 'lg';
} = $props();
const px = $derived(size === 'lg' ? 22 : size === 'sm' ? 12 : 15);
</script>
<span class="tile {size}" style:background aria-hidden="true"><Icon size={px} /></span>
<style>
.tile {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
border-radius: 0.5rem;
width: 1.85rem;
height: 1.85rem;
}
.sm {
width: 1.3rem;
height: 1.3rem;
border-radius: 999px;
}
.lg {
width: 2.75rem;
height: 2.75rem;
border-radius: 0.8rem;
}
</style>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { STATUS_LABELS, type Status } from './shared';
let { status }: { status: Status } = $props();
</script>
<span class="pill {status}">{STATUS_LABELS[status]}</span>
<style>
.pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.1rem 0.5rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.72rem;
font-weight: 550;
white-space: nowrap;
color: var(--site-text);
}
.pill::before {
content: '';
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
border: 1.5px solid currentColor;
}
.in_progress::before {
background: linear-gradient(90deg, currentColor 50%, transparent 50%);
}
.done {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.done::before {
background: currentColor;
}
</style>

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { resolve } from '$app/paths';
import IconTile from './IconTile.svelte';
import StatusPill from './StatusPill.svelte';
import { SCALE_ICONS, categoryIcon } from './icons';
import { SCALE_INFO, relativeTime, type Category, type PublicTask } from './shared';
let {
task,
category,
flash = false,
now
}: { task: PublicTask; category: Category | undefined; flash?: boolean; now: number } = $props();
const scale = $derived(SCALE_ICONS[task.scale]);
</script>
<a
class={['card', { flash }]}
href={resolve('/projects/worldwide-todo/[slug]', { slug: task.slug })}
data-sveltekit-preload-data="hover"
>
<span class="icons">
<IconTile icon={scale.icon} background={scale.gradient} />
<IconTile icon={categoryIcon(category?.icon)} background={category?.colour ?? '#737373'} />
<span class="meta">{SCALE_INFO[task.scale].label} · {category?.label ?? task.category}</span>
<StatusPill status={task.status} />
</span>
<h3>{task.title}</h3>
<span class="where">{task.locationName}</span>
<span class="desc">{task.description}</span>
<span class="foot">
<span>updated {relativeTime(task.updatedAt, now) || 'just now'}</span>
<span>{task.sourceUrls.length} source{task.sourceUrls.length === 1 ? '' : 's'}</span>
{#if task.pending}<span class="pending">{task.pending} pending</span>{/if}
</span>
</a>
<style>
.card {
display: flex;
flex-direction: column;
gap: 0.4rem;
height: 100%;
padding: 0.85rem 0.9rem;
border: 1px solid var(--site-border);
border-radius: 0.85rem;
background: var(--site-surface);
color: var(--site-text);
text-decoration: none;
transition:
border-color 0.15s,
transform 0.15s,
box-shadow 0.15s,
background-color 0.6s;
}
.card:hover,
.card:focus-visible {
border-color: var(--site-muted);
transform: translateY(-1px);
box-shadow: 0 6px 20px -12px rgb(0 0 0 / 0.5);
}
.card:focus-visible {
outline: 2px solid var(--site-text);
outline-offset: 2px;
}
.flash {
background: color-mix(in srgb, var(--site-surface) 80%, #facc15);
}
.icons {
display: flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.meta {
flex: 1;
min-width: 0;
font-size: 0.72rem;
color: var(--site-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
h3 {
font-size: 0.98rem;
font-weight: 600;
line-height: 1.3;
letter-spacing: -0.01em;
}
.where {
font-size: 0.8rem;
color: var(--site-muted);
}
.desc {
font-size: 0.83rem;
line-height: 1.4;
color: var(--site-muted);
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.foot {
margin-top: auto;
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.75rem;
font-size: 0.72rem;
color: var(--site-muted);
}
.pending {
color: var(--site-text);
font-weight: 550;
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover,
.card:focus-visible {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,401 @@
<script lang="ts">
import { untrack } from 'svelte';
import IconTile from './IconTile.svelte';
import { SCALE_ICONS } from './icons';
import {
SCALE_INFO,
STATUS_LABELS,
WORLDTODO_SCALES,
WORLDTODO_STATUSES,
type Category
} from './shared';
import Plus from 'lucide-svelte/icons/plus';
import X from 'lucide-svelte/icons/x';
// The task fields as form inputs (request form and the queue's "edit & approve"). Names
// match the request payload keys; source_urls repeats.
type Values = Record<string, unknown>;
let {
values,
errors = {},
categories,
id = 'f'
}: {
values: Values;
errors?: Record<string, string>;
categories: Category[];
id?: string;
} = $props();
const str = (v: unknown) => (v === null || v === undefined ? '' : String(v));
// The form owns its values after mount (the parent keys it per attempt), so the props are
// only read once here.
const init = untrack(() => ({ values, errors }));
let title = $state(str(init.values.title));
let description = $state(str(init.values.description));
// Everything is bound so text typed before hydration survives it.
let scale = $state(str(init.values.scale));
let category = $state(str(init.values.category));
let status = $state(str(init.values.status) || 'open');
let locationName = $state(str(init.values.location_name));
let countryCode = $state(str(init.values.country_code));
let lat = $state(str(init.values.lat));
let lon = $state(str(init.values.lon));
const initialUrls = Array.isArray(init.values.source_urls)
? (init.values.source_urls as string[]).filter(Boolean)
: [];
let urls = $state<string[]>(
initialUrls.length < 5 ? [...initialUrls, ''] : initialUrls.slice(0, 5)
);
const err = (k: string) => errors[k];
const described = (k: string, help = false) =>
[help ? `${id}-${k}-help` : '', err(k) ? `${id}-${k}-err` : ''].filter(Boolean).join(' ') ||
undefined;
const hasLatLon = !!(
str(init.values.lat) ||
str(init.values.lon) ||
init.errors.lat ||
init.errors.lon
);
</script>
<div class="fields">
<div class="field">
<label for="{id}-title">Title</label>
<input
id="{id}-title"
name="title"
bind:value={title}
maxlength="140"
required
aria-invalid={!!err('title')}
aria-describedby={described('title', true)}
/>
<span class="help" id="{id}-title-help"
>Phrase it as something to do, e.g. “Repair the …”. <span class="count"
>{title.length}/140</span
></span
>
{#if err('title')}<span class="err" id="{id}-title-err">{err('title')}</span>{/if}
</div>
<div class="field">
<label for="{id}-description">Description</label>
<textarea
id="{id}-description"
name="description"
bind:value={description}
rows="5"
maxlength="2000"
required
aria-invalid={!!err('description')}
aria-describedby={described('description', true)}></textarea>
<span class="help" id="{id}-description-help"
>What is wrong, what would “done” look like, and who could do it. Plain text. <span
class="count">{description.length}/2000</span
></span
>
{#if err('description')}<span class="err" id="{id}-description-err">{err('description')}</span
>{/if}
</div>
<fieldset class="field" aria-describedby={described('scale')}>
<legend>Scale</legend>
<div class="scales">
{#each WORLDTODO_SCALES as s (s)}
<label class="scale">
<input type="radio" name="scale" value={s} bind:group={scale} required />
<IconTile icon={SCALE_ICONS[s].icon} background={SCALE_ICONS[s].gradient} />
<span>{SCALE_INFO[s].label}</span>
</label>
{/each}
</div>
{#if err('scale')}<span class="err" id="{id}-scale-err">{err('scale')}</span>{/if}
</fieldset>
<div class="row">
<div class="field">
<label for="{id}-category">Category</label>
<select
id="{id}-category"
name="category"
bind:value={category}
required
aria-invalid={!!err('category')}
aria-describedby={described('category')}
>
<option value="" disabled>Pick one</option>
{#each categories as c (c.slug)}
<option value={c.slug}>{c.label}</option>
{/each}
</select>
{#if err('category')}<span class="err" id="{id}-category-err">{err('category')}</span>{/if}
</div>
<div class="field">
<label for="{id}-status">Status</label>
<select
id="{id}-status"
name="status"
bind:value={status}
aria-describedby={described('status')}
>
{#each WORLDTODO_STATUSES as s (s)}
<option value={s}>{STATUS_LABELS[s]}</option>
{/each}
</select>
{#if err('status')}<span class="err" id="{id}-status-err">{err('status')}</span>{/if}
</div>
</div>
<div class="row">
<div class="field grow">
<label for="{id}-location">Location</label>
<input
id="{id}-location"
name="location_name"
bind:value={locationName}
maxlength="120"
required
placeholder="e.g. Rotterdam, Netherlands"
aria-invalid={!!err('location_name')}
aria-describedby={described('location_name')}
/>
{#if err('location_name')}<span class="err" id="{id}-location_name-err"
>{err('location_name')}</span
>{/if}
</div>
<div class="field small">
<label for="{id}-cc">Country code</label>
<input
id="{id}-cc"
name="country_code"
bind:value={countryCode}
maxlength="2"
placeholder="NL"
autocomplete="off"
aria-invalid={!!err('country_code')}
aria-describedby={described('country_code')}
/>
{#if err('country_code')}<span class="err" id="{id}-country_code-err"
>{err('country_code')}</span
>{/if}
</div>
</div>
<details class="adv" open={hasLatLon}>
<summary>Map position (optional)</summary>
<div class="row">
<div class="field">
<label for="{id}-lat">Latitude</label>
<input
id="{id}-lat"
name="lat"
inputmode="decimal"
bind:value={lat}
placeholder="51.92"
aria-invalid={!!err('lat')}
aria-describedby={described('lat')}
/>
{#if err('lat')}<span class="err" id="{id}-lat-err">{err('lat')}</span>{/if}
</div>
<div class="field">
<label for="{id}-lon">Longitude</label>
<input
id="{id}-lon"
name="lon"
inputmode="decimal"
bind:value={lon}
placeholder="4.48"
aria-invalid={!!err('lon')}
aria-describedby={described('lon')}
/>
{#if err('lon')}<span class="err" id="{id}-lon-err">{err('lon')}</span>{/if}
</div>
</div>
</details>
<fieldset class="field" aria-describedby={described('source_urls', true)}>
<legend>Sources (1–5 links)</legend>
<span class="help" id="{id}-source_urls-help"
>A news article, report or official page that backs this up.</span
>
{#each urls, i (i)}
<div class="url">
<input
type="url"
name="source_urls"
bind:value={urls[i]}
maxlength="500"
placeholder="https://…"
aria-label="Source link {i + 1}"
required={i === 0}
aria-invalid={!!err('source_urls')}
/>
{#if urls.length > 1}
<button
type="button"
class="icon-btn"
aria-label="Remove link {i + 1}"
onclick={() => urls.splice(i, 1)}><X size={14} aria-hidden="true" /></button
>
{/if}
</div>
{/each}
{#if urls.length < 5}
<button type="button" class="add" onclick={() => urls.push('')}
><Plus size={14} aria-hidden="true" />another link</button
>
{/if}
{#if err('source_urls')}<span class="err" id="{id}-source_urls-err">{err('source_urls')}</span
>{/if}
</fieldset>
</div>
<style>
.fields {
display: flex;
flex-direction: column;
gap: 0.9rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.3rem;
min-width: 0;
border: 0;
padding: 0;
margin: 0;
}
label,
legend,
summary {
font-size: 0.82rem;
font-weight: 550;
}
legend {
margin-bottom: 0.3rem;
}
input:not([type='radio']),
textarea,
select {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
background: var(--site-bg);
color: var(--site-text);
font-size: 0.9rem;
}
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--site-text);
outline-offset: 1px;
}
[aria-invalid='true'] {
border-color: #dc2626 !important;
}
.help {
font-size: 0.75rem;
color: var(--site-muted);
}
.count {
float: right;
font-variant-numeric: tabular-nums;
}
.err {
font-size: 0.78rem;
color: #dc2626;
font-weight: 550;
}
.err::before {
content: '⚠ ';
}
.scales {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
gap: 0.4rem;
}
.scale {
display: flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.55rem;
border: 1px solid var(--site-border);
border-radius: 0.7rem;
cursor: pointer;
font-weight: 500;
transition:
border-color 0.15s,
transform 0.15s;
}
.scale:hover {
border-color: var(--site-muted);
transform: translateY(-1px);
}
.scale:has(input:checked) {
border-color: var(--site-text);
box-shadow: inset 0 0 0 1px var(--site-text);
}
.scale:has(input:focus-visible) {
outline: 2px solid var(--site-text);
outline-offset: 2px;
}
.scale input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.row:has(.grow) {
grid-template-columns: minmax(0, 1fr) 7rem;
}
.adv summary {
cursor: pointer;
color: var(--site-muted);
}
.adv[open] summary {
margin-bottom: 0.5rem;
}
.url {
display: flex;
gap: 0.35rem;
}
.icon-btn,
.add {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
font-size: 0.8rem;
color: var(--site-muted);
background: transparent;
cursor: pointer;
width: fit-content;
}
.icon-btn:hover,
.add:hover {
color: var(--site-text);
border-color: var(--site-muted);
}
@media (max-width: 520px) {
.row,
.row:has(.grow) {
grid-template-columns: minmax(0, 1fr);
}
}
@media (prefers-reduced-motion: reduce) {
.scale {
transition: none;
}
.scale:hover {
transform: none;
}
}
</style>

View File

@@ -0,0 +1,71 @@
<script lang="ts" module>
declare global {
interface Window {
turnstile?: {
render: (el: HTMLElement, opts: Record<string, unknown>) => string;
reset: (id?: string) => void;
remove: (id: string) => void;
};
}
}
let loading: Promise<void> | null = null;
function load() {
loading ??= new Promise((ok, fail) => {
const s = document.createElement('script');
s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
s.async = true;
s.onload = () => ok();
s.onerror = () => {
loading = null;
fail(new Error('turnstile'));
};
document.head.appendChild(s);
});
return loading;
}
</script>
<script lang="ts">
// Cloudflare Turnstile, rendered explicitly so it survives client-side navigation. The
// widget adds its own hidden `cf-turnstile-response` input to the surrounding form.
let { siteKey, describedby }: { siteKey: string; describedby?: string } = $props();
let el: HTMLDivElement;
let failed = $state(false);
$effect(() => {
let id: string | undefined;
let gone = false;
load()
.then(() => {
if (gone || !window.turnstile) return;
id = window.turnstile.render(el, {
sitekey: siteKey,
theme: 'auto',
'response-field-name': 'cf-turnstile-response'
});
})
.catch(() => (failed = true));
return () => {
gone = true;
if (id && window.turnstile) window.turnstile.remove(id);
};
});
</script>
<div bind:this={el} class="turnstile" aria-describedby={describedby}></div>
{#if failed}
<p class="fail">
The anti-bot check couldn't load. Check your connection or blockers and reload.
</p>
{/if}
<noscript><p class="fail">The anti-bot check needs JavaScript.</p></noscript>
<style>
.turnstile {
min-height: 65px;
}
.fail {
font-size: 0.8rem;
color: var(--site-muted);
}
</style>

View File

@@ -0,0 +1,52 @@
import type { Component } from 'svelte';
import Bike from 'lucide-svelte/icons/bike';
import Building2 from 'lucide-svelte/icons/building-2';
import Construction from 'lucide-svelte/icons/construction';
import Droplet from 'lucide-svelte/icons/droplet';
import Earth from 'lucide-svelte/icons/earth';
import Flag from 'lucide-svelte/icons/flag';
import HandHeart from 'lucide-svelte/icons/hand-heart';
import HeartPulse from 'lucide-svelte/icons/heart-pulse';
import House from 'lucide-svelte/icons/house';
import Landmark from 'lucide-svelte/icons/landmark';
import Leaf from 'lucide-svelte/icons/leaf';
import ListTodo from 'lucide-svelte/icons/list-todo';
import MapIcon from 'lucide-svelte/icons/map';
import MapPin from 'lucide-svelte/icons/map-pin';
import Recycle from 'lucide-svelte/icons/recycle';
import ShieldAlert from 'lucide-svelte/icons/shield-alert';
import Thermometer from 'lucide-svelte/icons/thermometer';
import Wheat from 'lucide-svelte/icons/wheat';
import Zap from 'lucide-svelte/icons/zap';
import type { Scale } from './shared';
// Colourful icons on the black-and-white base: categories use the icon key and accent colour
// stored in worldtodo.categories, scales have fixed gradients. Always paired with a text label.
type Icon = typeof Earth | Component;
export const CATEGORY_ICONS: Record<string, Icon> = {
thermometer: Thermometer,
leaf: Leaf,
zap: Zap,
wheat: Wheat,
landmark: Landmark,
'heart-pulse': HeartPulse,
house: House,
'hand-heart': HandHeart,
construction: Construction,
bike: Bike,
'shield-alert': ShieldAlert,
recycle: Recycle,
droplet: Droplet
};
export const categoryIcon = (key: string | undefined): Icon =>
(key && CATEGORY_ICONS[key]) || ListTodo;
export const SCALE_ICONS: Record<Scale, { icon: Icon; gradient: string }> = {
planet: { icon: Earth, gradient: 'linear-gradient(135deg, #38bdf8, #16a34a)' },
region: { icon: MapIcon, gradient: 'linear-gradient(135deg, #a78bfa, #4f46e5)' },
country: { icon: Flag, gradient: 'linear-gradient(135deg, #fb7185, #dc2626)' },
city: { icon: Building2, gradient: 'linear-gradient(135deg, #fbbf24, #ea580c)' },
street: { icon: MapPin, gradient: 'linear-gradient(135deg, #2dd4bf, #0f766e)' }
};

54
src/lib/worldtodo/live.ts Normal file
View File

@@ -0,0 +1,54 @@
// Browser side of the worldtodo realtime channels: connect to /ws/<channel>, reconnect with
// backoff (1 s → 30 s), and report whether the socket is up so pages can fall back to polling.
// Returns a stop function (call it from an $effect cleanup).
export function subscribe(
channel: 'worldtodo' | 'worldtodo-owner',
onMessage: (msg: unknown) => void,
onState: (up: boolean) => void = () => {}
): () => void {
let ws: WebSocket | null = null;
let stopped = false;
let delay = 1000;
let timer: ReturnType<typeof setTimeout> | undefined;
const connect = () => {
if (stopped) return;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
try {
ws = new WebSocket(`${proto}//${location.host}/ws/${channel}`);
} catch {
return retry();
}
ws.onopen = () => {
delay = 1000;
onState(true);
};
ws.onmessage = (e) => {
try {
onMessage(JSON.parse(String(e.data)));
} catch {
/* ignore malformed frames */
}
};
ws.onclose = () => {
onState(false);
retry();
};
};
const retry = () => {
if (stopped) return;
clearTimeout(timer);
timer = setTimeout(connect, delay + Math.random() * 500);
delay = Math.min(delay * 2, 30_000);
};
connect();
return () => {
stopped = true;
clearTimeout(timer);
if (ws) {
ws.onclose = null;
ws.close();
}
};
}

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from 'vitest';
import {
WORLDTODO_REQUEST_KINDS,
WORLDTODO_REQUEST_STATUSES,
WORLDTODO_SCALES,
WORLDTODO_STATUSES,
changedFields,
matchesFilters,
type PublicTask,
type TaskFields
} from './shared';
import {
worldtodoRequestKind,
worldtodoRequestStatus,
worldtodoScale,
worldtodoStatus
} from '#lib/server/db/schema/worldtodo.js';
import seed from '../../../data/worldtodo-seed.json';
const base: TaskFields = {
title: 'Repair the bridge',
description: 'The bridge is broken and needs fixing.',
scale: 'city',
category: 'infrastructure',
location_name: 'Gouda',
country_code: 'NL',
lat: 52,
lon: 4.7,
status: 'open',
source_urls: ['https://example.org/a']
};
describe('worldtodo shared', () => {
it('keeps the schema enums in sync with the shared constants', () => {
expect(worldtodoScale.enumValues).toEqual([...WORLDTODO_SCALES]);
expect(worldtodoStatus.enumValues).toEqual([...WORLDTODO_STATUSES]);
expect(worldtodoRequestKind.enumValues).toEqual([...WORLDTODO_REQUEST_KINDS]);
expect(worldtodoRequestStatus.enumValues).toEqual([...WORLDTODO_REQUEST_STATUSES]);
});
it('returns only the changed fields', () => {
expect(changedFields(base, { ...base })).toEqual({});
expect(changedFields(base, { ...base, status: 'done', lat: 52 })).toEqual({ status: 'done' });
expect(
changedFields(base, { ...base, source_urls: ['https://example.org/a', 'https://b.org'] })
).toEqual({ source_urls: ['https://example.org/a', 'https://b.org'] });
expect(changedFields(base, { country_code: null })).toEqual({ country_code: null });
});
it('matches list filters like the server', () => {
const t = {
slug: 'x',
title: 'Repair the bridge',
description: 'd',
locationName: 'Gouda',
scale: 'city',
category: 'infrastructure',
status: 'done'
} as PublicTask;
const f = { scale: '', category: '', status: 'todo', q: '' };
expect(matchesFilters(t, f)).toBe(false);
expect(matchesFilters(t, { ...f, status: 'all' })).toBe(true);
expect(matchesFilters(t, { ...f, status: 'done', q: 'gouda bridge' })).toBe(true);
expect(matchesFilters(t, { ...f, status: 'all', scale: 'planet' })).toBe(false);
});
it('ships the full, sourced seed (68 tasks with the expected spread)', () => {
const tasks = seed.tasks;
expect(tasks).toHaveLength(68);
expect(new Set(tasks.map((t) => t.slug)).size).toBe(68);
const by = (k: 'scale' | 'status') =>
tasks.reduce<Record<string, number>>((a, t) => ((a[t[k]] = (a[t[k]] ?? 0) + 1), a), {});
expect(by('scale')).toEqual({ planet: 13, region: 12, country: 12, city: 12, street: 19 });
expect(by('status')).toEqual({ open: 58, in_progress: 10 });
for (const t of tasks) {
expect(t.source_urls.length).toBeGreaterThan(0);
expect(seed.categories).toContain(t.category);
expect(['request', 'requests', 'queue', 'api']).not.toContain(t.slug);
expect(t.title.length).toBeGreaterThanOrEqual(5);
expect(t.title.length).toBeLessThanOrEqual(140);
}
});
});

180
src/lib/worldtodo/shared.ts Normal file
View File

@@ -0,0 +1,180 @@
// Shared (client + server) constants and types for the Worldwide Todo List.
export const WORLDTODO_SCALES = ['planet', 'region', 'country', 'city', 'street'] as const;
export type Scale = (typeof WORLDTODO_SCALES)[number];
export const WORLDTODO_STATUSES = ['open', 'in_progress', 'done'] as const;
export type Status = (typeof WORLDTODO_STATUSES)[number];
export const WORLDTODO_REQUEST_KINDS = ['add', 'edit', 'delete'] as const;
export type RequestKind = (typeof WORLDTODO_REQUEST_KINDS)[number];
export const WORLDTODO_REQUEST_STATUSES = ['pending', 'approved', 'rejected'] as const;
export type RequestStatus = (typeof WORLDTODO_REQUEST_STATUSES)[number];
/** The only keys a request payload may carry (checked server-side). */
export const EDITABLE_FIELDS = [
'title',
'description',
'scale',
'category',
'location_name',
'country_code',
'lat',
'lon',
'status',
'source_urls'
] as const;
export type EditableField = (typeof EDITABLE_FIELDS)[number];
export const FIELD_LABELS: Record<EditableField, string> = {
title: 'Title',
description: 'Description',
scale: 'Scale',
category: 'Category',
location_name: 'Location',
country_code: 'Country code',
lat: 'Latitude',
lon: 'Longitude',
status: 'Status',
source_urls: 'Sources'
};
export const SCALE_INFO: Record<Scale, { label: string; emoji: string }> = {
planet: { label: 'Planet', emoji: '🌍' },
region: { label: 'Region', emoji: '🗺️' },
country: { label: 'Country', emoji: '🏳️' },
city: { label: 'City', emoji: '🏙️' },
street: { label: 'Street', emoji: '🛣️' }
};
export const STATUS_LABELS: Record<Status, string> = {
open: 'Open',
in_progress: 'In progress',
done: 'Done'
};
/** Slugs that are routes under /projects/worldwide-todo, never task slugs. */
export const RESERVED_SLUGS = new Set(['request', 'requests', 'queue', 'api']);
/** A task as the public sees it (list, detail, realtime events). */
export type PublicTask = {
id: number;
slug: string;
title: string;
description: string;
scale: Scale;
category: string;
locationName: string;
countryCode: string | null;
lat: number | null;
lon: number | null;
status: Status;
sourceUrls: string[];
sourceDate: string | null;
confidence: 'high' | 'medium';
verificationNote: string | null;
asOf: string;
createdAt: string;
updatedAt: string;
pending?: number;
};
export type Category = { slug: string; label: string; icon: string; colour: string };
export type LiveEvent = {
type: 'task.added' | 'task.updated' | 'task.deleted';
slug: string;
task: PublicTask | null;
at: string;
};
/** Field values in the editable shape (snake_case keys, as in payloads). */
export type TaskFields = {
title: string;
description: string;
scale: Scale;
category: string;
location_name: string;
country_code: string | null;
lat: number | null;
lon: number | null;
status: Status;
source_urls: string[];
};
export function fieldsOf(task: PublicTask): TaskFields {
return {
title: task.title,
description: task.description,
scale: task.scale,
category: task.category,
location_name: task.locationName,
country_code: task.countryCode,
lat: task.lat,
lon: task.lon,
status: task.status,
source_urls: task.sourceUrls
};
}
const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
/** Only the fields of `next` that differ from `current`. */
export function changedFields(current: TaskFields, next: Partial<TaskFields>): Partial<TaskFields> {
const out: Record<string, unknown> = {};
for (const key of EDITABLE_FIELDS) {
if (key in next && !same(current[key], next[key])) out[key] = next[key];
}
return out as Partial<TaskFields>;
}
export function formatValue(key: EditableField, value: unknown): string {
if (value === null || value === undefined || value === '') return '—';
if (key === 'source_urls' && Array.isArray(value)) return value.join('\n');
if (key === 'status') return STATUS_LABELS[value as Status] ?? String(value);
if (key === 'scale') return SCALE_INFO[value as Scale]?.label ?? String(value);
return String(value);
}
/** Does a task match the list filters (used for live updates on open list pages)? */
export function matchesFilters(
task: PublicTask,
f: { scale: string; category: string; status: string; q: string }
): boolean {
if (f.scale && task.scale !== f.scale) return false;
if (f.category && task.category !== f.category) return false;
if (f.status === 'todo' && task.status === 'done') return false;
if (['open', 'in_progress', 'done'].includes(f.status) && task.status !== f.status) return false;
if (f.q) {
const hay = `${task.title} ${task.description} ${task.locationName}`.toLowerCase();
if (
!f.q
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
.every((w) => hay.includes(w))
)
return false;
}
return true;
}
export function relativeTime(iso: string, now = Date.now()): string {
const s = Math.round((now - new Date(iso).getTime()) / 1000);
if (s < 60) return 'just now';
const units: [number, string][] = [
[60, 'minute'],
[3600, 'hour'],
[86400, 'day'],
[2592000, 'month'],
[31536000, 'year']
];
let label = '';
for (let i = units.length - 1; i >= 0; i--) {
const [size, name] = units[i];
if (s >= size) {
const n = Math.floor(s / size);
label = `${n} ${name}${n === 1 ? '' : 's'} ago`;
break;
}
}
return label;
}