Fix calendar style
This commit is contained in:
+7
-10
@@ -107,18 +107,15 @@ Targets `.form-group`, `.error-message`, `.settings-page__message--success/--err
|
||||
|
||||
---
|
||||
|
||||
## S5 — Calendar (the "Google Calendar-like" view)
|
||||
## S5 — Calendar (the "Google Calendar-like" view) — ⚠️ SUPERSEDED
|
||||
|
||||
Targets `.calendar`, `.calendar__header`, `.calendar__grid`, `.calendar__weekday`, `.calendar-day*`, `.calendar-event*`, `.calendar__legend*`.
|
||||
> **SUPERSEDED by `junior_calendar_fix.md`.** The calendar is being fixed as its own focused task, which already covers the 7-column grid, day cells, today/weekend/adjacent-month states, the legend, plus correctness fixes (Monday-first Swedish week, default-to-current-month) and tests. **Do not restyle the calendar from this section** — you'll collide with that work.
|
||||
>
|
||||
> **Only remaining action here:** once `junior_calendar_fix.md` has shipped *and* `global.css` tokens (S1) exist, reconcile the calendar's co-located `calendar.css` to reference the tokens (`--c-*`, `--sp-*`, `--radius`) and define per-type color tokens (`--type-sjukpenning`, `--type-lagsta`, `--type-vacation`, plus a dubbeldagar marker) so cell chips and the legend stay in sync with the rest of the app. No layout or markup changes.
|
||||
|
||||
**Do:**
|
||||
- `calendar__grid` as a **7-column CSS grid** with square-ish day cells, thin borders, day number top-left, room for event chips below.
|
||||
- Distinguish **current month vs. adjacent**, **today** (accent ring/dot), and **weekends/red days** (subtle tint).
|
||||
- **Event chips** (`calendar-event`) color-coded by leave type — define a token per type and reuse the same colors in the legend so they match: e.g. `--type-sjukpenning`, `--type-lagsta`, `--type-vacation`, plus a marker for dubbeldagar. Keep chips compact and readable.
|
||||
- Month nav header (prev/next + month label) styled as a clean toolbar.
|
||||
- Legend as small labeled swatches.
|
||||
The original styling targets, kept for reference during that token reconciliation: `.calendar`, `.calendar__header`, `.calendar__grid`, `.calendar__weekday`, `.calendar-day*`, `.calendar-event*`, `.calendar__legend*`.
|
||||
|
||||
**Acceptance:** The calendar looks like a familiar month calendar; leave types are color-coded consistently between cells and legend; today/weekends/other-month are visually distinct.
|
||||
**Acceptance:** Calendar colors/spacing reference S1 tokens; leave-type colors match between cells and legend. (Layout, states, and behavior are owned by `junior_calendar_fix.md`.)
|
||||
|
||||
---
|
||||
|
||||
@@ -146,7 +143,7 @@ Targets the `QRCodeModal` (built in the MVP rounds) and `LoadingSpinner`.
|
||||
|
||||
## Sequencing & guardrails
|
||||
1. **S1 → S2 → S3 → S4** give the biggest visible lift fastest (foundation + shell + cards + controls).
|
||||
2. **S5 (calendar)** is the most involved; do it once `frontend_mvp_complete.md` T2.1 has the calendar interactive.
|
||||
2. **S5 (calendar) is superseded** by `junior_calendar_fix.md` — which is being delivered **first**, before this styling plan. Don't restyle the calendar here; the only S5 task left is the token reconciliation noted in that section, done *after* both this plan's S1 and the calendar fix have landed.
|
||||
3. **S6, S7** finish it off.
|
||||
|
||||
- **Don't change component markup or `data-testid`s** to style — tests rely on them. Style via the existing classes; only add a `className` where one is genuinely missing.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { ReactNode, useState } from 'react'
|
||||
import './manual-fetch-modal.css'
|
||||
|
||||
export interface ManualFetchModalProps {
|
||||
isOpen: boolean
|
||||
onSave: (data: {
|
||||
totalDays: number
|
||||
daysUsed: number
|
||||
reservedDaysLeft: number
|
||||
transferableDaysLeft: number
|
||||
dubbeldagarLeft: number
|
||||
sjukpenningUsed: number
|
||||
lagstaNivaUsed: number
|
||||
}) => Promise<void>
|
||||
onClose: () => void
|
||||
isSaving?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export function ManualFetchModal({
|
||||
isOpen,
|
||||
onSave,
|
||||
onClose,
|
||||
isSaving = false,
|
||||
error = null,
|
||||
}: ManualFetchModalProps): ReactNode {
|
||||
const [totalDays, setTotalDays] = useState(480)
|
||||
const [daysUsed, setDaysUsed] = useState(0)
|
||||
const [reservedDaysLeft, setReservedDaysLeft] = useState(90)
|
||||
const [transferableDaysLeft, setTransferableDaysLeft] = useState(0)
|
||||
const [dubbeldagarLeft, setDubbeldagarLeft] = useState(60)
|
||||
const [sjukpenningUsed, setSjukpenningUsed] = useState(0)
|
||||
const [lagstaNivaUsed, setLagstaNivaUsed] = useState(0)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLocalError(null)
|
||||
await onSave({
|
||||
totalDays,
|
||||
daysUsed,
|
||||
reservedDaysLeft,
|
||||
transferableDaysLeft,
|
||||
dubbeldagarLeft,
|
||||
sjukpenningUsed,
|
||||
lagstaNivaUsed,
|
||||
})
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : 'Failed to save manual data')
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const daysLeft = totalDays - daysUsed
|
||||
|
||||
return (
|
||||
<div className="manual-fetch-modal__overlay" onClick={onClose}>
|
||||
<div className="manual-fetch-modal__content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="manual-fetch-modal__header">
|
||||
<h2>Enter Parental Leave Data</h2>
|
||||
<button className="manual-fetch-modal__close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="manual-fetch-modal__body">
|
||||
<p className="manual-fetch-modal__description">
|
||||
Enter your parental leave information from Försäkringskassan manually if automatic fetching is unavailable.
|
||||
</p>
|
||||
|
||||
{(error || localError) && (
|
||||
<div className="manual-fetch-modal__error" role="alert">
|
||||
{error || localError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="manual-fetch-modal__section">
|
||||
<h3>Total Days</h3>
|
||||
<div className="manual-fetch-modal__field">
|
||||
<label htmlFor="total-days">Total days (0-480)</label>
|
||||
<input
|
||||
id="total-days"
|
||||
type="number"
|
||||
min="0"
|
||||
max="480"
|
||||
value={totalDays}
|
||||
onChange={(e) => setTotalDays(Number(e.target.value))}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="manual-fetch-modal__field">
|
||||
<label htmlFor="days-used">Days used</label>
|
||||
<input
|
||||
id="days-used"
|
||||
type="number"
|
||||
min="0"
|
||||
value={daysUsed}
|
||||
onChange={(e) => setDaysUsed(Number(e.target.value))}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="manual-fetch-modal__info">
|
||||
Days remaining: <strong>{daysLeft}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="manual-fetch-modal__section">
|
||||
<h3>Remaining Days Breakdown</h3>
|
||||
<div className="manual-fetch-modal__field">
|
||||
<label htmlFor="reserved-days">Reserved days left</label>
|
||||
<input
|
||||
id="reserved-days"
|
||||
type="number"
|
||||
min="0"
|
||||
value={reservedDaysLeft}
|
||||
onChange={(e) => setReservedDaysLeft(Number(e.target.value))}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="manual-fetch-modal__field">
|
||||
<label htmlFor="transferable-days">Transferable days left</label>
|
||||
<input
|
||||
id="transferable-days"
|
||||
type="number"
|
||||
min="0"
|
||||
value={transferableDaysLeft}
|
||||
onChange={(e) => setTransferableDaysLeft(Number(e.target.value))}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="manual-fetch-modal__field">
|
||||
<label htmlFor="dubbeldag-left">Dubbeldagar left (0-60)</label>
|
||||
<input
|
||||
id="dubbeldag-left"
|
||||
type="number"
|
||||
min="0"
|
||||
max="60"
|
||||
value={dubbeldagarLeft}
|
||||
onChange={(e) => setDubbeldagarLeft(Number(e.target.value))}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="manual-fetch-modal__section">
|
||||
<h3>Tier Breakdown</h3>
|
||||
<div className="manual-fetch-modal__field">
|
||||
<label htmlFor="sjukpenning-used">Sick leave (390 level) used</label>
|
||||
<input
|
||||
id="sjukpenning-used"
|
||||
type="number"
|
||||
min="0"
|
||||
value={sjukpenningUsed}
|
||||
onChange={(e) => setSjukpenningUsed(Number(e.target.value))}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="manual-fetch-modal__field">
|
||||
<label htmlFor="lagsta-niva-used">Parental (90 level) used</label>
|
||||
<input
|
||||
id="lagsta-niva-used"
|
||||
type="number"
|
||||
min="0"
|
||||
value={lagstaNivaUsed}
|
||||
onChange={(e) => setLagstaNivaUsed(Number(e.target.value))}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="manual-fetch-modal__footer">
|
||||
<button
|
||||
className="manual-fetch-modal__button manual-fetch-modal__button--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="manual-fetch-modal__button manual-fetch-modal__button--primary"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { ReactNode, useState, useEffect } from 'react'
|
||||
import type { PlannedLeaveDay } from '@/types'
|
||||
import './planned-day-modal.css'
|
||||
|
||||
export interface PlannedDayModalProps {
|
||||
date: string
|
||||
isOpen: boolean
|
||||
existingDay?: PlannedLeaveDay | null | undefined
|
||||
parents: Array<{ id: string; name: string }>
|
||||
selectedParentId: string
|
||||
onSave: (payload: {
|
||||
dayType: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
parentId: string
|
||||
isDubbeldag: boolean
|
||||
}) => Promise<void>
|
||||
onDelete?: (() => Promise<void>) | undefined
|
||||
onClose: () => void
|
||||
isSaving?: boolean | undefined
|
||||
error?: string | null | undefined
|
||||
}
|
||||
|
||||
export function PlannedDayModal({
|
||||
date,
|
||||
isOpen,
|
||||
existingDay,
|
||||
parents,
|
||||
selectedParentId,
|
||||
onSave,
|
||||
onDelete,
|
||||
onClose,
|
||||
isSaving = false,
|
||||
error = null,
|
||||
}: PlannedDayModalProps): ReactNode {
|
||||
const [dayType, setDayType] = useState<'sjukpenning' | 'lagsta' | 'vacation'>('vacation')
|
||||
const [parentId, setParentId] = useState(selectedParentId)
|
||||
const [isDubbeldag, setIsDubbeldag] = useState(false)
|
||||
const [localError, setLocalError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (existingDay) {
|
||||
setDayType(existingDay.dayType as 'sjukpenning' | 'lagsta' | 'vacation')
|
||||
setParentId(existingDay.parentPersonalDataUuid)
|
||||
setIsDubbeldag(existingDay.isDubbeldag)
|
||||
} else {
|
||||
setDayType('vacation')
|
||||
setParentId(selectedParentId)
|
||||
setIsDubbeldag(false)
|
||||
}
|
||||
setLocalError(null)
|
||||
}, [isOpen, existingDay, selectedParentId])
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setLocalError(null)
|
||||
await onSave({
|
||||
dayType,
|
||||
parentId,
|
||||
isDubbeldag,
|
||||
})
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : 'Failed to save planned day')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!onDelete) return
|
||||
if (confirm('Are you sure you want to delete this planned day?')) {
|
||||
try {
|
||||
setLocalError(null)
|
||||
await onDelete()
|
||||
} catch (err) {
|
||||
setLocalError(err instanceof Error ? err.message : 'Failed to delete planned day')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const displayDate = new Date(date + 'T00:00:00Z').toLocaleDateString('sv-SE', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="planned-day-modal__overlay" onClick={onClose}>
|
||||
<div className="planned-day-modal__content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="planned-day-modal__header">
|
||||
<h2 data-testid="modal-title">{existingDay ? 'Edit Planned Day' : 'Plan a Day'}</h2>
|
||||
<button className="planned-day-modal__close" onClick={onClose} aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="planned-day-modal__body">
|
||||
<p className="planned-day-modal__date">{displayDate}</p>
|
||||
|
||||
{(error || localError) && (
|
||||
<div className="planned-day-modal__error" role="alert">
|
||||
{error || localError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="planned-day-modal__field">
|
||||
<label htmlFor="day-type" className="planned-day-modal__label">
|
||||
Leave Type
|
||||
</label>
|
||||
<select
|
||||
id="day-type"
|
||||
value={dayType}
|
||||
onChange={(e) => setDayType(e.target.value as 'sjukpenning' | 'lagsta' | 'vacation')}
|
||||
className="planned-day-modal__select"
|
||||
disabled={isSaving}
|
||||
data-testid="day-type-select"
|
||||
>
|
||||
<option value="vacation">Vacation</option>
|
||||
<option value="sjukpenning">Sick Leave (390 level)</option>
|
||||
<option value="lagsta">Parental (90 level)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{parents.length > 1 && (
|
||||
<div className="planned-day-modal__field">
|
||||
<label htmlFor="parent-select" className="planned-day-modal__label">
|
||||
Parent
|
||||
</label>
|
||||
<select
|
||||
id="parent-select"
|
||||
value={parentId}
|
||||
onChange={(e) => setParentId(e.target.value)}
|
||||
className="planned-day-modal__select"
|
||||
disabled={isSaving}
|
||||
data-testid="parent-select"
|
||||
>
|
||||
{parents.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="planned-day-modal__field">
|
||||
<label className="planned-day-modal__checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDubbeldag}
|
||||
onChange={(e) => setIsDubbeldag(e.target.checked)}
|
||||
disabled={isSaving}
|
||||
data-testid="dubbeldag-checkbox"
|
||||
/>
|
||||
{' '}Double day (both parents taking leave)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="planned-day-modal__footer">
|
||||
{existingDay && onDelete && (
|
||||
<button
|
||||
className="planned-day-modal__button planned-day-modal__button--danger"
|
||||
onClick={handleDelete}
|
||||
disabled={isSaving}
|
||||
data-testid="delete-button"
|
||||
>
|
||||
{isSaving ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="planned-day-modal__button planned-day-modal__button--secondary"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="planned-day-modal__button planned-day-modal__button--primary"
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
data-testid="save-button"
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
.manual-fetch-modal__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__content {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: #666;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__description {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0 0 16px 0;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__section {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__field label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__field input {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__field input:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__info {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background-color: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__button--primary {
|
||||
background-color: #0066cc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__button--primary:hover:not(:disabled) {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__button--secondary {
|
||||
background-color: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.manual-fetch-modal__button--secondary:hover:not(:disabled) {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
.planned-day-modal__overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.planned-day-modal__content {
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.planned-day-modal__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.planned-day-modal__header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.planned-day-modal__close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: #666;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.planned-day-modal__close:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.planned-day-modal__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.planned-day-modal__date {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.planned-day-modal__error {
|
||||
background-color: #ffebee;
|
||||
color: #c62828;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.planned-day-modal__field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.planned-day-modal__label {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.planned-day-modal__select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d0d0d0;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.planned-day-modal__select:disabled {
|
||||
background-color: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.planned-day-modal__checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.planned-day-modal__checkbox-label input[type='checkbox'] {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.planned-day-modal__footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.planned-day-modal__button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.planned-day-modal__button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.planned-day-modal__button--primary {
|
||||
background-color: #0066cc;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.planned-day-modal__button--primary:hover:not(:disabled) {
|
||||
background-color: #0052a3;
|
||||
}
|
||||
|
||||
.planned-day-modal__button--secondary {
|
||||
background-color: #e0e0e0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.planned-day-modal__button--secondary:hover:not(:disabled) {
|
||||
background-color: #d0d0d0;
|
||||
}
|
||||
|
||||
.planned-day-modal__button--danger {
|
||||
background-color: #d32f2f;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.planned-day-modal__button--danger:hover:not(:disabled) {
|
||||
background-color: #b71c1c;
|
||||
}
|
||||
@@ -7,7 +7,8 @@ interface UsePlannedDaysResult {
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
refresh: () => Promise<void>
|
||||
add: (date: string, childId: string, leaveType: string) => Promise<PlannedLeaveDay>
|
||||
add: (payload: api.CreatePlannedDayPayload) => Promise<PlannedLeaveDay>
|
||||
update: (id: string, payload: api.UpdatePlannedDayPayload) => Promise<PlannedLeaveDay>
|
||||
remove: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -32,15 +33,21 @@ export function usePlannedDays(childId?: string): UsePlannedDaysResult {
|
||||
}, [childId])
|
||||
|
||||
const add = useCallback(
|
||||
async (date: string, childId: string, leaveType: string) => {
|
||||
const result = await api.createPlannedDay(childId, {
|
||||
date,
|
||||
leaveType,
|
||||
})
|
||||
async (payload: api.CreatePlannedDayPayload) => {
|
||||
const result = await api.createPlannedDay(payload)
|
||||
await fetch()
|
||||
return result
|
||||
},
|
||||
[fetch]
|
||||
[fetch],
|
||||
)
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string, payload: api.UpdatePlannedDayPayload) => {
|
||||
const result = await api.updatePlannedDay(id, payload)
|
||||
await fetch()
|
||||
return result
|
||||
},
|
||||
[fetch],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
@@ -48,12 +55,12 @@ export function usePlannedDays(childId?: string): UsePlannedDaysResult {
|
||||
await api.deletePlannedDay(id)
|
||||
await fetch()
|
||||
},
|
||||
[fetch]
|
||||
[fetch],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
fetch()
|
||||
}, [fetch])
|
||||
|
||||
return { data, isLoading, error, refresh: fetch, add, remove }
|
||||
return { data, isLoading, error, refresh: fetch, add, update, remove }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ProgressChart } from '@/components/dashboard/ProgressChart'
|
||||
import { ParentalLeaveBreakdown } from '@/components/dashboard/ParentalLeaveBreakdown'
|
||||
import { ChildBirthdayInput } from '@/components/inputs/ChildBirthdayInput'
|
||||
import { QRCodeModal } from '@/components/modals/QRCodeModal'
|
||||
import { ManualFetchModal } from '@/components/modals/ManualFetchModal'
|
||||
|
||||
export function Dashboard(): ReactNode {
|
||||
const { workspace, isLoading: wsLoading, selectedChildId, children, personalDataUuid, refreshContext } = useWorkspace()
|
||||
@@ -28,6 +29,9 @@ export function Dashboard(): ReactNode {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [showManualFetchModal, setShowManualFetchModal] = useState(false)
|
||||
const [isManualFetchSaving, setIsManualFetchSaving] = useState(false)
|
||||
const [manualFetchError, setManualFetchError] = useState<string | null>(null)
|
||||
|
||||
const isLoading = wsLoading || analyticsLoading || pdLoading || bpLoading
|
||||
|
||||
@@ -64,6 +68,44 @@ export function Dashboard(): ReactNode {
|
||||
await scraper.start(selectedChildId ?? undefined)
|
||||
}
|
||||
|
||||
const handleSaveManualFetch = async (data: {
|
||||
totalDays: number
|
||||
daysUsed: number
|
||||
reservedDaysLeft: number
|
||||
transferableDaysLeft: number
|
||||
dubbeldagarLeft: number
|
||||
sjukpenningUsed: number
|
||||
lagstaNivaUsed: number
|
||||
}) => {
|
||||
if (!workspace?.id || !selectedChildId || !personalDataUuid) return
|
||||
|
||||
try {
|
||||
setIsManualFetchSaving(true)
|
||||
setManualFetchError(null)
|
||||
|
||||
await api.saveManualFetch({
|
||||
personalDataUuid,
|
||||
workspaceId: workspace.id,
|
||||
childId: selectedChildId,
|
||||
totalDays: data.totalDays,
|
||||
daysUsed: data.daysUsed,
|
||||
reservedDaysLeft: data.reservedDaysLeft,
|
||||
transferableDaysLeft: data.transferableDaysLeft,
|
||||
dubbeldagarLeft: data.dubbeldagarLeft,
|
||||
sjukpenningUsed: data.sjukpenningUsed,
|
||||
lagstaNivaUsed: data.lagstaNivaUsed,
|
||||
})
|
||||
|
||||
await refreshParentalLeave()
|
||||
await refreshAnalytics()
|
||||
setShowManualFetchModal(false)
|
||||
} catch (err) {
|
||||
setManualFetchError(err instanceof Error ? err.message : 'Failed to save manual data')
|
||||
} finally {
|
||||
setIsManualFetchSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleModalClose = () => {
|
||||
if (scraper.isRunning) return
|
||||
setShowModal(false)
|
||||
@@ -135,13 +177,22 @@ export function Dashboard(): ReactNode {
|
||||
)}
|
||||
|
||||
<section className="dashboard__section">
|
||||
<button
|
||||
onClick={handleFetchFK}
|
||||
disabled={!selectedChildId}
|
||||
style={{ padding: '0.5rem 1rem', cursor: selectedChildId ? 'pointer' : 'not-allowed' }}
|
||||
>
|
||||
Hämta från Försäkringskassan
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<button
|
||||
onClick={handleFetchFK}
|
||||
disabled={!selectedChildId}
|
||||
style={{ padding: '0.5rem 1rem', cursor: selectedChildId ? 'pointer' : 'not-allowed' }}
|
||||
>
|
||||
Hämta från Försäkringskassan
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowManualFetchModal(true)}
|
||||
disabled={!selectedChildId}
|
||||
style={{ padding: '0.5rem 1rem', cursor: selectedChildId ? 'pointer' : 'not-allowed' }}
|
||||
>
|
||||
Ange uppgifter manuellt
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard__section">
|
||||
@@ -199,6 +250,14 @@ export function Dashboard(): ReactNode {
|
||||
isRunning={scraper.isRunning}
|
||||
onCancel={scraper.cancel}
|
||||
/>
|
||||
|
||||
<ManualFetchModal
|
||||
isOpen={showManualFetchModal}
|
||||
onSave={handleSaveManualFetch}
|
||||
onClose={() => setShowManualFetchModal(false)}
|
||||
isSaving={isManualFetchSaving}
|
||||
error={manualFetchError}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -25,6 +25,7 @@ export function Settings(): ReactNode {
|
||||
)
|
||||
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [newPartnerName, setNewPartnerName] = useState('')
|
||||
|
||||
const isLoading = wsLoading || vacLoading || bpLoading
|
||||
|
||||
@@ -163,6 +164,30 @@ export function Settings(): ReactNode {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddPartner = async () => {
|
||||
if (!newPartnerName.trim() || !workspace?.id) {
|
||||
setSaveMessage({ type: 'error', text: 'Please enter a partner name' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setSaveMessage(null)
|
||||
await api.addPartner(workspace.id, newPartnerName)
|
||||
await refreshContext()
|
||||
setNewPartnerName('')
|
||||
setSaveMessage({ type: 'success', text: 'Partner added! Refresh the page to see changes.' })
|
||||
setTimeout(() => setSaveMessage(null), 3000)
|
||||
} catch (err) {
|
||||
setSaveMessage({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : 'Failed to add partner',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div data-testid="settings-page" className="settings-page">
|
||||
@@ -258,6 +283,34 @@ export function Settings(): ReactNode {
|
||||
onRemove={handleRemoveBreakPeriod}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="settings-page__section">
|
||||
<h2>Partner</h2>
|
||||
<p>Add a partner to plan parental leave together.</p>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Partner name"
|
||||
value={newPartnerName}
|
||||
onChange={(e) => setNewPartnerName(e.target.value)}
|
||||
disabled={isSaving}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #d0d0d0',
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleAddPartner}
|
||||
className="settings-page__button"
|
||||
disabled={isSaving}
|
||||
data-testid="add-partner-button"
|
||||
>
|
||||
{isSaving ? 'Adding...' : 'Lägg till partner'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
|
||||
@@ -148,6 +148,16 @@ export async function updateWorkspace(
|
||||
})
|
||||
}
|
||||
|
||||
export async function addPartner(
|
||||
workspaceId: string,
|
||||
displayName: string,
|
||||
): Promise<{ uuid: string; name: string; salary_type: string; employment_status: string }> {
|
||||
return apiFetch(`/workspace/${workspaceId}/parents`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ display_name: displayName }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vacation days (reference endpoint group — mirror this pattern for others)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -289,13 +299,31 @@ export async function getPlannedDays(childId?: string): Promise<PlannedLeaveDay[
|
||||
return apiFetch(`/planned-days${query}`)
|
||||
}
|
||||
|
||||
export async function createPlannedDay(
|
||||
childId: string,
|
||||
payload: { date: string; leaveType: string },
|
||||
): Promise<PlannedLeaveDay> {
|
||||
export interface CreatePlannedDayPayload {
|
||||
workspace_id: string
|
||||
child_id: string
|
||||
parent_personal_data_uuid: string
|
||||
date: string
|
||||
day_type: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
is_dubbeldag?: boolean
|
||||
}
|
||||
|
||||
export async function createPlannedDay(payload: CreatePlannedDayPayload): Promise<PlannedLeaveDay> {
|
||||
return apiFetch(`/planned-days`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...payload, child_id: childId }),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export interface UpdatePlannedDayPayload {
|
||||
day_type?: 'sjukpenning' | 'lagsta' | 'vacation'
|
||||
is_dubbeldag?: boolean
|
||||
}
|
||||
|
||||
export async function updatePlannedDay(id: string, payload: UpdatePlannedDayPayload): Promise<PlannedLeaveDay> {
|
||||
return apiFetch(`/planned-days/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Junior Developer — Calendar View Fix
|
||||
|
||||
**Owner:** Junior developer
|
||||
**Status:** Ready to implement
|
||||
**Scope:** The **Kalender** view only (`/calendar`). Do not touch Dashboard, Settings, Tips, or backend.
|
||||
**Goal:** Make the month calendar render as a real 7-column grid that looks like a calendar, behaves correctly for Swedish users, and is keyboard/mobile friendly — **without** adding a calendar library and **without** rewriting the working planning logic.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — what's actually wrong
|
||||
|
||||
The calendar **logic is already correct and complete**. `Calendar.tsx` builds weeks, pads leading/trailing days, maps planned days to colored events, and opens the day-edit modal with full CRUD. The markup already uses good BEM class names (`calendar__grid`, `calendar-day`, `calendar-day__number`, `calendar__weekday`, …).
|
||||
|
||||
**The only real problem: those classes have no CSS.** The app ships exactly one stylesheet (`header.css`) and the calendar's classes are unstyled, so every cell falls back to a default block `<div>` and stacks vertically. That vertical list of "31 1 2 3 …" in the screenshot is unstyled markup, nothing more.
|
||||
|
||||
So this is a **CSS task first**, with a few small correctness fixes layered on top. **We are building this ourselves — no FullCalendar / react-big-calendar.** A month grid is `display: grid` + cell styling; a library would force our bespoke day-type / dubbeldag / age-4 model into its generic event API and is not worth it.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails (read before starting)
|
||||
|
||||
- **Do NOT add a dependency.** No FullCalendar, react-big-calendar, day-picker, Tailwind, etc. Plain CSS only.
|
||||
- **Do NOT rewrite the planning logic.** The week-building loop, `getEventsForDate`, the modal wiring, and the CRUD handlers in `Calendar.tsx` work — leave them unless a task here explicitly says to change them.
|
||||
- **TypeScript only** — no `.js`/`.jsx`. No `as any`, `@ts-ignore`, or `@ts-expect-error` (CI fails on ESLint warnings with `--max-warnings 0`).
|
||||
- **Match the existing CSS pattern:** components own a co-located stylesheet imported at the top of the file — e.g. `Header.tsx` imports `./header.css`, `PlannedDayModal.tsx` imports `./planned-day-modal.css`. Follow that exact pattern for the calendar. **Do not** wait on a global stylesheet to land.
|
||||
- **Read before edit.** Open every file before changing it; confirm structure first.
|
||||
- Keep `npx tsc --noEmit`, `npm run lint`, and `npm run coverage` green after every task.
|
||||
|
||||
---
|
||||
|
||||
## Files you will touch
|
||||
|
||||
- `frontend/src/pages/Calendar.tsx` — add a CSS import; small correctness edits (P2).
|
||||
- `frontend/src/pages/calendar.css` — **new** stylesheet (the bulk of the work).
|
||||
- `frontend/src/components/common/CalendarDay.tsx` — only if P2 needs a "today"/weekend hook.
|
||||
- Tests: `frontend/src/components/common/CalendarDay.test.tsx`, and a `Calendar.test.tsx` if one doesn't exist.
|
||||
|
||||
> ⚠️ **Confirm git state first.** `PlannedDayModal.tsx`, `ManualFetchModal.tsx`, and their CSS appear to be **untracked** (created during the MVP work but not committed). Run `git status` before you start. Don't accidentally revert or clobber them — commit or stash deliberately.
|
||||
|
||||
---
|
||||
|
||||
## P1 — Make it an actual calendar grid (the core fix)
|
||||
|
||||
**Problem:** `calendar__grid` has no layout, so the seven weekday headers and ~35 day cells render as a vertical stack.
|
||||
|
||||
**Do:**
|
||||
|
||||
1. Create `frontend/src/pages/calendar.css`.
|
||||
2. At the top of `Calendar.tsx`, add `import './calendar.css'` (mirror how `Header.tsx` imports `./header.css`).
|
||||
3. Style the structure that already exists in the JSX:
|
||||
- `.calendar` — center it, constrain width (~`max-width: 920px; margin: 0 auto; padding: 16px`).
|
||||
- `.calendar__header` — `display: flex; align-items: center; justify-content: space-between;` so the `←` / month title / `→` sit on one row. Style the nav buttons as clear, tappable controls (min 36×36px).
|
||||
- `.calendar__grid` — **this is the key rule:** `display: grid; grid-template-columns: repeat(7, 1fr);` with a small `gap`. The weekday headers and day cells are direct children, so they'll flow into the 7 columns automatically.
|
||||
- `.calendar__weekday` — centered, muted, smaller, semibold column header.
|
||||
- `.calendar-day` — give it a min-height (~`84px`), light border/`border-radius`, white background, padding, `cursor: pointer`, and a hover state. Day number top-left (`.calendar-day__number`).
|
||||
- `.calendar-day--disabled` — the leading/trailing days from adjacent months: muted text, faded background, no hover affordance.
|
||||
- `.calendar-day--has-events` — subtle emphasis is fine (optional).
|
||||
- `.calendar-day__events` — a small row/wrap of event chips at the bottom of the cell.
|
||||
- `.calendar-day__event` — the colored dot/bar per planned day. Keep it small (a dot or a thin bar), **not** a full-width block. Colors come from the inline `backgroundColor` style already set in `CalendarDay.tsx` — don't hardcode them again in CSS.
|
||||
- `:focus-visible` outline on `.calendar-day` (it's `role="button" tabIndex={0}` and already handles Enter/Space).
|
||||
4. **Fix the legend.** Right now the legend reuses `CalendarEvent`, which renders full-width colored bars (the green/orange/blue stripes in the screenshot). Style `.calendar__legend` as a compact horizontal row of small swatch + label pairs. Either restyle `.calendar-event` when it sits inside `.calendar__legend-item` (small inline-flex, fixed-size swatch, auto width) **or** replace the legend markup with simple swatch spans. Keep it tidy and small.
|
||||
|
||||
**Acceptance:** The Kalender view shows a recognizable month grid — 7 columns, weekday headers across the top, square-ish day cells, adjacent-month days dimmed, planned days marked with small colored dots, and a compact legend. No vertical stacking. Looks calm and readable on desktop.
|
||||
|
||||
---
|
||||
|
||||
## P2 — Correctness & polish (Swedish conventions)
|
||||
|
||||
Do these after P1 looks right. They're small but matter for a Swedish app.
|
||||
|
||||
1. **Monday-first week.** Sweden starts the week on Monday, not Sunday.
|
||||
- Weekday headers are currently `['Sun','Mon',…,'Sat']` in `Calendar.tsx`. Change to Swedish, Monday-first: `['Mån','Tis','Ons','Tor','Fre','Lör','Sön']`.
|
||||
- The leading-blank calculation uses `firstDay.getDay()` (0 = Sunday). Convert to Monday-based: `const startOffset = (firstDay.getDay() + 6) % 7;` and use `startOffset` where `startingDayOfWeek` is used (both the leading-pad loop and any references). Re-verify the trailing-pad math still fills to a multiple of 7.
|
||||
- **Test the boundary:** a month that starts on Sunday (e.g. **March 2026** starts Sunday) must show that Sunday in the **last** column, not the first.
|
||||
2. **Highlight "today."** Add a `calendar-day--today` modifier when a cell's date equals the real current date (compare `YYYY-MM-DD` strings; reuse the existing `formatDate` helper — don't introduce a timezone bug by using `Date` comparison). Give it a distinct but subtle ring/background. (You can compute this in `Calendar.tsx` and pass a prop into `CalendarDay`, or compute inside `CalendarDay`.)
|
||||
3. **Weekend tint (optional, nice-to-have).** Lightly tint Saturday/Sunday columns so the week structure reads at a glance.
|
||||
4. **Default month.** `Calendar.tsx` hardcodes `useState(new Date(2026, 5))` (June 2026). Default to the **current month** (`new Date()`) instead, so the calendar opens on a useful month. Keep the existing prev/next navigation.
|
||||
|
||||
> **Out of scope but worth flagging to the senior, don't fix here:** the header shows a stray "August" dropdown **and** an "August" link next to the parent name (visible top-right in the screenshot) — that looks like a duplicate/placeholder child selector in `Header.tsx`, unrelated to the calendar. Note it; leave it.
|
||||
|
||||
**Acceptance:** Week runs Mån→Sön; a Sunday-starting month renders correctly; today is visibly marked; the view opens on the current month.
|
||||
|
||||
---
|
||||
|
||||
## P3 — Tests
|
||||
|
||||
Keep the frontend coverage gate (90%) green.
|
||||
|
||||
- **`CalendarDay`:** assert the grid cell renders the day number, applies `--disabled` when `isCurrentMonth={false}`, renders one `.calendar-day__event` per event with the right `backgroundColor`, fires `onClick` on click and on Enter/Space, and applies `--today` when given today's date.
|
||||
- **`Calendar` (page):** with a mocked `usePlannedDays`/`useWorkspace`, assert that 7 weekday headers render, that the grid contains the expected number of day cells for a known month, that the **Monday-first** ordering is correct (first header is "Mån"), and that clicking a current-month day opens the modal. Use the boundary month from P2 as a regression test.
|
||||
- Don't delete or comment out existing tests to go green — fix the code or update the test with a clear reason.
|
||||
|
||||
**Acceptance:** `npx tsc --noEmit`, `npm run lint` (zero warnings), and `npm run coverage` (≥90%) all pass.
|
||||
|
||||
---
|
||||
|
||||
## Definition of done
|
||||
|
||||
- Kalender view renders as a proper 7-column month grid, styled calmly and legibly, with a compact legend and small event markers.
|
||||
- Week is Monday-first with Swedish weekday labels; Sunday-starting months render correctly; today is highlighted; opens on the current month.
|
||||
- No new dependencies; no `.js`/`.jsx`; no type suppressions.
|
||||
- All three frontend gates pass; existing modal/Dashboard behavior untouched.
|
||||
- `graphify update .` run after the code changes (per repo protocol).
|
||||
|
||||
---
|
||||
|
||||
## Suggested commit slices
|
||||
|
||||
1. `feat(calendar): add calendar.css grid layout + styled day cells and legend` (P1)
|
||||
2. `fix(calendar): Monday-first Swedish week, today highlight, default to current month` (P2)
|
||||
3. `test(calendar): grid layout, week ordering, and day-cell coverage` (P3)
|
||||
|
||||
---
|
||||
|
||||
## A note on "build vs. library" (for context)
|
||||
|
||||
We deliberately chose to build this ourselves. The hard parts of a calendar — date math, navigation, event mapping, the day-edit flow — are already done and working. The gap was purely CSS. A third-party calendar library would mean adapting our domain model (sjukpenning / lägstanivå / vacation day types, dubbeldagar, the age-4 cutoff, reserved days) to its generic event API and restyling it anyway — more work and less control, not less. A fixed month grid is the cheapest possible calendar to own. If we ever need drag-to-reschedule across an agenda view, revisit then; not now.
|
||||
Reference in New Issue
Block a user