This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user