feat: show progress bars for numeric habits
ci / gitleaks (push) Successful in 6s
ci / docker (push) Successful in 6m24s

This commit is contained in:
2026-09-08 15:51:23 +08:00
parent 7f4ba7779a
commit 3aa988c699
3 changed files with 18 additions and 1 deletions
+8
View File
@@ -178,6 +178,12 @@ function habitProgressText(h: Habit) {
if (h.kind !== 'numeric') return '' if (h.kind !== 'numeric') return ''
return `${Number(logFor(h, todayKey.value)?.value ?? 0)} / ${h.target ?? 1}` return `${Number(logFor(h, todayKey.value)?.value ?? 0)} / ${h.target ?? 1}`
} }
function habitProgressMax(h: Habit) {
return Math.max(Number(h.target ?? 1), 1)
}
function habitProgressValue(h: Habit) {
return Math.min(Math.max(Number(logFor(h, todayKey.value)?.value ?? 0), 0), habitProgressMax(h))
}
function setLocalHabitValue(h: Habit, next: number | boolean) { function setLocalHabitValue(h: Habit, next: number | boolean) {
habits.value = habits.value.map((item) => item.id !== h.id habits.value = habits.value.map((item) => item.id !== h.id
@@ -403,6 +409,7 @@ onBeforeUnmount(() => {
<div class="habit-main"> <div class="habit-main">
<span class="habit-name">{{ h.name }}</span> <span class="habit-name">{{ h.name }}</span>
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small> <small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
<progress v-if="h.kind === 'numeric'" class="habit-progress-bar" :value="habitProgressValue(h)" :max="habitProgressMax(h)" :aria-label="`${h.name}进度:${habitProgressText(h)}`"></progress>
</div> </div>
</article> </article>
<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 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>
@@ -429,6 +436,7 @@ onBeforeUnmount(() => {
<div class="habit-main"> <div class="habit-main">
<span class="habit-name">{{ h.name }}</span> <span class="habit-name">{{ h.name }}</span>
<small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small> <small v-if="habitProgressText(h)" class="habit-progress">{{ habitProgressText(h) }}</small>
<progress v-if="h.kind === 'numeric'" class="habit-progress-bar" :value="habitProgressValue(h)" :max="habitProgressMax(h)" :aria-label="`${h.name}进度:${habitProgressText(h)}`"></progress>
</div> </div>
<button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button> <button class="icon ghost" aria-label="归档习惯" @click="archiveHabit(h)"><Trash2 /></button>
</article> </article>
+2 -1
View File
@@ -16,9 +16,10 @@ main{min-width:0;padding:27px 34px 50px;overflow:auto;background:linear-gradient
.habit-row.done{background:#fffaf5} .habit-row.done{background:#fffaf5}
.habit-row.swipeable{touch-action:pan-y;transform:translate(var(--swipe-x),var(--reorder-y,0px))} .habit-row.swipeable{touch-action:pan-y;transform:translate(var(--swipe-x),var(--reorder-y,0px))}
.habit-row.swipeable:not([style*="0px"]){transition:none} .habit-row.swipeable:not([style*="0px"]){transition:none}
.habit-main{flex:1;min-width:0;display:flex;align-items:center;justify-content:space-between;gap:10px;border:0;background:transparent;padding:13px 4px;text-align:left;border-radius:10px} .habit-main{flex:1;min-width:0;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:7px 10px;border:0;background:transparent;padding:11px 4px;text-align:left;border-radius:10px}
.habit-name{font-size:15px;font-weight:700;color:#3c372f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .habit-name{font-size:15px;font-weight:700;color:#3c372f;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.habit-progress{margin-left:auto;flex:0 0 auto;font-size:11px;color:var(--muted);white-space:nowrap} .habit-progress{margin-left:auto;flex:0 0 auto;font-size:11px;color:var(--muted);white-space:nowrap}
.habit-progress-bar{grid-column:1/-1;width:100%;height:5px;border:0;border-radius:999px;overflow:hidden;background:#eee6db;appearance:none;-webkit-appearance:none}.habit-progress-bar::-webkit-progress-bar{background:#eee6db;border-radius:999px}.habit-progress-bar::-webkit-progress-value{background:linear-gradient(90deg,#f7a066,var(--accent));border-radius:999px;transition:width .2s ease}.habit-progress-bar::-moz-progress-bar{background:linear-gradient(90deg,#f7a066,var(--accent));border-radius:999px}
.habit-row.done .habit-name{color:var(--accent);text-decoration:line-through;text-decoration-thickness:1.5px;text-decoration-color:#b99a89} .habit-row.done .habit-name{color:var(--accent);text-decoration:line-through;text-decoration-thickness:1.5px;text-decoration-color:#b99a89}
.habit-row .icon.ghost{color:#b3a795;margin-left:4px} .habit-row .icon.ghost{color:#b3a795;margin-left:4px}
.habit-row .icon.ghost:hover{color:var(--danger)} .habit-row .icon.ghost:hover{color:var(--danger)}
+8
View File
@@ -120,6 +120,14 @@ describe('task and habit row decoration', () => {
expect(css).toContain('.habit-progress{margin-left:auto;') expect(css).toContain('.habit-progress{margin-left:auto;')
}) })
it('shows an accessible progress bar for numeric habits in both habit views', () => {
expect(mvpPanel.match(/<progress v-if="h.kind === 'numeric'" class="habit-progress-bar"/g)?.length).toBe(2)
expect(mvpPanel.match(/:value="habitProgressValue\(h\)" :max="habitProgressMax\(h\)"/g)?.length).toBe(2)
expect(mvpPanel.match(/:aria-label="`\$\{h.name\}进度:\$\{habitProgressText\(h\)\}`"/g)?.length).toBe(2)
expect(css).toContain('.habit-progress-bar{grid-column:1/-1;')
expect(css).toContain('.habit-progress-bar::-webkit-progress-value{background:linear-gradient')
})
it('restores the current page and list from local storage', () => { it('restores the current page and list from local storage', () => {
expect(app).toContain("readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY)") expect(app).toContain("readStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY)")
expect(app).toContain("writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)") expect(app).toContain("writeStoredNavigation(window.localStorage, NAVIGATION_STORAGE_KEY, view, activeList.value)")