[verified] feat: support monthly completion recurrence
ci / gitleaks (push) Successful in 23s
ci / docker (push) Successful in 7m16s

This commit is contained in:
2026-09-20 21:04:31 +08:00
parent e6d720b85c
commit 3155155f53
13 changed files with 193 additions and 39 deletions
+14 -8
View File
@@ -5,7 +5,7 @@ import {
Ellipsis, GripVertical, Heading2, Inbox, Italic, Link, List, ListChecks, ListOrdered, ListTodo, Menu, Pencil, Plus, Quote,
Settings, Trash2, X, Repeat2, StickyNote,
} from 'lucide-vue-next'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { applyMarkdownFormat, buildTaskDueDraft, buildTaskRecurrencePayload, defaultTaskDueAt, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskDueDraft, parseTaskRecurrence, parseTaskRrule, renderMarkdown, type AfterCompletionUnit, type MarkdownFormat, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, createTaskCompletionExitCoordinator, createTaskToggleCoordinator, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, mergeTaskToggleResponse, normalizeRequiredName, readStoredBoolean, readStoredNavigation, reconcileCurrentTaskView, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, taskVersionedPatchPayload, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
@@ -32,7 +32,7 @@ type FolderItem = { id: string; name: string }
type TaskList = { id: string; folder_id: string | null; name: string; is_inbox: boolean }
type Task = { id: string; list_id: string; parent_id: string | null; title: string; description: string; priority: number; completed: boolean; completed_at: string | null; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
type RepeatOption = TaskRepeatOption
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null; after_completion_unit: AfterCompletionUnit | null }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
const initialized = ref<boolean | null>(null)
@@ -158,9 +158,11 @@ const composePriority = ref(0)
const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none')
const composeAfterCompletionDays = ref('1')
const composeAfterCompletionUnit = ref<AfterCompletionUnit>('days')
const composeRepeatError = ref('')
const selectedTaskRepeat = ref<RepeatOption>('none')
const selectedAfterCompletionDays = ref('1')
const selectedAfterCompletionUnit = ref<AfterCompletionUnit>('days')
const selectedRepeatError = ref('')
const selectedTaskRecurrence = ref<Recurrence | null>(null)
const recurrenceLoading = ref(false)
@@ -205,6 +207,7 @@ function openTaskCompose() {
composeDescription.value = ''
composeRepeat.value = 'none'
composeAfterCompletionDays.value = '1'
composeAfterCompletionUnit.value = 'days'
composeRepeatError.value = ''
composeRepeatConfig.value = defaultRepeatConfig()
composeCalendarOpen.value = false
@@ -243,13 +246,13 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
else if (activeView.value === 'memos') void memoPanel.value?.createMemo()
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
}
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, recurrence: Recurrence | null) {
async function saveRepeat(task: Task, value: RepeatOption, config: TaskRepeatConfig, afterCompletionDays: string, afterCompletionUnit: AfterCompletionUnit, recurrence: Recurrence | null) {
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
if (value === 'none') {
if (recurrence) await api(`/recurrences/${recurrence.id}`, { method: 'DELETE' })
return null
}
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })
if (recurrence) {
return await api(`/recurrences/${recurrence.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
}
@@ -263,6 +266,7 @@ async function loadTaskRecurrence(task: Task) {
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
selectedAfterCompletionDays.value = '1'
selectedAfterCompletionUnit.value = 'days'
selectedRepeatError.value = ''
try {
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
@@ -271,6 +275,7 @@ async function loadTaskRecurrence(task: Task) {
const parsed = parseTaskRecurrence(recurrence)
selectedTaskRepeat.value = parsed.option
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
selectedAfterCompletionUnit.value = parsed.afterCompletionUnit
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
} catch (reason) {
if (selectionIsCurrent()) fail(reason)
@@ -293,7 +298,7 @@ async function submitTaskCompose() {
const targetListId = composeListId.value
creatingTask.value = true
try {
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, repeatConfig: composeRepeatConfig.value })
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.value, afterCompletionUnit: composeAfterCompletionUnit.value, repeatConfig: composeRepeatConfig.value })
const dueValue = composeDueAt.value ? `${composeDueAt.value}T${composeHasTime.value ? composeTime.value : '23:59'}` : ''
if (composeRepeat.value !== 'none' && !dueValue) throw new Error('请先设置截止时间')
await taskMutationReconciler.run(
@@ -1093,6 +1098,7 @@ async function saveSelectedTaskChanges() {
const repeatValue = selectedDueDate.value ? selectedTaskRepeat.value : 'none'
const repeatConfig = JSON.parse(JSON.stringify(selectedRepeatConfig.value)) as TaskRepeatConfig
const afterCompletionDays = selectedAfterCompletionDays.value
const afterCompletionUnit = selectedAfterCompletionUnit.value
const recurrence = selectedTaskRecurrence.value
selectedRepeatError.value = ''
try {
@@ -1102,7 +1108,7 @@ async function saveSelectedTaskChanges() {
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
} else {
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)
const updatedRecurrence = await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)
if (recurrenceLoadToken !== selectionToken || selectedTask.value?.id !== taskId) return
selectedTaskRecurrence.value = updatedRecurrence
}
@@ -1749,7 +1755,7 @@ onUnmounted(() => {
<div class="task-detail-field"><span class="task-detail-field-label">时间</span><button v-if="selectedDueDate && !selectedDueHasTime" class="task-compose-time-add task-detail-time-control" type="button" @click="addSelectedDueTime">添加时间</button><label v-else-if="selectedDueDate" class="task-compose-time-chip task-detail-time-control"><input ref="selectedDueTimePicker" v-model="selectedDueTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="selectedDueHasTime=false"><X/></button></label><span v-else class="task-detail-time-empty" aria-hidden="true">—</span></div>
</div>
<label class="task-detail-field"><span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedDueDate"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!selectedDueDate" class="field-hint">请先设置截止时间,才能开启重复</small></label>
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数">重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
<section v-if="selectedTaskRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="selectedAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="selectedAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months">月</option></select>重复</div><small>每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天</small></section>
<section v-if="selectedTaskRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="selectedRepeatConfig.interval" type="number" min="1"><select v-model="selectedRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="selectedRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="selectedRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="selectedRepeatConfig.frequency==='monthly'">每月日期<input v-model="selectedRepeatConfig.monthDays" placeholder="例如 1,15,31" @change="selectedRepeatConfig.monthDays=String(($event.target as HTMLInputElement).value).split(',').map(Number).filter(Boolean)"></label><label>结束方式<select v-model="selectedRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="selectedRepeatConfig.endMode==='date'">结束日期<input v-model="selectedRepeatConfig.until" type="date"></label><label v-if="selectedRepeatConfig.endMode==='count'">重复次数<input v-model.number="selectedRepeatConfig.count" type="number" min="1"></label></section>
<small v-if="selectedRepeatError" role="alert" class="field-error">{{selectedRepeatError}}</small>
</section>
@@ -1788,7 +1794,7 @@ onUnmounted(() => {
<label v-else-if="composeDueAt" class="task-compose-time-chip"><span>时间</span><input ref="composeTimePicker" v-model="composeTime" type="time" aria-label="选择截止时间"><button class="task-compose-time-remove" type="button" aria-label="移除时间" @click.prevent="composeHasTime=false"><X/></button></label>
</div>
<label>重复<select v-model="composeRepeat" :disabled="!composeDueAt"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">请先设置截止时间,才能开启重复</small></label>
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复天数">重复</div><small>每次完成后,将截止时间顺延对应天数;首版永不结束</small></section>
<section v-if="composeRepeat==='after_completion'" class="after-completion-fields"><div>完成后 <input v-model="composeAfterCompletionDays" type="number" min="1" max="3650" step="1" inputmode="numeric" aria-label="完成后重复间隔"><select v-model="composeAfterCompletionUnit" aria-label="完成后重复单位"><option value="days">天</option><option value="months">月</option></select>重复</div><small>每次完成后,将截止日期按所选间隔顺延;月末会自动取目标月最后一天</small></section>
<small v-if="composeRepeatError" role="alert" class="field-error">{{composeRepeatError}}</small>
<section v-if="composeRepeat==='custom'" class="repeat-custom-fields"><div><span>每隔</span><input v-model.number="composeRepeatConfig.interval" type="number" min="1"><select v-model="composeRepeatConfig.frequency"><option value="daily">天</option><option value="weekly">周</option><option value="monthly">月</option><option value="yearly">年</option></select></div><label v-if="composeRepeatConfig.frequency==='weekly'">重复日期<span class="weekday-picker"><label v-for="day in weekdayOptions" :key="day.value"><input v-model="composeRepeatConfig.weekdays" type="checkbox" :value="day.value">{{day.label}}</label></span></label><label v-if="composeRepeatConfig.frequency==='monthly'">每月日期<input v-model.number="composeRepeatConfig.monthDays![0]" type="number" min="1" max="31"></label><label>结束方式<select v-model="composeRepeatConfig.endMode"><option value="never">永不结束</option><option value="date">指定日期</option><option value="count">重复次数</option></select></label><label v-if="composeRepeatConfig.endMode==='date'">结束日期<input v-model="composeRepeatConfig.until" type="date"></label><label v-if="composeRepeatConfig.endMode==='count'">重复次数<input v-model.number="composeRepeatConfig.count" type="number" min="1"></label></section>
<label>备注<textarea v-model="composeDescription" rows="3" placeholder="可选支持 Markdown"/></label>
+8 -6
View File
@@ -75,12 +75,14 @@ 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 and parses completion-trigger intervals in days or months', () => {
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '7' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 7, after_completion_unit: 'days' })
expect(buildTaskRecurrencePayload('after_completion', { afterCompletionDays: '2', afterCompletionUnit: 'months' })).toEqual({ trigger_mode: 'after_completion', after_completion_days: 2, after_completion_unit: 'months' })
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 14 })).toEqual({ option: 'after_completion', afterCompletionDays: 14, afterCompletionUnit: 'days' })
expect(parseTaskRecurrence({ rrule: null, trigger_mode: 'after_completion', after_completion_days: 3, after_completion_unit: 'months' })).toEqual({ option: 'after_completion', afterCompletionDays: 3, afterCompletionUnit: 'months' })
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', () => {
+8 -7
View File
@@ -172,15 +172,16 @@ 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 type AfterCompletionUnit = 'days' | 'months'
export type TaskRecurrenceRecord = { rrule?: string | null; trigger_mode?: 'scheduled' | 'after_completion'; after_completion_days?: number | null; after_completion_unit?: AfterCompletionUnit | null }
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; repeatConfig?: TaskRepeatConfig }) {
export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { afterCompletionDays: string | number; afterCompletionUnit?: AfterCompletionUnit; 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 }
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, after_completion_unit: values.afterCompletionUnit ?? 'days' }
}
const rrule = option === 'custom'
? buildTaskRrule(values.repeatConfig ?? { frequency: 'daily', interval: 1, endMode: 'never' })
@@ -189,13 +190,13 @@ export function buildTaskRecurrencePayload(option: TaskRepeatOption, values: { a
}
export function parseTaskRecurrence(recurrence?: TaskRecurrenceRecord | null) {
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1 }
if (!recurrence) return { option: 'none' as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
if (recurrence.trigger_mode === 'after_completion') {
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1 }
return { option: 'after_completion' as TaskRepeatOption, afterCompletionDays: recurrence.after_completion_days ?? 1, afterCompletionUnit: recurrence.after_completion_unit ?? 'days' as AfterCompletionUnit }
}
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 }
return { option: (simple ? parsed.frequency : 'custom') as TaskRepeatOption, afterCompletionDays: 1, afterCompletionUnit: 'days' as AfterCompletionUnit }
}
export function defaultTaskDueAt(now = new Date()) {
+6 -3
View File
@@ -1370,10 +1370,13 @@ describe('unified floating add interaction', () => {
expect(app).toContain('<span class="task-detail-field-label">重复</span><select v-model="selectedTaskRepeat"')
expect(app).toContain('<option value="yearly">每年</option><option value="after_completion">完成后重复</option><option value="custom">自定义…</option>')
expect(app).toContain('完成后 <input v-model="composeAfterCompletionDays"')
expect(app).toContain('v-model="composeAfterCompletionUnit"')
expect(app).toContain('完成后 <input v-model="selectedAfterCompletionDays"')
expect(app).toContain('每次完成后,将截止时间顺延对应天数;首版永不结束')
expect(app).toContain('v-model="selectedAfterCompletionUnit"')
expect(app).toContain('<option value="days">天</option><option value="months">月</option>')
expect(app).toContain('月末会自动取目标月最后一天')
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, repeatConfig: config })')
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays, afterCompletionUnit, repeatConfig: config })')
expect(app).toContain("api(`/tasks/${task.id}/recurrence`)")
const createBlock = app.slice(app.indexOf('async function submitTaskCompose()'), app.indexOf('function toggleSidebar()'))
expect(createBlock).toContain("api('/tasks',")
@@ -1388,7 +1391,7 @@ describe('unified floating add interaction', () => {
expect(saveBlock).toContain('if (!taskSaved.due_at) {')
expect(saveBlock).toContain('selectedTaskRecurrence.value = null')
expect(saveBlock).toContain("selectedTaskRepeat.value = 'none'")
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, recurrence)')
expect(saveBlock).toContain('await saveRepeat(taskSaved, repeatValue, repeatConfig, afterCompletionDays, afterCompletionUnit, recurrence)')
const dueRemovalBlock = saveBlock.slice(saveBlock.indexOf('if (!taskSaved.due_at) {'), saveBlock.indexOf('} else {'))
expect(dueRemovalBlock).not.toContain('saveRepeat(')
expect(saveBlock).toContain("selectedRepeatError.value = ''")