The attodo.app, uhh... app.

ics feed

+44 -11
cmd/server/main.go
··· 6 6 "net/http" 7 7 "os" 8 8 "os/signal" 9 + "strings" 9 10 "syscall" 10 11 "time" 11 12 ··· 48 49 listHandler := handlers.NewListHandler(authHandler.Client()) 49 50 settingsHandler := handlers.NewSettingsHandler(authHandler.Client()) 50 51 pushHandler := handlers.NewPushHandler(notificationRepo) 52 + calendarHandler := handlers.NewCalendarHandler(authHandler.Client()) 53 + icalHandler := handlers.NewICalHandler(authHandler.Client()) 51 54 52 55 // Initialize Stripe client and supporter handler (only if Stripe keys are configured) 53 56 var supporterHandler *handlers.SupporterHandler ··· 64 67 65 68 // Initialize push notification sender (only if VAPID keys are configured) 66 69 var pushSender *push.Sender 67 - var jobRunner *jobs.Runner 70 + var taskJobRunner *jobs.Runner 71 + var calendarJobRunner *jobs.Runner 68 72 if cfg.VAPIDPublicKey != "" && cfg.VAPIDPrivateKey != "" { 69 73 pushSender = push.NewSender(cfg.VAPIDPublicKey, cfg.VAPIDPrivateKey, cfg.VAPIDSubscriber) 70 74 pushHandler.SetSender(pushSender) 71 75 log.Println("Push notification sender initialized") 72 76 73 - // Initialize background job runner (check every 5 minutes) 74 - jobRunner = jobs.NewRunner(5 * time.Minute) 75 - 76 - // Create and register notification check job 77 + // Initialize background job runner for task notifications (check every 5 minutes) 78 + taskJobRunner = jobs.NewRunner(5 * time.Minute) 77 79 notificationJob := jobs.NewNotificationCheckJob(notificationRepo, authHandler.Client(), pushSender) 78 - jobRunner.AddJob(notificationJob) 80 + taskJobRunner.AddJob(notificationJob) 81 + taskJobRunner.Start() 82 + log.Println("Task notification job runner started (5 minute interval)") 79 83 80 - // Start job runner 81 - jobRunner.Start() 82 - log.Println("Background job runner started") 84 + // Initialize background job runner for calendar notifications (check every 30 minutes) 85 + calendarJobRunner = jobs.NewRunner(30 * time.Minute) 86 + calendarNotificationJob := jobs.NewCalendarNotificationJob(notificationRepo, authHandler.Client(), pushSender, settingsHandler) 87 + calendarJobRunner.AddJob(calendarNotificationJob) 88 + calendarJobRunner.Start() 89 + log.Println("Calendar notification job runner started (30 minute interval)") 83 90 } else { 84 91 log.Println("VAPID keys not configured - push notifications disabled") 85 92 log.Println("Run 'go run ./cmd/vapid' to generate VAPID keys") ··· 137 144 mux.HandleFunc("/list/", listHandler.HandlePublicListView) 138 145 logRoute("GET /list/*") 139 146 147 + // Public iCal feed routes 148 + mux.HandleFunc("/calendar/feed/", icalHandler.GenerateCalendarFeed) 149 + logRoute("GET /calendar/feed/{did}/events.ics") 150 + mux.HandleFunc("/tasks/feed/", icalHandler.GenerateTasksFeed) 151 + logRoute("GET /tasks/feed/{did}/tasks.ics") 152 + 140 153 // Protected routes 141 154 mux.Handle("/app", authMiddleware.RequireAuth(http.HandlerFunc(handleDashboard))) 142 155 logRoute("GET /app [protected]") ··· 162 175 logRoute("POST /app/push/test [protected]") 163 176 mux.Handle("/app/push/check", authMiddleware.RequireAuth(http.HandlerFunc(pushHandler.HandleCheckTasks))) 164 177 logRoute("POST /app/push/check [protected]") 178 + 179 + // Calendar routes 180 + mux.Handle("/app/calendar/events", authMiddleware.RequireAuth(http.HandlerFunc(calendarHandler.ListEvents))) 181 + logRoute("GET /app/calendar/events [protected]") 182 + mux.Handle("/app/calendar/upcoming", authMiddleware.RequireAuth(http.HandlerFunc(calendarHandler.ListUpcomingEvents))) 183 + logRoute("GET /app/calendar/upcoming [protected]") 184 + // Note: These must be after the specific routes above to avoid matching them 185 + mux.Handle("/app/calendar/events/", authMiddleware.RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 186 + // Route to either GetEvent or GetEventRSVPs based on URL 187 + if strings.HasSuffix(r.URL.Path, "/rsvps") { 188 + calendarHandler.GetEventRSVPs(w, r) 189 + } else { 190 + calendarHandler.GetEvent(w, r) 191 + } 192 + }))) 193 + logRoute("GET /app/calendar/events/:rkey [protected]") 194 + logRoute("GET /app/calendar/events/:rkey/rsvps [protected]") 165 195 166 196 // Supporter routes (only if Stripe is configured) 167 197 if supporterHandler != nil { ··· 213 243 log.Println("Shutting down gracefully...") 214 244 215 245 // Stop background jobs 216 - if jobRunner != nil { 217 - jobRunner.Stop() 246 + if taskJobRunner != nil { 247 + taskJobRunner.Stop() 248 + } 249 + if calendarJobRunner != nil { 250 + calendarJobRunner.Stop() 218 251 } 219 252 220 253 log.Println("Shutdown complete")
+264
docs/features.md
··· 12 12 - [User Interface Preferences](#user-interface-preferences) 13 13 - [Notifications](#notifications) 14 14 - [Lists](#lists) 15 + - [Calendar Events](#calendar-events) 15 16 - [Progressive Web App](#progressive-web-app) 16 17 17 18 --- ··· 502 503 4. Share with anyone 503 504 504 505 **Shared lists are public** - anyone with the link can view tasks in that list (but not edit them). 506 + 507 + --- 508 + 509 + ## Calendar Events 510 + 511 + 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). 512 + 513 + ### What are Calendar Events? 514 + 515 + 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: 516 + 517 + - **Public or shared**: Visible to others in the AT Protocol network 518 + - **Time-specific**: Have defined start/end times 519 + - **Social**: Support RSVPs and attendance tracking 520 + - **External**: Created by dedicated calendar apps like Smokesignal 521 + 522 + ### Viewing Calendar Events 523 + 524 + **Access calendar events:** 525 + 1. Navigate to the **📅 Events** tab in your dashboard 526 + 2. Choose between two views: 527 + - **Upcoming Events**: Shows events in the next 7 days 528 + - **All Events**: Shows all calendar events 529 + 530 + **Event display includes:** 531 + - 📅 Event name and description 532 + - 🕐 Start and end times (in your local timezone) 533 + - 📍 Location information (for in-person events) 534 + - 💻 Attendance mode (Virtual, In Person, or Hybrid) 535 + - 🔗 Links to related resources 536 + - 💨 Direct link to view on Smokesignal 537 + 538 + ### Event Details 539 + 540 + **View detailed information:** 541 + 1. Click "View Details" on any event card 542 + 2. A modal opens showing: 543 + - Full event description 544 + - Complete date/time information 545 + - Event status (Planned, Scheduled, Rescheduled, Cancelled) 546 + - Location details with addresses 547 + - Attendance mode with visual indicators 548 + - Related URLs and resources 549 + - Your personal RSVP status (if you've RSVP'd) 550 + - Link to view all RSVPs on Smokesignal 551 + 552 + **Visual indicators:** 553 + - 💻 **Virtual**: Online-only events 554 + - 📍 **In Person**: Physical location events 555 + - 🔄 **Hybrid**: Both online and in-person options 556 + 557 + ### Event Sources 558 + 559 + AT Todo displays events from two sources: 560 + 561 + 1. **Your own events**: Calendar events in your AT Protocol repository 562 + 2. **RSVP'd events**: Events you've RSVP'd to via calendar apps 563 + 564 + All events are read-only in AT Todo. To create or manage events, use a dedicated calendar app like [Smokesignal](https://smokesignal.events). 565 + 566 + ### Calendar Notifications 567 + 568 + Stay informed about upcoming events with automatic notifications. 569 + 570 + **Enabling calendar notifications:** 571 + 1. Open Settings 572 + 2. Scroll to "Calendar Notification Settings" 573 + 3. Toggle "Enable calendar event notifications" 574 + 4. Set your preferred lead time (default: 1 hour before event) 575 + 5. Ensure push notifications are enabled 576 + 577 + **Notification timing:** 578 + - Choose how far in advance to be notified (e.g., "1h", "30m", "2h") 579 + - Notifications sent when events fall within your lead time window 580 + - Default: 1 hour before event start time 581 + - Respects quiet hours settings 582 + 583 + **What you'll receive:** 584 + ``` 585 + Upcoming Event: Community Meetup 586 + 💻 Virtual event starts in 1 hour 587 + ``` 588 + 589 + **Smart notification features:** 590 + - ✅ Shows event mode (Virtual/In-Person/Hybrid) 591 + - ✅ Calculates time until event starts 592 + - ✅ Links to Smokesignal for full details 593 + - ✅ Sent to all registered devices 594 + - ✅ Won't spam (24-hour cooldown per event) 595 + 596 + **Notification frequency:** 597 + Calendar events are checked every 30 minutes for upcoming events (separate from task notifications which run every 5 minutes). 598 + 599 + ### RSVPs and Attendance 600 + 601 + **Viewing your RSVP:** 602 + - Open event details to see your RSVP status 603 + - Status indicators: 604 + - ✓ **Going** (green) 605 + - ⓘ **Interested** (blue) 606 + - ✗ **Not Going** (red) 607 + 608 + **Managing RSVPs:** 609 + RSVPs are managed through calendar applications like Smokesignal. AT Todo displays your RSVP status but doesn't provide RSVP functionality. 610 + 611 + **To RSVP to an event:** 612 + 1. Click the 💨 Smokesignal link in the event details 613 + 2. RSVP on Smokesignal 614 + 3. Your RSVP status will appear in AT Todo automatically 615 + 616 + ### Event Status Badges 617 + 618 + Events display status badges to indicate their current state: 619 + 620 + - **Scheduled** (green): Confirmed and finalized 621 + - **Planned** (blue): Created but not yet finalized 622 + - **Rescheduled** (orange): Date/time has been changed 623 + - **Cancelled** (red): Event has been cancelled 624 + - **Postponed** (red): Event delayed with no new date 625 + 626 + Cancelled and postponed events still appear in your calendar but are clearly marked. 627 + 628 + ### Integration with Smokesignal 629 + 630 + AT Todo integrates seamlessly with [Smokesignal](https://smokesignal.events), the premier AT Protocol calendar application. 631 + 632 + **Smokesignal features:** 633 + - Create and manage calendar events 634 + - RSVP to events 635 + - View all RSVPs and attendees 636 + - Share event links 637 + - Event discovery and search 638 + 639 + **Quick access:** 640 + - Click the 💨 emoji next to any event name 641 + - Opens the event directly on Smokesignal 642 + - View full attendee list 643 + - Manage your RSVP 644 + - Share event with others 645 + 646 + ### Google Calendar & iCal Subscription 647 + 648 + Subscribe to your AT Protocol calendar events **and tasks** in Google Calendar, Apple Calendar, Outlook, or any calendar app that supports iCal feeds. 649 + 650 + **Getting your iCal feed URLs:** 651 + 652 + AT Todo provides two separate feeds: 653 + 654 + **Calendar Events Feed:** 655 + ``` 656 + https://attodo.app/calendar/feed/{your-did}/events.ics 657 + ``` 658 + 659 + **Tasks Feed (tasks with due dates):** 660 + ``` 661 + https://attodo.app/tasks/feed/{your-did}/tasks.ics 662 + ``` 663 + 664 + To find your DID: 665 + 1. Open AT Todo and navigate to the 📅 Events tab 666 + 2. Open your browser's developer console (F12) 667 + 3. Your DID appears in the console when loading events, or 668 + 4. Check your AT Protocol profile on Bluesky 669 + 670 + **Subscribing in Google Calendar:** 671 + 672 + You can subscribe to both feeds separately: 673 + 674 + **For Events:** 675 + 1. Open [Google Calendar](https://calendar.google.com) 676 + 2. Click the **+** next to "Other calendars" 677 + 3. Select **"From URL"** 678 + 4. Paste your events feed URL: `https://attodo.app/calendar/feed/{your-did}/events.ics` 679 + 5. Click **"Add calendar"** 680 + 681 + **For Tasks:** 682 + 1. Click the **+** next to "Other calendars" again 683 + 2. Select **"From URL"** 684 + 3. Paste your tasks feed URL: `https://attodo.app/tasks/feed/{your-did}/tasks.ics` 685 + 4. Click **"Add calendar"** 686 + 687 + Your AT Protocol events and tasks will now appear in Google Calendar and sync automatically! 688 + 689 + **Subscribing in Apple Calendar:** 690 + 691 + Subscribe to both feeds for complete coverage: 692 + 693 + 1. Open Calendar app 694 + 2. Go to **File** → **New Calendar Subscription** 695 + 3. Paste your events feed URL, click **Subscribe** 696 + 4. Choose auto-refresh frequency (recommended: every hour) 697 + 5. Repeat for tasks feed URL 698 + 699 + **Subscribing in Outlook:** 700 + 701 + Subscribe to both feeds separately: 702 + 703 + 1. Open Outlook 704 + 2. Go to **File** → **Account Settings** → **Internet Calendars** 705 + 3. Click **New** 706 + 4. Paste your events feed URL, click **Add** 707 + 5. Repeat for tasks feed URL 708 + 709 + **What syncs from Events Feed:** 710 + - ✅ Event names and descriptions 711 + - ✅ Start and end times (in your timezone) 712 + - ✅ Location information 713 + - ✅ Event status (confirmed, tentative, cancelled) 714 + - ✅ Links to Smokesignal 715 + - ✅ Event mode (virtual/in-person/hybrid) as categories 716 + 717 + **What syncs from Tasks Feed:** 718 + - ✅ Task titles and descriptions 719 + - ✅ Due dates (in your timezone) 720 + - ✅ Completion status 721 + - ✅ Tags as categories 722 + - ✅ Only tasks with due dates (no due date = not included) 723 + 724 + **Auto-refresh:** 725 + - Calendar apps check for updates periodically 726 + - Google Calendar: Every few hours 727 + - Apple Calendar: Configurable (hourly recommended) 728 + - Outlook: Configurable 729 + 730 + **Privacy note:** 731 + 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. 732 + 733 + **Tips:** 734 + - Subscribe to both feeds to see your complete schedule in one place 735 + - Tasks appear as "todos" in most calendar apps 736 + - Completed tasks remain in the feed with completion status 737 + - Use separate calendar colors to distinguish events from tasks 738 + 739 + ### Event Timezone Handling 740 + 741 + All event times are automatically converted to your local timezone: 742 + - **Displayed**: In your browser's timezone 743 + - **Stored**: In UTC in AT Protocol 744 + - **Notifications**: Respect your local time 745 + - **Created date**: Shows when the event was created (in local time) 746 + 747 + ### Read-Only Access 748 + 749 + **Important notes:** 750 + - 📖 Calendar events in AT Todo are **read-only** 751 + - ✏️ To create or edit events, use a calendar app like Smokesignal 752 + - 🔄 AT Todo automatically syncs events from your AT Protocol repository 753 + - 📅 Perfect for viewing your event schedule alongside your tasks 754 + 755 + ### Calendar Best Practices 756 + 757 + **Organizing your calendar:** 758 + 1. **Use Smokesignal** for event creation and management 759 + 2. **View in AT Todo** to see events alongside tasks 760 + 3. **Enable notifications** to stay informed 761 + 4. **RSVP on Smokesignal** to indicate attendance 762 + 5. **Share event links** from Smokesignal with others 763 + 764 + **Integrating with tasks:** 765 + - Create tasks for event preparation (e.g., "Prepare presentation for Monday meetup") 766 + - Use tags to link tasks to events (e.g., #meetup, #conference) 767 + - Set task due dates relative to event times 768 + - Enable both task and calendar notifications 505 769 506 770 --- 507 771
+593
internal/handlers/calendar.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "io" 8 + "net/http" 9 + "sort" 10 + "strings" 11 + "time" 12 + 13 + "github.com/shindakun/attodo/internal/models" 14 + "github.com/shindakun/attodo/internal/session" 15 + "github.com/shindakun/bskyoauth" 16 + ) 17 + 18 + const ( 19 + CalendarEventCollection = "community.lexicon.calendar.event" 20 + CalendarRSVPCollection = "community.lexicon.calendar.rsvp" 21 + ) 22 + 23 + type CalendarHandler struct { 24 + client *bskyoauth.Client 25 + } 26 + 27 + func NewCalendarHandler(client *bskyoauth.Client) *CalendarHandler { 28 + return &CalendarHandler{client: client} 29 + } 30 + 31 + // withRetry executes an operation with automatic token refresh on DPoP errors 32 + func (h *CalendarHandler) withRetry(ctx context.Context, sess *bskyoauth.Session, operation func(*bskyoauth.Session) error) (*bskyoauth.Session, error) { 33 + var err error 34 + 35 + for attempt := 0; attempt < 2; attempt++ { 36 + err = operation(sess) 37 + if err == nil { 38 + return sess, nil 39 + } 40 + 41 + // Check if it's a DPoP replay error or 401 42 + if strings.Contains(err.Error(), "invalid_dpop_proof") || strings.Contains(err.Error(), "401") { 43 + // Refresh the token 44 + sess, err = h.client.RefreshToken(ctx, sess) 45 + if err != nil { 46 + return sess, err 47 + } 48 + continue 49 + } 50 + 51 + // Other errors, don't retry 52 + break 53 + } 54 + 55 + return sess, err 56 + } 57 + 58 + // ListEvents fetches all calendar events (both owned and RSVP'd) 59 + func (h *CalendarHandler) ListEvents(w http.ResponseWriter, r *http.Request) { 60 + ctx := r.Context() 61 + sess, ok := session.GetSession(r) 62 + if !ok { 63 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 64 + return 65 + } 66 + 67 + var events []*models.CalendarEvent 68 + var err error 69 + 70 + sess, err = h.withRetry(ctx, sess, func(s *bskyoauth.Session) error { 71 + // Fetch events from user's own repository 72 + ownEvents, err := h.ListEventRecords(ctx, s) 73 + if err != nil { 74 + fmt.Printf("WARNING: Failed to fetch own events: %v\n", err) 75 + } else { 76 + events = append(events, ownEvents...) 77 + } 78 + 79 + // Fetch events user has RSVP'd to 80 + rsvpEvents, err := h.ListEventsFromRSVPs(ctx, s) 81 + if err != nil { 82 + fmt.Printf("WARNING: Failed to fetch RSVP'd events: %v\n", err) 83 + } else { 84 + events = append(events, rsvpEvents...) 85 + } 86 + 87 + return nil 88 + }) 89 + 90 + if err != nil { 91 + http.Error(w, getUserFriendlyError(err, "Failed to fetch calendar events"), http.StatusInternalServerError) 92 + return 93 + } 94 + 95 + // Sort events by StartsAt in reverse chronological order (newest first) 96 + sortEventsByDate(events) 97 + 98 + // Check if client wants HTML or JSON based on Accept header 99 + acceptHeader := r.Header.Get("Accept") 100 + if strings.Contains(acceptHeader, "text/html") || r.Header.Get("HX-Request") == "true" { 101 + // Return HTML for HTMX 102 + if len(events) == 0 { 103 + w.Header().Set("Content-Type", "text/html") 104 + 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>")) 105 + return 106 + } 107 + 108 + w.Header().Set("Content-Type", "text/html") 109 + for _, event := range events { 110 + Render(w, "calendar-event-card.html", event) 111 + } 112 + return 113 + } 114 + 115 + // Return JSON for API calls 116 + w.Header().Set("Content-Type", "application/json") 117 + json.NewEncoder(w).Encode(events) 118 + } 119 + 120 + // GetEvent fetches a single calendar event by rkey 121 + func (h *CalendarHandler) GetEvent(w http.ResponseWriter, r *http.Request) { 122 + ctx := r.Context() 123 + sess, ok := session.GetSession(r) 124 + if !ok { 125 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 126 + return 127 + } 128 + 129 + // Extract rkey from URL path 130 + rkey := strings.TrimPrefix(r.URL.Path, "/app/calendar/events/") 131 + if idx := strings.Index(rkey, "/"); idx != -1 { 132 + rkey = rkey[:idx] 133 + } 134 + 135 + if rkey == "" { 136 + http.Error(w, "Missing event ID", http.StatusBadRequest) 137 + return 138 + } 139 + 140 + var event *models.CalendarEvent 141 + var err error 142 + 143 + // Try to fetch from user's own repository first 144 + sess, err = h.withRetry(ctx, sess, func(s *bskyoauth.Session) error { 145 + event, err = h.getEventRecord(ctx, s, rkey) 146 + return err 147 + }) 148 + 149 + // If not found in user's repository, search through all events (including RSVP'd) 150 + if err != nil && (strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "400") || strings.Contains(err.Error(), "RecordNotFound")) { 151 + fmt.Printf("DEBUG: Event %s not found in user's own repository, searching RSVP'd events...\n", rkey) 152 + sess, err = h.withRetry(ctx, sess, func(s *bskyoauth.Session) error { 153 + // Get all events (own + RSVP'd) 154 + var allEvents []*models.CalendarEvent 155 + 156 + ownEvents, err := h.ListEventRecords(ctx, s) 157 + if err == nil { 158 + allEvents = append(allEvents, ownEvents...) 159 + } 160 + 161 + rsvpEvents, err := h.ListEventsFromRSVPs(ctx, s) 162 + if err == nil { 163 + allEvents = append(allEvents, rsvpEvents...) 164 + } 165 + 166 + fmt.Printf("DEBUG: Searching through %d total events for rkey %s\n", len(allEvents), rkey) 167 + 168 + // Find event with matching rkey 169 + for _, e := range allEvents { 170 + if e.RKey == rkey { 171 + fmt.Printf("DEBUG: Found event %s in all events list\n", rkey) 172 + event = e 173 + return nil 174 + } 175 + } 176 + 177 + return fmt.Errorf("event not found: %s", rkey) 178 + }) 179 + } 180 + 181 + if err != nil { 182 + fmt.Printf("ERROR: Failed to fetch event %s: %v\n", rkey, err) 183 + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "RecordNotFound") { 184 + http.Error(w, "Event not found", http.StatusNotFound) 185 + return 186 + } 187 + http.Error(w, getUserFriendlyError(err, "Failed to fetch event"), http.StatusInternalServerError) 188 + return 189 + } 190 + 191 + fmt.Printf("DEBUG: Successfully fetched event %s\n", rkey) 192 + 193 + w.Header().Set("Content-Type", "application/json") 194 + json.NewEncoder(w).Encode(event) 195 + } 196 + 197 + // GetEventRSVPs fetches RSVPs for a specific event 198 + func (h *CalendarHandler) GetEventRSVPs(w http.ResponseWriter, r *http.Request) { 199 + ctx := r.Context() 200 + sess, ok := session.GetSession(r) 201 + if !ok { 202 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 203 + return 204 + } 205 + 206 + // Extract rkey from URL path 207 + path := strings.TrimPrefix(r.URL.Path, "/app/calendar/events/") 208 + parts := strings.Split(path, "/") 209 + if len(parts) < 2 { 210 + http.Error(w, "Invalid URL", http.StatusBadRequest) 211 + return 212 + } 213 + rkey := parts[0] 214 + 215 + // Construct the event URI 216 + eventURI := fmt.Sprintf("at://%s/%s/%s", sess.DID, CalendarEventCollection, rkey) 217 + 218 + var rsvps []*models.CalendarRSVP 219 + var err error 220 + 221 + sess, err = h.withRetry(ctx, sess, func(s *bskyoauth.Session) error { 222 + allRSVPs, err := h.listRSVPRecords(ctx, s) 223 + if err != nil { 224 + return err 225 + } 226 + 227 + // Filter for this event 228 + for _, rsvp := range allRSVPs { 229 + if rsvp.Subject.URI == eventURI { 230 + rsvps = append(rsvps, rsvp) 231 + } 232 + } 233 + 234 + return nil 235 + }) 236 + 237 + if err != nil { 238 + http.Error(w, getUserFriendlyError(err, "Failed to fetch RSVPs"), http.StatusInternalServerError) 239 + return 240 + } 241 + 242 + w.Header().Set("Content-Type", "application/json") 243 + json.NewEncoder(w).Encode(rsvps) 244 + } 245 + 246 + // ListUpcomingEvents fetches events starting within a specified time window 247 + func (h *CalendarHandler) ListUpcomingEvents(w http.ResponseWriter, r *http.Request) { 248 + ctx := r.Context() 249 + sess, ok := session.GetSession(r) 250 + if !ok { 251 + http.Error(w, "Unauthorized", http.StatusUnauthorized) 252 + return 253 + } 254 + 255 + // Parse duration from query params (default: 7 days) 256 + durationStr := r.URL.Query().Get("within") 257 + duration := 7 * 24 * time.Hour // default 7 days 258 + if durationStr != "" { 259 + if parsed, err := time.ParseDuration(durationStr); err == nil { 260 + duration = parsed 261 + } 262 + } 263 + 264 + var events []*models.CalendarEvent 265 + var err error 266 + 267 + sess, err = h.withRetry(ctx, sess, func(s *bskyoauth.Session) error { 268 + allEvents, err := h.ListEventRecords(ctx, s) 269 + if err != nil { 270 + return err 271 + } 272 + 273 + // Filter for upcoming events 274 + for _, event := range allEvents { 275 + if event.IsUpcoming() && event.StartsWithin(duration) && !event.IsCancelled() { 276 + events = append(events, event) 277 + } 278 + } 279 + 280 + return nil 281 + }) 282 + 283 + if err != nil { 284 + http.Error(w, getUserFriendlyError(err, "Failed to fetch upcoming events"), http.StatusInternalServerError) 285 + return 286 + } 287 + 288 + // Check if client wants HTML or JSON based on Accept header 289 + acceptHeader := r.Header.Get("Accept") 290 + if strings.Contains(acceptHeader, "text/html") || r.Header.Get("HX-Request") == "true" { 291 + // Return HTML for HTMX 292 + if len(events) == 0 { 293 + w.Header().Set("Content-Type", "text/html") 294 + w.Write([]byte("<p style=\"color: var(--pico-muted-color); text-align: center; padding: 2rem;\">No upcoming events in the next 7 days.</p>")) 295 + return 296 + } 297 + 298 + w.Header().Set("Content-Type", "text/html") 299 + for _, event := range events { 300 + Render(w, "calendar-event-card.html", event) 301 + } 302 + return 303 + } 304 + 305 + // Return JSON for API calls 306 + w.Header().Set("Content-Type", "application/json") 307 + json.NewEncoder(w).Encode(events) 308 + } 309 + 310 + // listEventRecords fetches all calendar events using direct XRPC call 311 + func (h *CalendarHandler) ListEventRecords(ctx context.Context, sess *bskyoauth.Session) ([]*models.CalendarEvent, error) { 312 + // Build the XRPC URL 313 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", 314 + sess.PDS, sess.DID, CalendarEventCollection) 315 + 316 + // Create request 317 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 318 + if err != nil { 319 + return nil, err 320 + } 321 + 322 + // Add authorization header 323 + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) 324 + 325 + // Make request 326 + client := &http.Client{Timeout: 10 * time.Second} 327 + resp, err := client.Do(req) 328 + if err != nil { 329 + return nil, err 330 + } 331 + defer resp.Body.Close() 332 + 333 + if resp.StatusCode != http.StatusOK { 334 + body, _ := io.ReadAll(resp.Body) 335 + return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body)) 336 + } 337 + 338 + // Parse response 339 + var result struct { 340 + Records []struct { 341 + Uri string `json:"uri"` 342 + Cid string `json:"cid"` 343 + Value map[string]interface{} `json:"value"` 344 + } `json:"records"` 345 + } 346 + 347 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 348 + return nil, err 349 + } 350 + 351 + fmt.Printf("DEBUG: Received %d calendar event records from AT Protocol\n", len(result.Records)) 352 + 353 + // Convert to CalendarEvent models 354 + events := make([]*models.CalendarEvent, 0, len(result.Records)) 355 + for _, record := range result.Records { 356 + event, err := models.ParseCalendarEvent(record.Value, record.Uri, record.Cid) 357 + if err != nil { 358 + // Log error but continue with other events 359 + fmt.Printf("WARNING: Failed to parse calendar event %s: %v\n", record.Uri, err) 360 + continue 361 + } 362 + events = append(events, event) 363 + } 364 + 365 + fmt.Printf("DEBUG: Fetched %d calendar events from repository\n", len(events)) 366 + 367 + return events, nil 368 + } 369 + 370 + // getEventRecord fetches a single event using direct XRPC call 371 + func (h *CalendarHandler) getEventRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (*models.CalendarEvent, error) { 372 + // Build the XRPC URL 373 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 374 + sess.PDS, sess.DID, CalendarEventCollection, rkey) 375 + 376 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 377 + if err != nil { 378 + return nil, err 379 + } 380 + 381 + // Add authorization header 382 + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) 383 + 384 + // Make request 385 + client := &http.Client{Timeout: 10 * time.Second} 386 + resp, err := client.Do(req) 387 + if err != nil { 388 + return nil, err 389 + } 390 + defer resp.Body.Close() 391 + 392 + if resp.StatusCode != http.StatusOK { 393 + body, _ := io.ReadAll(resp.Body) 394 + return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body)) 395 + } 396 + 397 + // Parse response 398 + var result struct { 399 + Uri string `json:"uri"` 400 + Cid string `json:"cid"` 401 + Value map[string]interface{} `json:"value"` 402 + } 403 + 404 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 405 + return nil, err 406 + } 407 + 408 + // Convert to CalendarEvent model 409 + event, err := models.ParseCalendarEvent(result.Value, result.Uri, result.Cid) 410 + if err != nil { 411 + return nil, fmt.Errorf("failed to parse event: %w", err) 412 + } 413 + 414 + return event, nil 415 + } 416 + 417 + // listEventsFromRSVPs fetches events that the user has RSVP'd to 418 + func (h *CalendarHandler) ListEventsFromRSVPs(ctx context.Context, sess *bskyoauth.Session) ([]*models.CalendarEvent, error) { 419 + // First, fetch all RSVPs 420 + rsvps, err := h.listRSVPRecords(ctx, sess) 421 + if err != nil { 422 + return nil, fmt.Errorf("failed to fetch RSVPs: %w", err) 423 + } 424 + 425 + fmt.Printf("DEBUG: Found %d RSVPs\n", len(rsvps)) 426 + 427 + // Extract unique event URIs from RSVPs 428 + eventURIs := make(map[string]bool) 429 + for _, rsvp := range rsvps { 430 + if rsvp.Subject != nil && rsvp.Subject.URI != "" { 431 + eventURIs[rsvp.Subject.URI] = true 432 + } 433 + } 434 + 435 + fmt.Printf("DEBUG: Found %d unique event URIs from RSVPs\n", len(eventURIs)) 436 + 437 + // Fetch each event by URI 438 + events := make([]*models.CalendarEvent, 0, len(eventURIs)) 439 + for eventURI := range eventURIs { 440 + event, err := h.getEventByURI(ctx, sess, eventURI) 441 + if err != nil { 442 + fmt.Printf("WARNING: Failed to fetch event %s: %v\n", eventURI, err) 443 + continue 444 + } 445 + events = append(events, event) 446 + } 447 + 448 + fmt.Printf("DEBUG: Successfully fetched %d events from RSVPs\n", len(events)) 449 + 450 + return events, nil 451 + } 452 + 453 + // getEventByURI fetches an event by its AT URI 454 + func (h *CalendarHandler) getEventByURI(ctx context.Context, sess *bskyoauth.Session, uri string) (*models.CalendarEvent, error) { 455 + // Parse URI: at://did:plc:xxx/community.lexicon.calendar.event/rkey 456 + // Extract DID, collection, and rkey 457 + if len(uri) < 5 || uri[:5] != "at://" { 458 + return nil, fmt.Errorf("invalid AT URI: %s", uri) 459 + } 460 + 461 + parts := strings.Split(uri[5:], "/") 462 + if len(parts) != 3 { 463 + return nil, fmt.Errorf("invalid AT URI format: %s", uri) 464 + } 465 + 466 + did := parts[0] 467 + collection := parts[1] 468 + rkey := parts[2] 469 + 470 + // Build XRPC URL - use the repo from the URI, not the session DID 471 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 472 + sess.PDS, did, collection, rkey) 473 + 474 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 475 + if err != nil { 476 + return nil, err 477 + } 478 + 479 + // Add authorization header 480 + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) 481 + 482 + // Make request 483 + client := &http.Client{Timeout: 10 * time.Second} 484 + resp, err := client.Do(req) 485 + if err != nil { 486 + return nil, err 487 + } 488 + defer resp.Body.Close() 489 + 490 + if resp.StatusCode != http.StatusOK { 491 + body, _ := io.ReadAll(resp.Body) 492 + return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body)) 493 + } 494 + 495 + // Parse response 496 + var result struct { 497 + Uri string `json:"uri"` 498 + Cid string `json:"cid"` 499 + Value map[string]interface{} `json:"value"` 500 + } 501 + 502 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 503 + return nil, err 504 + } 505 + 506 + // Convert to CalendarEvent model 507 + event, err := models.ParseCalendarEvent(result.Value, result.Uri, result.Cid) 508 + if err != nil { 509 + return nil, fmt.Errorf("failed to parse event: %w", err) 510 + } 511 + 512 + return event, nil 513 + } 514 + 515 + // listRSVPRecords fetches all RSVP records using direct XRPC call 516 + func (h *CalendarHandler) listRSVPRecords(ctx context.Context, sess *bskyoauth.Session) ([]*models.CalendarRSVP, error) { 517 + // Build the XRPC URL 518 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", 519 + sess.PDS, sess.DID, CalendarRSVPCollection) 520 + 521 + // Create request 522 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 523 + if err != nil { 524 + return nil, err 525 + } 526 + 527 + // Add authorization header 528 + req.Header.Set("Authorization", "Bearer "+sess.AccessToken) 529 + 530 + // Make request 531 + client := &http.Client{Timeout: 10 * time.Second} 532 + resp, err := client.Do(req) 533 + if err != nil { 534 + return nil, err 535 + } 536 + defer resp.Body.Close() 537 + 538 + if resp.StatusCode != http.StatusOK { 539 + body, _ := io.ReadAll(resp.Body) 540 + return nil, fmt.Errorf("XRPC ERROR %d: %s", resp.StatusCode, string(body)) 541 + } 542 + 543 + // Parse response 544 + var result struct { 545 + Records []struct { 546 + Uri string `json:"uri"` 547 + Cid string `json:"cid"` 548 + Value map[string]interface{} `json:"value"` 549 + } `json:"records"` 550 + } 551 + 552 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 553 + return nil, err 554 + } 555 + 556 + // Convert to CalendarRSVP models 557 + rsvps := make([]*models.CalendarRSVP, 0, len(result.Records)) 558 + for _, record := range result.Records { 559 + rsvp, err := models.ParseCalendarRSVP(record.Value, record.Uri, record.Cid) 560 + if err != nil { 561 + // Log error but continue with other RSVPs 562 + continue 563 + } 564 + rsvps = append(rsvps, rsvp) 565 + } 566 + 567 + return rsvps, nil 568 + } 569 + 570 + // sortEventsByDate sorts events by StartsAt in reverse chronological order (newest first) 571 + // Events without StartsAt are placed at the end, sorted by CreatedAt 572 + func sortEventsByDate(events []*models.CalendarEvent) { 573 + sort.Slice(events, func(i, j int) bool { 574 + eventI := events[i] 575 + eventJ := events[j] 576 + 577 + // If both have StartsAt, sort by StartsAt (newest first) 578 + if eventI.StartsAt != nil && eventJ.StartsAt != nil { 579 + return eventI.StartsAt.After(*eventJ.StartsAt) 580 + } 581 + 582 + // Events with StartsAt come before events without 583 + if eventI.StartsAt != nil { 584 + return true 585 + } 586 + if eventJ.StartsAt != nil { 587 + return false 588 + } 589 + 590 + // Both don't have StartsAt, sort by CreatedAt (newest first) 591 + return eventI.CreatedAt.After(eventJ.CreatedAt) 592 + }) 593 + }
+475
internal/handlers/ical.go
··· 1 + package handlers 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "net/http" 8 + "strings" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/shindakun/attodo/internal/models" 14 + "github.com/shindakun/bskyoauth" 15 + ) 16 + 17 + // ICalHandler handles iCal feed generation 18 + type ICalHandler struct { 19 + client *bskyoauth.Client 20 + } 21 + 22 + // NewICalHandler creates a new iCal handler 23 + func NewICalHandler(client *bskyoauth.Client) *ICalHandler { 24 + return &ICalHandler{ 25 + client: client, 26 + } 27 + } 28 + 29 + // GenerateCalendarFeed generates an iCal feed for a user's calendar events 30 + func (h *ICalHandler) GenerateCalendarFeed(w http.ResponseWriter, r *http.Request) { 31 + ctx := r.Context() 32 + 33 + // Extract DID from path: /calendar/feed/{did}/events.ics 34 + pathParts := strings.Split(r.URL.Path, "/") 35 + if len(pathParts) < 5 { 36 + http.Error(w, "Invalid feed URL", http.StatusBadRequest) 37 + return 38 + } 39 + 40 + did := pathParts[3] 41 + if did == "" { 42 + http.Error(w, "Missing DID", http.StatusBadRequest) 43 + return 44 + } 45 + 46 + // Fetch events from AT Protocol (public read, no auth needed) 47 + events, err := h.fetchEventsForDID(ctx, did) 48 + if err != nil { 49 + http.Error(w, fmt.Sprintf("Failed to fetch events: %v", err), http.StatusInternalServerError) 50 + return 51 + } 52 + 53 + // Generate iCal feed 54 + ical := h.generateCalendarICalendar(did, events) 55 + 56 + // Set headers for iCal feed 57 + w.Header().Set("Content-Type", "text/calendar; charset=utf-8") 58 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s-events.ics\"", sanitizeDID(did))) 59 + w.Header().Set("Cache-Control", "no-cache, must-revalidate") 60 + 61 + // Write iCal content 62 + w.Write([]byte(ical)) 63 + } 64 + 65 + // GenerateTasksFeed generates an iCal feed for a user's tasks with due dates 66 + func (h *ICalHandler) GenerateTasksFeed(w http.ResponseWriter, r *http.Request) { 67 + ctx := r.Context() 68 + 69 + // Extract DID from path: /tasks/feed/{did}/tasks.ics 70 + pathParts := strings.Split(r.URL.Path, "/") 71 + if len(pathParts) < 5 { 72 + http.Error(w, "Invalid feed URL", http.StatusBadRequest) 73 + return 74 + } 75 + 76 + did := pathParts[3] 77 + if did == "" { 78 + http.Error(w, "Missing DID", http.StatusBadRequest) 79 + return 80 + } 81 + 82 + // Fetch tasks from AT Protocol (public read, no auth needed) 83 + tasks, err := h.fetchTasksForDID(ctx, did) 84 + if err != nil { 85 + http.Error(w, fmt.Sprintf("Failed to fetch tasks: %v", err), http.StatusInternalServerError) 86 + return 87 + } 88 + 89 + // Generate iCal feed 90 + ical := h.generateTasksICalendar(did, tasks) 91 + 92 + // Set headers for iCal feed 93 + w.Header().Set("Content-Type", "text/calendar; charset=utf-8") 94 + w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=\"%s-tasks.ics\"", sanitizeDID(did))) 95 + w.Header().Set("Cache-Control", "no-cache, must-revalidate") 96 + 97 + // Write iCal content 98 + w.Write([]byte(ical)) 99 + } 100 + 101 + // fetchEventsForDID fetches calendar events for a given DID using public read 102 + func (h *ICalHandler) fetchEventsForDID(ctx context.Context, did string) ([]*models.CalendarEvent, error) { 103 + // Resolve PDS endpoint for this DID 104 + pds, err := h.resolvePDSEndpoint(ctx, did) 105 + if err != nil { 106 + return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err) 107 + } 108 + 109 + // Build the XRPC URL for public read 110 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", 111 + pds, did, CalendarEventCollection) 112 + 113 + // Create and execute request 114 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 115 + if err != nil { 116 + return nil, err 117 + } 118 + 119 + client := &http.Client{Timeout: 10 * time.Second} 120 + resp, err := client.Do(req) 121 + if err != nil { 122 + return nil, err 123 + } 124 + defer resp.Body.Close() 125 + 126 + if resp.StatusCode != http.StatusOK { 127 + return nil, fmt.Errorf("XRPC error %d", resp.StatusCode) 128 + } 129 + 130 + // Parse response (reuse the same struct from calendar.go) 131 + var result struct { 132 + Records []struct { 133 + Uri string `json:"uri"` 134 + Cid string `json:"cid"` 135 + Value map[string]interface{} `json:"value"` 136 + } `json:"records"` 137 + } 138 + 139 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 140 + return nil, err 141 + } 142 + 143 + // Convert to CalendarEvent models 144 + events := make([]*models.CalendarEvent, 0, len(result.Records)) 145 + for _, record := range result.Records { 146 + event, err := models.ParseCalendarEvent(record.Value, record.Uri, record.Cid) 147 + if err != nil { 148 + // Skip invalid events but don't fail the whole feed 149 + continue 150 + } 151 + events = append(events, event) 152 + } 153 + 154 + return events, nil 155 + } 156 + 157 + // resolvePDSEndpoint resolves the PDS endpoint for a given DID 158 + func (h *ICalHandler) resolvePDSEndpoint(ctx context.Context, did string) (string, error) { 159 + dir := identity.DefaultDirectory() 160 + atid, err := syntax.ParseAtIdentifier(did) 161 + if err != nil { 162 + return "", err 163 + } 164 + 165 + ident, err := dir.Lookup(ctx, *atid) 166 + if err != nil { 167 + return "", err 168 + } 169 + 170 + return ident.PDSEndpoint(), nil 171 + } 172 + 173 + // generateCalendarICalendar generates an iCal format string from calendar events 174 + func (h *ICalHandler) generateCalendarICalendar(did string, events []*models.CalendarEvent) string { 175 + var ical strings.Builder 176 + 177 + // iCal header 178 + ical.WriteString("BEGIN:VCALENDAR\r\n") 179 + ical.WriteString("VERSION:2.0\r\n") 180 + ical.WriteString("PRODID:-//AT Todo//Calendar Feed//EN\r\n") 181 + ical.WriteString(fmt.Sprintf("X-WR-CALNAME:AT Protocol Events - %s\r\n", sanitizeDID(did))) 182 + ical.WriteString("X-WR-TIMEZONE:UTC\r\n") 183 + ical.WriteString("CALSCALE:GREGORIAN\r\n") 184 + ical.WriteString("METHOD:PUBLISH\r\n") 185 + 186 + // Add each event 187 + for _, event := range events { 188 + h.addEventToICalendar(&ical, event) 189 + } 190 + 191 + // iCal footer 192 + ical.WriteString("END:VCALENDAR\r\n") 193 + 194 + return ical.String() 195 + } 196 + 197 + // addEventToICalendar adds a single event to the iCalendar 198 + func (h *ICalHandler) addEventToICalendar(ical *strings.Builder, event *models.CalendarEvent) { 199 + ical.WriteString("BEGIN:VEVENT\r\n") 200 + 201 + // UID - unique identifier (use AT Protocol URI) 202 + ical.WriteString(fmt.Sprintf("UID:%s\r\n", event.URI)) 203 + 204 + // DTSTAMP - when the event was created 205 + ical.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", formatICalTime(event.CreatedAt))) 206 + 207 + // DTSTART - event start time 208 + if event.StartsAt != nil { 209 + ical.WriteString(fmt.Sprintf("DTSTART:%s\r\n", formatICalTime(*event.StartsAt))) 210 + } 211 + 212 + // DTEND - event end time 213 + if event.EndsAt != nil { 214 + ical.WriteString(fmt.Sprintf("DTEND:%s\r\n", formatICalTime(*event.EndsAt))) 215 + } 216 + 217 + // SUMMARY - event title 218 + ical.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", escapeICalText(event.Name))) 219 + 220 + // DESCRIPTION - event description 221 + if event.Description != "" { 222 + ical.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", escapeICalText(event.Description))) 223 + } 224 + 225 + // LOCATION - event location 226 + if len(event.Locations) > 0 { 227 + location := event.Locations[0] 228 + if location.Name != "" { 229 + ical.WriteString(fmt.Sprintf("LOCATION:%s\r\n", escapeICalText(location.Name))) 230 + } else if location.Address != "" { 231 + ical.WriteString(fmt.Sprintf("LOCATION:%s\r\n", escapeICalText(location.Address))) 232 + } 233 + } 234 + 235 + // URL - link to Smokesignal 236 + if smokesignalURL := event.SmokesignalURL(); smokesignalURL != "" { 237 + ical.WriteString(fmt.Sprintf("URL:%s\r\n", smokesignalURL)) 238 + } 239 + 240 + // STATUS - event status 241 + status := "CONFIRMED" 242 + switch event.Status { 243 + case models.EventStatusCancelled: 244 + status = "CANCELLED" 245 + case models.EventStatusPlanned: 246 + status = "TENTATIVE" 247 + } 248 + ical.WriteString(fmt.Sprintf("STATUS:%s\r\n", status)) 249 + 250 + // CATEGORIES - attendance mode 251 + if event.Mode != "" { 252 + ical.WriteString(fmt.Sprintf("CATEGORIES:%s\r\n", strings.ToUpper(event.Mode))) 253 + } 254 + 255 + ical.WriteString("END:VEVENT\r\n") 256 + } 257 + 258 + // fetchTasksForDID fetches tasks for a given DID using public read 259 + func (h *ICalHandler) fetchTasksForDID(ctx context.Context, did string) ([]*models.Task, error) { 260 + // Resolve PDS endpoint for this DID 261 + pds, err := h.resolvePDSEndpoint(ctx, did) 262 + if err != nil { 263 + return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err) 264 + } 265 + 266 + // Build the XRPC URL for public read 267 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", 268 + pds, did, TaskCollection) 269 + 270 + // Create and execute request 271 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 272 + if err != nil { 273 + return nil, err 274 + } 275 + 276 + client := &http.Client{Timeout: 10 * time.Second} 277 + resp, err := client.Do(req) 278 + if err != nil { 279 + return nil, err 280 + } 281 + defer resp.Body.Close() 282 + 283 + if resp.StatusCode != http.StatusOK { 284 + return nil, fmt.Errorf("XRPC error %d", resp.StatusCode) 285 + } 286 + 287 + // Parse response 288 + var result struct { 289 + Records []struct { 290 + Uri string `json:"uri"` 291 + Cid string `json:"cid"` 292 + Value map[string]interface{} `json:"value"` 293 + } `json:"records"` 294 + } 295 + 296 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 297 + return nil, err 298 + } 299 + 300 + // Convert to Task models and filter for tasks with due dates 301 + tasks := make([]*models.Task, 0) 302 + for _, record := range result.Records { 303 + task := parseTaskFieldsForICal(record.Value) 304 + task.URI = record.Uri 305 + task.RKey = extractRKeyForICal(record.Uri) 306 + 307 + // Only include tasks with due dates 308 + if task.DueDate != nil { 309 + tasks = append(tasks, &task) 310 + } 311 + } 312 + 313 + return tasks, nil 314 + } 315 + 316 + // generateTasksICalendar generates an iCal format string from tasks 317 + func (h *ICalHandler) generateTasksICalendar(did string, tasks []*models.Task) string { 318 + var ical strings.Builder 319 + 320 + // iCal header 321 + ical.WriteString("BEGIN:VCALENDAR\r\n") 322 + ical.WriteString("VERSION:2.0\r\n") 323 + ical.WriteString("PRODID:-//AT Todo//Tasks Feed//EN\r\n") 324 + ical.WriteString(fmt.Sprintf("X-WR-CALNAME:AT Protocol Tasks - %s\r\n", sanitizeDID(did))) 325 + ical.WriteString("X-WR-TIMEZONE:UTC\r\n") 326 + ical.WriteString("CALSCALE:GREGORIAN\r\n") 327 + ical.WriteString("METHOD:PUBLISH\r\n") 328 + 329 + // Add each task 330 + for _, task := range tasks { 331 + h.addTaskToICalendar(&ical, task) 332 + } 333 + 334 + // iCal footer 335 + ical.WriteString("END:VCALENDAR\r\n") 336 + 337 + return ical.String() 338 + } 339 + 340 + // addTaskToICalendar adds a single task to the iCalendar 341 + func (h *ICalHandler) addTaskToICalendar(ical *strings.Builder, task *models.Task) { 342 + ical.WriteString("BEGIN:VTODO\r\n") 343 + 344 + // UID - unique identifier (use AT Protocol URI) 345 + ical.WriteString(fmt.Sprintf("UID:%s\r\n", task.URI)) 346 + 347 + // DTSTAMP - when the task was created 348 + ical.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", formatICalTime(task.CreatedAt))) 349 + 350 + // DUE - task due date 351 + if task.DueDate != nil { 352 + ical.WriteString(fmt.Sprintf("DUE:%s\r\n", formatICalTime(*task.DueDate))) 353 + } 354 + 355 + // SUMMARY - task title 356 + ical.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", escapeICalText(task.Title))) 357 + 358 + // DESCRIPTION - task description 359 + if task.Description != "" { 360 + ical.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", escapeICalText(task.Description))) 361 + } 362 + 363 + // STATUS - task completion status 364 + if task.Completed { 365 + ical.WriteString("STATUS:COMPLETED\r\n") 366 + if task.CompletedAt != nil { 367 + ical.WriteString(fmt.Sprintf("COMPLETED:%s\r\n", formatICalTime(*task.CompletedAt))) 368 + } 369 + } else { 370 + ical.WriteString("STATUS:NEEDS-ACTION\r\n") 371 + } 372 + 373 + // PRIORITY - map task priority (0=none, 1-10) 374 + // iCal priority: 1=high, 5=medium, 9=low 375 + priority := 5 // default medium 376 + ical.WriteString(fmt.Sprintf("PRIORITY:%d\r\n", priority)) 377 + 378 + // CATEGORIES - tags 379 + if len(task.Tags) > 0 { 380 + categories := strings.Join(task.Tags, ",") 381 + ical.WriteString(fmt.Sprintf("CATEGORIES:%s\r\n", escapeICalText(categories))) 382 + } 383 + 384 + // URL - link to AT Todo 385 + // We could link to the specific task on attodo.app if we had that route 386 + // For now, just link to the dashboard 387 + ical.WriteString("URL:https://attodo.app/app\r\n") 388 + 389 + ical.WriteString("END:VTODO\r\n") 390 + } 391 + 392 + // formatICalTime formats a time.Time to iCal format (UTC) 393 + func formatICalTime(t time.Time) string { 394 + // iCal format: 20060102T150405Z 395 + return t.UTC().Format("20060102T150405Z") 396 + } 397 + 398 + // escapeICalText escapes special characters in iCal text fields 399 + func escapeICalText(text string) string { 400 + // Escape backslashes, commas, semicolons, and newlines 401 + text = strings.ReplaceAll(text, "\\", "\\\\") 402 + text = strings.ReplaceAll(text, ",", "\\,") 403 + text = strings.ReplaceAll(text, ";", "\\;") 404 + text = strings.ReplaceAll(text, "\n", "\\n") 405 + text = strings.ReplaceAll(text, "\r", "") 406 + return text 407 + } 408 + 409 + // sanitizeDID creates a filename-safe version of a DID 410 + func sanitizeDID(did string) string { 411 + // Remove "did:plc:" prefix and use just the identifier 412 + parts := strings.Split(did, ":") 413 + if len(parts) >= 3 { 414 + return parts[2][:min(len(parts[2]), 16)] // Truncate to 16 chars for filename 415 + } 416 + return "calendar" 417 + } 418 + 419 + func min(a, b int) int { 420 + if a < b { 421 + return a 422 + } 423 + return b 424 + } 425 + 426 + // parseTaskFieldsForICal parses task fields from a record map 427 + func parseTaskFieldsForICal(record map[string]interface{}) models.Task { 428 + task := models.Task{} 429 + 430 + if title, ok := record["title"].(string); ok { 431 + task.Title = title 432 + } 433 + if desc, ok := record["description"].(string); ok { 434 + task.Description = desc 435 + } 436 + if completed, ok := record["completed"].(bool); ok { 437 + task.Completed = completed 438 + } 439 + if createdAt, ok := record["createdAt"].(string); ok { 440 + if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 441 + task.CreatedAt = t 442 + } 443 + } 444 + if completedAt, ok := record["completedAt"].(string); ok { 445 + if t, err := time.Parse(time.RFC3339, completedAt); err == nil { 446 + task.CompletedAt = &t 447 + } 448 + } 449 + // Parse due date if present 450 + if dueDate, ok := record["dueDate"].(string); ok { 451 + if t, err := time.Parse(time.RFC3339, dueDate); err == nil { 452 + task.DueDate = &t 453 + } 454 + } 455 + // Parse tags if present 456 + if tags, ok := record["tags"].([]interface{}); ok { 457 + task.Tags = make([]string, 0, len(tags)) 458 + for _, tag := range tags { 459 + if tagStr, ok := tag.(string); ok { 460 + task.Tags = append(task.Tags, tagStr) 461 + } 462 + } 463 + } 464 + 465 + return task 466 + } 467 + 468 + // extractRKeyForICal extracts the record key from an AT URI 469 + func extractRKeyForICal(uri string) string { 470 + parts := strings.Split(uri, "/") 471 + if len(parts) > 0 { 472 + return parts[len(parts)-1] 473 + } 474 + return "" 475 + }
+42 -19
internal/handlers/settings.go
··· 53 53 var err error 54 54 sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 55 55 var fetchErr error 56 - record, fetchErr = h.getRecord(r.Context(), s, SettingsRKey) 56 + record, fetchErr = h.GetRecord(r.Context(), s, SettingsRKey) 57 57 return fetchErr 58 58 }) 59 59 ··· 65 65 settings = models.DefaultNotificationSettings() 66 66 } else { 67 67 // Parse existing settings 68 - settings = parseSettingsRecord(record) 68 + settings = ParseSettingsRecord(record) 69 69 settings.RKey = SettingsRKey 70 70 settings.URI = fmt.Sprintf("at://%s/%s/%s", sess.DID, SettingsCollection, SettingsRKey) 71 71 } ··· 101 101 102 102 // Convert to map for AT Protocol 103 103 record := map[string]interface{}{ 104 - "$type": SettingsCollection, 105 - "notifyOverdue": settings.NotifyOverdue, 106 - "notifyToday": settings.NotifyToday, 107 - "notifySoon": settings.NotifySoon, 108 - "hoursBefore": settings.HoursBefore, 109 - "checkFrequency": settings.CheckFrequency, 110 - "quietHoursEnabled": settings.QuietHoursEnabled, 111 - "quietStart": settings.QuietStart, 112 - "quietEnd": settings.QuietEnd, 113 - "pushEnabled": settings.PushEnabled, 114 - "taskInputCollapsed": settings.TaskInputCollapsed, 115 - "updatedAt": settings.UpdatedAt.Format(time.RFC3339), 104 + "$type": SettingsCollection, 105 + "notifyOverdue": settings.NotifyOverdue, 106 + "notifyToday": settings.NotifyToday, 107 + "notifySoon": settings.NotifySoon, 108 + "hoursBefore": settings.HoursBefore, 109 + "checkFrequency": settings.CheckFrequency, 110 + "quietHoursEnabled": settings.QuietHoursEnabled, 111 + "quietStart": settings.QuietStart, 112 + "quietEnd": settings.QuietEnd, 113 + "pushEnabled": settings.PushEnabled, 114 + "taskInputCollapsed": settings.TaskInputCollapsed, 115 + "calendarNotificationsEnabled": settings.CalendarNotificationsEnabled, 116 + "calendarNotificationLeadTime": settings.CalendarNotificationLeadTime, 117 + "updatedAt": settings.UpdatedAt.Format(time.RFC3339), 116 118 } 117 119 118 120 // Include appUsageHours if present ··· 120 122 record["appUsageHours"] = settings.AppUsageHours 121 123 } 122 124 125 + // Include notificationSentHistory if present 126 + if settings.NotificationSentHistory != nil { 127 + record["notificationSentHistory"] = settings.NotificationSentHistory 128 + } 129 + 123 130 // Check if settings record already exists 124 131 var err error 125 132 sess, err = h.WithRetry(r.Context(), sess, func(s *bskyoauth.Session) error { 126 - _, fetchErr := h.getRecord(r.Context(), s, SettingsRKey) 133 + _, fetchErr := h.GetRecord(r.Context(), s, SettingsRKey) 127 134 return fetchErr 128 135 }) 129 136 ··· 161 168 json.NewEncoder(w).Encode(settings) 162 169 } 163 170 164 - // parseSettingsRecord parses a settings record from AT Protocol 165 - func parseSettingsRecord(record map[string]interface{}) *models.NotificationSettings { 171 + // ParseSettingsRecord parses a settings record from AT Protocol 172 + func ParseSettingsRecord(record map[string]interface{}) *models.NotificationSettings { 166 173 settings := models.DefaultNotificationSettings() 167 174 168 175 if v, ok := record["notifyOverdue"].(bool); ok { ··· 195 202 if v, ok := record["taskInputCollapsed"].(bool); ok { 196 203 settings.TaskInputCollapsed = v 197 204 } 205 + if v, ok := record["calendarNotificationsEnabled"].(bool); ok { 206 + settings.CalendarNotificationsEnabled = v 207 + } 208 + if v, ok := record["calendarNotificationLeadTime"].(string); ok { 209 + settings.CalendarNotificationLeadTime = v 210 + } 198 211 199 212 // Parse appUsageHours if present 200 213 if usageMap, ok := record["appUsageHours"].(map[string]interface{}); ok { ··· 206 219 } 207 220 } 208 221 222 + // Parse notificationSentHistory if present 223 + if historyMap, ok := record["notificationSentHistory"].(map[string]interface{}); ok { 224 + settings.NotificationSentHistory = make(map[string]string) 225 + for k, v := range historyMap { 226 + if timestamp, ok := v.(string); ok { 227 + settings.NotificationSentHistory[k] = timestamp 228 + } 229 + } 230 + } 231 + 209 232 // Parse updatedAt 210 233 if v, ok := record["updatedAt"].(string); ok { 211 234 if t, err := time.Parse(time.RFC3339, v); err == nil { ··· 216 239 return settings 217 240 } 218 241 219 - // getRecord retrieves a settings record using com.atproto.repo.getRecord 220 - func (h *SettingsHandler) getRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (map[string]interface{}, error) { 242 + // GetRecord retrieves a settings record using com.atproto.repo.getRecord 243 + func (h *SettingsHandler) GetRecord(ctx context.Context, sess *bskyoauth.Session, rkey string) (map[string]interface{}, error) { 221 244 url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 222 245 sess.PDS, sess.DID, SettingsCollection, rkey) 223 246
+354
internal/jobs/calendar_notification_check.go
··· 1 + package jobs 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + "net/http" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/atproto/identity" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/shindakun/attodo/internal/database" 14 + "github.com/shindakun/attodo/internal/handlers" 15 + "github.com/shindakun/attodo/internal/models" 16 + "github.com/shindakun/attodo/internal/push" 17 + "github.com/shindakun/bskyoauth" 18 + ) 19 + 20 + // CalendarNotificationJob checks for upcoming calendar events and sends notifications 21 + type CalendarNotificationJob struct { 22 + repo *database.NotificationRepo 23 + client *bskyoauth.Client 24 + sender *push.Sender 25 + calendarHandler *handlers.CalendarHandler 26 + settingsHandler *handlers.SettingsHandler 27 + } 28 + 29 + // NewCalendarNotificationJob creates a new calendar notification job 30 + func NewCalendarNotificationJob(repo *database.NotificationRepo, client *bskyoauth.Client, sender *push.Sender, settingsHandler *handlers.SettingsHandler) *CalendarNotificationJob { 31 + return &CalendarNotificationJob{ 32 + repo: repo, 33 + client: client, 34 + sender: sender, 35 + calendarHandler: handlers.NewCalendarHandler(client), 36 + settingsHandler: settingsHandler, 37 + } 38 + } 39 + 40 + // Name returns the job name 41 + func (c *CalendarNotificationJob) Name() string { 42 + return "CalendarNotificationCheck" 43 + } 44 + 45 + // Run executes the calendar notification check job 46 + func (c *CalendarNotificationJob) Run(ctx context.Context) error { 47 + // Get all users with notifications enabled 48 + users, err := c.repo.GetEnabledNotificationUsers() 49 + if err != nil { 50 + return fmt.Errorf("failed to get enabled users: %w", err) 51 + } 52 + 53 + if len(users) == 0 { 54 + log.Println("[CalendarNotificationCheck] No users with notifications enabled") 55 + return nil 56 + } 57 + 58 + log.Printf("[CalendarNotificationCheck] Checking calendar events for %d user(s)", len(users)) 59 + 60 + // Check events for each user 61 + for _, user := range users { 62 + if err := c.checkUserEvents(ctx, user); err != nil { 63 + log.Printf("[CalendarNotificationCheck] Error checking events for %s: %v", user.DID, err) 64 + // Continue to next user instead of failing the whole job 65 + continue 66 + } 67 + } 68 + 69 + return nil 70 + } 71 + 72 + // checkUserEvents checks calendar events for a single user and sends notifications 73 + func (c *CalendarNotificationJob) checkUserEvents(ctx context.Context, user *models.NotificationUser) error { 74 + // Get user's push subscriptions 75 + subscriptions, err := c.repo.GetPushSubscriptionsByDID(user.DID) 76 + if err != nil { 77 + return fmt.Errorf("failed to get push subscriptions: %w", err) 78 + } 79 + 80 + if len(subscriptions) == 0 { 81 + log.Printf("[CalendarNotificationCheck] User %s has no push subscriptions", user.DID) 82 + return nil 83 + } 84 + 85 + // Get user's calendar notification settings 86 + settings, err := c.getUserSettings(ctx, user.DID) 87 + if err != nil { 88 + log.Printf("[CalendarNotificationCheck] Failed to get settings for %s: %v", user.DID, err) 89 + // Continue with defaults 90 + settings = models.DefaultNotificationSettings() 91 + } 92 + 93 + // Skip if calendar notifications are disabled 94 + if !settings.CalendarNotificationsEnabled { 95 + log.Printf("[CalendarNotificationCheck] Calendar notifications disabled for %s", user.DID) 96 + return nil 97 + } 98 + 99 + // Get notification lead time (default 1 hour) 100 + leadTime := time.Hour 101 + if settings.CalendarNotificationLeadTime != "" { 102 + parsed, err := time.ParseDuration(settings.CalendarNotificationLeadTime) 103 + if err == nil { 104 + leadTime = parsed 105 + } 106 + } 107 + 108 + // Fetch upcoming events within the lead time window 109 + events, err := c.fetchUpcomingEventsForUser(ctx, user.DID, leadTime) 110 + if err != nil { 111 + return fmt.Errorf("failed to fetch upcoming events: %w", err) 112 + } 113 + 114 + if len(events) == 0 { 115 + return nil // No upcoming events 116 + } 117 + 118 + log.Printf("[CalendarNotificationCheck] Found %d upcoming events for %s", len(events), user.DID) 119 + 120 + // Send notifications for events 121 + for _, event := range events { 122 + if err := c.sendEventNotification(ctx, user.DID, event, leadTime, subscriptions); err != nil { 123 + log.Printf("WARNING: Failed to send notification for event %s: %v", event.RKey, err) 124 + continue 125 + } 126 + } 127 + 128 + return nil 129 + } 130 + 131 + // getUserSettings fetches user settings without requiring a session (public read) 132 + func (c *CalendarNotificationJob) getUserSettings(ctx context.Context, did string) (*models.NotificationSettings, error) { 133 + // Resolve PDS endpoint for this DID 134 + pds, err := c.resolvePDSEndpoint(ctx, did) 135 + if err != nil { 136 + return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err) 137 + } 138 + 139 + // Build the XRPC URL for public read (no auth needed) 140 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.getRecord?repo=%s&collection=%s&rkey=%s", 141 + pds, did, handlers.SettingsCollection, handlers.SettingsRKey) 142 + 143 + // Create request 144 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 145 + if err != nil { 146 + return nil, err 147 + } 148 + 149 + // Make request (no auth needed for public reads) 150 + client := &http.Client{Timeout: 10 * time.Second} 151 + resp, err := client.Do(req) 152 + if err != nil { 153 + return nil, err 154 + } 155 + defer resp.Body.Close() 156 + 157 + if resp.StatusCode != http.StatusOK { 158 + // Settings not found is okay, return defaults 159 + return models.DefaultNotificationSettings(), nil 160 + } 161 + 162 + // Parse response 163 + var result struct { 164 + Value map[string]interface{} `json:"value"` 165 + } 166 + 167 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 168 + return nil, err 169 + } 170 + 171 + // Parse settings 172 + settings := handlers.ParseSettingsRecord(result.Value) 173 + return settings, nil 174 + } 175 + 176 + // fetchUpcomingEventsForUser fetches events for a user without requiring a session (public read) 177 + func (c *CalendarNotificationJob) fetchUpcomingEventsForUser(ctx context.Context, did string, within time.Duration) ([]*models.CalendarEvent, error) { 178 + // Resolve PDS endpoint for this DID 179 + pds, err := c.resolvePDSEndpoint(ctx, did) 180 + if err != nil { 181 + return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err) 182 + } 183 + 184 + // Build the XRPC URL for public read (no auth needed) 185 + url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", 186 + pds, did, handlers.CalendarEventCollection) 187 + 188 + // Create request 189 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 190 + if err != nil { 191 + return nil, err 192 + } 193 + 194 + // Make request (no auth needed for public reads) 195 + client := &http.Client{Timeout: 10 * time.Second} 196 + resp, err := client.Do(req) 197 + if err != nil { 198 + return nil, err 199 + } 200 + defer resp.Body.Close() 201 + 202 + if resp.StatusCode != http.StatusOK { 203 + return nil, fmt.Errorf("XRPC error %d", resp.StatusCode) 204 + } 205 + 206 + // Parse response 207 + var result struct { 208 + Records []struct { 209 + Uri string `json:"uri"` 210 + Cid string `json:"cid"` 211 + Value map[string]interface{} `json:"value"` 212 + } `json:"records"` 213 + } 214 + 215 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 216 + return nil, err 217 + } 218 + 219 + // Convert to CalendarEvent models and filter for upcoming events 220 + upcomingEvents := make([]*models.CalendarEvent, 0) 221 + for _, record := range result.Records { 222 + event, err := models.ParseCalendarEvent(record.Value, record.Uri, record.Cid) 223 + if err != nil { 224 + log.Printf("WARNING: Failed to parse calendar event %s: %v", record.Uri, err) 225 + continue 226 + } 227 + 228 + // Filter for upcoming events within the time window 229 + if event.StartsWithin(within) && !event.IsCancelled() { 230 + upcomingEvents = append(upcomingEvents, event) 231 + } 232 + } 233 + 234 + return upcomingEvents, nil 235 + } 236 + 237 + // resolvePDSEndpoint resolves the PDS endpoint for a given DID 238 + func (c *CalendarNotificationJob) resolvePDSEndpoint(ctx context.Context, did string) (string, error) { 239 + dir := identity.DefaultDirectory() 240 + atid, err := syntax.ParseAtIdentifier(did) 241 + if err != nil { 242 + return "", err 243 + } 244 + 245 + ident, err := dir.Lookup(ctx, *atid) 246 + if err != nil { 247 + return "", err 248 + } 249 + 250 + return ident.PDSEndpoint(), nil 251 + } 252 + 253 + // sendEventNotification sends a notification for an event 254 + func (c *CalendarNotificationJob) sendEventNotification(ctx context.Context, did string, event *models.CalendarEvent, leadTime time.Duration, subscriptions []*models.PushSubscription) error { 255 + // Check if we've already sent a notification for this event 256 + eventURI := event.URI 257 + recent, err := c.repo.GetRecentNotification(did, eventURI, 24) // Don't spam within 24 hours 258 + if err != nil { 259 + log.Printf("WARNING: Error checking notification history: %v", err) 260 + } 261 + if recent != nil { 262 + // Already notified recently, skip 263 + log.Printf("INFO: Skipping notification for event %s - already sent within 24h", event.RKey) 264 + return nil 265 + } 266 + 267 + // Build notification message 268 + title := fmt.Sprintf("Upcoming Event: %s", event.Name) 269 + body := c.buildNotificationBody(event, leadTime) 270 + 271 + notification := &push.Notification{ 272 + Title: title, 273 + Body: body, 274 + Icon: "/static/icon-192.png", 275 + Badge: "/static/icon-192.png", 276 + Tag: fmt.Sprintf("calendar-event-%s", event.RKey), 277 + Data: map[string]interface{}{ 278 + "type": "calendar_event", 279 + "eventURI": eventURI, 280 + "url": event.SmokesignalURL(), 281 + }, 282 + } 283 + 284 + // Send to all subscriptions 285 + successCount, errors := c.sender.SendToAll(subscriptions, notification) 286 + log.Printf("INFO: Sent calendar notification for event %s to %d/%d subscriptions", event.RKey, successCount, len(subscriptions)) 287 + 288 + // Record notification history 289 + status := "sent" 290 + var errMsg string 291 + if successCount == 0 { 292 + status = "failed" 293 + if len(errors) > 0 { 294 + errMsg = fmt.Sprintf("%v", errors[0]) 295 + } 296 + } else if len(errors) > 0 { 297 + errMsg = fmt.Sprintf("Sent to %d/%d subscriptions. Errors: %v", successCount, len(subscriptions), errors[0]) 298 + } 299 + 300 + history := &models.NotificationHistory{ 301 + DID: did, 302 + TaskURI: eventURI, // Reuse TaskURI field for event URI 303 + NotificationType: "calendar_event", 304 + Status: status, 305 + ErrorMessage: errMsg, 306 + } 307 + if err := c.repo.CreateNotificationHistory(history); err != nil { 308 + log.Printf("WARNING: Failed to create notification history: %v", err) 309 + } 310 + 311 + if successCount == 0 { 312 + return fmt.Errorf("failed to send to all subscriptions: %v", errors) 313 + } 314 + 315 + return nil 316 + } 317 + 318 + // buildNotificationBody builds the notification message body 319 + func (c *CalendarNotificationJob) buildNotificationBody(event *models.CalendarEvent, leadTime time.Duration) string { 320 + if event.StartsAt == nil { 321 + return event.Description 322 + } 323 + 324 + timeUntil := time.Until(*event.StartsAt) 325 + 326 + var timeMessage string 327 + if timeUntil < time.Hour { 328 + minutes := int(timeUntil.Minutes()) 329 + timeMessage = fmt.Sprintf("starts in %d minutes", minutes) 330 + } else if timeUntil < 24*time.Hour { 331 + hours := int(timeUntil.Hours()) 332 + timeMessage = fmt.Sprintf("starts in %d hours", hours) 333 + } else { 334 + days := int(timeUntil.Hours() / 24) 335 + timeMessage = fmt.Sprintf("starts in %d days", days) 336 + } 337 + 338 + // Add mode information 339 + var modeInfo string 340 + switch event.Mode { 341 + case models.AttendanceModeVirtual: 342 + modeInfo = "💻 Virtual event" 343 + case models.AttendanceModeInPerson: 344 + modeInfo = "📍 In-person event" 345 + case models.AttendanceModeHybrid: 346 + modeInfo = "🔄 Hybrid event" 347 + } 348 + 349 + if modeInfo != "" { 350 + return fmt.Sprintf("%s %s", modeInfo, timeMessage) 351 + } 352 + 353 + return timeMessage 354 + }
+369
internal/models/calendar.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + ) 8 + 9 + // Event status constants 10 + const ( 11 + EventStatusPlanned = "planned" // Created but not finalized 12 + EventStatusScheduled = "scheduled" // Created and finalized 13 + EventStatusRescheduled = "rescheduled" // Event time/details changed 14 + EventStatusCancelled = "cancelled" // Event removed 15 + EventStatusPostponed = "postponed" // No new date set 16 + ) 17 + 18 + // Attendance mode constants 19 + const ( 20 + AttendanceModeVirtual = "virtual" // Online only 21 + AttendanceModeInPerson = "in-person" // Physical location only 22 + AttendanceModeHybrid = "hybrid" // Both online and in-person 23 + ) 24 + 25 + // RSVP status constants 26 + const ( 27 + RSVPStatusInterested = "interested" // Interested in the event 28 + RSVPStatusGoing = "going" // Going to the event 29 + RSVPStatusNotGoing = "notgoing" // Not going to the event 30 + ) 31 + 32 + // CalendarEvent represents a community.lexicon.calendar.event record 33 + type CalendarEvent struct { 34 + Name string `json:"name"` 35 + Description string `json:"description,omitempty"` 36 + CreatedAt time.Time `json:"createdAt"` 37 + StartsAt *time.Time `json:"startsAt,omitempty"` 38 + EndsAt *time.Time `json:"endsAt,omitempty"` 39 + Mode string `json:"mode,omitempty"` // hybrid, in-person, virtual 40 + Status string `json:"status,omitempty"` // planned, scheduled, etc. 41 + Locations []Location `json:"locations,omitempty"` 42 + URIs []string `json:"uris,omitempty"` 43 + 44 + // AT Protocol metadata 45 + RKey string `json:"rKey,omitempty"` 46 + URI string `json:"uri,omitempty"` 47 + CID string `json:"cid,omitempty"` 48 + } 49 + 50 + // Location represents a location where an event takes place 51 + type Location struct { 52 + Name string `json:"name,omitempty"` 53 + Address string `json:"address,omitempty"` 54 + Lat float64 `json:"lat,omitempty"` 55 + Lon float64 `json:"lon,omitempty"` 56 + } 57 + 58 + // CalendarRSVP represents a community.lexicon.calendar.rsvp record 59 + type CalendarRSVP struct { 60 + Subject *StrongRef `json:"subject"` // Reference to event 61 + Status string `json:"status"` // interested, going, notgoing 62 + 63 + // AT Protocol metadata 64 + RKey string `json:"-"` 65 + URI string `json:"-"` 66 + CID string `json:"-"` 67 + } 68 + 69 + // StrongRef represents a com.atproto.repo.strongRef 70 + type StrongRef struct { 71 + URI string `json:"uri"` 72 + CID string `json:"cid"` 73 + } 74 + 75 + // IsUpcoming returns true if the event starts in the future 76 + func (e *CalendarEvent) IsUpcoming() bool { 77 + if e.StartsAt == nil { 78 + return false 79 + } 80 + return e.StartsAt.After(time.Now()) 81 + } 82 + 83 + // IsPast returns true if the event has already ended 84 + func (e *CalendarEvent) IsPast() bool { 85 + if e.EndsAt != nil { 86 + return e.EndsAt.Before(time.Now()) 87 + } 88 + if e.StartsAt != nil { 89 + return e.StartsAt.Before(time.Now()) 90 + } 91 + return false 92 + } 93 + 94 + // IsCancelled returns true if the event is cancelled or postponed 95 + func (e *CalendarEvent) IsCancelled() bool { 96 + return e.Status == EventStatusCancelled || e.Status == EventStatusPostponed 97 + } 98 + 99 + // StartsWithin returns true if the event starts within the given duration 100 + func (e *CalendarEvent) StartsWithin(d time.Duration) bool { 101 + if e.StartsAt == nil { 102 + return false 103 + } 104 + now := time.Now() 105 + return e.StartsAt.After(now) && e.StartsAt.Before(now.Add(d)) 106 + } 107 + 108 + // FormatStatus returns a human-readable status string 109 + func (e *CalendarEvent) FormatStatus() string { 110 + switch e.Status { 111 + case EventStatusPlanned: 112 + return "Planned" 113 + case EventStatusScheduled: 114 + return "Scheduled" 115 + case EventStatusRescheduled: 116 + return "Rescheduled" 117 + case EventStatusCancelled: 118 + return "Cancelled" 119 + case EventStatusPostponed: 120 + return "Postponed" 121 + case "": // Empty status 122 + return "" 123 + default: 124 + return "" 125 + } 126 + } 127 + 128 + // HasKnownStatus returns true if the event has a recognized status 129 + func (e *CalendarEvent) HasKnownStatus() bool { 130 + switch e.Status { 131 + case EventStatusPlanned, EventStatusScheduled, EventStatusRescheduled, EventStatusCancelled, EventStatusPostponed: 132 + return true 133 + default: 134 + return false 135 + } 136 + } 137 + 138 + // FormatMode returns a human-readable attendance mode string 139 + func (e *CalendarEvent) FormatMode() string { 140 + switch e.Mode { 141 + case AttendanceModeVirtual: 142 + return "Virtual" 143 + case AttendanceModeInPerson: 144 + return "In Person" 145 + case AttendanceModeHybrid: 146 + return "Hybrid" 147 + default: 148 + return "" 149 + } 150 + } 151 + 152 + // FormatRSVPStatus returns a human-readable RSVP status string 153 + func (r *CalendarRSVP) FormatStatus() string { 154 + switch r.Status { 155 + case RSVPStatusInterested: 156 + return "Interested" 157 + case RSVPStatusGoing: 158 + return "Going" 159 + case RSVPStatusNotGoing: 160 + return "Not Going" 161 + default: 162 + return "Unknown" 163 + } 164 + } 165 + 166 + // ExtractDID extracts the DID from the event URI 167 + // Example: at://did:plc:xxx/community.lexicon.calendar.event/abc123 -> did:plc:xxx 168 + func (e *CalendarEvent) ExtractDID() string { 169 + if e.URI == "" { 170 + return "" 171 + } 172 + 173 + // Remove "at://" prefix 174 + uri := e.URI 175 + if len(uri) > 5 && uri[:5] == "at://" { 176 + uri = uri[5:] 177 + } 178 + 179 + // Find first slash to get DID 180 + slashIndex := -1 181 + for i := 0; i < len(uri); i++ { 182 + if uri[i] == '/' { 183 + slashIndex = i 184 + break 185 + } 186 + } 187 + 188 + if slashIndex > 0 { 189 + return uri[:slashIndex] 190 + } 191 + 192 + return "" 193 + } 194 + 195 + // SmokesignalURL returns the Smokesignal event URL if this is a Smokesignal event 196 + // Returns empty string if not a Smokesignal event or if URI cannot be parsed 197 + func (e *CalendarEvent) SmokesignalURL() string { 198 + did := e.ExtractDID() 199 + if did == "" || e.RKey == "" { 200 + return "" 201 + } 202 + 203 + // Smokesignal URL format: https://smokesignal.events/{did}/{rkey} 204 + return fmt.Sprintf("https://smokesignal.events/%s/%s", did, e.RKey) 205 + } 206 + 207 + // TruncatedDescription returns a truncated version of the description 208 + func (e *CalendarEvent) TruncatedDescription(maxLen int) string { 209 + if len(e.Description) <= maxLen { 210 + return e.Description 211 + } 212 + return e.Description[:maxLen] + "..." 213 + } 214 + 215 + // ParseCalendarEvent parses an AT Protocol record into a CalendarEvent 216 + func ParseCalendarEvent(record map[string]interface{}, uri, cid string) (*CalendarEvent, error) { 217 + event := &CalendarEvent{ 218 + URI: uri, 219 + CID: cid, 220 + RKey: extractRKey(uri), 221 + } 222 + 223 + // Required: name 224 + if name, ok := record["name"].(string); ok { 225 + event.Name = name 226 + } else { 227 + return nil, fmt.Errorf("missing required field: name") 228 + } 229 + 230 + // Required: createdAt 231 + if createdAtStr, ok := record["createdAt"].(string); ok { 232 + createdAt, err := time.Parse(time.RFC3339, createdAtStr) 233 + if err != nil { 234 + return nil, fmt.Errorf("invalid createdAt: %w", err) 235 + } 236 + event.CreatedAt = createdAt 237 + } else { 238 + return nil, fmt.Errorf("missing required field: createdAt") 239 + } 240 + 241 + // Optional: description 242 + if description, ok := record["description"].(string); ok { 243 + event.Description = description 244 + } 245 + 246 + // Optional: startsAt 247 + if startsAtStr, ok := record["startsAt"].(string); ok { 248 + startsAt, err := time.Parse(time.RFC3339, startsAtStr) 249 + if err != nil { 250 + return nil, fmt.Errorf("invalid startsAt: %w", err) 251 + } 252 + event.StartsAt = &startsAt 253 + } 254 + 255 + // Optional: endsAt 256 + if endsAtStr, ok := record["endsAt"].(string); ok { 257 + endsAt, err := time.Parse(time.RFC3339, endsAtStr) 258 + if err != nil { 259 + return nil, fmt.Errorf("invalid endsAt: %w", err) 260 + } 261 + event.EndsAt = &endsAt 262 + } 263 + 264 + // Optional: mode 265 + if mode, ok := record["mode"].(string); ok { 266 + // Strip lexicon prefix if present (e.g., "community.lexicon.calendar.event#hybrid" -> "hybrid") 267 + if idx := strings.LastIndex(mode, "#"); idx != -1 { 268 + event.Mode = mode[idx+1:] 269 + } else { 270 + event.Mode = mode 271 + } 272 + } 273 + 274 + // Optional: status 275 + if status, ok := record["status"].(string); ok { 276 + // Strip lexicon prefix if present (e.g., "community.lexicon.calendar.event#scheduled" -> "scheduled") 277 + if idx := strings.LastIndex(status, "#"); idx != -1 { 278 + event.Status = status[idx+1:] 279 + } else { 280 + event.Status = status 281 + } 282 + } 283 + 284 + // Optional: locations 285 + if locationsRaw, ok := record["locations"].([]interface{}); ok { 286 + for _, locRaw := range locationsRaw { 287 + if locMap, ok := locRaw.(map[string]interface{}); ok { 288 + loc := Location{} 289 + if name, ok := locMap["name"].(string); ok { 290 + loc.Name = name 291 + } 292 + if address, ok := locMap["address"].(string); ok { 293 + loc.Address = address 294 + } 295 + if lat, ok := locMap["lat"].(float64); ok { 296 + loc.Lat = lat 297 + } 298 + if lon, ok := locMap["lon"].(float64); ok { 299 + loc.Lon = lon 300 + } 301 + event.Locations = append(event.Locations, loc) 302 + } 303 + } 304 + } 305 + 306 + // Optional: uris 307 + if urisRaw, ok := record["uris"].([]interface{}); ok { 308 + for _, uriRaw := range urisRaw { 309 + if uri, ok := uriRaw.(string); ok { 310 + event.URIs = append(event.URIs, uri) 311 + } 312 + } 313 + } 314 + 315 + return event, nil 316 + } 317 + 318 + // ParseCalendarRSVP parses an AT Protocol record into a CalendarRSVP 319 + func ParseCalendarRSVP(record map[string]interface{}, uri, cid string) (*CalendarRSVP, error) { 320 + rsvp := &CalendarRSVP{ 321 + URI: uri, 322 + CID: cid, 323 + RKey: extractRKey(uri), 324 + } 325 + 326 + // Required: subject 327 + if subjectRaw, ok := record["subject"].(map[string]interface{}); ok { 328 + subject := &StrongRef{} 329 + if subjectURI, ok := subjectRaw["uri"].(string); ok { 330 + subject.URI = subjectURI 331 + } else { 332 + return nil, fmt.Errorf("missing subject.uri") 333 + } 334 + if subjectCID, ok := subjectRaw["cid"].(string); ok { 335 + subject.CID = subjectCID 336 + } else { 337 + return nil, fmt.Errorf("missing subject.cid") 338 + } 339 + rsvp.Subject = subject 340 + } else { 341 + return nil, fmt.Errorf("missing required field: subject") 342 + } 343 + 344 + // Required: status 345 + if status, ok := record["status"].(string); ok { 346 + // Strip lexicon prefix if present (e.g., "community.lexicon.calendar.rsvp#going" -> "going") 347 + if idx := strings.LastIndex(status, "#"); idx != -1 { 348 + rsvp.Status = status[idx+1:] 349 + } else { 350 + rsvp.Status = status 351 + } 352 + } else { 353 + return nil, fmt.Errorf("missing required field: status") 354 + } 355 + 356 + return rsvp, nil 357 + } 358 + 359 + // extractRKey extracts the record key from an AT URI 360 + // Example: at://did:plc:xxx/community.lexicon.calendar.event/abc123 -> abc123 361 + func extractRKey(uri string) string { 362 + // Simple extraction - find last slash and return everything after it 363 + for i := len(uri) - 1; i >= 0; i-- { 364 + if uri[i] == '/' { 365 + return uri[i+1:] 366 + } 367 + } 368 + return "" 369 + }
+20 -12
internal/models/settings.go
··· 23 23 // UI preferences 24 24 TaskInputCollapsed bool `json:"taskInputCollapsed"` // Whether task input form is collapsed 25 25 26 + // Calendar notification settings 27 + CalendarNotificationsEnabled bool `json:"calendarNotificationsEnabled"` // Enable calendar event notifications 28 + CalendarNotificationLeadTime string `json:"calendarNotificationLeadTime,omitempty"` // Lead time for notifications (e.g. "1h", "30m") 29 + NotificationSentHistory map[string]string `json:"notificationSentHistory,omitempty"` // Event RKey -> last sent timestamp 30 + 26 31 // Usage pattern tracking (for smart notification scheduling in Phase 3) 27 32 AppUsageHours map[string]int `json:"appUsageHours,omitempty"` // Hour (0-23) -> count 28 33 ··· 37 42 // DefaultNotificationSettings returns default notification settings 38 43 func DefaultNotificationSettings() *NotificationSettings { 39 44 return &NotificationSettings{ 40 - NotifyOverdue: true, 41 - NotifyToday: true, 42 - NotifySoon: false, 43 - HoursBefore: 1, 44 - CheckFrequency: 30, 45 - QuietHoursEnabled: false, 46 - QuietStart: 22, 47 - QuietEnd: 8, 48 - PushEnabled: false, 49 - TaskInputCollapsed: false, 50 - UpdatedAt: time.Now().UTC(), 51 - RKey: "settings", 45 + NotifyOverdue: true, 46 + NotifyToday: true, 47 + NotifySoon: false, 48 + HoursBefore: 1, 49 + CheckFrequency: 30, 50 + QuietHoursEnabled: false, 51 + QuietStart: 22, 52 + QuietEnd: 8, 53 + PushEnabled: false, 54 + TaskInputCollapsed: false, 55 + CalendarNotificationsEnabled: true, 56 + CalendarNotificationLeadTime: "1h", 57 + NotificationSentHistory: make(map[string]string), 58 + UpdatedAt: time.Now().UTC(), 59 + RKey: "settings", 52 60 } 53 61 }
+340 -3
templates/dashboard.html
··· 482 482 #task-input-container header button:hover { 483 483 transform: scale(1.1); 484 484 } 485 + /* Calendar event styles */ 486 + .event-card { 487 + padding: 1rem; 488 + margin: 0.5rem 0; 489 + border: 1px solid var(--pico-muted-border-color); 490 + border-radius: var(--pico-border-radius); 491 + } 492 + .event-card h4 { 493 + margin: 0 0 0.5rem 0; 494 + } 495 + .event-status-badge { 496 + display: inline-block; 497 + padding: 0.25rem 0.5rem; 498 + font-size: 0.75rem; 499 + font-weight: bold; 500 + border-radius: 4px; 501 + text-transform: uppercase; 502 + } 503 + .event-status-badge.status-scheduled { 504 + background-color: #10b981; 505 + color: white; 506 + } 507 + .event-status-badge.status-planned { 508 + background-color: #3b82f6; 509 + color: white; 510 + } 511 + .event-status-badge.status-rescheduled { 512 + background-color: #f59e0b; 513 + color: white; 514 + } 515 + .event-status-badge.status-cancelled, 516 + .event-status-badge.status-postponed { 517 + background-color: #ef4444; 518 + color: white; 519 + } 520 + .event-meta { 521 + font-size: 0.875rem; 522 + color: var(--pico-muted-color); 523 + margin-top: 0.75rem; 524 + } 525 + .event-meta > div { 526 + margin: 0.25rem 0; 527 + } 528 + .event-actions { 529 + display: flex; 530 + gap: 0.5rem; 531 + justify-content: flex-end; 532 + } 485 533 </style> 486 534 <script> 487 535 // Toast notification system ··· 559 607 560 608 const tasksContainer = document.getElementById(tabName + '-tasks'); 561 609 562 - // Skip reload logic for lists tab (it doesn't have a tasks container) 563 - if (tabName === 'lists') { 610 + // Skip reload logic for lists and calendar tabs (they don't have a tasks container) 611 + if (tabName === 'lists' || tabName === 'calendar') { 564 612 return; 565 613 } 566 614 ··· 974 1022 <button onclick="switchTab('completed')">Completed</button> 975 1023 <button onclick="switchTab('due')">Due</button> 976 1024 <button onclick="switchTab('lists')">Lists</button> 1025 + <button onclick="switchTab('calendar')">📅 Events</button> 977 1026 </div> 978 1027 979 1028 <!-- Incomplete Tasks Tab --> ··· 1039 1088 <p style="margin-top: 1rem; color: var(--pico-muted-color);">Loading lists...</p> 1040 1089 </div> 1041 1090 </div> 1091 + 1092 + <!-- Calendar Events Tab --> 1093 + <div id="calendar-tab" class="tab-content"> 1094 + <article style="margin-bottom: 1rem; background-color: var(--pico-card-background-color); padding: 1rem; border-radius: var(--pico-border-radius);"> 1095 + <p style="margin: 0; color: var(--pico-muted-color);"> 1096 + <strong>Note:</strong> Calendar events are read-only. AT Todo displays events from your AT Protocol repository that were created by other calendar apps. 1097 + </p> 1098 + </article> 1099 + 1100 + <!-- Event Filters --> 1101 + <div style="margin-bottom: 1rem; display: flex; gap: 0.5rem; flex-wrap: wrap;"> 1102 + <button class="secondary active" data-filter="upcoming" onclick="filterEvents('upcoming')" style="padding: 0.25rem 0.75rem; margin: 0;"> 1103 + Upcoming 1104 + </button> 1105 + <button class="secondary" data-filter="all" onclick="filterEvents('all')" style="padding: 0.25rem 0.75rem; margin: 0;"> 1106 + All Events 1107 + </button> 1108 + </div> 1109 + 1110 + <!-- Upcoming Events (next 7 days) --> 1111 + <div id="upcoming-events-container"> 1112 + <h3>Upcoming Events (Next 7 Days)</h3> 1113 + <div id="upcoming-events" 1114 + hx-get="/app/calendar/upcoming?within=168h" 1115 + hx-trigger="load, reload-calendar from:body" 1116 + hx-swap="innerHTML" 1117 + hx-indicator="#calendar-loading"> 1118 + <!-- Upcoming events will be loaded here --> 1119 + </div> 1120 + </div> 1121 + 1122 + <!-- All Events --> 1123 + <div id="all-events-container" style="display: none;"> 1124 + <h3>All Calendar Events</h3> 1125 + <div id="all-events" 1126 + hx-get="/app/calendar/events" 1127 + hx-trigger="reload-calendar from:body, load-all-events from:body" 1128 + hx-swap="innerHTML" 1129 + hx-indicator="#calendar-loading"> 1130 + <!-- All events will be loaded here --> 1131 + </div> 1132 + </div> 1133 + 1134 + <!-- Loading indicator for calendar --> 1135 + <div id="calendar-loading" class="htmx-indicator" style="text-align: center; padding: 2rem; display: none;"> 1136 + <div style="display: inline-block; width: 40px; height: 40px; border: 4px solid var(--pico-primary); border-radius: 50%; border-top-color: transparent; animation: spin 0.8s linear infinite;"></div> 1137 + <p style="margin-top: 1rem; color: var(--pico-muted-color);">Loading events...</p> 1138 + </div> 1139 + </div> 1042 1140 </section> 1141 + 1142 + <!-- Calendar Event Detail Modal --> 1143 + {{template "calendar-event-modal.html"}} 1043 1144 1044 1145 <!-- Settings Dialog (Modal) --> 1045 1146 <dialog id="settings-dialog"> ··· 1095 1196 <script> 1096 1197 // Convert all timestamps to user's local timezone 1097 1198 function formatLocalTime() { 1098 - document.querySelectorAll('time.local-time').forEach(function(timeElement) { 1199 + document.querySelectorAll('time.local-time, time.utc-time').forEach(function(timeElement) { 1099 1200 const datetime = timeElement.getAttribute('datetime'); 1100 1201 if (datetime) { 1101 1202 try { ··· 2015 2116 e.stopPropagation(); 2016 2117 }); 2017 2118 })(); // End of IIFE 2119 + 2120 + // Calendar event functions 2121 + function filterEvents(filter) { 2122 + // Update button states 2123 + document.querySelectorAll('[data-filter]').forEach(btn => { 2124 + btn.classList.remove('active'); 2125 + }); 2126 + event.target.classList.add('active'); 2127 + 2128 + // Show/hide event containers 2129 + if (filter === 'upcoming') { 2130 + document.getElementById('upcoming-events-container').style.display = 'block'; 2131 + document.getElementById('all-events-container').style.display = 'none'; 2132 + } else { 2133 + document.getElementById('upcoming-events-container').style.display = 'none'; 2134 + document.getElementById('all-events-container').style.display = 'block'; 2135 + 2136 + // Trigger HTMX load for all-events container 2137 + const allEventsDiv = document.getElementById('all-events'); 2138 + if (allEventsDiv) { 2139 + // Always trigger to ensure fresh data 2140 + htmx.trigger(allEventsDiv, 'load-all-events'); 2141 + } 2142 + } 2143 + } 2144 + 2145 + async function viewEventDetails(rkey) { 2146 + try { 2147 + // Fetch event details 2148 + const response = await fetch(`/app/calendar/events/${rkey}`); 2149 + if (!response.ok) { 2150 + throw new Error('Failed to fetch event'); 2151 + } 2152 + const event = await response.json(); 2153 + 2154 + // Populate modal 2155 + document.getElementById('event-modal-title').textContent = event.name || 'Event'; 2156 + 2157 + // Smokesignal link - build URL from event URI and rkey 2158 + const smokesignalLink = document.getElementById('event-modal-smokesignal-link'); 2159 + if (event.uri && event.rKey) { 2160 + // Extract DID from URI (format: at://did:plc:xxx/collection/rkey) 2161 + const uriParts = event.uri.replace('at://', '').split('/'); 2162 + if (uriParts.length >= 1) { 2163 + const did = uriParts[0]; 2164 + const smokesignalURL = `https://smokesignal.events/${did}/${event.rKey}`; 2165 + smokesignalLink.href = smokesignalURL; 2166 + smokesignalLink.style.display = 'inline-flex'; 2167 + } else { 2168 + smokesignalLink.style.display = 'none'; 2169 + } 2170 + } else { 2171 + smokesignalLink.style.display = 'none'; 2172 + } 2173 + 2174 + // Status badge 2175 + const statusDiv = document.getElementById('event-modal-status'); 2176 + if (event.status) { 2177 + statusDiv.innerHTML = `<span class="event-status-badge status-${event.status}">${formatStatus(event.status)}</span>`; 2178 + } else { 2179 + statusDiv.innerHTML = ''; 2180 + } 2181 + 2182 + // Description 2183 + const descDiv = document.getElementById('event-modal-description'); 2184 + descDiv.textContent = event.description || ''; 2185 + descDiv.style.display = event.description ? 'block' : 'none'; 2186 + 2187 + // Start/End times 2188 + const startsDiv = document.getElementById('event-modal-starts'); 2189 + const endsDiv = document.getElementById('event-modal-ends'); 2190 + if (event.startsAt) { 2191 + startsDiv.innerHTML = `<strong>📅 Starts:</strong> <time class="utc-time" datetime="${event.startsAt}">${event.startsAt}</time>`; 2192 + startsDiv.style.display = 'block'; 2193 + } else { 2194 + startsDiv.style.display = 'none'; 2195 + } 2196 + if (event.endsAt) { 2197 + endsDiv.innerHTML = `<strong>🏁 Ends:</strong> <time class="utc-time" datetime="${event.endsAt}">${event.endsAt}</time>`; 2198 + endsDiv.style.display = 'block'; 2199 + } else { 2200 + endsDiv.style.display = 'none'; 2201 + } 2202 + 2203 + // Mode 2204 + const modeDiv = document.getElementById('event-modal-mode'); 2205 + if (event.mode) { 2206 + let modeIcon = '🔄'; 2207 + let modeText = event.mode; 2208 + if (event.mode === 'virtual') { 2209 + modeIcon = '💻'; 2210 + modeText = 'Virtual'; 2211 + } else if (event.mode === 'in-person') { 2212 + modeIcon = '📍'; 2213 + modeText = 'In Person'; 2214 + } else if (event.mode === 'hybrid') { 2215 + modeIcon = '🔄'; 2216 + modeText = 'Hybrid'; 2217 + } 2218 + modeDiv.innerHTML = `<strong>${modeIcon} Mode:</strong> ${modeText}`; 2219 + modeDiv.style.display = 'block'; 2220 + } else { 2221 + modeDiv.style.display = 'none'; 2222 + } 2223 + 2224 + // Locations 2225 + const locationsDiv = document.getElementById('event-modal-locations'); 2226 + if (event.locations && event.locations.length > 0) { 2227 + const locationsList = event.locations.map(loc => { 2228 + if (loc.name && loc.address) { 2229 + return `${loc.name} (${loc.address})`; 2230 + } 2231 + return loc.name || loc.address || ''; 2232 + }).filter(l => l).join(', '); 2233 + locationsDiv.innerHTML = `<strong>📍 Location${event.locations.length > 1 ? 's' : ''}:</strong> ${locationsList}`; 2234 + locationsDiv.style.display = 'block'; 2235 + } else { 2236 + locationsDiv.style.display = 'none'; 2237 + } 2238 + 2239 + // URIs 2240 + const urisDiv = document.getElementById('event-modal-uris'); 2241 + if (event.uris && event.uris.length > 0) { 2242 + const urisList = event.uris.map(uri => `<a href="${uri}" target="_blank" rel="noopener noreferrer">${uri}</a>`).join('<br>'); 2243 + urisDiv.innerHTML = `<strong>🔗 Links:</strong><br>${urisList}`; 2244 + urisDiv.style.display = 'block'; 2245 + } else { 2246 + urisDiv.style.display = 'none'; 2247 + } 2248 + 2249 + // Created date 2250 + const createdDiv = document.getElementById('event-modal-created'); 2251 + if (event.createdAt) { 2252 + createdDiv.innerHTML = `<strong>Created:</strong> <time class="utc-time" datetime="${event.createdAt}">${event.createdAt}</time>`; 2253 + } else { 2254 + createdDiv.innerHTML = ''; 2255 + } 2256 + 2257 + // Format times after populating 2258 + setTimeout(formatLocalTime, 0); 2259 + 2260 + // Show RSVP information with link to Smokesignal 2261 + loadEventRSVPs(event); 2262 + 2263 + // Show modal 2264 + document.getElementById('event-detail-modal').showModal(); 2265 + } catch (error) { 2266 + console.error('Error loading event:', error); 2267 + showToast('Failed to load event details', 'error'); 2268 + } 2269 + } 2270 + 2271 + async function loadEventRSVPs(event) { 2272 + const rsvpListDiv = document.getElementById('event-rsvp-list'); 2273 + 2274 + // Build Smokesignal URL for viewing all RSVPs 2275 + if (event.uri && event.rKey) { 2276 + const uriParts = event.uri.replace('at://', '').split('/'); 2277 + if (uriParts.length >= 1) { 2278 + const did = uriParts[0]; 2279 + const smokesignalURL = `https://smokesignal.events/${did}/${event.rKey}`; 2280 + 2281 + // Try to fetch user's own RSVP status 2282 + try { 2283 + const rsvpResponse = await fetch(`/app/calendar/events/${event.rKey}/rsvps`); 2284 + let userRsvpHtml = ''; 2285 + 2286 + if (rsvpResponse.ok) { 2287 + const rsvps = await rsvpResponse.json(); 2288 + if (rsvps && rsvps.length > 0) { 2289 + const userRsvp = rsvps[0]; // User's own RSVP 2290 + const statusEmoji = { 2291 + 'going': '✓', 2292 + 'interested': 'ⓘ', 2293 + 'notgoing': '✗' 2294 + }; 2295 + const statusColor = { 2296 + 'going': '#10b981', 2297 + 'interested': '#3b82f6', 2298 + 'notgoing': '#ef4444' 2299 + }; 2300 + const statusText = { 2301 + 'going': 'Going', 2302 + 'interested': 'Interested', 2303 + 'notgoing': 'Not Going' 2304 + }; 2305 + 2306 + userRsvpHtml = ` 2307 + <div style="margin-bottom: 1rem; padding: 0.75rem; background: var(--pico-card-background-color); border-radius: 0.25rem; border-left: 3px solid ${statusColor[userRsvp.status]};"> 2308 + <strong>Your RSVP:</strong> 2309 + <span style="color: ${statusColor[userRsvp.status]};"> 2310 + ${statusEmoji[userRsvp.status]} ${statusText[userRsvp.status]} 2311 + </span> 2312 + </div> 2313 + `; 2314 + } 2315 + } 2316 + 2317 + rsvpListDiv.innerHTML = ` 2318 + ${userRsvpHtml} 2319 + <p style="color: var(--pico-muted-color); margin-bottom: 0.75rem; font-size: 0.875rem;"> 2320 + All RSVPs are managed on Smokesignal 2321 + </p> 2322 + <a href="${smokesignalURL}" target="_blank" rel="noopener noreferrer" class="secondary" style="display: inline-block; font-size: 0.875rem;"> 2323 + View all RSVPs 💨 2324 + </a> 2325 + `; 2326 + } catch (error) { 2327 + console.error('Error fetching RSVP:', error); 2328 + rsvpListDiv.innerHTML = ` 2329 + <p style="color: var(--pico-muted-color); margin-bottom: 0.75rem; font-size: 0.875rem;"> 2330 + All RSVPs are managed on Smokesignal 2331 + </p> 2332 + <a href="${smokesignalURL}" target="_blank" rel="noopener noreferrer" class="secondary" style="display: inline-block; font-size: 0.875rem;"> 2333 + View all RSVPs 💨 2334 + </a> 2335 + `; 2336 + } 2337 + } else { 2338 + rsvpListDiv.innerHTML = '<p style="color: var(--pico-muted-color); font-style: italic;">RSVP information not available</p>'; 2339 + } 2340 + } else { 2341 + rsvpListDiv.innerHTML = '<p style="color: var(--pico-muted-color); font-style: italic;">RSVP information not available</p>'; 2342 + } 2343 + } 2344 + 2345 + function formatStatus(status) { 2346 + const statusMap = { 2347 + 'planned': 'Planned', 2348 + 'scheduled': 'Scheduled', 2349 + 'rescheduled': 'Rescheduled', 2350 + 'cancelled': 'Cancelled', 2351 + 'postponed': 'Postponed' 2352 + }; 2353 + return statusMap[status] || status; 2354 + } 2018 2355 </script> 2019 2356 </body> 2020 2357 </html>
+71
templates/partials/calendar-event-card.html
··· 1 + {{define "calendar-event-card.html"}} 2 + <article class="event-card" data-event-uri="{{.URI}}"> 3 + <header> 4 + <div style="display: flex; justify-content: space-between; align-items: start; gap: 0.5rem;"> 5 + <h4 style="margin: 0; flex: 1;">{{.Name}}</h4> 6 + <div style="display: flex; align-items: center; gap: 0.5rem;"> 7 + {{if .HasKnownStatus}} 8 + <span class="event-status-badge status-{{.Status}}">{{.FormatStatus}}</span> 9 + {{end}} 10 + {{if .SmokesignalURL}} 11 + <a href="{{.SmokesignalURL}}" target="_blank" rel="noopener noreferrer" title="View on Smokesignal" style="display: inline-flex; align-items: center; text-decoration: none; font-size: 18px;"> 12 + 💨 13 + </a> 14 + {{end}} 15 + </div> 16 + </div> 17 + </header> 18 + 19 + {{if .Description}} 20 + <p style="margin: 0.5rem 0; color: var(--pico-muted-color);">{{.TruncatedDescription 150}}</p> 21 + {{end}} 22 + 23 + <div class="event-meta"> 24 + {{if .StartsAt}} 25 + <div class="event-time"> 26 + <strong>📅 Starts:</strong> 27 + <time class="utc-time" datetime="{{.StartsAt.Format "2006-01-02T15:04:05Z07:00"}}"> 28 + {{.StartsAt.Format "2006-01-02T15:04:05Z07:00"}} 29 + </time> 30 + </div> 31 + {{end}} 32 + 33 + {{if .EndsAt}} 34 + <div class="event-time"> 35 + <strong>🏁 Ends:</strong> 36 + <time class="utc-time" datetime="{{.EndsAt.Format "2006-01-02T15:04:05Z07:00"}}"> 37 + {{.EndsAt.Format "2006-01-02T15:04:05Z07:00"}} 38 + </time> 39 + </div> 40 + {{end}} 41 + 42 + {{if .Mode}} 43 + <div class="event-mode"> 44 + {{if eq .Mode "virtual"}} 45 + <strong>💻 Mode:</strong> Virtual 46 + {{else if eq .Mode "in-person"}} 47 + <strong>📍 Mode:</strong> In Person 48 + {{else if eq .Mode "hybrid"}} 49 + <strong>🔄 Mode:</strong> Hybrid 50 + {{end}} 51 + </div> 52 + {{end}} 53 + 54 + {{if .Locations}} 55 + <div class="event-locations"> 56 + <strong>📍 Location{{if gt (len .Locations) 1}}s{{end}}:</strong> 57 + {{range $i, $loc := .Locations}} 58 + {{if $i}}, {{end}} 59 + {{if $loc.Name}}{{$loc.Name}}{{else if $loc.Address}}{{$loc.Address}}{{end}} 60 + {{end}} 61 + </div> 62 + {{end}} 63 + </div> 64 + 65 + <div class="event-actions" style="margin-top: 0.75rem;"> 66 + <button type="button" class="secondary" style="padding: 0.25rem 0.75rem; margin: 0;" onclick="viewEventDetails('{{.RKey}}')"> 67 + View Details 68 + </button> 69 + </div> 70 + </article> 71 + {{end}}
+54
templates/partials/calendar-event-modal.html
··· 1 + {{define "calendar-event-modal.html"}} 2 + <!-- Event Detail Modal --> 3 + <dialog id="event-detail-modal"> 4 + <article style="width: 90vw; max-width: 600px;"> 5 + <header> 6 + <button aria-label="Close" rel="prev" onclick="document.getElementById('event-detail-modal').close()"></button> 7 + <div style="display: flex; justify-content: space-between; align-items: center; gap: 1rem;"> 8 + <h3 id="event-modal-title" style="margin: 0; flex: 1;"></h3> 9 + <a id="event-modal-smokesignal-link" href="#" target="_blank" rel="noopener noreferrer" title="View on Smokesignal" style="display: none; flex-shrink: 0; text-decoration: none; font-size: 24px;"> 10 + 💨 11 + </a> 12 + </div> 13 + </header> 14 + 15 + <div id="event-modal-content"> 16 + <!-- Status Badge --> 17 + <div id="event-modal-status" style="margin-bottom: 1rem;"></div> 18 + 19 + <!-- Description --> 20 + <div id="event-modal-description" style="margin-bottom: 1rem; color: var(--pico-muted-color);"></div> 21 + 22 + <!-- Date/Time Information --> 23 + <div id="event-modal-times" style="margin-bottom: 1rem;"> 24 + <div id="event-modal-starts" class="event-time" style="margin-bottom: 0.5rem;"></div> 25 + <div id="event-modal-ends" class="event-time"></div> 26 + </div> 27 + 28 + <!-- Mode --> 29 + <div id="event-modal-mode" style="margin-bottom: 1rem;"></div> 30 + 31 + <!-- Locations --> 32 + <div id="event-modal-locations" style="margin-bottom: 1rem;"></div> 33 + 34 + <!-- URIs/Links --> 35 + <div id="event-modal-uris" style="margin-bottom: 1rem;"></div> 36 + 37 + <!-- RSVP Information --> 38 + <div id="event-modal-rsvps" style="margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--pico-muted-border-color);"> 39 + <h4 style="margin-top: 0;">RSVPs</h4> 40 + <div id="event-rsvp-list"> 41 + <p style="color: var(--pico-muted-color); font-style: italic;">Loading RSVPs...</p> 42 + </div> 43 + </div> 44 + 45 + <!-- Created Date --> 46 + <div id="event-modal-created" style="margin-top: 1.5rem; font-size: 0.875rem; color: var(--pico-muted-color);"></div> 47 + </div> 48 + 49 + <footer> 50 + <button class="secondary" onclick="document.getElementById('event-detail-modal').close()">Close</button> 51 + </footer> 52 + </article> 53 + </dialog> 54 + {{end}}