Files
Aether/backend/internal/calendar/events_api.go
T
marcandClaude Opus 5 105ce8de4a fix(calendar): Termine anklickbar, Ganztags-Datum korrekt, Wochenraster mit Stunden
Drei gemeldete Fehler, alle nachgestellt:

1. Ein vorhandener Termin ließ sich nicht öffnen. Der Klick auf den
   Termin reichte an die Tageszelle darunter durch, die sofort 'Neuer
   Termin' öffnete und den Bearbeiten-Dialog überschrieb. Fehlendes
   stopPropagation.

2. Klick auf den 3. legte den Termin am 2. an. Ein ganztägiger Termin
   ist ein *Datum*, kein Zeitpunkt - die Oberfläche rechnete ihn über
   new Date(...).toISOString() trotzdem in UTC um, was östlich von UTC
   den Vortag ergibt. Nachgestellt und bestätigt: DTSTART landete als
   20260902 statt 20260903. Ganztägige Termine laufen jetzt über
   dates.ts (allDayISO/eventDay), damit Dialog und Raster dieselbe
   Rechnung benutzen. Nebenbefund mitgefixt: DTEND war gleich DTSTART,
   was RFC 5545 verbietet (DTEND ist exklusiv) - ein eintägiger Termin
   am 3. schreibt jetzt DTEND 20260904, nach außen bleibt die
   Schnittstelle beim letzten belegten Tag.

3. 'Fühlt sich starr an': Wochenansicht ist jetzt ein echtes
   Stundenraster (Klick trifft die Stunde statt Mitternacht, ganztägige
   in eigener Zeile, überlappende Termine nebeneinander, Jetzt-Linie),
   mehrtägige Termine stehen in jeder Zelle statt nur am Starttag, neue
   Termine schlagen die nächste volle Stunde bzw. 9 Uhr vor,
   Kalenderauswahl im Termindialog, Escape schließt, Hover-Rückmeldung,
   Kalenderliste auf schmalen Schirmen einklappbar.

Außerdem: ein neu angelegter Kalender blieb unsichtbar, weil er in
keiner Sichtbar-Liste stand - die Liste merkt sich jetzt die
ausgeblendeten statt der sichtbaren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 23:47:08 +02:00

