diff --git a/README.md b/README.md index ec6004a..eb226a8 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ Minimal Getting Things Done app. One Expo/React Native codebase for iPhone, Andr ## Layout -- `app/` — Expo app (expo-router, TypeScript). Home shows the top 3 next actions plus a launcher menu: Today / Next / Waiting / Someday / Projects / Review. There is no inbox — every task is born fully scoped (title, priority, due date). Tap a task to clarify (list, project, context, waiting-for, due/defer dates, repeat). +- `app/` — Expo app (expo-router, TypeScript). Home shows the top 3 next actions plus a launcher menu: Today / Next / Waiting / Someday / Projects / Review. + - **Personal / Work**: the toggle in the home header is a global lens — every list, Today, Projects and Review show only the active area, and new tasks/projects are born into it. Tasks and projects carry an `area` column (`personal` default); the task form's Area chip re-files a task across areas. The choice persists locally (`area_v1`). There is no inbox — every task is born fully scoped (title, priority, due date). Tap a task to clarify (list, project, context, waiting-for, due/defer dates, repeat). - **Quick add**: the title field parses tokens live — `Pay rent tmr p1 @home #Sydney ~30m monthly` — and mirrors them into the form. - **Repeat**: daily / weekdays / weekly / monthly / yearly; completing spawns the next occurrence (lapsed tasks catch up to today). - **Defer**: deferred tasks are hidden from action lists until their date; a footer link reveals them. diff --git a/app/src/app/_layout.tsx b/app/src/app/_layout.tsx index dda5c4a..06783e9 100644 --- a/app/src/app/_layout.tsx +++ b/app/src/app/_layout.tsx @@ -5,6 +5,7 @@ import { useEffect } from "react"; import { AppState, Pressable } from "react-native"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { getState, hydrate, subscribe, sync } from "../lib/store"; +import AreaToggle from "../components/AreaToggle"; import Icon from "../components/Icon"; import { ToastHost } from "../components/Toast"; import { C, space } from "../lib/theme"; @@ -54,6 +55,11 @@ export default function RootLayout() { name="index" options={{ title: "GTD", + // The personal/work switch sits where the title would be: it is + // the screen's identity, and it must be reachable from the top of + // every visit without scrolling. + headerTitle: () => , + headerTitleAlign: "center", headerRight: () => ( ({ estimate_min: t.estimate_min, repeat: t.repeat, priority: t.priority, + area: t.area ?? "personal", }); export default function TaskDetail() { diff --git a/app/src/components/AreaToggle.tsx b/app/src/components/AreaToggle.tsx new file mode 100644 index 0000000..f475a97 --- /dev/null +++ b/app/src/components/AreaToggle.tsx @@ -0,0 +1,64 @@ +// The personal / work switch that lives in the home header. +// +// A two-segment pill rather than a menu or a settings row: the whole point is +// that flipping halves of your life is one tap and its state is always +// visible. Work gets its own tone so a glance at the header tells you which +// lens is on even before you read the label. +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { haptic } from "../lib/haptics"; +import { setArea } from "../lib/store"; +import { C, radius, type } from "../lib/theme"; +import { useStore } from "../lib/useStore"; +import { AREA_LABEL, AREAS, type Area } from "../lib/types"; + +export const AREA_TONE: Record = { personal: C.accent, work: C.work }; + +export default function AreaToggle() { + const { area } = useStore(); + return ( + + {AREAS.map((a) => { + const active = a === area; + return ( + { + if (active) return; + haptic.select(); + setArea(a); + }} + style={({ pressed }) => [ + s.segment, + active && { backgroundColor: AREA_TONE[a] }, + pressed && !active && s.segmentPressed, + ]} + > + {AREA_LABEL[a]} + + ); + })} + + ); +} + +const s = StyleSheet.create({ + track: { + flexDirection: "row", + padding: 3, + borderRadius: radius.pill, + backgroundColor: C.surface2, + borderWidth: StyleSheet.hairlineWidth, + borderColor: C.border, + }, + segment: { + paddingVertical: 5, + paddingHorizontal: 16, + borderRadius: radius.pill, + }, + segmentPressed: { backgroundColor: C.border }, + label: { ...type.chip, color: C.textSecondary }, + labelActive: { color: C.onAccent, fontWeight: "600" }, +}); diff --git a/app/src/components/Icon.tsx b/app/src/components/Icon.tsx index cd88c9d..aacaba6 100644 --- a/app/src/components/Icon.tsx +++ b/app/src/components/Icon.tsx @@ -18,6 +18,7 @@ export type IconName = | "flag" | "repeat" | "tag" + | "area" | "plus" | "close" | "chevron"; @@ -281,6 +282,38 @@ export default function Icon({ name, size = 22, color = C.textSecondary, bg = C. ); + case "area": + // Briefcase: a body with a short handle on top. + return ( + + + + + + ); + case "close": return ( diff --git a/app/src/components/TaskForm.tsx b/app/src/components/TaskForm.tsx index b77d795..7210472 100644 --- a/app/src/components/TaskForm.tsx +++ b/app/src/components/TaskForm.tsx @@ -15,15 +15,16 @@ import { friendlyDate } from "../lib/dates"; import { haptic } from "../lib/haptics"; import { formatEstimate, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority"; import { REPEAT_LABEL, REPEATS } from "../lib/repeat"; -import { activeProjects, knownContexts } from "../lib/store"; +import { getState, knownContexts } from "../lib/store"; import { C, radius, space, type } from "../lib/theme"; import { useStore } from "../lib/useStore"; -import type { Task, TaskStatus } from "../lib/types"; +import { AREA_LABEL, AREAS, type Task, type TaskStatus } from "../lib/types"; +import { AREA_TONE } from "./AreaToggle"; /** Everything a task screen edits. Priority is nullable while undecided. */ export type TaskDraft = Pick< Task, - "title" | "notes" | "status" | "project_id" | "context" | "waiting_for" | "due_date" | "defer_date" | "estimate_min" | "repeat" + "title" | "notes" | "status" | "project_id" | "context" | "waiting_for" | "due_date" | "defer_date" | "estimate_min" | "repeat" | "area" > & { priority: Task["priority"] | null }; export const BOX_LABEL: Record = { @@ -48,6 +49,8 @@ export const emptyDraft = (): TaskDraft => ({ estimate_min: null, repeat: null, priority: null, + // Born into whichever lens is on — that's what "add" means in work mode. + area: getState().area, }); export default function TaskForm({ @@ -71,7 +74,12 @@ export default function TaskForm({ const store = useStore(); // Keep a project that has since been archived visible, or editing an old task // would silently drop it. - const projects = activeProjects(store).filter((p) => p.status === "active" || p.id === d.project_id); + // Projects follow the draft's area, not the global lens, so re-filing a task + // to Work offers Work projects. An archived project stays visible while the + // task still points at it. + const projects = store.projects + .filter((p) => (p.status === "active" && (p.area ?? "personal") === d.area) || p.id === d.project_id) + .sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at)); const contexts = knownContexts(store); const projectName = projects.find((p) => p.id === d.project_id)?.name ?? null; @@ -185,6 +193,32 @@ export default function TaskForm({ }, ]; + // Area reads as a placeholder while it matches the lens you're in — only a + // deliberate cross-filing (a work task captured from personal) earns colour. + attrs.push({ + key: "area", + icon: "area", + placeholder: AREA_LABEL[store.area], + value: d.area === store.area ? null : AREA_LABEL[d.area], + tone: AREA_TONE[d.area], + panel: ( + + { + if (area === d.area) return; + haptic.select(); + // A project belongs to one area; drop it when the task moves. + onChange({ area, project_id: null }); + }} + /> + Only shows up when that lens is on. + + ), + }); + if (showDefer) { attrs.push({ key: "defer", diff --git a/app/src/lib/store.test.ts b/app/src/lib/store.test.ts index f2cb301..e62fea0 100644 --- a/app/src/lib/store.test.ts +++ b/app/src/lib/store.test.ts @@ -21,6 +21,7 @@ const store = await import("./store"); beforeEach(async () => { storage.clear(); await store.hydrate(); + store.setArea("personal"); }); const find = (id: string) => store.getState().tasks.find((t) => t.id === id)!; @@ -61,3 +62,66 @@ describe("completeTask", () => { expect(store.completeTask("nope")).toBeNull(); }); }); + +describe("area lens", () => { + test("new rows are born into the active area and selectors filter by it", () => { + const home = store.createTask({ title: "home" }); + store.setArea("work"); + const office = store.createTask({ title: "office" }); + const proj = store.createProject({ name: "Q4" }); + expect(home.area).toBe("personal"); + expect(office.area).toBe("work"); + expect(proj.area).toBe("work"); + + // The store is module state shared across tests, so assert on membership + // and on every visible row's area rather than on exact lists. + const ids = (s: ReturnType) => store.tasksByStatus(s, "next").map((t) => t.id); + let s = store.getState(); + expect(ids(s)).toContain(office.id); + expect(ids(s)).not.toContain(home.id); + expect(store.tasksByStatus(s, "next").every((t) => t.area === "work")).toBe(true); + expect(store.activeProjects(s).map((p) => p.id)).toEqual([proj.id]); + + store.setArea("personal"); + s = store.getState(); + expect(ids(s)).toContain(home.id); + expect(ids(s)).not.toContain(office.id); + expect(store.activeProjects(s).some((p) => p.id === proj.id)).toBe(false); + }); + + test("area persists across hydrate", async () => { + store.setArea("work"); + expect(storage.get("area_v1")).toBe("work"); + }); + + test("rows cached without an area count as personal", () => { + // Simulate a pre-migration cache row. + const legacy = store.createTask({ title: "old" }); + delete (legacy as { area?: string }).area; + store.setArea("work"); + expect(store.todayTasks(store.getState()).overdue).toEqual([]); + store.setArea("personal"); + expect(store.tasksByStatus(store.getState(), "next").some((t) => t.id === legacy.id)).toBe(true); + }); + + test("today, review and contexts respect the lens", () => { + store.createTask({ title: "p", due_date: "2000-01-01", context: "@home" }); + store.setArea("work"); + store.createTask({ title: "w", due_date: "2000-01-01", context: "@office" }); + const s = store.getState(); + expect(store.todayTasks(s).overdue.every((t) => t.area === "work")).toBe(true); + expect(store.todayTasks(s).overdue.map((t) => t.title)).toContain("w"); + expect(store.reviewBuckets(s).overdue.map((t) => t.title)).toContain("w"); + expect(store.reviewBuckets(s).overdue.map((t) => t.title)).not.toContain("p"); + expect(store.knownContexts(s)).toEqual(["@office"]); + }); + + test("completing a repeating work task spawns into work", () => { + store.setArea("work"); + const t = store.createTask({ title: "standup", repeat: "daily", due_date: "2000-01-01" }); + store.setArea("personal"); + store.completeTask(t.id); + const spawned = store.getState().tasks.find((x) => x.title === "standup" && x.status === "next")!; + expect(spawned.area).toBe("work"); + }); +}); diff --git a/app/src/lib/store.ts b/app/src/lib/store.ts index 9d3ef00..9ac0085 100644 --- a/app/src/lib/store.ts +++ b/app/src/lib/store.ts @@ -12,11 +12,17 @@ import { api } from "./api"; import { getConfig } from "./config"; import { daysFromToday, isDeferred, todayIso } from "./dates"; import { nextOccurrence, shiftDays } from "./repeat"; -import type { Project, Task } from "./types"; +import type { Area, Project, Task } from "./types"; export interface State { tasks: Task[]; projects: Project[]; + /** + * Which half of life is on screen. Every selector filters by it, so the + * toggle acts as a global lens: work mode shows no personal rows and vice + * versa. New tasks and projects are born into the active area. + */ + area: Area; hydrated: boolean; syncing: boolean; pending: number; @@ -31,10 +37,12 @@ type Op = const DATA_KEY = "store_v1"; const QUEUE_KEY = "queue_v1"; +const AREA_KEY = "area_v1"; let state: State = { tasks: [], projects: [], + area: "personal", hydrated: false, syncing: false, pending: 0, @@ -68,13 +76,39 @@ function persistQueue() { export async function hydrate() { if (state.hydrated) return; - const [data, q] = await Promise.all([AsyncStorage.getItem(DATA_KEY), AsyncStorage.getItem(QUEUE_KEY)]); + const [data, q, area] = await Promise.all([ + AsyncStorage.getItem(DATA_KEY), + AsyncStorage.getItem(QUEUE_KEY), + AsyncStorage.getItem(AREA_KEY), + ]); if (q) queue = JSON.parse(q); const parsed = data ? JSON.parse(data) : { tasks: [], projects: [] }; - setState({ tasks: parsed.tasks, projects: parsed.projects, hydrated: true }); + setState({ + tasks: parsed.tasks, + projects: parsed.projects, + area: area === "work" ? "work" : "personal", + hydrated: true, + }); void sync(); } +/** Flip the personal/work lens. Persists so the app reopens where you left it. */ +export function setArea(area: Area) { + if (area === state.area) return; + setState({ area }); + void AsyncStorage.setItem(AREA_KEY, area); +} + +// Rows cached before the area column existed have no `area`; they are +// personal, matching the server's column default. +function areaOf(row: { area?: Area }): Area { + return row.area ?? "personal"; +} + +function inArea(s: State, rows: T[]): T[] { + return rows.filter((r) => areaOf(r) === s.area); +} + // ---- mutations (instant; network happens later) ---- function now() { @@ -83,8 +117,8 @@ function now() { // Fields the API accepts — everything else (timestamps, completed_at) is local // bookkeeping until the server's authoritative row arrives via pull(). -const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "repeat", "sort_order"] as const; -const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"] as const; +const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "repeat", "sort_order", "area"] as const; +const PROJECT_FIELDS = ["name", "status", "notes", "sort_order", "area"] as const; function pickFields(data: Partial, fields: readonly (keyof T & string)[]): Partial { const out: Partial = {}; @@ -122,6 +156,7 @@ export function createTask(data: Partial & { title: string }): Task { estimate_min: data.estimate_min ?? null, repeat: data.repeat ?? null, completed_at: null, + area: data.area ?? state.area, // New tasks land at the top of their list. sort_order: data.sort_order ?? @@ -202,6 +237,7 @@ function spawnNextOccurrence(t: Task): Task { priority: t.priority, estimate_min: t.estimate_min, repeat: t.repeat, + area: areaOf(t), }); } @@ -216,6 +252,7 @@ export function createProject(data: Partial & { name: string }): Projec status: data.status ?? "active", notes: data.notes ?? "", sort_order: data.sort_order ?? 0, + area: data.area ?? state.area, created_at: now(), updated_at: now(), }; @@ -333,7 +370,7 @@ function byManualOrder(a: Task, b: Task): number { } export function tasksByStatus(s: State, status: Task["status"]): Task[] { - return s.tasks.filter((t) => t.status === status).sort(byManualOrder); + return inArea(s, s.tasks).filter((t) => t.status === status).sort(byManualOrder); } /** @@ -362,7 +399,7 @@ export function reorderTask(id: string, status: Task["status"], index: number) { export function todayTasks(s: State): { overdue: Task[]; today: Task[] } { const now = new Date(); const todayIso = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; - const open = s.tasks.filter((t) => t.status !== "done" && t.status !== "trashed"); + const open = inArea(s, s.tasks).filter((t) => t.status !== "done" && t.status !== "trashed"); return { overdue: open.filter((t) => t.due_date && t.due_date < todayIso).sort(byPriorityDue), today: open @@ -372,7 +409,7 @@ export function todayTasks(s: State): { overdue: Task[]; today: Task[] } { } export function activeProjects(s: State): Project[] { - return s.projects + return inArea(s, s.projects) .filter((p) => p.status !== "dropped") .sort((a, b) => a.sort_order - b.sort_order || b.created_at.localeCompare(a.created_at)); } @@ -384,7 +421,7 @@ export function openTaskCount(s: State, projectId: string): number { /** Distinct contexts on open tasks — suggestion chips beat retyping "@homr". */ export function knownContexts(s: State): string[] { const set = new Set(); - for (const t of s.tasks) { + for (const t of inArea(s, s.tasks)) { if (t.context && t.status !== "done" && t.status !== "trashed") set.add(t.context); } return [...set].sort(); @@ -410,7 +447,7 @@ export interface ReviewBuckets { export function reviewBuckets(s: State): ReviewBuckets { const today = todayIso(); - const open = s.tasks.filter((t) => t.status !== "done" && t.status !== "trashed"); + const open = inArea(s, s.tasks).filter((t) => t.status !== "done" && t.status !== "trashed"); const overdue = open.filter((t) => t.due_date && t.due_date < today).sort(byPriorityDue); const overdueIds = new Set(overdue.map((t) => t.id)); const actionable = new Set(["next", "waiting"]); diff --git a/app/src/lib/theme.ts b/app/src/lib/theme.ts index cea5cb3..f510e7b 100644 --- a/app/src/lib/theme.ts +++ b/app/src/lib/theme.ts @@ -27,6 +27,8 @@ const light = { accent: "#2F6FED", /** Tinted accent wash for selected chips and active states. */ accentSoft: "#E8F0FE", + /** Accent for the work lens — the one place the app changes colour. */ + work: "#0F766E", done: "#2FA36B", danger: "#E5484D", warn: "#D98324", @@ -45,6 +47,7 @@ const dark = { muted: "#7A7A85", accent: "#4C86F0", accentSoft: "#1B2740", + work: "#2DD4BF", done: "#3FBF84", danger: "#F0605F", warn: "#E0A93E", diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 14da65b..8376a7c 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -1,6 +1,11 @@ export type TaskStatus = "inbox" | "next" | "waiting" | "scheduled" | "someday" | "done" | "trashed"; export type ProjectStatus = "active" | "someday" | "completed" | "dropped"; export type Repeat = "daily" | "weekdays" | "weekly" | "monthly" | "yearly"; +/** Personal vs work — the app-wide toggle shows one area at a time. */ +export type Area = "personal" | "work"; + +export const AREAS: Area[] = ["personal", "work"]; +export const AREA_LABEL: Record = { personal: "Personal", work: "Work" }; export interface Task { id: string; @@ -17,6 +22,7 @@ export interface Task { repeat: Repeat | null; completed_at: string | null; sort_order: number; + area: Area; created_at: string; updated_at: string; } @@ -27,6 +33,7 @@ export interface Project { status: ProjectStatus; notes: string; sort_order: number; + area: Area; created_at: string; updated_at: string; open_tasks?: number; diff --git a/server/index.ts b/server/index.ts index 48e73ac..b605ec0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,12 +11,13 @@ await sql.unsafe(await Bun.file(new URL("schema.sql", import.meta.url)).text()); const TASK_COLS = `id, title, notes, status, project_id, context, waiting_for, due_date::text as due_date, defer_date::text as defer_date, - priority, estimate_min, repeat, completed_at, sort_order, created_at, updated_at`; -const PROJECT_COLS = `id, name, status, notes, sort_order, created_at, updated_at`; + priority, estimate_min, repeat, completed_at, sort_order, area, created_at, updated_at`; +const PROJECT_COLS = `id, name, status, notes, sort_order, area, created_at, updated_at`; const TASK_STATUSES = ["inbox", "next", "waiting", "scheduled", "someday", "done", "trashed"]; const REPEATS = ["daily", "weekdays", "weekly", "monthly", "yearly"]; const PROJECT_STATUSES = ["active", "someday", "completed", "dropped"]; +const AREAS = ["personal", "work"]; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; const CORS = { @@ -33,8 +34,8 @@ function err(message: string, status: number) { } // 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", "priority", "estimate_min", "repeat", "sort_order"]; -const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"]; +const TASK_FIELDS = ["title", "notes", "status", "project_id", "context", "waiting_for", "due_date", "defer_date", "priority", "estimate_min", "repeat", "sort_order", "area"]; +const PROJECT_FIELDS = ["name", "status", "notes", "sort_order", "area"]; // Only these may be cleared to NULL; notes/title are NOT NULL and keep "". const NULLABLE = new Set(["project_id", "context", "waiting_for", "due_date", "defer_date", "estimate_min", "repeat"]); @@ -62,12 +63,15 @@ async function handle(req: Request): Promise { if (path === "/v1/tasks" && method === "GET") { const status = url.searchParams.get("status"); const projectId = url.searchParams.get("project_id"); + const area = url.searchParams.get("area"); if (status && !TASK_STATUSES.includes(status)) return err("bad status", 400); + if (area && !AREAS.includes(area)) return err("bad area", 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}`); } + if (area) { params.push(area); where.push(`area = $${params.length}`); } const rows = await sql.unsafe( `select ${TASK_COLS} from tasks where ${where.join(" and ")} order by sort_order, created_at desc`, params); @@ -79,16 +83,17 @@ async function handle(req: Request): Promise { 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); if (t.repeat && !REPEATS.includes(t.repeat as string)) return err("bad repeat", 400); + if (t.area && !AREAS.includes(t.area as string)) return err("bad area", 400); const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null; // Client-supplied id + on-conflict no-op make offline-queue retries idempotent. const rows = await sql.unsafe( - `insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, priority, estimate_min, repeat, sort_order) - values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + `insert into tasks (id, title, notes, status, project_id, context, waiting_for, due_date, defer_date, priority, estimate_min, repeat, sort_order, area) + values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) on conflict (id) do nothing returning ${TASK_COLS}`, [id, t.title, t.notes ?? "", t.status ?? "next", t.project_id ?? null, t.context ?? null, t.waiting_for ?? null, t.due_date ?? null, t.defer_date ?? null, t.priority ?? 4, - t.estimate_min ?? null, t.repeat ?? null, t.sort_order ?? 0]); + t.estimate_min ?? null, t.repeat ?? null, t.sort_order ?? 0, t.area ?? "personal"]); if (rows.length) return json(rows[0], 201); const existing = await sql.unsafe(`select ${TASK_COLS} from tasks where id = $1`, [id]); return json(existing[0], 200); @@ -104,6 +109,7 @@ async function handle(req: Request): Promise { 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); if (patch.repeat && !REPEATS.includes(patch.repeat as string)) return err("bad repeat", 400); + if (patch.area && !AREAS.includes(patch.area as string)) return err("bad area", 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 }; @@ -111,10 +117,10 @@ async function handle(req: Request): Promise { 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, priority=$10, estimate_min=$11, repeat=$12, completed_at=$13, - sort_order=$14, updated_at=now() + sort_order=$14, area=$15, 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, cur.priority, cur.estimate_min, cur.repeat, completedAt, cur.sort_order]); + cur.due_date, cur.defer_date, cur.priority, cur.estimate_min, cur.repeat, completedAt, cur.sort_order, cur.area]); return json(rows[0]); } @@ -133,13 +139,14 @@ async function handle(req: Request): Promise { 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); + if (p.area && !AREAS.includes(p.area as string)) return err("bad area", 400); const id = typeof body.id === "string" && UUID_RE.test(body.id) ? body.id : null; const rows = await sql.unsafe( - `insert into projects (id, name, status, notes, sort_order) - values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5) + `insert into projects (id, name, status, notes, sort_order, area) + values (coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6) on conflict (id) do nothing returning ${PROJECT_COLS}`, - [id, p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0]); + [id, p.name, p.status ?? "active", p.notes ?? "", p.sort_order ?? 0, p.area ?? "personal"]); if (rows.length) return json(rows[0], 201); const existing = await sql.unsafe(`select ${PROJECT_COLS} from projects where id = $1`, [id]); return json(existing[0], 200); @@ -150,13 +157,14 @@ async function handle(req: Request): Promise { 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); + if (patch.area && !AREAS.includes(patch.area as string)) return err("bad area", 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() + `update projects set name=$2, status=$3, notes=$4, sort_order=$5, area=$6, updated_at=now() where id=$1 returning ${PROJECT_COLS}`, - [id, cur.name, cur.status, cur.notes, cur.sort_order]); + [id, cur.name, cur.status, cur.notes, cur.sort_order, cur.area]); return json(rows[0]); } diff --git a/server/schema.sql b/server/schema.sql index c5831e0..7f6d47d 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -38,3 +38,10 @@ create index if not exists tasks_project_idx on tasks (project_id); -- Columns added after the initial deploy (create-if-not-exists won't alter). alter table tasks add column if not exists repeat text check (repeat in ('daily', 'weekdays', 'weekly', 'monthly', 'yearly')); + +-- Personal vs work: the app's top-level toggle filters every list by area. +alter table tasks add column if not exists area text not null default 'personal' + check (area in ('personal', 'work')); +alter table projects add column if not exists area text not null default 'personal' + check (area in ('personal', 'work')); +create index if not exists tasks_area_idx on tasks (area);