feat(todo): auto-schedule next occurrence on recurring task completion
When a task with rec:Nd/w/m/y is checked off, a new open task is appended with the updated due date. Non-strict (rec:1m) bases the next due on today; strict (rec:+1m) bases it on the existing due date.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user