Skip to content

Add optional start and end times to calendar entries - #268

Merged
developeregrem merged 3 commits into
developeregrem:masterfrom
MeisterAdebar:feature/calendar-entry-time
Aug 18, 2026
Merged

Add optional start and end times to calendar entries#268
developeregrem merged 3 commits into
developeregrem:masterfrom
MeisterAdebar:feature/calendar-entry-time

Conversation

@MeisterAdebar

@MeisterAdebar MeisterAdebar commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Calendar entries can now carry an optional start and end time. Left empty they
stay all-day entries, which is what every entry was before and still is by
default.

Storage

Both times live in their own nullable TIME columns rather than widening
date to DATETIME. Everything downstream keys days by Y-m-d - sync
reconciliation, the reservation table decorations, the reminder query - and
each of them would otherwise have to strip a time component it never asked
for. null already says "all day", and that is exactly what existing rows say
without touching them.

Migration Version20260816120000 adds both columns, down() drops them again.

On an entry spanning several days the start time sits on the first day and the
end time on the last, with the days between carrying neither - they genuinely
run all day. That keeps one row per day while still letting the closing day say
when the entry stops, instead of showing nothing and leaving the reader to
guess.

One rule for midnight

A time stored without a date is ambiguous in exactly one place, and both halves
of the application have to read it the same way or they contradict each other:

An end time of 00:00 means midnight closing the day, never opening it.
It is therefore later than any start time.

CalendarEntryTimeRules is the only place that knows this. The ICS import, the
entry form and the validation all ask it, so an imported 18:00 - 00:00 is by
construction an entry the edit form accepts.

Two consequences fall out of the same rule:

  • A lone end time of 00:00 is rejected: a day that runs to its end and has no
    beginning is an all-day entry, and - 00:00 would read as ending at the
    day's start.
  • Accordingly, a period ending at midnight leaves its closing day all-day,
    whether it came from a feed or from the form.

Calendar sync

CalendarEntrySyncService now reads RFC 5545 date-times properly. All three
forms occur in the wild and only one was handled before: a trailing Z is UTC,
a TZID parameter names the zone, a bare value is local time. A Google export
publishes UTC instants, so its 13:00 appointment was read and stored as 11:00.

Values are resolved to the instant they denote and expressed in the application
timezone - PHP's date.timezone, the same single source Doctrine hydrates
zone-less DATETIME columns in. The conversion happens before the days are
derived, because the zone decides the calendar day as much as the clock:
20260814T223000Z is the 15th in Europe/Berlin.

Imported times are normalised to whole minutes, matching what the form can
express.

Multi-day events used to lose their closing day. DTEND is exclusive only for
all-day events; when it carries a time it is the moment the event stops, so its
day is still covered - unless it stops exactly at midnight, which belongs to
the day before. Aug 14 13:00 - Aug 16 14:00 now yields all three days.

Events that are discarded

An event whose period cannot be read is dropped rather than filed under a
guessed day: no DTSTART, an unparseable DTEND, a DTEND not after
DTSTART, or a span beyond a year. Equal values are rejected too - feeds write
them both for a zero-length event and, wrongly, for a single all-day one, and
picking one reading would invent a period the source never stated.

Discarded events are counted and reported after the sync, the same way
recurring ones already are, so nothing disappears silently.

The date arithmetic lives in IcsEventSpanResolver, where it is testable
without a database and where the sync service is left with reconciliation
alone.

Manual entries

Validation and the day-by-day creation of a period moved out of the controller
into CalendarEntryService. It returns violations naming the form field they
belong on; the controller only maps them onto the form.

Display and ordering

The time label moved off the entity into CalendarEntryTimeFormatter, reachable
from Twig through a filter, so mail and PDF templates can use it later. Times go
through IntlDateFormatter in the viewer's locale rather than a hand-written
format string.

Ordering within a day falls back to the end time (COALESCE(e.time, e.endTime)),
so the closing day of a multi-day entry sorts at its own hour instead of among
the all-day entries. Entries with no time at all still sort first.

