Redraw the calendar as an almanac: gridless cells, typed bands

Day cells lose their borders and fills; red days, klämdagar and
Saturdays are told apart by the numeral's ink with a 12px R/K mark,
today is a filled circle, overrides and exclusions mark the numeral.
Bands carry their funding type by fill, ink and hatching and their
owner by a left cap. The preset rail becomes a chip row, the legend a
help popover, the period popover's three delete buttons fold behind
one "Ta bort…", and solo users pick a type directly in the day modal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
This commit is contained in:
2026-09-19 19:02:02 +02:00
parent d14f86478a
commit dbef997da4
28 changed files with 1486 additions and 1109 deletions
+18 -11
View File
@@ -10,9 +10,10 @@
* default it now creates a fixed, one-off `LeaveWish` and lets the planner
* fund it (a `calendar-band-wish:` band, not `adhoc:`) — see
* `Calendar.tsx`'s `handleSaveWish`. The old "write exact ad-hoc
* `planned_leave_day` rows" behaviour still exists, behind the modal's
* "Advanced" disclosure (`handleSavePinnedDay`) — the first test below
* opens it explicitly to keep exercising that path.
* `planned_leave_day` rows" behaviour still exists (`handleSavePinnedDay`):
* a solo household reaches it by picking a concrete type in the modal's
* type select (a couple through the "Advanced" disclosure) — the first
* test below does so explicitly to keep exercising that path.
*
* The drag deliberately crosses a month boundary (the last two days of one
* month into the first day of the next) — a regression guard for the
@@ -57,7 +58,7 @@ function targetRange(): { month: Date; dates: [string, string, string] } {
return { month, dates }
}
test('dragging a range and pinning it through Advanced plans every day exactly, and deleting it removes the days for good', async ({
test('dragging a range and pinning an exact type plans every day exactly, and deleting it removes the days for good', async ({
page,
}) => {
await registerAndReachOnboarding(page, uniqueEmail('drag-delete'), 'Robin Testsson')
@@ -79,10 +80,12 @@ test('dragging a range and pinning it through Advanced plans every day exactly,
// A multi-day drag opens the day popover pre-selected on every dragged
// date — it does not create a period/wish by itself.
await expect(page.getByTestId('modal-title')).toHaveText('Planera 3 dagar')
// The default save path would create a fixed wish instead (see the next
// test) — opening "Advanced" keeps this test on the exact-pinned-days
// path todo.md #10/#11 kept as an escape hatch.
await page.getByTestId('advanced-toggle').click()
// The default save path ("let the planner choose") would create a fixed
// wish instead (see the next test) — picking a concrete type keeps this
// test on the exact-pinned-days path todo.md #10/#11 kept as an escape
// hatch. A solo account has no "Advanced" toggle: the select is right
// there.
await page.getByTestId('day-type-select').selectOption('vacation')
await page.getByTestId('save-button').click()
await expect(page.getByTestId('planned-day-modal__content')).not.toBeVisible()
@@ -112,9 +115,11 @@ test('dragging a range and pinning it through Advanced plans every day exactly,
await expect(page.getByTestId('period-adhoc-notice')).toBeVisible()
await expect(page.getByTestId('period-popover__content')).toContainText(`${firstDate} – ${lastDate}`)
// todo.md #9 — an ad-hoc band has no recurring plan, so "every year" is
// disabled; "this day"/"this period" are the only meaningful choices, and
// "this period" removes the whole ad-hoc range (today's only behaviour).
// todo.md #9 — the three delete scopes sit behind "Ta bort…". An ad-hoc
// band has no recurring plan, so "every year" is disabled; "this day"/
// "this period" are the only meaningful choices, and "this period"
// removes the whole ad-hoc range (today's only behaviour).
await page.getByTestId('period-delete-menu-button').click()
await expect(page.getByTestId('period-delete-all-years-button')).toBeDisabled()
await page.getByTestId('period-delete-year-button').click()
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
@@ -183,6 +188,7 @@ test('a drag saved without opening Advanced creates a planner-funded wish, and t
// thing, same as "every year".
await wishBand.first().click()
await expect(page.getByTestId('period-popover__content')).toBeVisible()
await page.getByTestId('period-delete-menu-button').click()
await expect(page.getByTestId('period-delete-day-button')).toBeEnabled()
await page.getByTestId('period-delete-year-button').click()
@@ -224,6 +230,7 @@ test('removing a single day out of a planner-placed period keeps it removed acro
// `clickedDate`). Either way the popover's "this day" removes exactly one.
await wishBand.first().click()
await expect(page.getByTestId('period-popover__content')).toBeVisible()
await page.getByTestId('period-delete-menu-button').click()
await page.getByTestId('period-delete-day-button').click()
await expect(page.getByTestId('period-delete-confirm')).toBeVisible()
await page.getByTestId('period-delete-confirm-button').click()
+30 -12
View File
@@ -24,40 +24,52 @@ import { calendarDayCell, goToCalendarMonth } from '../support/calendarNav'
* element actually painted with it — i.e. exactly what `toHaveCSS` compares
* against. Reading the token instead of a hardcoded literal means a future
* restyle that moves the palette can't silently desync this spec from
* calendar.css: whatever `--c-danger-weak`/`--c-weekend-bg` resolve to at
* test time is what's asserted.
* calendar.css: whatever `--c-danger`/`--c-text-faint` resolve to at test
* time is what's asserted.
*/
async function resolveCssVar(page: Page, varName: string): Promise<string> {
return page.evaluate((name) => {
const probe = document.createElement('div')
probe.style.backgroundColor = `var(${name})`
probe.style.color = `var(${name})`
document.body.appendChild(probe)
const resolved = getComputedStyle(probe).backgroundColor
const resolved = getComputedStyle(probe).color
probe.remove()
return resolved
}, varName)
}
/** The day's numeral — the element that carries the red/Saturday colour in
* the Almanacka design (cells themselves have no fill). */
function dayNumeral(cell: ReturnType<typeof calendarDayCell>) {
return cell.locator('.calendar-day__number')
}
test.describe('red days, klämdagar and the combined-holiday-name trap', () => {
test('Easter Sunday (Påskdagen) renders as a named red day', async ({ page }) => {
const easter = nextEasterOnOrAfter(new Date())
await goToCalendarMonth(page, easter)
const redDayBg = await resolveCssVar(page, '--c-danger-weak') // .calendar-day--red-day
const redInk = await resolveCssVar(page, '--c-danger') // .calendar-day--red-day .calendar-day__number
const redFill = await resolveCssVar(page, '--c-danger-weak')
const cell = calendarDayCell(page, toIsoDate(easter))
await expect(cell).toHaveClass(/calendar-day--red-day/)
await expect(cell).toHaveCSS('background-color', redDayBg)
// The numeral carries the colour; the cell itself has no red fill.
await expect(dayNumeral(cell)).toHaveCSS('color', redInk)
await expect(cell).not.toHaveCSS('background-color', redFill)
await expect(cell.getByTestId('calendar-day-type-badge')).toHaveText('R')
await expect(cell.getByTestId('calendar-day-holiday-name')).toHaveText('Påskdagen')
})
test('Whit Sunday (Pingstdagen) renders as a named red day', async ({ page }) => {
const whitsun = whitSunday(nextEasterOnOrAfter(new Date()))
await goToCalendarMonth(page, whitsun)
const redDayBg = await resolveCssVar(page, '--c-danger-weak') // .calendar-day--red-day
const redInk = await resolveCssVar(page, '--c-danger') // .calendar-day--red-day .calendar-day__number
const redFill = await resolveCssVar(page, '--c-danger-weak')
const cell = calendarDayCell(page, toIsoDate(whitsun))
await expect(cell).toHaveClass(/calendar-day--red-day/)
await expect(cell).toHaveCSS('background-color', redDayBg)
await expect(dayNumeral(cell)).toHaveCSS('color', redInk)
await expect(cell).not.toHaveCSS('background-color', redFill)
await expect(cell.getByTestId('calendar-day-holiday-name')).toHaveText('Pingstdagen')
})
@@ -77,18 +89,24 @@ test.describe('red days, klämdagar and the combined-holiday-name trap', () => {
// that first Mon–Sun week row (see Calendar.tsx's `startOffset`) — so
// this never depends on the two dates sharing a calendar month.
await goToCalendarMonth(page, plainSunday)
const redDayBg = await resolveCssVar(page, '--c-danger-weak') // .calendar-day--red-day
const weekendBg = await resolveCssVar(page, '--c-weekend-bg') // .calendar-day--weekend
const redInk = await resolveCssVar(page, '--c-danger') // .calendar-day--red-day .calendar-day__number
const redFill = await resolveCssVar(page, '--c-danger-weak')
const saturdayInk = await resolveCssVar(page, '--c-text-faint') // .calendar-day--weekend .calendar-day__number
const sundayCell = calendarDayCell(page, toIsoDate(plainSunday))
await expect(sundayCell).toHaveClass(/calendar-day--red-day/)
await expect(sundayCell).not.toHaveClass(/calendar-day--weekend/)
await expect(sundayCell).toHaveCSS('background-color', redDayBg)
await expect(dayNumeral(sundayCell)).toHaveCSS('color', redInk)
await expect(sundayCell).not.toHaveCSS('background-color', redFill)
await expect(sundayCell.getByTestId('calendar-day-type-badge')).toHaveText('R')
await expect(sundayCell.getByTestId('calendar-day-holiday-name')).toHaveCount(0)
const saturdayCell = calendarDayCell(page, toIsoDate(plainSaturday))
await expect(saturdayCell).toHaveClass(/calendar-day--weekend/)
await expect(saturdayCell).not.toHaveClass(/calendar-day--red-day/)
await expect(saturdayCell).toHaveCSS('background-color', weekendBg)
// A plain Saturday is a faint numeral, not a red one, and no fill.
await expect(dayNumeral(saturdayCell)).toHaveCSS('color', saturdayInk)
await expect(dayNumeral(saturdayCell)).not.toHaveCSS('color', redInk)
await expect(saturdayCell).not.toHaveCSS('background-color', redFill)
})
})