feat: show habit history in details
ci / gitleaks (push) Successful in 53s
ci / docker (push) Successful in 4m59s

This commit is contained in:
2026-09-15 16:32:53 +08:00
parent a6c12ee01f
commit e857506bf6
5 changed files with 159 additions and 9 deletions
+100 -5
View File
@@ -2,13 +2,14 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Activity, ArchiveRestore, Check, ChevronRight, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next' import { Activity, ArchiveRestore, Check, ChevronRight, Download, FileJson, GripVertical, LogOut, Pencil, Trash2, X } from 'lucide-vue-next'
import { mergeReorderedSubset, moveItemWithinScope } from './lib/task-utils' import { mergeReorderedSubset, moveItemWithinScope } from './lib/task-utils'
import { archivePanelFlags, changedHabitFields, dateKey, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues } from './lib/mvp-utils' import { archivePanelFlags, changedHabitFields, dateKey, dayBefore, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, invalidateHabitGridCache, isHabitComplete, isHabitScheduledToday, mergeHabitHistory, mergePage, nextHabitSwipeValue, performHabitRestore, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, validateHabitForm, writeHabitGridCache, type ArchivePanelState, type HabitFormErrors, type HabitFormValues, type HabitHistoryLog } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf' import { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
type View = 'habits' | 'today-habits' | 'settings' type View = 'habits' | 'today-habits' | 'settings'
type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean } type HabitCell = { day: string; scheduled?: boolean; paused?: boolean; value: number | boolean }
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; schedule_type?: HabitFormValues['schedule_type']; weekdays?: number[] | null; month_days?: number[] | null; interval_days?: number | null; archived_at?: string | null; unit?: string; cells?: HabitCell[]; stats?: Record<string, number> } type HabitLog = HabitHistoryLog
type Habit = { id: string; name: string; kind?: string; target?: number; max_value?: number | null; schedule_type?: HabitFormValues['schedule_type']; weekdays?: number[] | null; month_days?: number[] | null; interval_days?: number | null; start_date?: string; archived_at?: string | null; position?: number; unit?: string; cells?: HabitCell[]; stats?: Record<string, number> }
type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string } type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string }
const props = defineProps<{ view: View; showCompleted: boolean }>() const props = defineProps<{ view: View; showCompleted: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -42,6 +43,13 @@ const habitComposerOpen = ref(false)
const editingHabit = ref<Habit | null>(null) const editingHabit = ref<Habit | null>(null)
const originalHabitForm = ref<HabitFormValues | null>(null) const originalHabitForm = ref<HabitFormValues | null>(null)
const selectedHabit = ref<Habit | null>(null) const selectedHabit = ref<Habit | null>(null)
const habitHistory = ref<HabitLog[]>([])
const habitHistoryLoading = ref(false)
const habitHistoryLoadingMore = ref(false)
const habitHistoryError = ref('')
const habitHistoryNextTo = ref('')
const habitHistoryHasMore = ref(false)
let habitHistoryRequest = 0
const habitDetailClickSuppressed = ref(false) const habitDetailClickSuppressed = ref(false)
const habitDetailSheet = ref<HTMLElement | null>(null) const habitDetailSheet = ref<HTMLElement | null>(null)
let habitDetailOpener: HTMLElement | null = null let habitDetailOpener: HTMLElement | null = null
@@ -221,12 +229,18 @@ function habitCellLabel(h: Habit, cell: NonNullable<Habit['cells']>[number]) {
} }
function setLocalHabitValue(h: Habit, next: number | boolean) { function setLocalHabitValue(h: Habit, next: number | boolean) {
habits.value = habits.value.map((item) => item.id !== h.id const update = (item: Habit) => item.id !== h.id
? item ? item
: { : {
...item, ...item,
cells: (item.cells ?? []).map((cell) => cell.day === todayKey.value ? { ...cell, value: next } : cell), cells: (item.cells ?? []).map((cell) => cell.day === todayKey.value ? { ...cell, value: next } : cell),
}) }
habits.value = habits.value.map(update)
if (selectedHabit.value?.id === h.id) selectedHabit.value = update(selectedHabit.value)
}
function syncHabitHistoryToday(h: Habit) {
if (selectedHabit.value?.id !== h.id) return
void loadHabitHistory(true)
} }
async function applyHabitSwipe(h: Habit, deltaX: number) { async function applyHabitSwipe(h: Habit, deltaX: number) {
@@ -241,6 +255,7 @@ async function applyHabitSwipe(h: Habit, deltaX: number) {
if (!animateExit) setLocalHabitValue(h, next) if (!animateExit) setLocalHabitValue(h, next)
try { try {
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) }) await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
syncHabitHistoryToday(h)
if (animateExit) { if (animateExit) {
setHabitCompletionExiting(h.id, true) setHabitCompletionExiting(h.id, true)
setLocalHabitValue(h, next) setLocalHabitValue(h, next)
@@ -310,6 +325,7 @@ async function toggleHabitFromButton(h: Habit) {
setLocalHabitValue(h, next) setLocalHabitValue(h, next)
try { try {
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) }) await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
syncHabitHistoryToday(h)
await finishHabitCompletion(h, wasDone, next, animateExit) await finishHabitCompletion(h, wasDone, next, animateExit)
emit('notice', habitButtonNotice(h.kind, previous, next)) emit('notice', habitButtonNotice(h.kind, previous, next))
} catch (e) { } catch (e) {
@@ -406,14 +422,75 @@ function editHabit(h: Habit) {
void nextTick(() => habitNameInput.value?.focus()) void nextTick(() => habitNameInput.value?.focus())
} }
function closeHabitComposer() { habitComposerOpen.value = false; editingHabit.value = null; originalHabitForm.value = null } function closeHabitComposer() { habitComposerOpen.value = false; editingHabit.value = null; originalHabitForm.value = null }
function formatHabitHistoryDay(day: string) {
const [year, month, date] = day.split('-').map(Number)
const value = new Date(year, month - 1, date)
if (day === todayKey.value) return `今天 · ${month}${date}`
if (day === dayBefore(todayKey.value)) return `昨天 · ${month}${date}`
const sameYear = year === Number(todayKey.value.slice(0, 4))
const weekday = new Intl.DateTimeFormat('zh-CN', { weekday: 'short' }).format(value)
return sameYear ? `${month}${date}日 · ${weekday}` : `${year}${month}${date}`
}
function habitHistoryValue(log: HabitLog) {
if (selectedHabit.value?.kind !== 'numeric') return log.value > 0 ? '已完成' : '未完成'
const unit = selectedHabit.value.unit ? ` ${selectedHabit.value.unit}` : ''
return `${formatHabitHistoryNumber(log.value)} / ${formatHabitHistoryNumber(selectedHabit.value.target ?? 1)}${unit}`
}
function habitHistoryStatus(log: HabitLog) {
if (selectedHabit.value?.kind !== 'numeric') return log.value > 0 ? '已完成' : '未完成'
if (log.value >= (selectedHabit.value.target ?? 1)) return '已达标'
return log.value > 0 ? '进行中' : '未完成'
}
async function loadHabitHistory(reset = false) {
const habit = selectedHabit.value
if (!habit || (!reset && (habitHistoryLoading.value || habitHistoryLoadingMore.value))) return
const requestId = reset ? ++habitHistoryRequest : habitHistoryRequest
const to = reset ? todayKey.value : habitHistoryNextTo.value
if (!to) return
const window = habitHistoryWindow(to)
if (reset) {
habitHistory.value = []
habitHistoryNextTo.value = ''
habitHistoryHasMore.value = false
habitHistoryLoading.value = true
habitHistoryLoadingMore.value = false
} else {
habitHistoryLoadingMore.value = true
}
habitHistoryError.value = ''
try {
const rows = await request(`/habits/${habit.id}/logs?from=${window.from}&to=${window.to}`) as HabitLog[]
if (requestId !== habitHistoryRequest || selectedHabit.value?.id !== habit.id) return
habitHistory.value = mergeHabitHistory(reset ? [] : habitHistory.value, rows)
const nextTo = dayBefore(window.from)
habitHistoryNextTo.value = nextTo
habitHistoryHasMore.value = !habit.start_date || nextTo >= habit.start_date
} catch (reason) {
if (requestId !== habitHistoryRequest || selectedHabit.value?.id !== habit.id) return
habitHistoryError.value = reason instanceof Error ? reason.message : '历史记录加载失败'
} finally {
if (requestId === habitHistoryRequest && selectedHabit.value?.id === habit.id) {
habitHistoryLoading.value = false
habitHistoryLoadingMore.value = false
}
}
}
function openHabitDetail(h: Habit, opener?: HTMLElement | null) { function openHabitDetail(h: Habit, opener?: HTMLElement | null) {
if (habitDetailClickSuppressed.value) return if (habitDetailClickSuppressed.value) return
habitDetailOpener = opener ?? document.activeElement as HTMLElement | null habitDetailOpener = opener ?? document.activeElement as HTMLElement | null
selectedHabit.value = h selectedHabit.value = h
void loadHabitHistory(true)
void nextTick(() => habitDetailSheet.value?.focus()) void nextTick(() => habitDetailSheet.value?.focus())
} }
function closeHabitDetail() { function closeHabitDetail() {
habitHistoryRequest += 1
selectedHabit.value = null selectedHabit.value = null
habitHistory.value = []
habitHistoryNextTo.value = ''
habitHistoryHasMore.value = false
habitHistoryError.value = ''
habitHistoryLoading.value = false
habitHistoryLoadingMore.value = false
void nextTick(() => { void nextTick(() => {
if (habitDetailOpener?.isConnected) habitDetailOpener.focus() if (habitDetailOpener?.isConnected) habitDetailOpener.focus()
else habitArchiveToggle.value?.focus() else habitArchiveToggle.value?.focus()
@@ -663,7 +740,25 @@ onBeforeUnmount(() => {
<div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail"> <div v-if="selectedHabit" class="habit-detail-mask app-sheet-mask" @click.self="closeHabitDetail">
<article ref="habitDetailSheet" class="habit-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail"> <article ref="habitDetailSheet" class="habit-detail-sheet app-sheet app-sheet--detail" role="dialog" aria-modal="true" aria-labelledby="habit-detail-title" tabindex="-1" @keydown.esc="closeHabitDetail">
<header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header> <header class="app-sheet__header"><div><small>习惯详情</small><h3 id="habit-detail-title">{{ selectedHabit.name }}</h3></div><button type="button" aria-label="关闭习惯详情" @click="closeHabitDetail"><X/></button></header>
<div class="app-sheet__body"><div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div></div> <div class="app-sheet__body">
<div class="habit-detail-progress"><span>今日进度</span><strong>{{ selectedHabit.archived_at ? '已归档' : habitProgressText(selectedHabit) || (isDone(selectedHabit, todayKey) ? '已完成' : '未完成') }}</strong></div>
<section class="habit-history" aria-labelledby="habit-history-title" :aria-busy="habitHistoryLoading">
<header class="habit-history__header"><h4 id="habit-history-title">历史记录</h4><small v-if="habitHistory.length">{{ habitHistory.length }} </small></header>
<div v-if="habitHistoryLoading" class="habit-history__loading" role="status"><span v-for="index in 3" :key="index"></span><b class="sr-only">正在加载历史记录</b></div>
<div v-else-if="habitHistoryError && !habitHistory.length" class="habit-history__message" role="alert"><span>历史记录加载失败:{{ habitHistoryError }}</span><button type="button" class="soft-button" @click="loadHabitHistory(true)">重试</button></div>
<ol v-else-if="habitHistory.length" class="habit-history__list">
<li v-for="log in habitHistory" :key="log.day" class="habit-history__row">
<time :datetime="log.day">{{ formatHabitHistoryDay(log.day) }}</time>
<span class="habit-history__value">{{ habitHistoryValue(log) }}</span>
<small :class="{ success: habitHistoryStatus(log) === '已完成' || habitHistoryStatus(log) === '已达标' }">{{ habitHistoryStatus(log) }}</small>
</li>
</ol>
<p v-else class="habit-history__empty"> 90 天暂无记录</p>
<div v-if="habitHistoryError && habitHistory.length" class="habit-history__message" role="alert"><span>更早记录加载失败{{ habitHistoryError }}</span></div>
<button v-if="!habitHistoryLoading && habitHistoryHasMore" type="button" class="soft-button habit-history__more" :disabled="habitHistoryLoadingMore" @click="loadHabitHistory(false)">{{ habitHistoryLoadingMore ? '正在加载' : habitHistoryError && habitHistory.length ? '重试加载更早记录' : '加载更早记录' }}</button>
<small v-else-if="habitHistory.length && !habitHistoryLoading" class="habit-history__end">没有更早记录了</small>
</section>
</div>
<footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer> <footer v-if="!selectedHabit.archived_at" class="app-sheet__footer"><button type="button" class="soft-button" @click="editHabit(selectedHabit)"><Pencil/>编辑习惯</button><button type="button" class="soft-button" @click="archiveHabit(selectedHabit)"><ArchiveRestore/>归档习惯</button></footer>
<footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="soft-button habit-restore-button" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer> <footer v-if="selectedHabit.archived_at" class="app-sheet__danger"><button type="button" class="soft-button habit-restore-button" @click="restoreHabit(selectedHabit)"><ArchiveRestore/>恢复习惯</button><button type="button" class="danger-text habit-delete-button" @click="deleteHabit(selectedHabit)"><Trash2/>永久删除</button></footer>
</article> </article>
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, snapFabPosition, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils' import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, dayBefore, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatHabitHistoryNumber, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitHistoryWindow, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, mergeHabitHistory, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, snapFabPosition, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
describe('MVP view utilities', () => { describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => { it('formats a local date as YYYY-MM-DD', () => {
@@ -71,6 +71,25 @@ describe('MVP view utilities', () => {
expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' }) expect(readStoredNavigation(fakeStorage, 'dodo.navigation')).toEqual({ view: 'today', listId: '' })
}) })
it('builds non-overlapping 90-day habit history windows', () => {
expect(habitHistoryWindow('2026-09-15')).toEqual({ from: '2026-06-18', to: '2026-09-15' })
expect(dayBefore('2026-06-18')).toBe('2026-06-17')
expect(habitHistoryWindow('2026-01-01', 2)).toEqual({ from: '2025-12-31', to: '2026-01-01' })
})
it('merges habit history by day, coerces API values, and keeps newest first', () => {
expect(mergeHabitHistory(
[{ day: '2026-09-14', value: 1 }, { day: '2026-09-13', value: 2 }],
[{ day: '2026-09-15', value: 3 }, { day: '2026-09-14', value: 4 }],
)).toEqual([
{ day: '2026-09-15', value: 3 },
{ day: '2026-09-14', value: 4 },
{ day: '2026-09-13', value: 2 },
])
expect(formatHabitHistoryNumber(8)).toBe('8')
expect(formatHabitHistoryNumber(1.25)).toBe('1.25')
})
it('only completes numeric habits when progress reaches the target', () => { it('only completes numeric habits when progress reaches the target', () => {
expect(isHabitComplete('numeric', 3, 5)).toBe(false) expect(isHabitComplete('numeric', 3, 5)).toBe(false)
expect(isHabitComplete('numeric', 5, 5)).toBe(true) expect(isHabitComplete('numeric', 5, 5)).toBe(true)
+27
View File
@@ -80,6 +80,33 @@ export function isHabitComplete(kind: string | undefined, value: number | boolea
return Boolean(value) 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 HabitSchedule = { type HabitSchedule = {
kind?: string kind?: string
cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value?: number | boolean }> cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value?: number | boolean }>
+2 -2
View File
@@ -29,13 +29,13 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto
.habit-row .icon.ghost:hover{color:var(--danger)} .habit-row .icon.ghost:hover{color:var(--danger)}
.numeric-habit>span:first-child{display:grid;gap:3px}.numeric-habit small{font-size:11px;color:var(--muted)} .numeric-habit>span:first-child{display:grid;gap:3px}.numeric-habit small{font-size:11px;color:var(--muted)}
.numeric-action{display:flex;gap:6px;align-items:center}.numeric-action input{width:74px;border:1px solid var(--line);border-radius:8px;padding:7px;background:#fff}.numeric-action .soft-button{min-height:44px;padding:9px 12px} .numeric-action{display:flex;gap:6px;align-items:center}.numeric-action input{width:74px;border:1px solid var(--line);border-radius:8px;padding:7px;background:#fff}.numeric-action .soft-button{min-height:44px;padding:9px 12px}
.habit-row>.icon.ghost{width:44px;height:44px;flex:0 0 44px}.habit-check{margin-left:-4px}.habit-row.done .habit-check .task-check-mark{background:#71856b;border-color:#71856b;color:#fff}.habit-detail-mask{position:fixed;z-index:85;inset:0;background:rgba(45,38,31,.36);display:grid;place-items:end center;padding:20px}.habit-detail-sheet{width:min(500px,100%);background:#fffdf8;border:1px solid var(--line);border-radius:20px;box-shadow:0 12px 36px rgba(56,40,24,.18);padding:18px;display:grid;gap:16px}.habit-detail-sheet header{display:flex;align-items:center;justify-content:space-between;gap:10px}.habit-detail-sheet header small{color:var(--muted);font-size:11px}.habit-detail-sheet header h3{margin:2px 0 0;font-size:19px}.habit-detail-sheet header button{width:44px;height:44px;border:0;background:transparent;display:grid;place-items:center}.habit-detail-progress{display:flex;justify-content:space-between;align-items:center;padding:14px 0;border-block:1px solid var(--line);font-size:12px;color:var(--muted)}.habit-detail-progress strong{font-size:14px;color:#3c372f}.habit-detail-sheet footer{display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap}.habit-detail-sheet .habit-delete-button{font-weight:750} .habit-row>.icon.ghost{width:44px;height:44px;flex:0 0 44px}.habit-check{margin-left:-4px}.habit-row.done .habit-check .task-check-mark{background:#71856b;border-color:#71856b;color:#fff}.habit-detail-mask{position:fixed;z-index:85;inset:0;background:rgba(45,38,31,.36);display:grid;place-items:end center;padding:20px}.habit-detail-sheet{width:min(500px,100%);max-height:min(88dvh,760px);overflow:hidden;background:#fffdf8;border:1px solid var(--line);border-radius:20px;box-shadow:0 12px 36px rgba(56,40,24,.18);padding:18px;display:grid;gap:16px}.habit-detail-sheet header{display:flex;align-items:center;justify-content:space-between;gap:10px}.habit-detail-sheet header small{color:var(--muted);font-size:11px}.habit-detail-sheet header h3{margin:2px 0 0;font-size:19px}.habit-detail-sheet header button{width:44px;height:44px;border:0;background:transparent;display:grid;place-items:center}.habit-detail-progress{display:flex;justify-content:space-between;align-items:center;padding:14px 0;border-block:1px solid var(--line);font-size:12px;color:var(--muted)}.habit-detail-progress strong{font-size:14px;color:#3c372f}.habit-history{display:grid;gap:10px;min-width:0}.habit-history__header{display:flex!important;align-items:center;justify-content:space-between;gap:8px}.habit-history__header h4{margin:0;font-size:14px}.habit-history__header small,.habit-history__end{color:var(--muted);font-size:11px}.habit-history__list{list-style:none;margin:0;padding:0;border-block:1px solid var(--border-cream)}.habit-history__row{min-height:48px;display:grid;grid-template-columns:minmax(116px,1fr) minmax(84px,auto) 56px;align-items:center;gap:10px;border-bottom:1px solid var(--border-cream);font-size:12px}.habit-history__row:last-child{border-bottom:0}.habit-history__row time{color:var(--muted)}.habit-history__value{text-align:right;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.habit-history__row small{text-align:right;color:var(--muted)}.habit-history__row small.success{color:var(--success);font-weight:700}.habit-history__loading{display:grid;gap:8px}.habit-history__loading span{height:48px;border-radius:8px;background:#f3ece2}.habit-history__message,.habit-history__empty{min-height:64px;margin:0;display:flex;align-items:center;justify-content:space-between;gap:10px;color:var(--muted);font-size:12px}.habit-history__message .soft-button,.habit-history__more{min-height:44px}.habit-history__more{justify-self:center}.habit-history__end{text-align:center}.habit-detail-sheet footer{display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap}.habit-detail-sheet .habit-delete-button{font-weight:750}
.empty-panel{text-align:center;color:var(--muted);display:grid;place-items:center;gap:10px}.today-empty-panel{min-height:130px}.empty-action{margin-top:4px;color:#655d52}.empty-action svg{width:15px;height:15px} .empty-panel{text-align:center;color:var(--muted);display:grid;place-items:center;gap:10px}.today-empty-panel{min-height:130px}.empty-action{margin-top:4px;color:#655d52}.empty-action svg{width:15px;height:15px}
.field-error{display:block;color:var(--danger);font-size:12px;line-height:1.45;overflow-wrap:anywhere}.habit-state-note{color:var(--muted);font-size:11px}.habit-weekdays{min-width:0;border:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}.habit-weekdays legend{width:100%;font-size:12px;font-weight:650}.habit-weekdays label{min-height:44px;display:flex;align-items:center;gap:4px}.habit-compose-sheet input,.habit-compose-sheet select{max-width:100%}.task-check:disabled{cursor:not-allowed;opacity:.55} .field-error{display:block;color:var(--danger);font-size:12px;line-height:1.45;overflow-wrap:anywhere}.habit-state-note{color:var(--muted);font-size:11px}.habit-weekdays{min-width:0;border:0;padding:0;display:flex;flex-wrap:wrap;gap:8px}.habit-weekdays legend{width:100%;font-size:12px;font-weight:650}.habit-weekdays label{min-height:44px;display:flex;align-items:center;gap:4px}.habit-compose-sheet input,.habit-compose-sheet select{max-width:100%}.task-check:disabled{cursor:not-allowed;opacity:.55}
.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}.session-card-actions{width:100%;display:flex;align-items:center;justify-content:space-between;gap:12px}.session-card-actions p{min-width:0}.session-revoke-all{min-height:44px;flex:0 0 auto}.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} .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}.session-card-actions{width:100%;display:flex;align-items:center;justify-content:space-between;gap:12px}.session-card-actions p{min-width:0}.session-revoke-all{min-height:44px;flex:0 0 auto}.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: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: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){.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}} @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}.habit-history__row{grid-template-columns:minmax(0,1fr) auto;gap:4px 10px;padding:7px 0}.habit-history__row time{grid-column:1/-1}.habit-history__row small{grid-column:2;text-align:right}.habit-history__value{grid-column:1;grid-row:2;text-align:left}.habit-history__message{align-items:flex-start;flex-direction:column}}
.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:1px solid #e9b88f;border-radius:50%;background:#fff3df;box-shadow:inset 0 1px 0 rgba(255,255,255,.78),0 6px 16px rgba(112,65,35,.18);transition:left .2s cubic-bezier(.2,.8,.3,1),top .2s cubic-bezier(.2,.8,.3,1),transform .14s cubic-bezier(.2,.8,.3,1),box-shadow .16s ease;touch-action:none;user-select:none;overflow:visible}.unified-fab .fab-cat{width:52px;height:52px;overflow:visible}.fab-cat__character{transform-origin:28px 27px;transition:transform .14s cubic-bezier(.2,.8,.3,1)}.fab-cat__tail path{fill:none;stroke:#f28c52;stroke-width:4.5;stroke-linecap:round}.fab-cat__tail{transform-origin:40.5px 36.8px}.unified-fab.edge-left .fab-cat__tail{transform:translateX(56px) scaleX(-1)}.fab-cat__face-shape,.fab-cat__ear{fill:#f28c52;stroke:#98482c;stroke-width:1.5;stroke-linejoin:round}.fab-cat__inner-ear{fill:#f7b092;stroke:none}.fab-cat__ear{transform-box:fill-box;transition:transform .16s cubic-bezier(.2,.8,.3,1)}.fab-cat__ear--left{transform-origin:right bottom}.fab-cat__ear--right{transform-origin:left bottom}.fab-cat__eye{fill:#fff9f0;stroke:#98482c;stroke-width:1.15;transform-box:fill-box;transform-origin:center;animation:fab-cat-blink 8s 2.4s infinite cubic-bezier(.4,0,.2,1)}.fab-cat__pupils{fill:#71351f;stroke:none;transform:translate(var(--pupil-x),var(--pupil-y));transition:transform 70ms linear}.fab-cat__nose{fill:#71351f;stroke:none}.fab-cat__mouth{fill:none;stroke:#71351f;stroke-width:1.1;stroke-linecap:round}.fab-cat__plus{fill:none;stroke:#fff;stroke-width:2.4;stroke-linecap:round}.unified-fab:focus-visible{outline:3px solid #a84628;outline-offset:3px}.unified-fab:focus:not(:focus-visible){outline:none}.unified-fab:active:not(.dragging){transform:translateY(1px) scale(.97)}.unified-fab:active:not(.dragging) .fab-cat__character{transform:scale(.94)}.unified-fab.dragging{box-shadow:inset 0 1px 0 rgba(255,255,255,.78),0 9px 20px rgba(112,65,35,.24)}.unified-fab.dragging .fab-cat__eye,.unified-fab:active .fab-cat__eye{animation:none}.unified-fab.dragging .fab-cat__eye ellipse{ry:2.55}.unified-fab.snapping .fab-cat__tail{animation:fab-cat-tail-wag .46s cubic-bezier(.25,.8,.35,1) 1}@media(hover:hover) and (pointer:fine){.unified-fab:hover:not(.dragging){transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,255,255,.78),0 8px 18px rgba(112,65,35,.21)}.unified-fab:hover .fab-cat__eye{animation:none}.unified-fab:hover .fab-cat__ear--left{transform:translateY(-.8px) rotate(-4deg)}.unified-fab:hover .fab-cat__ear--right{transform:translateY(-1px) rotate(3deg)}}@keyframes fab-cat-blink{0%,5%,100%{transform:scaleY(1)}1.875%,2.875%{transform:scaleY(.12)}}@keyframes fab-cat-tail-wag{0%,100%{rotate:0deg}28%{rotate:12deg}62%{rotate:-8deg}}.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} .unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;padding:0;border:1px solid #e9b88f;border-radius:50%;background:#fff3df;box-shadow:inset 0 1px 0 rgba(255,255,255,.78),0 6px 16px rgba(112,65,35,.18);transition:left .2s cubic-bezier(.2,.8,.3,1),top .2s cubic-bezier(.2,.8,.3,1),transform .14s cubic-bezier(.2,.8,.3,1),box-shadow .16s ease;touch-action:none;user-select:none;overflow:visible}.unified-fab .fab-cat{width:52px;height:52px;overflow:visible}.fab-cat__character{transform-origin:28px 27px;transition:transform .14s cubic-bezier(.2,.8,.3,1)}.fab-cat__tail path{fill:none;stroke:#f28c52;stroke-width:4.5;stroke-linecap:round}.fab-cat__tail{transform-origin:40.5px 36.8px}.unified-fab.edge-left .fab-cat__tail{transform:translateX(56px) scaleX(-1)}.fab-cat__face-shape,.fab-cat__ear{fill:#f28c52;stroke:#98482c;stroke-width:1.5;stroke-linejoin:round}.fab-cat__inner-ear{fill:#f7b092;stroke:none}.fab-cat__ear{transform-box:fill-box;transition:transform .16s cubic-bezier(.2,.8,.3,1)}.fab-cat__ear--left{transform-origin:right bottom}.fab-cat__ear--right{transform-origin:left bottom}.fab-cat__eye{fill:#fff9f0;stroke:#98482c;stroke-width:1.15;transform-box:fill-box;transform-origin:center;animation:fab-cat-blink 8s 2.4s infinite cubic-bezier(.4,0,.2,1)}.fab-cat__pupils{fill:#71351f;stroke:none;transform:translate(var(--pupil-x),var(--pupil-y));transition:transform 70ms linear}.fab-cat__nose{fill:#71351f;stroke:none}.fab-cat__mouth{fill:none;stroke:#71351f;stroke-width:1.1;stroke-linecap:round}.fab-cat__plus{fill:none;stroke:#fff;stroke-width:2.4;stroke-linecap:round}.unified-fab:focus-visible{outline:3px solid #a84628;outline-offset:3px}.unified-fab:focus:not(:focus-visible){outline:none}.unified-fab:active:not(.dragging){transform:translateY(1px) scale(.97)}.unified-fab:active:not(.dragging) .fab-cat__character{transform:scale(.94)}.unified-fab.dragging{box-shadow:inset 0 1px 0 rgba(255,255,255,.78),0 9px 20px rgba(112,65,35,.24)}.unified-fab.dragging .fab-cat__eye,.unified-fab:active .fab-cat__eye{animation:none}.unified-fab.dragging .fab-cat__eye ellipse{ry:2.55}.unified-fab.snapping .fab-cat__tail{animation:fab-cat-tail-wag .46s cubic-bezier(.25,.8,.35,1) 1}@media(hover:hover) and (pointer:fine){.unified-fab:hover:not(.dragging){transform:translateY(-1px);box-shadow:inset 0 1px 0 rgba(255,255,255,.78),0 8px 18px rgba(112,65,35,.21)}.unified-fab:hover .fab-cat__eye{animation:none}.unified-fab:hover .fab-cat__ear--left{transform:translateY(-.8px) rotate(-4deg)}.unified-fab:hover .fab-cat__ear--right{transform:translateY(-1px) rotate(3deg)}}@keyframes fab-cat-blink{0%,5%,100%{transform:scaleY(1)}1.875%,2.875%{transform:scaleY(.12)}}@keyframes fab-cat-tail-wag{0%,100%{rotate:0deg}28%{rotate:12deg}62%{rotate:-8deg}}.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}} @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}}
+10 -1
View File
@@ -454,7 +454,16 @@ describe('mobile list row language', () => {
expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"') expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"')
expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"') expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"')
expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })") expect(mvpPanel).toContain("request(`/habits/${h.id}/permanent`, { method: 'DELETE' })")
expect(mvpPanel).toContain('v-if="!selectedHabit.archived_at"') expect(mvpPanel).toContain("request(`/habits/${habit.id}/logs?from=${window.from}&to=${window.to}`)")
expect(mvpPanel).toContain('aria-labelledby="habit-history-title"')
expect(mvpPanel).toContain('历史记录加载失败')
expect(mvpPanel).toContain('加载更早记录')
expect(mvpPanel).toContain('mergeHabitHistory(reset ? [] : habitHistory.value')
expect(css).toContain('.habit-history__row{min-height:48px')
expect(css).toContain('.habit-detail-sheet{width:min(500px,100%);max-height:min(88dvh,760px);overflow:hidden')
expect(mvpPanel).toContain("habitHistoryNextTo.value = ''")
expect(mvpPanel).toContain('habitHistoryHasMore.value = false')
expect(mvpPanel).toContain('syncHabitHistoryToday(h)')
expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"') expect(mvpPanel).toContain('@click="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"') expect(mvpPanel).toContain('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"')
expect(mvpPanel).toContain('ref="habitDetailSheet"') expect(mvpPanel).toContain('ref="habitDetailSheet"')