Deliberate limits

  • Times are minute-resolution. Seconds from a feed are dropped rather than
    stored and silently ignored.
  • An evening event ending shortly after midnight (23:11 - 00:11) does produce
    an entry on the following day showing - 00:11. That is correct - the event
    occupies those minutes - but it is worth knowing. Suppressing it would need a
    duration threshold, and any number there would be arbitrary; the only clean
    boundary is the 00:00 case above, which covers zero minutes.
  • No internal UTC storage. That would need startAt/endAt on the entity and
    is a separate change.

Tests

  • tests/Unit/CalendarEntryTimeRulesTest.php - the midnight rule and every
    valid and invalid combination of times
  • tests/Unit/CalendarEntryServiceTest.php - validation and how a period is
    split across days
  • tests/Unit/CalendarEntryTimeFormatterTest.php - label and locale
  • tests/Unit/IcsEventSpanResolverTest.php - UTC, TZID, local time, the zone
    deciding the day, exclusive DTEND, midnight, minute normalisation, and
    every discard case
  • tests/Functional/CalendarEntryFormTest.php - manual entry, periods,
    midnight, rejected input and ordering

The existing sync tests are extended for the new cases and pin the timezone
themselves, so they no longer depend on the php.ini of whoever runs them.

@developeregrem developeregrem added this to the 4.11.0 milestone Aug 1, 2026
@developeregrem

Copy link
Copy Markdown
Owner

Moin, habs mir jetzt angesehen.
Ich sehe noch ein paar Stolpersteine:

  • Zeit wird scheinbar immer als UTC behandelt. Bei der Anzeige muss es aber je nach gesetzter timezone ausgegeben werden. Beispiel: Ich habe in einen gesyncten Google Cal auf 17:30 (GMT+2) eingestellt, die Anwendung zeigt mir dann aber 15:30 an, obwohl date.timezone in der php.ini auf Europe/Berlin steht.
  • Mehrtägige Termine mit Uhrzeit verlieren ihren letzten Kalendertag. Beispiel 12.08. 18:00 - 14.08. 19:00 (in Google Cal) wird zu 12.08. 16:00 (Problem siehe oben) und 13.08. Der 14.08. geht komplett verloren.
  • wenn es wirklich nur eine Startzeit ist, solltest du das optionale Enddatum in der UI vielleicht einfach deaktivieren (sobald man eine Uhrzeit einträgt), sonst ist man verwundert, wenn bei den anderen Tagen nichts mehr steht. Oder du erlaubst auch ein optinalen Endzeitpunkt, dann sind auch mehrtägige Events leichter möglich und Problem 2 von oben wäre damit auch gegessen.

LG 😊

@MeisterAdebar

Copy link
Copy Markdown
Contributor Author

Alle drei Punkte Punkte angegangen, bitte kritisch prüfen.

feature/calendar-entry-time ist aktualisiert und gepusht.

Dazu kommt ein zweiter Branch, da steckt eine Entscheidung drin, die über den Kalender hinausgeht.


1. Zeitzone

Der Parser hat die Property-Parameter bisher komplett verworfen (IcsEventParser, beim Aufsplitten des Namens), damit war auch ein TZID weg. Übrig blieb der nackte Wert, der dann in PHPs Default-Zone interpretiert wurde.

Kurios daran: die TZID-Variante funktionierte dadurch zufällig richtig, solange date.timezone zur Zone des Feeds passte. Nur die UTC-Variante war falsch — und die liefert Google.

Der Parser behält die Parameter jetzt unter einem eigenen Schlüssel und beherrscht alle drei RFC-5545-Formen: Z als UTC, TZID als benannte Zone, nackte Werte als lokale Zeit. Der bestehende CalendarImportService liest weiterhin nur den Wert und ist davon unberührt.

Wichtig war, die Umrechnung vor der Datumsermittlung zu machen, nicht erst bei der Anzeige: die Zone entscheidet auch über den Kalendertag. 20260814T230000Z ist bei uns schon der 15., nicht der 14.

2. Verlorener letzter Tag

Der Code hat DTEND grundsätzlich als exklusiv behandelt. Das gilt nach RFC 5545 aber nur für ganztägige Termine. Trägt DTEND eine Uhrzeit, ist es der echte Endzeitpunkt — sein Tag gehört also dazu.

