33409352ee
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 28s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m38s
CI / test-python-agent-core (push) Successful in 20s
CI / test-nodejs-website (push) Successful in 26s
Backend (school-service):
- calendar_events.go — Create/List/Delete on cal_school_event with
UUID[] handling for affected_class_ids. Default lead-days [7,1]
if caller omits the array.
- calendar_rollover.go — single-transaction promotion: graduating
classes (grade >= 13) get deleted first so the +1 update doesn't
bump them to invalid grade 14. defaultSchoolYearDates() picks the
next Aug-Jul pair when the caller doesn't specify.
- Handlers + routes: GET/POST /calendar/events,
DELETE /calendar/events/:id, POST /calendar/school-year-rollover.
Frontend (studio-v2):
- EventModal: form with Title / Typ / Datum/Zeit / unterrichtsfrei /
Beschreibung / Sichtbarkeit + Notification-Checkboxen. Per-Type
Farb-Mapping in types.ts.
- DayDetail: Modal das beim Klick auf einen Kalender-Tag aufgeht und
Feiertage + Schulferien + Schul-Events fuer diesen Tag listet,
inkl. Loeschen-Button pro Event.
- RolloverWizard: zwei-Schritt-Dialog mit Datums-Auswahl + Tipp-
Bestaetigung ("SCHULJAHR WECHSELN") gegen versehentliche Auslo-
sung, danach Ergebnis-Card mit promoted/graduated-Counts.
- MonthView gewinnt onDayClick + onAddEvent + onRollover Props,
rendert farb-codierte Punkte fuer School-Events am Tagesrand.
- Page laed Events parallel mit Holidays und reicht alle Handler
nach unten.
Tests:
- Go: 3 neue Tests fuer defaultSchoolYearDates + parseClassIDs.
Validator-Test fuer CreateSchoolEventRequest existiert bereits.
80 Subtests gesamt, alle gruen.
- Playwright: mockCalendarApi gewinnt Routes fuer events GET/POST/
DELETE und school-year-rollover. 6 neue Tests (EventModal open,
submit, DayDetail open, Rollover-Trigger, Confirm-Schutz,
Ergebnis-Anzeige).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
202 lines
8.3 KiB
TypeScript
202 lines
8.3 KiB
TypeScript
'use client'
|
|
|
|
import { useMemo } from 'react'
|
|
import { useTheme } from '@/lib/ThemeContext'
|
|
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
|
|
import { EVENT_TYPE_COLOR } from '@/app/schulkalender/types'
|
|
|
|
interface MonthViewProps {
|
|
year: number
|
|
month: number // 1-12
|
|
holidays: PublicEvent[]
|
|
schoolEvents?: SchoolEvent[]
|
|
onPrev: () => void
|
|
onNext: () => void
|
|
onToday: () => void
|
|
onDayClick?: (iso: string) => void
|
|
onAddEvent?: () => void
|
|
onRollover?: () => 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, schoolEvents = [], onPrev, onNext, onToday, onDayClick, onAddEvent, onRollover }: MonthViewProps) {
|
|
const { isDark } = useTheme()
|
|
const cells = useMemo(() => buildMonthGrid(year, month, holidays), [year, month, holidays])
|
|
|
|
// School events per ISO date — quick lookup during cell render.
|
|
const schoolEventsByDate = useMemo(() => {
|
|
const map = new Map<string, SchoolEvent[]>()
|
|
for (const ev of schoolEvents) {
|
|
const start = new Date(ev.start_date)
|
|
const end = new Date(ev.end_date)
|
|
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
|
const iso = d.toISOString().slice(0, 10)
|
|
const arr = map.get(iso) || []
|
|
arr.push(ev)
|
|
map.set(iso, arr)
|
|
}
|
|
}
|
|
return map
|
|
}, [schoolEvents])
|
|
|
|
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">
|
|
{onAddEvent && (
|
|
<button onClick={onAddEvent} data-testid="add-event" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-indigo-500 hover:bg-indigo-600 text-white' : 'bg-indigo-600 hover:bg-indigo-700 text-white'}`}>+ Termin</button>
|
|
)}
|
|
{onRollover && (
|
|
<button onClick={onRollover} data-testid="rollover-trigger" className={`px-3 py-1.5 rounded-lg text-sm font-medium ${isDark ? 'bg-amber-500/30 hover:bg-amber-500/50 text-amber-100' : 'bg-amber-100 hover:bg-amber-200 text-amber-900'}`}>Schuljahr wechseln</button>
|
|
)}
|
|
<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'
|
|
|
|
const dayEvents = schoolEventsByDate.get(iso) || []
|
|
|
|
return (
|
|
<div
|
|
key={i}
|
|
data-testid={`day-${iso}`}
|
|
onClick={() => c.inMonth && onDayClick?.(iso)}
|
|
className={`relative aspect-square rounded-lg p-2 text-sm border ${
|
|
onDayClick && c.inMonth ? 'cursor-pointer hover:ring-2 hover:ring-indigo-300/50' : ''
|
|
} ${
|
|
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>
|
|
)}
|
|
{dayEvents.length > 0 && (
|
|
<div className="absolute bottom-1 left-1 right-1 flex flex-wrap gap-0.5">
|
|
{dayEvents.slice(0, 4).map(ev => (
|
|
<span
|
|
key={ev.id}
|
|
title={ev.title}
|
|
className="inline-block w-1.5 h-1.5 rounded-full"
|
|
style={{ backgroundColor: EVENT_TYPE_COLOR[ev.event_type] }}
|
|
/>
|
|
))}
|
|
</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>
|
|
)
|
|
}
|