feat: repeat tasks after completion
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
import { buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
|
||||
|
||||
type SearchTask = {
|
||||
id: string
|
||||
@@ -73,6 +73,19 @@ describe('task utilities', () => {
|
||||
expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1')
|
||||
})
|
||||
|
||||
it('builds a distinct completion-trigger payload and parses it without RRULE', () => {
|
||||
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7 })
|
||||
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14 })
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '1.5' })).toThrow('请输入 1 到 3650 的整数天数')
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '0' })).toThrow('请输入 1 到 3650 的整数天数')
|
||||
expect(() => buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '3651' })).toThrow('请输入 1 到 3650 的整数天数')
|
||||
})
|
||||
|
||||
it('builds scheduled recurrence payloads separately from completion triggers', () => {
|
||||
expect(buildTaskRecurrencePayload('daily', { afterCompletionDays: '1' })).toEqual({ trigger_mode: 'scheduled', rrule: 'FREQ=DAILY' })
|
||||
expect(buildTaskRecurrencePayload('none', { afterCompletionDays: '1' })).toEqual({})
|
||||
})
|
||||
|
||||
it('renders safe basic markdown and strips unsafe html', () => {
|
||||
const html = renderMarkdown('# Plan\n**bold** [link](https://example.com)\n<script>alert(1)</script>')
|
||||
expect(html).toContain('<h1>Plan</h1>')
|
||||
|
||||
@@ -173,6 +173,33 @@ export function parseTaskRrule(rrule = ''): TaskRepeatConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export type TaskRepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'after_completion' | 'custom'
|
||||
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null }
|
||||
|
||||
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; repeatConfig?: TaskRepeatConfig }) {
|
||||
if (option === 'none') return {}
|
||||
if (option === 'after_completion') {
|
||||
const raw = String(values.afterCompletionDays).trim()
|
||||
const days = Number(raw)
|
||||
if (!/^\d+$/.test(raw) || !Number.isInteger(days) || days < 1 || days > 3650) throw new Error('请输入 1 到 3650 的整数天数')
|
||||
return { trigger_mode: 'after_completion' as const, after_completion_days: days }
|
||||
}
|
||||
const rrule = option === 'custom'
|
||||
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
|
||||
: `FREQ=${option.toUpperCase()}`
|
||||
return { trigger_mode: 'scheduled' as const, rrule }
|
||||
}
|
||||
|
||||
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
|
||||
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1 }
|
||||
if (recurrence.trigger_mode === 'after_completion') {
|
||||
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1 }
|
||||
}
|
||||
const parsed = parseTaskRrule(recurrence.rrule ?? '')
|
||||
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
|
||||
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1 }
|
||||
}
|
||||
|
||||
export function defaultTaskDueAt(now = new Date()) {
|
||||
const year = now.getFullYear()
|
||||
const month = `${now.getMonth() + 1}`.padStart(2, '0')
|
||||
|
||||
Reference in New Issue
Block a user