From a14655a831308bc4defe1e6a970c8f0fb85ab5ae Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Wed, 23 Sep 2026 09:20:01 +0200 Subject: [PATCH] Label each leave-type change on calendar bands and add a per-day hover card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A band showed one label for its first day's type, so a mid-band type change had no text and the label ran over dark fills in the wrong ink. Each same-type run now gets its own label and ink (parent name only on the first), the lägsta outline covers only lägsta days, and hovering/focusing a day shows a card with parent, date, type, dubbeldag/pinned flags and the period range. Co-Authored-By: Claude Opus 5.5 --- .../calendar/BandDayHoverCard.test.tsx | 79 +++++ .../components/calendar/BandDayHoverCard.tsx | 123 +++++++ .../calendar/band-day-hover-card.css | 63 ++++ .../components/common/CalendarWeek.test.tsx | 243 ++++++++++++- .../src/components/common/CalendarWeek.tsx | 323 +++++++++++++++--- frontend/src/i18n/en/calendar.ts | 8 + frontend/src/i18n/sv/calendar.ts | 9 + frontend/src/pages/calendar.css | 53 ++- frontend/src/styles/primitives.css | 37 +- frontend/src/utils/dates.ts | 18 + 10 files changed, 868 insertions(+), 88 deletions(-) create mode 100644 frontend/src/components/calendar/BandDayHoverCard.test.tsx create mode 100644 frontend/src/components/calendar/BandDayHoverCard.tsx create mode 100644 frontend/src/components/calendar/band-day-hover-card.css diff --git a/frontend/src/components/calendar/BandDayHoverCard.test.tsx b/frontend/src/components/calendar/BandDayHoverCard.test.tsx new file mode 100644 index 0000000..8f46fe9 --- /dev/null +++ b/frontend/src/components/calendar/BandDayHoverCard.test.tsx @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { BandDayHoverCard } from './BandDayHoverCard' + +// No i18n mock here (unlike CalendarWeek.test.tsx) — this exercises the real +// catalogue/interpolation, since the card's whole job is composing several +// translated fragments (type, flags, period range) into one small tooltip. +// tests/setup.ts pins jsdom's navigator.language to sv-SE, so this renders +// the real Swedish copy. + +describe('BandDayHoverCard', () => { + const baseProps = { + id: 'hover-card-1', + parentName: 'Alice', + date: '2026-06-05', + isGap: false, + isDubbeldag: false, + isPinned: false, + periodStart: '2026-06-01', + periodEnd: '2026-06-07', + // A detached element is enough for content assertions — jsdom has no + // real layout engine, so `getBoundingClientRect()` on it (or on any + // attached element) returns an all-zero rect either way. Positioning + // itself is this component's own concern and isn't asserted here. + anchorEl: document.createElement('div'), + } + + it('renders as a tooltip with the given id', () => { + render() + const tooltip = screen.getByRole('tooltip') + expect(tooltip).toHaveAttribute('id', 'hover-card-1') + }) + + it('shows the parent name and the day type label', () => { + render() + const tooltip = screen.getByRole('tooltip') + expect(tooltip.textContent).toContain('Alice') + expect(tooltip.textContent).toContain('Sjukpenningnivå') + }) + + it('shows the weekday and date', () => { + render() + // 2026-06-05 is a Friday. + expect(screen.getByRole('tooltip').textContent).toContain('fredag') + }) + + it('shows a gap-day explanation instead of a funding type for a bridged gap day', () => { + render() + const tooltip = screen.getByRole('tooltip') + expect(tooltip.textContent).not.toContain('Semester') + expect(tooltip.textContent).not.toContain('Sjukpenningnivå') + expect(tooltip.textContent).toContain('Helg') + }) + + it('shows the dubbeldag flag only when the day is a dubbeldag', () => { + const { rerender } = render() + expect(screen.queryByText('Dubbeldag')).not.toBeInTheDocument() + + rerender() + expect(screen.getByText('Dubbeldag')).toBeInTheDocument() + }) + + it('shows the pinned flag (reusing calendarDay.pinnedAria wording) only when pinned', () => { + const { rerender } = render() + expect(screen.queryByText(/låst av dig/)).not.toBeInTheDocument() + + rerender() + expect(screen.getByText(/låst av dig/)).toBeInTheDocument() + }) + + it('shows the period range and day count', () => { + render() + const tooltip = screen.getByRole('tooltip') + expect(tooltip.textContent).toContain('2026-06-01') + expect(tooltip.textContent).toContain('2026-06-07') + // Inclusive range: 7 days. + expect(tooltip.textContent).toContain('7') + }) +}) diff --git a/frontend/src/components/calendar/BandDayHoverCard.tsx b/frontend/src/components/calendar/BandDayHoverCard.tsx new file mode 100644 index 0000000..f65fa34 --- /dev/null +++ b/frontend/src/components/calendar/BandDayHoverCard.tsx @@ -0,0 +1,123 @@ +import { ReactNode, useLayoutEffect, useRef, useState } from 'react' +import type { PlannedLeaveDay } from '@/types' +import { useTranslation } from '@/i18n' +import { getLabelForType } from '@/utils/leaveTypes' +import { datesInRange, formatWeekdayAndDate } from '@/utils/dates' +import './band-day-hover-card.css' + +/** Vertical gap between the card and the slot it describes, and the margin + * kept from the viewport edge when clamping horizontally — both in px. */ +const ANCHOR_GAP_PX = 6 +const VIEWPORT_MARGIN_PX = 8 +/** Fallback size used before the card has painted once (`offsetWidth`/ + * `offsetHeight` are 0 pre-layout) — matches `band-day-hover-card.css`'s + * `max-width`, so the very first frame's guess is already close. */ +const FALLBACK_WIDTH_PX = 220 +const FALLBACK_HEIGHT_PX = 60 + +export interface BandDayHoverCardProps { + /** DOM id this card is described by (`aria-describedby` on the hovered + * slot points at it) — see `CalendarWeek`'s `useId()`-generated id. */ + id: string + parentName: string + date: string + /** `null` for a bridged gap day (weekend/red day the band passes through + * without a funded row) — see `BandDayInfo.isGap` in CalendarWeek. */ + dayType: PlannedLeaveDay['dayType'] | null + isGap: boolean + isDubbeldag: boolean + isPinned: boolean + /** The whole period's date range (not just this row's slice of it), so + * the card always reports the period's true length. */ + periodStart: string + periodEnd: string + /** The hovered/focused `.calendar-band__day` slot the card describes and + * positions itself against. `CalendarWeek` portals this component to + * `document.body` (see its render) specifically so the card can escape + * `.calendar-week`'s `overflow: hidden` — that clip is deliberate there + * (it stops a band bleeding into the next row) but would otherwise also + * clip this card, which needs to poke out above the row. Positioning is + * computed from `anchorEl`'s real screen position instead of the band's + * own grid/percentage math, so it works the same regardless of which + * ancestor would otherwise have clipped it, and flips below when hovering + * the calendar's very first row leaves no room above the viewport. + */ + anchorEl: HTMLElement +} + +export function BandDayHoverCard({ + id, + parentName, + date, + dayType, + isGap, + isDubbeldag, + isPinned, + periodStart, + periodEnd, + anchorEl, +}: BandDayHoverCardProps): ReactNode { + const { t, locale } = useTranslation() + const cardRef = useRef(null) + const [position, setPosition] = useState<{ top: number; left: number } | null>(null) + + useLayoutEffect(() => { + const updatePosition = (): void => { + const anchorRect = anchorEl.getBoundingClientRect() + const cardWidth = cardRef.current?.offsetWidth || FALLBACK_WIDTH_PX + const cardHeight = cardRef.current?.offsetHeight || FALLBACK_HEIGHT_PX + + // Prefer above the slot; flip below when there isn't room (the + // calendar's top week row has nothing above it to clip into anyway). + let top = anchorRect.top - cardHeight - ANCHOR_GAP_PX + if (top < VIEWPORT_MARGIN_PX) { + top = anchorRect.bottom + ANCHOR_GAP_PX + } + + // Center on the slot, then clamp so a day near either edge (the + // Sunday column, or the leading Monday when week numbers are off) + // never pushes the card off-screen. + let left = anchorRect.left + anchorRect.width / 2 - cardWidth / 2 + const maxLeft = window.innerWidth - VIEWPORT_MARGIN_PX - cardWidth + left = Math.min(Math.max(left, VIEWPORT_MARGIN_PX), Math.max(maxLeft, VIEWPORT_MARGIN_PX)) + + setPosition({ top, left }) + } + + updatePosition() + // A drag-scroll or a window resize while the card is showing would + // otherwise leave it pointing at empty space instead of the slot. + window.addEventListener('scroll', updatePosition, true) + window.addEventListener('resize', updatePosition) + return () => { + window.removeEventListener('scroll', updatePosition, true) + window.removeEventListener('resize', updatePosition) + } + }, [anchorEl]) + + const typeLabel = isGap ? t('calendarBand.hover.gapDay') : getLabelForType(dayType ?? 'vacation', t) + const dayCount = datesInRange(periodStart, periodEnd).length + const periodLabel = t('calendarBand.hover.period', { start: periodStart, end: periodEnd, count: dayCount }) + + return ( + + ) +} diff --git a/frontend/src/components/calendar/band-day-hover-card.css b/frontend/src/components/calendar/band-day-hover-card.css new file mode 100644 index 0000000..c218e79 --- /dev/null +++ b/frontend/src/components/calendar/band-day-hover-card.css @@ -0,0 +1,63 @@ +/* Calendar band day hover/focus card — a small floating tooltip describing + * the hovered/focused day slot inside a band (see CalendarWeek's `hoveredDay` + * state and BandDayHoverCard). `position: fixed` with `top`/`left` computed + * in BandDayHoverCard.tsx from the slot's real screen position, and the + * component is portalled to `document.body` by CalendarWeek — NOT rendered + * inside `.calendar-week`/`.calendar-band`, both of which clip via + * `overflow: hidden` (deliberately, so a band never bleeds into the next + * row — see calendar.css). A percentage/grid position relative to either + * ancestor would be clipped the moment the card pokes above the row, which + * is most of the time (a band sits near the bottom of its cell); the fixed + * position plus portal is what actually escapes that, in every row + * including the calendar's first one. Surface/shadow mirror the modal shell + * (`--c-surface`/`--shadow-float`, see primitives.css) since this is the + * app's other floating layer, just far smaller and non-interactive. Sized + * to content (bilingual rule — Swedish strings run longer than English). */ +.band-day-hover-card { + position: fixed; + z-index: var(--z-popover); + display: flex; + flex-direction: column; + gap: 2px; + width: max-content; + max-width: 220px; + padding: var(--sp-2) var(--sp-3); + background-color: var(--c-surface); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-float); + color: var(--c-text); + font-size: var(--fs-xs); + line-height: 1.35; + pointer-events: none; + white-space: normal; +} + +.band-day-hover-card__header { + display: flex; + flex-direction: column; + font-weight: 600; +} + +.band-day-hover-card__parent { + color: var(--c-text); +} + +.band-day-hover-card__date { + font-weight: 400; + color: var(--c-text-muted); +} + +.band-day-hover-card__type { + color: var(--c-text); +} + +.band-day-hover-card__flag { + color: var(--c-text-muted); +} + +.band-day-hover-card__period { + margin-top: 2px; + padding-top: 2px; + border-top: 1px solid var(--c-border); + color: var(--c-text-muted); +} diff --git a/frontend/src/components/common/CalendarWeek.test.tsx b/frontend/src/components/common/CalendarWeek.test.tsx index 7f301af..f5224ab 100644 --- a/frontend/src/components/common/CalendarWeek.test.tsx +++ b/frontend/src/components/common/CalendarWeek.test.tsx @@ -1,7 +1,7 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, fireEvent } from '@testing-library/react' -import { CalendarWeek } from './CalendarWeek' -import type { BandClickInfo } from './CalendarWeek' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, fireEvent, act } from '@testing-library/react' +import { CalendarWeek, computeTypeRuns } from './CalendarWeek' +import type { BandClickInfo, BandDayInfo } from './CalendarWeek' import type { PlannedLeaveDay, CalendarDayEntry } from '@/types' import type { CalendarDayProps } from './CalendarDay' import type { CalendarEventProps } from './CalendarEvent' @@ -15,9 +15,13 @@ vi.mock('@/i18n', () => ({ 'leaveType.sjukpenning': 'Sjukpenning', 'leaveType.lagsta': 'Lägsta nivå', 'calendarDay.pinnedAria': 'pinned', + 'calendarBand.hover.gapDay': 'Weekend or public holiday', + 'calendarBand.hover.dubbeldag': 'Double day', + 'calendarBand.hover.period': 'period range', } return translations[key] ?? key }, + locale: 'sv', }), })) @@ -87,6 +91,16 @@ function calDay(overrides: Partial & Pick & Pick): BandDayInfo { + return { + dayType: 'vacation', + isDubbeldag: false, + isPinned: false, + isGap: false, + ...overrides, + } +} + describe('CalendarWeek', () => { const defaultProps = { weekDates: ['2026-01-05', '2026-01-06', '2026-01-07', '2026-01-08', '2026-01-09', '2026-01-10', '2026-01-11'], @@ -1013,4 +1027,225 @@ describe('CalendarWeek', () => { expect(cell).toHaveAttribute('data-is-override', 'false') }) }) + + describe('computeTypeRuns', () => { + it('returns one run spanning the single day', () => { + const runs = computeTypeRuns([bandDayInfo({ date: '2026-01-05', dayType: 'vacation' })]) + expect(runs).toEqual([{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 0 }]) + }) + + it('splits into one run per day when every day has a different type (alternating types)', () => { + const runs = computeTypeRuns([ + bandDayInfo({ date: '2026-01-05', dayType: 'vacation' }), + bandDayInfo({ date: '2026-01-06', dayType: 'sjukpenning' }), + bandDayInfo({ date: '2026-01-07', dayType: 'lagsta' }), + ]) + expect(runs).toEqual([ + { typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 0 }, + { typeKey: 'sjukpenning', dayType: 'sjukpenning', startIdx: 1, endIdx: 1 }, + { typeKey: 'lagsta', dayType: 'lagsta', startIdx: 2, endIdx: 2 }, + ]) + }) + + it('absorbs leading gaps into the first run', () => { + const runs = computeTypeRuns([ + bandDayInfo({ date: '2026-01-03', isGap: true, dayType: null }), + bandDayInfo({ date: '2026-01-04', isGap: true, dayType: null }), + bandDayInfo({ date: '2026-01-05', dayType: 'vacation' }), + ]) + expect(runs).toEqual([{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 2 }]) + }) + + it('keeps a gap between two same-type days inside one run (Friday-to-Monday)', () => { + const runs = computeTypeRuns([ + bandDayInfo({ date: '2026-01-09', dayType: 'vacation' }), + bandDayInfo({ date: '2026-01-10', isGap: true, dayType: null }), + bandDayInfo({ date: '2026-01-11', isGap: true, dayType: null }), + bandDayInfo({ date: '2026-01-12', dayType: 'vacation' }), + ]) + expect(runs).toEqual([{ typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 3 }]) + }) + + it('ends a run at a gap that is followed by a different type, joining the gap to the earlier run', () => { + const runs = computeTypeRuns([ + bandDayInfo({ date: '2026-01-09', dayType: 'vacation' }), + bandDayInfo({ date: '2026-01-10', isGap: true, dayType: null }), + bandDayInfo({ date: '2026-01-11', isGap: true, dayType: null }), + bandDayInfo({ date: '2026-01-12', dayType: 'sjukpenning' }), + ]) + expect(runs).toEqual([ + { typeKey: 'vacation', dayType: 'vacation', startIdx: 0, endIdx: 2 }, + { typeKey: 'sjukpenning', dayType: 'sjukpenning', startIdx: 3, endIdx: 3 }, + ]) + }) + }) + + describe('mixed-type bands: one label per run, not one band-wide label', () => { + it('renders one label per same-type run in a band whose funding type changes mid-period', () => { + const plannedDays = [ + pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }), + pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning' }), + pd({ id: '3', date: '2026-01-07', wishId: 'wish-1', dayType: 'lagsta' }), + ] + const { container } = render() + const labels = container.querySelectorAll('.calendar-band__label') + expect(labels).toHaveLength(3) + }) + + it('gives only the first run the parent name; later runs show the type alone', () => { + const plannedDays = [ + pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }), + pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning' }), + ] + const { container } = render() + const labels = Array.from(container.querySelectorAll('.calendar-band__label')) + expect(labels[0]?.textContent).toContain('Alice') + expect(labels[1]?.textContent).not.toContain('Alice') + expect(labels[1]?.textContent).toContain('Sjukpenning') + }) + + it('inks each run label from its own type, not the band\'s first day', () => { + const plannedDays = [ + pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }), + pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning' }), + ] + const { container } = render() + const labels = Array.from(container.querySelectorAll('.calendar-band__label')) + expect(labels[0]?.style.color).toBe('var(--c-leave-vacation-ink)') + expect(labels[1]?.style.color).toBe('var(--c-leave-sjukpenning-ink)') + }) + + it('keeps a same-type run spanning a weekend as one label, not split by the bridged gap', () => { + const plannedDays = [ + pd({ id: '1', date: '2026-01-09', wishId: 'wish-1', dayType: 'vacation' }), + pd({ id: '2', date: '2026-01-12', wishId: 'wish-1', dayType: 'vacation' }), + ] + const calendarDaysByDate = new Map([ + ['2026-01-10', calDay({ date: '2026-01-10', dayType: 'weekend' })], + ['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })], + ]) + const { container } = render( + , + ) + expect(container.querySelectorAll('.calendar-band__label')).toHaveLength(1) + }) + + it('marks only the lägsta run with the outline overlay, not the whole mixed band', () => { + const plannedDays = [ + pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation' }), + pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'lagsta' }), + pd({ id: '3', date: '2026-01-07', wishId: 'wish-1', dayType: 'sjukpenning' }), + ] + const { container } = render() + const outlines = container.querySelectorAll('.calendar-band__run--lagsta') + expect(outlines).toHaveLength(1) + // 3 equal-width slots: the lägsta day is the middle third. + expect(outlines[0]?.style.left).toBe(`${(1 / 3) * 100}%`) + }) + + it('marks each run\'s own dubbeldag flag rather than flagging the whole band', () => { + const plannedDays = [ + pd({ id: '1', date: '2026-01-05', wishId: 'wish-1', dayType: 'vacation', isDubbeldag: false }), + pd({ id: '2', date: '2026-01-06', wishId: 'wish-1', dayType: 'sjukpenning', isDubbeldag: true }), + ] + const { container } = render() + const labels = Array.from(container.querySelectorAll('.calendar-band__label')) + expect(labels[0]?.querySelector('[data-testid="calendar-event"]')).not.toHaveClass('dubbeldag') + expect(labels[1]?.querySelector('[data-testid="calendar-event"]')).toHaveClass('dubbeldag') + }) + }) + + describe('per-day hover/focus card', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('shows nothing until the hover delay elapses, then shows the card for the hovered day', () => { + const plannedDays = [pd({ id: '1', date: '2026-01-05', dayType: 'sjukpenning' })] + const { container } = render() + const slot = container.querySelector('.calendar-band__day')! + fireEvent.pointerEnter(slot) + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + + act(() => { + vi.advanceTimersByTime(150) + }) + + const tooltip = screen.getByRole('tooltip') + expect(tooltip.textContent).toContain('Alice') + expect(tooltip.textContent).toContain('Sjukpenning') + expect(slot).toHaveAttribute('aria-describedby', tooltip.id) + }) + + it('does not show the card while a pointer button is held anywhere on the page (dragging)', () => { + const plannedDays = [pd({ id: '1', date: '2026-01-05' })] + const { container } = render() + const slot = container.querySelector('.calendar-band__day')! + // A drag-select starts with a pointerdown that bubbles to the window — + // CalendarWeek tracks that globally rather than reading the + // pointerenter event's own `buttons` bitmask (jsdom doesn't populate + // it), same as a real drag started elsewhere in the grid and dragged + // over this slot. + fireEvent.pointerDown(slot) + fireEvent.pointerEnter(slot) + act(() => { + vi.advanceTimersByTime(150) + }) + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + + fireEvent.pointerUp(slot) + }) + + it('hides the card on pointer leave', () => { + const plannedDays = [pd({ id: '1', date: '2026-01-05' })] + const { container } = render() + const slot = container.querySelector('.calendar-band__day')! + fireEvent.pointerEnter(slot) + act(() => { + vi.advanceTimersByTime(150) + }) + expect(screen.getByRole('tooltip')).toBeInTheDocument() + + fireEvent.pointerLeave(slot) + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) + + it('shows the gap-day text, not a funding type, for a bridged gap slot', () => { + const plannedDays = [ + pd({ id: '1', date: '2026-01-09', wishId: 'wish-1' }), + pd({ id: '2', date: '2026-01-12', wishId: 'wish-1' }), + ] + const calendarDaysByDate = new Map([ + ['2026-01-10', calDay({ date: '2026-01-10', dayType: 'weekend' })], + ['2026-01-11', calDay({ date: '2026-01-11', dayType: 'weekend' })], + ]) + const { container } = render( + , + ) + const gapSlot = container.querySelector('.calendar-band__day--gap')! + fireEvent.pointerEnter(gapSlot) + act(() => { + vi.advanceTimersByTime(150) + }) + expect(screen.getByRole('tooltip').textContent).toContain('Weekend or public holiday') + }) + + it('shows the card on focus too, without requiring a pointer', () => { + const plannedDays = [pd({ id: '1', date: '2026-01-05' })] + const { container } = render() + const slot = container.querySelector('.calendar-band__day')! + fireEvent.focus(slot) + act(() => { + vi.advanceTimersByTime(150) + }) + expect(screen.getByRole('tooltip')).toBeInTheDocument() + + fireEvent.blur(slot) + expect(screen.queryByRole('tooltip')).not.toBeInTheDocument() + }) + }) }) diff --git a/frontend/src/components/common/CalendarWeek.tsx b/frontend/src/components/common/CalendarWeek.tsx index eba42d8..1828dfc 100644 --- a/frontend/src/components/common/CalendarWeek.tsx +++ b/frontend/src/components/common/CalendarWeek.tsx @@ -1,10 +1,17 @@ -import { ReactNode, useMemo } from 'react' +import { ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import type { PlannedLeaveDay, CalendarDayEntry } from '@/types' -import { getColorForType, getInkForType, getLabelForType, getTypeKey } from '@/utils/leaveTypes' +import { getColorForType, getInkForType, getLabelForType, getTypeKey, type LeaveTypeKey } from '@/utils/leaveTypes' import { getISOWeekNumber, formatDateLocalISO } from '@/utils/dates' import { useTranslation } from '@/i18n' import { CalendarDay } from './CalendarDay' import { CalendarEvent } from './CalendarEvent' +import { BandDayHoverCard } from '@/components/calendar/BandDayHoverCard' + +/** Delay before the per-day hover/focus card appears — long enough that a + * pointer merely passing through several slots (dragging across the grid, + * or just moving the mouse toward something else) never flashes it. */ +const HOVER_SHOW_DELAY_MS = 150 /** Fired when a band (a period of planned days, possibly spanning several * weeks) is clicked. `wishId` is null for an ad-hoc period not tied to a @@ -74,7 +81,7 @@ export interface CalendarWeekProps { * (`isGap: false`, carrying its own funding type/dubbeldag/pinned flags) or * a bridged free-day gap (a weekend/red day the band passes through without * needing a funded row — see `isBridgeableGap`). */ -interface BandDayInfo { +export interface BandDayInfo { date: string dayType: PlannedLeaveDay['dayType'] | null isDubbeldag: boolean @@ -82,6 +89,91 @@ interface BandDayInfo { isGap: boolean } +/** A run of consecutive same-type days inside one band segment's + * `dayInfos` — see `computeTypeRuns`. `startIdx`/`endIdx` are inclusive + * indices into that segment's `dayInfos` array. */ +export interface TypeRun { + typeKey: LeaveTypeKey + dayType: PlannedLeaveDay['dayType'] | null + startIdx: number + endIdx: number +} + +/** + * Splits a band segment's per-day info into runs of consecutive same-type + * days, so each run can get its own readable label instead of one label + * (inked for the first day's type) covering the whole segment. + * + * A bridged gap day (weekend/red day, `isGap: true`) has no type of its + * own, so it joins whichever run it sits next to: + * - a gap between two real days of the SAME type stays inside that one run + * (a Friday→Monday vacation stretch across a weekend is one run, not + * three); + * - a gap immediately followed by a real day of a DIFFERENT type joins the + * run BEFORE it — the new type starts its own run at the next real day; + * - gap(s) with no real day before them yet (this segment is a week row + * that opens mid-gap, continuing a band that started earlier in the + * previous week) join the first run once it's found, rather than being + * orphaned before it. + */ +export function computeTypeRuns(dayInfos: BandDayInfo[]): TypeRun[] { + const runs: TypeRun[] = [] + + let i = 0 + while (i < dayInfos.length && dayInfos[i]?.isGap) i++ + const hasLeadingGaps = i > 0 + + while (i < dayInfos.length) { + const info = dayInfos[i] + if (!info) { + i++ + continue + } + + const dayType = info.dayType + const typeKey = getTypeKey(dayType ?? 'vacation') + const startIdx = i + let endIdx = i + i++ + + while (i < dayInfos.length) { + const next = dayInfos[i] + if (!next) break + if (next.isGap) { + endIdx = i + i++ + continue + } + if (next.dayType === dayType) { + endIdx = i + i++ + continue + } + break + } + + runs.push({ typeKey, dayType, startIdx, endIdx }) + } + + // A leading gap (no real day before it in this segment) belongs to the + // first run once one exists — this segment is a continuation row, not a + // segment with no type at all (a band's dayInfos always contains at + // least one real day; only fully-bridged weeks are pure gaps). + const firstRun = runs[0] + if (hasLeadingGaps && firstRun) { + firstRun.startIdx = 0 + } + + // Defensive fallback for a segment that is entirely bridged gaps (a whole + // week bridged by a wish that starts/ends outside it) — not expected in + // practice, but rendering nothing would silently drop the row. + if (runs.length === 0 && dayInfos.length > 0) { + runs.push({ typeKey: 'vacation', dayType: null, startIdx: 0, endIdx: dayInfos.length - 1 }) + } + + return runs +} + interface BandSegment { id: string wishId: string | null @@ -299,6 +391,62 @@ export function CalendarWeek({ monthMaxLaneCount, }: CalendarWeekProps): ReactNode { const { t } = useTranslation() + const hoverCardId = useId() + const [hoveredDay, setHoveredDay] = useState<{ segmentId: string; date: string; anchorEl: HTMLElement } | null>( + null, + ) + const hoverTimeoutRef = useRef | null>(null) + // Tracks whether a pointer button is currently held anywhere on the page — + // read (not stored in state) so a drag-select in progress never surfaces + // the card, without re-rendering the whole week on every pointerdown/up. + const pointerDownRef = useRef(false) + + useEffect(() => { + const handlePointerDown = (): void => { + pointerDownRef.current = true + } + const handlePointerUp = (): void => { + pointerDownRef.current = false + } + window.addEventListener('pointerdown', handlePointerDown) + window.addEventListener('pointerup', handlePointerUp) + return () => { + window.removeEventListener('pointerdown', handlePointerDown) + window.removeEventListener('pointerup', handlePointerUp) + } + }, []) + + useEffect( + () => () => { + if (hoverTimeoutRef.current !== null) clearTimeout(hoverTimeoutRef.current) + }, + [], + ) + + const clearHoverTimeout = (): void => { + if (hoverTimeoutRef.current !== null) { + clearTimeout(hoverTimeoutRef.current) + hoverTimeoutRef.current = null + } + } + + // Delayed show avoids flicker while a pointer drags across many day + // slots; an active drag-select (a pointer button held anywhere) must + // never surface the card at all. `anchorEl` (the slot itself) is what the + // portalled card positions itself against — see BandDayHoverCard's doc + // comment for why it needs the real DOM element rather than a percentage + // position handed down from here. + const scheduleHoverShow = (segmentId: string, date: string, anchorEl: HTMLElement): void => { + clearHoverTimeout() + if (pointerDownRef.current) return + hoverTimeoutRef.current = setTimeout(() => setHoveredDay({ segmentId, date, anchorEl }), HOVER_SHOW_DELAY_MS) + } + + const hideHover = (): void => { + clearHoverTimeout() + setHoveredDay(null) + } + const bandSegments = useMemo( () => computeBandSegments(weekDates, plannedDays, calendarDaysByDate), [weekDates, plannedDays, calendarDaysByDate], @@ -394,21 +542,18 @@ export function CalendarWeek({ const firstRealDay = segment.dayInfos.find((d) => !d.isGap) const representativeType = firstRealDay?.dayType ?? 'vacation' const typeLabel = getLabelForType(representativeType, t) - const color = getColorForType(representativeType) - const ink = getInkForType(representativeType) - const anyDubbeldag = segment.dayInfos.some((d) => !d.isGap && d.isDubbeldag) // Type by fill/ink/outline (`--vacation|--sjukpenning|--lagsta`, // see the --c-leave-* tokens) and owner by a 2px left cap // (`--self`/`--partner`) — never by hue, so the two axes stay - // readable side by side and in greyscale. + // readable side by side and in greyscale. The band itself only + // keeps the owner cap now — a mixed-type band's fill/outline is + // drawn per run below (see `runs`), not band-wide from whichever + // type happens to come first. const typeKey = getTypeKey(representativeType) const classNames = [ 'calendar-band', `calendar-band--${typeKey}`, - // `leave-swatch--*` (primitives.css) carries the shared - // fill/hatch/outline/cap-mark recipe; `unknown` has no recipe. - typeKey !== 'unknown' ? `leave-swatch--${typeKey}` : '', segment.parentUuid === personalDataUuid ? 'calendar-band--self' : 'calendar-band--partner', segment.parentUuid === personalDataUuid ? 'leave-swatch--cap-self' : 'leave-swatch--cap-partner', segment.isBandStart ? 'calendar-band--start' : '', @@ -418,6 +563,9 @@ export function CalendarWeek({ .filter(Boolean) .join(' ') + const dayCount = segment.dayInfos.length + const runs = computeTypeRuns(segment.dayInfos) + const fireClick = (clickedDate?: string) => onBandClick?.({ wishId: segment.wishId, @@ -429,6 +577,12 @@ export function CalendarWeek({ clickedDate: clickedDate ?? segment.overallStart, }) + const hoveredInfo = + hoveredDay?.segmentId === segment.id + ? segment.dayInfos.find((d) => d.date === hoveredDay.date) + : undefined + const hoveredIdx = hoveredInfo ? segment.dayInfos.indexOf(hoveredInfo) : -1 + return (
- {segment.dayInfos.map((info) => ( - { - e.stopPropagation() - fireClick(info.date) - } - : undefined - } + {segment.dayInfos.map((info) => { + const isHovered = hoveredDay?.segmentId === segment.id && hoveredDay.date === info.date + return ( + { + e.stopPropagation() + fireClick(info.date) + } + : undefined + } + onKeyDown={ + onBandClick + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + fireClick(info.date) + } + } + : undefined + } + // The hover/focus card (below) replaces the old `title` + // attribute for a pinned day — a native tooltip and this + // card fighting over the same slot just doubles up. + onPointerEnter={(e) => scheduleHoverShow(segment.id, info.date, e.currentTarget)} + onPointerDown={hideHover} + onPointerLeave={hideHover} + onFocus={(e) => scheduleHoverShow(segment.id, info.date, e.currentTarget)} + onBlur={hideHover} + /> + ) + })} +
+ {runs + .filter((run) => run.typeKey === 'lagsta') + .map((run) => ( +
))} -
-
- -
+ {runs.map((run, runIdx) => { + const runDayType = run.dayType ?? 'vacation' + const runTypeLabel = getLabelForType(runDayType, t) + const runDubbeldag = segment.dayInfos + .slice(run.startIdx, run.endIdx + 1) + .some((d) => !d.isGap && d.isDubbeldag) + return ( +
+ +
+ ) + })} + {hoveredInfo && + hoveredIdx >= 0 && + hoveredDay && + createPortal( + // Portalled to document.body (not rendered as a normal + // descendant here) so the card escapes `.calendar-week`'s + // and `.calendar-band`'s `overflow: hidden` — both clip + // deliberately (a band must never bleed into the next row), + // which would otherwise also invisibly clip this card the + // moment it pokes above its row. See BandDayHoverCard's doc + // comment for how it then positions itself from `anchorEl`. + , + document.body, + )}
) })} diff --git a/frontend/src/i18n/en/calendar.ts b/frontend/src/i18n/en/calendar.ts index cd56384..ec50582 100644 --- a/frontend/src/i18n/en/calendar.ts +++ b/frontend/src/i18n/en/calendar.ts @@ -226,4 +226,12 @@ export const calendar = { 'calendar.wantsDubbeldagHint': "Both parents are off the same day and each spend one of their own parental-benefit days (no vacation day is used) — only available in the child's first 15 months, capped at 60 dates per child, shared between you.", 'calendar.wantsDubbeldagRequiresBothHint': 'Requires the period to apply to both parents.', + + // Calendar band hover/focus card (see BandDayHoverCard) — one label per + // same-type run inside a band replaced the single band-wide label, so a + // per-day card fills in the detail (parent, date, type, flags, period + // range) that no longer fits in the run label itself. + 'calendarBand.hover.gapDay': 'Weekend or public holiday — no leave day is drawn here', + 'calendarBand.hover.dubbeldag': 'Double day', + 'calendarBand.hover.period': '{start} – {end} · {count} {count|day|days}', } as const diff --git a/frontend/src/i18n/sv/calendar.ts b/frontend/src/i18n/sv/calendar.ts index 7d5e7ff..108eb88 100644 --- a/frontend/src/i18n/sv/calendar.ts +++ b/frontend/src/i18n/sv/calendar.ts @@ -227,4 +227,13 @@ export const calendar = { 'calendar.wantsDubbeldagHint': 'Båda föräldrarna är lediga samma dag och tar var sin egen föräldrapenningdag (ingen semesterdag går åt) — gäller bara de första 15 månaderna och max 60 dagar per barn, delat mellan er.', 'calendar.wantsDubbeldagRequiresBothHint': 'Kräver att perioden gäller båda föräldrarna.', + + // Kort som visas vid hover/fokus på en dag i ett band (se + // BandDayHoverCard) — en etikett per sammanhängande typ i ett band + // ersatte den enda bandbreda etiketten, så det här kortet fyller i + // detaljen (förälder, datum, typ, flaggor, periodens intervall) som inte + // längre får plats i själva etiketten. + 'calendarBand.hover.gapDay': 'Helg eller röd dag — ingen ledighetsdag ritas här', + 'calendarBand.hover.dubbeldag': 'Dubbeldag', + 'calendarBand.hover.period': '{start} – {end} · {count} {count|dag|dagar}', } as const \ No newline at end of file diff --git a/frontend/src/pages/calendar.css b/frontend/src/pages/calendar.css index 8bb8ad8..355160b 100644 --- a/frontend/src/pages/calendar.css +++ b/frontend/src/pages/calendar.css @@ -404,9 +404,12 @@ border-bottom-right-radius: var(--radius-band); } -/* The owner cap and lägsta's outline are drawn on an overlay above the - * per-day ticks (which would otherwise paint over an inset shadow on the - * band itself) and below the label. */ +/* The owner cap is drawn on an overlay above the per-day ticks (which would + * otherwise paint over an inset shadow on the band itself) and below the + * label. Lägsta's outline used to be composed here too, band-wide from + * whichever type happened to be the band's first day — now it's a per-run + * overlay instead (`.calendar-band__run--lagsta` below), since a band can + * mix types day to day. */ .calendar-band::after { content: ''; position: absolute; @@ -416,14 +419,12 @@ border-radius: inherit; } -/* The self/partner cap-mark and lägsta's outline are the shared - * `.leave-swatch--cap-self`/`--cap-partner`/`--lagsta` recipe from - * primitives.css, scoped to `::after` here — see the comment there and on - * `.calendar-band::after` above for why. `getTypeKey`'s `--lagsta`/ - * `leave-swatch--lagsta` and the owner's `--self`/`--partner`/ - * `leave-swatch--cap-*` are added side by side in CalendarWeek.tsx, so - * lägsta's outline and the owner's cap keep composing even though this - * file no longer states the box-shadow values itself. */ +/* The self/partner cap-mark is the shared `.leave-swatch--cap-self`/ + * `--cap-partner` recipe from primitives.css, scoped to `::after` here — + * see the comment there and on `.calendar-band::after` above for why. + * `CalendarWeek.tsx` adds the class alongside the band's own modifiers, so + * the owner's cap composes onto the band without this file stating the + * box-shadow value itself. */ .calendar-band__day--lagsta { background-image: repeating-linear-gradient( @@ -453,11 +454,17 @@ } } -/* The label: 12px/500 in the type's ink (set inline on the band from - * getInkForType), no shadow. */ +/* One label per same-type run inside the band (see `computeTypeRuns` in + * CalendarWeek.tsx), not one label for the whole band — a mixed-type band + * (vacation → sjukpenning mid-period) would otherwise show unlabelled text + * in the wrong ink over the later type's days. Each instance is positioned + * over its own run via inline `left`/`width` percentages and inked from + * that run's own type (inline `color`, from getInkForType), 12px/500, no + * shadow. */ .calendar-band__label { position: absolute; - inset: 0; + top: 0; + bottom: 0; z-index: 2; display: flex; align-items: center; @@ -465,6 +472,24 @@ font-size: var(--fs-xs); color: inherit; pointer-events: none; + min-width: 0; +} + +/* A per-run decoration overlay, sized/positioned like `.calendar-band__label` + * above but drawing no text — currently only the lägsta outline, which used + * to be band-wide (drawn from the first day's type) and wrongly outlined a + * mixed band across every day. Sits above the per-day ticks, below the + * label. */ +.calendar-band__run { + position: absolute; + top: 0; + bottom: 0; + pointer-events: none; +} + +.calendar-band__run--lagsta { + z-index: 1; + box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); } /* The per-day funding-type strip: a period is ONE band, but each day still diff --git a/frontend/src/styles/primitives.css b/frontend/src/styles/primitives.css index c3f1797..eea6db7 100644 --- a/frontend/src/styles/primitives.css +++ b/frontend/src/styles/primitives.css @@ -463,8 +463,10 @@ /* Modal shell * ------------------------------------------------------------------------ * Shared by PeriodPopover, PlannedDayModal, ParentBalanceForm and - * QRCodeModal. The one floating layer in the app, so the one place - * --shadow-float is used. Header and footer have no rules of their own; + * QRCodeModal — the app's main floating layer, so the main place + * --shadow-float is used (the calendar band's day hover card, a much + * smaller non-interactive tooltip, is the other — see + * band-day-hover-card.css). Header and footer have no rules of their own; * the footer is transparent — spacing does the separating. */ .modal__overlay { position: fixed; @@ -596,15 +598,16 @@ * legend; consumers keep only their own size/position rule and add one of * these classes alongside their existing BEM modifier. * - * `--lagsta`/`--cap-self`/`--cap-partner` are defined twice: once for a - * plain element (the year-row swatch, the calendar-help sample — nothing - * paints over them) and once scoped to `::after` (the calendar band, whose - * per-day ticks sit on top of the band's own background and would hide an - * inset shadow drawn directly on it — see `.calendar-band::after` in - * calendar.css). The combined lagsta+cap-self / lagsta+cap-partner - * `::after` rules exist because `box-shadow` doesn't merge across two - * separate rules — the more specific combined selector is what lets a - * lägsta band still show its owner's cap. */ + * `--cap-self`/`--cap-partner` are defined twice: once for a plain element + * (the year-row swatch, the calendar-help sample — nothing paints over + * them) and once scoped to `::after` (the calendar band, whose per-day + * ticks sit on top of the band's own background and would hide an inset + * shadow drawn directly on it — see `.calendar-band::after` in + * calendar.css). `--lagsta` only needs the plain-element form: the band no + * longer wears it at all (a mixed-type band's lägsta days get their own + * per-run outline overlay instead — `.calendar-band__run--lagsta` in + * calendar.css — since the type varies day to day and the old band-wide + * `--lagsta` class only ever reflected the first day). */ .leave-swatch--vacation { background-color: var(--c-leave-vacation); } @@ -619,10 +622,6 @@ box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); } -.leave-swatch--lagsta::after { - box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline); -} - .leave-swatch--cap-self { box-shadow: inset 2px 0 0 var(--c-band-cap-self); } @@ -639,14 +638,6 @@ box-shadow: inset 2px 0 0 var(--c-band-cap-partner); } -.leave-swatch--lagsta.leave-swatch--cap-self::after { - box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-self); -} - -.leave-swatch--lagsta.leave-swatch--cap-partner::after { - box-shadow: inset 0 0 0 1px var(--c-leave-lagsta-outline), inset 2px 0 0 var(--c-band-cap-partner); -} - /* Layout helpers * ------------------------------------------------------------------------ */ .stack { diff --git a/frontend/src/utils/dates.ts b/frontend/src/utils/dates.ts index 4a60e69..357c41c 100644 --- a/frontend/src/utils/dates.ts +++ b/frontend/src/utils/dates.ts @@ -32,6 +32,24 @@ export function formatDateForDisplay(date: string | Date, locale: Locale = 'sv') return formatter.format(d) } +/** + * Format a date as "weekday, day month" for display (e.g. "fredag 5 juni" in + * Swedish, "Friday, June 5" in English) — used by the calendar band hover + * card, which needs the weekday alongside the date and has no calendar cell + * of its own to supply it from context. Unlike `getDayName`/`getMonthName` + * (Swedish-only), this takes the active locale like `formatDateForDisplay`. + */ +export function formatWeekdayAndDate(date: string | Date, locale: Locale = 'sv'): string { + const d = typeof date === 'string' ? new Date(date + 'T00:00:00Z') : date + if (!isValidDate(d)) return '' + const formatter = new Intl.DateTimeFormat(DISPLAY_INTL_LOCALE[locale], { + weekday: 'long', + day: 'numeric', + month: 'long', + }) + return formatter.format(d) +} + /** * Format a date for HTML input value (e.g. "2026-06-01"). * Accepts ISO string or Date object.