Files
breakpilot-lehrer/studio-v2/app/schulkalender/_components/DayDetail.tsx
T
Benjamin Admin 8311b33fb3
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 1m10s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 4m4s
CI / test-python-agent-core (push) Successful in 44s
CI / test-nodejs-website (push) Successful in 51s
Phase 9d: Notification cron + multilingual templates + status badges
Backend (school-service):
  - notification_log table with UNIQUE(event_id, lead_days, audience,
    channel) for idempotent re-runs. Status enum sent/failed/skipped.
  - internal/notifications/templates.go: per-event-type × audience ×
    lead-day-bucket × language templates in 8 languages (de/en/tr/ar/
    uk/ru/pl/fr). Fallback chain (lang→de, eventType→andere) so we
    never miss a render.
  - service.go scans cal_school_event for events whose
    (start_date - runDate) appears in notification_lead_days. For each
    due (audience, channel) tuple it dispatches via POST to the
    Matrix/Email upstreams owned by the colleague's services.
    Empty URL → status='skipped', logged for visibility.
  - dispatcher.go handles the POST, parent-recipient lookup (joins
    parent_account + parent_child + cal_school_event.affected_class_ids),
    and writeLog with the unique constraint dropping duplicate runs.
  - main.go runs a 1-hour ticker; when time.Hour()==6 it invokes the
    scanner for today. Idempotent so transient restarts don't double-
    send.
  - POST /calendar/notifications/run-now for manual trigger + backfill
    (?date=YYYY-MM-DD).
  - GET /calendar/events/:id/notifications returns notification_log
    rows scoped to the owning teacher.
  - MATRIX_SERVICE_URL + EMAIL_SERVICE_URL env vars added (default
    empty = stub mode).

Frontend (studio-v2):
  - NotificationStatus component fetches /events/:id/notifications and
    renders coloured badges per (lead, audience, channel, status).
  - DayDetail mounts NotificationStatus inside each event card when
    notify_parents or notify_students is set.

Tests:
  - 6 new Go unit tests for bucketFor + Render (de/tr/fallback paths)
    + substitute(class_suffix). 89 subtests gesamt.
  - 2 new Playwright tests: badge render with mocked log, hidden when
    notifications are off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 18:12:39 +02:00

107 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { calendarApi } from '@/lib/schulkalender/api'
import type { PublicEvent, SchoolEvent } from '@/app/schulkalender/types'
import { EVENT_TYPE_COLOR, EVENT_TYPE_LABEL } from '@/app/schulkalender/types'
import { NotificationStatus } from './NotificationStatus'
interface DayDetailProps {
iso: string
holidays: PublicEvent[]
events: SchoolEvent[]
onClose: () => void
onDeleted: () => void
}
export function DayDetail({ iso, holidays, events, onClose, onDeleted }: DayDetailProps) {
const { isDark } = useTheme()
const handleDelete = async (id: string) => {
if (!confirm('Termin wirklich loeschen?')) return
try {
await calendarApi.deleteEvent(id)
onDeleted()
} catch {
// best-effort
}
}
const cardClass = isDark ? 'bg-slate-900/95 border-white/20 text-white' : 'bg-white border-black/10 text-slate-900'
const dayHolidays = holidays.filter(h => iso >= h.start_date && iso <= h.end_date)
const dayEvents = events.filter(e => iso >= e.start_date && iso <= e.end_date)
const formattedDate = new Date(iso).toLocaleDateString('de-DE', {
weekday: 'long', day: 'numeric', month: 'long', year: 'numeric',
})
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur" data-testid="day-detail">
<div className={`w-full max-w-lg rounded-2xl border p-6 space-y-4 max-h-[90vh] overflow-y-auto ${cardClass}`}>
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{formattedDate}</h2>
<button onClick={onClose} className="opacity-60 hover:opacity-100"></button>
</div>
{dayHolidays.length === 0 && dayEvents.length === 0 && (
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Keine Eintraege fuer diesen Tag.
</p>
)}
{dayHolidays.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium opacity-80">Bundesweite Eintraege</h3>
{dayHolidays.map(h => (
<div key={h.id} className={`p-2 rounded-lg text-sm ${h.event_type === 'public_holiday' ? (isDark ? 'bg-rose-500/20' : 'bg-rose-50') : (isDark ? 'bg-amber-500/20' : 'bg-amber-50')}`}>
<div className="font-medium">{h.name_de}</div>
<div className="text-xs opacity-70">{h.event_type === 'public_holiday' ? 'Feiertag' : 'Schulferien'} · {h.start_date}{h.start_date !== h.end_date ? ` ${h.end_date}` : ''}</div>
</div>
))}
</section>
)}
{dayEvents.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-medium opacity-80">Schul-Termine</h3>
{dayEvents.map(e => (
<div
key={e.id}
className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-slate-50'}`}
style={{ borderLeft: `4px solid ${EVENT_TYPE_COLOR[e.event_type]}` }}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="font-medium">{e.title}</div>
<div className="text-xs opacity-70 mt-0.5">
{EVENT_TYPE_LABEL[e.event_type]}
{e.start_time && ` · ${e.start_time}${e.end_time ? `${e.end_time}` : ''}`}
{e.is_school_free && ' · unterrichtsfrei'}
</div>
{e.description && <div className="text-sm opacity-90 mt-1">{e.description}</div>}
<div className="text-xs opacity-60 mt-1.5">
{e.visible_to_parents && '👨‍👩‍👧 sichtbar fuer Eltern'}
{e.notify_parents && ' · 📧 Eltern erinnern'}
{e.notify_students && ' · 💬 Schueler erinnern'}
</div>
{(e.notify_parents || e.notify_students) && (
<NotificationStatus eventId={e.id} />
)}
</div>
<button
onClick={() => handleDelete(e.id)}
className="text-xs text-red-400 hover:text-red-300"
>
Loeschen
</button>
</div>
</div>
))}
</section>
)}
</div>
</div>
)
}