Skip to content

Add an optional start time to calendar entries - #268

Open
MeisterAdebar wants to merge 6 commits into
developeregrem:masterfrom
MeisterAdebar:feature/calendar-entry-time
Open

Add an optional start time to calendar entries#268
MeisterAdebar wants to merge 6 commits into
developeregrem:masterfrom
MeisterAdebar:feature/calendar-entry-time

Conversation

@MeisterAdebar

Copy link
Copy Markdown
Contributor

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

Storage

The time lives in its own nullable TIME column 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 Version20260729140000 adds the column, down() drops it again.

Calendar sync

CalendarEntrySyncService reads the start time from DTSTART:

  • All-day events are written as a bare date (DTSTART;VALUE=DATE:20260801).
    The parser drops the parameters, so the plain Ymd shape of the value is
    what identifies them - the same signal resolveDates() already relies on.
  • On a multi-day event only the day it starts on carries the time; the days it
    runs through are whole days by definition. Tracked per date rather than per
    event, so two VEVENTs merging under one UID keep their own.

Times are compared by wall clock, not by object identity - otherwise every
re-sync would report every timed entry as "updated".

Known limitation: time zones

The wall-clock digits are taken as they stand. A feed sending
DTSTART;TZID=Europe/Berlin:20260801T140000 loses the zone during parsing but
keeps the 14:00 it meant, which is the time that should be displayed. An
explicit UTC value (trailing Z) is read as UTC, and the application runs in
UTC, so that is what gets shown.

For a feed in a different zone than the one the instance runs in, the
displayed time will therefore be off. Carrying the zone through would mean
teaching the ICS parser to keep DTSTART parameters, which is a larger change
than this one - worth doing if it turns out to matter in practice, but the
common case here is a feed from the same zone.

Display and ordering

The popover and the reminder list show the time next to the title when there
is one. Within a calendar and day, all-day entries sort first (null time),
followed by the timed ones in clock order.

In the form the time input sits with the start date, not with the date range,
mirroring that only the first day of a range carries it.

Multi-day entries

The unmapped dateTo field creates one entry per day of the range instead of
teaching the entity and every consumer about ranges - the same approach the
sync already takes for multi-day ICS events.

Wording of the existing end-date hint

The end-date field predates this branch, but its hint led with
"End date"/"Enddatum", repeating the label right above it and pushing the part
that matters - that the field may be left alone - into the middle of the
sentence. It now leads with "Optional", matching the time field this branch
adds:

time_help:     "Optional - leave empty for an all-day entry."
date_to_help:  "Optional - end date for a multi-day entry (e.g. a vacation period). ..."

Tests

Six functional tests cover the sync path: a timed event stores its time, an
all-day event stores none, a multi-day event puts the time on the first day
only, re-syncing reports unchanged, a changed time updates the entry, and an
event losing its time becomes all-day again.

Entries were always all-day: the date column held a bare date and the ICS
import zeroed the time off DTSTART. Appointments that happen at a set hour
had nowhere to put it.

Adds a nullable time column rather than widening date to DATETIME. Every
consumer keys days by Y-m-d - sync reconciliation, the reservation table
decorations, the reminder query - and each would otherwise have to strip a
time component it never asked for. Null means all-day, which is what every
existing entry already says.

The ICS import now keeps the time where the feed states one, recognising
all-day events by DTSTART's bare-date form. On a multi-day event only the
starting day carries it; the days it runs through are whole days. Times are
tracked per date so two VEVENTs merging under one UID keep their own, and
reconciliation compares them by wall clock so a re-sync does not report every
timed entry as updated. Manual entries created over a range follow the same
rule.

Shown after the title in the day popover and the reminder list, and sorted
after the all-day entries within a day.
The hint started with "End date"/"Enddatum", repeating the label right
above it and pushing the part that actually matters - that the field may
be left alone - to the middle of the sentence. The neighbouring time
field already reads "Optional - ...", so both optional fields now
announce themselves the same way.
@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 😊

Addresses three points from review.

Times were stored as whatever wall clock the feed happened to carry. A
Google export publishes UTC instants, so a 13:00 appointment was filed
and shown as 11:00. The ICS parser now keeps each property's parameters
alongside its value and resolves DTSTART/DTEND properly: a trailing Z as
UTC, a TZID as the zone it names, a bare value as local time. 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 per RFC 5545; when it carries a time it is the moment the
event stops, so its day is still covered - unless it stops exactly at
midnight. "Aug 14 13:00 - Aug 16 14:00" now yields all three days.

Entries gain an optional end time. On a range the first day carries the
start and the last one the end, with the days between running all day,
which keeps one row per day while letting the closing day say when the
event stops instead of showing nothing. Both the manual form and the ICS
sync make that same split.

The zone comes from a new appTimezone parameter rather than php.ini's
date.timezone, which differs between SAPIs - the Apache config sets it
from ${TZ} while the CLI stays on UTC, so the same feed stored different
times depending on whether the web form or the calendars:sync cron ran
it. The lastSyncedAt and confirmedAt stamps went through the same split
and are now taken in that zone too.
The zone was read from an appTimezone parameter fed by ${TZ}. That works
only when the deployment sets it, and it splits into two half-configured
states: TZ in Symfony's .env reaches the parameter but never php.ini,
which is parsed long before .env is loaded.

It now lives on the AppSettings singleton, where web requests and the
cron commands read the same row and cannot disagree by construction.
Configuring it needs no redeploy, and a fresh install gets Europe/Berlin
without anyone setting anything. A container test confirmed the previous
approach did hold up under Docker - crond does pass TZ to its jobs, and
conf.ini covers both SAPIs - but only while TZ is set, which nothing
enforces.

The migration defaults existing rows to Europe/Berlin rather than reading
php.ini: that value differs per SAPI, so there is no single existing one
to carry over. Deployments on another zone set it once in the settings UI.

CalendarEntrySyncService resolves the zone once per import, before any
entity changes are queued - getSettings() creates and flushes the row on
first access, which must not land mid-import.
The three help texts all opened with "Optional -", so the eye had nothing
to tell them apart, and the end time's text explained a multi-day rule
while the edit modal it appears in has no end date field at all - that
only exists when creating an entry. Each text now says one thing, and
nothing claims a behaviour the surrounding form cannot reach.

The fields shared one row of equal columns, which left the date and time
inputs narrower than their own content while the calendar and title had
width to spare. Creating an entry has six of them, too many for a
modal-lg without squeezing, so it now puts what the entry is on the first
row and when it happens on the second; editing has neither the calendar
picker nor the end date and still fits one row.

Splitting the two also lets the creating form order its dates as they
read - date, time, until, end time - instead of placing the end time
before the field naming the day it belongs to.
Both date inputs render as native date pickers, so the browser shows its
own tt.mm.jjjj placeholder and drops the one the form sets. With no
labels either, nothing on screen said which of the two dates was the end
one - the help text described what the field does ("Mehrtägig") without
naming what it is. It now leads with "Bis", and fits on one line while
doing so.
@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.

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