Bei mir wurde aus DTEND 20260814T170000Z durch das Abschneiden der Uhrzeit der 14.08. 00:00, und die Schleife lief mit < davor ab — daher genau der eine fehlende Tag.

Ausnahme ist ein Ende glatt um Mitternacht, das weiter zum Vortag zählt. Sonst bekäme ein Termin von 18:00 bis 00:00 fälschlich noch einen Eintrag am Folgetag.

3. Endzeitpunkt

Ich habe deinen zweiten Vorschlag genommen, nicht den ersten — das Enddatum zu sperren hätte Punkt 2 für Termine aus der Quelle ja nicht gelöst.

CalendarEntry hat jetzt ein optionales endTime. Bei mehrtägigen Terminen trägt der erste Tag die Startzeit, der letzte die Endzeit, dazwischen läuft es durch. Damit bleibt die Annahme „ein Eintrag = ein Tag" erhalten, auf der Erinnerungen, Reservierungstabelle und Popover aufsetzen — und der Schlusstag sagt trotzdem, wann Schluss ist, statt gar nichts anzuzeigen. Genau die Verwunderung, die du beschrieben hast.

Angezeigt wird 13:00, 13:00 - 14:00 oder - 14:00, je nachdem was gesetzt ist. Formuliert an einer Stelle, damit Popover und Erinnerungsliste nicht auseinanderlaufen. Das manuelle Formular macht denselben Split und validiert, dass die Endzeit innerhalb eines Tages nach der Startzeit liegt.

Dein Beispiel habe ich einmal komplett durch den reparierten Import geschickt. 12.08. 18:00 bis 14.08. 19:00 ergibt jetzt:

Datum Start Ende Anzeige
12.08. 18:00 18:00
13.08. ganztägig
14.08. 19:00 - 19:00

Beide Fehler auf einmal erledigt: die 18:00 stehen richtig da, und der 14. ist wieder dabei — mit der Endzeit statt einer leeren Zeile.


Die Zeitzone kommt nicht mehr aus der php.ini

Das ist der Teil, den ich dir erklären möchte, bevor du den Code liest.

Beim Nachstellen bin ich darauf gestoßen, dass date.timezone je nach SAPI unterschiedlich ist. Auf meiner Installation setzt Apache sie aus ${TZ}, die CLI setzt gar nichts und landet auf UTC. Konsequenz: derselbe Feed hätte je nach Auslöser unterschiedliche Zeiten gespeichert — über das Web-Formular andere als über den calendars:sync-Cron. Dasselbe galt für lastSyncedAt und confirmedAt, die waren aus demselben Grund um zwei Stunden daneben.

Eine Umgebungsvariable als Grundlage fand ich deshalb zu wackelig. Sie muss im Deployment gesetzt sein, und es gibt eine Falle: TZ in Symfonys .env wirkt nur halb, weil .env zu spät geladen wird, um die php.ini noch zu beeinflussen. Man hätte dann korrekte Kalenderzeiten und falsche Zeitstempel daneben.

Die Zone liegt jetzt als Feld auf AppSettings, mit einem eigenen Abschnitt in den allgemeinen Einstellungen. Web und Cron lesen dieselbe Zeile, damit ist die Konsistenz strukturell statt konfigurationsabhängig. Bestandsinstallationen bekommen per Migration Europe/Berlin und können es in der Oberfläche umstellen.

Das passt auch zu der Richtung, die in .env.dist bei den Mail-Einstellungen steht: „new installations configure these in the UI".

Nebenbei habe ich das Docker-Setup gegengeprüft, weil ich es nicht raten wollte. Dort war der SAPI-Bruch nie das Problem: docker/app/conf.ini liegt in der gemeinsamen conf.d und gilt für php-fpm wie CLI, und BusyBox crond reicht TZ nachweislich an die Jobs durch (in einem Container getestet). Ohne gesetztes TZ fällt allerdings alles auf UTC, mit einer entsprechenden PHP-Warnung im Log.


Zweiter Branch: feature/global-timezone

Beim Aufräumen ist mir aufgefallen, dass rund 25 Stellen im Projekt ein nacktes new \DateTime() benutzen — darunter CalendarImportService und CalendarSyncService für ihre eigenen Sync-Zeitstempel. Die haben alle dasselbe Problem, nur an anderer Stelle.

