feat: recurring tasks, habits, attachments, import/export, audit and security

This commit is contained in:
2026-09-05 13:35:17 +08:00
parent 57145c198b
commit 067d27a477
22 changed files with 1231 additions and 33 deletions
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { dateKey, moveDueDate } from './mvp-utils'
describe('MVP view utilities', () => {
it('normalizes to UTC so stored due_at stays stable in every local timezone', () => {
const moved = moveDueDate('2026-09-05T14:30:00+08:00', '2026-09-09')
expect(moved).toBe('2026-09-09T14:30:00.000Z')
const newly = moveDueDate(null, '2026-09-09')
expect(newly).toBe('2026-09-09T09:00:00.000Z')
})
})
+41
View File
@@ -0,0 +1,41 @@
export function dateKey(date: Date) {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
export function calendarRange(date: Date) {
const first = new Date(date.getFullYear(), date.getMonth(), 1)
const from = new Date(first)
const mondayOffset = (first.getDay() + 6) % 7
from.setDate(first.getDate() - mondayOffset)
const to = new Date(from)
to.setDate(from.getDate() + 41)
return { from: dateKey(from), to: dateKey(to) }
}
export function moveDueDate(current: string | null, day: string) {
const source = current ? new Date(current) : null
const hours = source && !Number.isNaN(source.valueOf()) ? source.getHours() : 9
const minutes = source && !Number.isNaN(source.valueOf()) ? source.getMinutes() : 0
const date = new Date(`${day}T00:00:00`)
date.setHours(hours, minutes, 0, 0)
const offsetMs = date.getTimezoneOffset() * 60_000
return new Date(date.getTime() - offsetMs).toISOString()
}
export function habitWeek(now = new Date()) {
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7))
return Array.from({ length: 7 }, (_, index) => {
const date = new Date(monday)
date.setDate(monday.getDate() + index)
return date
})
}
export function mergePage<T>(page: T[] | { items?: T[]; next_cursor?: string | null }) {
if (Array.isArray(page)) return { items: page, nextCursor: null }
return { items: page.items ?? [], nextCursor: page.next_cursor ?? null }
}