feat: repeat tasks after completion
This commit is contained in:
+40
-31
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user