From e857506bf698846b91fee6bb0dc039052cb5d101 Mon Sep 17 00:00:00 2001 From: bboysoul Date: Tue, 15 Sep 2026 16:32:53 +0800 Subject: [PATCH] feat: show habit history in details --- frontend/src/MvpPanel.vue | 105 +++++++++++++++++++++++++++-- frontend/src/lib/mvp-utils.test.ts | 21 +++++- frontend/src/lib/mvp-utils.ts | 27 ++++++++ frontend/src/style.css | 4 +- frontend/src/style.test.ts | 11 ++- 5 files changed, 159 insertions(+), 9 deletions(-) diff --git a/frontend/src/MvpPanel.vue b/frontend/src/MvpPanel.vue index f128efa..4796816 100644 --- a/frontend/src/MvpPanel.vue +++ b/frontend/src/MvpPanel.vue @@ -2,13 +2,14 @@ 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 { 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 { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion' type View = 'habits' | 'today-habits' | 'settings' 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 } +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 } type Session = { id: string; created_at?: string; last_seen_at?: string; current?: boolean; user_agent?: string } const props = defineProps<{ view: View; showCompleted: boolean }>() const emit = defineEmits<{ @@ -42,6 +43,13 @@ const habitComposerOpen = ref(false) const editingHabit = ref(null) const originalHabitForm = ref(null) const selectedHabit = ref(null) +const habitHistory = ref([]) +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 habitDetailSheet = ref(null) let habitDetailOpener: HTMLElement | null = null @@ -221,12 +229,18 @@ function habitCellLabel(h: Habit, cell: NonNullable[number]) { } 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, 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) { @@ -241,6 +255,7 @@ async function applyHabitSwipe(h: Habit, deltaX: number) { if (!animateExit) setLocalHabitValue(h, next) try { await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) }) + syncHabitHistoryToday(h) if (animateExit) { setHabitCompletionExiting(h.id, true) setLocalHabitValue(h, next) @@ -310,6 +325,7 @@ async function toggleHabitFromButton(h: Habit) { setLocalHabitValue(h, next) try { await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) }) + syncHabitHistoryToday(h) await finishHabitCompletion(h, wasDone, next, animateExit) emit('notice', habitButtonNotice(h.kind, previous, next)) } catch (e) { @@ -406,14 +422,75 @@ function editHabit(h: Habit) { void nextTick(() => habitNameInput.value?.focus()) } 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) { if (habitDetailClickSuppressed.value) return habitDetailOpener = opener ?? document.activeElement as HTMLElement | null selectedHabit.value = h + void loadHabitHistory(true) void nextTick(() => habitDetailSheet.value?.focus()) } function closeHabitDetail() { + habitHistoryRequest += 1 selectedHabit.value = null + habitHistory.value = [] + habitHistoryNextTo.value = '' + habitHistoryHasMore.value = false + habitHistoryError.value = '' + habitHistoryLoading.value = false + habitHistoryLoadingMore.value = false void nextTick(() => { if (habitDetailOpener?.isConnected) habitDetailOpener.focus() else habitArchiveToggle.value?.focus() @@ -663,7 +740,25 @@ onBeforeUnmount(() => {
diff --git a/frontend/src/lib/mvp-utils.test.ts b/frontend/src/lib/mvp-utils.test.ts index 0ad959c..5e53fd5 100644 --- a/frontend/src/lib/mvp-utils.test.ts +++ b/frontend/src/lib/mvp-utils.test.ts @@ -1,5 +1,5 @@ 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', () => { 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: '' }) }) + 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', () => { expect(isHabitComplete('numeric', 3, 5)).toBe(false) expect(isHabitComplete('numeric', 5, 5)).toBe(true) diff --git a/frontend/src/lib/mvp-utils.ts b/frontend/src/lib/mvp-utils.ts index 62316f0..01f27f7 100644 --- a/frontend/src/lib/mvp-utils.ts +++ b/frontend/src/lib/mvp-utils.ts @@ -80,6 +80,33 @@ export function isHabitComplete(kind: string | undefined, value: number | boolea 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 = { kind?: string cells?: Array<{ day: string; scheduled?: boolean; paused?: boolean; value?: number | boolean }> diff --git a/frontend/src/style.css b/frontend/src/style.css index b3f7136..97f9e78 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -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)} .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} -.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} .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} @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: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} @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}} diff --git a/frontend/src/style.test.ts b/frontend/src/style.test.ts index 240e903..b64b533 100644 --- a/frontend/src/style.test.ts +++ b/frontend/src/style.test.ts @@ -454,7 +454,16 @@ describe('mobile list row language', () => { expect(mvpPanel).toContain('v-if="selectedHabit.archived_at"') expect(mvpPanel).toContain('@click="deleteHabit(selectedHabit)"') 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('@keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)"') expect(mvpPanel).toContain('ref="habitDetailSheet"')