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
Some checks failed
Deploy production / deploy (push) Failing after 9s
This commit is contained in:
180
src/lib/worldtodo/shared.ts
Normal file
180
src/lib/worldtodo/shared.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user