worldwide todo list: global map (List/Map toggle, clustered category pins, Global strip, pick on map, location_precision, tasks.geojson)
All checks were successful
Deploy production / deploy (push) Successful in 1m13s

This commit is contained in:
2026-10-26 08:38:51 +00:00
parent 21b10ea894
commit 9d79dfb43a
15 changed files with 42669 additions and 13 deletions

View File

@@ -18,18 +18,20 @@ export const load: PageServerLoad = async ({ url, parent }) => {
const status = STATUSES.includes(first('status')) ? first('status') : 'todo';
const sort = (SORTS as string[]).includes(first('sort')) ? (first('sort') as Sort) : 'updated';
const q = first('q').slice(0, 100);
const view = first('view') === 'map' ? 'map' : 'list';
const filter = { scale, category, status, q };
const [list, totals] = await Promise.all([listTasks(filter, sort, 500), counts(filter)]);
return {
filter,
sort,
view,
tasks: list.tasks,
counts: totals,
seo: {
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.',
index: !scale && !category && status === 'todo' && !q && sort === 'updated'
index: !scale && !category && status === 'todo' && !q && sort === 'updated' && view === 'list'
}
};
};

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { untrack } from 'svelte';
import TaskCard from '#lib/worldtodo/TaskCard.svelte';
import WorldMap from '#lib/worldtodo/WorldMap.svelte';
import StatusPill from '#lib/worldtodo/StatusPill.svelte';
import IconTile from '#lib/worldtodo/IconTile.svelte';
import { SCALE_ICONS, categoryIcon } from '#lib/worldtodo/icons';
import { subscribe } from '#lib/worldtodo/live';
@@ -12,6 +14,9 @@
type PublicTask
} from '#lib/worldtodo/shared';
import Plus from 'lucide-svelte/icons/plus';
import List from 'lucide-svelte/icons/list';
import MapIcon from 'lucide-svelte/icons/map';
import Earth from 'lucide-svelte/icons/earth';
import Search from 'lucide-svelte/icons/search';
let { data } = $props();
@@ -90,6 +95,31 @@
};
const f = $derived(data.filter);
const isMap = $derived(data.view === 'map');
// List / Map toggle keeps every filter in the URL (?view=map is shareable).
const viewHref = (view: 'list' | 'map') => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- plain lookup/scratch, never read reactively
const p = new URLSearchParams();
if (f.scale) p.set('scale', f.scale);
if (f.category) p.set('category', f.category);
if (f.status !== 'todo') p.set('status', f.status);
if (f.q) p.set('q', f.q);
if (data.sort !== 'updated') p.set('sort', data.sort);
if (view === 'map') p.set('view', 'map');
const s = p.toString();
return `/projects/worldwide-todo${s ? `?${s}` : ''}`;
};
const pinnable = (t: PublicTask) => t.locationPrecision !== 'none';
const globalTasks = $derived(tasks.filter((t) => !pinnable(t)));
const mapListed = $derived(tasks.filter(pinnable));
const fitKey = $derived(JSON.stringify(data.filter));
let mapRef = $state<ReturnType<typeof WorldMap>>();
let hovered = $state<string | null>(null);
function cardClick(e: MouseEvent, task: PublicTask) {
if (!isMap || !mapRef || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button) return;
e.preventDefault();
mapRef.focusTask(task.slug);
}
const total = $derived(data.counts.status.open + data.counts.status.in_progress);
</script>
@@ -185,16 +215,75 @@
{#if f.scale}<input type="hidden" name="scale" value={f.scale} />{/if}
{#if f.category}<input type="hidden" name="category" value={f.category} />{/if}
{#if f.status !== 'todo'}<input type="hidden" name="status" value={f.status} />{/if}
{#if isMap}<input type="hidden" name="view" value="map" />{/if}
</form>
<p class="results" aria-live="polite">
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q}&nbsp;matching “{f.q}”{/if}
{#if f.scale || f.category || f.q || f.status !== 'todo'}
· <a href="/projects/worldwide-todo" data-sveltekit-noscroll>clear filters</a>
{/if}
</p>
<div class="results-bar">
<p class="results" aria-live="polite">
{tasks.length} task{tasks.length === 1 ? '' : 's'}{#if f.q}&nbsp;matching “{f.q}”{/if}
{#if isMap}· {mapListed.length} on the map{/if}
{#if f.scale || f.category || f.q || f.status !== 'todo'}
· <a
href={isMap ? '/projects/worldwide-todo?view=map' : '/projects/worldwide-todo'}
data-sveltekit-noscroll>clear filters</a
>
{/if}
</p>
<nav class="views" aria-label="View">
<a
href={viewHref('list')}
class={{ on: !isMap }}
aria-current={!isMap ? 'page' : undefined}
data-sveltekit-noscroll
data-sveltekit-keepfocus><List size={15} aria-hidden="true" />List</a
>
<a
href={viewHref('map')}
class={{ on: isMap }}
aria-current={isMap ? 'page' : undefined}
data-sveltekit-noscroll
data-sveltekit-keepfocus><MapIcon size={15} aria-hidden="true" />Map</a
>
</nav>
</div>
<p class="sr-only" aria-live="polite">{announce}</p>
{#if isMap}
{#if globalTasks.length}
<section class="global" aria-label="Global tasks (no single place on the map)">
<h2><Earth size={15} aria-hidden="true" />Global <span>{globalTasks.length}</span></h2>
<ul>
{#each globalTasks as task (task.slug)}
{@const cat = catBySlug.get(task.category)}
<li>
<a
href="/projects/worldwide-todo/{task.slug}"
class={{ flash: flashing === task.slug }}
>
<span class="g-top">
<span class="badge">{task.scale === 'planet' ? 'Global' : 'No pin'}</span>
<span class="g-cat"
><i style="background:{cat?.colour ?? '#737373'}"></i>{cat?.label ??
task.category}</span
>
<StatusPill status={task.status} />
</span>
<span class="g-title">{task.title}</span>
</a>
</li>
{/each}
</ul>
</section>
{/if}
<WorldMap
bind:this={mapRef}
tasks={mapListed}
categories={data.categories}
highlight={hovered}
{fitKey}
/>
{/if}
<ul class="grid">
<li>
<a class="suggest" href="/projects/worldwide-todo/request?kind=add">
@@ -203,8 +292,14 @@
<span>Phrase it as something to do and link a source. It shows up here once approved.</span>
</a>
</li>
{#each tasks as task (task.slug)}
<li>
{#each isMap ? mapListed : tasks as task (task.slug)}
<li
onmouseenter={() => (hovered = task.slug)}
onmouseleave={() => (hovered = hovered === task.slug ? null : hovered)}
onfocusin={() => (hovered = task.slug)}
onfocusout={() => (hovered = hovered === task.slug ? null : hovered)}
onclickcapture={(e) => cardClick(e, task)}
>
<TaskCard
{task}
category={catBySlug.get(task.category)}
@@ -379,6 +474,133 @@
font-size: 0.8rem;
color: var(--site-muted);
}
.results-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.views {
display: inline-flex;
gap: 0.35rem;
}
.views a {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.8rem;
border: 1px solid var(--site-border);
border-radius: 0.7rem;
background: var(--site-surface);
color: var(--site-muted);
font-size: 0.82rem;
font-weight: 550;
text-decoration: none;
transition:
border-color 0.15s,
color 0.15s,
transform 0.15s;
}
.views a:hover,
.views a:focus-visible {
color: var(--site-text);
border-color: var(--site-muted);
transform: translateY(-1px);
}
.views a.on {
color: var(--site-text);
border-color: var(--site-text);
box-shadow: inset 0 0 0 1px var(--site-text);
}
.global h2 {
display: flex;
align-items: center;
gap: 0.4rem;
margin-bottom: 0.4rem;
font-size: 0.85rem;
font-weight: 600;
}
.global h2 :global(svg) {
color: #38bdf8;
}
.global h2 span {
color: var(--site-muted);
font-weight: 450;
font-variant-numeric: tabular-nums;
}
.global ul {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(15rem, 18rem);
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.35rem;
scroll-snap-type: x proximity;
scrollbar-width: thin;
}
.global li {
scroll-snap-align: start;
}
.global a {
display: flex;
flex-direction: column;
gap: 0.35rem;
height: 100%;
padding: 0.6rem 0.7rem;
border: 1px solid var(--site-border);
border-radius: 0.75rem;
background: var(--site-surface);
color: var(--site-text);
text-decoration: none;
transition:
border-color 0.15s,
transform 0.15s,
background-color 0.6s;
}
.global a:hover,
.global a:focus-visible {
border-color: var(--site-muted);
transform: translateY(-1px);
}
.global a.flash {
background: color-mix(in srgb, var(--site-surface) 80%, #facc15);
}
.g-top {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
font-size: 0.72rem;
color: var(--site-muted);
}
.badge {
padding: 0.05rem 0.45rem;
border-radius: 999px;
color: #fff;
font-weight: 600;
background: linear-gradient(135deg, #38bdf8, #16a34a);
}
.g-cat {
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.g-cat i {
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
}
.g-title {
font-size: 0.85rem;
font-weight: 550;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.results a {
color: var(--site-text);
}

View File

@@ -0,0 +1,45 @@
import { listTasks } from '#lib/server/worldtodo/store.js';
import type { RequestHandler } from './$types';
// GET /projects/worldwide-todo/api/tasks.geojson?scale=&category=&status=&q=
// Every task with a map position (precision exact or approximate) as a GeoJSON
// FeatureCollection, same filters as /api/tasks. Planet tasks and tasks without coordinates
// are left out (precision 'none').
export const GET: RequestHandler = async ({ url }) => {
const p = (k: string) => url.searchParams.get(k)?.trim() ?? '';
const scale = ['planet', 'region', 'country', 'city', 'street'].includes(p('scale'))
? p('scale')
: '';
const status = ['todo', 'open', 'in_progress', 'done', 'all'].includes(p('status'))
? p('status')
: 'todo';
const category = /^[a-z][a-z0-9-]{1,30}$/.test(p('category')) ? p('category') : '';
const { tasks } = await listTasks(
{ scale, category, status, q: p('q').slice(0, 100) },
'updated',
1000
);
const features = tasks
.filter((t) => t.locationPrecision !== 'none' && t.lat !== null && t.lon !== null)
.map((t) => ({
type: 'Feature' as const,
id: t.id,
geometry: { type: 'Point' as const, coordinates: [t.lon!, t.lat!] },
properties: {
slug: t.slug,
title: t.title,
scale: t.scale,
category: t.category,
status: t.status,
location_name: t.locationName,
location_precision: t.locationPrecision,
url: `${url.origin}/projects/worldwide-todo/${t.slug}`
}
}));
return new Response(JSON.stringify({ type: 'FeatureCollection', features }), {
headers: {
'content-type': 'application/geo+json; charset=utf-8',
'cache-control': 'public, max-age=30'
}
});
};

View File

@@ -60,6 +60,16 @@
new: formatValue(k, change[k])
}));
}
/** Coordinates the request proposes (picked on the map or typed), to check before approving. */
function proposedPosition(item: Item) {
const p = item.payload as Record<string, unknown>;
if (item.kind === 'delete' || !('lat' in p || 'lon' in p)) return null;
const lat = Number(p.lat ?? item.task?.lat);
const lon = Number(p.lon ?? item.task?.lon);
return p.lat !== null && p.lon !== null && Number.isFinite(lat) && Number.isFinite(lon)
? { lat, lon }
: null;
}
function tweakValues(item: Item) {
const base = item.task ? fieldsOf(item.task) : {};
return { ...base, ...item.payload } as Record<string, unknown>;
@@ -194,6 +204,15 @@
{/each}
</tbody>
</table>
{@const pos = proposedPosition(item)}
{#if pos}
<a
class="osm"
href="https://www.openstreetmap.org/?mlat={pos.lat}&mlon={pos.lon}#map=15/{pos.lat}/{pos.lon}"
target="_blank"
rel="noopener noreferrer">Check the proposed map position on OpenStreetMap ↗</a
>
{/if}
{/if}
<dl class="meta">
@@ -573,4 +592,10 @@
transform: none;
}
}
.osm {
display: inline-block;
margin-top: 0.4rem;
font-size: 0.78rem;
color: var(--site-text);
}
</style>