314 lines
10 KiB
Go
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.
package calendar
import (
"encoding/json"
"net/http"
"strings"
"time"
ical "github.com/emersion/go-ical"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"aether/internal/auth"
"aether/pkg/response"
)
// EventDTO ist die vereinfachte Sicht eines Termins für die Web-Oberfläche.
// Anders als CalDAV (byte-genaues .ics) bildet sie nur die gängigen Felder
// ab – reicht für Anlegen/Ändern eines einfachen Termins im Browser. Ein
// über einen echten CalDAV-Client angelegter Termin mit Wiederholung bleibt
// über /caldav/ voll erhalten, zeigt sich hier nur mit Recurring=true und
// lässt sich in der Web-Oberfläche bewusst nicht bearbeiten (siehe Update) –
// eine RRULE über dieses vereinfachte Formular zu verändern, würde sie eher
// beschädigen als korrekt anpassen.
type EventDTO struct {
UID string `json:"uid"`
Summary string `json:"summary"`
Description string `json:"description"`
Location string `json:"location"`
// Start/End bei AllDay: Mitternacht UTC des ersten bzw. des **letzten
// belegten** Tages – in beide Richtungen gleich. Die exklusive
// DTEND-Rechnung des .ics-Formats (letzter Tag + 1) bleibt vollständig
// hier im Paket; wer diese Schnittstelle nutzt, rechnet nie mit ±1 Tag.
Start time.Time `json:"start"`
End *time.Time `json:"end,omitempty"`
AllDay bool `json:"all_day"`
Recurring bool `json:"recurring"`
ETag string `json:"etag"`
}
func eventFromObject(o *Object) (*EventDTO, error) {
dec := ical.NewDecoder(strings.NewReader(o.Data))
cal, err := dec.Decode()
if err != nil {
return nil, err
}
var ev *ical.Component
for _, child := range cal.Children {
if child.Name == "VEVENT" || child.Name == "VTODO" {
ev = child
break
}
}
if ev == nil {
return nil, errNoEventComponent
}
summary, _ := ev.Props.Text(ical.PropSummary)
description, _ := ev.Props.Text(ical.PropDescription)
location, _ := ev.Props.Text(ical.PropLocation)
_, recurring := ev.Props["RRULE"]
dto := &EventDTO{
UID: o.UID, Summary: summary, Description: description, Location: location,
Recurring: recurring, ETag: o.ETag,
}
startProp := ev.Props.Get(ical.PropDateTimeStart)
if startProp != nil {
dto.AllDay = isDateOnly(startProp)
if t, err := startProp.DateTime(time.UTC); err == nil {
dto.Start = t
}
}
if endProp := ev.Props.Get(ical.PropDateTimeEnd); endProp != nil {
if t, err := endProp.DateTime(time.UTC); err == nil {
// Im .ics ist DTEND eines Ganztagstermins **exklusiv**: ein
// eintägiger Termin am 3. endet dort auf dem 4. Nach außen gibt
// diese Schnittstelle den letzten *belegten* Tag zurück – sonst
// müsste jede Oberfläche dieselbe Rechnung noch einmal machen
// und eine davon würde sie vergessen.
if dto.AllDay {
t = t.AddDate(0, 0, -1)
if t.Before(dto.Start) {
t = dto.Start
}
}
dto.End = &t
}
}
if dto.AllDay && dto.End == nil {
// Ohne DTEND belegt ein Ganztagstermin genau seinen Starttag.
end := dto.Start
dto.End = &end
}
return dto, nil
}
// isDateOnly erkennt einen Ganztagstermin. Nicht jeder Client schreibt
// VALUE=DATE dazu, die achtstellige Form ("20260903") ist aber eindeutig –
// eine Zeitangabe hat immer mindestens 15 Zeichen ("20260903T090000").
func isDateOnly(prop *ical.Prop) bool {
return prop.ValueType() == ical.ValueDate || len(prop.Value) == 8
}
var errNoEventComponent = &icalError{"calendar: object has no VEVENT/VTODO component"}
type icalError struct{ msg string }
func (e *icalError) Error() string { return e.msg }
// GET /api/v1/calendars/{id}/events
func (h *APIHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
calID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
objs, err := h.store.ListObjects(r.Context(), calID)
if err != nil {
response.Error(w, http.StatusInternalServerError, "list events failed")
return
}
out := make([]EventDTO, 0, len(objs))
for i := range objs {
dto, err := eventFromObject(&objs[i])
if err != nil {
continue // eine kaputte Einzeldatei darf die Liste nicht abbrechen
}
out = append(out, *dto)
}
response.JSON(w, http.StatusOK, out)
}
type eventBody struct {
Summary string `json:"summary"`
Description string `json:"description"`
Location string `json:"location"`
Start time.Time `json:"start"`
End *time.Time `json:"end"`
AllDay bool `json:"all_day"`
}
func buildEventICal(uid string, body eventBody) *ical.Calendar {
root := ical.NewCalendar()
root.Props.SetText(ical.PropVersion, "2.0")
root.Props.SetText(ical.PropProductID, "-//Aether//Kalender//DE")
ev := ical.NewEvent()
ev.Props.SetText(ical.PropUID, uid)
ev.Props.SetDateTime(ical.PropDateTimeStamp, time.Now().UTC())
if body.Summary != "" {
ev.Props.SetText(ical.PropSummary, body.Summary)
}
if body.Description != "" {
ev.Props.SetText(ical.PropDescription, body.Description)
}
if body.Location != "" {
ev.Props.SetText(ical.PropLocation, body.Location)
}
if body.AllDay {
// Ganztägig heißt: ein Datum ohne Uhrzeit und ohne Zeitzone. Deshalb
// hier ausdrücklich .UTC() – der Aufrufer schickt für einen
// Ganztagstermin Mitternacht UTC, und ohne die Umrechnung würde eine
// Zeitzone östlich von UTC den Tag um eins nach hinten schieben (der
// 3. wurde zum 2.).
start := body.Start.UTC()
ev.Props.SetDate(ical.PropDateTimeStart, start)
// DTEND ist im .ics **exklusiv**: der Tag *nach* dem letzten
// belegten. Die Schnittstelle nimmt den letzten belegten Tag entgegen
// (siehe eventFromObject), also hier +1. Ohne das verletzt ein
// eintägiger Termin RFC 5545 (DTEND muss größer als DTSTART sein) und
// Clients zeigen ihn unterschiedlich an.
last := start
if body.End != nil {
last = body.End.UTC()
if last.Before(start) {
last = start
}
}
ev.Props.SetDate(ical.PropDateTimeEnd, last.AddDate(0, 0, 1))
} else {
ev.Props.SetDateTime(ical.PropDateTimeStart, body.Start.UTC())
if body.End != nil {
ev.Props.SetDateTime(ical.PropDateTimeEnd, body.End.UTC())
}
}
root.Children = append(root.Children, ev.Component)
return root
}
// POST /api/v1/calendars/{id}/events
func (h *APIHandler) CreateEvent(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
calID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
var body eventBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
response.Error(w, http.StatusBadRequest, "summary and start are required")
return
}
uid := uuid.New().String()
cal := buildEventICal(uid, body)
data, err := encodeICal(cal)
if err != nil {
response.Error(w, http.StatusInternalServerError, "encode event failed")
return
}
start, end := timeRange(cal)
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
if err != nil {
response.Error(w, http.StatusInternalServerError, "create event failed")
return
}
dto, err := eventFromObject(obj)
if err != nil {
response.Error(w, http.StatusInternalServerError, "read back event failed")
return
}
response.JSON(w, http.StatusCreated, dto)
}
// PATCH /api/v1/calendars/{id}/events/{uid}
func (h *APIHandler) UpdateEvent(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
calID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
uid := chi.URLParam(r, "uid")
existing, err := h.store.GetObjectByUID(r.Context(), calID, uid)
if err == ErrNotFound {
response.Error(w, http.StatusNotFound, "event not found")
return
}
if err != nil {
response.Error(w, http.StatusInternalServerError, "load event failed")
return
}
existingDTO, err := eventFromObject(existing)
if err == nil && existingDTO.Recurring {
// Diese Ansicht kennt keine RRULE-Bearbeitung – ein Formular mit nur
// "Titel/Start/Ende" würde die Wiederholungsregel beim Speichern
// stillschweigend verwerfen. Lieber ablehnen als Daten beschädigen.
response.Error(w, http.StatusConflict, "recurring events can only be edited in a CalDAV client")
return
}
var body eventBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Summary == "" || body.Start.IsZero() {
response.Error(w, http.StatusBadRequest, "summary and start are required")
return
}
cal := buildEventICal(uid, body)
data, err := encodeICal(cal)
if err != nil {
response.Error(w, http.StatusInternalServerError, "encode event failed")
return
}
start, end := timeRange(cal)
obj, err := h.store.PutObject(r.Context(), calID, uid, "VEVENT", data, etagFor(data), start, end)
if err != nil {
response.Error(w, http.StatusInternalServerError, "update event failed")
return
}
dto, err := eventFromObject(obj)
if err != nil {
response.Error(w, http.StatusInternalServerError, "read back event failed")
return
}
response.JSON(w, http.StatusOK, dto)
}
// DELETE /api/v1/calendars/{id}/events/{uid}
func (h *APIHandler) DeleteEvent(w http.ResponseWriter, r *http.Request) {
user := auth.UserFromContext(r.Context())
calID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
if _, err := h.store.GetCalendar(r.Context(), user.ID, calID); err != nil {
response.Error(w, http.StatusNotFound, "calendar not found")
return
}
uid := chi.URLParam(r, "uid")
if err := h.store.DeleteObject(r.Context(), calID, uid); err == ErrNotFound {
response.Error(w, http.StatusNotFound, "event not found")
return
} else if err != nil {
response.Error(w, http.StatusInternalServerError, "delete event failed")
return
}
response.NoContent(w)
}