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
@@ -23,11 +23,47 @@ describe('PeriodPopover', () => {
vi.clearAllMocks()
})
/** The three delete scopes sit behind "Ta bort…" — press it first. */
function openDeleteMenu() {
fireEvent.click(screen.getByTestId('period-delete-menu-button'))
}
it('returns null when not open', () => {
const { container } = render(<PeriodPopover {...defaultProps} isOpen={false} />)
expect(container.firstChild).toBeNull()
})
describe('the delete menu', () => {
it('keeps the three delete scopes hidden until "Ta bort…" is pressed', () => {
render(<PeriodPopover {...defaultProps} />)
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
expect(screen.getByTestId('period-delete-menu-button')).toHaveTextContent('Ta bort…')
expect(screen.getByTestId('period-delete-menu-button')).toHaveAttribute('aria-expanded', 'false')
openDeleteMenu()
expect(screen.getByTestId('period-delete-menu-button')).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByTestId('period-delete-group')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-day-button')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-year-button')).toBeInTheDocument()
expect(screen.getByTestId('period-delete-all-years-button')).toBeInTheDocument()
})
it('folds the scopes away again on a second press', () => {
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
openDeleteMenu()
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
})
it('starts folded again each time the popover reopens', () => {
const { rerender } = render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
rerender(<PeriodPopover {...defaultProps} isOpen={false} />)
rerender(<PeriodPopover {...defaultProps} isOpen={true} />)
expect(screen.queryByTestId('period-delete-group')).not.toBeInTheDocument()
})
})
describe('ad-hoc band (no wish)', () => {
it('shows the ad-hoc notice and date range instead of editable fields', () => {
render(<PeriodPopover {...defaultProps} />)
@@ -43,6 +79,7 @@ describe('PeriodPopover', () => {
it('shows the ad-hoc "this period" label and disables "every year" (nothing recurring to remove)', () => {
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Ta bort dessa dagar')
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-all-years-button')).toBeDisabled()
@@ -54,6 +91,7 @@ describe('PeriodPopover', () => {
it('shows an inline confirmation instead of deleting immediately', () => {
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
expect(screen.getByTestId('period-delete-confirm')).toBeInTheDocument()
@@ -62,6 +100,7 @@ describe('PeriodPopover', () => {
it('calls onDelete with the "year" scope after confirming inline', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
@@ -70,6 +109,7 @@ describe('PeriodPopover', () => {
it('calls onDelete with the "day" scope for the day button', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
@@ -77,6 +117,7 @@ describe('PeriodPopover', () => {
it('does not call onDelete when the inline confirmation is cancelled', () => {
render(<PeriodPopover {...defaultProps} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-cancel-button'))
expect(mockOnDelete).not.toHaveBeenCalled()
@@ -394,6 +435,7 @@ describe('PeriodPopover', () => {
// the option is live — see Calendar.tsx's `excludeDay`.
it('enables "this day" for a wish-linked band and explains what it does', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
expect(screen.getByTestId('period-delete-day-button')).not.toBeDisabled()
expect(screen.getByTestId('period-delete-day-button')).toHaveAttribute(
'title',
@@ -406,6 +448,7 @@ describe('PeriodPopover', () => {
it('calls onDelete with the "day" scope for a wish-linked band after confirmation', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('day'))
@@ -420,6 +463,7 @@ describe('PeriodPopover', () => {
removeDayParentName="Alex"
/>,
)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Ta bort 17 juni 2028 för Alex?',
@@ -428,6 +472,7 @@ describe('PeriodPopover', () => {
it('falls back to the generic confirmation when the parent is unknown', () => {
render(<PeriodPopover {...defaultProps} wish={wish} removeDayLabel="17 juni 2028" />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-day-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort den här dagen?',
@@ -437,6 +482,7 @@ describe('PeriodPopover', () => {
it('displays a generic local error when onDelete rejects with a non-Error value', async () => {
mockOnDelete.mockRejectedValue('boom')
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => {
@@ -447,6 +493,7 @@ describe('PeriodPopover', () => {
it('calls onDelete with the "allYears" scope for a wish-linked band after confirmation', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-all-years-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('allYears'))
@@ -455,6 +502,7 @@ describe('PeriodPopover', () => {
it('calls onDelete with the "year" scope for a wish-linked band', async () => {
mockOnDelete.mockResolvedValue(undefined)
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
fireEvent.click(screen.getByTestId('period-delete-confirm-button'))
await waitFor(() => expect(mockOnDelete).toHaveBeenCalledWith('year'))
@@ -462,12 +510,14 @@ describe('PeriodPopover', () => {
it('shows the wish-linked "this period" and "every year" labels', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
expect(screen.getByTestId('period-delete-year-button')).toHaveTextContent('Den här perioden')
expect(screen.getByTestId('period-delete-all-years-button')).toHaveTextContent('Den här perioden, varje år')
})
it('shows a scope-specific confirmation message for each delete button', () => {
render(<PeriodPopover {...defaultProps} wish={wish} />)
openDeleteMenu()
fireEvent.click(screen.getByTestId('period-delete-year-button'))
expect(screen.getByTestId('period-delete-confirm')).toHaveTextContent(
'Är du säker på att du vill ta bort den här perioden (bara det här året)?',
@@ -519,7 +569,9 @@ describe('PeriodPopover', () => {
describe('close behaviour', () => {
it('calls onClose on the close button', () => {
render(<PeriodPopover {...defaultProps} />)
fireEvent.click(screen.getByLabelText('Stäng'))
const closeButton = screen.getByLabelText('Stäng')
expect(closeButton.querySelector('[data-icon="close"]')).toBeInTheDocument()
fireEvent.click(closeButton)
expect(mockOnClose).toHaveBeenCalled()
})