Add virtual Birthdays calendar computed from contacts

The combined month view now always includes a read-only "Birthdays"
calendar (ref "@birthdays"), generated on the fly from every contact's
BDAY field across the user's address books — no calendar objects are
stored for it. Each birthday appears as an all-day event titled
"🎂 Name (Age)" (age omitted if BDAY has no year, e.g. "--MM-DD"), colored
pink, and links to the contact's edit page instead of an event editor.
Handles both "YYYY-MM-DD"/"YYYYMMDD" and year-less vCard BDAY formats,
and skips Feb 29 birthdays in non-leap years.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 19:27:55 +02:00
co-authored by Copilot
parent adabfd22ca
commit cd96b365d0
3 changed files with 315 additions and 111 deletions
+18 -2
View File
@@ -11,6 +11,7 @@ type CalendarSummary struct {
Color string // hex color like "#3b82f6", "" if unset
Shared bool // true if owned by someone other than the viewer
Writable bool
Virtual bool // true for computed calendars (e.g. birthdays) with no import/export
}
// EventSummary is a single event shown inside a month-view day cell.
@@ -21,6 +22,10 @@ type EventSummary struct {
Summary string
TimeText string // e.g. "14:00" or "" for all-day events
AllDay bool
// LinkURL overrides the default "/web/calendar/{CalRef}/{ID}/edit"
// link target, used by virtual/read-only calendars (e.g. birthdays)
// that don't have an editable event object of their own.
LinkURL string
}
// MonthDay is one day cell in the month grid.
@@ -123,7 +128,9 @@ templ calendarLegendRow(c CalendarSummary) {
<span class="text-xs text-gray-400">(read-only)</span>
}
<span class="flex-1"></span>
<a href={ templ.URL("/web/calendar/" + c.Ref + "/export") } class="text-indigo-600 hover:underline text-xs">Export .ics</a>
if !c.Virtual {
<a href={ templ.URL("/web/calendar/" + c.Ref + "/export") } class="text-indigo-600 hover:underline text-xs">Export .ics</a>
}
if c.Writable {
<form method="POST" action={ templ.URL("/web/calendar/" + c.Ref + "/import") } enctype="multipart/form-data" class="flex items-center gap-1">
<input type="file" name="file" accept=".ics,text/calendar" required class="text-xs"/>
@@ -146,7 +153,7 @@ templ monthDayCell(day MonthDay) {
</div>
for _, ev := range day.Events {
<a
href={ templ.URL("/web/calendar/" + ev.CalRef + "/" + ev.ID + "/edit") }
href={ templ.URL(eventLinkURL(ev)) }
class="block truncate rounded px-1.5 py-0.5 text-xs hover:opacity-80"
style={ "background-color: " + colorOrDefault(ev.Color) + "22; color: " + colorOrDefault(ev.Color) }
title={ ev.Summary }
@@ -161,6 +168,15 @@ templ monthDayCell(day MonthDay) {
</div>
}
func eventLinkURL(ev EventSummary) string {
if ev.LinkURL != "" {
return ev.LinkURL
}
return "/web/calendar/" + ev.CalRef + "/" + ev.ID + "/edit"
}
templ EventForm(username string, data EventFormData, errMsg string) {
@Layout("Calendar", username) {
<a href="/web/calendar" class="text-sm text-indigo-600 hover:underline">&larr; Calendar</a>