fix: land on today and swipe count habits incrementally
ci / docker (push) Successful in 3m16s

This commit is contained in:
2026-09-06 10:44:06 +08:00
parent cc98bc53e9
commit 4c93d0e1f8
7 changed files with 90 additions and 51 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-DBkBt5cU.js"></script>
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#f15a29"><link rel="manifest" href="/manifest.json"><title>dodo</title> <script type="module" crossorigin src="/assets/index-DqfzvhAi.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CDrLYTXE.css">
</head><body><div id="app"></div></body></html>
+2 -2
View File
@@ -6,7 +6,7 @@ import {
Settings, Trash2, X, Repeat2,
} from 'lucide-vue-next'
import { filterTasks, fromDateTimeLocal, groupTaskTree, renderMarkdown, toDateTimeLocal } from './lib/task-utils'
import { isTaskView, shouldToggleRowSwipe } from './lib/mvp-utils'
import { defaultView, isTaskView, shouldToggleRowSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
import MvpPanel from './MvpPanel.vue'
@@ -31,7 +31,7 @@ const tags = ref<Tag[]>([])
const tasks = ref<Task[]>([])
const trash = ref<Task[]>([])
const activeList = ref('')
const activeView = ref<View>('tasks')
const activeView = ref<View>(defaultView())
const selectedTask = ref<Task | null>(null)
const title = ref('')
const query = ref('')
+51 -40
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Activity, ArchiveRestore, Download, FileJson, LogOut, Plus, RefreshCw, Trash2, Upload } from 'lucide-vue-next'
import { dateKey, isHabitComplete, mergePage, numericHabitInputValue, shouldToggleRowSwipe } from './lib/mvp-utils'
import { dateKey, isHabitComplete, mergePage, nextHabitSwipeValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './lib/mvp-utils'
import { csrfHeader } from './lib/csrf'
type View = 'habits' | 'today-habits' | 'settings'
@@ -26,7 +26,6 @@ const habitTarget = ref(1)
const importFile = ref<File | null>(null)
const importPreview = ref<any>(null)
const restoreFile = ref<File | null>(null)
const numericValues = ref<Record<string, number | string>>({})
const todayKey = ref(dateKey(new Date()))
const habitSwipeStart = ref<{ id: string; x: number; y: number } | null>(null)
const habitSwipeOffsets = ref<Record<string, number>>({})
@@ -83,7 +82,7 @@ function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
}
function startHabitSwipe(h: Habit, event: TouchEvent) {
if (h.kind === 'numeric' || busy.value || isInteractiveTarget(event.target)) return
if (busy.value || isInteractiveTarget(event.target)) return
const touch = event.touches[0]
if (touch) {
habitSwipeStart.value = { id: h.id, x: touch.clientX, y: touch.clientY }
@@ -96,24 +95,52 @@ function moveHabitSwipe(h: Habit, event: TouchEvent) {
if (!start || start.id !== h.id || !touch) return
const deltaX = touch.clientX - start.x
const deltaY = touch.clientY - start.y
const done = isDone(h, todayKey.value)
const current = Number(logFor(h, todayKey.value)?.value ?? 0)
if (Math.abs(deltaX) > Math.abs(deltaY)) {
if ((!done && deltaX > 0) || (done && deltaX < 0)) {
const canIncrement = deltaX > 0 && current < (h.target ?? 1)
const canDecrement = deltaX < 0 && current > 0
if (canIncrement || canDecrement) {
habitSwipeOffsets.value[h.id] = Math.max(-92, Math.min(deltaX, 92))
}
}
}
function habitProgressText(h: Habit) {
if (h.kind !== 'numeric') return isDone(h, todayKey.value) ? '今天已完成 · 左滑取消' : '右滑整行打卡'
const value = Number(logFor(h, todayKey.value)?.value ?? 0)
return `${value} / ${h.target ?? 1} · ${isDone(h, todayKey.value) ? '左滑减少一次' : '右滑完成一次'}`
}
function habitActionText(h: Habit) {
if (h.kind !== 'numeric') return isDone(h, todayKey.value) ? '已完成' : '打卡'
return `${Number(logFor(h, todayKey.value)?.value ?? 0)} / ${h.target ?? 1}`
}
async function applyHabitSwipe(h: Habit, deltaX: number) {
const current = logFor(h, todayKey.value)?.value
const next = deltaX > 0
? nextHabitSwipeValue(h.kind, current, h.target ?? 1)
: previousHabitSwipeValue(h.kind, current)
await safe(async () => {
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
await loadHabits()
emit('notice', next > Number(current ?? 0) ? '已记录一次 🎉' : '已减少一次')
})
}
async function finishHabitSwipe(h: Habit, event: TouchEvent) {
const start = habitSwipeStart.value
habitSwipeStart.value = null
habitSwipeOffsets.value[h.id] = 0
if (!start || start.id !== h.id || h.kind === 'numeric' || busy.value) return
if (!start || start.id !== h.id || busy.value) return
const touch = event.changedTouches[0]
const deltaX = touch ? touch.clientX - start.x : 0
const deltaY = touch ? touch.clientY - start.y : 0
const expectedDirection = isDone(h, todayKey.value) ? deltaX < 0 : deltaX > 0
const current = Number(logFor(h, todayKey.value)?.value ?? 0)
const expectedDirection = h.kind === 'numeric'
? ((deltaX > 0 && current < (h.target ?? 1)) || (deltaX < 0 && current > 0))
: (isDone(h, todayKey.value) ? deltaX < 0 : deltaX > 0)
if (touch && expectedDirection && shouldToggleRowSwipe(Math.abs(deltaX), deltaY)) {
await toggleHabit(h, todayKey.value)
await applyHabitSwipe(h, deltaX)
}
}
function cancelHabitSwipe(h?: Habit) {
@@ -121,16 +148,14 @@ function cancelHabitSwipe(h?: Habit) {
if (h) habitSwipeOffsets.value[h.id] = 0
}
async function toggleHabitFromButton(h: Habit) {
await toggleHabit(h, todayKey.value)
}
async function recordNumeric(h: Habit, day: string) {
if (busy.value) return
const next = numericHabitInputValue(numericValues.value[h.id])
if (next === null || (h.max_value != null && next > h.max_value)) return
const current = logFor(h, todayKey.value)?.value
const next = isDone(h, todayKey.value)
? previousHabitSwipeValue(h.kind, current)
: nextHabitSwipeValue(h.kind, current, h.target ?? 1)
await safe(async () => {
await request(`/habits/${h.id}/logs/${day}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
await request(`/habits/${h.id}/logs/${todayKey.value}`, { method: 'PUT', body: JSON.stringify({ value: next }) })
await loadHabits()
emit('notice', next > 0 ? '已记录 🎉' : '已清零')
emit('notice', next > Number(current ?? 0) ? '已记录一次 🎉' : '已减少一次')
})
}
async function addHabit() {
@@ -272,18 +297,11 @@ onBeforeUnmount(() => {
<!-- 今日习惯只展示今天该做的习惯复用正式习惯行样式 -->
<div v-if="view === 'today-habits' && !busy" class="habit-list today-habit-list">
<article v-for="h in todayHabits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric', ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--swipe-width': `${Math.abs(habitSwipeOffsets[h.id] ?? 0)}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<span v-if="h.kind !== 'numeric'" class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? ' 未完成' : ' 打卡' }}</span>
<div v-if="h.kind !== 'numeric'" class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已完成 · 左滑取消' : '右滑整行打卡' }}</small></span>
<button class="habit-swipe-hint" :aria-label="isDone(h, todayKey) ? `取消${h.name}今天的打卡` : `完成${h.name}今天的打卡`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">{{ isDone(h, todayKey) ? '已完成' : '打卡' }}</button>
</div>
<div v-else class="habit-main numeric-habit">
<span><span class="habit-name">{{ h.name }}</span><small>今天 {{ logFor(h, todayKey)?.value || 0 }} / {{ h.target || 1 }}{{ h.unit || '' }}</small></span>
<span class="numeric-action">
<input v-model.number="numericValues[h.id]" type="number" min="0" :max="h.max_value ?? undefined" step="any" :placeholder="String(h.target || 1)" :aria-label="`${h.name}今日数值`">
<button class="soft-button" @click="recordNumeric(h, todayKey)">记录</button>
</span>
<article v-for="h in todayHabits" :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`, '--swipe-width': `${Math.abs(habitSwipeOffsets[h.id] ?? 0)}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<span class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? (h.kind === 'numeric' ? ' 一次' : '↩ 未完成') : (h.kind === 'numeric' ? '+ 一次' : '✓ 打卡') }}</span>
<div class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ habitProgressText(h) }}</small></span>
<button class="habit-swipe-hint" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" @click.stop="toggleHabitFromButton(h)">{{ habitActionText(h) }}</button>
</div>
</article>
<div v-if="!todayHabits.length" class="empty-panel">今天没有安排习惯轻松一下吧</div>
@@ -304,18 +322,11 @@ onBeforeUnmount(() => {
<!-- 习惯列表布尔习惯整行右滑打卡数值习惯保留明确输入记录 -->
<div v-if="view === 'habits'" class="habit-list">
<article v-for="h in habits" :key="h.id" class="habit-row" :class="{ done: isDone(h, todayKey), swipeable: h.kind !== 'numeric', ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64 }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--swipe-width': `${Math.abs(habitSwipeOffsets[h.id] ?? 0)}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<span v-if="h.kind !== 'numeric'" class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? ' 未完成' : ' 打卡' }}</span>
<div v-if="h.kind !== 'numeric'" class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ isDone(h, todayKey) ? '今天已完成 · 左滑取消' : '右滑整行打卡' }}</small></span>
<button class="habit-swipe-hint" :aria-label="isDone(h, todayKey) ? `取消${h.name}今天的打卡` : `完成${h.name}今天的打卡`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">{{ isDone(h, todayKey) ? '已完成' : '打卡' }}</button>
</div>
<div v-else class="habit-main numeric-habit">
<span><span class="habit-name">{{ h.name }}</span><small>今天 {{ logFor(h, todayKey)?.value || 0 }} / {{ h.target || 1 }}{{ h.unit || '' }}</small></span>
<span class="numeric-action">
<input v-model.number="numericValues[h.id]" type="number" min="0" :max="h.max_value ?? undefined" step="any" :placeholder="String(h.target || 1)" :aria-label="`${h.name}今日数值`">
<button class="soft-button" @click="recordNumeric(h, todayKey)">记录</button>
</span>
<article v-for="h in habits" :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`, '--swipe-width': `${Math.abs(habitSwipeOffsets[h.id] ?? 0)}px` }" @touchstart.passive="startHabitSwipe(h, $event)" @touchmove.passive="moveHabitSwipe(h, $event)" @touchend="finishHabitSwipe(h, $event)" @touchcancel="cancelHabitSwipe(h)">
<span class="swipe-bg" :class="{ cancel: isDone(h, todayKey) }">{{ isDone(h, todayKey) ? (h.kind === 'numeric' ? ' 一次' : '↩ 未完成') : (h.kind === 'numeric' ? '+ 一次' : '✓ 打卡') }}</span>
<div class="habit-main">
<span><span class="habit-name">{{ h.name }}</span><small>{{ habitProgressText(h) }}</small></span>
<button class="habit-swipe-hint" :aria-label="isDone(h, todayKey) ? `减少${h.name}一次` : `完成${h.name}一次`" :aria-pressed="isDone(h, todayKey)" @click.stop="toggleHabitFromButton(h)">{{ habitActionText(h) }}</button>
</div>
<button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button>
</article>
+15 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { dateKey, habitWeek, isHabitComplete, isTaskView, numericHabitInputValue, shouldToggleRowSwipe } from './mvp-utils'
import { dateKey, defaultView, habitWeek, isHabitComplete, isTaskView, nextHabitSwipeValue, numericHabitInputValue, previousHabitSwipeValue, shouldToggleRowSwipe } from './mvp-utils'
describe('MVP view utilities', () => {
it('formats a local date as YYYY-MM-DD', () => {
@@ -29,10 +29,22 @@ describe('MVP view utilities', () => {
expect(numericHabitInputValue(4)).toBe(4)
})
it('toggles a row only for a deliberate right swipe', () => {
it('uses today as the default landing view', () => {
expect(defaultView()).toBe('today')
})
it('increments count habits one swipe at a time and caps at target', () => {
expect(nextHabitSwipeValue('numeric', 2, 5)).toBe(3)
expect(nextHabitSwipeValue('numeric', 5, 5)).toBe(5)
expect(previousHabitSwipeValue('numeric', 3)).toBe(2)
expect(previousHabitSwipeValue('numeric', 0)).toBe(0)
})
it('toggles a row for a deliberate mostly-horizontal swipe', () => {
expect(shouldToggleRowSwipe(78, 8)).toBe(true)
expect(shouldToggleRowSwipe(88, 28)).toBe(true)
expect(shouldToggleRowSwipe(-78, 8)).toBe(false)
expect(shouldToggleRowSwipe(30, 2)).toBe(false)
expect(shouldToggleRowSwipe(80, 35)).toBe(false)
expect(shouldToggleRowSwipe(80, 60)).toBe(false)
})
})
+17 -1
View File
@@ -24,11 +24,27 @@ export function isTaskView(view: string) {
return view === 'tasks' || view === 'today' || view === 'upcoming'
}
export function defaultView() {
return 'today' as const
}
export function isHabitComplete(kind: string | undefined, value: number | boolean | undefined, target = 1) {
if (kind === 'numeric') return Number(value ?? 0) >= target
return Boolean(value)
}
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 numericHabitInputValue(input: number | string | undefined) {
if (input === undefined || input === '') return null
const value = Number(input)
@@ -36,5 +52,5 @@ export function numericHabitInputValue(input: number | string | undefined) {
}
export function shouldToggleRowSwipe(deltaX: number, deltaY: number) {
return deltaX >= 64 && Math.abs(deltaY) <= 24 && deltaX > Math.abs(deltaY) * 1.5
return deltaX >= 64 && deltaX > Math.abs(deltaY) * 1.5
}