All checks were successful
Deploy production / deploy (push) Successful in 1m13s
196 lines
5.9 KiB
TypeScript
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;
|
|
}
|