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:
@@ -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)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user