Task + goal tracker: private tasks schema, /trackers/tasks page, idempotent seed import (daisy-import)
All checks were successful
Deploy production / deploy (push) Successful in 2m38s
All checks were successful
Deploy production / deploy (push) Successful in 2m38s
This commit is contained in:
643
data/tasks/daisy-import.json
Normal file
643
data/tasks/daisy-import.json
Normal file
@@ -0,0 +1,643 @@
|
|||||||
|
{
|
||||||
|
"source": "daisy-import",
|
||||||
|
"areas": [
|
||||||
|
{
|
||||||
|
"slug": "deadlines",
|
||||||
|
"name": "Deadlines and dates",
|
||||||
|
"sortOrder": 10
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "house",
|
||||||
|
"name": "House and moving",
|
||||||
|
"description": "Jan–Feb 2027 with Juhee",
|
||||||
|
"sortOrder": 20
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "kibi",
|
||||||
|
"name": "Kibi",
|
||||||
|
"sortOrder": 30
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "admin",
|
||||||
|
"name": "Phone, subscriptions and admin",
|
||||||
|
"sortOrder": 40
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "clients",
|
||||||
|
"name": "Clients",
|
||||||
|
"sortOrder": 50
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "website",
|
||||||
|
"name": "Website (stanrunge.dev)",
|
||||||
|
"sortOrder": 60
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "setup",
|
||||||
|
"name": "Setup and security",
|
||||||
|
"description": "Perma evolvable setup, before 2027",
|
||||||
|
"sortOrder": 70
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "home",
|
||||||
|
"name": "Home",
|
||||||
|
"sortOrder": 80
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "health",
|
||||||
|
"name": "Health and self-care",
|
||||||
|
"sortOrder": 90
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "reading",
|
||||||
|
"name": "Reading",
|
||||||
|
"sortOrder": 100
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slug": "planning",
|
||||||
|
"name": "Planning and goals",
|
||||||
|
"sortOrder": 110
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"goals": [
|
||||||
|
{
|
||||||
|
"title": "€10k net/month into own Revolut by 2030",
|
||||||
|
"area": "planning",
|
||||||
|
"targetDate": "2030-01-01",
|
||||||
|
"metric": "Net income per month",
|
||||||
|
"metricUnit": "EUR",
|
||||||
|
"metricTarget": 10000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Own a really nice Ford Mustang GT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "PC overhaul",
|
||||||
|
"notes": "Top AMD X3D CPU, RTX 5090.",
|
||||||
|
"metric": "Budget",
|
||||||
|
"metricUnit": "EUR",
|
||||||
|
"metricTarget": 10000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Organise goals into a plan",
|
||||||
|
"area": "planning",
|
||||||
|
"notes": "Social media, Grok Bot, IT company."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"area": "setup",
|
||||||
|
"targetDate": "2026-12-31"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Get in shape long term",
|
||||||
|
"area": "health"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Jan Loman printer visit",
|
||||||
|
"status": "done",
|
||||||
|
"notes": "Settled.",
|
||||||
|
"due": "2026-10-24",
|
||||||
|
"dueTime": "14:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Call BackHome about Kibi's chip",
|
||||||
|
"status": "todo",
|
||||||
|
"due": "2026-10-26",
|
||||||
|
"dueTime": "10:00",
|
||||||
|
"tags": ["kibi", "call"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Cargill collective health insurance (OHRA, code 114)",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Needs current insurer + premium to compare.",
|
||||||
|
"due": "2026-12-31",
|
||||||
|
"tags": ["insurance"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Reply to mom about insurance info",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Waiting since Oct 9.",
|
||||||
|
"tags": ["insurance"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Stop zorgtoeslag from Jan 2027",
|
||||||
|
"status": "todo",
|
||||||
|
"due": "2027-01-01",
|
||||||
|
"reminderAt": "2026-12-07T09:00:00+01:00",
|
||||||
|
"tags": ["admin"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Supermarket product list",
|
||||||
|
"status": "todo",
|
||||||
|
"reminderAt": "2026-10-24T11:50:00+02:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "deadlines",
|
||||||
|
"title": "Book haircut",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Around Nov 14.",
|
||||||
|
"due": "2026-11-14"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "House overview",
|
||||||
|
"status": "in_progress",
|
||||||
|
"flagged": true,
|
||||||
|
"notes": "Applied only to Graaf Hendrikstraat 16 so far."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "React to house options",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Raam €1,475; Burg. van Dijkesingel 214-B; Groenhovenweg 161; Spoorstraat €795; Noothoven van Goorstraat 1-206."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "Tell Daisy budget, move date, must-haves, Funda/Pararius applications",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "Mijndak Midden-Holland sign-up",
|
||||||
|
"status": "todo",
|
||||||
|
"flagged": true,
|
||||||
|
"notes": "€23 option."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "Sign up: socialehuurwoning.nl, Vesteda Goudshof, VBT, Viramah, agency Jesse's mom uses",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "House tracker/scraper on the site",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["website"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "Update address everywhere",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Checklist ready."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "house",
|
||||||
|
"title": "UniFi network for the new place",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["network"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Food ordered",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Chip scanner ordered",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Chip number found",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Chip owner change at BackHome",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Still 'Kwiebus' under E Zwart."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Call vet about Kibi's patient card",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["call"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "GPS tracker",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Dad may have done it already."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "kibi",
|
||||||
|
"title": "Dash KIBI",
|
||||||
|
"status": "todo",
|
||||||
|
"flagged": true,
|
||||||
|
"notes": "Meaning unclear, ask Stan."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Basic-Fit cancelled",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Ohra inboedelverzekering cancelled",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Zorgtoeslag income updated for 2026",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Move number to Revolut eSIM, then iPad eSIM, cancel Odido",
|
||||||
|
"status": "in_progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Move SIM + WhatsApp from iPhone to Pixel 11 Pro XL",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Check Oasen and Delta Energie fully cancelled",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Cancel other unused subscriptions",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "admin",
|
||||||
|
"title": "Merge banks into Revolut",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Quote dack €450 for vet clinic site fix",
|
||||||
|
"status": "done",
|
||||||
|
"tags": ["dack"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Reply to dack",
|
||||||
|
"status": "done",
|
||||||
|
"tags": ["dack"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Cloudflare DNS/SSL for dack",
|
||||||
|
"status": "in_progress",
|
||||||
|
"tags": ["dack"],
|
||||||
|
"notes": "Grind-week goal."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Send dack's invoice via Moneybird",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["dack"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Cheap shared WordPress hosting for dack's clients on own VMs",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["dack"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Karolina's Wix site, Master Purpose (NL/PL)",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["karolina"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "clients",
|
||||||
|
"title": "Wix Studio + Velo/CLI so an AI can edit code",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["karolina"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "DB reorganised into schemas, staging retired",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Energy tracker (NED data) live",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Health tracker (private) + 15 RIVM vaccinations",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Book tracker, reader, 362 books live",
|
||||||
|
"status": "done"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Reader zoom, 2-page view, scroll/paging",
|
||||||
|
"status": "in_progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Money tracker with MoneyMan",
|
||||||
|
"status": "in_progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "NED backfill",
|
||||||
|
"status": "in_progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Music listening tracker + now-playing",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Task/goal tracker to replace TickTick",
|
||||||
|
"status": "in_progress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Hardware DB (current PC parts first)",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "List of every organisation holding my data and what they know",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Move to SvelteKit 3",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "website",
|
||||||
|
"title": "Juhee website data",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "AWS cleanup ($0/month)",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Google Cloud cleanup",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "stan-vm hardened (ufw, Tailscale-only SSH)",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Nightly restic backups to B2 + Storage Box",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "WhatsApp bridge on stan-vm",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "gcloud + moneybird-cli on stan-vm",
|
||||||
|
"status": "done",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Domains Squarespace to Cloudflare",
|
||||||
|
"status": "in_progress",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"notes": "Both transfers pending.",
|
||||||
|
"due": "2026-10-29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Syncthing: Introducer + Auto Accept on Arch and Windows",
|
||||||
|
"status": "in_progress",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Backup follow-up: restic passwords into 1Password",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"due": "2026-10-24",
|
||||||
|
"dueTime": "11:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Delete empty B2 bucket",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"due": "2026-10-25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Rotate weak admin secret + DB password",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"tags": ["security"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "1Password overhaul",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"notes": "Imports, 2FA, passkeys, dedupe, delete unused accounts.",
|
||||||
|
"tags": ["security"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Moneybird API token, then clean up Moneybird",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Authentik SSO on stan-vm",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "vash-prod memory warnings + finish security audit",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"tags": ["security"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Remove old grok-bot devices from Tailscale",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Close empty GCP billing account; decide on AWS 'Vash Software'",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"notes": "AWS part optional."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Raise WSL RAM",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"notes": "Sees 8 of 16 GB."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Home server rebuild with ZFS",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Analytics, monitoring, alerts across devices",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Mouse sensitivity cm/360 across devices",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Buy Logitech G840 XL mousepad",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"tags": ["buy"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Router and networking setup",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"tags": ["network"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "J5 Hexagon research, then move wachtboden",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "Agent-deck + AI/Linux dev setup",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "setup",
|
||||||
|
"title": "MITM/certificate lab",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Perma evolvable setup foundation ready before 2027",
|
||||||
|
"notes": "Idea.",
|
||||||
|
"tags": ["idea"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Unpack boxes; organise batteries and tools; buy thermometer",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Clean desk, laundry, clothes",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Connect vinyl player, printer, monitor, switch, osu! pad, tablet, fridge, microwave",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Hue lights + Home Assistant + Apple Home",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Fan controller",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "home",
|
||||||
|
"title": "Opa's stuff (WhatsApp, sensor install)",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Getting in shape with Doc",
|
||||||
|
"status": "in_progress",
|
||||||
|
"notes": "Lesson 1 done; ~2,300–2,400 kcal and 160–190 g protein a day.",
|
||||||
|
"goal": "Get in shape long term"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Join the gym my parents use",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Get in shape long term"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Brush teeth twice a day, shower, hair care",
|
||||||
|
"status": "todo",
|
||||||
|
"tags": ["habit"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Dentist checkup",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Pick hardware for Withings/Garmin syncs",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "health",
|
||||||
|
"title": "Curaçao vaccines",
|
||||||
|
"status": "todo",
|
||||||
|
"notes": "Via Doc."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "reading",
|
||||||
|
"title": "Read 'De meeste mensen deugen' (Bregman)",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "reading",
|
||||||
|
"title": "Re-download 'Haves and Have-Nots'",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "reading",
|
||||||
|
"title": "Book questions: duplicates, 6 unknown files, Nick's repo 404",
|
||||||
|
"status": "todo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"area": "planning",
|
||||||
|
"title": "Schedule sync (paper agenda, TickTick, Daisy)",
|
||||||
|
"status": "todo",
|
||||||
|
"goal": "Organise goals into a plan"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ export default defineConfig({
|
|||||||
'site',
|
'site',
|
||||||
'meta',
|
'meta',
|
||||||
'health',
|
'health',
|
||||||
'books'
|
'books',
|
||||||
|
'tasks'
|
||||||
],
|
],
|
||||||
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
migrations: { schema: 'drizzle', table: '__drizzle_migrations' },
|
||||||
verbose: true,
|
verbose: true,
|
||||||
|
|||||||
142
drizzle/20261024022836_tasks/migration.sql
Normal file
142
drizzle/20261024022836_tasks/migration.sql
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
CREATE SCHEMA "tasks";
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tasks"."activity" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"task_id" integer,
|
||||||
|
"goal_id" integer,
|
||||||
|
"kind" text NOT NULL,
|
||||||
|
"from_status" text,
|
||||||
|
"to_status" text,
|
||||||
|
"note" text,
|
||||||
|
"value" numeric(14,2),
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "activity_target_check" CHECK ("task_id" IS NOT NULL OR "goal_id" IS NOT NULL),
|
||||||
|
CONSTRAINT "activity_kind_check" CHECK ("kind" IN ('created','status','edit','note','progress'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tasks"."areas" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"slug" text NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"color" text,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"archived_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "areas_slug_check" CHECK ("slug" ~ '^[a-z0-9][a-z0-9-]{0,62}$'),
|
||||||
|
CONSTRAINT "areas_name_check" CHECK (length(btrim("name")) > 0)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tasks"."goals" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"status" text DEFAULT 'active' NOT NULL,
|
||||||
|
"area_id" integer,
|
||||||
|
"target_date" date,
|
||||||
|
"metric" text,
|
||||||
|
"metric_unit" text,
|
||||||
|
"metric_target" numeric(14,2),
|
||||||
|
"metric_current" numeric(14,2),
|
||||||
|
"flagged" boolean DEFAULT false NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"source" text,
|
||||||
|
"achieved_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "goals_title_check" CHECK (length(btrim("title")) > 0),
|
||||||
|
CONSTRAINT "goals_status_check" CHECK ("status" IN ('active','achieved','dropped'))
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "tasks"."tasks" (
|
||||||
|
"id" serial PRIMARY KEY,
|
||||||
|
"title" text NOT NULL,
|
||||||
|
"notes" text,
|
||||||
|
"status" text DEFAULT 'todo' NOT NULL,
|
||||||
|
"priority" smallint DEFAULT 0 NOT NULL,
|
||||||
|
"flagged" boolean DEFAULT false NOT NULL,
|
||||||
|
"due_date" date,
|
||||||
|
"due_time" time,
|
||||||
|
"reminder_at" timestamp with time zone,
|
||||||
|
"recurrence" text,
|
||||||
|
"area_id" integer,
|
||||||
|
"goal_id" integer,
|
||||||
|
"parent_id" integer,
|
||||||
|
"tags" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||||
|
"sort_order" integer DEFAULT 0 NOT NULL,
|
||||||
|
"source" text,
|
||||||
|
"external_id" text,
|
||||||
|
"started_at" timestamp with time zone,
|
||||||
|
"completed_at" timestamp with time zone,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "tasks_title_check" CHECK (length(btrim("title")) > 0),
|
||||||
|
CONSTRAINT "tasks_status_check" CHECK ("status" IN ('todo','in_progress','done','dropped')),
|
||||||
|
CONSTRAINT "tasks_priority_check" CHECK ("priority" BETWEEN 0 AND 3),
|
||||||
|
CONSTRAINT "tasks_parent_check" CHECK ("parent_id" IS NULL OR "parent_id" <> "id"),
|
||||||
|
CONSTRAINT "tasks_time_check" CHECK ("due_time" IS NULL OR "due_date" IS NOT NULL)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX "activity_task_idx" ON "tasks"."activity" ("task_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "activity_goal_idx" ON "tasks"."activity" ("goal_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "areas_slug_key" ON "tasks"."areas" ("slug");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "goals_title_key" ON "tasks"."goals" (lower("title"));--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_area_idx" ON "tasks"."tasks" ("area_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_goal_idx" ON "tasks"."tasks" ("goal_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_parent_idx" ON "tasks"."tasks" ("parent_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_status_due_idx" ON "tasks"."tasks" ("status","due_date");--> statement-breakpoint
|
||||||
|
CREATE INDEX "tasks_tags_idx" ON "tasks"."tasks" USING gin ("tags");--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "tasks_source_external_key" ON "tasks"."tasks" ("source","external_id") WHERE "external_id" IS NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_task_id_tasks_id_fkey" FOREIGN KEY ("task_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."activity" ADD CONSTRAINT "activity_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."goals" ADD CONSTRAINT "goals_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_area_id_areas_id_fkey" FOREIGN KEY ("area_id") REFERENCES "tasks"."areas"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_goal_id_goals_id_fkey" FOREIGN KEY ("goal_id") REFERENCES "tasks"."goals"("id") ON DELETE SET NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "tasks"."tasks" ADD CONSTRAINT "tasks_parent_id_tasks_id_fkey" FOREIGN KEY ("parent_id") REFERENCES "tasks"."tasks"("id") ON DELETE CASCADE;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Hand-written below this line (not generated by drizzle-kit).
|
||||||
|
-- updated_at triggers, same as every other core table.
|
||||||
|
DO $$
|
||||||
|
DECLARE t text;
|
||||||
|
BEGIN
|
||||||
|
IF to_regprocedure('meta.touch_updated_at()') IS NOT NULL THEN
|
||||||
|
FOREACH t IN ARRAY ARRAY['areas','goals','tasks'] LOOP
|
||||||
|
EXECUTE format('CREATE TRIGGER touch_updated_at BEFORE UPDATE ON tasks.%I FOR EACH ROW EXECUTE FUNCTION meta.touch_updated_at()', t);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Privacy: like health, the task tracker is readable/writable by the app role only
|
||||||
|
-- (no personal_ro grant). Grants only run on prod, where migrations run as `personal`.
|
||||||
|
REVOKE ALL ON SCHEMA tasks FROM PUBLIC;
|
||||||
|
--> statement-breakpoint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF current_user = 'personal' AND EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||||
|
GRANT USAGE ON SCHEMA tasks TO personal_app;
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tasks TO personal_app;
|
||||||
|
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA tasks TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO personal_app;
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE personal IN SCHEMA tasks
|
||||||
|
GRANT USAGE, SELECT ON SEQUENCES TO personal_app;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
--> statement-breakpoint
|
||||||
|
-- Registered in meta.tables (structure only; the lists GUI only shows kind = 'list').
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('meta.tables') IS NOT NULL THEN
|
||||||
|
INSERT INTO meta.tables (schema_name, table_name, slug, display_name, kind, editable, has_done, sort_order)
|
||||||
|
VALUES ('tasks','tasks','tasks-tasks','Tasks','core',true,true,1000),
|
||||||
|
('tasks','goals','tasks-goals','Goals','core',true,false,1000),
|
||||||
|
('tasks','areas','tasks-areas','Task areas','core',true,false,1000),
|
||||||
|
('tasks','activity','tasks-activity','Task activity','core',false,false,1000)
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
-- deploy-remote.sh runs meta.sync() as the app role after migrating.
|
||||||
|
IF has_function_privilege('meta.sync(integer)', 'EXECUTE') THEN
|
||||||
|
PERFORM meta.sync();
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
14935
drizzle/20261024022836_tasks/snapshot.json
Normal file
14935
drizzle/20261024022836_tasks/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -129,6 +129,28 @@ try {
|
|||||||
health.public_usage
|
health.public_usage
|
||||||
)
|
)
|
||||||
throw new Error(`health schema grants are wrong: ${JSON.stringify(health)}`);
|
throw new Error(`health schema grants are wrong: ${JSON.stringify(health)}`);
|
||||||
|
// Private task tracker: same rules as health (app role read/write, personal_ro none).
|
||||||
|
const [tasks] = await client`
|
||||||
|
select
|
||||||
|
count(*) filter (where has_table_privilege(c.oid, 'select, insert, update, delete')) as app_rw,
|
||||||
|
count(*) filter (
|
||||||
|
where exists (
|
||||||
|
select 1 from aclexplode(c.relacl) a join pg_roles r on r.oid = a.grantee
|
||||||
|
where r.rolname = 'personal_ro'
|
||||||
|
)
|
||||||
|
) as ro_read,
|
||||||
|
count(*) as tables,
|
||||||
|
has_schema_privilege('public', 'tasks', 'usage') as public_usage
|
||||||
|
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
||||||
|
where n.nspname = 'tasks' and c.relkind = 'r'
|
||||||
|
`;
|
||||||
|
if (
|
||||||
|
Number(tasks.tables) !== 4 ||
|
||||||
|
Number(tasks.app_rw) !== 4 ||
|
||||||
|
Number(tasks.ro_read) !== 0 ||
|
||||||
|
tasks.public_usage
|
||||||
|
)
|
||||||
|
throw new Error(`tasks schema grants are wrong: ${JSON.stringify(tasks)}`);
|
||||||
// Energy tracker reads the ned-collector tables (SELECT only).
|
// Energy tracker reads the ned-collector tables (SELECT only).
|
||||||
const [energy] = await client`
|
const [energy] = await client`
|
||||||
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
select count(*) as hours, max(valid_from) as latest from energy.ned_utilization
|
||||||
@@ -138,7 +160,7 @@ try {
|
|||||||
if (!Number(energy.hours))
|
if (!Number(energy.hours))
|
||||||
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
console.warn('Warning: energy.ned_utilization has no hourly data from the last 7 days');
|
||||||
console.log(
|
console.log(
|
||||||
`health: ${health.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
`health: ${health.tables} tables, tasks: ${tasks.tables} tables, app read/write, personal_ro none; energy: ${energy.hours} hourly rows, latest ${energy.latest ? new Date(energy.latest).toISOString() : 'none'}`
|
||||||
);
|
);
|
||||||
const [catalog] = await client`
|
const [catalog] = await client`
|
||||||
select
|
select
|
||||||
|
|||||||
46
scripts/tasks-import.ts
Normal file
46
scripts/tasks-import.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Import tasks, goals and areas into the private `tasks` schema (idempotent).
|
||||||
|
*
|
||||||
|
* bun scripts/tasks-import.ts data/tasks/daisy-import.json # insert what is missing
|
||||||
|
* bun scripts/tasks-import.ts --dry-run data/tasks/daisy-import.json # validate only
|
||||||
|
* bun scripts/tasks-import.ts --update data/tasks/daisy-import.json # also overwrite existing rows
|
||||||
|
*
|
||||||
|
* Dedupe: areas on slug, goals on title, tasks on area + title (case-insensitive).
|
||||||
|
* Uses DB_URL (the app role on prod, personal_dev on dev).
|
||||||
|
*/
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import { importSeed, seedFile } from '../src/lib/server/tasks';
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const dryRun = args.includes('--dry-run');
|
||||||
|
const update = args.includes('--update');
|
||||||
|
const files = args.filter((a) => !a.startsWith('--'));
|
||||||
|
if (files.length !== 1) {
|
||||||
|
console.error('usage: bun scripts/tasks-import.ts [--dry-run] [--update] <seed.json>');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
const parsed = seedFile.safeParse(JSON.parse(await readFile(files[0], 'utf8')));
|
||||||
|
if (!parsed.success) {
|
||||||
|
for (const issue of parsed.error.issues.slice(0, 50))
|
||||||
|
console.error(`${issue.path.join('.')}: ${issue.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const seed = parsed.data;
|
||||||
|
console.log(
|
||||||
|
`Valid: ${seed.areas.length} areas, ${seed.goals.length} goals, ${seed.tasks.length} tasks`
|
||||||
|
);
|
||||||
|
if (dryRun) process.exit(0);
|
||||||
|
if (!process.env.DB_URL) throw new Error('DB_URL is required');
|
||||||
|
const client = postgres(process.env.DB_URL, { max: 1, onnotice: () => {} });
|
||||||
|
try {
|
||||||
|
const r = await importSeed(drizzle({ client }), seed, { update });
|
||||||
|
console.log(
|
||||||
|
`areas: ${r.areas.inserted} inserted, ${r.areas.existing} existing; ` +
|
||||||
|
`goals: ${r.goals.inserted} inserted, ${r.goals.existing} existing, ${r.goals.updated} updated; ` +
|
||||||
|
`tasks: ${r.tasks.inserted} inserted, ${r.tasks.existing} existing, ${r.tasks.updated} updated`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
@@ -92,6 +92,12 @@ export const privateTrackers = [
|
|||||||
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
|
subtitle: 'private: body, workouts, sleep, meds and vaccinations',
|
||||||
href: '/trackers/health',
|
href: '/trackers/health',
|
||||||
external: false
|
external: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'tasks',
|
||||||
|
subtitle: 'private: to-dos and long-term goals',
|
||||||
|
href: '/trackers/tasks',
|
||||||
|
external: false
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ export * from './cs-prices';
|
|||||||
export * from './house-tracker';
|
export * from './house-tracker';
|
||||||
export * from './health';
|
export * from './health';
|
||||||
export * from './books';
|
export * from './books';
|
||||||
|
export * from './tasks';
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ export const siteSchema = pgSchema('site');
|
|||||||
export const metaSchema = pgSchema('meta');
|
export const metaSchema = pgSchema('meta');
|
||||||
export const healthSchema = pgSchema('health');
|
export const healthSchema = pgSchema('health');
|
||||||
export const booksSchema = pgSchema('books');
|
export const booksSchema = pgSchema('books');
|
||||||
|
export const tasksSchema = pgSchema('tasks');
|
||||||
|
|||||||
143
src/lib/server/db/schema/tasks.ts
Normal file
143
src/lib/server/db/schema/tasks.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import {
|
||||||
|
type AnyPgColumn,
|
||||||
|
boolean,
|
||||||
|
check,
|
||||||
|
date,
|
||||||
|
index,
|
||||||
|
integer,
|
||||||
|
numeric,
|
||||||
|
smallint,
|
||||||
|
text,
|
||||||
|
time,
|
||||||
|
uniqueIndex
|
||||||
|
} from 'drizzle-orm/pg-core';
|
||||||
|
import { tasksSchema } from './schemas';
|
||||||
|
import { id, timestamps, timestamptz } from './helpers';
|
||||||
|
|
||||||
|
// Private task + goal tracker (owner only, see requireOwner); meant to replace TickTick.
|
||||||
|
// No user_id: there is exactly one owner, and the schema is granted to personal_app only.
|
||||||
|
// Statuses are text + CHECK (not enums) so new ones are a one-line migration.
|
||||||
|
// Due dates are Europe/Amsterdam wall-clock (date + optional time), like TickTick's
|
||||||
|
// all-day vs timed tasks; reminders are real moments (timestamptz).
|
||||||
|
|
||||||
|
export const TASK_STATUSES = ['todo', 'in_progress', 'done', 'dropped'] as const;
|
||||||
|
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
||||||
|
export const GOAL_STATUSES = ['active', 'achieved', 'dropped'] as const;
|
||||||
|
export type GoalStatus = (typeof GOAL_STATUSES)[number];
|
||||||
|
|
||||||
|
/** Areas group tasks (TickTick "lists"); `slug` is the stable key for imports. */
|
||||||
|
export const todoAreas = tasksSchema.table(
|
||||||
|
'areas',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
description: text('description'),
|
||||||
|
color: text('color'), // CSS colour, optional
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
archivedAt: timestamptz('archived_at'),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('areas_slug_key').on(t.slug),
|
||||||
|
check('areas_slug_check', sql`${t.slug} ~ '^[a-z0-9][a-z0-9-]{0,62}$'`),
|
||||||
|
check('areas_name_check', sql`length(btrim(${t.name})) > 0`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Long-term goals, optionally with a target date and a numeric metric. */
|
||||||
|
export const todoGoals = tasksSchema.table(
|
||||||
|
'goals',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
notes: text('notes'),
|
||||||
|
status: text('status').$type<GoalStatus>().notNull().default('active'),
|
||||||
|
areaId: integer('area_id').references(() => todoAreas.id, { onDelete: 'set null' }),
|
||||||
|
targetDate: date('target_date', { mode: 'string' }),
|
||||||
|
metric: text('metric'), // what is measured, e.g. "net income per month"
|
||||||
|
metricUnit: text('metric_unit'), // e.g. "EUR"
|
||||||
|
metricTarget: numeric('metric_target', { precision: 14, scale: 2, mode: 'number' }),
|
||||||
|
metricCurrent: numeric('metric_current', { precision: 14, scale: 2, mode: 'number' }),
|
||||||
|
flagged: boolean('flagged').notNull().default(false),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
|
||||||
|
achievedAt: timestamptz('achieved_at'),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex('goals_title_key').on(sql`lower(${t.title})`),
|
||||||
|
check('goals_title_check', sql`length(btrim(${t.title})) > 0`),
|
||||||
|
check('goals_status_check', sql`${t.status} IN ('active','achieved','dropped')`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export const todoTasks = tasksSchema.table(
|
||||||
|
'tasks',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
notes: text('notes'),
|
||||||
|
status: text('status').$type<TaskStatus>().notNull().default('todo'),
|
||||||
|
priority: smallint('priority').notNull().default(0), // 0 none, 1 low, 2 medium, 3 high
|
||||||
|
flagged: boolean('flagged').notNull().default(false), // Stan's "(!)"
|
||||||
|
dueDate: date('due_date', { mode: 'string' }),
|
||||||
|
dueTime: time('due_time'), // optional; Europe/Amsterdam wall clock
|
||||||
|
reminderAt: timestamptz('reminder_at'),
|
||||||
|
recurrence: text('recurrence'), // RFC 5545 RRULE, e.g. FREQ=WEEKLY;BYDAY=MO (not acted on yet)
|
||||||
|
areaId: integer('area_id').references(() => todoAreas.id, { onDelete: 'set null' }),
|
||||||
|
goalId: integer('goal_id').references(() => todoGoals.id, { onDelete: 'set null' }),
|
||||||
|
parentId: integer('parent_id').references((): AnyPgColumn => todoTasks.id, {
|
||||||
|
onDelete: 'cascade'
|
||||||
|
}),
|
||||||
|
tags: text('tags').array().notNull().default([]),
|
||||||
|
sortOrder: integer('sort_order').notNull().default(0),
|
||||||
|
source: text('source'), // e.g. 'daisy-import', 'manual', 'ticktick'
|
||||||
|
externalId: text('external_id'), // id in the source system, for future syncs
|
||||||
|
startedAt: timestamptz('started_at'),
|
||||||
|
completedAt: timestamptz('completed_at'),
|
||||||
|
...timestamps
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('tasks_area_idx').on(t.areaId),
|
||||||
|
index('tasks_goal_idx').on(t.goalId),
|
||||||
|
index('tasks_parent_idx').on(t.parentId),
|
||||||
|
index('tasks_status_due_idx').on(t.status, t.dueDate),
|
||||||
|
index('tasks_tags_idx').using('gin', t.tags),
|
||||||
|
uniqueIndex('tasks_source_external_key')
|
||||||
|
.on(t.source, t.externalId)
|
||||||
|
.where(sql`${t.externalId} IS NOT NULL`),
|
||||||
|
check('tasks_title_check', sql`length(btrim(${t.title})) > 0`),
|
||||||
|
check('tasks_status_check', sql`${t.status} IN ('todo','in_progress','done','dropped')`),
|
||||||
|
check('tasks_priority_check', sql`${t.priority} BETWEEN 0 AND 3`),
|
||||||
|
check('tasks_parent_check', sql`${t.parentId} IS NULL OR ${t.parentId} <> ${t.id}`),
|
||||||
|
check('tasks_time_check', sql`${t.dueTime} IS NULL OR ${t.dueDate} IS NOT NULL`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Append-only history per task (status changes, notes, progress updates). */
|
||||||
|
export const todoActivity = tasksSchema.table(
|
||||||
|
'activity',
|
||||||
|
{
|
||||||
|
id: id(),
|
||||||
|
taskId: integer('task_id').references(() => todoTasks.id, { onDelete: 'cascade' }),
|
||||||
|
goalId: integer('goal_id').references(() => todoGoals.id, { onDelete: 'cascade' }),
|
||||||
|
kind: text('kind').notNull(), // created | status | edit | note | progress
|
||||||
|
fromStatus: text('from_status'),
|
||||||
|
toStatus: text('to_status'),
|
||||||
|
note: text('note'),
|
||||||
|
value: numeric('value', { precision: 14, scale: 2, mode: 'number' }), // goal metric snapshot
|
||||||
|
createdAt: timestamptz('created_at').notNull().defaultNow()
|
||||||
|
},
|
||||||
|
(t) => [
|
||||||
|
index('activity_task_idx').on(t.taskId, t.createdAt),
|
||||||
|
index('activity_goal_idx').on(t.goalId, t.createdAt),
|
||||||
|
check('activity_target_check', sql`${t.taskId} IS NOT NULL OR ${t.goalId} IS NOT NULL`),
|
||||||
|
check('activity_kind_check', sql`${t.kind} IN ('created','status','edit','note','progress')`)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TodoArea = typeof todoAreas.$inferSelect;
|
||||||
|
export type TodoGoal = typeof todoGoals.$inferSelect;
|
||||||
|
export type TodoTask = typeof todoTasks.$inferSelect;
|
||||||
@@ -16,6 +16,9 @@ describe('owner gate', () => {
|
|||||||
expect(isOwnerOnlyPath('/trackers/Health/import')).toBe(true);
|
expect(isOwnerOnlyPath('/trackers/Health/import')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
|
expect(isOwnerOnlyPath('/trackers/healthy')).toBe(false);
|
||||||
expect(isOwnerOnlyPath('/trackers')).toBe(false);
|
expect(isOwnerOnlyPath('/trackers')).toBe(false);
|
||||||
|
expect(isOwnerOnlyPath('/trackers/tasks')).toBe(true);
|
||||||
|
expect(isOwnerOnlyPath('/trackers/Tasks/')).toBe(true);
|
||||||
|
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gates the book reader, files and APIs but not the catalogue', () => {
|
it('gates the book reader, files and APIs but not the catalogue', () => {
|
||||||
|
|||||||
@@ -19,7 +19,11 @@ export function requireOwner(event: OwnerEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
|
/** Paths that only the owner may see (checked in hooks.server.ts before any route runs). */
|
||||||
export const ownerOnlyPrefixes = ['/trackers/health', '/trackers/books/files'] as const;
|
export const ownerOnlyPrefixes = [
|
||||||
|
'/trackers/health',
|
||||||
|
'/trackers/tasks',
|
||||||
|
'/trackers/books/files'
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owner-only paths inside otherwise public areas: the book reader and its progress/notes
|
* Owner-only paths inside otherwise public areas: the book reader and its progress/notes
|
||||||
@@ -31,7 +35,11 @@ export const ownerOnlyPatterns = [/^\/trackers\/books\/\d+\/(read|progress|notes
|
|||||||
* Areas where only the owner may write: any non-GET request from anyone else is refused
|
* Areas where only the owner may write: any non-GET request from anyone else is refused
|
||||||
* (404) in hooks.server.ts, which also covers form actions on the public book pages.
|
* (404) in hooks.server.ts, which also covers form actions on the public book pages.
|
||||||
*/
|
*/
|
||||||
export const ownerWritePrefixes = ['/trackers/health', '/trackers/books'] as const;
|
export const ownerWritePrefixes = [
|
||||||
|
'/trackers/health',
|
||||||
|
'/trackers/tasks',
|
||||||
|
'/trackers/books'
|
||||||
|
] as const;
|
||||||
|
|
||||||
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
const normalise = (pathname: string) => pathname.toLowerCase().replace(/\/+$/, '');
|
||||||
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`);
|
const under = (path: string, prefix: string) => path === prefix || path.startsWith(`${prefix}/`);
|
||||||
|
|||||||
62
src/lib/server/tasks.test.ts
Normal file
62
src/lib/server/tasks.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import seed from '../../../data/tasks/daisy-import.json';
|
||||||
|
import { parseQuickTitle, seedFile, statusTimestamps, taskInput } from './tasks';
|
||||||
|
|
||||||
|
describe('task tracker', () => {
|
||||||
|
it('parses the seed file', () => {
|
||||||
|
const parsed = seedFile.parse(seed);
|
||||||
|
expect(parsed.tasks.length).toBeGreaterThan(50);
|
||||||
|
expect(parsed.goals.length).toBeGreaterThan(3);
|
||||||
|
const slugs = new Set(parsed.areas.map((a) => a.slug));
|
||||||
|
for (const t of parsed.tasks) expect(slugs.has(t.area!)).toBe(true);
|
||||||
|
const goals = new Set(parsed.goals.map((g) => g.title.toLowerCase()));
|
||||||
|
for (const t of parsed.tasks) if (t.goal) expect(goals.has(t.goal.toLowerCase())).toBe(true);
|
||||||
|
const keys = parsed.tasks.map((t) => `${t.area}|${t.title.toLowerCase()}`);
|
||||||
|
expect(new Set(keys).size).toBe(keys.length);
|
||||||
|
});
|
||||||
|
it('validates form input', () => {
|
||||||
|
expect(taskInput.safeParse({ title: ' ' }).success).toBe(false);
|
||||||
|
expect(taskInput.safeParse({ title: 'x', dueTime: '10:00' }).success).toBe(false);
|
||||||
|
const ok = taskInput.parse({ title: 'x', due: '2026-10-26', dueTime: '10:00', tags: '#a, b' });
|
||||||
|
expect(ok.tags).toEqual(['a', 'b']);
|
||||||
|
expect(ok.status).toBe('todo');
|
||||||
|
});
|
||||||
|
it('parses quick-add flags', () => {
|
||||||
|
expect(parseQuickTitle('Call vet !')).toEqual({
|
||||||
|
title: 'Call vet',
|
||||||
|
flagged: true,
|
||||||
|
priority: 0
|
||||||
|
});
|
||||||
|
expect(parseQuickTitle('Pay rent !!!')).toEqual({
|
||||||
|
title: 'Pay rent',
|
||||||
|
flagged: true,
|
||||||
|
priority: 3
|
||||||
|
});
|
||||||
|
expect(parseQuickTitle('Sign up (!)')).toEqual({
|
||||||
|
title: 'Sign up',
|
||||||
|
flagged: true,
|
||||||
|
priority: 0
|
||||||
|
});
|
||||||
|
expect(parseQuickTitle('Hello!')).toEqual({ title: 'Hello!', flagged: false, priority: 0 });
|
||||||
|
});
|
||||||
|
it('sets completion timestamps from the status', () => {
|
||||||
|
const now = new Date('2026-10-24T10:00:00Z');
|
||||||
|
expect(statusTimestamps('done', null, now).completedAt).toEqual(now);
|
||||||
|
expect(statusTimestamps('todo', { startedAt: null, completedAt: now }, now).completedAt).toBe(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
expect(statusTimestamps('in_progress', null, now).startedAt).toEqual(now);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('local times', () => {
|
||||||
|
it('reads datetime-local values as Amsterdam time', async () => {
|
||||||
|
const { parseLocalMoment } = await import('./tasks');
|
||||||
|
expect(parseLocalMoment('2026-10-24T11:50')?.toISOString()).toBe('2026-10-24T09:50:00.000Z');
|
||||||
|
expect(parseLocalMoment('2026-12-07T09:00')?.toISOString()).toBe('2026-12-07T08:00:00.000Z');
|
||||||
|
expect(parseLocalMoment('2026-10-24T11:50:00+02:00')?.toISOString()).toBe(
|
||||||
|
'2026-10-24T09:50:00.000Z'
|
||||||
|
);
|
||||||
|
expect(parseLocalMoment('nope')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
338
src/lib/server/tasks.ts
Normal file
338
src/lib/server/tasks.ts
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
// Task + goal tracker: validation and the idempotent seed import. Shared by
|
||||||
|
// /trackers/tasks (owner only) and scripts/tasks-import.ts, so it must not import
|
||||||
|
// anything from SvelteKit.
|
||||||
|
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||||
|
import type { PgAsyncDatabase } from 'drizzle-orm/pg-core';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import {
|
||||||
|
GOAL_STATUSES,
|
||||||
|
TASK_STATUSES,
|
||||||
|
todoActivity,
|
||||||
|
todoAreas,
|
||||||
|
todoGoals,
|
||||||
|
todoTasks
|
||||||
|
} from './db/schema/tasks';
|
||||||
|
|
||||||
|
export const TIME_ZONE = 'Europe/Amsterdam';
|
||||||
|
|
||||||
|
const blank = (v: unknown) => (typeof v === 'string' && v.trim() === '' ? null : v);
|
||||||
|
const optText = z.preprocess(blank, z.string().trim().max(10_000).nullish());
|
||||||
|
const day = z.preprocess(
|
||||||
|
blank,
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD')
|
||||||
|
.refine((s) => !Number.isNaN(Date.parse(`${s}T00:00:00Z`)), 'Invalid date')
|
||||||
|
.nullish()
|
||||||
|
);
|
||||||
|
const clock = z.preprocess(
|
||||||
|
blank,
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.regex(/^([01]\d|2[0-3]):[0-5]\d(:[0-5]\d)?$/, 'Use HH:MM')
|
||||||
|
.nullish()
|
||||||
|
);
|
||||||
|
const moment = z.preprocess(
|
||||||
|
blank,
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.refine((s) => !Number.isNaN(Date.parse(s)), 'Invalid date/time')
|
||||||
|
.nullish()
|
||||||
|
);
|
||||||
|
const tags = z.preprocess(
|
||||||
|
(v) =>
|
||||||
|
typeof v === 'string'
|
||||||
|
? v
|
||||||
|
.split(/[,\s]+/)
|
||||||
|
.map((s) => s.replace(/^#/, ''))
|
||||||
|
.filter(Boolean)
|
||||||
|
: v,
|
||||||
|
z.array(z.string().trim().toLowerCase().min(1).max(40)).max(30).default([])
|
||||||
|
);
|
||||||
|
const num = z.preprocess(
|
||||||
|
(v) => (blank(v) === null ? null : typeof v === 'string' ? Number(v.replace(',', '.')) : v),
|
||||||
|
z.number().finite().nullish()
|
||||||
|
);
|
||||||
|
|
||||||
|
export const areaSeed = z.object({
|
||||||
|
slug: z.string().regex(/^[a-z0-9][a-z0-9-]{0,62}$/),
|
||||||
|
name: z.string().trim().min(1),
|
||||||
|
description: optText,
|
||||||
|
color: optText,
|
||||||
|
sortOrder: z.number().int().default(0)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const goalInput = z.object({
|
||||||
|
title: z.string().trim().min(1).max(300),
|
||||||
|
notes: optText,
|
||||||
|
status: z.enum(GOAL_STATUSES).default('active'),
|
||||||
|
area: z.preprocess(blank, z.string().nullish()), // area slug
|
||||||
|
targetDate: day,
|
||||||
|
metric: optText,
|
||||||
|
metricUnit: optText,
|
||||||
|
metricTarget: num,
|
||||||
|
metricCurrent: num,
|
||||||
|
flagged: z.coerce.boolean().default(false)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const taskInput = z
|
||||||
|
.object({
|
||||||
|
title: z.string().trim().min(1).max(500),
|
||||||
|
notes: optText,
|
||||||
|
status: z.enum(TASK_STATUSES).default('todo'),
|
||||||
|
priority: z.coerce.number().int().min(0).max(3).default(0),
|
||||||
|
flagged: z.coerce.boolean().default(false),
|
||||||
|
due: day,
|
||||||
|
dueTime: clock,
|
||||||
|
reminderAt: moment,
|
||||||
|
recurrence: optText,
|
||||||
|
area: z.preprocess(blank, z.string().nullish()), // area slug
|
||||||
|
goal: z.preprocess(blank, z.string().nullish()), // goal title
|
||||||
|
parent: z.preprocess(blank, z.string().nullish()), // parent task title (same area)
|
||||||
|
tags
|
||||||
|
})
|
||||||
|
.refine((t) => !t.dueTime || t.due, {
|
||||||
|
message: 'A due time needs a due date',
|
||||||
|
path: ['dueTime']
|
||||||
|
});
|
||||||
|
|
||||||
|
export const seedFile = z.object({
|
||||||
|
source: z.string().min(1).default('daisy-import'),
|
||||||
|
areas: z.array(areaSeed).default([]),
|
||||||
|
goals: z.array(goalInput).default([]),
|
||||||
|
tasks: z.array(taskInput).default([])
|
||||||
|
});
|
||||||
|
export type SeedFile = z.infer<typeof seedFile>;
|
||||||
|
export type TaskInput = z.infer<typeof taskInput>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Buy milk !" or "! buy milk" → flagged; "!!!" anywhere → priority 3 (TickTick-style).
|
||||||
|
* Used by quick-add.
|
||||||
|
*/
|
||||||
|
export function parseQuickTitle(raw: string): {
|
||||||
|
title: string;
|
||||||
|
flagged: boolean;
|
||||||
|
priority: number;
|
||||||
|
} {
|
||||||
|
let title = raw.trim();
|
||||||
|
let priority = 0;
|
||||||
|
const bangs = title.match(/(^|\s)(!{1,3})(?=\s|$)/);
|
||||||
|
if (bangs) {
|
||||||
|
priority = bangs[2].length === 1 ? 0 : bangs[2].length;
|
||||||
|
title = title.replace(bangs[0], ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
const flagged = !!bangs || /\(!\)/.test(title);
|
||||||
|
title = title.replace(/\s*\(!\)\s*/g, ' ').trim();
|
||||||
|
return { title, flagged, priority };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status side effects: completedAt/startedAt follow the status. */
|
||||||
|
export function statusTimestamps(
|
||||||
|
status: (typeof TASK_STATUSES)[number],
|
||||||
|
prev: { startedAt: Date | null; completedAt: Date | null } | null,
|
||||||
|
now = new Date()
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
startedAt:
|
||||||
|
status === 'in_progress' || status === 'done'
|
||||||
|
? (prev?.startedAt ?? (status === 'in_progress' ? now : null))
|
||||||
|
: (prev?.startedAt ?? null),
|
||||||
|
completedAt: status === 'done' || status === 'dropped' ? (prev?.completedAt ?? now) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- any drizzle pg driver
|
||||||
|
type Db = PgAsyncDatabase<any, any>;
|
||||||
|
export type ImportResult = {
|
||||||
|
areas: { inserted: number; existing: number };
|
||||||
|
goals: { inserted: number; existing: number; updated: number };
|
||||||
|
tasks: { inserted: number; existing: number; updated: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotent import. Areas dedupe on slug, goals on lower(title), tasks on area + title
|
||||||
|
* (case-insensitive). Existing rows are left alone unless `update` is set, so re-running
|
||||||
|
* never undoes edits made on the site.
|
||||||
|
*/
|
||||||
|
export async function importSeed(
|
||||||
|
db: Db,
|
||||||
|
seed: SeedFile,
|
||||||
|
{ update = false }: { update?: boolean } = {}
|
||||||
|
): Promise<ImportResult> {
|
||||||
|
const result: ImportResult = {
|
||||||
|
areas: { inserted: 0, existing: 0 },
|
||||||
|
goals: { inserted: 0, existing: 0, updated: 0 },
|
||||||
|
tasks: { inserted: 0, existing: 0, updated: 0 }
|
||||||
|
};
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
const areaIds = new Map<string, number>();
|
||||||
|
for (const a of seed.areas) {
|
||||||
|
const [row] = await tx
|
||||||
|
.insert(todoAreas)
|
||||||
|
.values({ ...a, description: a.description ?? null, color: a.color ?? null })
|
||||||
|
.onConflictDoNothing({ target: todoAreas.slug })
|
||||||
|
.returning({ id: todoAreas.id });
|
||||||
|
if (row) result.areas.inserted++;
|
||||||
|
else result.areas.existing++;
|
||||||
|
}
|
||||||
|
for (const a of await tx.select({ id: todoAreas.id, slug: todoAreas.slug }).from(todoAreas))
|
||||||
|
areaIds.set(a.slug, a.id);
|
||||||
|
const areaId = (slug: string | null | undefined) => {
|
||||||
|
if (!slug) return null;
|
||||||
|
const id = areaIds.get(slug);
|
||||||
|
if (id === undefined) throw new Error(`Unknown area "${slug}"`);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goalIds = new Map<string, number>();
|
||||||
|
for (const g of seed.goals) {
|
||||||
|
const values = {
|
||||||
|
title: g.title,
|
||||||
|
notes: g.notes ?? null,
|
||||||
|
status: g.status,
|
||||||
|
areaId: areaId(g.area),
|
||||||
|
targetDate: g.targetDate ?? null,
|
||||||
|
metric: g.metric ?? null,
|
||||||
|
metricUnit: g.metricUnit ?? null,
|
||||||
|
metricTarget: g.metricTarget ?? null,
|
||||||
|
metricCurrent: g.metricCurrent ?? null,
|
||||||
|
flagged: g.flagged,
|
||||||
|
source: seed.source
|
||||||
|
};
|
||||||
|
const [existing] = await tx
|
||||||
|
.select({ id: todoGoals.id })
|
||||||
|
.from(todoGoals)
|
||||||
|
.where(sql`lower(${todoGoals.title}) = lower(${g.title})`);
|
||||||
|
if (existing) {
|
||||||
|
result.goals.existing++;
|
||||||
|
if (update) {
|
||||||
|
await tx.update(todoGoals).set(values).where(eq(todoGoals.id, existing.id));
|
||||||
|
result.goals.updated++;
|
||||||
|
}
|
||||||
|
goalIds.set(g.title.toLowerCase(), existing.id);
|
||||||
|
} else {
|
||||||
|
const [row] = await tx.insert(todoGoals).values(values).returning({ id: todoGoals.id });
|
||||||
|
result.goals.inserted++;
|
||||||
|
goalIds.set(g.title.toLowerCase(), row.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const goalId = async (title: string | null | undefined) => {
|
||||||
|
if (!title) return null;
|
||||||
|
const known = goalIds.get(title.toLowerCase());
|
||||||
|
if (known) return known;
|
||||||
|
const [row] = await tx
|
||||||
|
.select({ id: todoGoals.id })
|
||||||
|
.from(todoGoals)
|
||||||
|
.where(sql`lower(${todoGoals.title}) = lower(${title})`);
|
||||||
|
if (!row) throw new Error(`Unknown goal "${title}"`);
|
||||||
|
return row.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const findTask = async (area: number | null, title: string) => {
|
||||||
|
const [row] = await tx
|
||||||
|
.select({ id: todoTasks.id })
|
||||||
|
.from(todoTasks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
area === null ? isNull(todoTasks.areaId) : eq(todoTasks.areaId, area),
|
||||||
|
sql`lower(${todoTasks.title}) = lower(${title})`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row?.id ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parents first, so subtasks can point at them.
|
||||||
|
const ordered = [...seed.tasks].sort((a, b) => Number(!!a.parent) - Number(!!b.parent));
|
||||||
|
for (const [i, t] of ordered.entries()) {
|
||||||
|
const area = areaId(t.area);
|
||||||
|
const parentId = t.parent ? await findTask(area, t.parent) : null;
|
||||||
|
if (t.parent && parentId === null) throw new Error(`Unknown parent task "${t.parent}"`);
|
||||||
|
const values = {
|
||||||
|
title: t.title,
|
||||||
|
notes: t.notes ?? null,
|
||||||
|
status: t.status,
|
||||||
|
priority: t.priority,
|
||||||
|
flagged: t.flagged,
|
||||||
|
dueDate: t.due ?? null,
|
||||||
|
dueTime: t.dueTime ?? null,
|
||||||
|
reminderAt: t.reminderAt ? parseLocalMoment(t.reminderAt) : null,
|
||||||
|
recurrence: t.recurrence ?? null,
|
||||||
|
areaId: area,
|
||||||
|
goalId: await goalId(t.goal),
|
||||||
|
parentId,
|
||||||
|
tags: t.tags,
|
||||||
|
sortOrder: (i + 1) * 10,
|
||||||
|
source: seed.source,
|
||||||
|
...statusTimestamps(t.status, null)
|
||||||
|
};
|
||||||
|
const existing = await findTask(area, t.title);
|
||||||
|
if (existing !== null) {
|
||||||
|
result.tasks.existing++;
|
||||||
|
if (update) {
|
||||||
|
const rest: Partial<typeof values> = { ...values };
|
||||||
|
delete rest.startedAt;
|
||||||
|
delete rest.completedAt;
|
||||||
|
await tx.update(todoTasks).set(rest).where(eq(todoTasks.id, existing));
|
||||||
|
result.tasks.updated++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [row] = await tx.insert(todoTasks).values(values).returning({ id: todoTasks.id });
|
||||||
|
await tx.insert(todoActivity).values({
|
||||||
|
taskId: row.id,
|
||||||
|
kind: 'created',
|
||||||
|
toStatus: t.status,
|
||||||
|
note: `Imported (${seed.source})`
|
||||||
|
});
|
||||||
|
result.tasks.inserted++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetMinutes(at: Date, timeZone: string): number {
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
hourCycle: 'h23',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
})
|
||||||
|
.formatToParts(at)
|
||||||
|
.map((p) => [p.type, p.value])
|
||||||
|
);
|
||||||
|
const wall = Date.UTC(
|
||||||
|
+parts.year,
|
||||||
|
+parts.month - 1,
|
||||||
|
+parts.day,
|
||||||
|
+parts.hour,
|
||||||
|
+parts.minute,
|
||||||
|
+parts.second
|
||||||
|
);
|
||||||
|
return Math.round((wall - at.getTime()) / 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-12-07T09:00" (a datetime-local value, Amsterdam wall clock) → Date. ISO with offset passes through. */
|
||||||
|
export function parseLocalMoment(value: string, timeZone = TIME_ZONE): Date | null {
|
||||||
|
const local = value.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(:\d{2})?$/);
|
||||||
|
if (!local) {
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
const guess = new Date(`${local[1]}T${local[2]}${local[3] ?? ':00'}Z`);
|
||||||
|
if (Number.isNaN(guess.getTime())) return null;
|
||||||
|
let result = new Date(guess.getTime() - offsetMinutes(guess, timeZone) * 60_000);
|
||||||
|
const corrected = offsetMinutes(result, timeZone);
|
||||||
|
result = new Date(guess.getTime() - corrected * 60_000);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today's date in Amsterdam, YYYY-MM-DD. */
|
||||||
|
export function todayIn(timeZone = TIME_ZONE, now = new Date()): string {
|
||||||
|
return new Intl.DateTimeFormat('en-CA', { timeZone }).format(now);
|
||||||
|
}
|
||||||
11
src/routes/trackers/tasks/+layout.server.ts
Normal file
11
src/routes/trackers/tasks/+layout.server.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { requireOwner } from '#lib/server/owner.js';
|
||||||
|
import type { LayoutServerLoad } from './$types';
|
||||||
|
|
||||||
|
// Owner only: everyone else (logged out, or any other account) gets a plain 404.
|
||||||
|
// Form actions don't run this load, so +page.server.ts checks again in every action,
|
||||||
|
// and hooks.server.ts blocks the whole /trackers/tasks prefix as a backstop.
|
||||||
|
export const load: LayoutServerLoad = (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
event.setHeaders({ 'cache-control': 'private, no-store' });
|
||||||
|
return {};
|
||||||
|
};
|
||||||
261
src/routes/trackers/tasks/+page.server.ts
Normal file
261
src/routes/trackers/tasks/+page.server.ts
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
import { db } from '#lib/server/db/index.js';
|
||||||
|
import {
|
||||||
|
TASK_STATUSES,
|
||||||
|
todoActivity,
|
||||||
|
todoAreas,
|
||||||
|
todoGoals,
|
||||||
|
todoTasks,
|
||||||
|
type TaskStatus
|
||||||
|
} from '#lib/server/db/schema/index.js';
|
||||||
|
import { requireOwner } from '#lib/server/owner.js';
|
||||||
|
import {
|
||||||
|
goalInput,
|
||||||
|
parseLocalMoment,
|
||||||
|
parseQuickTitle,
|
||||||
|
statusTimestamps,
|
||||||
|
taskInput,
|
||||||
|
todayIn
|
||||||
|
} from '#lib/server/tasks.js';
|
||||||
|
import { asc, eq, sql } from 'drizzle-orm';
|
||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const [areas, goals, tasks] = await Promise.all([
|
||||||
|
db.select().from(todoAreas).orderBy(asc(todoAreas.sortOrder), asc(todoAreas.name)),
|
||||||
|
db.select().from(todoGoals).orderBy(asc(todoGoals.sortOrder), asc(todoGoals.id)),
|
||||||
|
db.select().from(todoTasks).orderBy(asc(todoTasks.sortOrder), asc(todoTasks.id))
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
today: todayIn(),
|
||||||
|
areas,
|
||||||
|
goals,
|
||||||
|
tasks,
|
||||||
|
seo: { title: 'Tasks', description: 'Private task and goal tracker.', index: false }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const id = (v: FormDataEntryValue | null) => {
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isInteger(n) && n > 0 ? n : null;
|
||||||
|
};
|
||||||
|
const formObject = (form: FormData) => {
|
||||||
|
const o: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of form) if (typeof v === 'string') o[k] = v;
|
||||||
|
o.flagged = form.get('flagged') === 'on' || form.get('flagged') === 'true';
|
||||||
|
return o;
|
||||||
|
};
|
||||||
|
const firstIssue = (e: { issues: { path: PropertyKey[]; message: string }[] }) => {
|
||||||
|
const i = e.issues[0];
|
||||||
|
return i ? `${i.path.join('.') || 'input'}: ${i.message}` : 'Invalid input';
|
||||||
|
};
|
||||||
|
/** Postgres errors arrive wrapped (DrizzleQueryError); report the cause, not the query. */
|
||||||
|
function dbError(e: unknown): string {
|
||||||
|
const cause = (e as { cause?: { message?: string; constraint_name?: string } })?.cause;
|
||||||
|
if (cause?.constraint_name === 'goals_title_key') return 'A goal with that title already exists';
|
||||||
|
if (cause?.constraint_name) return `Not allowed (${cause.constraint_name})`;
|
||||||
|
return cause?.message || 'Could not save';
|
||||||
|
}
|
||||||
|
const isStatus = (v: unknown): v is TaskStatus =>
|
||||||
|
typeof v === 'string' && (TASK_STATUSES as readonly string[]).includes(v);
|
||||||
|
|
||||||
|
async function nextSortOrder(areaId: number | null) {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ max: sql<number>`coalesce(max(${todoTasks.sortOrder}), 0)` })
|
||||||
|
.from(todoTasks)
|
||||||
|
.where(areaId === null ? sql`${todoTasks.areaId} IS NULL` : eq(todoTasks.areaId, areaId));
|
||||||
|
return Number(row?.max ?? 0) + 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
/** Quick add: a title (with "!" / "!!!" shortcuts), optional area and due date. */
|
||||||
|
quick: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const form = await event.request.formData();
|
||||||
|
const { title, flagged, priority } = parseQuickTitle(String(form.get('title') ?? ''));
|
||||||
|
const parsed = taskInput.safeParse({
|
||||||
|
title,
|
||||||
|
flagged,
|
||||||
|
priority,
|
||||||
|
due: form.get('due') ?? '',
|
||||||
|
area: form.get('areaId') ?? ''
|
||||||
|
});
|
||||||
|
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error), quick: true });
|
||||||
|
const areaId = id(form.get('areaId'));
|
||||||
|
const [row] = await db
|
||||||
|
.insert(todoTasks)
|
||||||
|
.values({
|
||||||
|
title: parsed.data.title,
|
||||||
|
flagged: parsed.data.flagged,
|
||||||
|
priority: parsed.data.priority,
|
||||||
|
dueDate: parsed.data.due ?? null,
|
||||||
|
areaId,
|
||||||
|
sortOrder: await nextSortOrder(areaId),
|
||||||
|
source: 'manual'
|
||||||
|
})
|
||||||
|
.returning({ id: todoTasks.id });
|
||||||
|
await db.insert(todoActivity).values({ taskId: row.id, kind: 'created', toStatus: 'todo' });
|
||||||
|
return { added: row.id };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Inline status change. */
|
||||||
|
status: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const form = await event.request.formData();
|
||||||
|
const taskId = id(form.get('id'));
|
||||||
|
const status = form.get('status');
|
||||||
|
if (!taskId || !isStatus(status)) return fail(400, { message: 'Invalid status change' });
|
||||||
|
const [prev] = await db.select().from(todoTasks).where(eq(todoTasks.id, taskId));
|
||||||
|
if (!prev) return fail(404, { message: 'Task not found' });
|
||||||
|
if (prev.status === status) return { updated: taskId };
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(todoTasks)
|
||||||
|
.set({ status, ...statusTimestamps(status, prev) })
|
||||||
|
.where(eq(todoTasks.id, taskId));
|
||||||
|
await tx.insert(todoActivity).values({
|
||||||
|
taskId,
|
||||||
|
kind: 'status',
|
||||||
|
fromStatus: prev.status,
|
||||||
|
toStatus: status
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { updated: taskId };
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Full create/edit from the dialog. */
|
||||||
|
save: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const form = await event.request.formData();
|
||||||
|
const taskId = id(form.get('id'));
|
||||||
|
const parsed = taskInput.safeParse(formObject(form));
|
||||||
|
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error) });
|
||||||
|
const t = parsed.data;
|
||||||
|
const reminderAt = t.reminderAt ? parseLocalMoment(t.reminderAt) : null;
|
||||||
|
if (t.reminderAt && !reminderAt) return fail(400, { message: 'Invalid reminder' });
|
||||||
|
const parentId = id(form.get('parentId'));
|
||||||
|
if (parentId && parentId === taskId)
|
||||||
|
return fail(400, { message: 'A task cannot be its own parent' });
|
||||||
|
const values = {
|
||||||
|
title: t.title,
|
||||||
|
notes: t.notes ?? null,
|
||||||
|
status: t.status,
|
||||||
|
priority: t.priority,
|
||||||
|
flagged: t.flagged,
|
||||||
|
dueDate: t.due ?? null,
|
||||||
|
dueTime: t.dueTime ?? null,
|
||||||
|
reminderAt,
|
||||||
|
recurrence: t.recurrence ?? null,
|
||||||
|
areaId: id(form.get('areaId')),
|
||||||
|
goalId: id(form.get('goalId')),
|
||||||
|
parentId,
|
||||||
|
tags: t.tags
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (taskId) {
|
||||||
|
const [prev] = await db.select().from(todoTasks).where(eq(todoTasks.id, taskId));
|
||||||
|
if (!prev) return fail(404, { message: 'Task not found' });
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(todoTasks)
|
||||||
|
.set({ ...values, ...statusTimestamps(t.status, prev) })
|
||||||
|
.where(eq(todoTasks.id, taskId));
|
||||||
|
await tx.insert(todoActivity).values({
|
||||||
|
taskId,
|
||||||
|
kind: prev.status === t.status ? 'edit' : 'status',
|
||||||
|
fromStatus: prev.status,
|
||||||
|
toStatus: t.status
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { saved: taskId };
|
||||||
|
}
|
||||||
|
const [row] = await db
|
||||||
|
.insert(todoTasks)
|
||||||
|
.values({
|
||||||
|
...values,
|
||||||
|
...statusTimestamps(t.status, null),
|
||||||
|
sortOrder: await nextSortOrder(values.areaId),
|
||||||
|
source: 'manual'
|
||||||
|
})
|
||||||
|
.returning({ id: todoTasks.id });
|
||||||
|
await db.insert(todoActivity).values({ taskId: row.id, kind: 'created', toStatus: t.status });
|
||||||
|
return { saved: row.id };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(400, { message: dbError(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const taskId = id((await event.request.formData()).get('id'));
|
||||||
|
if (!taskId) return fail(400, { message: 'Invalid task' });
|
||||||
|
await db.delete(todoTasks).where(eq(todoTasks.id, taskId));
|
||||||
|
return { deleted: taskId };
|
||||||
|
},
|
||||||
|
|
||||||
|
saveGoal: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const form = await event.request.formData();
|
||||||
|
const goalId = id(form.get('id'));
|
||||||
|
const parsed = goalInput.safeParse(formObject(form));
|
||||||
|
if (!parsed.success) return fail(400, { message: firstIssue(parsed.error) });
|
||||||
|
const g = parsed.data;
|
||||||
|
const values = {
|
||||||
|
title: g.title,
|
||||||
|
notes: g.notes ?? null,
|
||||||
|
status: g.status,
|
||||||
|
areaId: id(form.get('areaId')),
|
||||||
|
targetDate: g.targetDate ?? null,
|
||||||
|
metric: g.metric ?? null,
|
||||||
|
metricUnit: g.metricUnit ?? null,
|
||||||
|
metricTarget: g.metricTarget ?? null,
|
||||||
|
metricCurrent: g.metricCurrent ?? null,
|
||||||
|
flagged: g.flagged
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (goalId) {
|
||||||
|
const [prev] = await db.select().from(todoGoals).where(eq(todoGoals.id, goalId));
|
||||||
|
if (!prev) return fail(404, { message: 'Goal not found' });
|
||||||
|
const achievedAt = g.status === 'achieved' ? (prev.achievedAt ?? new Date()) : null;
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx
|
||||||
|
.update(todoGoals)
|
||||||
|
.set({ ...values, achievedAt })
|
||||||
|
.where(eq(todoGoals.id, goalId));
|
||||||
|
if (prev.metricCurrent !== values.metricCurrent && values.metricCurrent !== null)
|
||||||
|
await tx
|
||||||
|
.insert(todoActivity)
|
||||||
|
.values({ goalId, kind: 'progress', value: values.metricCurrent });
|
||||||
|
else if (prev.status !== g.status)
|
||||||
|
await tx.insert(todoActivity).values({
|
||||||
|
goalId,
|
||||||
|
kind: 'status',
|
||||||
|
fromStatus: prev.status,
|
||||||
|
toStatus: g.status
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { savedGoal: goalId };
|
||||||
|
}
|
||||||
|
const [row] = await db
|
||||||
|
.insert(todoGoals)
|
||||||
|
.values({
|
||||||
|
...values,
|
||||||
|
source: 'manual',
|
||||||
|
achievedAt: g.status === 'achieved' ? new Date() : null
|
||||||
|
})
|
||||||
|
.returning({ id: todoGoals.id });
|
||||||
|
return { savedGoal: row.id };
|
||||||
|
} catch (e) {
|
||||||
|
return fail(400, { message: dbError(e) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteGoal: async (event) => {
|
||||||
|
requireOwner(event);
|
||||||
|
const goalId = id((await event.request.formData()).get('id'));
|
||||||
|
if (!goalId) return fail(400, { message: 'Invalid goal' });
|
||||||
|
await db.delete(todoGoals).where(eq(todoGoals.id, goalId));
|
||||||
|
return { deletedGoal: goalId };
|
||||||
|
}
|
||||||
|
};
|
||||||
905
src/routes/trackers/tasks/+page.svelte
Normal file
905
src/routes/trackers/tasks/+page.svelte
Normal file
@@ -0,0 +1,905 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { Button } from '#lib/components/ui/button/index.js';
|
||||||
|
import { Input } from '#lib/components/ui/input/index.js';
|
||||||
|
import { NativeSelect } from '#lib/components/ui/native-select/index.js';
|
||||||
|
import { Textarea } from '#lib/components/ui/textarea/index.js';
|
||||||
|
import * as Dialog from '#lib/components/ui/dialog/index.js';
|
||||||
|
import Plus from 'lucide-svelte/icons/plus';
|
||||||
|
import Pencil from 'lucide-svelte/icons/pencil';
|
||||||
|
import Lock from 'lucide-svelte/icons/lock';
|
||||||
|
import Flag from 'lucide-svelte/icons/flag';
|
||||||
|
import Bell from 'lucide-svelte/icons/bell';
|
||||||
|
import Repeat from 'lucide-svelte/icons/repeat';
|
||||||
|
import Target from 'lucide-svelte/icons/target';
|
||||||
|
import Circle from 'lucide-svelte/icons/circle';
|
||||||
|
import CircleDot from 'lucide-svelte/icons/circle-dot';
|
||||||
|
import CircleCheck from 'lucide-svelte/icons/circle-check';
|
||||||
|
import CircleX from 'lucide-svelte/icons/circle-x';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
|
type Task = (typeof data.tasks)[number];
|
||||||
|
type Goal = (typeof data.goals)[number];
|
||||||
|
type Status = Task['status'];
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<Status, string> = {
|
||||||
|
todo: 'To do',
|
||||||
|
in_progress: 'In progress',
|
||||||
|
done: 'Done',
|
||||||
|
dropped: 'Dropped'
|
||||||
|
};
|
||||||
|
const STATUS_ICON = {
|
||||||
|
todo: Circle,
|
||||||
|
in_progress: CircleDot,
|
||||||
|
done: CircleCheck,
|
||||||
|
dropped: CircleX
|
||||||
|
} as const;
|
||||||
|
/** Clicking the status icon: to do → in progress → done → to do. */
|
||||||
|
const NEXT: Record<Status, Status> = {
|
||||||
|
todo: 'in_progress',
|
||||||
|
in_progress: 'done',
|
||||||
|
done: 'todo',
|
||||||
|
dropped: 'todo'
|
||||||
|
};
|
||||||
|
const PRIORITY = ['None', 'Low', 'Medium', 'High'] as const;
|
||||||
|
const STATUS_FILTERS = [
|
||||||
|
['open', 'Open'],
|
||||||
|
['todo', 'To do'],
|
||||||
|
['in_progress', 'In progress'],
|
||||||
|
['done', 'Done'],
|
||||||
|
['dropped', 'Dropped'],
|
||||||
|
['all', 'All']
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
let statusFilter = $state<'open' | 'all' | Status>('open');
|
||||||
|
let areaFilter = $state('all');
|
||||||
|
let priorityFilter = $state<'any' | 'flagged' | 'high'>('any');
|
||||||
|
let search = $state('');
|
||||||
|
|
||||||
|
const isOpen = (t: Task) => t.status === 'todo' || t.status === 'in_progress';
|
||||||
|
const matches = (t: Task) =>
|
||||||
|
(statusFilter === 'all' || (statusFilter === 'open' ? isOpen(t) : t.status === statusFilter)) &&
|
||||||
|
(areaFilter === 'all' ||
|
||||||
|
(areaFilter === 'none' ? t.areaId === null : t.areaId === Number(areaFilter))) &&
|
||||||
|
(priorityFilter === 'any' ||
|
||||||
|
(priorityFilter === 'flagged' ? t.flagged : t.priority >= 2 || t.flagged)) &&
|
||||||
|
(!search.trim() ||
|
||||||
|
`${t.title} ${t.notes ?? ''} ${t.tags.join(' ')}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(search.trim().toLowerCase()));
|
||||||
|
|
||||||
|
const visible = $derived(data.tasks.filter(matches));
|
||||||
|
const visibleIds = $derived(new Set(visible.map((t) => t.id)));
|
||||||
|
const childrenOf = $derived.by(() => {
|
||||||
|
const map: Record<number, Task[]> = {};
|
||||||
|
for (const t of visible)
|
||||||
|
if (t.parentId !== null && visibleIds.has(t.parentId)) (map[t.parentId] ??= []).push(t);
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
const rank = (t: Task) => (t.status === 'in_progress' ? 0 : t.status === 'todo' ? 1 : 2);
|
||||||
|
const byUrgency = (a: Task, b: Task) =>
|
||||||
|
rank(a) - rank(b) ||
|
||||||
|
Number(b.flagged) - Number(a.flagged) ||
|
||||||
|
(a.dueDate ?? '9999').localeCompare(b.dueDate ?? '9999') ||
|
||||||
|
b.priority - a.priority ||
|
||||||
|
a.sortOrder - b.sortOrder;
|
||||||
|
const groups = $derived.by(() => {
|
||||||
|
const roots = visible.filter((t) => t.parentId === null || !visibleIds.has(t.parentId));
|
||||||
|
const list = data.areas.map((a) => ({
|
||||||
|
key: String(a.id),
|
||||||
|
name: a.name,
|
||||||
|
description: a.description,
|
||||||
|
tasks: roots.filter((t) => t.areaId === a.id).sort(byUrgency),
|
||||||
|
total: data.tasks.filter((t) => t.areaId === a.id).length,
|
||||||
|
done: data.tasks.filter((t) => t.areaId === a.id && t.status === 'done').length
|
||||||
|
}));
|
||||||
|
const loose = roots.filter((t) => t.areaId === null).sort(byUrgency);
|
||||||
|
if (loose.length)
|
||||||
|
list.push({
|
||||||
|
key: 'none',
|
||||||
|
name: 'No area',
|
||||||
|
description: null,
|
||||||
|
tasks: loose,
|
||||||
|
total: loose.length,
|
||||||
|
done: 0
|
||||||
|
});
|
||||||
|
return list.filter((g) => g.tasks.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = $derived.by(() => {
|
||||||
|
const c = { todo: 0, in_progress: 0, done: 0, dropped: 0, overdue: 0, week: 0, flagged: 0 };
|
||||||
|
for (const t of data.tasks) {
|
||||||
|
c[t.status]++;
|
||||||
|
if (!isOpen(t)) continue;
|
||||||
|
if (t.flagged) c.flagged++;
|
||||||
|
const d = dueState(t);
|
||||||
|
if (d === 'overdue') c.overdue++;
|
||||||
|
else if (d === 'today' || d === 'soon') c.week++;
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
});
|
||||||
|
const upcoming = $derived(
|
||||||
|
data.tasks
|
||||||
|
.filter((t) => isOpen(t) && t.dueDate && daysUntil(t.dueDate) <= 14)
|
||||||
|
.sort((a, b) =>
|
||||||
|
`${a.dueDate}${a.dueTime ?? ''}`.localeCompare(`${b.dueDate}${b.dueTime ?? ''}`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
function daysUntil(day: string) {
|
||||||
|
return Math.round(
|
||||||
|
(Date.parse(`${day}T12:00:00Z`) - Date.parse(`${data.today}T12:00:00Z`)) / 86_400_000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function dueState(t: Task): 'overdue' | 'today' | 'soon' | 'later' | null {
|
||||||
|
if (!t.dueDate) return null;
|
||||||
|
const n = daysUntil(t.dueDate);
|
||||||
|
return n < 0 ? 'overdue' : n === 0 ? 'today' : n <= 7 ? 'soon' : 'later';
|
||||||
|
}
|
||||||
|
function dueLabel(t: Task) {
|
||||||
|
if (!t.dueDate) return '';
|
||||||
|
const n = daysUntil(t.dueDate);
|
||||||
|
const time = t.dueTime ? ` ${t.dueTime.slice(0, 5)}` : '';
|
||||||
|
if (n === 0) return `today${time}`;
|
||||||
|
if (n === 1) return `tomorrow${time}`;
|
||||||
|
if (n === -1) return `yesterday${time}`;
|
||||||
|
const date = new Date(`${t.dueDate}T12:00:00Z`).toLocaleDateString('en-GB', {
|
||||||
|
weekday: n > 0 && n < 7 ? 'short' : undefined,
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: t.dueDate.slice(0, 4) === data.today.slice(0, 4) ? undefined : 'numeric',
|
||||||
|
timeZone: 'UTC'
|
||||||
|
});
|
||||||
|
return `${date}${time}`;
|
||||||
|
}
|
||||||
|
const localMoment = new Intl.DateTimeFormat('sv-SE', {
|
||||||
|
timeZone: 'Europe/Amsterdam',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
const reminderLabel = (d: Date) =>
|
||||||
|
d.toLocaleString('en-GB', {
|
||||||
|
timeZone: 'Europe/Amsterdam',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
const areaName = (id: number | null) => data.areas.find((a) => a.id === id)?.name ?? '';
|
||||||
|
const goalTitle = (id: number | null) => data.goals.find((g) => g.id === id)?.title ?? '';
|
||||||
|
|
||||||
|
// Goals
|
||||||
|
const goalStats = (g: Goal) => {
|
||||||
|
const linked = data.tasks.filter((t) => t.goalId === g.id && t.status !== 'dropped');
|
||||||
|
return { total: linked.length, done: linked.filter((t) => t.status === 'done').length };
|
||||||
|
};
|
||||||
|
const metricPct = (g: Goal) =>
|
||||||
|
g.metricTarget && g.metricCurrent !== null
|
||||||
|
? Math.max(0, Math.min(100, (g.metricCurrent / g.metricTarget) * 100))
|
||||||
|
: null;
|
||||||
|
const money = (n: number, unit: string | null) =>
|
||||||
|
unit === 'EUR'
|
||||||
|
? new Intl.NumberFormat('nl-NL', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'EUR',
|
||||||
|
maximumFractionDigits: 0
|
||||||
|
}).format(n)
|
||||||
|
: `${n.toLocaleString('en-GB')}${unit ? ` ${unit}` : ''}`;
|
||||||
|
function targetLabel(day: string) {
|
||||||
|
const n = daysUntil(day);
|
||||||
|
const date = new Date(`${day}T12:00:00Z`).toLocaleDateString('en-GB', {
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC'
|
||||||
|
});
|
||||||
|
if (n < 0) return `${date} (passed)`;
|
||||||
|
if (n < 60) return `${date} · ${n} days`;
|
||||||
|
if (n < 730) return `${date} · ${Math.round(n / 30.4)} months`;
|
||||||
|
return `${date} · ${(n / 365.25).toFixed(1)} years`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editors
|
||||||
|
let taskDialog = $state(false);
|
||||||
|
let goalDialog = $state(false);
|
||||||
|
let editing = $state<Task | null>(null);
|
||||||
|
let editingGoal = $state<Goal | null>(null);
|
||||||
|
let confirmDelete = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let quickError = $state<string | null>(null);
|
||||||
|
let quickArea = $state('');
|
||||||
|
|
||||||
|
function openTask(t: Task | null = null) {
|
||||||
|
editing = t;
|
||||||
|
confirmDelete = false;
|
||||||
|
error = null;
|
||||||
|
taskDialog = true;
|
||||||
|
}
|
||||||
|
function openGoal(g: Goal | null = null) {
|
||||||
|
editingGoal = g;
|
||||||
|
confirmDelete = false;
|
||||||
|
error = null;
|
||||||
|
goalDialog = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitDialog: SubmitFunction =
|
||||||
|
() =>
|
||||||
|
async ({ result, update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
if (result.type === 'success') taskDialog = goalDialog = false;
|
||||||
|
else error = String((result.type === 'failure' && result.data?.message) || 'Could not save');
|
||||||
|
};
|
||||||
|
const submitQuick: SubmitFunction =
|
||||||
|
() =>
|
||||||
|
async ({ result, update }) => {
|
||||||
|
await update();
|
||||||
|
quickError =
|
||||||
|
result.type === 'failure' ? String(result.data?.message ?? 'Could not add') : null;
|
||||||
|
};
|
||||||
|
const submitInline: SubmitFunction =
|
||||||
|
() =>
|
||||||
|
async ({ update }) => {
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet statusButton(t: Task)}
|
||||||
|
{@const Icon = STATUS_ICON[t.status]}
|
||||||
|
<form method="POST" action="?/status" use:enhance={submitInline} class="contents">
|
||||||
|
<input type="hidden" name="id" value={t.id} />
|
||||||
|
<input type="hidden" name="status" value={NEXT[t.status]} />
|
||||||
|
<button
|
||||||
|
class="status status-{t.status}"
|
||||||
|
type="submit"
|
||||||
|
title={`${STATUS_LABEL[t.status]}: mark ${STATUS_LABEL[NEXT[t.status]].toLowerCase()}`}
|
||||||
|
aria-label={`${t.title}: ${STATUS_LABEL[t.status]}, mark ${STATUS_LABEL[NEXT[t.status]].toLowerCase()}`}
|
||||||
|
><Icon size={18} /></button
|
||||||
|
>
|
||||||
|
</form>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet taskRow(t: Task, depth: number)}
|
||||||
|
{@const due = dueState(t)}
|
||||||
|
<li class="task" class:closed={!isOpen(t)} style:padding-left={`${depth * 1.5}rem`}>
|
||||||
|
{@render statusButton(t)}
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<button type="button" class="title" onclick={() => openTask(t)}>{t.title}</button>
|
||||||
|
{#if t.flagged}<Flag size={13} class="text-red-500" aria-label="Flagged" />{/if}
|
||||||
|
{#if t.priority >= 2}<span class="chip prio-{t.priority}">{PRIORITY[t.priority]}</span>{/if}
|
||||||
|
{#if t.dueDate}<span class="chip due-{isOpen(t) ? due : 'later'}">{dueLabel(t)}</span>{/if}
|
||||||
|
{#if t.reminderAt && isOpen(t)}<span class="chip" title="Reminder"
|
||||||
|
><Bell size={11} /> {reminderLabel(t.reminderAt)}</span
|
||||||
|
>{/if}
|
||||||
|
{#if t.recurrence}<Repeat size={12} aria-label="Repeats" />{/if}
|
||||||
|
{#each t.tags as tag (tag)}<span class="tag">#{tag}</span>{/each}
|
||||||
|
{#if t.goalId}<span class="tag"><Target size={11} /> {goalTitle(t.goalId)}</span>{/if}
|
||||||
|
</p>
|
||||||
|
{#if t.notes}<p class="notes">{t.notes}</p>{/if}
|
||||||
|
</div>
|
||||||
|
<form method="POST" action="?/status" use:enhance={submitInline} class="hidden sm:block">
|
||||||
|
<input type="hidden" name="id" value={t.id} />
|
||||||
|
<select
|
||||||
|
name="status"
|
||||||
|
class="status-select"
|
||||||
|
aria-label={`Status of ${t.title}`}
|
||||||
|
value={t.status}
|
||||||
|
onchange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||||
|
>
|
||||||
|
{#each Object.entries(STATUS_LABEL) as [value, label] (value)}<option {value}
|
||||||
|
>{label}</option
|
||||||
|
>{/each}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
<button class="icon" type="button" aria-label={`Edit ${t.title}`} onclick={() => openTask(t)}
|
||||||
|
><Pencil size={14} /></button
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
{#each childrenOf[t.id] ?? [] as child (child.id)}
|
||||||
|
{@render taskRow(child, depth + 1)}
|
||||||
|
{/each}
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||||
|
<div class="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">tasks</h1>
|
||||||
|
<p class="mt-2 flex items-center gap-1.5 text-sm text-[var(--site-muted)]">
|
||||||
|
<Lock size={14} aria-hidden="true" /> Private. Only you can see this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl class="stats">
|
||||||
|
<div>
|
||||||
|
<dt>to do</dt>
|
||||||
|
<dd>{counts.todo}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>in progress</dt>
|
||||||
|
<dd>{counts.in_progress}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>done</dt>
|
||||||
|
<dd>{counts.done}</dd>
|
||||||
|
</div>
|
||||||
|
<div class:alert={counts.overdue > 0}>
|
||||||
|
<dt>overdue</dt>
|
||||||
|
<dd>{counts.overdue}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>this week</dt>
|
||||||
|
<dd>{counts.week}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>flagged</dt>
|
||||||
|
<dd>{counts.flagged}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/quick"
|
||||||
|
use:enhance={submitQuick}
|
||||||
|
class="panel mb-4 flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
name="title"
|
||||||
|
required
|
||||||
|
maxlength={500}
|
||||||
|
placeholder="Add a task… (end with ! to flag, !!! for high priority)"
|
||||||
|
class="min-w-[14rem] flex-1"
|
||||||
|
aria-label="New task"
|
||||||
|
/>
|
||||||
|
<NativeSelect name="areaId" bind:value={quickArea} aria-label="Area">
|
||||||
|
<option value="">No area</option>
|
||||||
|
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
|
||||||
|
</NativeSelect>
|
||||||
|
<Input name="due" type="date" class="w-auto" aria-label="Due date" />
|
||||||
|
<Button type="submit"><Plus size={16} /> Add</Button>
|
||||||
|
{#if quickError}<p class="w-full text-sm text-red-500" role="alert">{quickError}</p>{/if}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mb-6 flex flex-wrap items-center gap-2">
|
||||||
|
<div class="segmented" role="group" aria-label="Status filter">
|
||||||
|
{#each STATUS_FILTERS as [value, label] (value)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={statusFilter === value}
|
||||||
|
onclick={() => (statusFilter = value)}>{label}</button
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<NativeSelect bind:value={areaFilter} aria-label="Area filter">
|
||||||
|
<option value="all">All areas</option>
|
||||||
|
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
|
||||||
|
<option value="none">No area</option>
|
||||||
|
</NativeSelect>
|
||||||
|
<NativeSelect bind:value={priorityFilter} aria-label="Priority filter">
|
||||||
|
<option value="any">Any priority</option>
|
||||||
|
<option value="flagged">Flagged (!)</option>
|
||||||
|
<option value="high">Flagged or medium/high</option>
|
||||||
|
</NativeSelect>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
bind:value={search}
|
||||||
|
placeholder="Search…"
|
||||||
|
class="w-auto min-w-[10rem]"
|
||||||
|
aria-label="Search tasks"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onclick={() => openTask()}
|
||||||
|
><Plus size={16} /> Detailed task</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if upcoming.length && statusFilter === 'open' && areaFilter === 'all' && !search}
|
||||||
|
<section class="panel mb-6">
|
||||||
|
<h2 class="mb-2 text-lg font-semibold tracking-tight">Next two weeks</h2>
|
||||||
|
<ul class="divide-y divide-[var(--site-border)]">
|
||||||
|
{#each upcoming as t (t.id)}
|
||||||
|
{@const due = dueState(t)}
|
||||||
|
<li class="flex items-center gap-3 py-1.5 text-sm">
|
||||||
|
{@render statusButton(t)}
|
||||||
|
<span class="chip due-{due} w-28 shrink-0 justify-center">{dueLabel(t)}</span>
|
||||||
|
<button type="button" class="title truncate" onclick={() => openTask(t)}
|
||||||
|
>{t.title}</button
|
||||||
|
>
|
||||||
|
<small class="ml-auto hidden shrink-0 sm:inline">{areaName(t.areaId)}</small>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
|
{#each groups as g (g.key)}
|
||||||
|
<section class="panel">
|
||||||
|
<header class="mb-2 flex items-baseline justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold tracking-tight">{g.name}</h2>
|
||||||
|
{#if g.description}<small>{g.description}</small>{/if}
|
||||||
|
</div>
|
||||||
|
<small class="shrink-0 tabular-nums">{g.done}/{g.total} done</small>
|
||||||
|
</header>
|
||||||
|
{#if g.total}<div class="bar mb-2" aria-hidden="true">
|
||||||
|
<span style:width={`${(g.done / g.total) * 100}%`}></span>
|
||||||
|
</div>{/if}
|
||||||
|
<ul class="divide-y divide-[var(--site-border)]">
|
||||||
|
{#each g.tasks as t (t.id)}{@render taskRow(t, 0)}{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[var(--site-muted)]">No tasks match these filters.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="mt-12">
|
||||||
|
<header class="mb-4 flex items-center justify-between gap-2">
|
||||||
|
<h2 class="text-2xl font-semibold tracking-tight">goals</h2>
|
||||||
|
<Button type="button" variant="outline" onclick={() => openGoal()}
|
||||||
|
><Plus size={16} /> Goal</Button
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each data.goals as goal (goal.id)}
|
||||||
|
{@const stats = goalStats(goal)}
|
||||||
|
{@const pct = metricPct(goal)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="panel goal text-left"
|
||||||
|
class:muted={goal.status !== 'active'}
|
||||||
|
onclick={() => openGoal(goal)}
|
||||||
|
>
|
||||||
|
<p class="mb-1 flex items-start justify-between gap-2">
|
||||||
|
<strong class="leading-snug">{goal.title}</strong>
|
||||||
|
{#if goal.status !== 'active'}<span class="chip">{goal.status}</span
|
||||||
|
>{:else if goal.flagged}<Flag size={13} class="shrink-0 text-red-500" />{/if}
|
||||||
|
</p>
|
||||||
|
{#if goal.notes}<p class="notes mb-2">{goal.notes}</p>{/if}
|
||||||
|
{#if goal.targetDate}<small class="block">🎯 {targetLabel(goal.targetDate)}</small>{/if}
|
||||||
|
{#if goal.metricTarget !== null}
|
||||||
|
<small class="block"
|
||||||
|
>{goal.metric ?? 'Target'}: {goal.metricCurrent !== null
|
||||||
|
? `${money(goal.metricCurrent, goal.metricUnit)} / `
|
||||||
|
: ''}{money(goal.metricTarget, goal.metricUnit)}</small
|
||||||
|
>
|
||||||
|
{#if pct !== null}<div class="bar mt-1" aria-hidden="true">
|
||||||
|
<span style:width={`${pct}%`}></span>
|
||||||
|
</div>{/if}
|
||||||
|
{/if}
|
||||||
|
{#if stats.total}
|
||||||
|
<small class="mt-1 block">{stats.done}/{stats.total} linked tasks done</small>
|
||||||
|
<div class="bar mt-1" aria-hidden="true">
|
||||||
|
<span style:width={`${(stats.done / stats.total) * 100}%`}></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<p class="text-[var(--site-muted)]">No goals yet.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={taskDialog}>
|
||||||
|
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<Dialog.Header class="mb-4 pr-8">
|
||||||
|
<Dialog.Title
|
||||||
|
>{confirmDelete ? 'Delete task' : editing ? 'Edit task' : 'New task'}</Dialog.Title
|
||||||
|
>
|
||||||
|
<Dialog.Description class="sr-only">Manage a private task.</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
{#if confirmDelete && editing}
|
||||||
|
<form method="POST" action="?/delete" use:enhance={submitDialog}>
|
||||||
|
<input type="hidden" name="id" value={editing.id} />
|
||||||
|
<p class="mb-4 text-sm">
|
||||||
|
Delete “{editing.title}”{childrenOf[editing.id]?.length ? ' and its subtasks' : ''}? This
|
||||||
|
cannot be undone. (Marking it dropped keeps the history.)
|
||||||
|
</p>
|
||||||
|
{#if error}<p class="mb-3 text-sm text-red-500" role="alert">{error}</p>{/if}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onclick={() => (confirmDelete = false)}
|
||||||
|
>Back</Button
|
||||||
|
>
|
||||||
|
<Button type="submit" variant="destructive">Delete</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
{#key editing?.id ?? 'new'}
|
||||||
|
<form method="POST" action="?/save" use:enhance={submitDialog} class="grid gap-3">
|
||||||
|
{#if editing}<input type="hidden" name="id" value={editing.id} />{/if}
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Title *</span>
|
||||||
|
<Input name="title" required maxlength={500} value={editing?.title ?? ''} /></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Notes</span>
|
||||||
|
<Textarea name="notes" rows={3} value={editing?.notes ?? ''} /></label
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Status</span>
|
||||||
|
<NativeSelect name="status" value={editing?.status ?? 'todo'}>
|
||||||
|
{#each Object.entries(STATUS_LABEL) as [value, label] (value)}<option {value}
|
||||||
|
>{label}</option
|
||||||
|
>{/each}
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Priority</span>
|
||||||
|
<NativeSelect name="priority" value={String(editing?.priority ?? 0)}>
|
||||||
|
{#each PRIORITY as label, i (label)}<option value={String(i)}>{label}</option
|
||||||
|
>{/each}
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Due date</span>
|
||||||
|
<Input name="due" type="date" value={editing?.dueDate ?? ''} /></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Due time</span>
|
||||||
|
<Input
|
||||||
|
name="dueTime"
|
||||||
|
type="time"
|
||||||
|
value={editing?.dueTime?.slice(0, 5) ?? ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Area</span>
|
||||||
|
<NativeSelect
|
||||||
|
name="areaId"
|
||||||
|
value={editing ? String(editing.areaId ?? '') : quickArea}
|
||||||
|
>
|
||||||
|
<option value="">No area</option>
|
||||||
|
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Goal</span>
|
||||||
|
<NativeSelect name="goalId" value={String(editing?.goalId ?? '')}>
|
||||||
|
<option value="">None</option>
|
||||||
|
{#each data.goals as g (g.id)}<option value={String(g.id)}>{g.title}</option>{/each}
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Reminder</span>
|
||||||
|
<Input
|
||||||
|
name="reminderAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={editing?.reminderAt
|
||||||
|
? localMoment.format(editing.reminderAt).replace(' ', 'T')
|
||||||
|
: ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Subtask of</span>
|
||||||
|
<NativeSelect name="parentId" value={String(editing?.parentId ?? '')}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{#each data.tasks.filter((t) => t.id !== editing?.id && t.parentId === null && isOpen(t)) as t (t.id)}<option
|
||||||
|
value={String(t.id)}>{t.title}</option
|
||||||
|
>{/each}
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Tags <small>(comma or space separated)</small></span>
|
||||||
|
<Input name="tags" value={editing?.tags.join(', ') ?? ''} /></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Repeat <small>(RRULE, e.g. FREQ=WEEKLY;BYDAY=MO; not automatic yet)</small></span
|
||||||
|
>
|
||||||
|
<Input name="recurrence" value={editing?.recurrence ?? ''} /></label
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-2 text-sm"
|
||||||
|
><input type="checkbox" name="flagged" checked={editing?.flagged ?? false} /> Flagged (!)</label
|
||||||
|
>
|
||||||
|
{#if editing}<small
|
||||||
|
>Created {editing.createdAt.toLocaleDateString('en-GB')}{editing.completedAt
|
||||||
|
? ` · closed ${editing.completedAt.toLocaleDateString('en-GB')}`
|
||||||
|
: ''}{editing.source ? ` · ${editing.source}` : ''}</small
|
||||||
|
>{/if}
|
||||||
|
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
|
||||||
|
<div class="mt-2 flex justify-between gap-2">
|
||||||
|
{#if editing}<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-red-500"
|
||||||
|
onclick={() => (confirmDelete = true)}>Delete</Button
|
||||||
|
>{:else}<span></span>{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" onclick={() => (taskDialog = false)}
|
||||||
|
>Cancel</Button
|
||||||
|
>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<Dialog.Root bind:open={goalDialog}>
|
||||||
|
<Dialog.Content class="block max-h-[90dvh] overflow-y-auto sm:max-w-lg">
|
||||||
|
<Dialog.Header class="mb-4 pr-8">
|
||||||
|
<Dialog.Title
|
||||||
|
>{confirmDelete ? 'Delete goal' : editingGoal ? 'Edit goal' : 'New goal'}</Dialog.Title
|
||||||
|
>
|
||||||
|
<Dialog.Description class="sr-only">Manage a private goal.</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
{#if confirmDelete && editingGoal}
|
||||||
|
<form method="POST" action="?/deleteGoal" use:enhance={submitDialog}>
|
||||||
|
<input type="hidden" name="id" value={editingGoal.id} />
|
||||||
|
<p class="mb-4 text-sm">
|
||||||
|
Delete “{editingGoal.title}”? Linked tasks stay, without the goal.
|
||||||
|
</p>
|
||||||
|
{#if error}<p class="mb-3 text-sm text-red-500" role="alert">{error}</p>{/if}
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onclick={() => (confirmDelete = false)}
|
||||||
|
>Back</Button
|
||||||
|
>
|
||||||
|
<Button type="submit" variant="destructive">Delete</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
{#key editingGoal?.id ?? 'new'}
|
||||||
|
<form method="POST" action="?/saveGoal" use:enhance={submitDialog} class="grid gap-3">
|
||||||
|
{#if editingGoal}<input type="hidden" name="id" value={editingGoal.id} />{/if}
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Title *</span>
|
||||||
|
<Input name="title" required maxlength={300} value={editingGoal?.title ?? ''} /></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Notes</span>
|
||||||
|
<Textarea name="notes" rows={2} value={editingGoal?.notes ?? ''} /></label
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Status</span>
|
||||||
|
<NativeSelect name="status" value={editingGoal?.status ?? 'active'}>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="achieved">Achieved</option>
|
||||||
|
<option value="dropped">Dropped</option>
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Target date</span>
|
||||||
|
<Input name="targetDate" type="date" value={editingGoal?.targetDate ?? ''} /></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Area</span>
|
||||||
|
<NativeSelect name="areaId" value={String(editingGoal?.areaId ?? '')}>
|
||||||
|
<option value="">No area</option>
|
||||||
|
{#each data.areas as a (a.id)}<option value={String(a.id)}>{a.name}</option>{/each}
|
||||||
|
</NativeSelect></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Metric</span>
|
||||||
|
<Input
|
||||||
|
name="metric"
|
||||||
|
value={editingGoal?.metric ?? ''}
|
||||||
|
placeholder="e.g. Net income per month"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Current</span>
|
||||||
|
<Input
|
||||||
|
name="metricCurrent"
|
||||||
|
inputmode="decimal"
|
||||||
|
value={editingGoal?.metricCurrent ?? ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Target</span>
|
||||||
|
<Input
|
||||||
|
name="metricTarget"
|
||||||
|
inputmode="decimal"
|
||||||
|
value={editingGoal?.metricTarget ?? ''}
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="grid gap-1 text-sm"
|
||||||
|
><span>Unit</span>
|
||||||
|
<Input
|
||||||
|
name="metricUnit"
|
||||||
|
value={editingGoal?.metricUnit ?? ''}
|
||||||
|
placeholder="EUR, kg…"
|
||||||
|
/></label
|
||||||
|
>
|
||||||
|
<label class="flex items-center gap-2 self-end pb-2 text-sm"
|
||||||
|
><input type="checkbox" name="flagged" checked={editingGoal?.flagged ?? false} /> Flagged</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if error}<p class="text-sm text-red-500" role="alert">{error}</p>{/if}
|
||||||
|
<div class="mt-2 flex justify-between gap-2">
|
||||||
|
{#if editingGoal}<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
class="text-red-500"
|
||||||
|
onclick={() => (confirmDelete = true)}>Delete</Button
|
||||||
|
>{:else}<span></span>{/if}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button type="button" variant="outline" onclick={() => (goalDialog = false)}
|
||||||
|
>Cancel</Button
|
||||||
|
>
|
||||||
|
<Button type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: var(--site-surface);
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
}
|
||||||
|
small,
|
||||||
|
.notes {
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.notes {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
.stats dt {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.stats dd {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.stats .alert dd {
|
||||||
|
color: rgb(239 68 68);
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.task.closed .title {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.925rem;
|
||||||
|
}
|
||||||
|
.title:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: var(--site-muted);
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
}
|
||||||
|
.status:hover {
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.status-in_progress {
|
||||||
|
color: rgb(245 158 11);
|
||||||
|
}
|
||||||
|
.status-done {
|
||||||
|
color: rgb(34 197 94);
|
||||||
|
}
|
||||||
|
.status-select {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--site-muted);
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.1rem 0.25rem;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
color: var(--site-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.due-overdue {
|
||||||
|
background: rgb(239 68 68 / 0.15);
|
||||||
|
border-color: rgb(239 68 68 / 0.5);
|
||||||
|
color: rgb(239 68 68);
|
||||||
|
}
|
||||||
|
.due-today {
|
||||||
|
background: rgb(245 158 11 / 0.18);
|
||||||
|
border-color: rgb(245 158 11 / 0.55);
|
||||||
|
color: rgb(217 119 6);
|
||||||
|
}
|
||||||
|
.due-soon {
|
||||||
|
border-color: rgb(59 130 246 / 0.5);
|
||||||
|
color: rgb(59 130 246);
|
||||||
|
}
|
||||||
|
.prio-3 {
|
||||||
|
color: rgb(239 68 68);
|
||||||
|
}
|
||||||
|
.prio-2 {
|
||||||
|
color: rgb(245 158 11);
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.3rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.icon:hover {
|
||||||
|
background: color-mix(in srgb, var(--site-muted) 15%, transparent);
|
||||||
|
color: var(--site-text);
|
||||||
|
}
|
||||||
|
.segmented {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
border: 1px solid var(--site-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.segmented button {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
color: var(--site-muted);
|
||||||
|
}
|
||||||
|
.segmented button[aria-pressed='true'] {
|
||||||
|
background: color-mix(in srgb, var(--site-text) 12%, transparent);
|
||||||
|
color: var(--site-text);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--site-muted) 20%, transparent);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: rgb(34 197 94);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
.goal {
|
||||||
|
display: block;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.goal:hover {
|
||||||
|
border-color: color-mix(in srgb, var(--site-text) 35%, var(--site-border));
|
||||||
|
}
|
||||||
|
.goal.muted {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user