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
+41 -3
View File
@@ -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', () => {