Make birthdays calendar color configurable and expose it via CalDAV

- Add a per-user birthday_calendars table (color, cascade-deletes with
  the user) and GetBirthdayCalendarColor/SetBirthdayCalendarColor in
  internal/db, plus a reservedCalendarNames guard ("birthdays") in
  CreateCalendarWithColor so no real calendar can collide with the
  synthetic one, whether created via the web UI, nidusctl, or CalDAV
  MKCALENDAR.
- Add a "Birthdays" virtual resource card to the dashboard (color
  picker only, no delete/share controls) backed by a new
  ResourceCard.Virtual flag and POST /web/resources/birthdays/color
  handler.
- Extract the birthday-parsing/generation logic shared by the web
  calendar view and CalDAV into internal/birthdays (ParseBirthday,
  Collect, OccurrenceDate, Summary) instead of duplicating it.
- Expose the Birthdays calendar over real CalDAV in
  internal/caldav/backend.go + birthdays.go: it's always listed for
  every user, generates one VEVENT per (contact, year) for a rolling
  window (current year -2..+8) with "🎂 Name (Age)" titles, is
  read-only (Put/Delete/DeleteCalendar all return 403), and its
  Apple/DAVx5 calendar-color is injected from the same per-user
  setting used by the dashboard/web view.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-08-20 20:09:59 +02:00
co-authored by Copilot
parent cd96b365d0
commit b451f1a76e
12 changed files with 753 additions and 353 deletions
+45 -1
View File
@@ -24,6 +24,19 @@ var ErrResourceExists = errors.New("resource already exists")
// that doesn't exist.
var ErrResourceNotFound = errors.New("resource not found")
// ErrReservedName is returned when trying to create a calendar whose name
// is reserved for a computed/virtual calendar (see reservedCalendarNames).
var ErrReservedName = errors.New("calendar name is reserved")
// reservedCalendarNames are calendar names that can't be used for a real,
// user-created calendar because they're reserved for a synthetic,
// computed calendar shown alongside real ones (e.g. "birthdays", see
// internal/web/calendar.go and internal/caldav/backend.go). Matched
// case-insensitively.
var reservedCalendarNames = map[string]bool{
"birthdays": true,
}
// User is an account stored in the database.
type User struct {
Username string
@@ -175,8 +188,11 @@ func (d *DB) CreateCalendar(owner, name string) error {
// CreateCalendarWithColor registers a new calendar owned by owner with the
// given color (an empty string means no color is set, in which case the
// client picks its own default). Returns ErrResourceExists if it already
// exists.
// exists, or ErrReservedName if name is reserved for a computed calendar.
func (d *DB) CreateCalendarWithColor(owner, name, color string) error {
if reservedCalendarNames[strings.ToLower(name)] {
return ErrReservedName
}
_, err := d.conn.Exec(`INSERT INTO calendars (owner, name, color) VALUES (?, ?, ?)`, owner, name, color)
if err != nil {
if isUniqueConstraintErr(err) {
@@ -254,6 +270,34 @@ func (d *DB) ListCalendars(owner string) ([]Calendar, error) {
return cals, rows.Err()
}
// GetBirthdayCalendarColor returns owner's display color for the
// synthetic "Birthdays" calendar, or "" if they haven't set one (callers
// should fall back to a built-in default in that case).
func (d *DB) GetBirthdayCalendarColor(owner string) (string, error) {
var color string
err := d.conn.QueryRow(`SELECT color FROM birthday_calendars WHERE owner = ?`, owner).Scan(&color)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", fmt.Errorf("getting birthday calendar color: %w", err)
}
return color, nil
}
// SetBirthdayCalendarColor sets (or updates) owner's display color for
// the synthetic "Birthdays" calendar.
func (d *DB) SetBirthdayCalendarColor(owner, color string) error {
_, err := d.conn.Exec(`
INSERT INTO birthday_calendars (owner, color) VALUES (?, ?)
ON CONFLICT (owner) DO UPDATE SET color = excluded.color`,
owner, color)
if err != nil {
return fmt.Errorf("setting birthday calendar color: %w", err)
}
return nil
}
// -------- address books --------
// CreateAddressBook registers a new address book owned by owner. Returns