Der Branch enthält einen ApplicationTimezoneSubscriber, der die konfigurierte Zone auf kernel.request und console.command als PHP-Default setzt. Damit werden alle diese Stellen korrekt, ohne sie einzeln anzufassen — ebenso Twigs date-Filter und Doctrines Hydration der zonenlosen DATETIME-Spalten. Priorität 1024, also vor Router und Firewall.

Der Zugriff auf die Einstellungen ist abgesichert: die Tabelle existiert noch nicht, während doctrine:migrations:migrate und app:first-run sie gerade anlegen. Ein Fehlschlag wird gefangen und pro Prozess gemerkt, damit eine nicht erreichbare Datenbank einen Verbindungs-Timeout kostet und nicht einen pro Event.

Der Branch stapelt auf feature/calendar-entry-time, weil er die Spalte timezone braucht. Ich habe bewusst keinen PR aufgemacht — ob du beides zusammen oder getrennt haben willst, entscheidest besser du.


Was beim Release zu beachten ist

Drei Migrationen über beide Branches: time und end_time an calendar_entry, timezone an app_settings.

Der Punkt, der jemanden überraschen kann: Installationen, die bisher effektiv auf UTC liefen, haben UTC-Wanduhrzeiten in den zonenlosen Spalten stehen. Nach dem Merge werden die als Ortszeit gelesen, erscheinen also verschoben. Das ist keine neue Fehlfunktion, sondern die Altlast, die dabei sichtbar wird — wer schon auf TZ=Europe/Berlin fuhr, merkt nichts. Für den zweiten Branch wäre das ein Satz im Release-Hinweis wert.

Tests: 871 gesamt. Sieben schlagen fehl (PasskeyProfileTest, WebauthnControllerTest), die sind aber vorbestehend — ich habe gegen den unveränderten Stand geprüft, sie fallen dort identisch. Für die Kalenderseite sind rund ein Dutzend Tests dazugekommen: UTC-Umrechnung, Tageswechsel über Mitternacht, fremde Zeitzone, Mitternachtsende, Null-Länge-Termine, und einer, der absichert, dass die neue Inklusiv-Regel nicht auf ganztägige Termine durchschlägt.

Einen bestehenden Test musste ich umschreiben — der hatte das fehlerhafte Verhalten bei mehrtägigen Terminen festgeschrieben.


Eine Kleinigkeit am Rande

RUN_MIGRATIONS ist nirgends dokumentiert. Ohne die Variable spielt der php-fpm-Entrypoint keine Migrationen ein, und da app:first-run das Schema voraussetzt, funktioniert eine Docker-Installation ohne sie gar nicht. Wer die fertige Compose-Datei nutzt, hat es vermutlich gesetzt — für jemanden, der selbst aufsetzt, ist es eine unsichtbare Voraussetzung. Vielleicht ein Zweizeiler in .env.dist wert.

@developeregrem

