An event companion and onboarding experience for the ATmosphere (alpha) atmo.quest
6

Configure Feed

Select the types of activity you want to include in your feed.

docs: implementation plan for current-event connection association

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

authored by

Brittany Ellich
Claude Opus 4.8
and committed by
Tangled
(Jun 1, 2026, 3:21 PM UTC) 5e61c14a 4796eea5

+540
+540
docs/superpowers/plans/2026-06-01-associate-connection-with-current-event.md
··· 1 + # Associate a Connection With Current Event — Implementation Plan 2 + 3 + > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. 4 + 5 + **Goal:** On a connection's profile page, let a checked-in ATProto user tag that person's `quest.atmo.connection` PDS record with the event they're currently checked into, reversibly and in place. 6 + 7 + **Architecture:** A new `connection.SetEvent` updates an existing connection record in place via `com.atproto.repo.putRecord` (reusing the rkey, mirroring `event.Update`). The connection `View` handler resolves the current check-in and the target's most-recent connection record, passing both to the template, which renders a checkbox. A new `POST /connections/{did}/event` endpoint, driven by a small client script, performs the toggle — re-deriving the event server-side and never trusting the client for anything but the boolean. 8 + 9 + **Tech Stack:** Go, chi router, templ, indigo atproto OAuth client, SQLite, vanilla JS. 10 + 11 + --- 12 + 13 + ### Task 1: `connection.SetEvent` + record-finding helpers 14 + 15 + **Files:** 16 + - Modify: `internal/connection/connection.go` 17 + - Modify: `internal/connection/list.go` 18 + - Test: `internal/connection/connection_test.go` 19 + - Test: `internal/connection/list_test.go` (create if absent) 20 + 21 + - [ ] **Step 1: Write failing tests for the record-value helper and target finder** 22 + 23 + Add to `internal/connection/connection_test.go`: 24 + 25 + ```go 26 + func TestBuildConnectionValue_OmitsEmptyEvent(t *testing.T) { 27 + v := buildConnectionValue(syntax.DID(didA), time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), "") 28 + if v["$type"] != NSID { 29 + t.Errorf("missing $type, got %v", v["$type"]) 30 + } 31 + if v["with"] != didA { 32 + t.Errorf("with = %v, want %s", v["with"], didA) 33 + } 34 + if _, ok := v["event"]; ok { 35 + t.Error("event should be omitted when empty") 36 + } 37 + } 38 + 39 + func TestBuildConnectionValue_IncludesEvent(t *testing.T) { 40 + v := buildConnectionValue(syntax.DID(didA), time.Now().UTC(), "at://did:plc:x/quest.atmo.event/abc") 41 + if v["event"] != "at://did:plc:x/quest.atmo.event/abc" { 42 + t.Errorf("event = %v", v["event"]) 43 + } 44 + } 45 + 46 + func TestSetEvent_RejectsNilSession(t *testing.T) { 47 + err := SetEvent(context.Background(), nil, "at://did:plc:x/quest.atmo.connection/r1", syntax.DID(didA), time.Now(), "") 48 + if err == nil || !strings.Contains(err.Error(), "nil oauth session") { 49 + t.Fatalf("expected nil session error, got %v", err) 50 + } 51 + } 52 + 53 + func TestSetEvent_RejectsBadURI(t *testing.T) { 54 + sess := &oauth.ClientSession{Data: &oauth.ClientSessionData{AccountDID: syntax.DID(didB)}} 55 + err := SetEvent(context.Background(), sess, "", syntax.DID(didA), time.Now(), "") 56 + if err == nil || !strings.Contains(err.Error(), "rkey") { 57 + t.Fatalf("expected rkey error, got %v", err) 58 + } 59 + } 60 + ``` 61 + 62 + Add to `internal/connection/list_test.go` (create the file with the same package and imports if it does not exist — `package connection`, importing `testing`, `time`, and `github.com/bluesky-social/indigo/atproto/syntax`): 63 + 64 + ```go 65 + func TestFindForTarget_PicksMostRecent(t *testing.T) { 66 + target := syntax.DID(didA) 67 + older := ListEntry{URI: "at://x/c/old", With: target, ConnectedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EventURI: "at://x/e/1"} 68 + newer := ListEntry{URI: "at://x/c/new", With: target, ConnectedAt: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC), EventURI: "at://x/e/2"} 69 + other := ListEntry{URI: "at://x/c/z", With: syntax.DID(didB), ConnectedAt: time.Now()} 70 + 71 + got, ok := FindForTarget([]ListEntry{older, other, newer}, target) 72 + if !ok { 73 + t.Fatal("expected to find target") 74 + } 75 + if got.URI != "at://x/c/new" { 76 + t.Errorf("URI = %s, want at://x/c/new", got.URI) 77 + } 78 + } 79 + 80 + func TestFindForTarget_NotFound(t *testing.T) { 81 + _, ok := FindForTarget([]ListEntry{{With: syntax.DID(didB)}}, syntax.DID(didA)) 82 + if ok { 83 + t.Error("expected not found") 84 + } 85 + } 86 + ``` 87 + 88 + Note: `didA` and `didB` are already defined as test constants in the `connection` package (used by `connection_test.go`). If `list_test.go` is a new file in the same package they are visible; do not redefine them. 89 + 90 + - [ ] **Step 2: Run the tests to verify they fail** 91 + 92 + Run: `cd /Users/brittany/Documents/Collective/atmoquest && go test ./internal/connection/ -run 'BuildConnectionValue|SetEvent|FindForTarget' -v` 93 + Expected: FAIL — `undefined: buildConnectionValue`, `undefined: SetEvent`, `undefined: FindForTarget`. 94 + 95 + - [ ] **Step 3: Implement `FindForTarget` in `list.go`** 96 + 97 + Append to `internal/connection/list.go`: 98 + 99 + ```go 100 + // FindForTarget returns the most-recent connection entry for the given target 101 + // DID. When several records exist (e.g. across multiple events), the one with 102 + // the latest ConnectedAt wins — matching the dedup the list/profile pages show. 103 + // Returns ok=false when the target has no connection record. 104 + func FindForTarget(entries []ListEntry, target syntax.DID) (ListEntry, bool) { 105 + var best ListEntry 106 + found := false 107 + for _, e := range entries { 108 + if e.With != target { 109 + continue 110 + } 111 + if !found || e.ConnectedAt.After(best.ConnectedAt) { 112 + best = e 113 + found = true 114 + } 115 + } 116 + return best, found 117 + } 118 + ``` 119 + 120 + - [ ] **Step 4: Implement `buildConnectionValue` and `SetEvent` in `connection.go`** 121 + 122 + Add the `nsidPutRecord` constant alongside the existing constants in `connection.go`: 123 + 124 + ```go 125 + // putRecord is the XRPC procedure for in-place updates. 126 + nsidPutRecord = "com.atproto.repo.putRecord" 127 + ``` 128 + 129 + Append to `internal/connection/connection.go`: 130 + 131 + ```go 132 + // buildConnectionValue assembles the record body for a quest.atmo.connection 133 + // record. The event field is included only when eventURI is non-empty. 134 + func buildConnectionValue(with syntax.DID, connectedAt time.Time, eventURI string) map[string]any { 135 + value := map[string]any{ 136 + "$type": NSID, 137 + "with": with.String(), 138 + "connectedAt": connectedAt.UTC().Format(time.RFC3339), 139 + } 140 + if eventURI != "" { 141 + value["event"] = eventURI 142 + } 143 + return value 144 + } 145 + 146 + // SetEvent rewrites an existing connection record in place (putRecord, reusing 147 + // the record's rkey) so its event association becomes eventURI. Passing an 148 + // empty eventURI clears the association. The caller carries the record's 149 + // current `with` and `connectedAt` (from the list entry) so no fields are 150 + // dropped. The at-uri stays stable because the rkey is reused. 151 + // 152 + // Caller must hold the repo:quest.atmo.connection OAuth scope. 153 + func SetEvent(ctx context.Context, sess *oauth.ClientSession, recordURI string, with syntax.DID, connectedAt time.Time, eventURI string) error { 154 + if sess == nil { 155 + return errors.New("connection: nil oauth session") 156 + } 157 + rkey := rkeyFromURI(recordURI) 158 + if rkey == "" { 159 + return fmt.Errorf("connection: cannot parse rkey from %q", recordURI) 160 + } 161 + if connectedAt.IsZero() { 162 + connectedAt = time.Now().UTC() 163 + } 164 + 165 + input := map[string]any{ 166 + "repo": sess.Data.AccountDID.String(), 167 + "collection": NSID, 168 + "rkey": rkey, 169 + // `validate` omitted — see Put for rationale. 170 + "record": buildConnectionValue(with, connectedAt, eventURI), 171 + } 172 + if err := sess.APIClient().Post(ctx, syntax.NSID(nsidPutRecord), input, nil); err != nil { 173 + return fmt.Errorf("putRecord %s: %w", NSID, err) 174 + } 175 + return nil 176 + } 177 + 178 + // rkeyFromURI extracts the record key (last path segment) from an at:// URI. 179 + func rkeyFromURI(uri string) string { 180 + if uri == "" { 181 + return "" 182 + } 183 + parts := strings.Split(uri, "/") 184 + return parts[len(parts)-1] 185 + } 186 + ``` 187 + 188 + Add `"strings"` to the import block in `connection.go` if it is not already imported. 189 + 190 + - [ ] **Step 5: Run the tests to verify they pass** 191 + 192 + Run: `cd /Users/brittany/Documents/Collective/atmoquest && go test ./internal/connection/ -v` 193 + Expected: PASS (all connection tests, including the new ones). 194 + 195 + - [ ] **Step 6: Commit** 196 + 197 + ```bash 198 + cd /Users/brittany/Documents/Collective/atmoquest 199 + git add internal/connection/ 200 + git commit -m "feat(connection): SetEvent for in-place event tagging + FindForTarget" 201 + ``` 202 + 203 + --- 204 + 205 + ### Task 2: Add fields to `ProfileView` and render the checkbox 206 + 207 + **Files:** 208 + - Modify: `features/connections/pages/profile.templ` 209 + 210 + - [ ] **Step 1: Add the new fields to the `ProfileView` struct** 211 + 212 + In `features/connections/pages/profile.templ`, extend the struct (after `FollowUp bool`): 213 + 214 + ```go 215 + FollowUp bool // follow-up flag from SQLite 216 + // Current-event association (only populated for ATProto viewers who are 217 + // checked into an ongoing event AND have a PDS connection record here). 218 + CurrentEventURI string // at-uri of the ongoing event, "" if not checked in 219 + CurrentEventName string // display name for the checkbox label 220 + ConnRecordURI string // at-uri (rkey) of the record to update, "" if none 221 + AssociatedWithCurrent bool // initial checked state 222 + ``` 223 + 224 + - [ ] **Step 2: Render the checkbox inside the notes section** 225 + 226 + In the same file, immediately after the `</label>` that closes the follow-up toggle (around the `pv-followup-toggle` block) and before the closing `</div>` of `pv-notes-section`, add: 227 + 228 + ```go 229 + if v.CurrentEventURI != "" && v.ConnRecordURI != "" { 230 + <label class="pv-event-toggle" data-target-did={ v.TargetDID }> 231 + if v.AssociatedWithCurrent { 232 + <input type="checkbox" id="pv-event-associate" checked/> 233 + } else { 234 + <input type="checkbox" id="pv-event-associate"/> 235 + } 236 + <span class="pv-event-text">📍 met at { v.CurrentEventName }</span> 237 + <span class="pv-event-saved muted" id="pv-event-status"></span> 238 + </label> 239 + } 240 + ``` 241 + 242 + - [ ] **Step 3: Add the script include** 243 + 244 + In the same file, next to the existing `<script src="/static/js/notes.js" defer></script>` line, add below it: 245 + 246 + ```go 247 + <script src="/static/js/connection-event.js" defer></script> 248 + ``` 249 + 250 + - [ ] **Step 4: Regenerate the templ Go file** 251 + 252 + Run: `cd /Users/brittany/Documents/Collective/atmoquest && go tool templ generate -f features/connections/pages/profile.templ` 253 + Expected: clean output, `features/connections/pages/profile_templ.go` updated. 254 + 255 + - [ ] **Step 5: Verify it builds** 256 + 257 + Run: `cd /Users/brittany/Documents/Collective/atmoquest && go build ./...` 258 + Expected: silent success. 259 + 260 + - [ ] **Step 6: Commit** 261 + 262 + ```bash 263 + cd /Users/brittany/Documents/Collective/atmoquest 264 + git add features/connections/pages/profile.templ features/connections/pages/profile_templ.go 265 + git commit -m "feat(connections): render current-event association checkbox" 266 + ``` 267 + 268 + --- 269 + 270 + ### Task 3: Populate the new fields in the `View` handler 271 + 272 + **Files:** 273 + - Modify: `features/connections/handlers.go` (the `View` method, around lines 420–445) 274 + 275 + - [ ] **Step 1: Wire current-event + record lookup into `View`** 276 + 277 + In `features/connections/handlers.go`, the `View` method already computes 278 + `viewerPDS := h.lookupPDSForDID(r, viewerDID)` and 279 + `entries, err := connection.List(r.Context(), viewerPDS, viewerDID)` and then 280 + loops over `entries` to set `view.ConnectedAt` / `view.EventName`. 281 + 282 + Immediately **after** that existing loop (after the `for _, e := range entries { ... }` block that sets `ConnectedAt`), add: 283 + 284 + ```go 285 + // Current-event association: only for ATProto viewers checked into an 286 + // ongoing event who have a PDS connection record with this target. 287 + if evURI, ok, cErr := checkin.Current(r.Context(), h.DB, viewerDID); cErr == nil && ok && evURI != "" { 288 + if rec, found := connection.FindForTarget(entries, target); found { 289 + view.CurrentEventURI = evURI 290 + view.ConnRecordURI = rec.URI 291 + view.AssociatedWithCurrent = rec.EventURI == evURI 292 + if ev, gErr := event.Get(r.Context(), h.DB, evURI); gErr == nil { 293 + view.CurrentEventName = ev.Name 294 + } 295 + } 296 + } 297 + ``` 298 + 299 + `checkin`, `connection`, and `event` are already imported in this file. No new imports. 300 + 301 + - [ ] **Step 2: Verify it builds** 302 + 303 + Run: `cd /Users/brittany/Documents/Collective/atmoquest && go build ./...` 304 + Expected: silent success. 305 + 306 + - [ ] **Step 3: Commit** 307 + 308 + ```bash 309 + cd /Users/brittany/Documents/Collective/atmoquest 310 + git add features/connections/handlers.go 311 + git commit -m "feat(connections): pass current-event association data to profile view" 312 + ``` 313 + 314 + --- 315 + 316 + ### Task 4: `POST /connections/{did}/event` endpoint + route 317 + 318 + **Files:** 319 + - Modify: `features/connections/handlers.go` (new `SetConnectionEvent` method) 320 + - Modify: `features/connections/routes.go` 321 + 322 + - [ ] **Step 1: Add the handler method** 323 + 324 + Append a new method to `features/connections/handlers.go`: 325 + 326 + ```go 327 + // SetConnectionEvent handles POST /connections/{did}/event — toggles whether 328 + // the viewer's connection record with {did} is tagged with the event the 329 + // viewer is currently checked into. Body: {"associate": bool}. 330 + // 331 + // The event is always re-derived server-side from checkin.Current; the client 332 + // only sends the boolean. Updates the most-recent connection record in place. 333 + func (h *Handlers) SetConnectionEvent(w http.ResponseWriter, r *http.Request) { 334 + viewerDID, sess, ok := h.Auth.RequireSession(w, r) 335 + if !ok { 336 + return // RequireSession wrote the redirect/response 337 + } 338 + 339 + targetStr, err := url.PathUnescape(chi.URLParam(r, "did")) 340 + if err != nil { 341 + http.Error(w, "invalid DID", http.StatusBadRequest) 342 + return 343 + } 344 + target, err := syntax.ParseDID(targetStr) 345 + if err != nil { 346 + http.Error(w, "invalid DID", http.StatusBadRequest) 347 + return 348 + } 349 + 350 + var body struct { 351 + Associate bool `json:"associate"` 352 + } 353 + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4*1024)).Decode(&body); err != nil { 354 + http.Error(w, "invalid JSON", http.StatusBadRequest) 355 + return 356 + } 357 + 358 + // Re-derive the current event server-side. 359 + eventURI := "" 360 + if body.Associate { 361 + evURI, checked, cErr := checkin.Current(r.Context(), h.DB, viewerDID) 362 + if cErr != nil { 363 + slog.Warn("set connection event: checkin.Current", "err", cErr) 364 + http.Error(w, "failed to resolve current event", http.StatusInternalServerError) 365 + return 366 + } 367 + if !checked || evURI == "" { 368 + http.Error(w, "not checked into an ongoing event", http.StatusBadRequest) 369 + return 370 + } 371 + eventURI = evURI 372 + } 373 + 374 + // Find the most-recent connection record for this target. 375 + viewerPDS := h.lookupPDSForDID(r, viewerDID) 376 + entries, err := connection.List(r.Context(), viewerPDS, viewerDID) 377 + if err != nil { 378 + slog.Warn("set connection event: list connections", "err", err) 379 + http.Error(w, "failed to load connection", http.StatusInternalServerError) 380 + return 381 + } 382 + rec, found := connection.FindForTarget(entries, target) 383 + if !found { 384 + http.Error(w, "no connection record found", http.StatusNotFound) 385 + return 386 + } 387 + 388 + if err := connection.SetEvent(r.Context(), sess, rec.URI, rec.With, rec.ConnectedAt, eventURI); err != nil { 389 + slog.Warn("set connection event: putRecord", "uri", rec.URI, "err", err) 390 + http.Error(w, "failed to update record", http.StatusInternalServerError) 391 + return 392 + } 393 + 394 + w.Header().Set("Content-Type", "application/json") 395 + _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) 396 + } 397 + ``` 398 + 399 + All referenced packages (`url`, `json`, `slog`, `http`, `chi`, `syntax`, `checkin`, `connection`) are already imported in this file. 400 + 401 + - [ ] **Step 2: Register the route** 402 + 403 + In `features/connections/routes.go`, add inside `SetupRoutes` after the notes route: 404 + 405 + ```go 406 + router.Post("/connections/{did}/event", h.SetConnectionEvent) 407 + ``` 408 + 409 + Update the doc comment above `SetupRoutes` to list the new route: 410 + 411 + ```go 412 + // - POST /connections/{did}/event — associate/clear the current event tag 413 + ``` 414 + 415 + - [ ] **Step 3: Verify it builds** 416 + 417 + Run: `cd /Users/brittany/Documents/Collective/atmoquest && go build ./...` 418 + Expected: silent success. 419 + 420 + - [ ] **Step 4: Commit** 421 + 422 + ```bash 423 + cd /Users/brittany/Documents/Collective/atmoquest 424 + git add features/connections/handlers.go features/connections/routes.go 425 + git commit -m "feat(connections): POST /connections/{did}/event endpoint" 426 + ``` 427 + 428 + --- 429 + 430 + ### Task 5: Client script 431 + 432 + **Files:** 433 + - Create: `web/resources/static/js/connection-event.js` 434 + 435 + - [ ] **Step 1: Write the script** 436 + 437 + Create `web/resources/static/js/connection-event.js`: 438 + 439 + ```javascript 440 + // atmo.quest — toggle whether a connection is tagged with the event you're 441 + // currently checked into. POSTs {associate: bool} to 442 + // /connections/{did}/event. The server re-derives which event; we only send 443 + // the boolean. On failure we revert the checkbox so the UI matches the PDS. 444 + (function () { 445 + "use strict"; 446 + 447 + var toggle = document.querySelector(".pv-event-toggle"); 448 + if (!toggle) return; 449 + 450 + var targetDID = toggle.getAttribute("data-target-did"); 451 + var checkbox = document.getElementById("pv-event-associate"); 452 + var status = document.getElementById("pv-event-status"); 453 + if (!targetDID || !checkbox) return; 454 + 455 + function showStatus(msg) { 456 + if (!status) return; 457 + status.textContent = msg; 458 + clearTimeout(status._timer); 459 + status._timer = setTimeout(function () { status.textContent = ""; }, 2000); 460 + } 461 + 462 + checkbox.addEventListener("change", function () { 463 + var desired = checkbox.checked; 464 + checkbox.disabled = true; 465 + fetch("/connections/" + encodeURI(targetDID) + "/event", { 466 + method: "POST", 467 + credentials: "same-origin", 468 + headers: { "Content-Type": "application/json" }, 469 + body: JSON.stringify({ associate: desired }) 470 + }) 471 + .then(function (resp) { 472 + if (resp.ok) { 473 + showStatus("✓ saved"); 474 + } else { 475 + checkbox.checked = !desired; // revert 476 + showStatus("save failed"); 477 + } 478 + }) 479 + .catch(function () { 480 + checkbox.checked = !desired; // revert 481 + showStatus("save failed"); 482 + }) 483 + .finally(function () { 484 + checkbox.disabled = false; 485 + }); 486 + }); 487 + })(); 488 + ``` 489 + 490 + - [ ] **Step 2: Confirm the file is syntactically valid** 491 + 492 + Run: `node --check /Users/brittany/Documents/Collective/atmoquest/web/resources/static/js/connection-event.js` 493 + Expected: silent success (exit 0). 494 + 495 + - [ ] **Step 3: Commit** 496 + 497 + ```bash 498 + cd /Users/brittany/Documents/Collective/atmoquest 499 + git add web/resources/static/js/connection-event.js 500 + git commit -m "feat(connections): client toggle for current-event association" 501 + ``` 502 + 503 + --- 504 + 505 + ### Task 6: Full build, tests, and manual verification 506 + 507 + **Files:** none (verification only) 508 + 509 + - [ ] **Step 1: Build, vet, and test everything** 510 + 511 + Run: `cd /Users/brittany/Documents/Collective/atmoquest && go tool templ generate && go build ./... && go vet ./... && go test ./...` 512 + Expected: templ clean, build silent, vet silent, all tests `ok`. 513 + 514 + - [ ] **Step 2: Manual verification (requires a running instance + checked-in account)** 515 + 516 + 1. Start the app: `go tool task live` and sign in as an ATProto user. 517 + 2. Check into an ongoing event (so `checkin.Current` returns it). 518 + 3. Open `/connections/{did}` for a person you have a `quest.atmo.connection` record with. Confirm the `📍 met at <event>` checkbox appears. 519 + 4. Check the box → confirm `✓ saved`. Verify on the PDS: 520 + `com.atproto.repo.getRecord` for that record now shows the `event` field set to the current event's at-uri. 521 + 5. Uncheck the box → confirm `✓ saved` and that the record's `event` field is gone. 522 + 6. Sign out / use a local account → confirm the checkbox does **not** appear. 523 + 7. Open a connection profile while **not** checked into any ongoing event → confirm the checkbox does **not** appear. 524 + 525 + - [ ] **Step 3: Final commit (if templ regeneration changed anything)** 526 + 527 + ```bash 528 + cd /Users/brittany/Documents/Collective/atmoquest 529 + git add -A 530 + git commit -m "chore(connections): regenerate templ for current-event association" || echo "nothing to commit" 531 + ``` 532 + 533 + --- 534 + 535 + ## Notes for the implementer 536 + 537 + - **In-place update is intentional.** When a connection record is already tagged with a *different* event, checking the box overwrites that event with the current one. This was an explicit product decision (see the design doc). 538 + - **Server is the source of truth for the event.** The client only sends `{associate: bool}`; the handler re-derives the event from `checkin.Current`. Never accept an event URI from the client. 539 + - **Scope:** local accounts and local-only connections are out of scope — they have no PDS record, so the checkbox is never rendered and the endpoint will 404 for them. 540 + - **Spec:** `docs/superpowers/specs/2026-06-01-associate-connection-with-current-event-design.md`.