Compare commits

..

2 Commits

Author SHA1 Message Date
Julien Calixte
ad0a388f57 Merge branch 'main' of ssh://git.apoena.dev:22222/julien/remanso
All checks were successful
CI / verify (push) Successful in 2m3s
2026-06-21 10:29:23 +02:00
Julien Calixte
d76d63a198 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.
2026-06-21 10:26:37 +02:00
3 changed files with 152 additions and 1 deletions

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest" import { describe, expect, it } from "vitest"
import { import {
applyRecurrence,
contextsOf, contextsOf,
isBlank, isBlank,
parseFile, 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", () => { describe("todotxt toggleCompleted", () => {
it("marks a task complete with today's date", () => { it("marks a task complete with today's date", () => {
// Construct via local-time fields so the test doesn't depend on the // Construct via local-time fields so the test doesn't depend on the

View File

@@ -210,6 +210,74 @@ const todayIso = (now: Date): string => {
return `${y}-${m}-${d}` 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. // Toggle the completed state of a task.
// On complete: stamp today's completion date. Priority stays in place per spec. // 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 — // On uncomplete: drop completion date but leave the rest of the line as-is —

View File

@@ -9,6 +9,7 @@ import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8" import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
import { errorMessage } from "@/utils/notif" import { errorMessage } from "@/utils/notif"
import { import {
applyRecurrence,
contextsOf, contextsOf,
FileLine, FileLine,
isBlank, isBlank,
@@ -237,7 +238,15 @@ const filteredEntries = computed(() => {
}) })
const updateTask = (index: number, next: Task) => { 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) => { const deleteTask = (index: number) => {