feat: repeat tasks after completion
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m31s

This commit is contained in:
2026-09-10 21:21:28 +08:00
parent 84592db098
commit 64b8525720
14 changed files with 849 additions and 114 deletions
+40 -31
View File
@@ -5,7 +5,7 @@ import {
Ellipsis, GripVertical, Inbox, ListChecks, ListTodo, Menu, Pencil, Plus, Search,
Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next'
import { buildTaskRrule, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig } from './lib/task-utils'
import { buildTaskRecurrencePayload, defaultTaskDueAt, filterTasks, fromDateTimeLocal, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal, type TaskRepeatConfig, type TaskRepeatOption } from './lib/task-utils'
import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isLatestRequest, isTaskView, loadCountdownCache, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, performTrashMutation, readStoredBoolean, readStoredNavigation, runLatestRequest, shouldToggleRowSwipe, startPrimaryWithBackground, writeStoredBoolean, writeStoredNavigation } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import { createCompletionPulse } from './lib/completion-motion'
@@ -23,8 +23,8 @@ import { useTaskDueClock } from './lib/task-due-clock'
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; version: number; due_at: string | null; due_has_time: boolean; subtasks?: Task[] }
type RepeatOption = 'none' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'custom'
type Recurrence = { id: string; task_id: string; rrule: string }
type RepeatOption = TaskRepeatOption
type Recurrence = { id: string; task_id: string; rrule: string | null; trigger_mode: 'scheduled' | 'after_completion'; after_completion_days: number | null }
type View = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'settings'
const initialized = ref<boolean | null>(null)
@@ -114,7 +114,11 @@ const composeTimePicker = ref<HTMLInputElement | null>(null)
const composePriority = ref(0)
const composeDescription = ref('')
const composeRepeat = ref<RepeatOption>('none')
const composeAfterCompletionDays = ref('1')
const composeRepeatError = ref('')
const selectedTaskRepeat = ref<RepeatOption>('none')
const selectedAfterCompletionDays = ref('1')
const selectedRepeatError = ref('')
const selectedTaskRecurrence = ref<Recurrence | null>(null)
const defaultRepeatConfig = (): TaskRepeatConfig => ({ frequency: 'daily', interval: 1, weekdays: [], monthDays: [], endMode: 'never', count: 10, until: '' })
const composeRepeatConfig = ref<TaskRepeatConfig>(defaultRepeatConfig())
@@ -140,6 +144,8 @@ function openTaskCompose() {
composePriority.value = 0
composeDescription.value = ''
composeRepeat.value = 'none'
composeAfterCompletionDays.value = '1'
composeRepeatError.value = ''
composeRepeatConfig.value = defaultRepeatConfig()
composeCalendarOpen.value = false
taskComposeOpen.value = true
@@ -171,50 +177,41 @@ function activateFloatingAdd(origin: { x: number; y: number }) {
else if (activeView.value === 'countdowns') countdownComposer.value?.openCountdownComposer(origin)
else if (['tasks', 'today', 'upcoming'].includes(activeView.value)) openTaskCompose()
}
function repeatRrule(value: RepeatOption, config: TaskRepeatConfig) {
if (value === 'none') return ''
if (value === 'custom') return buildTaskRrule(config)
return `FREQ=${value.toUpperCase()}`
}
function repeatOption(rrule?: string): RepeatOption {
if (!rrule) return 'none'
const parsed = parseTaskRrule(rrule)
const simple = parsed.interval === 1 && !parsed.weekdays?.length && !parsed.monthDays?.length && parsed.endMode === 'never'
return simple ? parsed.frequency : 'custom'
}
async function saveRepeat(task: Task, value: RepeatOption, config = selectedRepeatConfig.value) {
if (value !== 'none' && !task.due_at) throw new Error('请先设置截止时间')
if (value === 'none') {
if (selectedTaskRecurrence.value) await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
return
}
const rrule = repeatRrule(value, config)
const recurrencePayload = buildTaskRecurrencePayload(value, { afterCompletionDays: selectedAfterCompletionDays.value, repeatConfig: config })
if (selectedTaskRecurrence.value) {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ rrule }) })
selectedTaskRecurrence.value = { ...selectedTaskRecurrence.value, rrule }
const updated = await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify(recurrencePayload) }) as Recurrence
selectedTaskRecurrence.value = updated
} else {
selectedTaskRecurrence.value = await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, rrule }) })
selectedTaskRecurrence.value = await api('/recurrences', { method: 'POST', body: JSON.stringify({ task_id: task.id, ...recurrencePayload }) }) as Recurrence
}
selectedTaskRepeat.value = value
}
async function loadTaskRecurrence(task: Task) {
const token = ++recurrenceLoadToken
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
selectedAfterCompletionDays.value = '1'
selectedRepeatError.value = ''
try {
const recurrence = await api(`/tasks/${task.id}/recurrence`) as Recurrence | null
if (token !== recurrenceLoadToken || selectedTask.value?.id !== task.id) return
selectedTaskRecurrence.value = recurrence
selectedTaskRepeat.value = repeatOption(recurrence?.rrule)
selectedRepeatConfig.value = recurrence ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
const parsed = parseTaskRecurrence(recurrence)
selectedTaskRepeat.value = parsed.option
selectedAfterCompletionDays.value = String(parsed.afterCompletionDays)
selectedRepeatConfig.value = recurrence?.rrule ? parseTaskRrule(recurrence.rrule) : defaultRepeatConfig()
} catch (reason) { if (token === recurrenceLoadToken) fail(reason) }
}
async function updateSelectedTaskRepeat() {
if (!selectedTask.value) return
try { await saveRepeat(selectedTask.value, selectedTaskRepeat.value); toast('重复设置已保存') }
catch (reason) { selectedTaskRepeat.value = repeatOption(selectedTaskRecurrence.value?.rrule); fail(reason) }
try { selectedRepeatError.value = ''; await saveRepeat(selectedTask.value, selectedTaskRepeat.value); toast('重复设置已保存') }
catch (reason) { selectedRepeatError.value = reason instanceof Error ? reason.message : '保存失败'; fail(reason) }
}
async function submitTaskCompose() {
@@ -228,8 +225,9 @@ async function submitTaskCompose() {
composeTitleError.value = ''
if (!composeListId.value) return
try {
const rrule = composeRepeat.value === 'none' ? null : repeatRrule(composeRepeat.value, composeRepeatConfig.value)
const recurrencePayload = buildTaskRecurrencePayload(composeRepeat.value, { afterCompletionDays: composeAfterCompletionDays.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('请先设置截止时间')
const task = await api('/tasks', { method: 'POST', body: JSON.stringify({
title: taskTitle,
list_id: composeListId.value,
@@ -237,7 +235,7 @@ async function submitTaskCompose() {
due_has_time: composeHasTime.value,
priority: composePriority.value,
description: composeDescription.value,
rrule,
...recurrencePayload,
}) })
if (isTaskView(activeView.value)) {
tasks.value.push(task)
@@ -246,7 +244,7 @@ async function submitTaskCompose() {
if (activeView.value === 'today') void loadTodayTaskSummary()
taskComposeOpen.value = false
toast('任务已添加')
} catch (reason) { fail(reason) }
} catch (reason) { composeRepeatError.value = reason instanceof Error ? reason.message : '添加失败'; fail(reason) }
}
function toggleSidebar() {
const compact = window.matchMedia('(max-width: 930px)').matches
@@ -367,6 +365,12 @@ function toast(message: string) {
}
function fail(reason: unknown) { error.value = reason instanceof Error ? reason.message : '请求失败' }
async function syncBrowserTimezone(currentTimezone?: string) {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (!timezone || timezone === currentTimezone) return
await api('/me', { method: 'PATCH', body: JSON.stringify({ timezone }) })
}
function restoreNavigation(inboxId: string) {
if (restoredNavigation.view === 'tasks' && restoredNavigation.listId) {
activeList.value = lists.value.some((item) => item.id === restoredNavigation.listId) ? restoredNavigation.listId : inboxId
@@ -397,6 +401,7 @@ async function bootstrap() {
navigationLoaded.value = true
restoreNavigation(data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || '')
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
await syncBrowserTimezone(data.user?.timezone)
await loadArchivedLists()
void preloadCountdowns()
await loadRestoredView()
@@ -420,6 +425,7 @@ async function submitAuth() {
navigationLoaded.value = true
restoreNavigation(data.inbox_id || lists.value.find((item: TaskList) => item.is_inbox)?.id || lists.value[0]?.id || '')
expandedFolders.value = new Set(folders.value.map((folder) => folder.id))
await syncBrowserTimezone(data.user?.timezone)
void preloadCountdowns()
await loadRestoredView()
} catch (reason) { fail(reason) }
@@ -816,7 +822,6 @@ async function saveTask() {
if (dueAt) {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'PATCH', body: JSON.stringify({ due_at: dueAt }) })
} else {
await api(`/recurrences/${selectedTaskRecurrence.value.id}`, { method: 'DELETE' })
selectedTaskRecurrence.value = null
selectedTaskRepeat.value = 'none'
}
@@ -1283,8 +1288,10 @@ onUnmounted(() => {
<div class="detail-title"><button class="check large" :class="`p${selectedTask.priority}`" @click="toggle(selectedTask)"><Check v-if="selectedTask.completed"/></button><textarea v-model="selectedTask.title" rows="2" aria-label="任务标题" @blur="saveTask"/></div>
<label>清单<select v-model="selectedTask.list_id" class="task-detail-field-input" @change="saveTask"><option v-for="list in lists" :key="list.id" :value="list.id">{{list.name}}</option></select></label>
<label class="task-detail-due-row">截止时间<input class="task-detail-due-input task-detail-field-input" :value="toDateTimeLocal(selectedTask.due_at)" type="datetime-local" @change="selectedTask!.due_at=($event.target as HTMLInputElement).value;saveTask()"></label>
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedTask.due_at" @change="updateSelectedTaskRepeat"><option value="none">不重复</option><option value="daily">每天</option><option value="weekly">每周</option><option value="monthly">每月</option><option value="yearly">每年</option><option value="custom">自定义…</option></select><small v-if="!selectedTask.due_at" class="field-hint">设置截止时间后可重复</small></label>
<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><button type="button" class="soft-button" @click="updateSelectedTaskRepeat">保存自定义重复</button></section>
<label>重复<select v-model="selectedTaskRepeat" class="task-detail-field-input" :disabled="!selectedTask.due_at"><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="!selectedTask.due_at" 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==='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><button type="button" class="soft-button repeat-save" :disabled="!selectedTask.due_at" @click="updateSelectedTaskRepeat">保存重复设置</button>
<div class="field markdown"><div class="field-label"><span>备注</span><span><button :class="{active:!markdownPreview}" @click="markdownPreview=false">编辑</button><button :class="{active:markdownPreview}" @click="markdownPreview=true">预览</button></span></div><div v-if="markdownPreview" class="markdown-preview" v-html="renderMarkdown(selectedTask.description)"/><textarea v-else v-model="selectedTask.description" rows="9" placeholder="支持 Markdown" @blur="saveTask"/></div>
<div class="subtasks"><div class="field-label"><span>子任务</span><button class="link" @click="addSubtask"><Plus/>添加</button></div><button v-for="subtask in selectedTaskSubtasks" :key="subtask.id" class="subtask-detail" @click="toggle(subtask)"><span class="check"><Check v-if="subtask.completed"/></span><span :class="{strike:subtask.completed}">{{subtask.title}}</span></button><span v-if="!selectedTaskSubtasks.length" class="hint">把这件事拆成更小的步骤</span></div>
<details class="more-settings" :open="moreSettingsOpen" @toggle="moreSettingsOpen=($event.target as HTMLDetailsElement).open"><summary>更多设置</summary><div class="more-settings-body">
@@ -1315,7 +1322,9 @@ onUnmounted(() => {
<button v-if="composeDueAt && !composeHasTime" class="task-compose-time-add" type="button" @click="addComposeTime">添加时间</button>
<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="custom">自定义…</option></select><small v-if="!composeDueAt" class="field-hint">设置截止时间后可重复</small></label>
<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>
<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>
</div>