commit df4068bb3ba14435d3b0afd80c1db46fbdecfc34 Author: Marcus Rehbock Date: Fri Aug 7 16:36:06 2026 -0700 GTD MVP: Expo app (iOS/Android/web) + Bun API + Postgres schema Co-Authored-By: Claude Fable 5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0e9638 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +.expo/ +server/.env +server/.env.token.local +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..2c378b1 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# GTD + +Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Android, and web; data lives in Postgres on the VPS. + +- **Web**: https://gtd.rehbock.xyz (Expo web export, static) +- **API**: https://gtd.rehbock.xyz/api → `gtd-api` container (Bun, `server/`) +- **DB**: `gtd` database in the `personal-db` Postgres container (`server/schema.sql`) +- **Auth**: single bearer token (`API_TOKEN` in `server/.env` on the VPS); enter it once in the app's Settings + +## Layout + +- `app/` — Expo app (expo-router, TypeScript). Tabs: Inbox / Next / Waiting / Someday / Projects; tap a task to clarify (list, project, context, waiting-for, due/defer dates). +- `server/` — Bun API + Dockerfile + compose. Deployed at `~/personal/gtd` on rehbock.xyz, joined to `personal-db_default` + `caddy_net`. + +## Develop + +```sh +cd app && npx expo start # scan QR with Expo Go (iPhone or Pixel) +``` + +## Deploy + +```sh +# API +rsync -a server/ --exclude .env.token.local rehbock.xyz:personal/gtd/ +ssh rehbock.xyz 'cd ~/personal/gtd; and docker compose up -d --build' + +# Web +cd app && npx expo export --platform web +rsync -a --delete dist/ rehbock.xyz:personal/gtd/web/ +``` diff --git a/app b/app new file mode 160000 index 0000000..b35ebf9 --- /dev/null +++ b/app @@ -0,0 +1 @@ +Subproject commit b35ebf933672c2ab26d9a90e205fa3188c8c9a64 diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..38cf07a --- /dev/null +++ b/server/.env.example @@ -0,0 +1,3 @@ +# Copy to .env on the server. Real values live only there. +DATABASE_URL=postgres://marcus:CHANGE_ME@personal-db:5432/gtd +API_TOKEN=CHANGE_ME diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..9e8a326 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,5 @@ +FROM oven/bun:1-alpine +WORKDIR /app +COPY index.ts schema.sql ./ +EXPOSE 3000 +CMD ["bun", "run", "index.ts"] diff --git a/server/docker-compose.yml b/server/docker-compose.yml new file mode 100644 index 0000000..fbca856 --- /dev/null +++ b/server/docker-compose.yml @@ -0,0 +1,22 @@ +services: + gtd-api: + build: . + container_name: gtd-api + restart: unless-stopped + env_file: .env + networks: + # Reach Postgres by service name `personal-db` on its network. + - personal-db_default + # Be reachable by paico-proxy for gtd.rehbock.xyz. + - caddy_net + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/healthz || exit 1"] + interval: 30s + timeout: 5s + retries: 3 + +networks: + personal-db_default: + external: true + caddy_net: + external: true diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..f0110fc --- /dev/null +++ b/server/index.ts @@ -0,0 +1,160 @@ +// gtd-api — minimal GTD backend. Bun + Postgres (personal-db), bearer-token auth. +import { SQL } from "bun"; + +const sql = new SQL(process.env.DATABASE_URL!); +const API_TOKEN = process.env.API_TOKEN!; +if (!API_TOKEN) throw new Error("API_TOKEN not set"); + +const TASK_COLS = `id, title, notes, status, project_id, context, waiting_for, + due_date::text as due_date, defer_date::text as defer_date, + completed_at, sort_order, created_at, updated_at`; +const PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_at`; + +const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"]; +const PROJECT_STATUSES = ["active", "someday", "completed", "dropped"]; + +const CORS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type", +}; + +function json(data: unknown, status = 200) { + return Response.json(data, { status, headers: CORS }); +} +function err(message: string, status: number) { + return json({ error: message }, status); +} + +// Fields a client may set. completed_at is derived from status server-side. +const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "sort_order"]; +const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"]; + +function pick(body: Record, fields: string[]) { + const out: Record = {}; + for (const f of fields) if (f in body) out[f] = body[f] === "" ? null : body[f]; + return out; +} + +async function handle(req: Request): Promise { + const url = new URL(req.url); + const path = url.pathname; + const method = req.method; + + if (method === "OPTIONS") return new Response(null, { status: 204, headers: CORS }); + if (path === "/healthz") return json({ ok: true }); + + if (req.headers.get("authorization") !== `Bearer ${API_TOKEN}`) return err("unauthorized", 401); + + const body = method === "POST" || method === "PATCH" ? await req.json().catch(() => null) : null; + if ((method === "POST" || method === "PATCH") && !body) return err("invalid JSON body", 400); + + // ---- tasks ---- + if (path === "/v1/tasks" && method === "GET") { + const status = url.searchParams.get("status"); + const projectId = url.searchParams.get("project_id"); + if (status && !TASK_STATUSES.includes(status)) return err("bad status", 400); + const where: string[] = []; + const params: unknown[] = []; + if (status) { params.push(status); where.push(`status = $${params.length}`); } + else where.push(`status != 'trashed'`); + if (projectId) { params.push(projectId); where.push(`project_id = $${params.length}`); } + const rows = await sql.unsafe( + `select ${TASK_COLS} from tasks where ${where.join(" and ")} + order by sort_order, created_at desc`, params); + return json(rows); + } + + if (path === "/v1/tasks" && method === "POST") { + const t = pick(body, TASK_FIELDS); + if (typeof t.title !== "string" || !t.title.trim()) return err("title required", 400); + if (t.status && !TASK_STATUSES.includes(t.status as string)) return err("bad status", 400); + const rows = await sql.unsafe( + `insert into tasks (title, notes, status, project_id, context, waiting_for, due_date, defer_date, sort_order) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9) + returning ${TASK_COLS}`, + [t.title, t.notes ?? "", t.status ?? "inbox", t.project_id ?? null, t.context ?? null, + t.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.sort_order ?? 0]); + return json(rows[0], 201); + } + + let m = path.match(/^\/v1\/tasks\/([0-9a-f-]{36})$/); + if (m && method === "GET") { + const rows = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [m[1]]); + return rows.length ? json(rows[0]) : err("not found", 404); + } + if (m && (method === "PATCH" || method === "DELETE")) { + const id = m[1]; + const patch = method === "DELETE" ? { status: "trashed" } : pick(body, TASK_FIELDS); + if (patch.status && !TASK_STATUSES.includes(patch.status as string)) return err("bad status", 400); + const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]); + if (!existing.length) return err("not found", 404); + const cur = { ...existing[0], ...patch }; + const completedAt = cur.status === "done" ? (existing[0].completed_at ?? new Date()) : null; + const rows = await sql.unsafe( + `update tasks set title=$2, notes=$3, status=$4, project_id=$5, context=$6, waiting_for=$7, + due_date=$8, defer_date=$9, completed_at=$10, sort_order=$11, updated_at=now() + where id=$1 returning ${TASK_COLS}`, + [id, cur.title, cur.notes, cur.status, cur.project_id, cur.context, cur.waiting_for, + cur.due_date, cur.defer_date, completedAt, cur.sort_order]); + return json(rows[0]); + } + + // ---- projects ---- + if (path === "/v1/projects" && method === "GET") { + const rows = await sql.unsafe( + `select ${PROJECT_COLS}, + (select count(*) from tasks t where t.project_id = projects.id + and t.status not in ('done', 'trashed'))::int as open_tasks + from projects where status != 'dropped' + order by sort_order, created_at desc`, []); + return json(rows); + } + + if (path === "/v1/projects" && method === "POST") { + const p = pick(body, PROJECT_FIELDS); + if (typeof p.name !== "string" || !p.name.trim()) return err("name required", 400); + if (p.status && !PROJECT_STATUSES.includes(p.status as string)) return err("bad status", 400); + const rows = await sql.unsafe( + `insert into projects (name, status, notes, sort_order) values ($1, $2, $3, $4) + returning ${PROJECT_COLS}`, + [p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0]); + return json(rows[0], 201); + } + + m = path.match(/^\/v1\/projects\/([0-9a-f-]{36})$/); + if (m && (method === "PATCH" || method === "DELETE")) { + const id = m[1]; + const patch = method === "DELETE" ? { status: "dropped" } : pick(body, PROJECT_FIELDS); + if (patch.status && !PROJECT_STATUSES.includes(patch.status as string)) return err("bad status", 400); + const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]); + if (!existing.length) return err("not found", 404); + const cur = { ...existing[0], ...patch }; + const rows = await sql.unsafe( + `update projects set name=$2, status=$3, notes=$4, sort_order=$5, updated_at=now() + where id=$1 returning ${PROJECT_COLS}`, + [id, cur.name, cur.status, cur.notes, cur.sort_order]); + return json(rows[0]); + } + + if (path === "/v1/contexts" && method === "GET") { + const rows = await sql.unsafe( + `select distinct context from tasks + where context is not null and status not in ('done', 'trashed') order by context`, []); + return json(rows.map((r: { context: string }) => r.context)); + } + + return err("not found", 404); +} + +Bun.serve({ + port: 3000, + hostname: "0.0.0.0", + fetch: (req) => + handle(req).catch((e) => { + console.error(e); + return err("internal error", 500); + }), +}); + +console.log("gtd-api listening on :3000"); diff --git a/server/schema.sql b/server/schema.sql new file mode 100644 index 0000000..2883e00 --- /dev/null +++ b/server/schema.sql @@ -0,0 +1,33 @@ +-- GTD schema. Applied idempotently by migrate step in entrypoint. + +create table if not exists projects ( + id uuid primary key default gen_random_uuid(), + name text not null, + status text not null default 'active' + check (status in ('active', 'someday', 'completed', 'dropped')), + notes text not null default '', + sort_order double precision not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists tasks ( + id uuid primary key default gen_random_uuid(), + title text not null, + notes text not null default '', + -- The GTD lists. 'scheduled' = calendar/tickler items surfaced by defer_date. + status text not null default 'inbox' + check (status in ('inbox', 'next', 'waiting', 'scheduled', 'someday', 'done', 'trashed')), + project_id uuid references projects(id) on delete set null, + context text, -- '@home', '@computer', '@errands', '@calls', ... + waiting_for text, -- who/what is being waited on when status = 'waiting' + due_date date, + defer_date date, -- hide from lists until this date + completed_at timestamptz, + sort_order double precision not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists tasks_status_idx on tasks (status); +create index if not exists tasks_project_idx on tasks (project_id);