chore(web): add Vitest; add dayCapabilities helper with full test coverage

- Install vitest + jsdom
- Add test/test:watch scripts to package.json
- Add test:web and test:all tasks to mise.toml
- Add dayCapabilities() to utils.ts — single source of truth for
  what actions are permitted per day (future/today/past, open/closed)
- Add DayCapabilities interface to utils.ts
- 11 unit tests: dayCapabilities (5 cases), weekDayKeys (3 cases),
  isWorkday (3 cases)
This commit is contained in:
2026-04-30 19:03:04 +02:00
parent 73e5b01577
commit 6c4f78d101
6 changed files with 1055 additions and 3 deletions

84
web/src/lib/utils.test.ts Normal file
View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { dayCapabilities, weekDayKeys, isWorkday } from './utils';
describe('dayCapabilities', () => {
const today = '2026-04-30';
it('future day: only canMarkKind', () => {
const cap = dayCapabilities('2026-05-01', today, false);
expect(cap.canStartStop).toBe(false);
expect(cap.canAddInterval).toBe(false);
expect(cap.canEditEntries).toBe(false);
expect(cap.canMarkKind).toBe(true);
expect(cap.canCloseDay).toBe(false);
expect(cap.canReopenDay).toBe(false);
});
it('today, open: full access except reopen', () => {
const cap = dayCapabilities(today, today, false);
expect(cap.canStartStop).toBe(true);
expect(cap.canAddInterval).toBe(true);
expect(cap.canEditEntries).toBe(true);
expect(cap.canMarkKind).toBe(true);
expect(cap.canCloseDay).toBe(true);
expect(cap.canReopenDay).toBe(false);
});
it('today, closed: only markKind and reopen', () => {
const cap = dayCapabilities(today, today, true);
expect(cap.canStartStop).toBe(false);
expect(cap.canAddInterval).toBe(false);
expect(cap.canEditEntries).toBe(false);
expect(cap.canMarkKind).toBe(true);
expect(cap.canCloseDay).toBe(false);
expect(cap.canReopenDay).toBe(true);
});
it('past, open: no start/stop; can add/edit/mark/close', () => {
const cap = dayCapabilities('2026-04-29', today, false);
expect(cap.canStartStop).toBe(false);
expect(cap.canAddInterval).toBe(true);
expect(cap.canEditEntries).toBe(true);
expect(cap.canMarkKind).toBe(true);
expect(cap.canCloseDay).toBe(true);
expect(cap.canReopenDay).toBe(false);
});
it('past, closed: only markKind and reopen', () => {
const cap = dayCapabilities('2026-04-28', today, true);
expect(cap.canStartStop).toBe(false);
expect(cap.canAddInterval).toBe(false);
expect(cap.canEditEntries).toBe(false);
expect(cap.canMarkKind).toBe(true);
expect(cap.canCloseDay).toBe(false);
expect(cap.canReopenDay).toBe(true);
});
});
describe('weekDayKeys', () => {
it('2026-W18: Mon Apr 27 Sun May 3', () => {
const keys = weekDayKeys('2026-W18');
expect(keys[0]).toBe('2026-04-27');
expect(keys[6]).toBe('2026-05-03');
expect(keys).toHaveLength(7);
});
it('2024-W03: Mon Jan 15 Sun Jan 21', () => {
const keys = weekDayKeys('2024-W03');
expect(keys[0]).toBe('2024-01-15');
expect(keys[6]).toBe('2024-01-21');
});
it('2023-W01: Mon Jan 2 Sun Jan 8', () => {
const keys = weekDayKeys('2023-W01');
expect(keys[0]).toBe('2023-01-02');
expect(keys[6]).toBe('2023-01-08');
});
});
describe('isWorkday', () => {
const monToFri = 31; // bits 1+2+4+8+16
it('Monday is a workday', () => expect(isWorkday('2026-04-27', monToFri)).toBe(true));
it('Saturday is not a workday', () => expect(isWorkday('2026-05-02', monToFri)).toBe(false));
it('Sunday is not a workday', () => expect(isWorkday('2026-05-03', monToFri)).toBe(false));
});

View File

@@ -100,3 +100,61 @@ export function isWorkday(dayKey: string, mask: number): boolean {
const d = new Date(dayKey + 'T12:00:00Z');
return (weekdayBit(d.getUTCDay()) & mask) !== 0;
}
/** Capabilities for a given day in the day detail panel. */
export interface DayCapabilities {
canStartStop: boolean;
canAddInterval: boolean;
canEditEntries: boolean;
canMarkKind: boolean;
canCloseDay: boolean;
canReopenDay: boolean;
}
/**
* Compute what actions are permitted for a given day.
*
* @param dayKey The day in question (YYYY-MM-DD).
* @param today Today's day key (YYYY-MM-DD).
* @param isClosed Whether the day has a closed_days row.
*/
export function dayCapabilities(
dayKey: string,
today: string,
isClosed: boolean
): DayCapabilities {
const isFuture = dayKey > today;
const isToday = dayKey === today;
if (isFuture) {
return {
canStartStop: false,
canAddInterval: false,
canEditEntries: false,
canMarkKind: true,
canCloseDay: false,
canReopenDay: false
};
}
if (isClosed) {
return {
canStartStop: false,
canAddInterval: false,
canEditEntries: false,
canMarkKind: true,
canCloseDay: false,
canReopenDay: true
};
}
// Past or today, open
return {
canStartStop: isToday,
canAddInterval: true,
canEditEntries: true,
canMarkKind: true,
canCloseDay: true,
canReopenDay: false
};
}