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>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -9,7 +9,7 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto
|
||||
.list-drag-handle{width:44px;min-width:44px;height:44px;display:grid;place-items:center;border:0;background:transparent;color:#ad9f8c;cursor:grab;touch-action:none;border-radius:8px}.list-drag-handle svg{width:15px;height:15px}.list-row{touch-action:pan-y}.list-row.list-dragging{position:relative;z-index:8;opacity:.8;box-shadow:0 10px 24px rgba(78,58,34,.2);transform:translateY(var(--list-drag-y));transition:none;pointer-events:none;background:#fffaf4}.folder-row.list-drop-target{background:var(--accent-soft);box-shadow:inset 3px 0 0 var(--accent)}.list-root-drop.list-drop-target{background:var(--accent-soft);color:#b7421e;border-radius:9px}.list-row.list-reorder-target{box-shadow:inset 0 2px 0 var(--accent)}.list-move-menu{display:grid;gap:4px;padding:6px 0 6px 28px}.list-move-menu button{min-height:42px}.list-move-menu button:disabled,.sidebar-action-sheet .app-sheet__body button:disabled{opacity:.45;cursor:default}
|
||||
.archived-lists{position:relative;margin-top:4px}.archived-lists-toggle{min-height:44px;width:100%;display:flex;align-items:center;gap:8px;border:0;border-radius:9px;background:transparent;padding:0 12px;color:#81786c;text-align:left;font-size:12px;font-weight:650}.archived-lists-toggle:hover:not(:disabled){background:rgba(255,255,255,.52)}.archived-lists-toggle:focus-visible,.archived-row-menu-trigger:focus-visible,.archived-row-actions button:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.archived-lists-toggle:disabled{cursor:default;color:#aaa196}.archived-lists-toggle svg{width:15px;height:15px;transition:transform .16s ease}.archived-lists-toggle svg.expanded{transform:rotate(90deg)}.archived-list-items{display:grid;transition:opacity .16s ease}.archived-row{min-height:44px;display:grid;grid-template-columns:minmax(0,1fr) 44px;align-items:center;padding-left:35px;color:#746c61}.archived-row-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px}.archived-row-menu{display:grid;place-items:center}.archived-row-menu-trigger{width:44px;height:44px;display:grid;place-items:center;border:0;border-radius:9px;background:transparent;color:#8e8477}.archived-row-menu-trigger svg{width:15px;height:15px}.archived-action-mask{display:contents}.archived-row-actions{position:fixed;z-index:70;width:164px;display:grid;gap:2px;padding:6px;background:#fffdf8;border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow)}.archived-row-actions button{min-height:44px;display:flex;align-items:center;gap:9px;border:0;border-radius:8px;background:transparent;padding:8px 10px;text-align:left;font-size:12px}.archived-row-actions button:hover{background:#f8f1e8}.archived-row-actions svg{width:15px;height:15px}@media(max-width:930px){.archived-action-mask{display:block;position:fixed;z-index:60;inset:0;background:rgba(45,38,31,.3)}.archived-row-actions{position:fixed;z-index:61;left:0;right:0;top:auto;bottom:0;width:100%;padding:12px 16px calc(16px + env(safe-area-inset-bottom));border-radius:22px 22px 0 0}.archived-row-actions button{font-size:14px}}@media(prefers-reduced-motion:reduce){.archived-lists-toggle svg,.archived-list-items{transition:none}}
|
||||
.detail{min-width:0;border-left:1px solid var(--line);background:#faf7f0;overflow:auto}.detail-head{height:57px;display:flex;align-items:center;justify-content:space-between;padding:0 21px;border-bottom:1px solid var(--line);font-size:12px;font-weight:700;color:#80766a;text-transform:uppercase;letter-spacing:.08em}.paper{margin:22px;padding:28px 20px;min-height:180px;background:#fff;border:1px solid var(--line);border-radius:11px;box-shadow:0 4px 18px rgba(76,57,34,.05);display:grid;place-items:center;align-content:center;text-align:center;color:#8f8578}.paper svg{width:32px;height:32px;color:#ceb8a4;margin-bottom:12px}.paper b{color:#625b50}.paper p{font-size:13px;line-height:1.6}.detail-form{min-width:0;grid-template-columns:minmax(0,1fr);padding:19px;display:grid;gap:15px}.detail-form>*{min-width:0}.detail-title{display:flex;align-items:flex-start;gap:10px}.check.large{margin-top:8px;width:22px;height:22px;flex-basis:22px}.detail-title textarea{flex:1;border:0;background:transparent;resize:none;outline:none;font-size:19px;line-height:1.4;font-weight:700}.detail-form>label{grid-template-columns:80px 1fr;align-items:center}.detail-form>label input,.detail-form>label select{padding:8px}.task-detail-field-input{width:190px!important;max-width:100%;justify-self:end}.task-detail-due-input{padding-inline:9px!important}.field{display:grid;gap:7px}.field-label{display:flex;justify-content:space-between;align-items:center;font-size:12px;font-weight:700;color:#756d61}.hint{font-size:12px;color:#a49a8d}.markdown .field-label>span:last-child{display:flex;background:#eee7dc;padding:2px;border-radius:6px}.markdown .field-label button{border:0;background:transparent;padding:4px 8px;border-radius:5px;font-size:11px}.markdown .field-label button.active{background:#fff;color:var(--accent)}.markdown textarea{border:1px solid var(--line);background:#fff;border-radius:9px;padding:11px;resize:vertical;outline:none;font:13px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.markdown-preview{min-height:160px;padding:10px 12px;background:#fff;border:1px solid var(--line);border-radius:9px;font-size:13px;line-height:1.65;overflow-wrap:anywhere}.markdown-preview h1{font-size:20px}.markdown-preview h2{font-size:16px}.markdown-preview p{margin:8px 0}.markdown-preview code{background:#f2ece2;padding:2px 4px;border-radius:4px}.markdown-preview a{color:var(--accent)}.subtasks{display:grid;gap:5px}.subtask-detail{width:100%;min-width:0;max-width:100%;overflow-wrap:anywhere;word-break:break-word;white-space:normal;display:flex;align-items:center;gap:8px;border:0;background:#fff;padding:8px;border-radius:7px;text-align:left}.subtask-detail .check{pointer-events:none}.strike{text-decoration:line-through;color:var(--muted)}.detail-actions{display:flex;justify-content:space-between;align-items:center;padding-top:10px;border-top:1px solid var(--line)}.secondary{border:1px solid var(--line);background:#fff;padding:8px 11px;border-radius:8px;font-weight:650}.danger-text{border:0;background:transparent;color:var(--danger);display:flex;align-items:center;gap:5px;font-size:12px}.danger-text svg{width:14px}
|
||||
.field-hint{color:var(--muted);font-size:11px;font-weight:400}.repeat-custom-fields{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:11px;background:#fffaf4}.repeat-custom-fields>div,.repeat-custom-fields>label{display:flex;align-items:center;gap:8px;color:#675f54;font-size:12px}.repeat-custom-fields input[type="number"]{width:72px}.repeat-custom-fields input,.repeat-custom-fields select{min-height:40px;border:1px solid var(--line);border-radius:8px;background:#fff;padding:7px 9px}.weekday-picker{display:flex;gap:4px;flex-wrap:wrap}.weekday-picker label{width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;background:#fff}.weekday-picker input{position:absolute;opacity:0;pointer-events:none}.weekday-picker label:has(input:checked){background:var(--accent);border-color:var(--accent);color:#fff}.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||
.field-hint{color:var(--muted);font-size:11px;font-weight:400}.after-completion-fields{display:grid;gap:5px;padding:10px 12px;border:1px solid var(--line);border-radius:10px;background:#fffaf4;color:#675f54;font-size:12px}.after-completion-fields>div{display:flex;align-items:center;gap:7px;min-height:44px}.after-completion-fields input{width:76px;min-height:44px;border:1px solid var(--line);border-radius:8px;background:#fff;padding:0 10px}.after-completion-fields small{color:var(--muted);line-height:1.5}.repeat-save{width:100%;min-height:44px}.repeat-custom-fields{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:11px;background:#fffaf4}.repeat-custom-fields>div,.repeat-custom-fields>label{display:flex;align-items:center;gap:8px;color:#675f54;font-size:12px}.repeat-custom-fields input[type="number"]{width:72px}.repeat-custom-fields input,.repeat-custom-fields select{min-height:40px;border:1px solid var(--line);border-radius:8px;background:#fff;padding:7px 9px}.weekday-picker{display:flex;gap:4px;flex-wrap:wrap}.weekday-picker label{width:34px;height:34px;display:grid;place-items:center;border:1px solid var(--line);border-radius:50%;background:#fff}.weekday-picker input{position:absolute;opacity:0;pointer-events:none}.weekday-picker label:has(input:checked){background:var(--accent);border-color:var(--accent);color:#fff}.toast,.error-toast{position:fixed;z-index:50;left:50%;bottom:24px;transform:translateX(-50%);background:#322d28;color:#fff;border-radius:9px;padding:10px 15px;box-shadow:var(--shadow);font-size:13px}.error-toast{background:var(--danger);display:flex;align-items:center;gap:10px}.error-toast button{border:0;background:transparent;color:#fff;padding:0}.toast-enter-active,.toast-leave-active{transition:.2s}.toast-enter-from,.toast-leave-to{opacity:0;transform:translate(-50%,8px)}
|
||||
@media(max-width:1050px) and (min-width:931px){.shell.detail-open{grid-template-columns:220px minmax(400px,1fr) minmax(280px,30vw)}.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(400px,1fr) minmax(280px,30vw)}main{padding-inline:24px}}
|
||||
@media(max-width:930px){.topbar:has(.search):has(.topbar-filter){grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter" ". search search";grid-template-rows:auto auto;column-gap:10px;row-gap:8px}.topbar>.icon{grid-area:menu}.topbar-title{grid-area:title}.topbar-filter{grid-area:filter}.topbar .search{grid-area:search;grid-row:2;justify-self:end;width:min(260px,100%)}}
|
||||
@media(max-width:930px){.shell{height:100dvh;display:block;overflow:auto}.shell.mobile-sidebar-open{overflow:hidden}.shell.mobile-sidebar-open main{overflow:hidden;touch-action:none}.sidebar{position:fixed;z-index:50;display:flex;top:0;bottom:0;transition:transform .22s ease;box-shadow:var(--shadow);left:0;width:min(300px,86vw);transform:translateX(-105%);overscroll-behavior:contain}.sidebar.open{transform:none}.detail{position:fixed;z-index:40;display:block;left:0;right:0;top:auto;bottom:0;width:100%;max-height:min(88dvh,760px);overflow:auto;background:#fffdf8;border:1px solid var(--line);border-bottom:0;border-radius:22px 22px 0 0;box-shadow:0 -14px 38px rgba(56,40,24,.2);transform:translateY(105%);transition:transform .22s ease;padding-bottom:max(16px,env(safe-area-inset-bottom))}.detail.open{transform:none}.detail-head{position:sticky;z-index:2;top:0;height:62px;background:#fffdf8;border-bottom:1px solid var(--line);padding:0 18px}.detail-head .icon{display:grid;width:42px;height:42px;background:var(--accent-soft);color:var(--accent);border-radius:50%}.detail-form{padding:18px}.detail-title textarea{font-size:21px}.more-mask{position:fixed;z-index:45;inset:0;background:rgba(45,38,31,.32);display:flex;align-items:flex-end}.more-sheet{width:100%;background:#fffdf8;border-radius:22px 22px 0 0;padding:14px 16px max(22px,env(safe-area-inset-bottom));display:grid;gap:6px}.more-sheet-head{display:flex;align-items:center;justify-content:space-between;padding:2px 4px 8px}.more-sheet>button{display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}.scrim{position:fixed;z-index:35;inset:0;background:rgba(45,38,31,.32);display:block}.mobile-only{display:grid}main{min-height:100dvh;padding:20px 17px 112px}.topbar h1{font-size:24px}.completed-filter-pill{min-width:138px;height:44px}.completed-filter-pill__track{width:36px;height:22px}.completed-filter-pill__thumb{width:18px;height:18px}.today-board{gap:1px;margin:14px 0 4px;padding:5px}.today-track{min-height:58px;padding:8px 9px;gap:7px}.today-track-head{align-items:flex-start;flex-direction:column;gap:2px}.today-track-head strong{font-size:13px}.search{width:auto;margin-bottom:13px;padding:8px}.search input{width:90px}.search kbd{display:none}.list-toolbar{height:auto;min-height:36px}.section-heading{margin:12px 0 8px}.empty{min-height:190px}.today-empty-panel{min-height:104px}.row-actions{position:static;opacity:1;pointer-events:auto}.row-actions button{padding:7px;min-height:34px}.list-row{padding:6px 4px}.archived-row{padding:9px 7px;border:1px dashed #e0cfae;border-radius:8px;color:#8a6d3b;background:#fbf3e2}.archived-row .list-row-main{padding-left:14px}.archived-row .list-row-main>svg{color:#c9a45c}.bottom{display:flex;position:fixed;z-index:15;left:0;right:0;bottom:0;justify-content:space-around;background:rgba(255,253,248,.96);border-top:1px solid var(--line);padding:8px 5px max(8px,env(safe-area-inset-bottom));box-shadow:0 -5px 18px rgba(79,59,34,.06)}.bottom button{min-height:44px;min-width:60px;border:0;background:transparent;color:#81786d;display:grid;place-items:center;gap:2px;font-size:10px}.bottom button.active{color:var(--accent);font-weight:700}.bottom svg{width:20px}.toast,.error-toast{bottom:142px}.task-row{padding-inline:2px}.subtask{padding-left:35px}.ghost{opacity:.45}}
|
||||
@@ -35,7 +35,7 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto
|
||||
.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.tool-card{display:flex;flex-direction:column;align-items:flex-start;gap:10px}.tool-card>h2{margin:0;font-size:1.17em}.tool-card>svg{color:var(--accent);width:25px;height:25px}.tool-card p{margin:0;color:var(--muted);font-size:13px}.tool-card.wide{grid-column:1/-1}.password-form{width:100%;display:grid;gap:10px}.password-form label{display:grid;gap:6px;color:#675f54;font-size:12px;font-weight:650}.password-form input{width:100%;border:1px solid var(--line);background:#fff;border-radius:10px;padding:11px 12px;outline:none}.password-form input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(241,90,41,.1)}.password-form button{justify-self:start}.file-button input{display:none}.tool-card pre{width:100%;max-height:180px;overflow:auto;background:#f8f3e8;padding:10px;border-radius:8px;font-size:10px}.session-row,.audit-row{width:100%;display:flex;justify-content:space-between;align-items:center;gap:10px;border-top:1px solid var(--line);padding:9px 0}.session-copy{min-width:0;flex:1;display:grid}.session-title{line-height:1.4}.session-meta{min-width:0;display:flex;flex-wrap:wrap;align-items:center;line-height:1.45}.session-device{min-width:0;overflow-wrap:anywhere}.session-revoke{min-width:44px;min-height:44px;flex:0 0 auto;justify-content:center}.audit-copy{min-width:0;display:grid;gap:2px}.audit-action{display:-webkit-box;overflow:hidden;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow-wrap:anywhere}.session-row small,.audit-row time{color:var(--muted);font-size:11px}.inline-error{padding:9px;border-radius:8px;color:var(--danger);background:#fff0ed}.settings.active{background:var(--accent-soft);color:#b7421e;font-weight:700}
|
||||
@media(max-width:930px){.task-row,.habit-row,.countdown-row{min-height:62px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:none}.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}.task-row{padding:4px 7px}.task-row:hover,.task-row.selected{background:#fffaf5}.habit-row{padding:4px 7px}.countdown-row,.countdown-row:first-of-type{border:1px solid var(--line)}.countdown-row{grid-template-columns:minmax(0,1fr) 72px;padding:8px 9px;gap:8px}.task-main strong,.habit-name,.countdown-main>b{font-size:14px;font-weight:650}.meta,.countdown-main>small,.countdown-state small{font-size:11px;color:var(--muted)}.habit-progress{font-size:16px}.task-check{width:44px;flex-basis:44px}.countdown-icon{width:36px;height:36px;border-radius:10px}.countdown-state strong{font-size:24px}.countdown-group{gap:8px}.countdown-group>h3{padding-left:3px}.habit-detail-mask{padding:0}.habit-detail-sheet{width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom))}}
|
||||
@media(max-width:800px){.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-main{padding:10px 2px}.numeric-action input{width:62px}}
|
||||
@media(max-width:390px){.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}}
|
||||
@media(max-width:390px){.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}.task-compose-sheet .app-sheet__footer>button{min-height:44px}.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}}
|
||||
.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 8px 20px rgba(241,90,41,.28);transition:transform .16s ease,box-shadow .16s ease;touch-action:none;user-select:none}.unified-fab svg{width:25px;height:25px}.unified-fab:hover{transform:translateY(-2px);box-shadow:0 10px 24px rgba(241,90,41,.32)}.unified-fab:active{transform:scale(.96)}.unified-fab.dragging{transform:scale(1.06);box-shadow:0 12px 28px rgba(241,90,41,.36)}.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim);overscroll-behavior:contain}.app-sheet{min-height:0;overflow:hidden;background:var(--paper)}.app-sheet__header{min-height:64px;flex:0 0 64px;position:sticky;z-index:3;top:0;background:var(--paper);border-bottom:1px solid var(--line);padding:0 18px}.app-sheet__header>div{min-width:0}.app-sheet__header h2,.app-sheet__header h3{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.app-sheet__header>button{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;background:transparent;border-radius:10px}.app-sheet__body{min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;display:grid;gap:14px;padding:16px 18px}.app-sheet__footer{position:sticky;bottom:0;z-index:3;margin:0;background:var(--paper);border-top:1px solid var(--line);padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end;gap:8px}.app-sheet__danger{border-top:1px solid #f1d4cd;background:#fff8f6;padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end}.app-sheet--actions .app-sheet__body{gap:6px;padding:8px 16px calc(16px + env(safe-area-inset-bottom))}.app-sheet--actions .app-sheet__body>button{min-height:50px;width:100%;display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}
|
||||
@media(max-width:930px){.app-sheet{width:100%;max-height:min(88dvh,760px);display:flex!important;flex-direction:column!important;overflow:hidden!important;border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important;padding:0!important}.app-sheet--detail,.app-sheet--create{max-width:none!important}.app-sheet-mask{padding:0!important;place-items:end center!important;align-items:flex-end!important}.app-sheet__header{display:flex!important;align-items:center!important;justify-content:space-between!important;width:100%}.app-sheet__body{width:100%;flex:1 1 auto}.app-sheet__body label{display:grid;gap:6px}.app-sheet__footer{width:100%;flex:0 0 auto}.app-sheet__footer .primary-small{min-width:124px}.app-sheet__danger{width:100%;flex:0 0 auto}.app-sheet__danger .danger-text{width:100%;min-height:48px;justify-content:center}}
|
||||
|
||||
|
||||
@@ -209,6 +209,31 @@ describe('completion feedback motion', () => {
|
||||
expect(css).toContain('@media(prefers-reduced-motion:reduce){.task-row.just-completed,.habit-row.just-completed,.task-row.just-completed .task-check-mark,.habit-row.just-completed .task-check-mark{animation:none}}')
|
||||
})
|
||||
|
||||
it('syncs the browser IANA timezone before loading user task data', () => {
|
||||
expect(app).toContain("Intl.DateTimeFormat().resolvedOptions().timeZone")
|
||||
expect(app).toContain("await api('/me', { method: 'PATCH', body: JSON.stringify({ timezone }) })")
|
||||
const bootstrapBlock = app.slice(app.indexOf('async function bootstrap()'), app.indexOf('async function submitAuth()'))
|
||||
expect(bootstrapBlock.indexOf("await syncBrowserTimezone(data.user?.timezone)")).toBeLessThan(bootstrapBlock.indexOf('await loadRestoredView()'))
|
||||
const authBlock = app.slice(app.indexOf('async function submitAuth()'), app.indexOf('async function loadTaskPages'))
|
||||
expect(authBlock.indexOf("await syncBrowserTimezone(data.user?.timezone)")).toBeLessThan(authBlock.indexOf('await loadRestoredView()'))
|
||||
})
|
||||
|
||||
it('saves due removal atomically without deleting recurrence a second time', () => {
|
||||
const saveTaskBlock = app.slice(app.indexOf('async function saveTask()'), app.indexOf('async function removeTask('))
|
||||
expect(saveTaskBlock).not.toContain("method: 'DELETE'")
|
||||
expect(saveTaskBlock).toContain('selectedTaskRecurrence.value = null')
|
||||
expect(saveTaskBlock).toContain("selectedTaskRepeat.value = 'none'")
|
||||
})
|
||||
|
||||
it('keeps all task composer touch controls at least 44px on mobile without horizontal overflow', () => {
|
||||
expect(css).toContain('@media(max-width:390px){.task-compose-sheet')
|
||||
expect(css).toContain('.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}')
|
||||
expect(css).toContain('.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}')
|
||||
expect(css).toContain('.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}')
|
||||
expect(css).toContain('.task-compose-sheet .app-sheet__footer>button{min-height:44px}')
|
||||
expect(css).toContain('.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}')
|
||||
})
|
||||
|
||||
it('restarts the pulse and ignores a stale timer on rapid repeat completion', () => {
|
||||
vi.useFakeTimers()
|
||||
const active = new Set<string>()
|
||||
@@ -774,7 +799,7 @@ describe('approved habit safety and U2 title hierarchy', () => {
|
||||
})
|
||||
|
||||
it('keeps the 390px habit sheets full width with 44px close and bottom actions', () => {
|
||||
expect(css).toContain('@media(max-width:390px){.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}')
|
||||
expect(css).toContain('.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}')
|
||||
expect(css).toContain('.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}')
|
||||
expect(css).toContain('.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}')
|
||||
})
|
||||
@@ -852,8 +877,19 @@ describe('unified floating add interaction', () => {
|
||||
expect(app).toContain('const selectedTaskRepeat = ref')
|
||||
expect(app).toContain('重复<select v-model="composeRepeat"')
|
||||
expect(app).toContain('重复<select v-model="selectedTaskRepeat"')
|
||||
expect(app).toContain('rrule,')
|
||||
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('完成后 <input v-model="selectedAfterCompletionDays"')
|
||||
expect(app).toContain('每次完成后,将截止时间顺延对应天数;首版永不结束')
|
||||
expect(app).toContain('buildTaskRecurrencePayload(composeRepeat.value')
|
||||
expect(app).toContain('buildTaskRecurrencePayload(value, { afterCompletionDays: selectedAfterCompletionDays.value')
|
||||
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',")
|
||||
expect(createBlock).not.toContain("api('/recurrences'")
|
||||
const saveBlock = app.slice(app.indexOf('async function updateSelectedTaskRepeat()'), app.indexOf('async function submitTaskCompose()'))
|
||||
expect(saveBlock).not.toContain('selectedTaskRepeat.value = parseTaskRecurrence')
|
||||
expect(css).toContain('.after-completion-fields input{width:76px;min-height:44px')
|
||||
expect(app).toContain('<option value="custom">自定义…</option>')
|
||||
expect(app).toContain('class="repeat-custom-fields"')
|
||||
expect(app).toContain('每隔')
|
||||
@@ -1078,7 +1114,9 @@ describe('sidebar layout', () => {
|
||||
})
|
||||
|
||||
it('loads archived lists during bootstrap so archived rows are visible after refresh', () => {
|
||||
expect(app).toContain('expandedFolders.value = new Set(folders.value.map((folder) => folder.id))\n await loadArchivedLists()\n void preloadCountdowns()\n await loadRestoredView()')
|
||||
const bootstrapBlock = app.slice(app.indexOf('async function bootstrap()'), app.indexOf('async function submitAuth()'))
|
||||
expect(bootstrapBlock).toContain('await loadArchivedLists()')
|
||||
expect(bootstrapBlock.indexOf('await loadArchivedLists()')).toBeLessThan(bootstrapBlock.indexOf('await loadRestoredView()'))
|
||||
})
|
||||
|
||||
it('styles archived rows through the compact disclosure structure', () => {
|
||||
|
||||
Reference in New Issue
Block a user