+44
-11
cmd/server/main.go
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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}}