Files
dodo/frontend/src/lib/mvp-utils.ts
T
bboysoul 9ec9d60633
ci / gitleaks (push) Successful in 6s
ci / docker (push) Successful in 3m27s
fix: align floating button safe bounds
2026-09-11 17:18:25 +08:00

546 lines
21 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' | 'settings'
type StoredNavigation = { view: NavigationView; listId: string }
const NAVIGATION_VIEWS = new Set<NavigationView>(['tasks', 'today', 'upcoming', 'trash', 'habits', 'countdowns', '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)
}
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 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 MutationSuccessCallback<T> = (value: T) => void | Promise<void>
export type MutationReconciler<TContext> = {
run<T>(
mutation: () => Promise<T>,
onSuccess: (value: T) => void,
onError?: (reason: unknown) => void,
onCurrentSuccess?: MutationSuccessCallback<T>,
): Promise<boolean>
}
export function createMutationReconciler<TContext>(
currentContext: () => TContext,
sameContext: (left: TContext, right: TContext) => boolean,
refresh: () => Promise<unknown>,
): MutationReconciler<TContext> {
let dirty = 0
let reconciled = 0
let refreshLoop: Promise<void> | null = null
const reconcile = (context: TContext) => {
if (!sameContext(context, currentContext())) return Promise.resolve()
dirty += 1
if (!refreshLoop) {
refreshLoop = (async () => {
while (reconciled < dirty && sameContext(context, currentContext())) {
const target = dirty
await refresh()
if (!sameContext(context, currentContext())) break
reconciled = target
}
})().finally(() => { refreshLoop = null })
}
return refreshLoop
}
return {
async run(mutation, onSuccess, onError, onCurrentSuccess) {
const context = currentContext()
let value: Awaited<ReturnType<typeof mutation>>
try {
value = await mutation()
} catch (reason) {
onError?.(reason)
return false
}
onSuccess(value)
if (sameContext(context, currentContext())) {
const currentSuccess = onCurrentSuccess?.(value)
if (currentSuccess instanceof Promise) await currentSuccess
}
await reconcile(context)
if (sameContext(context, currentContext()) && reconciled < dirty) await reconcile(context)
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 '请求失败'
}