From d951971d5951f3002339cafd996363b856bcfe9b Mon Sep 17 00:00:00 2001 From: Marcusrehbock Date: Tue, 1 Sep 2026 22:55:48 +1000 Subject: [PATCH] refactor(task): one TaskForm behind both capture and clarify --- app/src/app/add.tsx | 285 ++++--------------------------- app/src/app/task/[id].tsx | 161 +++++------------- app/src/components/TaskForm.tsx | 287 ++++++++++++++++++++++++++++++++ app/src/components/ui.tsx | 6 +- 4 files changed, 365 insertions(+), 374 deletions(-) create mode 100644 app/src/components/TaskForm.tsx diff --git a/app/src/app/add.tsx b/app/src/app/add.tsx index 8a29e41..18a8239 100644 --- a/app/src/app/add.tsx +++ b/app/src/app/add.tsx @@ -1,28 +1,12 @@ import { useRouter } from "expo-router"; import { useRef, useState } from "react"; -import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from "react-native"; -import AttrBar, { type Attr } from "../components/AttrBar"; -import DateField from "../components/DateField"; -import EstimateChips from "../components/EstimateChips"; -import PriorityChips from "../components/PriorityChips"; -import { Chips, Separator } from "../components/ui"; -import { friendlyDate } from "../lib/dates"; +import { Pressable, ScrollView, StyleSheet, Text } from "react-native"; +import TaskForm, { BOX_LABEL, emptyDraft, type TaskDraft } from "../components/TaskForm"; import { haptic } from "../lib/haptics"; -import { formatEstimate, PRIORITY_COLOR, PRIORITY_LABEL } from "../lib/priority"; import { parseQuickAdd, type ParsedQuickAdd } from "../lib/quickadd"; -import { REPEAT_LABEL, REPEATS } from "../lib/repeat"; -import { activeProjects, createTask, knownContexts } from "../lib/store"; -import { C, radius, space, type } from "../lib/theme"; +import { activeProjects, createTask } from "../lib/store"; +import { C, radius, space } from "../lib/theme"; import { useStore } from "../lib/useStore"; -import type { Repeat, Task } from "../lib/types"; - -const BOX_LABEL = { - next: "Next up", - waiting: "Waiting", - someday: "Someday", -} as const; -type Box = keyof typeof BOX_LABEL; -const BOXES = Object.keys(BOX_LABEL) as Box[]; /** * Capture screen. @@ -35,183 +19,50 @@ const BOXES = Object.keys(BOX_LABEL) as Box[]; export default function AddTask() { const router = useRouter(); const store = useStore(); - const [raw, setRaw] = useState(""); - const [notes, setNotes] = useState(""); - const [priority, setPriority] = useState(null); - const [estimate, setEstimate] = useState(null); - const [due, setDue] = useState(null); - const [repeat, setRepeat] = useState(null); - const [box, setBox] = useState("next"); - const [waitingFor, setWaitingFor] = useState(""); - const [projectId, setProjectId] = useState(null); - const [context, setContext] = useState(null); + const [draft, setDraft] = useState(emptyDraft); const projects = activeProjects(store).filter((p) => p.status === "active"); - const contexts = knownContexts(store); // The title field understands tokens ("Pay rent tmr p1 @home ~30m monthly") - // and mirrors each one into the chips above as you type, so capture is one + // and mirrors each one into the chips below as you type, so capture is one // line but every inference stays visible and correctable. A token only // writes its field when it changes — chips tapped by hand stick. const prevParsed = useRef({ title: "" }); - const onTitleChange = (text: string) => { - setRaw(text); - const p = parseQuickAdd(text, projects); + const change = (patch: Partial) => { + if (patch.title === undefined) return setDraft((d) => ({ ...d, ...patch })); + + const p = parseQuickAdd(patch.title, projects); const prev = prevParsed.current; - if (p.priority !== prev.priority && p.priority) setPriority(p.priority); - if (p.due_date !== prev.due_date && p.due_date) setDue(p.due_date); - if (p.estimate_min !== prev.estimate_min && p.estimate_min) setEstimate(p.estimate_min); - if (p.repeat !== prev.repeat && p.repeat) setRepeat(p.repeat); - if (p.project_id !== prev.project_id && p.project_id) setProjectId(p.project_id); - if (p.context !== prev.context) setContext(p.context ?? null); + const mirrored: Partial = {}; + if (p.priority !== prev.priority && p.priority) mirrored.priority = p.priority; + if (p.due_date !== prev.due_date && p.due_date) mirrored.due_date = p.due_date; + if (p.estimate_min !== prev.estimate_min && p.estimate_min) mirrored.estimate_min = p.estimate_min; + if (p.repeat !== prev.repeat && p.repeat) mirrored.repeat = p.repeat; + if (p.project_id !== prev.project_id && p.project_id) mirrored.project_id = p.project_id; + if (p.context !== prev.context) mirrored.context = p.context ?? null; prevParsed.current = p; + setDraft((d) => ({ ...d, ...patch, ...mirrored })); }; - const cleanTitle = parseQuickAdd(raw, projects).title.trim() || raw.trim(); - const tokensUsed = raw.trim() !== cleanTitle; + // The field keeps the tokens visible; only the saved task loses them. + const cleanTitle = parseQuickAdd(draft.title, projects).title.trim() || draft.title.trim(); + const tokensUsed = draft.title.trim() !== cleanTitle; const ready = cleanTitle.length > 0; const save = () => { if (!ready) return; createTask({ + ...draft, title: cleanTitle, - notes, - status: box, // Unset priority means "ordinary", not "urgent" — pick the middle rung // rather than blocking capture on a decision that can wait for review. - priority: priority ?? 3, - estimate_min: estimate, - due_date: due, - repeat, - project_id: projectId, - context, - waiting_for: box === "waiting" ? waitingFor.trim() || null : null, + priority: draft.priority ?? 3, + waiting_for: draft.status === "waiting" ? draft.waiting_for?.trim() || null : null, }); haptic.success(); router.back(); }; - const projectName = projects.find((p) => p.id === projectId)?.name ?? null; - - const attrs: Attr[] = [ - { - key: "when", - icon: "today", - placeholder: "When", - value: due ? friendlyDate(due) : null, - panel: , - onClear: () => setDue(null), - }, - { - key: "priority", - icon: "flag", - placeholder: "Priority", - value: priority ? PRIORITY_LABEL[priority] : null, - tone: priority ? PRIORITY_COLOR[priority] : undefined, - panel: , - onClear: () => setPriority(null), - }, - { - key: "box", - icon: "next", - // "Next up" is the default, so it reads as a placeholder until changed — - // only a deliberate choice earns the accent. - placeholder: BOX_LABEL.next, - value: box === "next" ? null : BOX_LABEL[box], - panel: ( - - { - haptic.select(); - setBox(b); - }} - /> - {box === "waiting" && ( - - )} - - ), - }, - { - key: "project", - icon: "projects", - placeholder: "Project", - value: projectName, - panel: - projects.length === 0 ? ( - No active projects yet. - ) : ( - p.id)} - labels={Object.fromEntries(projects.map((p) => [p.id, p.name]))} - value={projectId} - onChange={(v) => { - haptic.select(); - setProjectId(v === projectId ? null : v); - }} - /> - ), - onClear: () => setProjectId(null), - }, - { - key: "estimate", - icon: "waiting", - placeholder: "Estimate", - value: formatEstimate(estimate), - panel: , - onClear: () => setEstimate(null), - }, - { - key: "repeat", - icon: "repeat", - placeholder: "Repeat", - value: repeat ? REPEAT_LABEL[repeat] : null, - panel: ( - { - haptic.select(); - setRepeat(v === repeat ? null : v); - }} - /> - ), - onClear: () => setRepeat(null), - }, - ]; - - // Only offer the tag picker once there's something to pick — an empty chip - // that opens an empty panel is worse than no chip. - if (contexts.length > 0 || context) { - attrs.push({ - key: "context", - icon: "tag", - placeholder: "Tag", - value: context, - panel: ( - { - haptic.select(); - setContext(v === context ? null : v); - }} - /> - ), - onClear: () => setContext(null), - }); - } - return ( - - - - - - - {tokensUsed ? ( - Saving as “{cleanTitle || "…"}” — tokens moved into the chips below. - ) : ( - Type p1 · tmr · @home · ~30m · #project to fill these in as you go. - )} - - + [s.save, !ready && s.saveDisabled, pressed && ready && { opacity: 0.85 }]} onPress={save} disabled={!ready} > - - Add to {BOX_LABEL[box]} - + Add to {BOX_LABEL[draft.status]} ); @@ -264,50 +95,6 @@ export default function AddTask() { const s = StyleSheet.create({ content: { padding: space.lg, paddingBottom: space.xxl }, - - card: { - backgroundColor: C.surface, - borderRadius: radius.lg, - borderWidth: StyleSheet.hairlineWidth, - borderColor: C.border, - overflow: "hidden", - }, - titleInput: { - color: C.text, - fontSize: 20, - fontWeight: "600", - letterSpacing: -0.3, - paddingHorizontal: space.lg, - paddingVertical: space.lg - 2, - }, - notesInput: { - color: C.text, - fontSize: 15, - minHeight: 76, - textAlignVertical: "top", - paddingHorizontal: space.lg, - paddingVertical: space.md, - }, - - hint: { - ...type.meta, - color: C.muted, - paddingHorizontal: space.xs, - paddingTop: space.md, - paddingBottom: space.md, - }, - - panelInput: { - backgroundColor: C.surface2, - color: C.text, - borderRadius: radius.md, - paddingHorizontal: space.md, - paddingVertical: space.md - 2, - fontSize: 15, - marginTop: space.md, - }, - panelEmpty: { ...type.meta, color: C.muted, paddingBottom: space.sm }, - save: { backgroundColor: C.accent, borderRadius: radius.md, diff --git a/app/src/app/task/[id].tsx b/app/src/app/task/[id].tsx index c3d60f2..66f672e 100644 --- a/app/src/app/task/[id].tsx +++ b/app/src/app/task/[id].tsx @@ -1,18 +1,30 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { useState } from "react"; -import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; -import DateField from "../../components/DateField"; -import EstimateChips from "../../components/EstimateChips"; -import PriorityChips from "../../components/PriorityChips"; -import { Button, Chips, Empty, Field } from "../../components/ui"; +import { ScrollView, StyleSheet, Text } from "react-native"; +import TaskForm, { CAPTURE_BOXES, type TaskDraft } from "../../components/TaskForm"; +import { Button, Empty } from "../../components/ui"; import { haptic } from "../../lib/haptics"; -import { REPEAT_LABEL, REPEATS } from "../../lib/repeat"; -import { activeProjects, knownContexts, trashTask, updateTask } from "../../lib/store"; -import { C, radius, space, type } from "../../lib/theme"; +import { trashTask, updateTask } from "../../lib/store"; +import { C, space, type } from "../../lib/theme"; import { useStore } from "../../lib/useStore"; -import type { Repeat, Task, TaskStatus } from "../../lib/types"; +import type { Task, TaskStatus } from "../../lib/types"; -const STATUSES: TaskStatus[] = ["next", "waiting", "someday", "done"]; +// Clarify can also finish a task outright; capture never needs to. +const BOXES: TaskStatus[] = [...CAPTURE_BOXES, "done"]; + +const toDraft = (t: Task): TaskDraft => ({ + title: t.title, + notes: t.notes, + status: t.status, + project_id: t.project_id, + context: t.context, + waiting_for: t.waiting_for, + due_date: t.due_date, + defer_date: t.defer_date, + estimate_min: t.estimate_min, + repeat: t.repeat, + priority: t.priority, +}); export default function TaskDetail() { const { id } = useLocalSearchParams<{ id: string }>(); @@ -20,44 +32,26 @@ export default function TaskDetail() { const store = useStore(); const original = store.tasks.find((t) => t.id === id); // Draft is local to the screen; nothing is written until Save. - const [draft, setDraft] = useState(original ?? null); + const [draft, setDraft] = useState(original ? toDraft(original) : null); - if (!draft) return Task not found.; - - const set = (patch: Partial) => setDraft({ ...draft, ...patch }); + if (!original || !draft) return Task not found.; const save = () => { - updateTask(draft.id, { - title: draft.title, - notes: draft.notes, - status: draft.status, - project_id: draft.project_id, - context: draft.context, - waiting_for: draft.waiting_for, - due_date: draft.due_date, - defer_date: draft.defer_date, - priority: draft.priority, - estimate_min: draft.estimate_min, - repeat: draft.repeat, - }); + updateTask(original.id, { ...draft, priority: draft.priority ?? 3 }); + haptic.success(); router.back(); }; - const projects = activeProjects(store).filter((p) => p.status === "active" || p.id === draft.project_id); - const projectOptions = ["none", ...projects.map((p) => p.id)]; - const projectLabels = Object.fromEntries([["none", "None"], ...projects.map((p) => [p.id, p.name])]); - const contexts = knownContexts(store); - - if (draft.status === "trashed") { + if (original.status === "trashed") { return ( - {draft.title} + {original.title} In the trash.