Files
website/src/lib/worldtodo/shared.ts
2026-10-26 08:38:51 +00:00

196 lines
5.9 KiB
TypeScript

// 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];
export const WORLDTODO_LOCATION_PRECISIONS = ['exact', 'approximate', 'none'] as const;
export type LocationPrecision = (typeof WORLDTODO_LOCATION_PRECISIONS)[number];
/** Same rule as the tasks_location_precision trigger: what kind of pin a task gets. */
export function precisionFor(
scale: Scale,
lat: number | null | undefined,
lon: number | null | undefined
): LocationPrecision {
if (lat === null || lat === undefined || lon === null || lon === undefined) return 'none';
if (scale === 'planet') return 'none';
return scale === 'street' || scale === 'city' ? 'exact' : 'approximate';
}
/** 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;
/** Map pin kind (see precisionFor). */
locationPrecision: LocationPrecision;
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;
}