fix: align task due dates to the right
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, normalizeRequiredName, numericHabitInputValue, performHabitRestore, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
||||
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
||||
|
||||
describe('MVP view utilities', () => {
|
||||
it('formats a local date as YYYY-MM-DD', () => {
|
||||
@@ -17,6 +17,45 @@ describe('MVP view utilities', () => {
|
||||
expect(['habits', 'settings', 'trash'].filter(isTaskView)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps Trash totals aligned after restoring or purging visible rows', () => {
|
||||
expect(nextTotalAfterLocalTaskRemoval(2, 1)).toBe(1)
|
||||
expect(nextTotalAfterLocalTaskRemoval(0, 1)).toBe(0)
|
||||
expect(nextTotalAfterLocalTaskRemoval(Number.NaN, 1)).toBe(0)
|
||||
})
|
||||
|
||||
it('does not reconcile Trash state when the mutation fails', async () => {
|
||||
let reconciled = 0
|
||||
const result = await performTrashMutation(
|
||||
async () => { throw new Error('mutation failed') },
|
||||
() => { reconciled += 1 },
|
||||
async () => true,
|
||||
)
|
||||
expect(result).toEqual({ mutated: false, refreshed: false, error: expect.any(Error) })
|
||||
expect(reconciled).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps confirmed Trash reconciliation when the refresh fails', async () => {
|
||||
let reconciled = 0
|
||||
const result = await performTrashMutation(
|
||||
async () => undefined,
|
||||
() => { reconciled += 1 },
|
||||
async () => false,
|
||||
)
|
||||
expect(result).toEqual({ mutated: true, refreshed: false })
|
||||
expect(reconciled).toBe(1)
|
||||
})
|
||||
|
||||
it('reports a fully reconciled Trash mutation after refresh succeeds', async () => {
|
||||
let reconciled = 0
|
||||
const result = await performTrashMutation(
|
||||
async () => undefined,
|
||||
() => { reconciled += 1 },
|
||||
async () => true,
|
||||
)
|
||||
expect(result).toEqual({ mutated: true, refreshed: true })
|
||||
expect(reconciled).toBe(1)
|
||||
})
|
||||
|
||||
it('restores the last page and selected task list after refresh', () => {
|
||||
const storage = new Map<string, string>()
|
||||
const fakeStorage = {
|
||||
|
||||
@@ -141,6 +141,28 @@ export function nextTotalAfterLocalTaskAdd(total: number) {
|
||||
return Math.max(0, Number(total) || 0) + 1
|
||||
}
|
||||
|
||||
export function nextTotalAfterLocalTaskRemoval(total: number, removed = 1) {
|
||||
return Math.max(0, (Number(total) || 0) - Math.max(0, removed))
|
||||
}
|
||||
|
||||
export type TrashMutationResult =
|
||||
| { mutated: false; refreshed: false; error: unknown }
|
||||
| { mutated: true; refreshed: boolean }
|
||||
|
||||
export async function performTrashMutation(
|
||||
mutate: () => Promise<unknown>,
|
||||
reconcileLocal: () => void,
|
||||
refresh: () => Promise<boolean>,
|
||||
): Promise<TrashMutationResult> {
|
||||
try {
|
||||
await mutate()
|
||||
} catch (error) {
|
||||
return { mutated: false, refreshed: false, error }
|
||||
}
|
||||
reconcileLocal()
|
||||
return { mutated: true, refreshed: await refresh() }
|
||||
}
|
||||
|
||||
export function clampFabPosition(x: number, y: number, viewportWidth: number, viewportHeight: number, size = 52, margin = 14, bottomReserved = 74) {
|
||||
return {
|
||||
x: Math.min(Math.max(x, margin), Math.max(margin, viewportWidth - size - margin)),
|
||||
@@ -190,14 +212,19 @@ export async function runLatestRequest<T>(
|
||||
},
|
||||
) {
|
||||
const generation = beginLatestRequest(key)
|
||||
let committed = false
|
||||
try {
|
||||
const value = await request()
|
||||
if (isLatestRequest(key, generation)) callbacks.success(value)
|
||||
if (isLatestRequest(key, generation)) {
|
||||
callbacks.success(value)
|
||||
committed = true
|
||||
}
|
||||
} catch (reason) {
|
||||
if (isLatestRequest(key, generation)) callbacks.error(reason)
|
||||
} finally {
|
||||
if (isLatestRequest(key, generation)) callbacks.finally()
|
||||
}
|
||||
return committed
|
||||
}
|
||||
|
||||
export type MutationReconciler<TContext> = {
|
||||
|
||||
@@ -159,6 +159,31 @@ describe('request generation protection', () => {
|
||||
expect(events).toEqual(['new:new', 'new:finally'])
|
||||
})
|
||||
|
||||
it('returns whether a latest request committed successfully', async () => {
|
||||
await expect(runLatestRequest('trash-result-success', async () => 'ok', {
|
||||
success: () => undefined,
|
||||
error: () => undefined,
|
||||
finally: () => undefined,
|
||||
})).resolves.toBe(true)
|
||||
await expect(runLatestRequest('trash-result-failure', async () => { throw new Error('offline') }, {
|
||||
success: () => undefined,
|
||||
error: () => undefined,
|
||||
finally: () => undefined,
|
||||
})).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('returns false when a request becomes stale before it settles', async () => {
|
||||
let finish!: () => void
|
||||
const request = runLatestRequest('trash-result-stale', () => new Promise<void>((resolve) => { finish = resolve }), {
|
||||
success: () => undefined,
|
||||
error: () => undefined,
|
||||
finally: () => undefined,
|
||||
})
|
||||
beginLatestRequest('trash-result-stale')
|
||||
finish()
|
||||
await expect(request).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('prevents a stale Trash rejection from surfacing after a new view starts', async () => {
|
||||
const events: string[] = []
|
||||
let rejectOld!: (reason: Error) => void
|
||||
|
||||
Reference in New Issue
Block a user