From 6ed2a3ae0c79bcc7d7cf41c9682faaa76dc5b770 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sun, 14 Jun 2026 13:05:53 +0200 Subject: [PATCH] Fix calendar style --- JUNIOR_styling_plan.md | 17 +- .../components/modals/ManualFetchModal.tsx | 197 ++++++++++++++++++ .../src/components/modals/PlannedDayModal.tsx | 190 +++++++++++++++++ .../components/modals/manual-fetch-modal.css | 165 +++++++++++++++ .../components/modals/planned-day-modal.css | 161 ++++++++++++++ frontend/src/hooks/usePlannedDays.ts | 25 ++- frontend/src/pages/Dashboard.tsx | 73 ++++++- frontend/src/pages/Settings.tsx | 53 +++++ frontend/src/services/api.ts | 38 +++- junior_calendar_fix.md | 117 +++++++++++ 10 files changed, 1005 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/modals/ManualFetchModal.tsx create mode 100644 frontend/src/components/modals/PlannedDayModal.tsx create mode 100644 frontend/src/components/modals/manual-fetch-modal.css create mode 100644 frontend/src/components/modals/planned-day-modal.css create mode 100644 junior_calendar_fix.md diff --git a/JUNIOR_styling_plan.md b/JUNIOR_styling_plan.md index de7e8e9..23cf743 100644 --- a/JUNIOR_styling_plan.md +++ b/JUNIOR_styling_plan.md @@ -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. diff --git a/frontend/src/components/modals/ManualFetchModal.tsx b/frontend/src/components/modals/ManualFetchModal.tsx new file mode 100644 index 0000000..4c208be --- /dev/null +++ b/frontend/src/components/modals/ManualFetchModal.tsx @@ -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 + 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(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 ( +
+
e.stopPropagation()}> +
+

Enter Parental Leave Data

+ +
+ +
+

+ Enter your parental leave information from Försäkringskassan manually if automatic fetching is unavailable. +

+ + {(error || localError) && ( +
+ {error || localError} +
+ )} + +
+

Total Days

+
+ + setTotalDays(Number(e.target.value))} + disabled={isSaving} + /> +
+ +
+ + setDaysUsed(Number(e.target.value))} + disabled={isSaving} + /> +
+ +
+ Days remaining: {daysLeft} +
+
+ +
+

Remaining Days Breakdown

+
+ + setReservedDaysLeft(Number(e.target.value))} + disabled={isSaving} + /> +
+ +
+ + setTransferableDaysLeft(Number(e.target.value))} + disabled={isSaving} + /> +
+ +
+ + setDubbeldagarLeft(Number(e.target.value))} + disabled={isSaving} + /> +
+
+ +
+

Tier Breakdown

+
+ + setSjukpenningUsed(Number(e.target.value))} + disabled={isSaving} + /> +
+ +
+ + setLagstaNivaUsed(Number(e.target.value))} + disabled={isSaving} + /> +
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/modals/PlannedDayModal.tsx b/frontend/src/components/modals/PlannedDayModal.tsx new file mode 100644 index 0000000..b110cf3 --- /dev/null +++ b/frontend/src/components/modals/PlannedDayModal.tsx @@ -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 + onDelete?: (() => Promise) | 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(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 ( +
+
e.stopPropagation()}> +
+

{existingDay ? 'Edit Planned Day' : 'Plan a Day'}

+ +
+ +
+

{displayDate}

+ + {(error || localError) && ( +
+ {error || localError} +
+ )} + +
+ + +
+ + {parents.length > 1 && ( +
+ + +
+ )} + +
+ +
+
+ +
+ {existingDay && onDelete && ( + + )} + + +
+
+
+ ) +} diff --git a/frontend/src/components/modals/manual-fetch-modal.css b/frontend/src/components/modals/manual-fetch-modal.css new file mode 100644 index 0000000..2311a5a --- /dev/null +++ b/frontend/src/components/modals/manual-fetch-modal.css @@ -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; +} diff --git a/frontend/src/components/modals/planned-day-modal.css b/frontend/src/components/modals/planned-day-modal.css new file mode 100644 index 0000000..aa6c272 --- /dev/null +++ b/frontend/src/components/modals/planned-day-modal.css @@ -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; +} diff --git a/frontend/src/hooks/usePlannedDays.ts b/frontend/src/hooks/usePlannedDays.ts index fbc5628..8a7689b 100644 --- a/frontend/src/hooks/usePlannedDays.ts +++ b/frontend/src/hooks/usePlannedDays.ts @@ -7,7 +7,8 @@ interface UsePlannedDaysResult { isLoading: boolean error: string | null refresh: () => Promise - add: (date: string, childId: string, leaveType: string) => Promise + add: (payload: api.CreatePlannedDayPayload) => Promise + update: (id: string, payload: api.UpdatePlannedDayPayload) => Promise remove: (id: string) => Promise } @@ -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 } } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 9ca782b..47fd5b2 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -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(null) const [showModal, setShowModal] = useState(false) + const [showManualFetchModal, setShowManualFetchModal] = useState(false) + const [isManualFetchSaving, setIsManualFetchSaving] = useState(false) + const [manualFetchError, setManualFetchError] = useState(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 { )}
- +
+ + +
@@ -199,6 +250,14 @@ export function Dashboard(): ReactNode { isRunning={scraper.isRunning} onCancel={scraper.cancel} /> + + setShowManualFetchModal(false)} + isSaving={isManualFetchSaving} + error={manualFetchError} + /> ) diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 253dbd4..2f476bb 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -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 (
@@ -258,6 +283,34 @@ export function Settings(): ReactNode { onRemove={handleRemoveBreakPeriod} />
+ +
+

Partner

+

Add a partner to plan parental leave together.

+
+ setNewPartnerName(e.target.value)} + disabled={isSaving} + style={{ + flex: 1, + padding: '8px 10px', + border: '1px solid #d0d0d0', + borderRadius: '4px', + }} + /> + +
+
) diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 72958d3..e97a90c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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 { +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 { 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 { + return apiFetch(`/planned-days/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), }) } diff --git a/junior_calendar_fix.md b/junior_calendar_fix.md new file mode 100644 index 0000000..638b1e3 --- /dev/null +++ b/junior_calendar_fix.md @@ -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 `
` 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.