developeregrem commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Danke für die Überarbeitung. Ich musste das ganze jetzt einmal in Ruhe durchdenken.
Die Behandlung von TZID, UTC-Werten und mehrtägigen Terminen geht grundsätzlich in die richtige Richtung. Vor dem Merge sehe ich aber noch folgende Punkte:

  • Die zusätzliche Zeitzoneneinstellung würde ich aus diesem PR herausnehmen. Die Anwendungszeitzone wird bereits über die PHP-Konfiguration festgelegt. Eine zweite Quelle, die nur Kalenderimport und einzelne Stellen beeinflusst, führt zu inkonsistentem Verhalten bei Anzeige, Reminder und Export.
  • UTC- und TZID-Werte sollten beim Import korrekt in die konfigurierte PHP-Zeitzone umgerechnet und anschließend als lokale DATE-/TIME-Werte gespeichert werden. Das aktuelle Datenmodell speichert keine echten UTC-Zeitpunkte. Eine konsequente interne UTC-Speicherung würde startAt/endAt benötigen und sollte ein separater Umbau sein. Ganztägige Termine müssen reine Datumswerte bleiben.
  • Die Mitternachtsbehandlung muss zwischen ICS-Import, Formular und Validierung identisch sein. Ein importierter Termin von 18:00 bis 00:00 darf nicht gespeichert werden, wenn er anschließend im Formular nicht mehr validiert werden kann.
  • Ungültige Zeiträume wie DTEND <= DTSTART sollten ignoriert beziehungsweise abgelehnt werden.
  • Ein Abschlusstag, der nur eine Endzeit besitzt, muss anhand dieser Uhrzeit und nicht zusammen mit ganztägigen Einträgen sortiert werden.
  • Die Logik für Validierung und das Erzeugen mehrtägiger Einträge sollte aus dem Controller in einen Service verschoben werden.
  • getTimeLabel() wird derzeit ausschließlich für die Darstellung in Popover und Reminder verwendet. Die einheitliche Aufbereitung ist sinnvoll, sollte aber in einem wiederverwendbaren Formatter beziehungsweise View-DTO liegen und nicht in der Entity. So kann sie später auch für Mail- und PDF-Templates genutzt und zentral über Twig/Intl beziehungsweise die jeweilige Locale formatiert werden.
  • Da die Änderungen noch nicht veröffentlicht wurden, sollten die drei aufeinander aufbauenden Migrationen zu einer Migration zusammengeführt werden.
  • Neben den vorhandenen Sync-Tests fehlen Unit-Tests sowie Functional-Tests für manuelle Eingaben, Mitternachtsfälle, Validierung und Sortierung.
  • Falls eine neue Konfiguration bestehen bleibt, muss sie dokumentiert werden und tatsächlich anwendungsweit gelten.

Eine optionale Endzeit finde ich grundsätzlich sinnvoll und passend zur Rückmeldung über mehrtägige Termine. Die genannten Randfälle sollten aber vor dem Merge geklärt werden.

Ich bin auch gerade dabei ein paar Informationen und Regeln für Agenten und Menschen zu erstellen. Bitte berücksichtige daher die Konventionen aus der kommenden AGENTS.md, insbesondere hinsichtlich Controller-/Service-Aufteilung, zentraler Formatierung, Migrationen, Dokumentation sowie Unit- und Functional-Tests.

Wenn du Fragen hast, melde dich einfach.

Entries were all-day only. A collection date or a maintenance appointment
usually has an hour attached, and showing nothing left the reader to guess.

Both times are optional and nullable, so every existing row stays exactly
what it was: an all-day entry. They are separate TIME columns rather than
a widened DATETIME date, because every consumer keys days by Y-m-d - sync
reconciliation, the table decorations, the reminder query - and each would
otherwise have to strip a time component it never asked for.

On a period the start time sits on the first day and the end time on the
last, with the days between running all day. That keeps one row per day
while still letting the closing day say when the event stops.

One rule needs stating because a time without a date is ambiguous: an end
of 00:00 is midnight *closing* the day, not opening it. CalendarEntryTimeRules
is the single place that knows this, so the ICS import, the form and the
validation cannot contradict each other - an imported "18:00 - 00:00" has
to be an entry the edit form accepts.

Validation and the day-by-day creation move out of the controller into
CalendarEntryService, which returns violations naming the form field they
belong on; the controller only maps them onto the form. The time label
moves off the entity into CalendarEntryTimeFormatter, reachable from Twig
via a filter, so mail and PDF templates can use it later and the times are
formatted through Intl in the viewer's locale rather than by hand.

Ordering within a day now falls back to the end time, so the closing day
of a multi-day entry sorts at its own hour instead of among the all-day
entries.
The parser threw away each property's parameters and read the value as
whatever it happened to say. RFC 5545 knows three forms and only one of
them was handled: a trailing Z is UTC, a TZID parameter names the zone, a
bare value is local time. A Google export publishes UTC instants, so its
13:00 appointment was read and stored as 11:00.

Values are now resolved to the instant they denote and expressed in the
application timezone, which is PHP's date.timezone - the same single
source Doctrine hydrates zone-less DATETIME columns in. Conversion happens
before the dates are derived, because the zone decides the calendar day as
much as the clock: 22:30Z belongs to the next day here.

