Compare commits
2 Commits
7575e34f8b
...
ad0a388f57
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad0a388f57 | ||
|
|
d76d63a198 |
@@ -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
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user