From e7c092f35db229586200358cd85b481b9f53e53a Mon Sep 17 00:00:00 2001 From: Marcus Rehbock Date: Fri, 7 Aug 2026 20:45:15 -0700 Subject: [PATCH] Interactive date picker: quick chips + inline month calendar (due & defer) Co-Authored-By: Claude Fable 5 --- app/src/app/add.tsx | 30 +--- app/src/app/task/[id].tsx | 17 +-- app/src/components/DateField.tsx | 228 +++++++++++++++++++++++++++++++ 3 files changed, 233 insertions(+), 42 deletions(-) create mode 100644 app/src/components/DateField.tsx diff --git a/app/src/app/add.tsx b/app/src/app/add.tsx index 73506e0..f3ca933 100644 --- a/app/src/app/add.tsx +++ b/app/src/app/add.tsx @@ -1,6 +1,7 @@ import { useRouter } from "expo-router"; import { useState } from "react"; import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native"; +import DateField from "../components/DateField"; import PriorityChips from "../components/PriorityChips"; import { Chips, Field } from "../components/ui"; import { haptic } from "../lib/haptics"; @@ -17,12 +18,6 @@ const BOXES: { key: TaskStatus; label: string }[] = [ const ESTIMATES = [15, 30, 45, 60, 90, 120]; -function isoToday(offset = 0): string { - const d = new Date(); - d.setDate(d.getDate() + offset); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; -} - export default function AddTask() { const router = useRouter(); const store = useStore(); @@ -69,28 +64,7 @@ export default function AddTask() { style={{ minHeight: 64, textAlignVertical: "top" }} /> - - Due date - - {[ - { label: "Today", v: isoToday() }, - { label: "Tomorrow", v: isoToday(1) }, - { label: "Next week", v: isoToday(7) }, - ].map(({ label, v }) => ( - { - haptic.select(); - setDue(due === v ? "" : v); - }} - > - {label} - - ))} - - - + setDue(v ?? "")} /> Estimated time diff --git a/app/src/app/task/[id].tsx b/app/src/app/task/[id].tsx index 95ff40a..8045d86 100644 --- a/app/src/app/task/[id].tsx +++ b/app/src/app/task/[id].tsx @@ -1,6 +1,7 @@ import { useLocalSearchParams, useRouter } from "expo-router"; import { useState } from "react"; import { ScrollView, Text } from "react-native"; +import DateField from "../../components/DateField"; import PriorityChips from "../../components/PriorityChips"; import { Button, Chips, Field } from "../../components/ui"; import { activeProjects, trashTask, updateTask } from "../../lib/store"; @@ -76,20 +77,8 @@ export default function TaskDetail() { placeholder="Who or what?" /> )} - set({ due_date: v || null })} - placeholder="YYYY-MM-DD" - autoCapitalize="none" - /> - set({ defer_date: v || null })} - placeholder="YYYY-MM-DD" - autoCapitalize="none" - /> + set({ due_date: v })} clearable /> + set({ defer_date: v })} clearable /> void }) { + const initial = value ? fromIso(value) : new Date(); + const [year, setYear] = useState(initial.getFullYear()); + const [month, setMonth] = useState(initial.getMonth()); + const todayIso = iso(new Date()); + + const weeks = useMemo(() => { + const first = new Date(year, month, 1); + const start = new Date(first); + start.setDate(1 - ((first.getDay() + 6) % 7)); // back to Monday + return Array.from({ length: 6 }, (_, w) => + Array.from({ length: 7 }, (_, i) => { + const d = new Date(start); + d.setDate(start.getDate() + w * 7 + i); + return d; + }), + ); + }, [year, month]); + + const shift = (delta: number) => { + haptic.select(); + const d = new Date(year, month + delta, 1); + setYear(d.getFullYear()); + setMonth(d.getMonth()); + }; + + return ( + + + shift(-1)} hitSlop={10} style={s.arrow}> + + + + {MONTHS[month]} {year} + + shift(1)} hitSlop={10} style={s.arrow}> + + + + + {WEEKDAYS.map((w, i) => ( + + {w} + + ))} + + {weeks.map((week, wi) => ( + + {week.map((d) => { + const dIso = iso(d); + const inMonth = d.getMonth() === month; + const selected = dIso === value; + const isToday = dIso === todayIso; + return ( + { + haptic.select(); + onPick(dIso); + }} + > + + {d.getDate()} + + + ); + })} + + ))} + + ); +} + +export default function DateField({ + label, + value, + onChange, + clearable, +}: { + label: string; + value: string | null; + onChange: (v: string | null) => void; + clearable?: boolean; +}) { + const [open, setOpen] = useState(false); + const today = new Date(); + const quick = [ + { label: "Today", v: iso(today) }, + { label: "Tomorrow", v: iso(new Date(today.getTime() + DAY_MS)) }, + { label: "Next week", v: iso(new Date(today.getTime() + 7 * DAY_MS)) }, + ]; + + return ( + + + {label} + {value != null && {pretty(value)}} + + + {quick.map((q) => ( + { + haptic.select(); + onChange(value === q.v && clearable ? null : q.v); + }} + > + {q.label} + + ))} + { + haptic.select(); + setOpen(!open); + }} + > + 📆 Pick… + + {clearable && value != null && ( + { + haptic.tap(); + onChange(null); + }} + > + Clear + + )} + + {open && ( + { + onChange(v); + setOpen(false); + }} + /> + )} + + ); +} + +const s = StyleSheet.create({ + labelRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "baseline", marginBottom: 6 }, + label: { color: C.muted, fontSize: 13, textTransform: "uppercase", letterSpacing: 0.5 }, + chosen: { color: C.accent, fontSize: 14, fontWeight: "600" }, + rowWrap: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + chip: { + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 20, + backgroundColor: C.surface, + borderWidth: 1, + borderColor: C.border, + }, + chipActive: { backgroundColor: C.surface2, borderColor: C.accent }, + chipText: { color: C.muted, fontSize: 14 }, + calendar: { + marginTop: 10, + backgroundColor: C.surface, + borderRadius: 14, + borderWidth: 1, + borderColor: C.border, + padding: 10, + }, + calHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center", marginBottom: 6 }, + calTitle: { color: C.text, fontSize: 15, fontWeight: "600" }, + arrow: { paddingHorizontal: 14, paddingVertical: 4 }, + arrowText: { color: C.accent, fontSize: 22, fontWeight: "600" }, + week: { flexDirection: "row" }, + weekday: { flex: 1, textAlign: "center", color: C.muted, fontSize: 11, paddingVertical: 4 }, + day: { + flex: 1, + aspectRatio: 1.15, + alignItems: "center", + justifyContent: "center", + borderRadius: 999, + margin: 1, + }, + daySelected: { backgroundColor: C.accent }, + dayToday: { borderWidth: 1, borderColor: C.accent }, + dayText: { color: C.text, fontSize: 14 }, +});