Multi-day events lost their closing day. DTEND is exclusive only for
all-day events; when it carries a time it is the moment the event stops,
so its day is still covered - unless it stops exactly at midnight, which
belongs to the day before. "Aug 14 13:00 - Aug 16 14:00" now yields all
three days.

An event whose period cannot be read is discarded instead of filed under a
guessed day: no DTSTART, an unparseable DTEND, or a DTEND not after
DTSTART. Equal values are rejected too - feeds write them both for a
zero-length event and, wrongly, for a single all-day one, and picking one
reading would invent a period the source never stated. Discarded events
are counted and reported in the sync result, the same way recurring ones
already are, so nothing disappears silently.

The date arithmetic moves into IcsEventSpanResolver, where it can be
tested without a database and where the sync service is left with
reconciliation alone.
@MeisterAdebar
MeisterAdebar force-pushed the feature/calendar-entry-time branch from e62751f to 4b695e4 Compare August 16, 2026 13:58
@MeisterAdebar

Copy link
Copy Markdown
Contributor Author

Hi,

alle Punkte überarbeietet, der Branch ist neu auf
master aufgebaut, die Historie also einmal umgeschrieben.

Zeitzone raus (Punkt 1 und 10)

OK hast recht:
docker/app/Dockerfile:38 legt conf.ini nach /usr/local/etc/php/conf.d/,
was für alle SAPIs gilt, und der Cron-Container ist derselbe Image-Stage. Die
PHP-Konfiguration ist also bereits eine gemeinsame Quelle für Web und Cron.

Die Einstellung, die Migration, das Formularfeld, die Settings-Karte und
AppSettingsService::getTimezone() sind wieder raus. Der Import liest die Zone
jetzt aus date_default_timezone_get(). Auch die lastSyncedAt- und
confirmedAt-Stempel sind wieder auf ein schlichtes new \DateTime() zurück.

Der Zwei-Stunden-Versatz, den du gemeldet hattest, kam ohnehin nicht daher: der
alte Parser hat den TZID-Parameter verworfen und einen UTC-Wert ungerechnet
übernommen. Das behebt die Parser-Korrektur allein, unabhängig von jeder
Einstellung. Die Web/CLI-Aufteilung war ein Problem meiner lokalen Installation.

Damit fällt auch der Folge-Branch weg, der die Zone prozessweit gesetzt hätte –
ohne den Datenbankwert wäre er ein No-op.

Import rechnet um, speichert lokal (Punkt 2)

UTC- und TZID-Werte werden in die konfigurierte PHP-Zone umgerechnet und dann
als lokale DATE-/TIME-Werte gespeichert. Die Umrechnung passiert vor der
Tagesableitung, weil die Zone den Kalendertag mitbestimmt: 20260814T223000Z
ist hier der 15. Ganztägige Termine bleiben reine Datumswerte ohne jede Uhrzeit.

Mitternacht: eine zentrale Regel (Punkt 3)

Ich habe mich gegen das Verwerfen der Endzeit und gegen das Klemmen auf
23:59 entschieden. Beides verliert oder verfälscht, was im Feed steht. Statt
dessen gilt jetzt eine einzige Regel, die in CalendarEntryTimeRules liegt und
von Import, Formular und Validierung gemeinsam benutzt wird:

Eine Endzeit von 00:00 meint Mitternacht am Ende des Tages, nie an
seinem Anfang. Sie ist damit später als jede Startzeit.

Konkret:

  • Der Import speichert 00:00 unverändert; angezeigt wird 18:00 - 00:00.
  • Die Validierung lässt 00:00 zu jeder Startzeit zu.
  • Eine alleinstehende Endzeit 00:00 ohne Startzeit wird abgelehnt – ein Tag,
    der bis zu seinem Ende läuft und keinen Beginn hat, ist ein ganztägiger
    Eintrag. Ein einzelnes - 00:00 würde sonst wie „endet beim Tagesanfang“
    gelesen.
  • Entsprechend bekommt bei einem mehrtägigen Termin, der um Mitternacht endet,
    der letzte Tag gar keine Endzeit: er läuft ohnehin durch.

