From cd96b365d065b86c462cdaa94e1be69030659a61 Mon Sep 17 00:00:00 2001
From: arnef
Date: Thu, 20 Aug 2026 19:27:55 +0200
Subject: [PATCH] Add virtual Birthdays calendar computed from contacts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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>
---
internal/web/calendar.go | 180 +++++++++++++++++-
internal/web/templates/calendar.templ | 20 +-
internal/web/templates/calendar_templ.go | 226 ++++++++++++-----------
3 files changed, 315 insertions(+), 111 deletions(-)
diff --git a/internal/web/calendar.go b/internal/web/calendar.go
index fb06b0c..615e63d 100644
--- a/internal/web/calendar.go
+++ b/internal/web/calendar.go
@@ -16,6 +16,7 @@ import (
"time"
ical "github.com/emersion/go-ical"
+ vcard "github.com/emersion/go-vcard"
"github.com/yourusername/caldav-server/internal/db"
"github.com/yourusername/caldav-server/internal/web/templates"
)
@@ -82,6 +83,11 @@ func (s *Server) ownsCalendar(username, cal string) (bool, error) {
// store.Store/db.DB. If requireWrite is true, a read-only share is
// rejected with errCalendarReadOnly.
func (s *Server) resolveCalRef(username, ref string, requireWrite bool) (owner, name string, err error) {
+ if ref == birthdaysCalRef {
+ // The birthdays calendar is virtual/computed, not backed by any
+ // stored calendar object โ there's nothing to resolve to.
+ return "", "", errCalendarNotFound
+ }
if owner, name, ok := strings.Cut(ref, calRefSep); ok {
if !resourceNameRe.MatchString(owner) || !resourceNameRe.MatchString(name) {
return "", "", errCalendarNotFound
@@ -117,13 +123,28 @@ type calendarEntry struct {
Name string // calendar's own name (unqualified)
Color string
Writable bool
+ Virtual bool // true for computed calendars (e.g. birthdays) with no backing store objects
}
-// listCalendarEntries returns every calendar visible to username: their
-// own calendars (always writable) plus any calendars shared with them
-// (writable only if the share grants write permission).
+// birthdaysCalRef is the fixed reference for the synthetic "Birthdays"
+// calendar. It deliberately can't collide with a real calendar's ref: "@"
+// is not in resourceNameRe's character class (so it can't be a bare own
+// calendar name) and it contains no calRefSep ("~") (so it can't be
+// mistaken for an "owner~name" shared-calendar ref either).
+const birthdaysCalRef = "@birthdays"
+
+// birthdayColor is the fixed display color for the virtual birthdays
+// calendar (a pink, distinct from typical user-picked calendar colors).
+const birthdayColor = "#ec4899"
+
+// listCalendarEntries returns every calendar visible to username: the
+// synthetic birthdays calendar, their own calendars (always writable),
+// and any calendars shared with them (writable only if the share grants
+// write permission).
func (s *Server) listCalendarEntries(username string) ([]calendarEntry, error) {
- var entries []calendarEntry
+ entries := []calendarEntry{
+ {Ref: birthdaysCalRef, Owner: username, Name: "Birthdays", Color: birthdayColor, Writable: false, Virtual: true},
+ }
own, err := s.dbase.ListCalendars(username)
if err != nil {
@@ -241,9 +262,16 @@ func (s *Server) buildMonthView(username string, year int, month time.Month) (te
}
calSummaries = append(calSummaries, templates.CalendarSummary{
Ref: entry.Ref, Name: entry.Name, Owner: entry.Owner, Color: entry.Color,
- Shared: entry.Owner != username, Writable: entry.Writable,
+ Shared: entry.Owner != username, Writable: entry.Writable, Virtual: entry.Virtual,
})
+ if entry.Ref == birthdaysCalRef {
+ if err := s.addBirthdayEvents(username, entry, gridStart, gridEnd, dayIndex, days); err != nil {
+ s.logger.Warn("computing birthday events", "error", err)
+ }
+ continue
+ }
+
ids, err := s.store.ListObjects(entry.Owner, "cal-"+entry.Name)
if err != nil {
continue
@@ -944,3 +972,145 @@ func (s *Server) handleCalendarImport(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/web/calendar?imported="+strconv.Itoa(imported), http.StatusSeeOther)
}
+
+// birthdayContact holds one contact's parsed birthday, ready to be placed
+// on the month grid.
+type birthdayContact struct {
+ Name string
+ Book string
+ ID string
+ Month time.Month
+ Day int
+ Year int // 0 if the vCard's BDAY has no year (e.g. "--08-20")
+}
+
+// birthdayFullRe matches a BDAY value that includes a year, in either
+// "YYYY-MM-DD" or the older vCard 3.0 "YYYYMMDD" form.
+var birthdayFullRe = regexp.MustCompile(`^(\d{4})-?(\d{2})-?(\d{2})`)
+
+// birthdayNoYearRe matches a year-less BDAY value per RFC 6350 ยง4.3.1,
+// "--MM-DD" or "--MMDD".
+var birthdayNoYearRe = regexp.MustCompile(`^--(\d{2})-?(\d{2})`)
+
+// parseBirthday extracts month/day (and year, if present) from a vCard
+// BDAY field value. Returns ok=false if v isn't a recognized date format
+// or names an impossible month/day.
+func parseBirthday(v string) (month time.Month, day int, year int, ok bool) {
+ v = strings.TrimSpace(v)
+ if m := birthdayNoYearRe.FindStringSubmatch(v); m != nil {
+ mo, _ := strconv.Atoi(m[1])
+ d, _ := strconv.Atoi(m[2])
+ if mo < 1 || mo > 12 || d < 1 || d > 31 {
+ return 0, 0, 0, false
+ }
+ return time.Month(mo), d, 0, true
+ }
+ if m := birthdayFullRe.FindStringSubmatch(v); m != nil {
+ y, _ := strconv.Atoi(m[1])
+ mo, _ := strconv.Atoi(m[2])
+ d, _ := strconv.Atoi(m[3])
+ if mo < 1 || mo > 12 || d < 1 || d > 31 {
+ return 0, 0, 0, false
+ }
+ return time.Month(mo), d, y, true
+ }
+ return 0, 0, 0, false
+}
+
+// collectBirthdays scans every one of username's own address books for
+// contacts with a parseable BDAY field.
+func (s *Server) collectBirthdays(username string) ([]birthdayContact, error) {
+ books, err := s.dbase.ListAddressBooks(username)
+ if err != nil {
+ return nil, err
+ }
+
+ var contacts []birthdayContact
+ for _, book := range books {
+ ids, err := s.store.ListObjects(username, "card-"+book)
+ if err != nil {
+ continue
+ }
+ for _, id := range ids {
+ data, err := s.store.GetObject(username, "card-"+book, id)
+ if err != nil {
+ continue
+ }
+ card, err := vcard.NewDecoder(bytes.NewReader(data)).Decode()
+ if err != nil {
+ continue
+ }
+ bday := card.PreferredValue(vcard.FieldBirthday)
+ if bday == "" {
+ continue
+ }
+ month, day, year, ok := parseBirthday(bday)
+ if !ok {
+ continue
+ }
+ name := card.PreferredValue(vcard.FieldFormattedName)
+ if name == "" {
+ name = id
+ }
+ contacts = append(contacts, birthdayContact{
+ Name: name, Book: book, ID: id, Month: month, Day: day, Year: year,
+ })
+ }
+ }
+ return contacts, nil
+}
+
+// addBirthdayEvents places one virtual all-day event per contact
+// birthday falling within [gridStart, gridEnd] into days, titled
+// "๐ Name" (or "๐ Name (Age)" when the birth year is known). Each event
+// links to the contact's edit page instead of an event edit page, since
+// there is no underlying calendar object to edit.
+func (s *Server) addBirthdayEvents(username string, entry calendarEntry, gridStart, gridEnd time.Time, dayIndex map[string]int, days []templates.MonthDay) error {
+ contacts, err := s.collectBirthdays(username)
+ if err != nil {
+ return err
+ }
+ if len(contacts) == 0 {
+ return nil
+ }
+
+ // The 42-day grid can span at most two calendar years (e.g. a
+ // December/January boundary), so only those years' occurrences need
+ // checking.
+ years := []int{gridStart.Year()}
+ if gridEnd.Year() != gridStart.Year() {
+ years = append(years, gridEnd.Year())
+ }
+
+ for _, c := range contacts {
+ for _, year := range years {
+ occurrence := time.Date(year, c.Month, c.Day, 0, 0, 0, 0, gridStart.Location())
+ // Guard against date normalization (e.g. Feb 29 in a
+ // non-leap year rolling over into March) placing the event
+ // on the wrong day.
+ if occurrence.Month() != c.Month || occurrence.Day() != c.Day {
+ continue
+ }
+ if occurrence.Before(gridStart) || occurrence.After(gridEnd) {
+ continue
+ }
+ idx, ok := dayIndex[occurrence.Format(dateLayout)]
+ if !ok {
+ continue
+ }
+ summary := "๐ " + c.Name
+ if c.Year > 0 {
+ summary += fmt.Sprintf(" (%d)", year-c.Year)
+ }
+ days[idx].Events = append(days[idx].Events, templates.EventSummary{
+ ID: c.Book + "/" + c.ID,
+ CalRef: entry.Ref,
+ Color: entry.Color,
+ Summary: summary,
+ AllDay: true,
+ LinkURL: "/web/contacts/" + c.Book + "/" + c.ID + "/edit",
+ })
+ }
+ }
+ return nil
+}
diff --git a/internal/web/templates/calendar.templ b/internal/web/templates/calendar.templ
index 81ef5e6..f2078a9 100644
--- a/internal/web/templates/calendar.templ
+++ b/internal/web/templates/calendar.templ
@@ -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) {
(read-only)
}
- Export .ics
+ if !c.Virtual {
+ Export .ics
+ }
if c.Writable {
")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" enctype=\"multipart/form-data\" class=\"flex items-center gap-1\"> ")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -383,7 +394,7 @@ func monthDayCell(day MonthDay) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "