···66 "net/http"
77 "os"
88 "os/signal"
99+ "strings"
910 "syscall"
1011 "time"
1112···4849 listHandler := handlers.NewListHandler(authHandler.Client())
4950 settingsHandler := handlers.NewSettingsHandler(authHandler.Client())
5051 pushHandler := handlers.NewPushHandler(notificationRepo)
5252+ calendarHandler := handlers.NewCalendarHandler(authHandler.Client())
5353+ icalHandler := handlers.NewICalHandler(authHandler.Client())
51545255 // Initialize Stripe client and supporter handler (only if Stripe keys are configured)
5356 var supporterHandler *handlers.SupporterHandler
···64676568 // Initialize push notification sender (only if VAPID keys are configured)
6669 var pushSender *push.Sender
6767- var jobRunner *jobs.Runner
7070+ var taskJobRunner *jobs.Runner
7171+ var calendarJobRunner *jobs.Runner
6872 if cfg.VAPIDPublicKey != "" && cfg.VAPIDPrivateKey != "" {
6973 pushSender = push.NewSender(cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubscriber)
7074 pushHandler.SetSender(pushSender)
7175 log.Println("Push notification sender initialized")
72767373- // Initialize background job runner (check every 5 minutes)
7474- jobRunner = jobs.NewRunner(5 * time.Minute)
7575-7676- // Create and register notification check job
7777+ // Initialize background job runner for task notifications (check every 5 minutes)
7878+ taskJobRunner = jobs.NewRunner(5 * time.Minute)
7779 notificationJob := jobs.NewNotificationCheckJob(notificationRepo, authHandler.Client(), pushSender)
7878- jobRunner.AddJob(notificationJob)
8080+ taskJobRunner.AddJob(notificationJob)
8181+ taskJobRunner.Start()
8282+ log.Println("Task notification job runner started (5 minute interval)")
79838080- // Start job runner
8181- jobRunner.Start()
8282- log.Println("Background job runner started")
8484+ // Initialize background job runner for calendar notifications (check every 30 minutes)
8585+ calendarJobRunner = jobs.NewRunner(30 * time.Minute)
8686+ calendarNotificationJob := jobs.NewCalendarNotificationJob(notificationRepo, authHandler.Client(), pushSender, settingsHandler)
8787+ calendarJobRunner.AddJob(calendarNotificationJob)
8888+ calendarJobRunner.Start()
8989+ log.Println("Calendar notification job runner started (30 minute interval)")
8390 } else {
8491 log.Println("VAPID keys not configured - push notifications disabled")
8592 log.Println("Run 'go run ./cmd/vapid' to generate VAPID keys")
···137144 mux.HandleFunc("/list/", listHandler.HandlePublicListView)
138145 logRoute("GET /list/*")
139146147147+ // Public iCal feed routes
148148+ mux.HandleFunc("/calendar/feed/", icalHandler.GenerateCalendarFeed)
149149+ logRoute("GET /calendar/feed/{did}/events.ics")
150150+ mux.HandleFunc("/tasks/feed/", icalHandler.GenerateTasksFeed)
151151+ logRoute("GET /tasks/feed/{did}/tasks.ics")
152152+140153 // Protected routes
141154 mux.Handle("/app", authMiddleware.RequireAuth(http.HandlerFunc(handleDashboard)))
142155 logRoute("GET /app [protected]")
···162175 logRoute("POST /app/push/test [protected]")
163176 mux.Handle("/app/push/check", authMiddleware.RequireAuth(http.HandlerFunc(pushHandler.HandleCheckTasks)))
164177 logRoute("POST /app/push/check [protected]")
178178+179179+ // Calendar routes
180180+ mux.Handle("/app/calendar/events", authMiddleware.RequireAuth(http.HandlerFunc(calendarHandler.ListEvents)))
181181+ logRoute("GET /app/calendar/events [protected]")
182182+ mux.Handle("/app/calendar/upcoming", authMiddleware.RequireAuth(http.HandlerFunc(calendarHandler.ListUpcomingEvents)))
183183+ logRoute("GET /app/calendar/upcoming [protected]")
184184+ // Note: These must be after the specific routes above to avoid matching them
185185+ mux.Handle("/app/calendar/events/", authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
186186+ // Route to either GetEvent or GetEventRSVPs based on URL
187187+ if strings.HasSuffix(r.URL.Path, "/rsvps") {
188188+ calendarHandler.GetEventRSVPs(w, r)
189189+ } else {
190190+ calendarHandler.GetEvent(w, r)
191191+ }
192192+ })))
193193+ logRoute("GET /app/calendar/events/:rkey [protected]")
194194+ logRoute("GET /app/calendar/events/:rkey/rsvps [protected]")
165195166196 // Supporter routes (only if Stripe is configured)
167197 if supporterHandler != nil {
···213243 log.Println("Shutting down gracefully...")
214244215245 // Stop background jobs
216216- if jobRunner != nil {
217217- jobRunner.Stop()
246246+ if taskJobRunner != nil {
247247+ taskJobRunner.Stop()
248248+ }
249249+ if calendarJobRunner != nil {
250250+ calendarJobRunner.Stop()
218251 }
219252220253 log.Println("Shutdown complete")
+264
docs/features.md
···1212- [User Interface Preferences](#user-interface-preferences)
1313- [Notifications](#notifications)
1414- [Lists](#lists)
1515+- [Calendar Events](#calendar-events)
1516- [Progressive Web App](#progressive-web-app)
16171718---
···5025034. Share with anyone
503504504505**Shared lists are public** - anyone with the link can view tasks in that list (but not edit them).
506506+507507+---
508508+509509+## Calendar Events
510510+511511+AT Todo integrates with the AT Protocol calendar ecosystem, allowing you to view calendar events created by other calendar applications like [Smokesignal](https://smokesignal.events).
512512+513513+### What are Calendar Events?
514514+515515+Calendar events are social events stored in the AT Protocol using the `community.lexicon.calendar.event` lexicon. Unlike tasks (which are personal todo items), calendar events are typically:
516516+517517+- **Public or shared**: Visible to others in the AT Protocol network
518518+- **Time-specific**: Have defined start/end times
519519+- **Social**: Support RSVPs and attendance tracking
520520+- **External**: Created by dedicated calendar apps like Smokesignal
521521+522522+### Viewing Calendar Events
523523+524524+**Access calendar events:**
525525+1. Navigate to the **📅 Events** tab in your dashboard
526526+2. Choose between two views:
527527+ - **Upcoming Events**: Shows events in the next 7 days
528528+ - **All Events**: Shows all calendar events
529529+530530+**Event display includes:**
531531+- 📅 Event name and description
532532+- 🕐 Start and end times (in your local timezone)
533533+- 📍 Location information (for in-person events)
534534+- 💻 Attendance mode (Virtual, In Person, or Hybrid)
535535+- 🔗 Links to related resources
536536+- 💨 Direct link to view on Smokesignal
537537+538538+### Event Details
539539+540540+**View detailed information:**
541541+1. Click "View Details" on any event card
542542+2. A modal opens showing:
543543+ - Full event description
544544+ - Complete date/time information
545545+ - Event status (Planned, Scheduled, Rescheduled, Cancelled)
546546+ - Location details with addresses
547547+ - Attendance mode with visual indicators
548548+ - Related URLs and resources
549549+ - Your personal RSVP status (if you've RSVP'd)
550550+ - Link to view all RSVPs on Smokesignal
551551+552552+**Visual indicators:**
553553+- 💻 **Virtual**: Online-only events
554554+- 📍 **In Person**: Physical location events
555555+- 🔄 **Hybrid**: Both online and in-person options
556556+557557+### Event Sources
558558+559559+AT Todo displays events from two sources:
560560+561561+1. **Your own events**: Calendar events in your AT Protocol repository
562562+2. **RSVP'd events**: Events you've RSVP'd to via calendar apps
563563+564564+All events are read-only in AT Todo. To create or manage events, use a dedicated calendar app like [Smokesignal](https://smokesignal.events).
565565+566566+### Calendar Notifications
567567+568568+Stay informed about upcoming events with automatic notifications.
569569+570570+**Enabling calendar notifications:**
571571+1. Open Settings
572572+2. Scroll to "Calendar Notification Settings"
573573+3. Toggle "Enable calendar event notifications"
574574+4. Set your preferred lead time (default: 1 hour before event)
575575+5. Ensure push notifications are enabled
576576+577577+**Notification timing:**
578578+- Choose how far in advance to be notified (e.g., "1h", "30m", "2h")
579579+- Notifications sent when events fall within your lead time window
580580+- Default: 1 hour before event start time
581581+- Respects quiet hours settings
582582+583583+**What you'll receive:**
584584+```
585585+Upcoming Event: Community Meetup
586586+💻 Virtual event starts in 1 hour
587587+```
588588+589589+**Smart notification features:**
590590+- ✅ Shows event mode (Virtual/In-Person/Hybrid)
591591+- ✅ Calculates time until event starts
592592+- ✅ Links to Smokesignal for full details
593593+- ✅ Sent to all registered devices
594594+- ✅ Won't spam (24-hour cooldown per event)
595595+596596+**Notification frequency:**
597597+Calendar events are checked every 30 minutes for upcoming events (separate from task notifications which run every 5 minutes).
598598+599599+### RSVPs and Attendance
600600+601601+**Viewing your RSVP:**
602602+- Open event details to see your RSVP status
603603+- Status indicators:
604604+ - ✓ **Going** (green)
605605+ - ⓘ **Interested** (blue)
606606+ - ✗ **Not Going** (red)
607607+608608+**Managing RSVPs:**
609609+RSVPs are managed through calendar applications like Smokesignal. AT Todo displays your RSVP status but doesn't provide RSVP functionality.
610610+611611+**To RSVP to an event:**
612612+1. Click the 💨 Smokesignal link in the event details
613613+2. RSVP on Smokesignal
614614+3. Your RSVP status will appear in AT Todo automatically
615615+616616+### Event Status Badges
617617+618618+Events display status badges to indicate their current state:
619619+620620+- **Scheduled** (green): Confirmed and finalized
621621+- **Planned** (blue): Created but not yet finalized
622622+- **Rescheduled** (orange): Date/time has been changed
623623+- **Cancelled** (red): Event has been cancelled
624624+- **Postponed** (red): Event delayed with no new date
625625+626626+Cancelled and postponed events still appear in your calendar but are clearly marked.
627627+628628+### Integration with Smokesignal
629629+630630+AT Todo integrates seamlessly with [Smokesignal](https://smokesignal.events), the premier AT Protocol calendar application.
631631+632632+**Smokesignal features:**
633633+- Create and manage calendar events
634634+- RSVP to events
635635+- View all RSVPs and attendees
636636+- Share event links
637637+- Event discovery and search
638638+639639+**Quick access:**
640640+- Click the 💨 emoji next to any event name
641641+- Opens the event directly on Smokesignal
642642+- View full attendee list
643643+- Manage your RSVP
644644+- Share event with others
645645+646646+### Google Calendar & iCal Subscription
647647+648648+Subscribe to your AT Protocol calendar events **and tasks** in Google Calendar, Apple Calendar, Outlook, or any calendar app that supports iCal feeds.
649649+650650+**Getting your iCal feed URLs:**
651651+652652+AT Todo provides two separate feeds:
653653+654654+**Calendar Events Feed:**
655655+```
656656+https://attodo.app/calendar/feed/{your-did}/events.ics
657657+```
658658+659659+**Tasks Feed (tasks with due dates):**
660660+```
661661+https://attodo.app/tasks/feed/{your-did}/tasks.ics
662662+```
663663+664664+To find your DID:
665665+1. Open AT Todo and navigate to the 📅 Events tab
666666+2. Open your browser's developer console (F12)
667667+3. Your DID appears in the console when loading events, or
668668+4. Check your AT Protocol profile on Bluesky
669669+670670+**Subscribing in Google Calendar:**
671671+672672+You can subscribe to both feeds separately:
673673+674674+**For Events:**
675675+1. Open [Google Calendar](https://calendar.google.com)
676676+2. Click the **+** next to "Other calendars"
677677+3. Select **"From URL"**
678678+4. Paste your events feed URL: `https://attodo.app/calendar/feed/{your-did}/events.ics`
679679+5. Click **"Add calendar"**
680680+681681+**For Tasks:**
682682+1. Click the **+** next to "Other calendars" again
683683+2. Select **"From URL"**
684684+3. Paste your tasks feed URL: `https://attodo.app/tasks/feed/{your-did}/tasks.ics`
685685+4. Click **"Add calendar"**
686686+687687+Your AT Protocol events and tasks will now appear in Google Calendar and sync automatically!
688688+689689+**Subscribing in Apple Calendar:**
690690+691691+Subscribe to both feeds for complete coverage:
692692+693693+1. Open Calendar app
694694+2. Go to **File** → **New Calendar Subscription**
695695+3. Paste your events feed URL, click **Subscribe**
696696+4. Choose auto-refresh frequency (recommended: every hour)
697697+5. Repeat for tasks feed URL
698698+699699+**Subscribing in Outlook:**
700700+701701+Subscribe to both feeds separately:
702702+703703+1. Open Outlook
704704+2. Go to **File** → **Account Settings** → **Internet Calendars**
705705+3. Click **New**
706706+4. Paste your events feed URL, click **Add**
707707+5. Repeat for tasks feed URL
708708+709709+**What syncs from Events Feed:**
710710+- ✅ Event names and descriptions
711711+- ✅ Start and end times (in your timezone)
712712+- ✅ Location information
713713+- ✅ Event status (confirmed, tentative, cancelled)
714714+- ✅ Links to Smokesignal
715715+- ✅ Event mode (virtual/in-person/hybrid) as categories
716716+717717+**What syncs from Tasks Feed:**
718718+- ✅ Task titles and descriptions
719719+- ✅ Due dates (in your timezone)
720720+- ✅ Completion status
721721+- ✅ Tags as categories
722722+- ✅ Only tasks with due dates (no due date = not included)
723723+724724+**Auto-refresh:**
725725+- Calendar apps check for updates periodically
726726+- Google Calendar: Every few hours
727727+- Apple Calendar: Configurable (hourly recommended)
728728+- Outlook: Configurable
729729+730730+**Privacy note:**
731731+Calendar events and tasks in AT Protocol are public by design. Anyone with your iCal feed URLs can view your events and tasks. This is the same as viewing them on Smokesignal or other AT Protocol apps.
732732+733733+**Tips:**
734734+- Subscribe to both feeds to see your complete schedule in one place
735735+- Tasks appear as "todos" in most calendar apps
736736+- Completed tasks remain in the feed with completion status
737737+- Use separate calendar colors to distinguish events from tasks
738738+739739+### Event Timezone Handling
740740+741741+All event times are automatically converted to your local timezone:
742742+- **Displayed**: In your browser's timezone
743743+- **Stored**: In UTC in AT Protocol
744744+- **Notifications**: Respect your local time
745745+- **Created date**: Shows when the event was created (in local time)
746746+747747+### Read-Only Access
748748+749749+**Important notes:**
750750+- 📖 Calendar events in AT Todo are **read-only**
751751+- ✏️ To create or edit events, use a calendar app like Smokesignal
752752+- 🔄 AT Todo automatically syncs events from your AT Protocol repository
753753+- 📅 Perfect for viewing your event schedule alongside your tasks
754754+755755+### Calendar Best Practices
756756+757757+**Organizing your calendar:**
758758+1. **Use Smokesignal** for event creation and management
759759+2. **View in AT Todo** to see events alongside tasks
760760+3. **Enable notifications** to stay informed
761761+4. **RSVP on Smokesignal** to indicate attendance
762762+5. **Share event links** from Smokesignal with others
763763+764764+**Integrating with tasks:**
765765+- Create tasks for event preparation (e.g., "Prepare presentation for Monday meetup")
766766+- Use tags to link tasks to events (e.g., #meetup, #conference)
767767+- Set task due dates relative to event times
768768+- Enable both task and calendar notifications
505769506770---
507771
+593
internal/handlers/calendar.go
···11+package handlers
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "io"
88+ "net/http"
99+ "sort"
1010+ "strings"
1111+ "time"
1212+1313+ "github.com/shindakun/attodo/internal/models"
1414+ "github.com/shindakun/attodo/internal/session"
1515+ "github.com/shindakun/bskyoauth"
1616+)
1717+1818+const (
1919+ CalendarEventCollection = "community.lexicon.calendar.event"
2020+ CalendarRSVPCollection = "community.lexicon.calendar.rsvp"
2121+)
2222+2323+type CalendarHandler struct {
2424+ client *bskyoauth.Client
2525+}
2626+2727+func NewCalendarHandler(client *bskyoauth.Client) *CalendarHandler {
2828+ return &CalendarHandler{client: client}
2929+}
3030+3131+// withRetry executes an operation with automatic token refresh on DPoP errors
3232+func (h *CalendarHandler) withRetry(ctx context.Context, sess *bskyoauth.Session, operation func(*bskyoauth.Session) error) (*bskyoauth.Session, error) {
3333+ var err error
3434+3535+ for attempt := 0; attempt < 2; attempt++ {
3636+ err = operation(sess)
3737+ if err == nil {
3838+ return sess, nil
3939+ }
4040+4141+ // Check if it's a DPoP replay error or 401
4242+ if strings.Contains(err.Error(), "invalid_dpop_proof") || strings.Contains(err.Error(), "401") {
4343+ // Refresh the token
4444+ sess, err = h.client.RefreshToken(ctx, sess)
4545+ if err != nil {
4646+ return sess, err
4747+ }
4848+ continue
4949+ }
5050+5151+ // Other errors, don't retry
5252+ break
5353+ }
5454+5555+ return sess, err
5656+}
5757+5858+// ListEvents fetches all calendar events (both owned and RSVP'd)
5959+func (h *CalendarHandler) ListEvents(w http.ResponseWriter, r *http.Request) {
6060+ ctx := r.Context()
6161+ sess, ok := session.GetSession(r)
6262+ if !ok {
6363+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
6464+ return
6565+ }
6666+6767+ var events []*models.CalendarEvent
6868+ var err error
6969+7070+ sess, err = h.withRetry(ctx, sess, func(s *bskyoauth.Session) error {
7171+ // Fetch events from user's own repository
7272+ ownEvents, err := h.ListEventRecords(ctx, s)
7373+ if err != nil {
7474+ fmt.Printf("WARNING: Failed to fetch own events: %v\n", err)
7575+ } else {
7676+ events = append(events, ownEvents...)
7777+ }
7878+7979+ // Fetch events user has RSVP'd to
8080+ rsvpEvents, err := h.ListEventsFromRSVPs(ctx, s)
8181+ if err != nil {
8282+ fmt.Printf("WARNING: Failed to fetch RSVP'd events: %v\n", err)
8383+ } else {
8484+ events = append(events, rsvpEvents...)
8585+ }
8686+8787+ return nil
8888+ })
8989+9090+ if err != nil {
9191+ http.Error(w, getUserFriendlyError(err, "Failed to fetch calendar events"), http.StatusInternalServerError)
9292+ return
9393+ }
9494+9595+ // Sort events by StartsAt in reverse chronological order (newest first)
9696+ sortEventsByDate(events)
9797+9898+ // Check if client wants HTML or JSON based on Accept header
9999+ acceptHeader := r.Header.Get("Accept")
100100+ if strings.Contains(acceptHeader, "text/html") || r.Header.Get("HX-Request") == "true" {
101101+ // Return HTML for HTMX
102102+ if len(events) == 0 {
103103+ w.Header().Set("Content-Type", "text/html")
104104+ w.Write([]byte("<p style=\"color: var(--pico-muted-color); text-align: center; padding: 2rem;\">No calendar events found. Events created in other AT Protocol calendar apps will appear here.</p>"))
105105+ return
106106+ }
107107+108108+ w.Header().Set("Content-Type", "text/html")
109109+ for _, event := range events {
110110+ Render(w, "calendar-event-card.html", event)
111111+ }
112112+ return
113113+ }
114114+115115+ // Return JSON for API calls
116116+ w.Header().Set("Content-Type", "application/json")
117117+ json.NewEncoder(w).Encode(events)
118118+}
119119+120120+// GetEvent fetches a single calendar event by rkey
121121+func (h *CalendarHandler) GetEvent(w http.ResponseWriter, r *http.Request) {
122122+ ctx := r.Context()
123123+ sess, ok := session.GetSession(r)
124124+ if !ok {
125125+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
126126+ return
127127+ }
128128+129129+ // Extract rkey from URL path
130130+ rkey := strings.TrimPrefix(r.URL.Path, "/app/calendar/events/")
131131+ if idx := strings.Index(rkey, "/"); idx != -1 {
132132+ rkey = rkey[:idx]
133133+ }
134134+135135+ if rkey == "" {
136136+ http.Error(w, "Missing event ID", http.StatusBadRequest)
137137+ return
138138+ }
139139+140140+ var event *models.CalendarEvent
141141+ var err error
142142+143143+ // Try to fetch from user's own repository first
144144+ sess, err = h.withRetry(ctx, sess, func(s *bskyoauth.Session) error {
145145+ event, err = h.getEventRecord(ctx, s, rkey)
146146+ return err
147147+ })
148148+149149+ // If not found in user's repository, search through all events (including RSVP'd)
150150+ if err != nil && (strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "400") || strings.Contains(err.Error(), "RecordNotFound")) {
151151+ fmt.Printf("DEBUG: Event %s not found in user's own repository, searching RSVP'd events...\n", rkey)
152152+ sess, err = h.withRetry(ctx, sess, func(s *bskyoauth.Session) error {
153153+ // Get all events (own + RSVP'd)
154154+ var allEvents []*models.CalendarEvent
155155+156156+ ownEvents, err := h.ListEventRecords(ctx, s)
157157+ if err == nil {
158158+ allEvents = append(allEvents, ownEvents...)
159159+ }
160160+161161+ rsvpEvents, err := h.ListEventsFromRSVPs(ctx, s)
162162+ if err == nil {
163163+ allEvents = append(allEvents, rsvpEvents...)
164164+ }
165165+166166+ fmt.Printf("DEBUG: Searching through %d total events for rkey %s\n", len(allEvents), rkey)
167167+168168+ // Find event with matching rkey
169169+ for _, e := range allEvents {
170170+ if e.RKey == rkey {
171171+ fmt.Printf("DEBUG: Found event %s in all events list\n", rkey)
172172+ event = e
173173+ return nil
174174+ }
175175+ }
176176+177177+ return fmt.Errorf("event not found: %s", rkey)
178178+ })
179179+ }
180180+181181+ if err != nil {
182182+ fmt.Printf("ERROR: Failed to fetch event %s: %v\n", rkey, err)
183183+ if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "RecordNotFound") {
184184+ http.Error(w, "Event not found", http.StatusNotFound)
185185+ return
186186+ }
187187+ http.Error(w, getUserFriendlyError(err, "Failed to fetch event"), http.StatusInternalServerError)
188188+ return
189189+ }
190190+191191+ fmt.Printf("DEBUG: Successfully fetched event %s\n", rkey)
192192+193193+ w.Header().Set("Content-Type", "application/json")
194194+ json.NewEncoder(w).Encode(event)
195195+}
196196+197197+// GetEventRSVPs fetches RSVPs for a specific event
198198+func (h *CalendarHandler) GetEventRSVPs(w http.ResponseWriter, r *http.Request) {
199199+ ctx := r.Context()
200200+ sess, ok := session.GetSession(r)
201201+ if !ok {
202202+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
203203+ return
204204+ }
205205+206206+ // Extract rkey from URL path
207207+ path := strings.TrimPrefix(r.URL.Path, "/app/calendar/events/")
208208+ parts := strings.Split(path, "/")
209209+ if len(parts) < 2 {
210210+ http.Error(w, "Invalid URL", http.StatusBadRequest)
211211+ return
212212+ }
213213+ rkey := parts[0]
214214+215215+ // Construct the event URI
216216+ eventURI := fmt.Sprintf("at://%s/%s/%s", sess.DID, CalendarEventCollection, rkey)
217217+218218+ var rsvps []*models.CalendarRSVP
219219+ var err error
220220+221221+ sess, err = h.withRetry(ctx, sess, func(s *bskyoauth.Session) error {
222222+ allRSVPs, err := h.listRSVPRecords(ctx, s)
223223+ if err != nil {
224224+ return err
225225+ }
226226+227227+ // Filter for this event
228228+ for _, rsvp := range allRSVPs {
229229+ if rsvp.Subject.URI == eventURI {
230230+ rsvps = append(rsvps, rsvp)
231231+ }
232232+ }
233233+234234+ return nil
235235+ })
236236+237237+ if err != nil {
238238+ http.Error(w, getUserFriendlyError(err, "Failed to fetch RSVPs"), http.StatusInternalServerError)
239239+ return
240240+ }
241241+242242+ w.Header().Set("Content-Type", "application/json")
243243+ json.NewEncoder(w).Encode(rsvps)
244244+}
245245+246246+// ListUpcomingEvents fetches events starting within a specified time window
247247+func (h *CalendarHandler) ListUpcomingEvents(w http.ResponseWriter, r *http.Request) {
248248+ ctx := r.Context()
249249+ sess, ok := session.GetSession(r)
250250+ if !ok {
251251+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
252252+ return
253253+ }
254254+255255+ // Parse duration from query params (default: 7 days)
256256+ durationStr := r.URL.Query().Get("within")
257257+ duration := 7 * 24 * time.Hour // default 7 days
258258+ if durationStr != "" {
259259+ if parsed, err := time.ParseDuration(durationStr); err == nil {
260260+ duration = parsed
261261+ }
262262+ }
263263+264264+ var events []*models.CalendarEvent
265265+ var err error
266266+267267+ sess, err = h.withRetry(ctx, sess, func(s *bskyoauth.Session) error {
268268+ allEvents, err := h.ListEventRecords(ctx, s)
269269+ if err != nil {
270270+ return err
271271+ }
272272+273273+ // Filter for upcoming events
274274+ for _, event := range allEvents {
275275+ if event.IsUpcoming() && event.StartsWithin(duration) && !event.IsCancelled() {
276276+ events = append(events, event)
277277+ }
278278+ }
279279+280280+ return nil
281281+ })
282282+283283+ if err != nil {
284284+ http.Error(w, getUserFriendlyError(err, "Failed to fetch upcoming events"), http.StatusInternalServerError)
285285+ return
286286+ }
287287+288288+ // Check if client wants HTML or JSON based on Accept header
289289+ acceptHeader := r.Header.Get("Accept")
290290+ if strings.Contains(acceptHeader, "text/html") || r.Header.Get("HX-Request") == "true" {
291291+ // Return HTML for HTMX
292292+ if len(events) == 0 {
293293+ w.Header().Set("Content-Type", "text/html")
294294+ w.Write([]byte("<p style=\"color: var(--pico-muted-color); text-align: center; padding: 2rem;\">No upcoming events in the next 7 days.</p>"))
295295+ return
296296+ }
297297+298298+ w.Header().Set("Content-Type", "text/html")
299299+ for _, event := range events {
300300+ Render(w, "calendar-event-card.html", event)
301301+ }
302302+ return
303303+ }
304304+305305+ // Return JSON for API calls
306306+ w.Header().Set("Content-Type", "application/json")
307307+ json.NewEncoder(w).Encode(events)
308308+}
309309+310310+// listEventRecords fetches all calendar events using direct XRPC call
311311+func (h *CalendarHandler) ListEventRecords(ctx context.Context, sess *bskyoauth.Session) ([]*models.CalendarEvent, error) {
312312+ // Build the XRPC URL
313313+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s",
314314+ sess.PDS, sess.DID, CalendarEventCollection)
315315+316316+ // Create request
317317+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
318318+ if err != nil {
319319+ return nil, err
320320+ }
321321+322322+ // Add authorization header
323323+ req.Header.Set("Authorization", "Bearer "+sess.AccessToken)
324324+325325+ // Make request
326326+ client := &http.Client{Timeout: 10 * time.Second}
327327+ resp, err := client.Do(req)
328328+ if err != nil {
329329+ return nil, err
330330+ }
331331+ defer resp.Body.Close()
332332+333333+ if resp.StatusCode != http.StatusOK {
334334+ body, _ := io.ReadAll(resp.Body)
335335+ return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body))
336336+ }
337337+338338+ // Parse response
339339+ var result struct {
340340+ Records []struct {
341341+ Uri string `json:"uri"`
342342+ Cid string `json:"cid"`
343343+ Value map[string]interface{} `json:"value"`
344344+ } `json:"records"`
345345+ }
346346+347347+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
348348+ return nil, err
349349+ }
350350+351351+ fmt.Printf("DEBUG: Received %d calendar event records from AT Protocol\n", len(result.Records))
352352+353353+ // Convert to CalendarEvent models
354354+ events := make([]*models.CalendarEvent, 0, len(result.Records))
355355+ for _, record := range result.Records {
356356+ event, err := models.ParseCalendarEvent(record.Value, record.Uri, record.Cid)
357357+ if err != nil {
358358+ // Log error but continue with other events
359359+ fmt.Printf("WARNING: Failed to parse calendar event %s: %v\n", record.Uri, err)
360360+ continue
361361+ }
362362+ events = append(events, event)
363363+ }
364364+365365+ fmt.Printf("DEBUG: Fetched %d calendar events from repository\n", len(events))
366366+367367+ return events, nil
368368+}
369369+370370+// getEventRecord fetches a single event using direct XRPC call
371371+func (h *CalendarHandler) getEventRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (*models.CalendarEvent, error) {
372372+ // Build the XRPC URL
373373+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
374374+ sess.PDS, sess.DID, CalendarEventCollection, rkey)
375375+376376+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
377377+ if err != nil {
378378+ return nil, err
379379+ }
380380+381381+ // Add authorization header
382382+ req.Header.Set("Authorization", "Bearer "+sess.AccessToken)
383383+384384+ // Make request
385385+ client := &http.Client{Timeout: 10 * time.Second}
386386+ resp, err := client.Do(req)
387387+ if err != nil {
388388+ return nil, err
389389+ }
390390+ defer resp.Body.Close()
391391+392392+ if resp.StatusCode != http.StatusOK {
393393+ body, _ := io.ReadAll(resp.Body)
394394+ return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body))
395395+ }
396396+397397+ // Parse response
398398+ var result struct {
399399+ Uri string `json:"uri"`
400400+ Cid string `json:"cid"`
401401+ Value map[string]interface{} `json:"value"`
402402+ }
403403+404404+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
405405+ return nil, err
406406+ }
407407+408408+ // Convert to CalendarEvent model
409409+ event, err := models.ParseCalendarEvent(result.Value, result.Uri, result.Cid)
410410+ if err != nil {
411411+ return nil, fmt.Errorf("failed to parse event: %w", err)
412412+ }
413413+414414+ return event, nil
415415+}
416416+417417+// listEventsFromRSVPs fetches events that the user has RSVP'd to
418418+func (h *CalendarHandler) ListEventsFromRSVPs(ctx context.Context, sess *bskyoauth.Session) ([]*models.CalendarEvent, error) {
419419+ // First, fetch all RSVPs
420420+ rsvps, err := h.listRSVPRecords(ctx, sess)
421421+ if err != nil {
422422+ return nil, fmt.Errorf("failed to fetch RSVPs: %w", err)
423423+ }
424424+425425+ fmt.Printf("DEBUG: Found %d RSVPs\n", len(rsvps))
426426+427427+ // Extract unique event URIs from RSVPs
428428+ eventURIs := make(map[string]bool)
429429+ for _, rsvp := range rsvps {
430430+ if rsvp.Subject != nil && rsvp.Subject.URI != "" {
431431+ eventURIs[rsvp.Subject.URI] = true
432432+ }
433433+ }
434434+435435+ fmt.Printf("DEBUG: Found %d unique event URIs from RSVPs\n", len(eventURIs))
436436+437437+ // Fetch each event by URI
438438+ events := make([]*models.CalendarEvent, 0, len(eventURIs))
439439+ for eventURI := range eventURIs {
440440+ event, err := h.getEventByURI(ctx, sess, eventURI)
441441+ if err != nil {
442442+ fmt.Printf("WARNING: Failed to fetch event %s: %v\n", eventURI, err)
443443+ continue
444444+ }
445445+ events = append(events, event)
446446+ }
447447+448448+ fmt.Printf("DEBUG: Successfully fetched %d events from RSVPs\n", len(events))
449449+450450+ return events, nil
451451+}
452452+453453+// getEventByURI fetches an event by its AT URI
454454+func (h *CalendarHandler) getEventByURI(ctx context.Context, sess *bskyoauth.Session, uri string) (*models.CalendarEvent, error) {
455455+ // Parse URI: at://did:plc:xxx/community.lexicon.calendar.event/rkey
456456+ // Extract DID, collection, and rkey
457457+ if len(uri) < 5 || uri[:5] != "at://" {
458458+ return nil, fmt.Errorf("invalid AT URI: %s", uri)
459459+ }
460460+461461+ parts := strings.Split(uri[5:], "/")
462462+ if len(parts) != 3 {
463463+ return nil, fmt.Errorf("invalid AT URI format: %s", uri)
464464+ }
465465+466466+ did := parts[0]
467467+ collection := parts[1]
468468+ rkey := parts[2]
469469+470470+ // Build XRPC URL - use the repo from the URI, not the session DID
471471+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
472472+ sess.PDS, did, collection, rkey)
473473+474474+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
475475+ if err != nil {
476476+ return nil, err
477477+ }
478478+479479+ // Add authorization header
480480+ req.Header.Set("Authorization", "Bearer "+sess.AccessToken)
481481+482482+ // Make request
483483+ client := &http.Client{Timeout: 10 * time.Second}
484484+ resp, err := client.Do(req)
485485+ if err != nil {
486486+ return nil, err
487487+ }
488488+ defer resp.Body.Close()
489489+490490+ if resp.StatusCode != http.StatusOK {
491491+ body, _ := io.ReadAll(resp.Body)
492492+ return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body))
493493+ }
494494+495495+ // Parse response
496496+ var result struct {
497497+ Uri string `json:"uri"`
498498+ Cid string `json:"cid"`
499499+ Value map[string]interface{} `json:"value"`
500500+ }
501501+502502+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
503503+ return nil, err
504504+ }
505505+506506+ // Convert to CalendarEvent model
507507+ event, err := models.ParseCalendarEvent(result.Value, result.Uri, result.Cid)
508508+ if err != nil {
509509+ return nil, fmt.Errorf("failed to parse event: %w", err)
510510+ }
511511+512512+ return event, nil
513513+}
514514+515515+// listRSVPRecords fetches all RSVP records using direct XRPC call
516516+func (h *CalendarHandler) listRSVPRecords(ctx context.Context, sess *bskyoauth.Session) ([]*models.CalendarRSVP, error) {
517517+ // Build the XRPC URL
518518+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s",
519519+ sess.PDS, sess.DID, CalendarRSVPCollection)
520520+521521+ // Create request
522522+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
523523+ if err != nil {
524524+ return nil, err
525525+ }
526526+527527+ // Add authorization header
528528+ req.Header.Set("Authorization", "Bearer "+sess.AccessToken)
529529+530530+ // Make request
531531+ client := &http.Client{Timeout: 10 * time.Second}
532532+ resp, err := client.Do(req)
533533+ if err != nil {
534534+ return nil, err
535535+ }
536536+ defer resp.Body.Close()
537537+538538+ if resp.StatusCode != http.StatusOK {
539539+ body, _ := io.ReadAll(resp.Body)
540540+ return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body))
541541+ }
542542+543543+ // Parse response
544544+ var result struct {
545545+ Records []struct {
546546+ Uri string `json:"uri"`
547547+ Cid string `json:"cid"`
548548+ Value map[string]interface{} `json:"value"`
549549+ } `json:"records"`
550550+ }
551551+552552+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
553553+ return nil, err
554554+ }
555555+556556+ // Convert to CalendarRSVP models
557557+ rsvps := make([]*models.CalendarRSVP, 0, len(result.Records))
558558+ for _, record := range result.Records {
559559+ rsvp, err := models.ParseCalendarRSVP(record.Value, record.Uri, record.Cid)
560560+ if err != nil {
561561+ // Log error but continue with other RSVPs
562562+ continue
563563+ }
564564+ rsvps = append(rsvps, rsvp)
565565+ }
566566+567567+ return rsvps, nil
568568+}
569569+570570+// sortEventsByDate sorts events by StartsAt in reverse chronological order (newest first)
571571+// Events without StartsAt are placed at the end, sorted by CreatedAt
572572+func sortEventsByDate(events []*models.CalendarEvent) {
573573+ sort.Slice(events, func(i, j int) bool {
574574+ eventI := events[i]
575575+ eventJ := events[j]
576576+577577+ // If both have StartsAt, sort by StartsAt (newest first)
578578+ if eventI.StartsAt != nil && eventJ.StartsAt != nil {
579579+ return eventI.StartsAt.After(*eventJ.StartsAt)
580580+ }
581581+582582+ // Events with StartsAt come before events without
583583+ if eventI.StartsAt != nil {
584584+ return true
585585+ }
586586+ if eventJ.StartsAt != nil {
587587+ return false
588588+ }
589589+590590+ // Both don't have StartsAt, sort by CreatedAt (newest first)
591591+ return eventI.CreatedAt.After(eventJ.CreatedAt)
592592+ })
593593+}
+475
internal/handlers/ical.go
···11+package handlers
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "net/http"
88+ "strings"
99+ "time"
1010+1111+ "github.com/bluesky-social/indigo/atproto/identity"
1212+ "github.com/bluesky-social/indigo/atproto/syntax"
1313+ "github.com/shindakun/attodo/internal/models"
1414+ "github.com/shindakun/bskyoauth"
1515+)
1616+1717+// ICalHandler handles iCal feed generation
1818+type ICalHandler struct {
1919+ client *bskyoauth.Client
2020+}
2121+2222+// NewICalHandler creates a new iCal handler
2323+func NewICalHandler(client *bskyoauth.Client) *ICalHandler {
2424+ return &ICalHandler{
2525+ client: client,
2626+ }
2727+}
2828+2929+// GenerateCalendarFeed generates an iCal feed for a user's calendar events
3030+func (h *ICalHandler) GenerateCalendarFeed(w http.ResponseWriter, r *http.Request) {
3131+ ctx := r.Context()
3232+3333+ // Extract DID from path: /calendar/feed/{did}/events.ics
3434+ pathParts := strings.Split(r.URL.Path, "/")
3535+ if len(pathParts) < 5 {
3636+ http.Error(w, "Invalid feed URL", http.StatusBadRequest)
3737+ return
3838+ }
3939+4040+ did := pathParts[3]
4141+ if did == "" {
4242+ http.Error(w, "Missing DID", http.StatusBadRequest)
4343+ return
4444+ }
4545+4646+ // Fetch events from AT Protocol (public read, no auth needed)
4747+ events, err := h.fetchEventsForDID(ctx, did)
4848+ if err != nil {
4949+ http.Error(w, fmt.Sprintf("Failed to fetch events: %v", err), http.StatusInternalServerError)
5050+ return
5151+ }
5252+5353+ // Generate iCal feed
5454+ ical := h.generateCalendarICalendar(did, events)
5555+5656+ // Set headers for iCal feed
5757+ w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
5858+ w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s-events.ics\"", sanitizeDID(did)))
5959+ w.Header().Set("Cache-Control", "no-cache, must-revalidate")
6060+6161+ // Write iCal content
6262+ w.Write([]byte(ical))
6363+}
6464+6565+// GenerateTasksFeed generates an iCal feed for a user's tasks with due dates
6666+func (h *ICalHandler) GenerateTasksFeed(w http.ResponseWriter, r *http.Request) {
6767+ ctx := r.Context()
6868+6969+ // Extract DID from path: /tasks/feed/{did}/tasks.ics
7070+ pathParts := strings.Split(r.URL.Path, "/")
7171+ if len(pathParts) < 5 {
7272+ http.Error(w, "Invalid feed URL", http.StatusBadRequest)
7373+ return
7474+ }
7575+7676+ did := pathParts[3]
7777+ if did == "" {
7878+ http.Error(w, "Missing DID", http.StatusBadRequest)
7979+ return
8080+ }
8181+8282+ // Fetch tasks from AT Protocol (public read, no auth needed)
8383+ tasks, err := h.fetchTasksForDID(ctx, did)
8484+ if err != nil {
8585+ http.Error(w, fmt.Sprintf("Failed to fetch tasks: %v", err), http.StatusInternalServerError)
8686+ return
8787+ }
8888+8989+ // Generate iCal feed
9090+ ical := h.generateTasksICalendar(did, tasks)
9191+9292+ // Set headers for iCal feed
9393+ w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
9494+ w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s-tasks.ics\"", sanitizeDID(did)))
9595+ w.Header().Set("Cache-Control", "no-cache, must-revalidate")
9696+9797+ // Write iCal content
9898+ w.Write([]byte(ical))
9999+}
100100+101101+// fetchEventsForDID fetches calendar events for a given DID using public read
102102+func (h *ICalHandler) fetchEventsForDID(ctx context.Context, did string) ([]*models.CalendarEvent, error) {
103103+ // Resolve PDS endpoint for this DID
104104+ pds, err := h.resolvePDSEndpoint(ctx, did)
105105+ if err != nil {
106106+ return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err)
107107+ }
108108+109109+ // Build the XRPC URL for public read
110110+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s",
111111+ pds, did, CalendarEventCollection)
112112+113113+ // Create and execute request
114114+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
115115+ if err != nil {
116116+ return nil, err
117117+ }
118118+119119+ client := &http.Client{Timeout: 10 * time.Second}
120120+ resp, err := client.Do(req)
121121+ if err != nil {
122122+ return nil, err
123123+ }
124124+ defer resp.Body.Close()
125125+126126+ if resp.StatusCode != http.StatusOK {
127127+ return nil, fmt.Errorf("XRPC error %d", resp.StatusCode)
128128+ }
129129+130130+ // Parse response (reuse the same struct from calendar.go)
131131+ var result struct {
132132+ Records []struct {
133133+ Uri string `json:"uri"`
134134+ Cid string `json:"cid"`
135135+ Value map[string]interface{} `json:"value"`
136136+ } `json:"records"`
137137+ }
138138+139139+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
140140+ return nil, err
141141+ }
142142+143143+ // Convert to CalendarEvent models
144144+ events := make([]*models.CalendarEvent, 0, len(result.Records))
145145+ for _, record := range result.Records {
146146+ event, err := models.ParseCalendarEvent(record.Value, record.Uri, record.Cid)
147147+ if err != nil {
148148+ // Skip invalid events but don't fail the whole feed
149149+ continue
150150+ }
151151+ events = append(events, event)
152152+ }
153153+154154+ return events, nil
155155+}
156156+157157+// resolvePDSEndpoint resolves the PDS endpoint for a given DID
158158+func (h *ICalHandler) resolvePDSEndpoint(ctx context.Context, did string) (string, error) {
159159+ dir := identity.DefaultDirectory()
160160+ atid, err := syntax.ParseAtIdentifier(did)
161161+ if err != nil {
162162+ return "", err
163163+ }
164164+165165+ ident, err := dir.Lookup(ctx, *atid)
166166+ if err != nil {
167167+ return "", err
168168+ }
169169+170170+ return ident.PDSEndpoint(), nil
171171+}
172172+173173+// generateCalendarICalendar generates an iCal format string from calendar events
174174+func (h *ICalHandler) generateCalendarICalendar(did string, events []*models.CalendarEvent) string {
175175+ var ical strings.Builder
176176+177177+ // iCal header
178178+ ical.WriteString("BEGIN:VCALENDAR\r\n")
179179+ ical.WriteString("VERSION:2.0\r\n")
180180+ ical.WriteString("PRODID:-//AT Todo//Calendar Feed//EN\r\n")
181181+ ical.WriteString(fmt.Sprintf("X-WR-CALNAME:AT Protocol Events - %s\r\n", sanitizeDID(did)))
182182+ ical.WriteString("X-WR-TIMEZONE:UTC\r\n")
183183+ ical.WriteString("CALSCALE:GREGORIAN\r\n")
184184+ ical.WriteString("METHOD:PUBLISH\r\n")
185185+186186+ // Add each event
187187+ for _, event := range events {
188188+ h.addEventToICalendar(&ical, event)
189189+ }
190190+191191+ // iCal footer
192192+ ical.WriteString("END:VCALENDAR\r\n")
193193+194194+ return ical.String()
195195+}
196196+197197+// addEventToICalendar adds a single event to the iCalendar
198198+func (h *ICalHandler) addEventToICalendar(ical *strings.Builder, event *models.CalendarEvent) {
199199+ ical.WriteString("BEGIN:VEVENT\r\n")
200200+201201+ // UID - unique identifier (use AT Protocol URI)
202202+ ical.WriteString(fmt.Sprintf("UID:%s\r\n", event.URI))
203203+204204+ // DTSTAMP - when the event was created
205205+ ical.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", formatICalTime(event.CreatedAt)))
206206+207207+ // DTSTART - event start time
208208+ if event.StartsAt != nil {
209209+ ical.WriteString(fmt.Sprintf("DTSTART:%s\r\n", formatICalTime(*event.StartsAt)))
210210+ }
211211+212212+ // DTEND - event end time
213213+ if event.EndsAt != nil {
214214+ ical.WriteString(fmt.Sprintf("DTEND:%s\r\n", formatICalTime(*event.EndsAt)))
215215+ }
216216+217217+ // SUMMARY - event title
218218+ ical.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", escapeICalText(event.Name)))
219219+220220+ // DESCRIPTION - event description
221221+ if event.Description != "" {
222222+ ical.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", escapeICalText(event.Description)))
223223+ }
224224+225225+ // LOCATION - event location
226226+ if len(event.Locations) > 0 {
227227+ location := event.Locations[0]
228228+ if location.Name != "" {
229229+ ical.WriteString(fmt.Sprintf("LOCATION:%s\r\n", escapeICalText(location.Name)))
230230+ } else if location.Address != "" {
231231+ ical.WriteString(fmt.Sprintf("LOCATION:%s\r\n", escapeICalText(location.Address)))
232232+ }
233233+ }
234234+235235+ // URL - link to Smokesignal
236236+ if smokesignalURL := event.SmokesignalURL(); smokesignalURL != "" {
237237+ ical.WriteString(fmt.Sprintf("URL:%s\r\n", smokesignalURL))
238238+ }
239239+240240+ // STATUS - event status
241241+ status := "CONFIRMED"
242242+ switch event.Status {
243243+ case models.EventStatusCancelled:
244244+ status = "CANCELLED"
245245+ case models.EventStatusPlanned:
246246+ status = "TENTATIVE"
247247+ }
248248+ ical.WriteString(fmt.Sprintf("STATUS:%s\r\n", status))
249249+250250+ // CATEGORIES - attendance mode
251251+ if event.Mode != "" {
252252+ ical.WriteString(fmt.Sprintf("CATEGORIES:%s\r\n", strings.ToUpper(event.Mode)))
253253+ }
254254+255255+ ical.WriteString("END:VEVENT\r\n")
256256+}
257257+258258+// fetchTasksForDID fetches tasks for a given DID using public read
259259+func (h *ICalHandler) fetchTasksForDID(ctx context.Context, did string) ([]*models.Task, error) {
260260+ // Resolve PDS endpoint for this DID
261261+ pds, err := h.resolvePDSEndpoint(ctx, did)
262262+ if err != nil {
263263+ return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err)
264264+ }
265265+266266+ // Build the XRPC URL for public read
267267+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s",
268268+ pds, did, TaskCollection)
269269+270270+ // Create and execute request
271271+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
272272+ if err != nil {
273273+ return nil, err
274274+ }
275275+276276+ client := &http.Client{Timeout: 10 * time.Second}
277277+ resp, err := client.Do(req)
278278+ if err != nil {
279279+ return nil, err
280280+ }
281281+ defer resp.Body.Close()
282282+283283+ if resp.StatusCode != http.StatusOK {
284284+ return nil, fmt.Errorf("XRPC error %d", resp.StatusCode)
285285+ }
286286+287287+ // Parse response
288288+ var result struct {
289289+ Records []struct {
290290+ Uri string `json:"uri"`
291291+ Cid string `json:"cid"`
292292+ Value map[string]interface{} `json:"value"`
293293+ } `json:"records"`
294294+ }
295295+296296+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
297297+ return nil, err
298298+ }
299299+300300+ // Convert to Task models and filter for tasks with due dates
301301+ tasks := make([]*models.Task, 0)
302302+ for _, record := range result.Records {
303303+ task := parseTaskFieldsForICal(record.Value)
304304+ task.URI = record.Uri
305305+ task.RKey = extractRKeyForICal(record.Uri)
306306+307307+ // Only include tasks with due dates
308308+ if task.DueDate != nil {
309309+ tasks = append(tasks, &task)
310310+ }
311311+ }
312312+313313+ return tasks, nil
314314+}
315315+316316+// generateTasksICalendar generates an iCal format string from tasks
317317+func (h *ICalHandler) generateTasksICalendar(did string, tasks []*models.Task) string {
318318+ var ical strings.Builder
319319+320320+ // iCal header
321321+ ical.WriteString("BEGIN:VCALENDAR\r\n")
322322+ ical.WriteString("VERSION:2.0\r\n")
323323+ ical.WriteString("PRODID:-//AT Todo//Tasks Feed//EN\r\n")
324324+ ical.WriteString(fmt.Sprintf("X-WR-CALNAME:AT Protocol Tasks - %s\r\n", sanitizeDID(did)))
325325+ ical.WriteString("X-WR-TIMEZONE:UTC\r\n")
326326+ ical.WriteString("CALSCALE:GREGORIAN\r\n")
327327+ ical.WriteString("METHOD:PUBLISH\r\n")
328328+329329+ // Add each task
330330+ for _, task := range tasks {
331331+ h.addTaskToICalendar(&ical, task)
332332+ }
333333+334334+ // iCal footer
335335+ ical.WriteString("END:VCALENDAR\r\n")
336336+337337+ return ical.String()
338338+}
339339+340340+// addTaskToICalendar adds a single task to the iCalendar
341341+func (h *ICalHandler) addTaskToICalendar(ical *strings.Builder, task *models.Task) {
342342+ ical.WriteString("BEGIN:VTODO\r\n")
343343+344344+ // UID - unique identifier (use AT Protocol URI)
345345+ ical.WriteString(fmt.Sprintf("UID:%s\r\n", task.URI))
346346+347347+ // DTSTAMP - when the task was created
348348+ ical.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", formatICalTime(task.CreatedAt)))
349349+350350+ // DUE - task due date
351351+ if task.DueDate != nil {
352352+ ical.WriteString(fmt.Sprintf("DUE:%s\r\n", formatICalTime(*task.DueDate)))
353353+ }
354354+355355+ // SUMMARY - task title
356356+ ical.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", escapeICalText(task.Title)))
357357+358358+ // DESCRIPTION - task description
359359+ if task.Description != "" {
360360+ ical.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", escapeICalText(task.Description)))
361361+ }
362362+363363+ // STATUS - task completion status
364364+ if task.Completed {
365365+ ical.WriteString("STATUS:COMPLETED\r\n")
366366+ if task.CompletedAt != nil {
367367+ ical.WriteString(fmt.Sprintf("COMPLETED:%s\r\n", formatICalTime(*task.CompletedAt)))
368368+ }
369369+ } else {
370370+ ical.WriteString("STATUS:NEEDS-ACTION\r\n")
371371+ }
372372+373373+ // PRIORITY - map task priority (0=none, 1-10)
374374+ // iCal priority: 1=high, 5=medium, 9=low
375375+ priority := 5 // default medium
376376+ ical.WriteString(fmt.Sprintf("PRIORITY:%d\r\n", priority))
377377+378378+ // CATEGORIES - tags
379379+ if len(task.Tags) > 0 {
380380+ categories := strings.Join(task.Tags, ",")
381381+ ical.WriteString(fmt.Sprintf("CATEGORIES:%s\r\n", escapeICalText(categories)))
382382+ }
383383+384384+ // URL - link to AT Todo
385385+ // We could link to the specific task on attodo.app if we had that route
386386+ // For now, just link to the dashboard
387387+ ical.WriteString("URL:https://attodo.app/app\r\n")
388388+389389+ ical.WriteString("END:VTODO\r\n")
390390+}
391391+392392+// formatICalTime formats a time.Time to iCal format (UTC)
393393+func formatICalTime(t time.Time) string {
394394+ // iCal format: 20060102T150405Z
395395+ return t.UTC().Format("20060102T150405Z")
396396+}
397397+398398+// escapeICalText escapes special characters in iCal text fields
399399+func escapeICalText(text string) string {
400400+ // Escape backslashes, commas, semicolons, and newlines
401401+ text = strings.ReplaceAll(text, "\\", "\\\\")
402402+ text = strings.ReplaceAll(text, ",", "\\,")
403403+ text = strings.ReplaceAll(text, ";", "\\;")
404404+ text = strings.ReplaceAll(text, "\n", "\\n")
405405+ text = strings.ReplaceAll(text, "\r", "")
406406+ return text
407407+}
408408+409409+// sanitizeDID creates a filename-safe version of a DID
410410+func sanitizeDID(did string) string {
411411+ // Remove "did:plc:" prefix and use just the identifier
412412+ parts := strings.Split(did, ":")
413413+ if len(parts) >= 3 {
414414+ return parts[2][:min(len(parts[2]), 16)] // Truncate to 16 chars for filename
415415+ }
416416+ return "calendar"
417417+}
418418+419419+func min(a, b int) int {
420420+ if a < b {
421421+ return a
422422+ }
423423+ return b
424424+}
425425+426426+// parseTaskFieldsForICal parses task fields from a record map
427427+func parseTaskFieldsForICal(record map[string]interface{}) models.Task {
428428+ task := models.Task{}
429429+430430+ if title, ok := record["title"].(string); ok {
431431+ task.Title = title
432432+ }
433433+ if desc, ok := record["description"].(string); ok {
434434+ task.Description = desc
435435+ }
436436+ if completed, ok := record["completed"].(bool); ok {
437437+ task.Completed = completed
438438+ }
439439+ if createdAt, ok := record["createdAt"].(string); ok {
440440+ if t, err := time.Parse(time.RFC3339, createdAt); err == nil {
441441+ task.CreatedAt = t
442442+ }
443443+ }
444444+ if completedAt, ok := record["completedAt"].(string); ok {
445445+ if t, err := time.Parse(time.RFC3339, completedAt); err == nil {
446446+ task.CompletedAt = &t
447447+ }
448448+ }
449449+ // Parse due date if present
450450+ if dueDate, ok := record["dueDate"].(string); ok {
451451+ if t, err := time.Parse(time.RFC3339, dueDate); err == nil {
452452+ task.DueDate = &t
453453+ }
454454+ }
455455+ // Parse tags if present
456456+ if tags, ok := record["tags"].([]interface{}); ok {
457457+ task.Tags = make([]string, 0, len(tags))
458458+ for _, tag := range tags {
459459+ if tagStr, ok := tag.(string); ok {
460460+ task.Tags = append(task.Tags, tagStr)
461461+ }
462462+ }
463463+ }
464464+465465+ return task
466466+}
467467+468468+// extractRKeyForICal extracts the record key from an AT URI
469469+func extractRKeyForICal(uri string) string {
470470+ parts := strings.Split(uri, "/")
471471+ if len(parts) > 0 {
472472+ return parts[len(parts)-1]
473473+ }
474474+ return ""
475475+}
+42-19
internal/handlers/settings.go
···5353 var err error
5454 sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
5555 var fetchErr error
5656- record, fetchErr = h.getRecord(r.Context(), s, SettingsRKey)
5656+ record, fetchErr = h.GetRecord(r.Context(), s, SettingsRKey)
5757 return fetchErr
5858 })
5959···6565 settings = models.DefaultNotificationSettings()
6666 } else {
6767 // Parse existing settings
6868- settings = parseSettingsRecord(record)
6868+ settings = ParseSettingsRecord(record)
6969 settings.RKey = SettingsRKey
7070 settings.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, SettingsCollection, SettingsRKey)
7171 }
···101101102102 // Convert to map for AT Protocol
103103 record := map[string]interface{}{
104104- "$type": SettingsCollection,
105105- "notifyOverdue": settings.NotifyOverdue,
106106- "notifyToday": settings.NotifyToday,
107107- "notifySoon": settings.NotifySoon,
108108- "hoursBefore": settings.HoursBefore,
109109- "checkFrequency": settings.CheckFrequency,
110110- "quietHoursEnabled": settings.QuietHoursEnabled,
111111- "quietStart": settings.QuietStart,
112112- "quietEnd": settings.QuietEnd,
113113- "pushEnabled": settings.PushEnabled,
114114- "taskInputCollapsed": settings.TaskInputCollapsed,
115115- "updatedAt": settings.UpdatedAt.Format(time.RFC3339),
104104+ "$type": SettingsCollection,
105105+ "notifyOverdue": settings.NotifyOverdue,
106106+ "notifyToday": settings.NotifyToday,
107107+ "notifySoon": settings.NotifySoon,
108108+ "hoursBefore": settings.HoursBefore,
109109+ "checkFrequency": settings.CheckFrequency,
110110+ "quietHoursEnabled": settings.QuietHoursEnabled,
111111+ "quietStart": settings.QuietStart,
112112+ "quietEnd": settings.QuietEnd,
113113+ "pushEnabled": settings.PushEnabled,
114114+ "taskInputCollapsed": settings.TaskInputCollapsed,
115115+ "calendarNotificationsEnabled": settings.CalendarNotificationsEnabled,
116116+ "calendarNotificationLeadTime": settings.CalendarNotificationLeadTime,
117117+ "updatedAt": settings.UpdatedAt.Format(time.RFC3339),
116118 }
117119118120 // Include appUsageHours if present
···120122 record["appUsageHours"] = settings.AppUsageHours
121123 }
122124125125+ // Include notificationSentHistory if present
126126+ if settings.NotificationSentHistory != nil {
127127+ record["notificationSentHistory"] = settings.NotificationSentHistory
128128+ }
129129+123130 // Check if settings record already exists
124131 var err error
125132 sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error {
126126- _, fetchErr := h.getRecord(r.Context(), s, SettingsRKey)
133133+ _, fetchErr := h.GetRecord(r.Context(), s, SettingsRKey)
127134 return fetchErr
128135 })
129136···161168 json.NewEncoder(w).Encode(settings)
162169}
163170164164-// parseSettingsRecord parses a settings record from AT Protocol
165165-func parseSettingsRecord(record map[string]interface{}) *models.NotificationSettings {
171171+// ParseSettingsRecord parses a settings record from AT Protocol
172172+func ParseSettingsRecord(record map[string]interface{}) *models.NotificationSettings {
166173 settings := models.DefaultNotificationSettings()
167174168175 if v, ok := record["notifyOverdue"].(bool); ok {
···195202 if v, ok := record["taskInputCollapsed"].(bool); ok {
196203 settings.TaskInputCollapsed = v
197204 }
205205+ if v, ok := record["calendarNotificationsEnabled"].(bool); ok {
206206+ settings.CalendarNotificationsEnabled = v
207207+ }
208208+ if v, ok := record["calendarNotificationLeadTime"].(string); ok {
209209+ settings.CalendarNotificationLeadTime = v
210210+ }
198211199212 // Parse appUsageHours if present
200213 if usageMap, ok := record["appUsageHours"].(map[string]interface{}); ok {
···206219 }
207220 }
208221222222+ // Parse notificationSentHistory if present
223223+ if historyMap, ok := record["notificationSentHistory"].(map[string]interface{}); ok {
224224+ settings.NotificationSentHistory = make(map[string]string)
225225+ for k, v := range historyMap {
226226+ if timestamp, ok := v.(string); ok {
227227+ settings.NotificationSentHistory[k] = timestamp
228228+ }
229229+ }
230230+ }
231231+209232 // Parse updatedAt
210233 if v, ok := record["updatedAt"].(string); ok {
211234 if t, err := time.Parse(time.RFC3339, v); err == nil {
···216239 return settings
217240}
218241219219-// getRecord retrieves a settings record using com.atproto.repo.getRecord
220220-func (h *SettingsHandler) getRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (map[string]interface{}, error) {
242242+// GetRecord retrieves a settings record using com.atproto.repo.getRecord
243243+func (h *SettingsHandler) GetRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (map[string]interface{}, error) {
221244 url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
222245 sess.PDS, sess.DID, SettingsCollection, rkey)
223246
+354
internal/jobs/calendar_notification_check.go
···11+package jobs
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "fmt"
77+ "log"
88+ "net/http"
99+ "time"
1010+1111+ "github.com/bluesky-social/indigo/atproto/identity"
1212+ "github.com/bluesky-social/indigo/atproto/syntax"
1313+ "github.com/shindakun/attodo/internal/database"
1414+ "github.com/shindakun/attodo/internal/handlers"
1515+ "github.com/shindakun/attodo/internal/models"
1616+ "github.com/shindakun/attodo/internal/push"
1717+ "github.com/shindakun/bskyoauth"
1818+)
1919+2020+// CalendarNotificationJob checks for upcoming calendar events and sends notifications
2121+type CalendarNotificationJob struct {
2222+ repo *database.NotificationRepo
2323+ client *bskyoauth.Client
2424+ sender *push.Sender
2525+ calendarHandler *handlers.CalendarHandler
2626+ settingsHandler *handlers.SettingsHandler
2727+}
2828+2929+// NewCalendarNotificationJob creates a new calendar notification job
3030+func NewCalendarNotificationJob(repo *database.NotificationRepo, client *bskyoauth.Client, sender *push.Sender, settingsHandler *handlers.SettingsHandler) *CalendarNotificationJob {
3131+ return &CalendarNotificationJob{
3232+ repo: repo,
3333+ client: client,
3434+ sender: sender,
3535+ calendarHandler: handlers.NewCalendarHandler(client),
3636+ settingsHandler: settingsHandler,
3737+ }
3838+}
3939+4040+// Name returns the job name
4141+func (c *CalendarNotificationJob) Name() string {
4242+ return "CalendarNotificationCheck"
4343+}
4444+4545+// Run executes the calendar notification check job
4646+func (c *CalendarNotificationJob) Run(ctx context.Context) error {
4747+ // Get all users with notifications enabled
4848+ users, err := c.repo.GetEnabledNotificationUsers()
4949+ if err != nil {
5050+ return fmt.Errorf("failed to get enabled users: %w", err)
5151+ }
5252+5353+ if len(users) == 0 {
5454+ log.Println("[CalendarNotificationCheck] No users with notifications enabled")
5555+ return nil
5656+ }
5757+5858+ log.Printf("[CalendarNotificationCheck] Checking calendar events for %d user(s)", len(users))
5959+6060+ // Check events for each user
6161+ for _, user := range users {
6262+ if err := c.checkUserEvents(ctx, user); err != nil {
6363+ log.Printf("[CalendarNotificationCheck] Error checking events for %s: %v", user.DID, err)
6464+ // Continue to next user instead of failing the whole job
6565+ continue
6666+ }
6767+ }
6868+6969+ return nil
7070+}
7171+7272+// checkUserEvents checks calendar events for a single user and sends notifications
7373+func (c *CalendarNotificationJob) checkUserEvents(ctx context.Context, user *models.NotificationUser) error {
7474+ // Get user's push subscriptions
7575+ subscriptions, err := c.repo.GetPushSubscriptionsByDID(user.DID)
7676+ if err != nil {
7777+ return fmt.Errorf("failed to get push subscriptions: %w", err)
7878+ }
7979+8080+ if len(subscriptions) == 0 {
8181+ log.Printf("[CalendarNotificationCheck] User %s has no push subscriptions", user.DID)
8282+ return nil
8383+ }
8484+8585+ // Get user's calendar notification settings
8686+ settings, err := c.getUserSettings(ctx, user.DID)
8787+ if err != nil {
8888+ log.Printf("[CalendarNotificationCheck] Failed to get settings for %s: %v", user.DID, err)
8989+ // Continue with defaults
9090+ settings = models.DefaultNotificationSettings()
9191+ }
9292+9393+ // Skip if calendar notifications are disabled
9494+ if !settings.CalendarNotificationsEnabled {
9595+ log.Printf("[CalendarNotificationCheck] Calendar notifications disabled for %s", user.DID)
9696+ return nil
9797+ }
9898+9999+ // Get notification lead time (default 1 hour)
100100+ leadTime := time.Hour
101101+ if settings.CalendarNotificationLeadTime != "" {
102102+ parsed, err := time.ParseDuration(settings.CalendarNotificationLeadTime)
103103+ if err == nil {
104104+ leadTime = parsed
105105+ }
106106+ }
107107+108108+ // Fetch upcoming events within the lead time window
109109+ events, err := c.fetchUpcomingEventsForUser(ctx, user.DID, leadTime)
110110+ if err != nil {
111111+ return fmt.Errorf("failed to fetch upcoming events: %w", err)
112112+ }
113113+114114+ if len(events) == 0 {
115115+ return nil // No upcoming events
116116+ }
117117+118118+ log.Printf("[CalendarNotificationCheck] Found %d upcoming events for %s", len(events), user.DID)
119119+120120+ // Send notifications for events
121121+ for _, event := range events {
122122+ if err := c.sendEventNotification(ctx, user.DID, event, leadTime, subscriptions); err != nil {
123123+ log.Printf("WARNING: Failed to send notification for event %s: %v", event.RKey, err)
124124+ continue
125125+ }
126126+ }
127127+128128+ return nil
129129+}
130130+131131+// getUserSettings fetches user settings without requiring a session (public read)
132132+func (c *CalendarNotificationJob) getUserSettings(ctx context.Context, did string) (*models.NotificationSettings, error) {
133133+ // Resolve PDS endpoint for this DID
134134+ pds, err := c.resolvePDSEndpoint(ctx, did)
135135+ if err != nil {
136136+ return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err)
137137+ }
138138+139139+ // Build the XRPC URL for public read (no auth needed)
140140+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s",
141141+ pds, did, handlers.SettingsCollection, handlers.SettingsRKey)
142142+143143+ // Create request
144144+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
145145+ if err != nil {
146146+ return nil, err
147147+ }
148148+149149+ // Make request (no auth needed for public reads)
150150+ client := &http.Client{Timeout: 10 * time.Second}
151151+ resp, err := client.Do(req)
152152+ if err != nil {
153153+ return nil, err
154154+ }
155155+ defer resp.Body.Close()
156156+157157+ if resp.StatusCode != http.StatusOK {
158158+ // Settings not found is okay, return defaults
159159+ return models.DefaultNotificationSettings(), nil
160160+ }
161161+162162+ // Parse response
163163+ var result struct {
164164+ Value map[string]interface{} `json:"value"`
165165+ }
166166+167167+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
168168+ return nil, err
169169+ }
170170+171171+ // Parse settings
172172+ settings := handlers.ParseSettingsRecord(result.Value)
173173+ return settings, nil
174174+}
175175+176176+// fetchUpcomingEventsForUser fetches events for a user without requiring a session (public read)
177177+func (c *CalendarNotificationJob) fetchUpcomingEventsForUser(ctx context.Context, did string, within time.Duration) ([]*models.CalendarEvent, error) {
178178+ // Resolve PDS endpoint for this DID
179179+ pds, err := c.resolvePDSEndpoint(ctx, did)
180180+ if err != nil {
181181+ return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err)
182182+ }
183183+184184+ // Build the XRPC URL for public read (no auth needed)
185185+ url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s",
186186+ pds, did, handlers.CalendarEventCollection)
187187+188188+ // Create request
189189+ req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
190190+ if err != nil {
191191+ return nil, err
192192+ }
193193+194194+ // Make request (no auth needed for public reads)
195195+ client := &http.Client{Timeout: 10 * time.Second}
196196+ resp, err := client.Do(req)
197197+ if err != nil {
198198+ return nil, err
199199+ }
200200+ defer resp.Body.Close()
201201+202202+ if resp.StatusCode != http.StatusOK {
203203+ return nil, fmt.Errorf("XRPC error %d", resp.StatusCode)
204204+ }
205205+206206+ // Parse response
207207+ var result struct {
208208+ Records []struct {
209209+ Uri string `json:"uri"`
210210+ Cid string `json:"cid"`
211211+ Value map[string]interface{} `json:"value"`
212212+ } `json:"records"`
213213+ }
214214+215215+ if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
216216+ return nil, err
217217+ }
218218+219219+ // Convert to CalendarEvent models and filter for upcoming events
220220+ upcomingEvents := make([]*models.CalendarEvent, 0)
221221+ for _, record := range result.Records {
222222+ event, err := models.ParseCalendarEvent(record.Value, record.Uri, record.Cid)
223223+ if err != nil {
224224+ log.Printf("WARNING: Failed to parse calendar event %s: %v", record.Uri, err)
225225+ continue
226226+ }
227227+228228+ // Filter for upcoming events within the time window
229229+ if event.StartsWithin(within) && !event.IsCancelled() {
230230+ upcomingEvents = append(upcomingEvents, event)
231231+ }
232232+ }
233233+234234+ return upcomingEvents, nil
235235+}
236236+237237+// resolvePDSEndpoint resolves the PDS endpoint for a given DID
238238+func (c *CalendarNotificationJob) resolvePDSEndpoint(ctx context.Context, did string) (string, error) {
239239+ dir := identity.DefaultDirectory()
240240+ atid, err := syntax.ParseAtIdentifier(did)
241241+ if err != nil {
242242+ return "", err
243243+ }
244244+245245+ ident, err := dir.Lookup(ctx, *atid)
246246+ if err != nil {
247247+ return "", err
248248+ }
249249+250250+ return ident.PDSEndpoint(), nil
251251+}
252252+253253+// sendEventNotification sends a notification for an event
254254+func (c *CalendarNotificationJob) sendEventNotification(ctx context.Context, did string, event *models.CalendarEvent, leadTime time.Duration, subscriptions []*models.PushSubscription) error {
255255+ // Check if we've already sent a notification for this event
256256+ eventURI := event.URI
257257+ recent, err := c.repo.GetRecentNotification(did, eventURI, 24) // Don't spam within 24 hours
258258+ if err != nil {
259259+ log.Printf("WARNING: Error checking notification history: %v", err)
260260+ }
261261+ if recent != nil {
262262+ // Already notified recently, skip
263263+ log.Printf("INFO: Skipping notification for event %s - already sent within 24h", event.RKey)
264264+ return nil
265265+ }
266266+267267+ // Build notification message
268268+ title := fmt.Sprintf("Upcoming Event: %s", event.Name)
269269+ body := c.buildNotificationBody(event, leadTime)
270270+271271+ notification := &push.Notification{
272272+ Title: title,
273273+ Body: body,
274274+ Icon: "/static/icon-192.png",
275275+ Badge: "/static/icon-192.png",
276276+ Tag: fmt.Sprintf("calendar-event-%s", event.RKey),
277277+ Data: map[string]interface{}{
278278+ "type": "calendar_event",
279279+ "eventURI": eventURI,
280280+ "url": event.SmokesignalURL(),
281281+ },
282282+ }
283283+284284+ // Send to all subscriptions
285285+ successCount, errors := c.sender.SendToAll(subscriptions, notification)
286286+ log.Printf("INFO: Sent calendar notification for event %s to %d/%d subscriptions", event.RKey, successCount, len(subscriptions))
287287+288288+ // Record notification history
289289+ status := "sent"
290290+ var errMsg string
291291+ if successCount == 0 {
292292+ status = "failed"
293293+ if len(errors) > 0 {
294294+ errMsg = fmt.Sprintf("%v", errors[0])
295295+ }
296296+ } else if len(errors) > 0 {
297297+ errMsg = fmt.Sprintf("Sent to %d/%d subscriptions. Errors: %v", successCount, len(subscriptions), errors[0])
298298+ }
299299+300300+ history := &models.NotificationHistory{
301301+ DID: did,
302302+ TaskURI: eventURI, // Reuse TaskURI field for event URI
303303+ NotificationType: "calendar_event",
304304+ Status: status,
305305+ ErrorMessage: errMsg,
306306+ }
307307+ if err := c.repo.CreateNotificationHistory(history); err != nil {
308308+ log.Printf("WARNING: Failed to create notification history: %v", err)
309309+ }
310310+311311+ if successCount == 0 {
312312+ return fmt.Errorf("failed to send to all subscriptions: %v", errors)
313313+ }
314314+315315+ return nil
316316+}
317317+318318+// buildNotificationBody builds the notification message body
319319+func (c *CalendarNotificationJob) buildNotificationBody(event *models.CalendarEvent, leadTime time.Duration) string {
320320+ if event.StartsAt == nil {
321321+ return event.Description
322322+ }
323323+324324+ timeUntil := time.Until(*event.StartsAt)
325325+326326+ var timeMessage string
327327+ if timeUntil < time.Hour {
328328+ minutes := int(timeUntil.Minutes())
329329+ timeMessage = fmt.Sprintf("starts in %d minutes", minutes)
330330+ } else if timeUntil < 24*time.Hour {
331331+ hours := int(timeUntil.Hours())
332332+ timeMessage = fmt.Sprintf("starts in %d hours", hours)
333333+ } else {
334334+ days := int(timeUntil.Hours() / 24)
335335+ timeMessage = fmt.Sprintf("starts in %d days", days)
336336+ }
337337+338338+ // Add mode information
339339+ var modeInfo string
340340+ switch event.Mode {
341341+ case models.AttendanceModeVirtual:
342342+ modeInfo = "💻 Virtual event"
343343+ case models.AttendanceModeInPerson:
344344+ modeInfo = "📍 In-person event"
345345+ case models.AttendanceModeHybrid:
346346+ modeInfo = "🔄 Hybrid event"
347347+ }
348348+349349+ if modeInfo != "" {
350350+ return fmt.Sprintf("%s %s", modeInfo, timeMessage)
351351+ }
352352+353353+ return timeMessage
354354+}
+369
internal/models/calendar.go
···11+package models
22+33+import (
44+ "fmt"
55+ "strings"
66+ "time"
77+)
88+99+// Event status constants
1010+const (
1111+ EventStatusPlanned = "planned" // Created but not finalized
1212+ EventStatusScheduled = "scheduled" // Created and finalized
1313+ EventStatusRescheduled = "rescheduled" // Event time/details changed
1414+ EventStatusCancelled = "cancelled" // Event removed
1515+ EventStatusPostponed = "postponed" // No new date set
1616+)
1717+1818+// Attendance mode constants
1919+const (
2020+ AttendanceModeVirtual = "virtual" // Online only
2121+ AttendanceModeInPerson = "in-person" // Physical location only
2222+ AttendanceModeHybrid = "hybrid" // Both online and in-person
2323+)
2424+2525+// RSVP status constants
2626+const (
2727+ RSVPStatusInterested = "interested" // Interested in the event
2828+ RSVPStatusGoing = "going" // Going to the event
2929+ RSVPStatusNotGoing = "notgoing" // Not going to the event
3030+)
3131+3232+// CalendarEvent represents a community.lexicon.calendar.event record
3333+type CalendarEvent struct {
3434+ Name string `json:"name"`
3535+ Description string `json:"description,omitempty"`
3636+ CreatedAt time.Time `json:"createdAt"`
3737+ StartsAt *time.Time `json:"startsAt,omitempty"`
3838+ EndsAt *time.Time `json:"endsAt,omitempty"`
3939+ Mode string `json:"mode,omitempty"` // hybrid, in-person, virtual
4040+ Status string `json:"status,omitempty"` // planned, scheduled, etc.
4141+ Locations []Location `json:"locations,omitempty"`
4242+ URIs []string `json:"uris,omitempty"`
4343+4444+ // AT Protocol metadata
4545+ RKey string `json:"rKey,omitempty"`
4646+ URI string `json:"uri,omitempty"`
4747+ CID string `json:"cid,omitempty"`
4848+}
4949+5050+// Location represents a location where an event takes place
5151+type Location struct {
5252+ Name string `json:"name,omitempty"`
5353+ Address string `json:"address,omitempty"`
5454+ Lat float64 `json:"lat,omitempty"`
5555+ Lon float64 `json:"lon,omitempty"`
5656+}
5757+5858+// CalendarRSVP represents a community.lexicon.calendar.rsvp record
5959+type CalendarRSVP struct {
6060+ Subject *StrongRef `json:"subject"` // Reference to event
6161+ Status string `json:"status"` // interested, going, notgoing
6262+6363+ // AT Protocol metadata
6464+ RKey string `json:"-"`
6565+ URI string `json:"-"`
6666+ CID string `json:"-"`
6767+}
6868+6969+// StrongRef represents a com.atproto.repo.strongRef
7070+type StrongRef struct {
7171+ URI string `json:"uri"`
7272+ CID string `json:"cid"`
7373+}
7474+7575+// IsUpcoming returns true if the event starts in the future
7676+func (e *CalendarEvent) IsUpcoming() bool {
7777+ if e.StartsAt == nil {
7878+ return false
7979+ }
8080+ return e.StartsAt.After(time.Now())
8181+}
8282+8383+// IsPast returns true if the event has already ended
8484+func (e *CalendarEvent) IsPast() bool {
8585+ if e.EndsAt != nil {
8686+ return e.EndsAt.Before(time.Now())
8787+ }
8888+ if e.StartsAt != nil {
8989+ return e.StartsAt.Before(time.Now())
9090+ }
9191+ return false
9292+}
9393+9494+// IsCancelled returns true if the event is cancelled or postponed
9595+func (e *CalendarEvent) IsCancelled() bool {
9696+ return e.Status == EventStatusCancelled || e.Status == EventStatusPostponed
9797+}
9898+9999+// StartsWithin returns true if the event starts within the given duration
100100+func (e *CalendarEvent) StartsWithin(d time.Duration) bool {
101101+ if e.StartsAt == nil {
102102+ return false
103103+ }
104104+ now := time.Now()
105105+ return e.StartsAt.After(now) && e.StartsAt.Before(now.Add(d))
106106+}
107107+108108+// FormatStatus returns a human-readable status string
109109+func (e *CalendarEvent) FormatStatus() string {
110110+ switch e.Status {
111111+ case EventStatusPlanned:
112112+ return "Planned"
113113+ case EventStatusScheduled:
114114+ return "Scheduled"
115115+ case EventStatusRescheduled:
116116+ return "Rescheduled"
117117+ case EventStatusCancelled:
118118+ return "Cancelled"
119119+ case EventStatusPostponed:
120120+ return "Postponed"
121121+ case "": // Empty status
122122+ return ""
123123+ default:
124124+ return ""
125125+ }
126126+}
127127+128128+// HasKnownStatus returns true if the event has a recognized status
129129+func (e *CalendarEvent) HasKnownStatus() bool {
130130+ switch e.Status {
131131+ case EventStatusPlanned, EventStatusScheduled, EventStatusRescheduled, EventStatusCancelled, EventStatusPostponed:
132132+ return true
133133+ default:
134134+ return false
135135+ }
136136+}
137137+138138+// FormatMode returns a human-readable attendance mode string
139139+func (e *CalendarEvent) FormatMode() string {
140140+ switch e.Mode {
141141+ case AttendanceModeVirtual:
142142+ return "Virtual"
143143+ case AttendanceModeInPerson:
144144+ return "In Person"
145145+ case AttendanceModeHybrid:
146146+ return "Hybrid"
147147+ default:
148148+ return ""
149149+ }
150150+}
151151+152152+// FormatRSVPStatus returns a human-readable RSVP status string
153153+func (r *CalendarRSVP) FormatStatus() string {
154154+ switch r.Status {
155155+ case RSVPStatusInterested:
156156+ return "Interested"
157157+ case RSVPStatusGoing:
158158+ return "Going"
159159+ case RSVPStatusNotGoing:
160160+ return "Not Going"
161161+ default:
162162+ return "Unknown"
163163+ }
164164+}
165165+166166+// ExtractDID extracts the DID from the event URI
167167+// Example: at://did:plc:xxx/community.lexicon.calendar.event/abc123 -> did:plc:xxx
168168+func (e *CalendarEvent) ExtractDID() string {
169169+ if e.URI == "" {
170170+ return ""
171171+ }
172172+173173+ // Remove "at://" prefix
174174+ uri := e.URI
175175+ if len(uri) > 5 && uri[:5] == "at://" {
176176+ uri = uri[5:]
177177+ }
178178+179179+ // Find first slash to get DID
180180+ slashIndex := -1
181181+ for i := 0; i < len(uri); i++ {
182182+ if uri[i] == '/' {
183183+ slashIndex = i
184184+ break
185185+ }
186186+ }
187187+188188+ if slashIndex > 0 {
189189+ return uri[:slashIndex]
190190+ }
191191+192192+ return ""
193193+}
194194+195195+// SmokesignalURL returns the Smokesignal event URL if this is a Smokesignal event
196196+// Returns empty string if not a Smokesignal event or if URI cannot be parsed
197197+func (e *CalendarEvent) SmokesignalURL() string {
198198+ did := e.ExtractDID()
199199+ if did == "" || e.RKey == "" {
200200+ return ""
201201+ }
202202+203203+ // Smokesignal URL format: https://smokesignal.events/{did}/{rkey}
204204+ return fmt.Sprintf("https://smokesignal.events/%s/%s", did, e.RKey)
205205+}
206206+207207+// TruncatedDescription returns a truncated version of the description
208208+func (e *CalendarEvent) TruncatedDescription(maxLen int) string {
209209+ if len(e.Description) <= maxLen {
210210+ return e.Description
211211+ }
212212+ return e.Description[:maxLen] + "..."
213213+}
214214+215215+// ParseCalendarEvent parses an AT Protocol record into a CalendarEvent
216216+func ParseCalendarEvent(record map[string]interface{}, uri, cid string) (*CalendarEvent, error) {
217217+ event := &CalendarEvent{
218218+ URI: uri,
219219+ CID: cid,
220220+ RKey: extractRKey(uri),
221221+ }
222222+223223+ // Required: name
224224+ if name, ok := record["name"].(string); ok {
225225+ event.Name = name
226226+ } else {
227227+ return nil, fmt.Errorf("missing required field: name")
228228+ }
229229+230230+ // Required: createdAt
231231+ if createdAtStr, ok := record["createdAt"].(string); ok {
232232+ createdAt, err := time.Parse(time.RFC3339, createdAtStr)
233233+ if err != nil {
234234+ return nil, fmt.Errorf("invalid createdAt: %w", err)
235235+ }
236236+ event.CreatedAt = createdAt
237237+ } else {
238238+ return nil, fmt.Errorf("missing required field: createdAt")
239239+ }
240240+241241+ // Optional: description
242242+ if description, ok := record["description"].(string); ok {
243243+ event.Description = description
244244+ }
245245+246246+ // Optional: startsAt
247247+ if startsAtStr, ok := record["startsAt"].(string); ok {
248248+ startsAt, err := time.Parse(time.RFC3339, startsAtStr)
249249+ if err != nil {
250250+ return nil, fmt.Errorf("invalid startsAt: %w", err)
251251+ }
252252+ event.StartsAt = &startsAt
253253+ }
254254+255255+ // Optional: endsAt
256256+ if endsAtStr, ok := record["endsAt"].(string); ok {
257257+ endsAt, err := time.Parse(time.RFC3339, endsAtStr)
258258+ if err != nil {
259259+ return nil, fmt.Errorf("invalid endsAt: %w", err)
260260+ }
261261+ event.EndsAt = &endsAt
262262+ }
263263+264264+ // Optional: mode
265265+ if mode, ok := record["mode"].(string); ok {
266266+ // Strip lexicon prefix if present (e.g., "community.lexicon.calendar.event#hybrid" -> "hybrid")
267267+ if idx := strings.LastIndex(mode, "#"); idx != -1 {
268268+ event.Mode = mode[idx+1:]
269269+ } else {
270270+ event.Mode = mode
271271+ }
272272+ }
273273+274274+ // Optional: status
275275+ if status, ok := record["status"].(string); ok {
276276+ // Strip lexicon prefix if present (e.g., "community.lexicon.calendar.event#scheduled" -> "scheduled")
277277+ if idx := strings.LastIndex(status, "#"); idx != -1 {
278278+ event.Status = status[idx+1:]
279279+ } else {
280280+ event.Status = status
281281+ }
282282+ }
283283+284284+ // Optional: locations
285285+ if locationsRaw, ok := record["locations"].([]interface{}); ok {
286286+ for _, locRaw := range locationsRaw {
287287+ if locMap, ok := locRaw.(map[string]interface{}); ok {
288288+ loc := Location{}
289289+ if name, ok := locMap["name"].(string); ok {
290290+ loc.Name = name
291291+ }
292292+ if address, ok := locMap["address"].(string); ok {
293293+ loc.Address = address
294294+ }
295295+ if lat, ok := locMap["lat"].(float64); ok {
296296+ loc.Lat = lat
297297+ }
298298+ if lon, ok := locMap["lon"].(float64); ok {
299299+ loc.Lon = lon
300300+ }
301301+ event.Locations = append(event.Locations, loc)
302302+ }
303303+ }
304304+ }
305305+306306+ // Optional: uris
307307+ if urisRaw, ok := record["uris"].([]interface{}); ok {
308308+ for _, uriRaw := range urisRaw {
309309+ if uri, ok := uriRaw.(string); ok {
310310+ event.URIs = append(event.URIs, uri)
311311+ }
312312+ }
313313+ }
314314+315315+ return event, nil
316316+}
317317+318318+// ParseCalendarRSVP parses an AT Protocol record into a CalendarRSVP
319319+func ParseCalendarRSVP(record map[string]interface{}, uri, cid string) (*CalendarRSVP, error) {
320320+ rsvp := &CalendarRSVP{
321321+ URI: uri,
322322+ CID: cid,
323323+ RKey: extractRKey(uri),
324324+ }
325325+326326+ // Required: subject
327327+ if subjectRaw, ok := record["subject"].(map[string]interface{}); ok {
328328+ subject := &StrongRef{}
329329+ if subjectURI, ok := subjectRaw["uri"].(string); ok {
330330+ subject.URI = subjectURI
331331+ } else {
332332+ return nil, fmt.Errorf("missing subject.uri")
333333+ }
334334+ if subjectCID, ok := subjectRaw["cid"].(string); ok {
335335+ subject.CID = subjectCID
336336+ } else {
337337+ return nil, fmt.Errorf("missing subject.cid")
338338+ }
339339+ rsvp.Subject = subject
340340+ } else {
341341+ return nil, fmt.Errorf("missing required field: subject")
342342+ }
343343+344344+ // Required: status
345345+ if status, ok := record["status"].(string); ok {
346346+ // Strip lexicon prefix if present (e.g., "community.lexicon.calendar.rsvp#going" -> "going")
347347+ if idx := strings.LastIndex(status, "#"); idx != -1 {
348348+ rsvp.Status = status[idx+1:]
349349+ } else {
350350+ rsvp.Status = status
351351+ }
352352+ } else {
353353+ return nil, fmt.Errorf("missing required field: status")
354354+ }
355355+356356+ return rsvp, nil
357357+}
358358+359359+// extractRKey extracts the record key from an AT URI
360360+// Example: at://did:plc:xxx/community.lexicon.calendar.event/abc123 -> abc123
361361+func extractRKey(uri string) string {
362362+ // Simple extraction - find last slash and return everything after it
363363+ for i := len(uri) - 1; i >= 0; i-- {
364364+ if uri[i] == '/' {
365365+ return uri[i+1:]
366366+ }
367367+ }
368368+ return ""
369369+}