Einen Fall regele ich bewusst nicht, weil er beim Testen an echten Daten
auffiel und du sonst im Review darüber stolperst: ein Abendtermin, der kurz
nach Mitternacht endet – etwa 23:11 bis 00:11 – erzeugt jetzt korrekterweise
zwei Einträge, und der zweite Tag zeigt nur „– 00:11“ für elf Minuten. Das ist
nach RFC 5545 richtig und entspricht dem, was gängige Kalender anzeigen, wirkt
im Belegungsplan aber wie Rauschen.

Ich habe darauf verzichtet, das zu unterdrücken, weil es strukturell derselbe
Eintrag ist wie der Abschlusstag, den du repariert haben wolltest: letzter Tag,
nur Endzeit. Der einzige Unterschied ist die belegte Dauer – elf Minuten gegen
vierzehn Stunden. Jede Schwelle dazwischen („unter 30 Minuten nicht“) wäre
gegriffen und müsste dauerhaft begründet werden. Die einzige sachlich saubere
Grenze ist die oben genannte: exakt 00:00 belegt null Minuten und erzeugt
deshalb keinen Folgetag. Falls du das anders siehst, ändere ich es gern – dann
brauchen wir aber eine Zahl, hinter der wir stehen.

Ungültige Zeiträume werden verworfen (Punkt 4)

DTEND <= DTSTART wird abgelehnt, ebenso ein fehlendes DTSTART, ein
unlesbares DTEND und eine unplausible Spanne. Gleichstand habe ich bewusst mit
abgelehnt, wie von dir gewünscht – auch wenn das Feeds trifft, die einen
eintägigen Ganztagstermin fälschlich als DTSTART=DTEND schreiben statt exklusiv.
Ein Rateverfahren hätte einen Zeitraum erfunden, den die Quelle nie genannt hat.

Damit das nicht still passiert, werden verworfene Termine gezählt und dem Nutzer
gemeldet – genau wie die übersprungenen Serientermine (neues Feld
skippedInvalid im Sync-Ergebnis plus Flash-Meldung). Eine eigene Doku-Seite
gibt es dafür noch nicht; sobald es eine gibt, gehört der Punkt dort hinein.

Sortierung des Abschlusstags (Punkt 5)

Die Sortierung fällt jetzt auf die Endzeit zurück
(COALESCE(e.time, e.endTime) als HIDDEN-Alias, weil DQL im ORDER BY keinen
Ausdruck erlaubt). Ein Abschlusstag mit 14:00 sortiert damit an seiner Uhrzeit
und nicht mehr zwischen den ganztägigen Einträgen.

Controller entlastet (Punkt 6)

Validierung und das Anlegen mehrtägiger Einträge liegen in
CalendarEntryService. Der Service gibt CalendarEntryViolation-DTOs zurück,
die das Formularfeld benennen; der Controller mappt sie nur noch auf das
Formular. Die Span-Auflösung des Imports ist in IcsEventSpanResolver
gewandert, damit die Datumsarithmetik ohne Datenbank testbar ist und der
Sync-Service nur noch abgleicht.

Formatierung zentral (Punkt 7)

getTimeLabel() ist aus der Entity raus. Stattdessen gibt es
CalendarEntryTimeFormatter, der über \IntlDateFormatter in der Locale des
Betrachters formatiert und die Bausteine über Übersetzungsschlüssel
zusammensetzt. Erreichbar über einen Twig-Filter, sodass Mail- und
PDF-Templates ihn später mitbenutzen können; die Tabellenansicht bekommt ihn
weiterhin fertig im View-DTO.

Eine Migration (Punkt 8)

Die drei Migrationen sind zu Version20260816120000 zusammengefasst, die beide
Spalten nullable und ohne Default anlegt – bestehende Zeilen bleiben damit
unverändert ganztägig.

Tests (Punkt 9)

Neu:

  • tests/Unit/CalendarEntryTimeRulesTest.php – die Mitternachtsregel und alle
    gültigen und ungültigen Zeitkombinationen
  • tests/Unit/CalendarEntryServiceTest.php – Validierung und die Tagesaufteilung
    eines Zeitraums
  • tests/Unit/CalendarEntryTimeFormatterTest.php – Beschriftung und Locale
  • tests/Unit/IcsEventSpanResolverTest.php – UTC, TZID, lokale Zeit, Tageswechsel
    durch die Zone, exklusives DTEND, Mitternacht, alle Verwerfungsfälle
  • tests/Functional/CalendarEntryFormTest.php – manuelle Eingabe, Zeitraum,
    Mitternacht, abgelehnte Eingaben und die Sortierung

