diff --git a/src/utils/todotxt.spec.ts b/src/utils/todotxt.spec.ts index 4d98eb7..2586350 100644 --- a/src/utils/todotxt.spec.ts +++ b/src/utils/todotxt.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest" import { + applyRecurrence, contextsOf, isBlank, parseFile, @@ -216,6 +217,79 @@ Task two }) }) +describe("todotxt applyRecurrence", () => { + it("returns null when task has no rec tag", () => { + expect(applyRecurrence(parseLine("Buy milk"))).toBeNull() + }) + + it("returns null for an invalid rec value", () => { + expect(applyRecurrence(parseLine("Task rec:bad"))).toBeNull() + }) + + it("non-strict: bases next due on today, replacing existing due", () => { + const now = new Date(2026, 0, 15) // Jan 15 + const task = parseLine("Pay rent due:2025-12-15 rec:1m") + const next = applyRecurrence(task, now)! + expect(next.completed).toBe(false) + expect(next.body).toContain("due:2026-02-15") + expect(next.body).toContain("rec:1m") + expect(next.body).not.toContain("due:2025-12-15") + }) + + it("strict: bases next due on existing due date", () => { + const now = new Date(2026, 0, 15) // Jan 15 + const task = parseLine("Pay rent due:2025-12-15 rec:+1m") + const next = applyRecurrence(task, now)! + expect(next.body).toContain("due:2026-01-15") + expect(next.body).toContain("rec:+1m") + }) + + it("strict with no due: falls back to today", () => { + const now = new Date(2026, 0, 15) + const task = parseLine("Daily standup rec:+1d") + const next = applyRecurrence(task, now)! + expect(next.body).toContain("due:2026-01-16") + }) + + it("appends due tag when task has none", () => { + const now = new Date(2026, 0, 15) + const task = parseLine("Daily standup rec:1d") + const next = applyRecurrence(task, now)! + expect(next.body).toContain("due:2026-01-16") + }) + + it("preserves projects, contexts, and rec tag in new body", () => { + const now = new Date(2026, 0, 15) + const task = parseLine("Pay rent +finance @home due:2026-01-01 rec:1m") + const next = applyRecurrence(task, now)! + expect(next.body).toContain("+finance") + expect(next.body).toContain("@home") + expect(next.body).toContain("rec:1m") + }) + + it("preserves priority on the new task", () => { + const now = new Date(2026, 0, 15) + const task = parseLine("(A) Pay rent rec:1w") + const next = applyRecurrence(task, now)! + expect(next.priority).toBe("A") + expect(next.body).toContain("due:2026-01-22") + }) + + it("handles week interval", () => { + const now = new Date(2026, 0, 15) + expect(applyRecurrence(parseLine("Task rec:2w"), now)!.body).toContain( + "due:2026-01-29" + ) + }) + + it("handles year interval", () => { + const now = new Date(2026, 0, 15) + expect(applyRecurrence(parseLine("Task rec:1y"), now)!.body).toContain( + "due:2027-01-15" + ) + }) +}) + describe("todotxt toggleCompleted", () => { it("marks a task complete with today's date", () => { // Construct via local-time fields so the test doesn't depend on the diff --git a/src/utils/todotxt.ts b/src/utils/todotxt.ts index fe54e22..a5de7c1 100644 --- a/src/utils/todotxt.ts +++ b/src/utils/todotxt.ts @@ -210,6 +210,74 @@ const todayIso = (now: Date): string => { return `${y}-${m}-${d}` } +const parseDateStr = (s: string): Date | null => { + if (!DATE_RE.test(s)) return null + const [y, mo, d] = s.split("-").map(Number) + return new Date(y, mo - 1, d) +} + +const addInterval = (base: Date, n: number, unit: string): Date => { + const d = new Date(base) + switch (unit.toLowerCase()) { + case "d": + d.setDate(d.getDate() + n) + break + case "w": + d.setDate(d.getDate() + n * 7) + break + case "m": + d.setMonth(d.getMonth() + n) + break + case "y": + d.setFullYear(d.getFullYear() + n) + break + } + return d +} + +const REC_RE = /^\+?(\d+)([dwmyDWMY])$/ + +// When a recurring task is completed, build a new open task with the next due date. +// Strict mode (rec:+Nd): base date is the existing due: date (or today if absent). +// Non-strict (rec:Nd): base date is today (completion date). +// Returns null when the task has no valid rec: tag. +export const applyRecurrence = (task: Task, now = new Date()): Task | null => { + const tags = tagsOf(task) + const rec = tags["rec"] + if (!rec) return null + const m = rec.match(REC_RE) + if (!m) return null + + const strict = rec.startsWith("+") + const n = parseInt(m[1], 10) + const unit = m[2] + + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + let base = today + if (strict && tags["due"]) { + const parsed = parseDateStr(tags["due"]) + if (parsed) base = parsed + } + + const nextDate = addInterval(base, n, unit) + const nextDue = todayIso(nextDate) + + let newBody = task.body + if (tags["due"]) { + newBody = removeTagFromBody(newBody, "due", tags["due"]) + } + newBody = (newBody.trim() + ` due:${nextDue}`).trim() + + return { + raw: "", + completed: false, + priority: task.priority, + creationDate: todayIso(now), + completionDate: undefined, + body: newBody + } +} + // Toggle the completed state of a task. // On complete: stamp today's completion date. Priority stays in place per spec. // On uncomplete: drop completion date but leave the rest of the line as-is — diff --git a/src/views/TodoNotes.vue b/src/views/TodoNotes.vue index 9aca62d..a10c5c4 100644 --- a/src/views/TodoNotes.vue +++ b/src/views/TodoNotes.vue @@ -9,6 +9,7 @@ import { useUserRepoStore } from "@/modules/repo/store/userRepo.store" import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8" import { errorMessage } from "@/utils/notif" import { + applyRecurrence, contextsOf, FileLine, isBlank, @@ -204,7 +205,15 @@ const filteredEntries = computed(() => { }) const updateTask = (index: number, next: Task) => { - mutate((current) => current.map((line, i) => (i === index ? next : line))) + mutate((current) => { + const prev = current[index] + const updated = current.map((line, i) => (i === index ? next : line)) + if (!isBlank(prev) && !prev.completed && next.completed) { + const recurring = applyRecurrence(next) + if (recurring) return [...updated, recurring] + } + return updated + }) } const deleteTask = (index: number) => {