This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { Activity, ArchiveRestore, Check, Download, FileJson, LogOut, RefreshCw, Trash2, Upload, X } from 'lucide-vue-next'
|
import { Activity, ArchiveRestore, Check, Download, FileJson, LogOut, RefreshCw, Trash2, Upload, X } from 'lucide-vue-next'
|
||||||
import { dateKey, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './lib/mvp-utils'
|
import { dateKey, habitButtonValue, isHabitComplete, isHabitScheduledToday, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, readHabitGridCache, shouldToggleRowSwipe, writeHabitGridCache } from './lib/mvp-utils'
|
||||||
import { csrfHeader } from './lib/csrf'
|
import { csrfHeader } from './lib/csrf'
|
||||||
|
|
||||||
type View = 'habits' | 'today-habits' | 'settings'
|
type View = 'habits' | 'today-habits' | 'settings'
|
||||||
@@ -233,10 +233,20 @@ function refreshHabitDay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function loadHabits() {
|
async function loadHabits() {
|
||||||
await safe(async () => {
|
const week = dateKey(new Date())
|
||||||
const data = await request(`/habits/grid?week=${dateKey(new Date())}`) as { habits?: Habit[] }
|
const cached = readHabitGridCache<Habit>(week)
|
||||||
|
if (cached) habits.value = cached
|
||||||
|
if (!cached) busy.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const data = await request(`/habits/grid?week=${week}`) as { habits?: Habit[] }
|
||||||
habits.value = data.habits ?? []
|
habits.value = data.habits ?? []
|
||||||
})
|
writeHabitGridCache(week, habits.value)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '请求失败'
|
||||||
|
} finally {
|
||||||
|
if (!cached) busy.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
await safe(async () => {
|
await safe(async () => {
|
||||||
@@ -302,7 +312,7 @@ onBeforeUnmount(() => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 今日习惯:只展示“今天该做”的习惯,复用正式习惯行样式 -->
|
<!-- 今日习惯:只展示“今天该做”的习惯,复用正式习惯行样式 -->
|
||||||
<div v-if="view === 'today-habits' && !busy" class="habit-list today-habit-list">
|
<div v-if="view === 'today-habits' && (habits.length || !busy)" class="habit-list today-habit-list">
|
||||||
<div class="habit-toolbar habit-toolbar-today"><label><input v-model="hideCompletedHabits" type="checkbox"> 隐藏已完成</label></div>
|
<div class="habit-toolbar habit-toolbar-today"><label><input v-model="hideCompletedHabits" type="checkbox"> 隐藏已完成</label></div>
|
||||||
<article v-for="h in visibleTodayHabits" :key="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
<article v-for="h in visibleTodayHabits" :key="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px` }" @pointerdown="startHabitPointer(h, $event)" @pointermove="moveHabitPointer(h, $event)" @pointerup="finishHabitPointer(h, $event)" @pointercancel="cancelHabitPointer(h)" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
|
||||||
<button class="task-check habit-check" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
|
<button class="task-check habit-check" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)"><span class="task-check-mark"><Check v-if="isDone(h, todayKey)" /></span></button>
|
||||||
@@ -310,9 +320,8 @@ onBeforeUnmount(() => {
|
|||||||
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
|
<span><span class="habit-name">{{ h.name }}</span><small v-if="habitProgressText(h)">{{ habitProgressText(h) }}</small></span>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<div v-if="!visibleTodayHabits.length" class="empty-panel today-empty-panel"><span>{{ hideCompletedHabits && todayHabits.length ? '已完成的习惯已隐藏。' : '今天没有安排习惯,轻松一下吧。' }}</span><button v-if="!hideCompletedHabits || !todayHabits.length" class="soft-button empty-action" @click="openHabitComposer"><Check/>添加习惯</button></div>
|
<div v-if="!visibleTodayHabits.length && !busy" class="empty-panel today-empty-panel"><span>{{ hideCompletedHabits && todayHabits.length ? '已完成的习惯已隐藏。' : '今天没有安排习惯,轻松一下吧。' }}</span><button v-if="!hideCompletedHabits || !todayHabits.length" class="soft-button empty-action" @click="openHabitComposer"><Check/>添加习惯</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="view === 'today-habits' && busy" class="empty-panel">加载中…</div>
|
|
||||||
|
|
||||||
<!-- 完整习惯列表 -->
|
<!-- 完整习惯列表 -->
|
||||||
<Transition name="task-compose">
|
<Transition name="task-compose">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { calendarModeLabel, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, shouldToggleRowSwipe } from './mvp-utils'
|
import { calendarModeLabel, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, habitButtonValue, habitWeek, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, numericHabitInputValue, previousHabitSwipeValue, quickTaskFields, readHabitGridCache, shouldToggleRowSwipe, writeHabitGridCache } 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', () => {
|
||||||
@@ -70,6 +70,13 @@ describe('MVP view utilities', () => {
|
|||||||
expect(habitButtonValue('boolean', 1, 1)).toBe(0)
|
expect(habitButtonValue('boolean', 1, 1)).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('reuses the current week habit grid while refreshing in the background', () => {
|
||||||
|
const habits = [{ id: 'habit-1', name: '喝水' }]
|
||||||
|
writeHabitGridCache('2026-09-07', habits)
|
||||||
|
expect(readHabitGridCache('2026-09-07')).toEqual(habits)
|
||||||
|
expect(readHabitGridCache('2026-09-14')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('renders countdown labels from date-only day values', () => {
|
it('renders countdown labels from date-only day values', () => {
|
||||||
expect(countdownDayText(8)).toBe('还有 8 天')
|
expect(countdownDayText(8)).toBe('还有 8 天')
|
||||||
expect(countdownDayText(0)).toBe('就是今天')
|
expect(countdownDayText(0)).toBe('就是今天')
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ export function isFabDrag(deltaX: number, deltaY: number, threshold = 8) {
|
|||||||
return Math.hypot(deltaX, deltaY) >= threshold
|
return Math.hypot(deltaX, deltaY) >= threshold
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let habitGridCache: { week: string; habits: unknown[] } | null = null
|
||||||
|
|
||||||
|
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 formatApiErrorDetail(detail: unknown): string {
|
export function formatApiErrorDetail(detail: unknown): string {
|
||||||
if (typeof detail === 'string') return detail
|
if (typeof detail === 'string') return detail
|
||||||
if (Array.isArray(detail)) {
|
if (Array.isArray(detail)) {
|
||||||
|
|||||||
@@ -110,6 +110,13 @@ describe('task and habit row decoration', () => {
|
|||||||
expect(css).toContain('.empty-action{')
|
expect(css).toContain('.empty-action{')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps cached today habits visible during background refresh', () => {
|
||||||
|
expect(mvpPanel).toContain('readHabitGridCache<Habit>(week)')
|
||||||
|
expect(mvpPanel).toContain('writeHabitGridCache(week, habits.value)')
|
||||||
|
expect(mvpPanel).toContain("view === 'today-habits' && (habits.length || !busy)")
|
||||||
|
expect(mvpPanel).not.toContain("view === 'today-habits' && busy\" class=\"empty-panel\">加载中…")
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps habit cards borderless so the rounded left edge has no visual gap', () => {
|
it('keeps habit cards borderless so the rounded left edge has no visual gap', () => {
|
||||||
expect(css).toMatch(/\.habit-row\{border:0;/)
|
expect(css).toMatch(/\.habit-row\{border:0;/)
|
||||||
expect(css).not.toMatch(/\.habit-row\{[^}]*border-top:/)
|
expect(css).not.toMatch(/\.habit-row\{[^}]*border-top:/)
|
||||||
|
|||||||
Reference in New Issue
Block a user