fix: reorder visible habits
ci / gitleaks (push) Successful in 8s
ci / docker (push) Successful in 7m45s

This commit is contained in:
2026-09-15 15:14:55 +08:00
parent fa11e5f432
commit 51761f7615
4 changed files with 29 additions and 10 deletions
+8 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
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 { 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 { csrfHeader } from './lib/csrf'
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
@@ -113,7 +113,7 @@ function isInteractiveTarget(target: EventTarget | null) {
return target instanceof Element && Boolean(target.closest('button,input,select,textarea,a,label'))
}
function startHabitReorder(h: Habit, event: PointerEvent) {
if (busy.value || !props.showCompleted) return
if (busy.value) return
habitReorder.value = { id: h.id, startY: event.clientY, offsetY: 0 }
habitReorderTarget.value = h.id
try { (event.currentTarget as Element).setPointerCapture(event.pointerId) } catch { /* synthetic events */ }
@@ -136,12 +136,14 @@ async function finishHabitReorder(h: Habit, event: PointerEvent) {
if (!drag || drag.id !== h.id || !targetId || targetId === h.id) return
const placement = event.clientY >= drag.startY ? 'after' : 'before'
const previous = habits.value
const next = moveItemWithinScope(previous, h.id, targetId, placement)
if (next === previous) return
const visiblePrevious = visibleHabits.value
const reorderedVisible = moveItemWithinScope(visiblePrevious, h.id, targetId, placement)
if (reorderedVisible === visiblePrevious) return
const next = mergeReorderedSubset(previous, reorderedVisible)
habits.value = next
writeHabitGridCache(dateKey(new Date()), next)
try {
await request('/habits/reorder', { method: 'PUT', body: JSON.stringify({ habit_ids: next.map((item) => item.id) }) })
await request('/habits/reorder', { method: 'PUT', body: JSON.stringify({ habit_ids: reorderedVisible.map((item) => item.id) }) })
emit('notice', '顺序已保存')
} catch (e) {
habits.value = previous
@@ -632,7 +634,7 @@ onBeforeUnmount(() => {
<!-- 习惯列表支持整行滑动记录 -->
<div v-if="view === 'habits'" class="habit-list">
<article v-for="h in visibleHabits" :key="h.id" :data-habit-id="h.id" class="habit-row swipeable" :class="{ done: isDone(h, todayKey), 'just-completed': justCompletedHabitIds.has(h.id), 'completion-exiting': completionExitingHabitIds.has(h.id), ready: Math.abs(habitSwipeOffsets[h.id] ?? 0) >= 64, reordering: habitReorder?.id === h.id, 'reorder-target': habitReorderTarget === h.id }" :style="{ '--swipe-x': `${habitSwipeOffsets[h.id] ?? 0}px`, '--reorder-y': `${habitReorder?.id === h.id ? habitReorder.offsetY : 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="drag-handle habit-drag-handle" :disabled="!showCompleted" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
<button class="drag-handle habit-drag-handle" aria-label="上下拖动习惯排序" title="上下拖动排序" @pointerdown.stop="startHabitReorder(h, $event)" @pointermove.stop="moveHabitReorder(h, $event)" @pointerup.stop="finishHabitReorder(h, $event)" @pointercancel.stop="cancelHabitReorder"><GripVertical/></button>
<button class="task-check habit-check" :disabled="!habitAction(h).writable" :aria-disabled="!habitAction(h).writable" :title="habitAction(h).reason" :aria-label="habitAction(h).reason || (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>
<div class="habit-main" role="button" tabindex="0" :aria-label="`查看习惯详情:${h.name}`" @click="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.enter.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)" @keydown.space.prevent="openHabitDetail(h, $event.currentTarget as HTMLElement)">
<span class="habit-name">{{ h.name }}</span>
+8 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { applyMarkdownFormat, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
import { applyMarkdownFormat, buildTaskRecurrencePayload, buildTaskRrule, classifyTaskForToday, defaultTaskDueAt, filterTasks, groupTaskTree, isSameTaskSortTier, mergeReorderedSubset, moveItemWithinScope, parseTaskRecurrence, parseTaskRrule, renderMarkdown, toDateTimeLocal } from './task-utils'
type SearchTask = {
id: string
@@ -64,6 +64,13 @@ describe('task utilities', () => {
expect(moveItemWithinScope(rows, 'a1', 'b', 'before')).toEqual(rows)
})
it('merges a reordered visible subset without moving hidden slots', () => {
const rows = [{ id: 'a' }, { id: 'hidden-x' }, { id: 'hidden-y' }, { id: 'b' }]
expect(mergeReorderedSubset(rows, [rows[3], rows[0]]).map((row) => row.id)).toEqual([
'b', 'hidden-x', 'hidden-y', 'a',
])
})
it('builds and parses custom repeat rules like TickTick', () => {
expect(buildTaskRrule({ frequency: 'weekly', interval: 2, weekdays: ['MO', 'WE', 'FR'], endMode: 'never' })).toBe('FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE,FR')
expect(buildTaskRrule({ frequency: 'monthly', interval: 1, monthDays: [1, 15, 31], endMode: 'count', count: 10 })).toBe('FREQ=MONTHLY;BYMONTHDAY=1,15,31;COUNT=10')
+6
View File
@@ -137,6 +137,12 @@ export function moveItemWithinScope<T extends { id: string; parent_id?: string |
return items.map((item) => (item.parent_id ?? null) === (source.parent_id ?? null) ? reorderedIterator.next().value! : item)
}
export function mergeReorderedSubset<T extends { id: string }>(items: T[], reorderedSubset: T[]) {
const subsetIds = new Set(reorderedSubset.map((item) => item.id))
const reorderedIterator = reorderedSubset[Symbol.iterator]()
return items.map((item) => subsetIds.has(item.id) ? reorderedIterator.next().value! : item)
}
export type TaskRepeatConfig = {
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'
interval: number
+7 -3
View File
@@ -750,13 +750,17 @@ describe('task and habit row decoration', () => {
expect(css).not.toContain('.habit-toolbar{')
})
it('filters habits with the shared preference and disables sorting while completed items are hidden', () => {
it('filters habits with the shared preference while keeping visible habits sortable', () => {
expect(mvpPanel).toContain('const visibleTodayHabits = computed(() => props.showCompleted ? todayHabits.value')
expect(mvpPanel).toContain('const visibleHabits = computed(() => props.showCompleted ? habits.value')
expect(mvpPanel).toContain('v-for="h in visibleTodayHabits"')
expect(mvpPanel).toContain('v-for="h in visibleHabits"')
expect(mvpPanel).toContain(':disabled="!showCompleted"')
expect(mvpPanel).toContain('if (busy.value || !props.showCompleted) return')
expect(mvpPanel).not.toContain(':disabled="!showCompleted"')
expect(mvpPanel).toContain('if (busy.value) return')
expect(mvpPanel).not.toContain('if (busy.value || !props.showCompleted) return')
expect(mvpPanel).toContain('const reorderedVisible = moveItemWithinScope(visiblePrevious')
expect(mvpPanel).toContain('const next = mergeReorderedSubset(previous, reorderedVisible)')
expect(mvpPanel).toContain('habit_ids: reorderedVisible.map((item) => item.id)')
expect(css).not.toContain('.habit-toolbar{')
})