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:
2026-08-18 00:06:39 -07:00
parent 6ccd417f2a
commit 4ef3f8c63c
22 changed files with 754 additions and 93 deletions

View File

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