Die Sync-Tests sind um die neuen Fälle ergänzt und pinnen die Zeitzone jetzt
selbst, damit sie nicht von der php.ini dessen abhängen, der sie ausführt.

bin/run-tests.sh läuft durch: 917 Tests. Übrig bleiben 7 Fehler in
PasskeyProfileTest und WebauthnControllerTest, die auch auf blankem master
auftreten – die Tests erwarten localhost, während .env RELYING_PARTY_ID=example.com
setzt. Damit hat dieser PR nichts zu tun, aber vielleicht magst du da mal
draufschauen.

Ein Hinweis noch: PHPStan lässt sich auf master nicht vollständig laufen, er
bricht an src/Controller/StatisticsController.php:82 und
src/Service/MailService.php:51,75 mit Syntaxfehlern ab – die PHP-8.4-Schreibweise
new Foo()->bar() kennt diese Parser-Version nicht. Über die geänderten Dateien
läuft er sauber durch. Auf 4.11.0-dev ist das mit dem Dependency-Update
vermutlich schon erledigt.

@developeregrem

Copy link
Copy Markdown
Owner

Danke für die ausführliche Überarbeitung. Das sieht für mich deutlich runder aus 😊
Für den Umfang dieser optionalen Funktion erwarte ich keine vollständige RFC-5545-Implementierung.

Einen Fehler würde ich vor dem Merge noch korrigieren: Bei einem manuell angelegten mehrtägigen Eintrag mit Endzeit 00:00 wird auf dem letzten Tag ein alleinstehendes „– 00:00“ gespeichert. Diesen Eintrag lehnt das Bearbeitungsformular anschließend unverändert ab. Da das Bis-Datum einschließlich gilt und 00:00 als Tagesende verstanden wird, sollte der letzte Tag in diesem Fall ohne Endzeit ganztägig gespeichert werden. Bitte ergänze dafür auch einen Unit- oder Functional-Test.

Sekunden müssen aus meiner Sicht nicht unterstützt werden. Passend zum Formular wäre es aber sauber, importierte Uhrzeiten konsequent auf Minuten zu normalisieren. Dann wird beispielsweise 00:00:30 bewusst zu 00:00, statt möglicherweise einen zusätzlichen ganztägigen Kalendertag zu erzeugen.

Bitte aktualisiere außerdem noch die eigentliche PR-Beschreibung. Sie beschreibt weiterhin den alten Stand mit verworfenen Parametern, nur einer Startzeit und der vorherigen Migration. Danach wäre der PR aus meiner Sicht bereit für den merge. 👍

Alex

… seconds

A manually created period ending at 00:00 stored a lone "- 00:00" on its
closing day - an entry the edit form then refused to save unchanged. The
ICS import already avoided that, which is the actual defect: the rule sat
in IcsEventSpanResolver rather than in CalendarEntryTimeRules, so only one
of the two creation paths followed it.

It moves to endTimeForClosingDay(), which both paths now call. A midnight
end on a day that has no start time of its own says nothing an all-day
entry does not already say, so nothing is stored. A single day keeps its
"18:00 - 00:00", where the end time is the only thing stating when the
entry stops.

Imported times are normalised to whole minutes. The form works in minutes,
so a feed's seconds could not be represented anyway - and 00:00:30 in
particular was midnight to the rules but not to the day loop, which then
covered an extra calendar day for those thirty seconds. Normalising before
the checks also means an event shorter than a minute is treated as the
zero-length period it becomes, rather than slipping through.
@MeisterAdebar MeisterAdebar changed the title Add an optional start time to calendar entries Add optional start and end times to calendar entries Aug 16, 2026
@developeregrem
developeregrem merged commit de274fc into developeregrem:master Aug 18, 2026
1 check passed
@MeisterAdebar
MeisterAdebar deleted the feature/calendar-entry-time branch August 18, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants