Files
dodo/frontend/src/lib/mvp-utils.ts
T
bboysoul af83a68fe1
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 4m41s
feat: add calendar frontend
2026-09-20 13:21:47 +08:00

899 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
type BooleanStorage = Pick<Storage, 'getItem' | 'setItem'>
type NavigationView = 'tasks' | 'today' | 'upcoming' | 'trash' | 'habits' | 'countdowns' | 'memos' | 'calendar' | 'settings'
type StoredNavigation = { view: NavigationView; listId: string }
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', 'memos', 'calendar', 'settings'])
export function readStoredNavigation(storage: BooleanStorage, key: string): StoredNavigation {
try {
const value = JSON.parse(storage.getItem(key) ?? 'null')
if (value && NAVIGATION_VIEWS.has(value.view) && typeof value.listId === 'string') {
return { view: value.view, listId: value.listId }
}
} catch { /* storage may be unavailable or invalid */ }
return { view: defaultView(), listId: '' }
}
export function writeStoredNavigation(storage: BooleanStorage, key: string, view: NavigationView, listId: string) {
try { storage.setItem(key, JSON.stringify({ view, listId })) } catch { /* storage may be unavailable */ }
}
export function readStoredBoolean(storage: BooleanStorage, key: string, fallback: boolean) {
try {
const value = storage.getItem(key)
if (value === 'true') return true
if (value === 'false') return false
} catch { /* storage may be unavailable */ }
return fallback
}
export function writeStoredBoolean(storage: BooleanStorage, key: string, value: boolean) {
try { storage.setItem(key, String(value)) } catch { /* storage may be unavailable */ }
}
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 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 }
}
export function isTaskView(view: string) {
return view === 'tasks' || view === 'today' || view === 'upcoming'
}
export function defaultView() {
return 'today' as const
}
export function quickTaskFields(view: string, activeList: string, inboxList: string, now = new Date()) {
if (view !== 'today') return { list_id: activeList }
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(now)
const value = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value
return {
list_id: inboxList,
due_at: `${value('year')}-${value('month')}-${value('day')}T12:00:00+08:00`,
}
}
export function isHabitComplete(kind: string | undefined, value: number | boolean | undefined, target = 1) {
if (kind === 'numeric') return Number(value ?? 0) >= target
return Boolean(value)
}
export type HabitHistoryLog = { day: string; value: number }
export function habitHistoryWindow(toDay: string, size = 90) {
const [year, month, day] = toDay.split('-').map(Number)
const to = new Date(year, month - 1, day)
const from = new Date(to)
from.setDate(from.getDate() - (size - 1))
return { from: dateKey(from), to: dateKey(to) }
}
export function dayBefore(day: string) {
const [year, month, date] = day.split('-').map(Number)
const value = new Date(year, month - 1, date)
value.setDate(value.getDate() - 1)
return dateKey(value)
}
export function mergeHabitHistory(previous: HabitHistoryLog[], incoming: HabitHistoryLog[]) {
const merged = new Map(previous.map((item) => [item.day, item]))
for (const item of incoming) merged.set(item.day, { day: item.day, value: Number(item.value) })
return [...merged.values()].sort((a, b) => b.day.localeCompare(a.day))
}
export function formatHabitHistoryNumber(value: number) {
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(6)))
}
type HabitDetailFields = {
kind?: string
target?: number
unit?: string
archived_at?: string | null
schedule_type?: 'daily' | 'weekly' | 'monthly' | 'interval'
weekdays?: number[] | null
month_days?: number[] | null
interval_days?: number | null
}
export function formatHabitRecordMode(habit: HabitDetailFields) {
if (habit.kind !== 'numeric') return '完成 / 未完成'
const unit = habit.unit ? ` ${habit.unit}` : ''
return `按数量记录 · 目标 ${formatHabitHistoryNumber(habit.target ?? 1)}${unit}`
}
export function formatHabitSchedule(habit: HabitDetailFields) {
if (habit.schedule_type === 'weekly') {
const weekdays = habit.weekdays ?? []
if (!weekdays.length) return '每周(日期未设置)'
const labels = ['一', '二', '三', '四', '五', '六', '日']
return `每${weekdays.map((day) => `周${labels[day] ?? day}`).join('、')}`
}
if (habit.schedule_type === 'monthly') {
const monthDays = habit.month_days ?? []
return monthDays.length ? `每月 ${monthDays.join('、')} 日` : '每月(日期未设置)'
}
if (habit.schedule_type === 'interval') {
return habit.interval_days ? `每 ${habit.interval_days} 天` : '按间隔(天数未设置)'
}
return '每天'
}
export function formatHabitDetailDate(day?: string) {
if (!day) return '未提供'
const [year, month, date] = day.split('-').map(Number)
if (!year || !month || !date) return day
return `${year}${month}${date}日`
}
export function formatHabitDetailProgress(habit: Pick<HabitDetailFields, 'kind' | 'target' | 'unit' | 'archived_at'> & { value?: number | boolean }) {
if (habit.archived_at) return null
const target = habit.target ?? 1
const value = Number(habit.value ?? 0)
if (habit.kind !== 'numeric') {
const complete = Boolean(habit.value)
return { primary: complete ? '已完成' : '未完成', note: complete ? '今日目标已达成' : '今天尚未完成', semantic: complete ? '已完成' : '未完成' }
}
const complete = value >= target
return {
primary: `${formatHabitHistoryNumber(value)} / ${formatHabitHistoryNumber(target)}`,
note: complete ? '今日目标已达成' : `今天还差 ${formatHabitHistoryNumber(Math.max(0, target - value))}${habit.unit ? ` ${habit.unit}` : ''}`,
semantic: complete ? '已达标' : `${Math.round(target > 0 ? value / target * 100 : 0)}%`,
}
}
type HabitSchedule = {
kind?: string
cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value?: number | boolean }>
}
export function isHabitScheduledToday(habit: HabitSchedule, day: string) {
const cell = (habit.cells ?? []).find((item) => item.day === day)
return Boolean(cell?.scheduled && !cell.paused)
}
export function nextHabitSwipeValue(kind: string | undefined, current: number | boolean | undefined, target = 1) {
const value = Number(current ?? 0)
if (kind === 'numeric') return Math.min(value + 1, target)
return value > 0 ? 0 : 1
}
export function previousHabitSwipeValue(kind: string | undefined, current: number | boolean | undefined) {
const value = Number(current ?? 0)
if (kind === 'numeric') return Math.max(0, value - 1)
return 0
}
export function habitButtonValue(kind: string | undefined, current: number | boolean | undefined, target = 1) {
if (isHabitComplete(kind, current, target)) return 0
return nextHabitSwipeValue(kind, current, target)
}
export function habitButtonNotice(kind: string | undefined, previous: number | boolean | undefined, next: number | boolean) {
if (kind === 'numeric' && Number(previous ?? 0) > 0 && Number(next) === 0) return '今日进度已重置'
if (kind !== 'numeric' && Number(previous ?? 0) > 0 && Number(next) === 0) return '已取消完成'
return Number(next) > Number(previous ?? 0) ? '已记录一次 🎉' : '已减少一次'
}
export function numericHabitInputValue(input: number | string | undefined) {
if (input === undefined || input === '') return null
const value = Number(input)
return Number.isFinite(value) && value >= 0 ? value : null
}
export function countdownDayText(days: number) {
if (days > 0) return `还有 ${days} 天`
if (days === 0) return '就是今天'
return `已经 ${Math.abs(days)} 天`
}
export function countdownKindLabel(kind: string) {
return ({ countdown: '倒数日', anniversary: '纪念日', birthday: '生日' } as Record<string, string>)[kind] ?? '倒数日'
}
export function calendarModeLabel(mode: string) {
return mode === 'lunar' ? '农历' : '公历'
}
export function shouldToggleRowSwipe(deltaX: number, deltaY: number) {
return deltaX >= 64 && deltaX > Math.abs(deltaY) * 1.5
}
export function nextTotalAfterLocalTaskAdd(total: number) {
return Math.max(0, Number(total) || 0) + 1
}
export function nextTotalAfterLocalTaskRemoval(total: number, removed = 1) {
return Math.max(0, (Number(total) || 0) - Math.max(0, removed))
}
export type TrashMutationResult =
| { mutated: false; refreshed: false; error: unknown }
| { mutated: true; refreshed: boolean }
export async function performTrashMutation(
mutate: () => Promise<unknown>,
reconcileLocal: () => void,
refresh: () => Promise<boolean>,
): Promise<TrashMutationResult> {
try {
await mutate()
} catch (error) {
return { mutated: false, refreshed: false, error }
}
reconcileLocal()
return { mutated: true, refreshed: await refresh() }
}
export function fabBottomReserved(safeAreaBottom = 0, navigationHeight = 56, gap = 12) {
return navigationHeight + Math.max(0, safeAreaBottom) + gap
}
export function clampFabPosition(x: number, y: number, viewportWidth: number, viewportHeight: number, size = 56, margin = 14, bottomReserved = 84) {
return {
x: Math.min(Math.max(x, margin), Math.max(margin, viewportWidth - size - margin)),
y: Math.min(Math.max(y, margin), Math.max(margin, viewportHeight - size - bottomReserved)),
}
}
export function snapFabPosition(x: number, y: number, viewportWidth: number, viewportHeight: number, size = 56, margin = 14, bottomReserved = 84) {
const clamped = clampFabPosition(x, y, viewportWidth, viewportHeight, size, margin, bottomReserved)
const left = margin
const right = Math.max(margin, viewportWidth - size - margin)
const midpoint = viewportWidth / 2
return { x: clamped.x + size / 2 < midpoint ? left : right, y: clamped.y }
}
export function isFabDrag(deltaX: number, deltaY: number, threshold = 8) {
return Math.hypot(deltaX, deltaY) >= threshold
}
let habitGridCache: { week: string; habits: unknown[] } | null = null
let countdownCache: { items: unknown[]; archived: unknown[]; writtenAt: number } | null = null
let countdownInFlight: Promise<{ items: unknown[]; archived: unknown[] }> | null = null
let countdownCacheGeneration = 0
const requestGenerations = new Map<string, number>()
export function beginLatestRequest(key: string) {
const generation = (requestGenerations.get(key) ?? 0) + 1
requestGenerations.set(key, generation)
return generation
}
export function isLatestRequest(key: string, generation: number) {
return requestGenerations.get(key) === generation
}
export type RequestContext = { key: string; generation: number }
export function captureRequestContext(key: string): RequestContext {
return { key, generation: requestGenerations.get(key) ?? 0 }
}
export function commitIfRequestContextCurrent(context: RequestContext, commit: () => void) {
if (!isLatestRequest(context.key, context.generation)) return false
commit()
return true
}
export async function runLatestRequest<T>(
key: string,
request: () => Promise<T>,
callbacks: {
success: (value: T) => void
error: (reason: unknown) => void
finally: () => void
},
) {
const generation = beginLatestRequest(key)
let committed = false
try {
const value = await request()
if (isLatestRequest(key, generation)) {
callbacks.success(value)
committed = true
}
} catch (reason) {
if (isLatestRequest(key, generation)) callbacks.error(reason)
} finally {
if (isLatestRequest(key, generation)) callbacks.finally()
}
return committed
}
type MutationOwnership = { current: () => boolean }
type LatestMutationCallbacks<T> = {
success?: (value: T, ownership: MutationOwnership) => void | Promise<void>
error?: (reason: unknown, ownership: MutationOwnership) => void | Promise<void>
settled?: (result: { ok: true; value: T } | { ok: false; reason: unknown }, ownership: MutationOwnership) => void | Promise<void>
}
export function createKeyedLatestMutationQueue() {
type Entry = { generation: number; tail: Promise<void> }
const entries = new Map<string, Entry>()
return {
size: () => entries.size,
async run<T>(key: string, mutation: () => Promise<T>, callbacks: LatestMutationCallbacks<T> = {}) {
let entry = entries.get(key)
if (!entry) {
entry = { generation: 0, tail: Promise.resolve() }
entries.set(key, entry)
}
const generation = ++entry.generation
const ownership = { current: () => entries.get(key) === entry && entry.generation === generation }
const pending = entry.tail.catch(() => undefined).then(mutation)
const tail = pending.then(() => undefined, () => undefined)
entry.tail = tail
try {
const value = await pending
await callbacks.settled?.({ ok: true, value }, ownership)
if (!ownership.current()) return false
await callbacks.success?.(value, ownership)
return ownership.current()
} catch (reason) {
await callbacks.settled?.({ ok: false, reason }, ownership)
if (ownership.current()) await callbacks.error?.(reason, ownership)
return false
} finally {
if (entries.get(key) === entry && entry.generation === generation && entry.tail === tail) entries.delete(key)
}
},
}
}
export function createHabitMutationCoordinator<T>() {
type Entry = { generation: number; tail: Promise<void>; confirmed: T }
const entries = new Map<string, Entry>()
return {
size: () => entries.size,
async run(
key: string,
previous: T,
next: T,
mutation: () => Promise<unknown>,
callbacks: {
current?: () => boolean
reconcile?: (confirmed: T) => void | Promise<void>
success?: (ownership: MutationOwnership) => void | Promise<void>
error?: (rollback: T, reason: unknown, ownership: MutationOwnership) => void | Promise<void>
} = {},
) {
let entry = entries.get(key)
if (!entry) {
entry = { generation: 0, tail: Promise.resolve(), confirmed: previous }
entries.set(key, entry)
}
const generation = ++entry.generation
const ownership = {
current: () => entries.get(key) === entry && entry.generation === generation && callbacks.current?.() !== false,
}
const pending = entry.tail.catch(() => undefined).then(mutation)
const tail = pending.then(() => undefined, () => undefined)
entry.tail = tail
let result: { ok: true } | { ok: false; reason: unknown }
try {
await pending
result = { ok: true }
} catch (reason) {
result = { ok: false, reason }
}
try {
if (result.ok) {
entry.confirmed = next
await callbacks.reconcile?.(entry.confirmed)
if (!ownership.current()) return false
await callbacks.success?.(ownership)
return ownership.current()
}
await callbacks.reconcile?.(entry.confirmed)
if (ownership.current()) await callbacks.error?.(entry.confirmed, result.reason, ownership)
return false
} finally {
if (entries.get(key) === entry && entry.generation === generation && entry.tail === tail) entries.delete(key)
}
},
}
}
export function createTaskToggleCoordinator<T extends { completed: boolean; version: number }>() {
type Entry = { tail: Promise<void>; intendedCompleted: boolean; confirmed: T; generation: number }
const entries = new Map<string, Entry>()
return {
size: () => entries.size,
async toggle(
key: string,
initial: T,
mutation: (payload: { completed: boolean; version: number }) => Promise<T>,
callbacks: {
current?: () => boolean
beforeReconcile?: (value: T, ownership: MutationOwnership) => void | Promise<void>
reconcile?: (confirmed: T, ownership: MutationOwnership) => void | Promise<void>
success?: (value: T, ownership: MutationOwnership) => void | Promise<void>
error?: (confirmed: T, reason: unknown, ownership: MutationOwnership) => void | Promise<void>
} = {},
) {
let entry = entries.get(key)
if (!entry) {
entry = { tail: Promise.resolve(), intendedCompleted: initial.completed, confirmed: initial, generation: 0 }
entries.set(key, entry)
}
entry.intendedCompleted = !entry.intendedCompleted
const intendedCompleted = entry.intendedCompleted
const generation = ++entry.generation
const ownership = { current: () => entry!.generation === generation && callbacks.current?.() !== false }
const pending = entry.tail.then(async () => {
const value = await mutation({ completed: intendedCompleted, version: entry!.confirmed.version })
entry!.confirmed = value
return value
})
entry.tail = pending.then(() => undefined, () => undefined)
try {
let value: T
try {
value = await pending
} catch (reason) {
await callbacks.reconcile?.(entry.confirmed, ownership)
if (ownership.current()) await callbacks.error?.(entry.confirmed, reason, ownership)
return false
}
if (ownership.current()) await callbacks.beforeReconcile?.(value, ownership)
await callbacks.reconcile?.(value, ownership)
if (!ownership.current()) return false
await callbacks.success?.(value, ownership)
return ownership.current()
} finally {
if (entry.generation === generation && entries.get(key) === entry) entries.delete(key)
}
},
}
}
type CurrentViewReconciliationOptions<TContext> = {
capture: () => TContext
isTaskBacked: (context: TContext) => boolean
isTrash?: (context: TContext) => boolean
sameContext: (left: TContext, right: TContext) => boolean
loadTaskView: (context: TContext, ownership: MutationOwnership) => Promise<void>
loadTrash?: (ownership: MutationOwnership) => Promise<void>
affectsTrash?: boolean
affectsTaskView?: boolean
}
export async function reconcileCurrentTaskView<TContext>(options: CurrentViewReconciliationOptions<TContext>) {
const context = options.capture()
const ownership = { current: () => options.sameContext(context, options.capture()) }
if (options.affectsTaskView !== false && options.isTaskBacked(context)) {
await options.loadTaskView(context, ownership)
return ownership.current()
}
if (options.affectsTrash && options.isTrash?.(context) && options.loadTrash) {
await options.loadTrash(ownership)
return ownership.current()
}
return false
}
export function createTaskCompletionExitCoordinator(setExit: (key: string, active: boolean) => void) {
const tokens = new Map<string, object>()
const supersede = (key: string) => {
tokens.delete(key)
setExit(key, false)
}
const clearAll = () => {
const keys = [...tokens.keys()]
tokens.clear()
keys.forEach((key) => setExit(key, false))
}
const begin = (key: string, animate: boolean) => {
tokens.delete(key)
if (!animate) {
setExit(key, false)
return null
}
const token = {}
tokens.set(key, token)
setExit(key, true)
return token
}
const wait = async (key: string, token: object | null, waitForExit: () => Promise<void>) => {
if (!token) return
try {
await waitForExit()
} finally {
if (tokens.get(key) === token) {
tokens.delete(key)
setExit(key, false)
}
}
}
return {
size: () => tokens.size,
supersede,
clearAll,
begin,
wait,
async run(key: string, animate: boolean, waitForExit: () => Promise<void>) {
const token = begin(key, animate)
await wait(key, token, waitForExit)
},
}
}
type TaskToggleState = {
id: string
list_id: string
parent_id: string | null
title: string
description: string
priority: number
completed: boolean
completed_at: string | null
version: number
due_at: string | null
due_has_time: boolean
subtasks?: TaskToggleState[]
}
export function taskVersionedPatchPayload<T extends { version: number }, P extends object>(task: T, patch: P): P & { version: number } {
return { ...patch, version: task.version }
}
export function mergeTaskToggleResponse<T extends TaskToggleState>(draft: T, response: TaskToggleState): T {
return {
...response,
list_id: draft.list_id,
parent_id: draft.parent_id,
title: draft.title,
description: draft.description,
priority: draft.priority,
due_at: draft.due_at,
due_has_time: draft.due_has_time,
subtasks: draft.subtasks,
} as T
}
type MutationSuccessCallback<T> = (value: T) => void | Promise<void>
type MutationReconciliationOptions = { affectsTrash?: boolean; affectsTaskView?: boolean }
export type MutationReconciler<TContext> = {
run<T>(
mutation: () => Promise<T>,
onSuccess: (value: T) => void,
onError?: (reason: unknown) => void,
onCurrentSuccess?: MutationSuccessCallback<T>,
reconciliation?: MutationReconciliationOptions,
): Promise<boolean>
}
export function createMutationReconciler<TContext>(
currentContext: () => TContext,
sameContext: (left: TContext, right: TContext) => boolean,
reconcileCurrentView: (options?: MutationReconciliationOptions) => Promise<unknown>,
): MutationReconciler<TContext> {
let dirty = 0
let reconciled = 0
let refreshLoop: Promise<void> | null = null
const reconcile = (context: TContext, options?: MutationReconciliationOptions) => {
if (!sameContext(context, currentContext())) return Promise.resolve()
dirty += 1
if (!refreshLoop) {
refreshLoop = (async () => {
while (reconciled < dirty && sameContext(context, currentContext())) {
const target = dirty
await reconcileCurrentView(options)
if (!sameContext(context, currentContext())) break
reconciled = target
}
})().finally(() => { refreshLoop = null })
}
return refreshLoop
}
return {
async run(mutation, onSuccess, onError, onCurrentSuccess, reconciliation) {
const context = currentContext()
let value: Awaited<ReturnType<typeof mutation>>
try {
value = await mutation()
} catch (reason) {
if (sameContext(context, currentContext())) onError?.(reason)
return false
}
if (sameContext(context, currentContext())) {
onSuccess(value)
const currentSuccess = onCurrentSuccess?.(value)
if (currentSuccess instanceof Promise) await currentSuccess
} else {
await reconcileCurrentView(reconciliation)
return true
}
await reconcile(context, reconciliation)
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context, reconciliation)
return true
},
}
}
export function startPrimaryWithBackground<T>(
primary: Array<() => Promise<T>>,
background: () => Promise<unknown>,
) {
const pending = primary.map((start) => start())
void background().catch(() => undefined)
return Promise.all(pending)
}
export function readHabitGridCache<T>(week: string): T[] | null {
return habitGridCache?.week === week ? habitGridCache.habits as T[] : null
}
export function writeHabitGridCache<T>(week: string, habits: T[]) {
habitGridCache = { week, habits }
}
export function invalidateHabitGridCache() {
habitGridCache = null
}
export async function performHabitRestore(actions: {
restore: () => Promise<unknown>
commitRestore: () => void
refreshGrid: () => Promise<boolean | unknown>
}) {
await actions.restore()
actions.commitRestore()
try {
const refreshed = await actions.refreshGrid()
return { restored: true as const, refreshed: refreshed !== false }
} catch {
return { restored: true as const, refreshed: false }
}
}
export function readCountdownCache<T>() {
if (!countdownCache) return null
return { items: countdownCache.items as T[], archived: countdownCache.archived as T[] }
}
export function writeCountdownCache<T>(items: T[], archived: T[], now = Date.now()) {
countdownCache = { items, archived, writtenAt: now }
}
export function invalidateCountdownCache() {
countdownCacheGeneration += 1
countdownCache = null
countdownInFlight = null
}
export function getCountdownCacheGeneration() {
return countdownCacheGeneration
}
export function isCountdownCacheGenerationCurrent(generation: number) {
return generation === countdownCacheGeneration
}
export function loadCountdownCache<T>(
fetcher: () => Promise<{ items: T[]; archived: T[] }>,
options: { now?: number; maxAge?: number; force?: boolean } = {},
) {
const now = options.now ?? Date.now()
const maxAge = options.maxAge ?? 30_000
if (!options.force && countdownCache && now - countdownCache.writtenAt < maxAge) {
return Promise.resolve(readCountdownCache<T>()!)
}
if (countdownInFlight) return countdownInFlight as Promise<{ items: T[]; archived: T[] }>
const generation = countdownCacheGeneration
const pending = fetcher().then((data) => {
if (generation === countdownCacheGeneration) writeCountdownCache(data.items, data.archived, now)
return data
}).finally(() => {
if (countdownInFlight === pending) countdownInFlight = null
})
countdownInFlight = pending as Promise<{ items: unknown[]; archived: unknown[] }>
return pending
}
export type HabitFormValues = {
name: string
kind: 'boolean' | 'numeric'
target: number
max_value?: number | null
schedule_type: 'daily' | 'weekly' | 'monthly' | 'interval'
weekdays?: number[] | null
month_days?: number[] | null
interval_days?: number | null
}
export type HabitFormErrors = Partial<Record<keyof HabitFormValues, string>>
export function normalizeRequiredName(input: string) {
const value = input.trim()
return { value, error: value ? '' : '名称不能为空,请输入至少一个可见字符。' }
}
export function validateHabitForm(form: HabitFormValues): HabitFormErrors {
const errors: HabitFormErrors = {}
if (!form.name.trim()) errors.name = '请输入习惯名称'
if (form.kind === 'numeric') {
if (!Number.isFinite(Number(form.target)) || Number(form.target) <= 0) errors.target = '目标值必须大于 0'
if (form.max_value != null && (!Number.isFinite(Number(form.max_value)) || Number(form.max_value) <= 0 || Number(form.max_value) < Number(form.target))) errors.max_value = '最大值不能小于目标值'
}
if (form.schedule_type === 'weekly') {
const values = form.weekdays ?? []
if (!values.length) errors.weekdays = '至少选择一个星期'
else if (new Set(values).size !== values.length) errors.weekdays = '星期不能重复'
}
if (form.schedule_type === 'monthly') {
const values = form.month_days ?? []
if (!values.length || values.some((day) => !Number.isInteger(day) || day < 1 || day > 31)) errors.month_days = '请输入 1 到 31 的日期'
else if (new Set(values).size !== values.length) errors.month_days = '日期不能重复'
}
if (form.schedule_type === 'interval' && (!Number.isInteger(Number(form.interval_days)) || Number(form.interval_days) < 1)) errors.interval_days = '间隔天数至少为 1'
return errors
}
export function changedHabitFields(before: HabitFormValues, after: HabitFormValues) {
const result: Partial<HabitFormValues> = {}
for (const key of Object.keys(after) as Array<keyof HabitFormValues>) {
const oldValue = before[key]
const newValue = after[key]
if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) (result as Record<string, unknown>)[key] = newValue
}
return result
}
export function habitActionState(cell?: { scheduled?: boolean; paused?: boolean }, archived = false) {
if (archived) return { writable: false, reason: '该习惯已归档' }
if (cell?.paused) return { writable: false, reason: '今天已暂停' }
if (!cell?.scheduled) return { writable: false, reason: '今天未安排' }
return { writable: true, reason: '' }
}
export type ArchivePanelState = 'idle' | 'loading' | 'success' | 'error'
export function archivePanelFlags(state: ArchivePanelState, count: number) {
return {
loading: state === 'loading',
error: state === 'error',
empty: state === 'success' && count === 0,
list: state === 'success' && count > 0,
}
}
export function formatArchivedAt(value?: string | null, options: { timeZone?: string } = {}) {
const formatted = formatLocalShortDateTime(value, options)
return formatted === '未知时间' ? '归档时间未知' : `归档于 ${formatted}`
}
export function formatUserAgent(userAgent?: string | null): string {
const ua = userAgent?.trim() ?? ''
let device = '未知设备'
if (/iPhone/i.test(ua)) device = 'iPhone'
else if (/iPad/i.test(ua) || (/Macintosh/i.test(ua) && /Mobile/i.test(ua))) device = 'iPad'
else if (/Android/i.test(ua) && /Mobile/i.test(ua)) device = 'Android 手机'
else if (/Android/i.test(ua)) device = 'Android 平板'
else if (/Windows/i.test(ua)) device = 'Windows'
else if (/Macintosh|Mac OS X/i.test(ua)) device = 'Mac'
else if (/Linux/i.test(ua)) device = 'Linux'
let browser = '未知浏览器'
if (/Edg(?:e|iOS|A)?\//i.test(ua)) browser = 'Edge'
else if (/Chrome\/|CriOS\//i.test(ua)) browser = 'Chrome'
else if (/Firefox\/|FxiOS\//i.test(ua)) browser = 'Firefox'
else if (/Safari\//i.test(ua) && /Version\//i.test(ua)) browser = 'Safari'
return `${device} · ${browser}`
}
export function formatLocalShortDateTime(value?: string | null, options: { timeZone?: string } = {}): string {
const source = value?.trim()
if (!source) return '未知时间'
const calendar = /^(\d{4})-(\d{2})-(\d{2})T/.exec(source)
if (calendar) {
const [, year, month, day] = calendar
const probe = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)))
if (probe.getUTCFullYear() !== Number(year) || probe.getUTCMonth() + 1 !== Number(month) || probe.getUTCDate() !== Number(day)) return '未知时间'
}
const timezoneLess = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/i.test(source)
const date = new Date(timezoneLess ? `${source}Z` : source)
if (Number.isNaN(date.getTime())) return '未知时间'
try {
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'short', timeStyle: 'short', ...options }).format(date)
} catch {
return '未知时间'
}
}
const AUDIT_ACTIONS: Record<string, string> = {
create: '创建', update: '更新', complete: '完成', delete: '删除', archive: '归档', restore: '恢复', move: '移动', import: '导入',
}
const AUDIT_ENTITIES: Record<string, string> = {
task: '任务', list: '清单', folder: '文件夹', countdown: '倒数日', backup: '备份',
}
export function formatAuditAction(action?: string | null): string {
return action ? AUDIT_ACTIONS[action.toLowerCase()] ?? '其他操作' : '其他操作'
}
export function formatAuditEntity(entityType?: string | null): string {
return entityType ? AUDIT_ENTITIES[entityType.toLowerCase()] ?? '内容' : '内容'
}
export function formatHabitApiError(detail: unknown): string {
const source = detail && typeof detail === 'object' && !Array.isArray(detail) && 'detail' in detail
? (detail as Record<string, unknown>).detail
: detail
const text = typeof source === 'string' ? source : ''
const mappings: Array<[string, string]> = [
['暂停日不可记录正向进度', '今天已暂停,不能记录进度。'],
['非计划日不可记录正向进度', '今天未安排该习惯,不能记录进度。'],
['归档习惯不可修改', '该习惯已归档,不能继续操作。'],
['请先归档再永久删除', '请先归档该习惯,再永久删除。'],
]
for (const [needle, message] of mappings) if (text.includes(needle)) return message
if (Array.isArray(source)) {
const field = source.map((item) => item && typeof item === 'object' && Array.isArray((item as Record<string, unknown>).loc) ? ((item as Record<string, unknown>).loc as unknown[]).at(-1) : '').find(Boolean)
const fieldMessages: Record<string, string> = {
name: '名称不能为空,请输入至少一个可见字符。', weekdays: '每周计划至少选择一天,且不能重复。', month_days: '每月日期必须是 1–31,且不能重复。', interval_days: '间隔天数至少为 1。', target: '目标值必须大于 0。', max_value: '最大值必须大于 0。',
}
if (typeof field === 'string' && fieldMessages[field]) return fieldMessages[field]
}
return formatApiErrorDetail(source)
}
export function formatApiErrorDetail(detail: unknown): string {
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const messages = detail.map((item) => {
if (item && typeof item === 'object') {
const record = item as Record<string, unknown>
const location = Array.isArray(record.loc)
? record.loc.filter((part) => part !== 'body').join('.')
: ''
const message = typeof record.msg === 'string' ? record.msg : JSON.stringify(record)
return location ? `${location}${message}` : message
}
return String(item)
})
return messages.filter(Boolean).join('') || '请求参数有误'
}
if (detail && typeof detail === 'object') {
const record = detail as Record<string, unknown>
if ('detail' in record) return formatApiErrorDetail(record.detail)
return JSON.stringify(record)
}
return '请求失败'
}