fix: simplify date-only due labels
ci / gitleaks (push) Successful in 7s
ci / docker (push) Successful in 3m29s

This commit is contained in:
2026-09-10 17:22:06 +08:00
parent 1366f302ec
commit ab8c0a5138
6 changed files with 76 additions and 47 deletions
+35 -17
View File
@@ -24,26 +24,44 @@ describe('TaskDueDisplay', () => {
expect(host.innerHTML).toBe('<!--v-if-->') expect(host.innerHTML).toBe('<!--v-if-->')
}) })
it('renders semantic canonical time with absolute and relative copy', async () => { it('renders date-only visual text without an absolute date or explanatory prefixes', async () => {
const { host } = await mountDue({ dueAt: '2026-09-10', dueHasTime: false, completed: false, nowMs }) const cases = [
const root = host.querySelector('.task-due')! { dueAt: '2026-09-12', text: '2天', state: 'neutral', explanation: '还有2天' },
const time = root.querySelector('time')! { dueAt: '2026-09-08', text: '2天', state: 'overdue', explanation: '已过期2天' },
expect(root.classList.contains('task-due--neutral')).toBe(true) { dueAt: '2026-09-10', text: '今天', state: 'neutral', explanation: '今天' },
expect(time.getAttribute('datetime')).toBe('2026-09-10') ] as const
expect(time.textContent).toContain('9月10日') for (const item of cases) {
expect(time.textContent).toContain('今天') const { host } = await mountDue({ dueAt: item.dueAt, dueHasTime: false, completed: false, nowMs })
expect(time.getAttribute('title')).toBe(time.getAttribute('aria-label')) const root = host.querySelector('.task-due')!
expect(time.getAttribute('title')).toContain('2026年9月10日') const time = root.querySelector('time')!
expect(root.querySelector('svg')).toBeNull() expect(root.classList.contains(`task-due--${item.state}`)).toBe(true)
expect(time.textContent?.trim()).toBe(item.text)
expect(time.textContent).not.toMatch(/2026|9月|还有|已过期/)
expect(time.getAttribute('datetime')).toBe(item.dueAt)
expect(time.getAttribute('title')).toBe(time.getAttribute('aria-label'))
expect(time.getAttribute('title')).toContain(`2026年9月${Number(item.dueAt.slice(8))}`)
expect(time.getAttribute('title')).toContain(item.explanation)
}
}) })
it('distinguishes incomplete overdue and completed neutral states', async () => { it('keeps completed date-only deadlines neutral and visually minimal', async () => {
const overdue = await mountDue({ dueAt: '2026-09-09', dueHasTime: false, completed: false, nowMs })
expect(overdue.host.querySelector('.task-due--overdue')).not.toBeNull()
const completed = await mountDue({ dueAt: '2026-09-09', dueHasTime: false, completed: true, nowMs }) const completed = await mountDue({ dueAt: '2026-09-09', dueHasTime: false, completed: true, nowMs })
expect(completed.host.querySelector('.task-due--completed')).not.toBeNull() const root = completed.host.querySelector('.task-due')!
expect(completed.host.querySelector('.task-due--overdue')).toBeNull() const time = root.querySelector('time')!
expect(completed.host.textContent).toContain('已完成(原定1天前)') expect(root.classList.contains('task-due--completed')).toBe(true)
expect(root.classList.contains('task-due--overdue')).toBe(false)
expect(time.textContent?.trim()).toBe('已完成')
expect(time.textContent).not.toMatch(/2026|9月|已过期|还有/)
expect(time.getAttribute('title')).toContain('截止日期:2026年9月9日')
expect(time.getAttribute('title')).toContain('已完成(原定1天前)')
})
it('keeps timed task absolute and relative visual copy unchanged', async () => {
const dueAt = new Date(nowMs + 2 * 60 * 60_000).toISOString()
const { host } = await mountDue({ dueAt, dueHasTime: true, completed: false, nowMs })
const time = host.querySelector('time')!
expect(time.querySelector('.task-due__absolute')?.textContent).toMatch(/^9月10日 \d{2}:\d{2}$/)
expect(time.querySelector('.task-due__relative')?.textContent).toBe('还有2小时')
}) })
it('reacts when the shared nowMs prop advances', async () => { it('reacts when the shared nowMs prop advances', async () => {
+3 -3
View File
@@ -13,15 +13,15 @@ const presentation = computed(() => getTaskDuePresentation(props))
</script> </script>
<template> <template>
<span v-if="presentation" class="task-due" :class="`task-due--${presentation.state}`"> <span v-if="presentation" class="task-due" :class="[`task-due--${presentation.state}`, { 'task-due--timed': dueHasTime }]">
<time <time
class="task-due__text" class="task-due__text"
:datetime="presentation.datetime" :datetime="presentation.datetime"
:title="presentation.fullText" :title="presentation.fullText"
:aria-label="presentation.fullText" :aria-label="presentation.fullText"
> >
<span class="task-due__absolute">{{ presentation.absoluteText }}</span> <span v-if="dueHasTime" class="task-due__absolute">{{ presentation.absoluteText }}</span>
<span class="task-due__separator" aria-hidden="true"> · </span> <span v-if="dueHasTime" class="task-due__separator" aria-hidden="true"> · </span>
<span class="task-due__relative">{{ presentation.relativeText }}</span> <span class="task-due__relative">{{ presentation.relativeText }}</span>
</time> </time>
</span> </span>
+14 -14
View File
@@ -66,15 +66,15 @@ describe('getTaskDuePresentation date-only tasks', () => {
}) })
it.each([ it.each([
['2026-09-09', '9月9日', '已过期1天', 'overdue'], ['2026-09-08', '2天', 'overdue', '已过期2天'],
['2026-09-10', '9月10日', '今天', 'neutral'], ['2026-09-10', '今天', 'neutral', '今天'],
['2026-09-11', '9月11日', '还有1天', 'neutral'], ['2026-09-12', '2天', 'neutral', '还有2天'],
['2027-01-02', '2027年1月2日', '还有114天', 'neutral'], ] as const)('uses compact visual copy for %s while preserving the full description', (dueAt, relativeText, state, fullRelativeText) => {
] as const)('formats %s by local calendar day', (dueAt, absoluteText, relativeText, state) => {
const result = getTaskDuePresentation({ dueAt, dueHasTime: false, completed: false, nowMs }) const result = getTaskDuePresentation({ dueAt, dueHasTime: false, completed: false, nowMs })
expect(result).toMatchObject({ datetime: dueAt, absoluteText, relativeText, state }) expect(result).toMatchObject({ datetime: dueAt, relativeText, state })
expect(result?.fullText).toContain('截止日期:') expect(result?.relativeText).not.toMatch(/还有|已过期|月|日/)
expect(result?.fullText).toContain('2026年' === absoluteText.slice(0, 5) ? absoluteText : dueAt.startsWith('2026') ? `2026年${absoluteText}` : absoluteText) expect(result?.fullText).toContain(`截止日期:2026年${Number(dueAt.slice(5, 7))}${Number(dueAt.slice(8, 10))}`)
expect(result?.fullText).toContain(fullRelativeText)
}) })
it('compares calendar dates across a DST-sized 23 hour gap', () => { it('compares calendar dates across a DST-sized 23 hour gap', () => {
@@ -86,18 +86,18 @@ describe('getTaskDuePresentation date-only tasks', () => {
completed: false, completed: false,
nowMs: before.getTime(), nowMs: before.getTime(),
}) })
expect(result?.relativeText).toBe('还有1天') expect(result?.relativeText).toBe('1天')
expect(result?.fullText).toContain('还有1天')
}) })
it.each([ it.each([
['2026-09-09', '已完成(原定1天前)'], ['2026-09-09', '已完成(原定1天前)'],
['2026-09-10', '已完成(原定今天)'], ['2026-09-10', '已完成(原定今天)'],
['2026-09-12', '已完成(原定2天后)'], ['2026-09-12', '已完成(原定2天后)'],
])('uses completed neutral wording for %s', (dueAt, relativeText) => { ])('uses minimal completed visual copy and preserves the detailed description for %s', (dueAt, fullRelativeText) => {
expect(getTaskDuePresentation({ dueAt, dueHasTime: false, completed: true, nowMs })).toMatchObject({ const result = getTaskDuePresentation({ dueAt, dueHasTime: false, completed: true, nowMs })
relativeText, expect(result).toMatchObject({ relativeText: '已完成', state: 'completed' })
state: 'completed', expect(result?.fullText).toContain(fullRelativeText)
})
}) })
}) })
+7 -5
View File
@@ -161,13 +161,15 @@ export function getTaskDuePresentation(input: {
const dayDelta = calendarDelta(parsed.date, now) const dayDelta = calendarDelta(parsed.date, now)
const absoluteDateText = absoluteDate(parsed.date, now.getFullYear()) const absoluteDateText = absoluteDate(parsed.date, now.getFullYear())
const absoluteText = input.dueHasTime ? `${absoluteDateText} ${localTime(parsed.date)}` : absoluteDateText const absoluteText = input.dueHasTime ? `${absoluteDateText} ${localTime(parsed.date)}` : absoluteDateText
const completedDescription = completedRelative(dayDelta)
const dateOnlyDescription = dayDelta === 0
? '今天'
: dayDelta > 0 ? `还有${dayDelta}` : `已过期${Math.abs(dayDelta)}`
const relativeText = input.completed const relativeText = input.completed
? completedRelative(dayDelta) ? (input.dueHasTime ? completedDescription : '已完成')
: input.dueHasTime : input.dueHasTime
? timedRelative(parsed.date.getTime() - input.nowMs) ? timedRelative(parsed.date.getTime() - input.nowMs)
: dayDelta === 0 : dayDelta === 0 ? '今天' : `${Math.abs(dayDelta)}`
? '今天'
: dayDelta > 0 ? `还有${dayDelta}` : `已过期${Math.abs(dayDelta)}`
const state: TaskDueState = input.completed const state: TaskDueState = input.completed
? 'completed' ? 'completed'
: input.dueHasTime ? (parsed.date.getTime() < input.nowMs ? 'overdue' : 'neutral') : (dayDelta < 0 ? 'overdue' : 'neutral') : input.dueHasTime ? (parsed.date.getTime() < input.nowMs ? 'overdue' : 'neutral') : (dayDelta < 0 ? 'overdue' : 'neutral')
@@ -178,7 +180,7 @@ export function getTaskDuePresentation(input: {
datetime: parsed.datetime, datetime: parsed.datetime,
absoluteText, absoluteText,
relativeText, relativeText,
fullText: `${kind}${fullAbsolute}${relativeText}`, fullText: `${kind}${fullAbsolute}${input.completed ? completedDescription : input.dueHasTime ? relativeText : dateOnlyDescription}`,
state, state,
} }
} }
File diff suppressed because one or more lines are too long
+16 -7
View File
@@ -54,18 +54,27 @@ describe('unified task due display', () => {
expect(css).toContain('text-align:right;white-space:nowrap') expect(css).toContain('text-align:right;white-space:nowrap')
expect(css).toContain('.task-actions{display:flex;align-items:center;flex:0 0 auto}') expect(css).toContain('.task-actions{display:flex;align-items:center;flex:0 0 auto}')
expect(css).toContain('.task-row>.drag-handle,.task-row>.task-check,.task-actions>*{flex-shrink:0}') expect(css).toContain('.task-row>.drag-handle,.task-row>.task-check,.task-actions>*{flex-shrink:0}')
expect(css).toContain('@media(max-width:930px){.task-tail{flex:0 1 76px;max-width:76px;') expect(css).toContain('@media(max-width:930px){.task-tail{flex:0 1 auto;max-width:132px;')
expect(css).toContain('.task-row:has(>.task-tail){height:66px;max-height:68px;}') expect(css).toContain('.task-row:has(>.task-tail .task-due--timed){height:66px;max-height:68px;}')
expect(css).toContain('.task-main{padding:4px 0}') expect(css).toContain('.task-row:has(>.task-tail .task-due--timed) .task-main{padding:4px 0}')
expect(css).toContain('.task-due__text{display:grid;justify-items:end;overflow:hidden;text-overflow:ellipsis;') expect(css).toContain('.task-due--timed .task-due__text{display:grid;justify-items:end;overflow:hidden;text-overflow:ellipsis;')
expect(css).toContain('.task-due__absolute,.task-due__relative{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}') expect(css).toContain('.task-due--timed .task-due__absolute,.task-due--timed .task-due__relative{max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}')
expect(css).toContain('.task-due__separator{display:none}') expect(css).toContain('.task-due--timed .task-due__separator{display:none}')
expect(css).not.toContain('.task-main .meta{display:flex;flex-wrap:wrap;') expect(css).not.toContain('.task-main .meta{display:flex;flex-wrap:wrap;')
expect(css).toContain('.task-check{width:44px;height:44px;') expect(css).toContain('.task-check{width:44px;height:44px;')
expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;') expect(css).toContain('.icon,.ghost{min-width:44px;min-height:44px;')
expect(css).toMatch(/\.restore\{[^}]*min-height:44px/) expect(css).toMatch(/\.restore\{[^}]*min-height:44px/)
}) })
it('keeps date-only rows compact on mobile while preserving the timed two-line layout', () => {
expect(css).toContain('@media(max-width:930px){.task-tail{flex:0 1 auto;max-width:132px;')
expect(css).toContain('.task-tail:has(.task-due--timed){flex:0 1 76px;max-width:76px}')
expect(css).toContain('.task-row:has(>.task-tail .task-due--timed){height:66px;max-height:68px;}')
expect(css).not.toContain('.task-row:has(>.task-tail){height:66px;max-height:68px;}')
expect(css).toContain('.task-due--timed .task-due__text{display:grid;justify-items:end;')
expect(css).toContain('.task-due--timed .task-due__separator{display:none}')
})
it('scopes content metadata and keeps date semantics without a visible calendar icon', () => { it('scopes content metadata and keeps date semantics without a visible calendar icon', () => {
expect(css).toContain('.task-due--overdue{color:var(--danger)}') expect(css).toContain('.task-due--overdue{color:var(--danger)}')
expect(css).toContain('.task-due--neutral,.task-due--completed{color:var(--muted)}') expect(css).toContain('.task-due--neutral,.task-due--completed{color:var(--muted)}')
@@ -75,7 +84,7 @@ describe('unified task due display', () => {
expect(taskDueDisplay).not.toContain('CalendarDays') expect(taskDueDisplay).not.toContain('CalendarDays')
expect(taskDueDisplay).toContain('<time') expect(taskDueDisplay).toContain('<time')
expect(taskDueDisplay).toContain(':aria-label="presentation.fullText"') expect(taskDueDisplay).toContain(':aria-label="presentation.fullText"')
expect(taskDueDisplay).toContain('class="task-due__separator"') expect(taskDueDisplay).toContain('v-if="dueHasTime" class="task-due__separator"')
expect(css).not.toContain('.overdue-task .meta') expect(css).not.toContain('.overdue-task .meta')
expect(css).not.toContain('.overdue-badge') expect(css).not.toContain('.overdue-badge')
}) })