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,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;
}