feat: support custom task recurrence
ci / docker (push) Successful in 5m54s

This commit is contained in:
2026-09-08 08:42:32 +08:00
parent 0a3b959cb8
commit 2eca978820
8 changed files with 110 additions and 15 deletions
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { filterTasks, groupTaskTree, moveItemWithinScope, renderMarkdown, toDateTimeLocal } from './task-utils'
import { buildTaskRrule, filterTasks, groupTaskTree, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
type SearchTask = {
id: string
@@ -38,6 +38,15 @@ describe('task utilities', () => {
expect(moveItemWithinScope(rows, 'a1', 'b', 'before')).toEqual(rows)
})
it('builds and parses custom repeat rules like TickTick', () => {
expect(buildTaskRrule({ frequency: 'weekly', interval: 2, weekdays: ['MO', 'WE', 'FR'], endMode: 'never' })).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR')
expect(buildTaskRrule({ frequency: 'monthly', interval: 1, monthDays: [1, 15, 31], endMode: 'count', count: 10 })).toBe('FREQ=MONTHLY;BYMONTHDAY=1,15,31;COUNT=10')
expect(buildTaskRrule({ frequency: 'yearly', interval: 1, weekdays: [], monthDays: [], endMode: 'date', count: 10, until: '2027-12-31' })).toBe('FREQ=YEARLY;UNTIL=2027-12-31T23:59:59')
expect(parseTaskRrule('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE;COUNT=8')).toEqual({ frequency: 'weekly', interval: 2, weekdays: ['MO', 'WE'], monthDays: [], endMode: 'count', count: 8, until: '' })
expect(() => buildTaskRrule({ frequency: 'weekly', interval: 2, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('至少选择一个重复日期')
expect(() => buildTaskRrule({ frequency: 'daily', interval: 0, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })).toThrow('重复间隔至少为 1')
})
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>')
+40
View File
@@ -104,6 +104,46 @@ export function moveItemWithinScope<T extends { id: string; parent_id?: string |
return items.map((item) => (item.parent_id ?? null) === (source.parent_id ?? null) ? reorderedIterator.next().value! : item)
}
export type TaskRepeatConfig = {
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
weekdays?: string[]
monthDays?: number[]
endMode: 'never' | 'date' | 'count'
count?: number
until?: string
}
export function buildTaskRrule(config: TaskRepeatConfig) {
const interval = Math.floor(Number(config.interval))
if (!Number.isFinite(interval) || interval < 1) throw new Error('重复间隔至少为 1')
if (config.frequency === 'weekly' && !config.weekdays?.length) throw new Error('至少选择一个重复日期')
if (config.frequency === 'monthly' && (!config.monthDays?.length || config.monthDays.some((day) => day < 1 || day > 31))) throw new Error('请输入 1 到 31 的每月日期')
if (config.endMode === 'count' && (!config.count || config.count < 1)) throw new Error('重复次数至少为 1')
if (config.endMode === 'date' && !config.until) throw new Error('请选择结束日期')
const parts = [`FREQ=${config.frequency.toUpperCase()}`]
if (interval > 1) parts.push(`INTERVAL=${interval}`)
if (config.frequency === 'weekly' && config.weekdays?.length) parts.push(`BYDAY=${config.weekdays.join(',')}`)
if (config.frequency === 'monthly' && config.monthDays?.length) parts.push(`BYMONTHDAY=${config.monthDays.join(',')}`)
if (config.endMode === 'count') parts.push(`COUNT=${Math.max(1, Math.floor(config.count ?? 1))}`)
if (config.endMode === 'date' && config.until) parts.push(`UNTIL=${config.until}T23:59:59`)
return parts.join(';')
}
export function parseTaskRrule(rrule = ''): TaskRepeatConfig {
const parts = Object.fromEntries(rrule.split(';').filter(Boolean).map((part) => part.split('=', 2)))
const frequency = (parts.FREQ?.toLowerCase() || 'daily') as TaskRepeatConfig['frequency']
return {
frequency,
interval: Math.max(1, Number(parts.INTERVAL ?? 1)),
weekdays: parts.BYDAY?.split(',').filter(Boolean) ?? [],
monthDays: parts.BYMONTHDAY?.split(',').map(Number).filter(Number.isFinite) ?? [],
endMode: parts.COUNT ? 'count' : parts.UNTIL ? 'date' : 'never',
count: parts.COUNT ? Number(parts.COUNT) : 1,
until: parts.UNTIL?.slice(0, 10) ?? '',
}
}
export function toDateTimeLocal(value: string | null | undefined) {
if (!value) return ''
const date = new Date(value)