feat: personal/work area toggle
Some checks failed
Build & Release APK / check (push) Has been cancelled
Build & Release APK / build (push) Has been cancelled

Global lens in the home header: every list, Today, Projects and Review
show only the active area; new rows are born into it. Tasks and projects
gain an area column (personal default); the task form's Area chip
re-files across areas.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW5iVXkZ2JBHgyweMCRymY
This commit is contained in:
marcuspaico
2026-09-08 17:05:50 +10:00
parent 26922af997
commit abc380bb84
13 changed files with 295 additions and 30 deletions

View File

@@ -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.

View File

@@ -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: () => <AreaToggle />,
headerTitleAlign: "center",
headerRight: () => (
<Link href="/settings" asChild>
<Pressable

View File

@@ -30,7 +30,7 @@ export default function ProjectDetail() {
if (!title) return;
setDraft("");
haptic.success();
createTask({ title, status: "next", project_id: id });
createTask({ title, status: "next", project_id: id, area: project.area ?? "personal" });
};
return (

View File

@@ -25,6 +25,7 @@ const toDraft = (t: Task): TaskDraft => ({
estimate_min: t.estimate_min,
repeat: t.repeat,
priority: t.priority,
area: t.area ?? "personal",
});
export default function TaskDetail() {

View File

@@ -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<Area, string> = { personal: C.accent, work: C.work };
export default function AreaToggle() {
const { area } = useStore();
return (
<View style={s.track} accessibilityRole="tablist">
{AREAS.map((a) => {
const active = a === area;
return (
<Pressable
key={a}
accessibilityRole="tab"
accessibilityState={{ selected: active }}
hitSlop={6}
onPress={() => {
if (active) return;
haptic.select();
setArea(a);
}}
style={({ pressed }) => [
s.segment,
active && { backgroundColor: AREA_TONE[a] },
pressed && !active && s.segmentPressed,
]}
>
<Text style={[s.label, active && s.labelActive]}>{AREA_LABEL[a]}</Text>
</Pressable>
);
})}
</View>
);
}
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" },
});

View File

@@ -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.
</View>
);
case "area":
// Briefcase: a body with a short handle on top.
return (
<View style={[box, s.center]}>
<View
style={{
position: "absolute",
top: size * 0.12,
width: size * 0.36,
height: size * 0.26,
borderWidth: w,
borderBottomWidth: 0,
borderColor: color,
borderTopLeftRadius: 3,
borderTopRightRadius: 3,
}}
/>
<View
style={{
position: "absolute",
top: size * 0.32,
width: size * 0.84,
height: size * 0.56,
borderWidth: w,
borderColor: color,
borderRadius: 4,
}}
/>
<View style={{ position: "absolute", top: size * 0.32 + size * 0.2, width: size * 0.84, height: w, backgroundColor: color }} />
</View>
);
case "close":
return (
<View style={[box, s.center]}>

View File

@@ -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<string, string> = {
@@ -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: (
<View>
<Chips
options={AREAS}
labels={AREA_LABEL}
value={d.area}
onChange={(area) => {
if (area === d.area) return;
haptic.select();
// A project belongs to one area; drop it when the task moves.
onChange({ area, project_id: null });
}}
/>
<Text style={s.panelEmpty}>Only shows up when that lens is on.</Text>
</View>
),
});
if (showDefer) {
attrs.push({
key: "defer",

View File

@@ -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<typeof store.getState>) => 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");
});
});

View File

@@ -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<T extends { area?: Area }>(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<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> {
const out: Partial<T> = {};
@@ -122,6 +156,7 @@ export function createTask(data: Partial<Task> & { 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<Project> & { 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<string>();
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"]);

View File

@@ -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",

View File

@@ -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<Area, string> = { 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;

View File

@@ -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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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<Response> {
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]);
}

View File

@@ -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);