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
+77 -62
View File
@@ -11,10 +11,11 @@ type ShareRow struct {
// ResourceCard describes one of the user's own calendars/address books
// plus who it's currently shared with.
type ResourceCard struct {
Kind string // "calendar" or "addressbook"
Name string
Color string // hex color like "#3b82f6"; only used for calendars
Shares []ShareRow
Kind string // "calendar" or "addressbook"
Name string
Color string // hex color like "#3b82f6"; only used for calendars
Shares []ShareRow
Virtual bool // true for computed calendars (e.g. birthdays): no delete/sharing, just a color picker
}
// SharedWithMeItem describes a resource another user has shared with the
@@ -115,7 +116,7 @@ templ ResourceCardView(r ResourceCard) {
<h2 class="font-medium flex items-center gap-2">
if r.Kind == "calendar" {
<form
hx-post="/web/resources/calendar/color"
hx-post={ colorEndpoint(r) }
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
hx-swap="outerHTML"
hx-trigger="change"
@@ -132,70 +133,84 @@ templ ResourceCardView(r ResourceCard) {
}
{ r.Name }
<span class="text-xs uppercase tracking-wide text-gray-400 ml-2">{ r.Kind }</span>
if r.Virtual {
<span class="text-xs text-gray-400">(computed from contacts)</span>
}
</h2>
<button
class="text-red-600 hover:underline text-xs"
hx-delete={ resourceEndpoint(r.Kind) }
hx-vals={ resourceVals(r.Name) }
hx-target="#resources"
hx-swap="outerHTML"
hx-confirm={ "Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone." }
>
Delete
</button>
if !r.Virtual {
<button
class="text-red-600 hover:underline text-xs"
hx-delete={ resourceEndpoint(r.Kind) }
hx-vals={ resourceVals(r.Name) }
hx-target="#resources"
hx-swap="outerHTML"
hx-confirm={ "Delete " + r.Kind + " " + r.Name + "? This removes all its data and cannot be undone." }
>
Delete
</button>
}
</div>
<ul class="divide-y divide-gray-100 mb-4">
for _, sh := range r.Shares {
<li class="py-2 flex items-center justify-between text-sm">
<span>{ sh.SharedWith }</span>
<span class="flex items-center gap-3">
<span class="text-xs uppercase tracking-wide text-gray-500">{ sh.Permission }</span>
<button
class="text-red-600 hover:underline text-xs"
hx-delete={ shareEndpoint(r.Kind) }
hx-vals={ shareVals(r.Name, sh.SharedWith) }
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
hx-swap="outerHTML"
hx-confirm={ "Remove access for " + sh.SharedWith + "?" }
>
Remove
</button>
</span>
</li>
}
if len(r.Shares) == 0 {
<li class="py-2 text-sm text-gray-400">Not shared with anyone yet.</li>
}
</ul>
if !r.Virtual {
<ul class="divide-y divide-gray-100 mb-4">
for _, sh := range r.Shares {
<li class="py-2 flex items-center justify-between text-sm">
<span>{ sh.SharedWith }</span>
<span class="flex items-center gap-3">
<span class="text-xs uppercase tracking-wide text-gray-500">{ sh.Permission }</span>
<button
class="text-red-600 hover:underline text-xs"
hx-delete={ shareEndpoint(r.Kind) }
hx-vals={ shareVals(r.Name, sh.SharedWith) }
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
hx-swap="outerHTML"
hx-confirm={ "Remove access for " + sh.SharedWith + "?" }
>
Remove
</button>
</span>
</li>
}
if len(r.Shares) == 0 {
<li class="py-2 text-sm text-gray-400">Not shared with anyone yet.</li>
}
</ul>
<form
class="flex flex-col sm:flex-row sm:items-end gap-2"
hx-post={ shareEndpoint(r.Kind) }
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
hx-swap="outerHTML"
>
<input type="hidden" name="resource" value={ r.Name }/>
<div class="flex-1">
<label class="block text-xs text-gray-500 mb-1">Username</label>
<input name="shared_with" type="text" required
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Permission</label>
<select name="permission" class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm">
<option value="read">read</option>
<option value="write">write</option>
</select>
</div>
<button type="submit"
class="w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
Share
</button>
</form>
<form
class="flex flex-col sm:flex-row sm:items-end gap-2"
hx-post={ shareEndpoint(r.Kind) }
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
hx-swap="outerHTML"
>
<input type="hidden" name="resource" value={ r.Name }/>
<div class="flex-1">
<label class="block text-xs text-gray-500 mb-1">Username</label>
<input name="shared_with" type="text" required
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Permission</label>
<select name="permission" class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm">
<option value="read">read</option>
<option value="write">write</option>
</select>
</div>
<button type="submit"
class="w-full sm:w-auto bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
Share
</button>
</form>
}
</div>
}
func colorEndpoint(r ResourceCard) string {
if r.Virtual {
return "/web/resources/birthdays/color"
}
return "/web/resources/calendar/color"
}
func shareEndpoint(kind string) string {
if kind == "calendar" {
return "/web/shares/calendar"