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
+36 -35
View File
@@ -336,9 +336,8 @@ describe('Calendar', () => {
// week" logic would add a 6th row of 7 disabled June cells that serves
// no purpose.
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
fireEvent.change(yearSelect, { target: { value: '2026' } })
fireEvent.change(monthSelect, { target: { value: '4' } })
@@ -374,10 +373,20 @@ describe('Calendar', () => {
}
})
it('displays current month title', () => {
it('displays the month and year as real title text, capitalised', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
expect(title.textContent).toMatch(/\d{4}/)
expect(title.textContent).toMatch(
/^(Januari|Februari|Mars|April|Maj|Juni|Juli|Augusti|September|Oktober|November|December) \d{4}$/,
)
// The title holds no controls: the month/year selects live beside it.
expect(title.querySelector('select')).toBeNull()
})
it('renders the help popover button in place of the old legend', () => {
render(<Calendar />)
expect(screen.getByTestId('calendar-help-button')).toBeInTheDocument()
expect(document.querySelector('.calendar__legend')).toBeNull()
})
it('handles next/prev month navigation', () => {
@@ -405,9 +414,8 @@ describe('Calendar', () => {
it('jumps forward/backward via month and year dropdowns', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
fireEvent.change(monthSelect, { target: { value: '5' } })
expect(monthSelect.value).toBe('5')
@@ -418,9 +426,8 @@ describe('Calendar', () => {
it('Today button jumps back to the current month/year after navigating away', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
fireEvent.change(monthSelect, { target: { value: '5' } })
fireEvent.change(yearSelect, { target: { value: '2035' } })
@@ -438,8 +445,7 @@ describe('Calendar', () => {
makeCalendarDaysResult({ data: makeCalendarWindow({ minYear: 2026, maxYear: 2055 }) }),
)
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(Math.min(...options)).toBe(2026)
expect(Math.max(...options)).toBe(2055)
@@ -448,8 +454,7 @@ describe('Calendar', () => {
it('always includes the current year even before the calendar window has loaded', () => {
vi.mocked(useCalendarDays).mockReturnValue(makeCalendarDaysResult({ data: null, isLoading: true }))
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const options = Array.from(yearSelect.options).map((opt) => parseInt(opt.value))
expect(options).toContain(new Date().getFullYear())
})
@@ -462,10 +467,8 @@ describe('Calendar', () => {
])
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
expect(yearSelect.value).toBe('2030')
expect(monthSelect.value).toBe('6') // July is month index 6
@@ -485,9 +488,8 @@ describe('Calendar', () => {
render(<Calendar />)
const today = new Date()
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
expect(yearSelect.value).toBe(String(today.getFullYear()))
expect(monthSelect.value).toBe(String(today.getMonth()))
expect(screen.queryByTestId('modal')).not.toBeInTheDocument()
@@ -499,9 +501,8 @@ describe('Calendar', () => {
render(<Calendar />)
const today = new Date()
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
expect(yearSelect.value).toBe(String(today.getFullYear()))
expect(monthSelect.value).toBe(String(today.getMonth()))
})
@@ -686,9 +687,8 @@ describe('Calendar', () => {
it('a leading week\'s in-month days are draggable (regression: the week used to be gated by weekDates[0]\'s month, not the displayed month)', () => {
render(<Calendar />)
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
const monthSelect = screen.getByLabelText('Välj månad') as HTMLSelectElement
// March 2026 starts on a Sunday, so its first calendar row is a
// leading week made up of six February days plus March 1st.
fireEvent.change(yearSelect, { target: { value: '2026' } })
@@ -1037,8 +1037,7 @@ describe('Calendar', () => {
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => {
const title = screen.getByRole('heading', { level: 2 })
const yearSelect = title.querySelector('.calendar__year-select') as HTMLSelectElement
const yearSelect = screen.getByLabelText('Välj år') as HTMLSelectElement
expect(yearSelect.value).toBe('2030')
})
})
@@ -1448,9 +1447,11 @@ describe('Calendar', () => {
<Calendar />
</LanguageProvider>,
)
const title = screen.getByRole('heading', { level: 2 })
const monthSelect = title.querySelector('.calendar__month-select') as HTMLSelectElement
expect(monthSelect.getAttribute('aria-label')).toBe('Select month')
expect(screen.getByLabelText('Select month')).toBeInTheDocument()
// The title is real text now, in the English month name.
expect(screen.getByRole('heading', { level: 2 }).textContent).toMatch(
/^(January|February|March|April|May|June|July|August|September|October|November|December) \d{4}$/,
)
} finally {
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
vi.unstubAllGlobals()
@@ -1624,12 +1625,12 @@ describe('Calendar', () => {
render(<Calendar />)
await waitFor(() => expect(sync).toHaveBeenCalledTimes(1))
const yearBefore = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value
const yearBefore = (screen.getByLabelText('Välj år') as HTMLSelectElement).value
fireEvent.click(screen.getByTestId('preset-click'))
await waitFor(() => expect(screen.getByTestId('calendar-toast')).toHaveTextContent('sync failed'))
const yearAfter = (screen.getByRole('heading', { level: 2 }).querySelector('.calendar__year-select') as HTMLSelectElement).value
const yearAfter = (screen.getByLabelText('Välj år') as HTMLSelectElement).value
expect(yearAfter).toBe(yearBefore)
})