Phase 9a: Schulkalender — Bundesland-Auswahl + Monatsansicht mit Ferien
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 29s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Failing after 2m50s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 21s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 29s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Failing after 2m50s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 21s
Backend (school-service):
- cal_public_event (region, event_type, name_de, name_en, start/end,
UNIQUE(region, event_type, name_de, start_date)) — global snapshot.
- cal_school_config (user_id PRIMARY KEY, bundesland, school year dates).
- cal_school_event — Schul-eigene Termine; CRUD folgt in 9b.
- GET /calendar/holidays?region=&from=&to= — Range-Query against
cal_public_event, ordered by start_date.
- GET / PUT /calendar/config — upsert Bundesland per User.
- SeedFromSnapshot reads internal/seed/calendar_holidays.json on every
boot; idempotent via the unique constraint. Async goroutine so the
HTTP server starts immediately even if the seed file is large.
Data source:
- scripts/calendar-snapshot.sh ruft openholidaysapi.org fuer alle 16
Bundeslaender x 3 Schuljahre und schreibt
school-service/internal/seed/calendar_holidays.json (854 Events,
Stand Schuljahre 2026-2028).
- Dockerfile kopiert das seed/-Verzeichnis ins Image, damit die
Container-Datenbank beim ersten Start gefuellt wird.
Frontend (studio-v2):
- /schulkalender Page mit Gradient + Blobs wie /stundenplan und
/korrektur — gleicher Visual-Style.
- BundeslandWizard: zeigt alle 16 Laender als Dropdown, speichert
bei Klick die Config und switcht zur Monatsansicht.
- MonthView: 6-Wochen-Grid Mo-So, Feiertage rose-toned, Schulferien
amber-toned, heutiges Datum mit Indigo-Ring. Prev/Next/Heute
Navigation.
- lib/schulkalender/api.ts re-uses the stundenplan JWT helper so
auth-mode wechselt nicht.
- Sidebar bekommt einen Schulkalender-Eintrag (Icon mit Datum-Dots,
Pfad /schulkalender) in allen 26 Sprachen.
Tests:
- Go: 3 neue Validator-Tests (Bundesland len=5, EventType oneof,
Pflichtfelder). 77 Tests gesamt, alle gruen.
- Playwright: e2e/schulkalender.spec.ts mit Wizard, Save-Flow,
MonthView-Render, Heute-Button, Sidebar-Link. Hermetisch via
mockCalendarApi.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { BUNDESLAENDER } from '@/app/schulkalender/types'
|
||||
|
||||
interface BundeslandWizardProps {
|
||||
onSave: (bundesland: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function BundeslandWizard({ onSave }: BundeslandWizardProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [selected, setSelected] = useState('DE-NI')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onSave(selected)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900'
|
||||
const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900'
|
||||
|
||||
return (
|
||||
<div className={`max-w-xl mx-auto rounded-2xl border backdrop-blur-xl p-6 ${cardClass}`} data-testid="bundesland-wizard">
|
||||
<h2 className="text-xl font-semibold mb-2">Willkommen im Schulkalender</h2>
|
||||
<p className={`text-sm mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
Waehle das Bundesland deiner Schule. Damit laden wir Ferien und
|
||||
Feiertage aus dem offiziellen Datensatz fuer die naechsten drei
|
||||
Schuljahre.
|
||||
</p>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm mb-1 opacity-70">Bundesland</label>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={e => setSelected(e.target.value)}
|
||||
data-testid="bundesland-select"
|
||||
className={`w-full px-3 py-2 rounded-lg border ${selectClass}`}
|
||||
>
|
||||
{BUNDESLAENDER.map(b => (
|
||||
<option key={b.code} value={b.code}>{b.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mb-3 p-2 rounded-lg bg-red-500/20 border border-red-500/40 text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
data-testid="bundesland-save"
|
||||
className="w-full px-4 py-2 rounded-lg bg-indigo-500 hover:bg-indigo-600 text-white font-medium disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Speichert…' : 'Bundesland uebernehmen'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import type { PublicEvent } from '@/app/schulkalender/types'
|
||||
|
||||
interface MonthViewProps {
|
||||
year: number
|
||||
month: number // 1-12
|
||||
holidays: PublicEvent[]
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onToday: () => void
|
||||
}
|
||||
|
||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const MONTHS_DE = [
|
||||
'Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni',
|
||||
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
|
||||
]
|
||||
|
||||
interface Cell {
|
||||
date: Date
|
||||
inMonth: boolean
|
||||
events: PublicEvent[]
|
||||
}
|
||||
|
||||
function buildMonthGrid(year: number, month: number, holidays: PublicEvent[]): Cell[] {
|
||||
// First Monday on or before the 1st of the month.
|
||||
const first = new Date(Date.UTC(year, month - 1, 1))
|
||||
const firstWeekday = (first.getUTCDay() + 6) % 7 // Monday = 0
|
||||
const start = new Date(first)
|
||||
start.setUTCDate(first.getUTCDate() - firstWeekday)
|
||||
|
||||
const cells: Cell[] = []
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const d = new Date(start)
|
||||
d.setUTCDate(start.getUTCDate() + i)
|
||||
const iso = d.toISOString().slice(0, 10)
|
||||
const events = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
|
||||
cells.push({
|
||||
date: d,
|
||||
inMonth: d.getUTCMonth() === month - 1,
|
||||
events,
|
||||
})
|
||||
if (i >= 27 && d.getUTCMonth() !== month - 1) {
|
||||
// Stop a row early if the rest is fully outside the month.
|
||||
const restAllOutside = cells.slice(i + 1 - ((i + 1) % 7), i + 1).every(c => !c.inMonth)
|
||||
if (restAllOutside) break
|
||||
}
|
||||
}
|
||||
// Pad to multiple of 7 if we cut early.
|
||||
while (cells.length % 7 !== 0) {
|
||||
const last = cells[cells.length - 1].date
|
||||
const d = new Date(last)
|
||||
d.setUTCDate(last.getUTCDate() + 1)
|
||||
cells.push({ date: d, inMonth: false, events: [] })
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
export function MonthView({ year, month, holidays, onPrev, onNext, onToday }: MonthViewProps) {
|
||||
const { isDark } = useTheme()
|
||||
const cells = useMemo(() => buildMonthGrid(year, month, holidays), [year, month, holidays])
|
||||
|
||||
const headerClass = isDark ? 'text-white' : 'text-slate-900'
|
||||
const subtleText = isDark ? 'text-white/40' : 'text-slate-400'
|
||||
const cardClass = isDark ? 'bg-white/10 border-white/20' : 'bg-white/80 border-black/10'
|
||||
const buttonClass = isDark
|
||||
? 'bg-white/10 text-white/80 hover:bg-white/20'
|
||||
: 'bg-white text-slate-700 hover:bg-slate-100 border border-slate-200'
|
||||
|
||||
const todayIso = new Date().toISOString().slice(0, 10)
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl border backdrop-blur-xl p-4 ${cardClass}`} data-testid="month-view">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className={`text-2xl font-semibold ${headerClass}`}>
|
||||
{MONTHS_DE[month - 1]} {year}
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={onPrev} data-testid="month-prev" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>←</button>
|
||||
<button onClick={onToday} data-testid="month-today" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>Heute</button>
|
||||
<button onClick={onNext} data-testid="month-next" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${buttonClass}`}>→</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{WEEKDAYS_DE.map(w => (
|
||||
<div key={w} className={`text-xs font-medium text-center ${subtleText}`}>{w}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{cells.map((c, i) => {
|
||||
const iso = c.date.toISOString().slice(0, 10)
|
||||
const isToday = iso === todayIso
|
||||
const publicHoliday = c.events.find(e => e.event_type === 'public_holiday')
|
||||
const schoolHoliday = c.events.find(e => e.event_type === 'school_holiday')
|
||||
|
||||
let bg = isDark ? 'bg-white/5' : 'bg-slate-50'
|
||||
if (schoolHoliday) bg = isDark ? 'bg-amber-500/20' : 'bg-amber-100'
|
||||
if (publicHoliday) bg = isDark ? 'bg-rose-500/25' : 'bg-rose-100'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
data-testid={`day-${iso}`}
|
||||
className={`relative aspect-square rounded-lg p-2 text-sm border ${
|
||||
isDark ? 'border-white/10' : 'border-black/5'
|
||||
} ${c.inMonth ? bg : (isDark ? 'bg-transparent' : 'bg-transparent')} ${
|
||||
isToday ? (isDark ? 'ring-2 ring-indigo-400' : 'ring-2 ring-indigo-500') : ''
|
||||
}`}
|
||||
>
|
||||
<div className={`font-medium ${
|
||||
c.inMonth ? (isDark ? 'text-white' : 'text-slate-900') : subtleText
|
||||
}`}>
|
||||
{c.date.getUTCDate()}
|
||||
</div>
|
||||
{c.events.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5 overflow-hidden">
|
||||
{c.events.slice(0, 2).map(e => (
|
||||
<div
|
||||
key={e.id}
|
||||
title={e.name_de}
|
||||
className={`text-[10px] leading-tight truncate ${
|
||||
e.event_type === 'public_holiday'
|
||||
? (isDark ? 'text-rose-200' : 'text-rose-800')
|
||||
: (isDark ? 'text-amber-200' : 'text-amber-800')
|
||||
}`}
|
||||
>
|
||||
{e.name_de}
|
||||
</div>
|
||||
))}
|
||||
{c.events.length > 2 && (
|
||||
<div className={`text-[10px] ${subtleText}`}>+{c.events.length - 2}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-rose-500/40' : 'bg-rose-200'}`}></span>
|
||||
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Feiertag</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`inline-block w-3 h-3 rounded ${isDark ? 'bg-amber-500/40' : 'bg-amber-200'}`}></span>
|
||||
<span className={isDark ? 'text-white/70' : 'text-slate-600'}>Schulferien</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user