Repeats, estimates, weekly review: completing a repeating task spawns the next occurrence; estimate chips + quick-add tokens (~30m, monthly); review screen with one-tap triage; server gains repeat column and applies its idempotent schema at boot
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
51
app/src/lib/dates.ts
Normal file
51
app/src/lib/dates.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Date helpers for human-friendly display. All task dates are local ISO
|
||||
// "YYYY-MM-DD" strings; comparisons are lexicographic.
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
export function todayIso(): string {
|
||||
const d = new Date();
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function fromIso(s: string): Date {
|
||||
const [y, m, d] = s.split("-").map(Number);
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
export function toIso(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Deferred = hidden from action lists until the date arrives. */
|
||||
export function isDeferred(deferDate: string | null, today = todayIso()): boolean {
|
||||
return !!deferDate && deferDate > today;
|
||||
}
|
||||
|
||||
export function daysFromToday(dateIso: string): number {
|
||||
return Math.round((fromIso(dateIso).getTime() - fromIso(todayIso()).getTime()) / DAY_MS);
|
||||
}
|
||||
|
||||
/** "Today" / "Tomorrow" / "Mon 18 Aug" — no ISO date math for the reader. */
|
||||
export function friendlyDate(dateIso: string): string {
|
||||
const diff = daysFromToday(dateIso);
|
||||
if (diff === 0) return "Today";
|
||||
if (diff === 1) return "Tomorrow";
|
||||
if (diff === -1) return "Yesterday";
|
||||
const d = fromIso(dateIso);
|
||||
const wd = d.toLocaleDateString("en-AU", { weekday: "short" });
|
||||
const mo = d.toLocaleDateString("en-AU", { month: "short" });
|
||||
const yr = d.getFullYear() !== new Date().getFullYear() ? ` ${d.getFullYear()}` : "";
|
||||
return `${wd} ${d.getDate()} ${mo}${yr}`;
|
||||
}
|
||||
|
||||
export type DueTone = "overdue" | "today" | "later";
|
||||
|
||||
/** Row label for a due date, with a tone the UI can color. */
|
||||
export function dueMeta(dueIso: string): { label: string; tone: DueTone } {
|
||||
const diff = daysFromToday(dueIso);
|
||||
if (diff < 0) return { label: diff === -1 ? "1d overdue" : `${-diff}d overdue`, tone: "overdue" };
|
||||
if (diff === 0) return { label: "due today", tone: "today" };
|
||||
if (diff === 1) return { label: "due tomorrow", tone: "later" };
|
||||
return { label: `due ${friendlyDate(dueIso)}`, tone: "later" };
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
// Natural-language quick add, Todoist style:
|
||||
// "Pay rent tomorrow p1 @home #Sydney ~30m"
|
||||
// "Pay rent tomorrow p1 @home #Sydney ~30m monthly"
|
||||
// → title "Pay rent", due tomorrow, priority 1, context @home,
|
||||
// project "Sydney" (matched by name prefix), estimate 30 min.
|
||||
import type { Project, Task } from "./types";
|
||||
// project "Sydney" (matched by name prefix), estimate 30 min, repeats monthly.
|
||||
//
|
||||
// The add form parses the title field live and mirrors every recognized token
|
||||
// into a visible form control, so a false positive (e.g. "fri" in a name) is
|
||||
// seen and fixable, never silent.
|
||||
import type { Project, Repeat, Task } from "./types";
|
||||
|
||||
export interface ParsedQuickAdd {
|
||||
title: string;
|
||||
@@ -11,9 +15,17 @@ export interface ParsedQuickAdd {
|
||||
context?: string;
|
||||
project_id?: string;
|
||||
estimate_min?: number;
|
||||
repeat?: Repeat;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"];
|
||||
const REPEAT_WORDS: Record<string, Repeat> = {
|
||||
daily: "daily",
|
||||
weekdays: "weekdays",
|
||||
weekly: "weekly",
|
||||
monthly: "monthly",
|
||||
yearly: "yearly",
|
||||
};
|
||||
|
||||
function iso(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
@@ -26,7 +38,8 @@ function parseDateWord(word: string): string | undefined {
|
||||
const w = word.toLowerCase();
|
||||
const today = new Date();
|
||||
if (w === "today" || w === "tod") return iso(today);
|
||||
if (w === "tomorrow" || w === "tmr" || w === "tom") {
|
||||
// No "tom" alias — it's a name far more often than a date.
|
||||
if (w === "tomorrow" || w === "tmr") {
|
||||
const d = new Date(today);
|
||||
d.setDate(d.getDate() + 1);
|
||||
return iso(d);
|
||||
@@ -77,6 +90,11 @@ export function parseQuickAdd(input: string, projects: Project[]): ParsedQuickAd
|
||||
out.estimate_min = est;
|
||||
continue;
|
||||
}
|
||||
const rep = REPEAT_WORDS[token.toLowerCase()];
|
||||
if (rep && out.repeat === undefined) {
|
||||
out.repeat = rep;
|
||||
continue;
|
||||
}
|
||||
if (out.due_date === undefined) {
|
||||
const due = parseDateWord(token);
|
||||
if (due) {
|
||||
|
||||
65
app/src/lib/repeat.ts
Normal file
65
app/src/lib/repeat.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Recurrence rules. Completing a repeating task spawns the next occurrence
|
||||
// (see store.updateTask) — the done row keeps its history, the clone carries
|
||||
// the rule forward.
|
||||
import { fromIso, toIso } from "./dates";
|
||||
import type { Repeat } from "./types";
|
||||
|
||||
export const REPEATS: Repeat[] = ["daily", "weekdays", "weekly", "monthly", "yearly"];
|
||||
|
||||
export const REPEAT_LABEL: Record<Repeat, string> = {
|
||||
daily: "Daily",
|
||||
weekdays: "Weekdays",
|
||||
weekly: "Weekly",
|
||||
monthly: "Monthly",
|
||||
yearly: "Yearly",
|
||||
};
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const r = new Date(d);
|
||||
r.setDate(r.getDate() + n);
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Same day-of-month n months later, clamped to the month's last day. */
|
||||
function addMonthsClamped(d: Date, n: number): Date {
|
||||
const r = new Date(d.getFullYear(), d.getMonth() + n, 1);
|
||||
const last = new Date(r.getFullYear(), r.getMonth() + 1, 0).getDate();
|
||||
r.setDate(Math.min(d.getDate(), last));
|
||||
return r;
|
||||
}
|
||||
|
||||
function step(d: Date, repeat: Repeat): Date {
|
||||
switch (repeat) {
|
||||
case "daily":
|
||||
return addDays(d, 1);
|
||||
case "weekdays": {
|
||||
let n = addDays(d, 1);
|
||||
while (n.getDay() === 0 || n.getDay() === 6) n = addDays(n, 1);
|
||||
return n;
|
||||
}
|
||||
case "weekly":
|
||||
return addDays(d, 7);
|
||||
case "monthly":
|
||||
return addMonthsClamped(d, 1);
|
||||
case "yearly":
|
||||
return addMonthsClamped(d, 12);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Next occurrence strictly after the base date, catching up to today for
|
||||
* lapsed tasks (a monthly bill 3 months overdue advances to the next
|
||||
* anchor-day on or after today, not three stale dates in the past).
|
||||
*/
|
||||
export function nextOccurrence(baseIso: string | null, repeat: Repeat, todayIsoStr: string): string {
|
||||
let d = fromIso(baseIso ?? todayIsoStr);
|
||||
let guard = 0;
|
||||
do {
|
||||
d = step(d, repeat);
|
||||
} while (toIso(d) < todayIsoStr && ++guard < 5000);
|
||||
return toIso(d);
|
||||
}
|
||||
|
||||
export function shiftDays(dateIso: string, n: number): string {
|
||||
return toIso(addDays(fromIso(dateIso), n));
|
||||
}
|
||||
29
app/src/lib/review.ts
Normal file
29
app/src/lib/review.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Last-completed-review timestamp, local only — the review is a habit, not
|
||||
// synced data.
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const KEY = "review_last_v1";
|
||||
|
||||
export async function getLastReview(): Promise<string | null> {
|
||||
return AsyncStorage.getItem(KEY);
|
||||
}
|
||||
|
||||
export async function markReviewed(): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY, new Date().toISOString());
|
||||
}
|
||||
|
||||
export function daysSince(iso: string | null): number | null {
|
||||
if (!iso) return null;
|
||||
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
/** [lastReviewIso, reload] — call reload on focus to pick up changes. */
|
||||
export function useLastReview(): [string | null, () => void] {
|
||||
const [last, setLast] = useState<string | null>(null);
|
||||
const reload = useCallback(() => {
|
||||
void getLastReview().then(setLast);
|
||||
}, []);
|
||||
useEffect(reload, [reload]);
|
||||
return [last, reload];
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import * as Crypto from "expo-crypto";
|
||||
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";
|
||||
|
||||
export interface State {
|
||||
@@ -81,7 +83,7 @@ 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", "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"] as const;
|
||||
const PROJECT_FIELDS = ["name", "status", "notes", "sort_order"] as const;
|
||||
|
||||
function pickFields<T>(data: Partial<T>, fields: readonly (keyof T & string)[]): Partial<T> {
|
||||
@@ -104,11 +106,13 @@ function enqueue(op: Op) {
|
||||
}
|
||||
|
||||
export function createTask(data: Partial<Task> & { title: string }): Task {
|
||||
// "next" — this app has no inbox; a task without an explicit box is actionable.
|
||||
const status = data.status ?? "next";
|
||||
const task: Task = {
|
||||
id: Crypto.randomUUID(),
|
||||
title: data.title,
|
||||
notes: data.notes ?? "",
|
||||
status: data.status ?? "inbox",
|
||||
status,
|
||||
project_id: data.project_id ?? null,
|
||||
context: data.context ?? null,
|
||||
waiting_for: data.waiting_for ?? null,
|
||||
@@ -116,11 +120,12 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
|
||||
defer_date: data.defer_date ?? null,
|
||||
priority: data.priority ?? 4,
|
||||
estimate_min: data.estimate_min ?? null,
|
||||
repeat: data.repeat ?? null,
|
||||
completed_at: null,
|
||||
// New tasks land at the top of their list.
|
||||
sort_order:
|
||||
data.sort_order ??
|
||||
Math.min(0, ...state.tasks.filter((t) => t.status === (data.status ?? "next")).map((t) => t.sort_order)) - 1,
|
||||
Math.min(0, ...state.tasks.filter((t) => t.status === status).map((t) => t.sort_order)) - 1,
|
||||
created_at: now(),
|
||||
updated_at: now(),
|
||||
};
|
||||
@@ -131,6 +136,7 @@ export function createTask(data: Partial<Task> & { title: string }): Task {
|
||||
}
|
||||
|
||||
export function updateTask(id: string, patch: Partial<Task>) {
|
||||
const before = state.tasks.find((t) => t.id === id);
|
||||
setState({
|
||||
tasks: state.tasks.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
@@ -142,6 +148,39 @@ export function updateTask(id: string, patch: Partial<Task>) {
|
||||
});
|
||||
persistData();
|
||||
enqueue({ kind: "task.update", id, data: pickFields(patch, TASK_FIELDS) });
|
||||
// Completing a repeating task spawns the next occurrence. Un-checking the
|
||||
// done row later leaves the spawn in place — it's one tap to trash, versus
|
||||
// silently deleting a row the user may have edited.
|
||||
if (before && before.repeat && before.status !== "done" && patch.status === "done") {
|
||||
spawnNextOccurrence(before);
|
||||
}
|
||||
}
|
||||
|
||||
function spawnNextOccurrence(t: Task) {
|
||||
const today = todayIso();
|
||||
const due = t.due_date ? nextOccurrence(t.due_date, t.repeat!, today) : null;
|
||||
// Keep the same lead time between defer and due (e.g. "surfaces 3 days before").
|
||||
const defer =
|
||||
due && t.due_date && t.defer_date
|
||||
? shiftDays(t.defer_date, daysFromToday(due) - daysFromToday(t.due_date))
|
||||
: t.due_date
|
||||
? null
|
||||
: t.defer_date
|
||||
? nextOccurrence(t.defer_date, t.repeat!, today)
|
||||
: null;
|
||||
createTask({
|
||||
title: t.title,
|
||||
notes: t.notes,
|
||||
status: t.status,
|
||||
project_id: t.project_id,
|
||||
context: t.context,
|
||||
waiting_for: t.waiting_for,
|
||||
due_date: due,
|
||||
defer_date: defer,
|
||||
priority: t.priority,
|
||||
estimate_min: t.estimate_min,
|
||||
repeat: t.repeat,
|
||||
});
|
||||
}
|
||||
|
||||
export function trashTask(id: string) {
|
||||
@@ -229,7 +268,14 @@ async function push(op: Op) {
|
||||
}
|
||||
|
||||
async function pull() {
|
||||
const [tasks, projects] = await Promise.all([api.tasks.list(), api.projects.list()]);
|
||||
// The default list excludes trashed; fetch trash separately so the Trash
|
||||
// screen (and restore) survive a sync.
|
||||
const [open, trashed, projects] = await Promise.all([
|
||||
api.tasks.list(),
|
||||
api.tasks.list({ status: "trashed" }),
|
||||
api.projects.list(),
|
||||
]);
|
||||
const tasks = [...open, ...trashed];
|
||||
// Rows with ops still queued keep their local version; the next sync pass
|
||||
// pushes those ops and then this merge converges to the server state.
|
||||
const dirty = new Set(queue.map((o) => o.id));
|
||||
@@ -268,6 +314,15 @@ export function tasksByStatus(s: State, status: Task["status"]): Task[] {
|
||||
return s.tasks.filter((t) => t.status === status).sort(byManualOrder);
|
||||
}
|
||||
|
||||
/**
|
||||
* The action lists hide deferred tasks until their date arrives — that's the
|
||||
* whole point of a tickler. Review and the "show deferred" toggle see them.
|
||||
*/
|
||||
export function visibleTasksByStatus(s: State, status: Task["status"]): Task[] {
|
||||
const today = todayIso();
|
||||
return tasksByStatus(s, status).filter((t) => !isDeferred(t.defer_date, today));
|
||||
}
|
||||
|
||||
/** Re-slot a task at `index` within its status list (fractional sort_order). */
|
||||
export function reorderTask(id: string, status: Task["status"], index: number) {
|
||||
const list = tasksByStatus(getState(), status).filter((t) => t.id !== id);
|
||||
@@ -303,3 +358,52 @@ export function activeProjects(s: State): Project[] {
|
||||
export function openTaskCount(s: State, projectId: string): number {
|
||||
return s.tasks.filter((t) => t.project_id === projectId && t.status !== "done" && t.status !== "trashed").length;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (t.context && t.status !== "done" && t.status !== "trashed") set.add(t.context);
|
||||
}
|
||||
return [...set].sort();
|
||||
}
|
||||
|
||||
// ---- weekly review ----
|
||||
|
||||
const STALE_DAYS = 14;
|
||||
const WAITING_DAYS = 7;
|
||||
|
||||
function olderThanDays(ts: string, days: number): boolean {
|
||||
return Date.now() - new Date(ts).getTime() > days * 86_400_000;
|
||||
}
|
||||
|
||||
export interface ReviewBuckets {
|
||||
overdue: Task[];
|
||||
staleNext: Task[];
|
||||
agingWaiting: Task[];
|
||||
someday: Task[];
|
||||
deferred: Task[];
|
||||
stuckProjects: Project[];
|
||||
}
|
||||
|
||||
export function reviewBuckets(s: State): ReviewBuckets {
|
||||
const today = todayIso();
|
||||
const open = 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"]);
|
||||
return {
|
||||
overdue,
|
||||
staleNext: open
|
||||
.filter((t) => t.status === "next" && !overdueIds.has(t.id) && olderThanDays(t.updated_at, STALE_DAYS))
|
||||
.sort(byPriorityDue),
|
||||
agingWaiting: open
|
||||
.filter((t) => t.status === "waiting" && olderThanDays(t.updated_at, WAITING_DAYS))
|
||||
.sort((a, b) => a.updated_at.localeCompare(b.updated_at)),
|
||||
someday: tasksByStatus(s, "someday"),
|
||||
deferred: open.filter((t) => isDeferred(t.defer_date, today)).sort((a, b) => a.defer_date!.localeCompare(b.defer_date!)),
|
||||
stuckProjects: activeProjects(s).filter(
|
||||
(p) => p.status === "active" && !s.tasks.some((t) => t.project_id === p.id && actionable.has(t.status)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ export const C = {
|
||||
accent: "#5B8DEF",
|
||||
done: "#4CAF7D",
|
||||
danger: "#E05B5B",
|
||||
};
|
||||
warn: "#E0A93E",
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
|
||||
export interface Task {
|
||||
id: string;
|
||||
@@ -13,6 +14,7 @@ export interface Task {
|
||||
defer_date: string | null;
|
||||
priority: 1 | 2 | 3 | 4;
|
||||
estimate_min: number | null;
|
||||
repeat: Repeat | null;
|
||||
completed_at: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
|
||||
Reference in New Issue
Block a user