···11+# Associate a Connection With Current Event — Implementation Plan
22+33+> **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.
44+55+**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.
66+77+**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.
88+99+**Tech Stack:** Go, chi router, templ, indigo atproto OAuth client, SQLite, vanilla JS.
1010+1111+---
1212+1313+### Task 1: `connection.SetEvent` + record-finding helpers
1414+1515+**Files:**
1616+- Modify: `internal/connection/connection.go`
1717+- Modify: `internal/connection/list.go`
1818+- Test: `internal/connection/connection_test.go`
1919+- Test: `internal/connection/list_test.go` (create if absent)
2020+2121+- [ ] **Step 1: Write failing tests for the record-value helper and target finder**
2222+2323+Add to `internal/connection/connection_test.go`:
2424+2525+```go
2626+func TestBuildConnectionValue_OmitsEmptyEvent(t *testing.T) {
2727+ v := buildConnectionValue(syntax.DID(didA), time.Date(2026, 6, 1, 12, 0, 0, 0, time.UTC), "")
2828+ if v["$type"] != NSID {
2929+ t.Errorf("missing $type, got %v", v["$type"])
3030+ }
3131+ if v["with"] != didA {
3232+ t.Errorf("with = %v, want %s", v["with"], didA)
3333+ }
3434+ if _, ok := v["event"]; ok {
3535+ t.Error("event should be omitted when empty")
3636+ }
3737+}
3838+3939+func TestBuildConnectionValue_IncludesEvent(t *testing.T) {
4040+ v := buildConnectionValue(syntax.DID(didA), time.Now().UTC(), "at://did:plc:x/quest.atmo.event/abc")
4141+ if v["event"] != "at://did:plc:x/quest.atmo.event/abc" {
4242+ t.Errorf("event = %v", v["event"])
4343+ }
4444+}
4545+4646+func TestSetEvent_RejectsNilSession(t *testing.T) {
4747+ err := SetEvent(context.Background(), nil, "at://did:plc:x/quest.atmo.connection/r1", syntax.DID(didA), time.Now(), "")
4848+ if err == nil || !strings.Contains(err.Error(), "nil oauth session") {
4949+ t.Fatalf("expected nil session error, got %v", err)
5050+ }
5151+}
5252+5353+func TestSetEvent_RejectsBadURI(t *testing.T) {
5454+ sess := &oauth.ClientSession{Data: &oauth.ClientSessionData{AccountDID: syntax.DID(didB)}}
5555+ err := SetEvent(context.Background(), sess, "", syntax.DID(didA), time.Now(), "")
5656+ if err == nil || !strings.Contains(err.Error(), "rkey") {
5757+ t.Fatalf("expected rkey error, got %v", err)
5858+ }
5959+}
6060+```
6161+6262+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`):
6363+6464+```go
6565+func TestFindForTarget_PicksMostRecent(t *testing.T) {
6666+ target := syntax.DID(didA)
6767+ 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"}
6868+ 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"}
6969+ other := ListEntry{URI: "at://x/c/z", With: syntax.DID(didB), ConnectedAt: time.Now()}
7070+7171+ got, ok := FindForTarget([]ListEntry{older, other, newer}, target)
7272+ if !ok {
7373+ t.Fatal("expected to find target")
7474+ }
7575+ if got.URI != "at://x/c/new" {
7676+ t.Errorf("URI = %s, want at://x/c/new", got.URI)
7777+ }
7878+}
7979+8080+func TestFindForTarget_NotFound(t *testing.T) {
8181+ _, ok := FindForTarget([]ListEntry{{With: syntax.DID(didB)}}, syntax.DID(didA))
8282+ if ok {
8383+ t.Error("expected not found")
8484+ }
8585+}
8686+```
8787+8888+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.
8989+9090+- [ ] **Step 2: Run the tests to verify they fail**
9191+9292+Run: `cd /Users/brittany/Documents/Collective/atmoquest && go test ./internal/connection/ -run 'BuildConnectionValue|SetEvent|FindForTarget' -v`
9393+Expected: FAIL — `undefined: buildConnectionValue`, `undefined: SetEvent`, `undefined: FindForTarget`.
9494+9595+- [ ] **Step 3: Implement `FindForTarget` in `list.go`**
9696+9797+Append to `internal/connection/list.go`:
9898+9999+```go
100100+// FindForTarget returns the most-recent connection entry for the given target
101101+// DID. When several records exist (e.g. across multiple events), the one with
102102+// the latest ConnectedAt wins — matching the dedup the list/profile pages show.
103103+// Returns ok=false when the target has no connection record.
104104+func FindForTarget(entries []ListEntry, target syntax.DID) (ListEntry, bool) {
105105+ var best ListEntry
106106+ found := false
107107+ for _, e := range entries {
108108+ if e.With != target {
109109+ continue
110110+ }
111111+ if !found || e.ConnectedAt.After(best.ConnectedAt) {
112112+ best = e
113113+ found = true
114114+ }
115115+ }
116116+ return best, found
117117+}
118118+```
119119+120120+- [ ] **Step 4: Implement `buildConnectionValue` and `SetEvent` in `connection.go`**
121121+122122+Add the `nsidPutRecord` constant alongside the existing constants in `connection.go`:
123123+124124+```go
125125+ // putRecord is the XRPC procedure for in-place updates.
126126+ nsidPutRecord = "com.atproto.repo.putRecord"
127127+```
128128+129129+Append to `internal/connection/connection.go`:
130130+131131+```go
132132+// buildConnectionValue assembles the record body for a quest.atmo.connection
133133+// record. The event field is included only when eventURI is non-empty.
134134+func buildConnectionValue(with syntax.DID, connectedAt time.Time, eventURI string) map[string]any {
135135+ value := map[string]any{
136136+ "$type": NSID,
137137+ "with": with.String(),
138138+ "connectedAt": connectedAt.UTC().Format(time.RFC3339),
139139+ }
140140+ if eventURI != "" {
141141+ value["event"] = eventURI
142142+ }
143143+ return value
144144+}
145145+146146+// SetEvent rewrites an existing connection record in place (putRecord, reusing
147147+// the record's rkey) so its event association becomes eventURI. Passing an
148148+// empty eventURI clears the association. The caller carries the record's
149149+// current `with` and `connectedAt` (from the list entry) so no fields are
150150+// dropped. The at-uri stays stable because the rkey is reused.
151151+//
152152+// Caller must hold the repo:quest.atmo.connection OAuth scope.
153153+func SetEvent(ctx context.Context, sess *oauth.ClientSession, recordURI string, with syntax.DID, connectedAt time.Time, eventURI string) error {
154154+ if sess == nil {
155155+ return errors.New("connection: nil oauth session")
156156+ }
157157+ rkey := rkeyFromURI(recordURI)
158158+ if rkey == "" {
159159+ return fmt.Errorf("connection: cannot parse rkey from %q", recordURI)
160160+ }
161161+ if connectedAt.IsZero() {
162162+ connectedAt = time.Now().UTC()
163163+ }
164164+165165+ input := map[string]any{
166166+ "repo": sess.Data.AccountDID.String(),
167167+ "collection": NSID,
168168+ "rkey": rkey,
169169+ // `validate` omitted — see Put for rationale.
170170+ "record": buildConnectionValue(with, connectedAt, eventURI),
171171+ }
172172+ if err := sess.APIClient().Post(ctx, syntax.NSID(nsidPutRecord), input, nil); err != nil {
173173+ return fmt.Errorf("putRecord %s: %w", NSID, err)
174174+ }
175175+ return nil
176176+}
177177+178178+// rkeyFromURI extracts the record key (last path segment) from an at:// URI.
179179+func rkeyFromURI(uri string) string {
180180+ if uri == "" {
181181+ return ""
182182+ }
183183+ parts := strings.Split(uri, "/")
184184+ return parts[len(parts)-1]
185185+}
186186+```
187187+188188+Add `"strings"` to the import block in `connection.go` if it is not already imported.
189189+190190+- [ ] **Step 5: Run the tests to verify they pass**
191191+192192+Run: `cd /Users/brittany/Documents/Collective/atmoquest && go test ./internal/connection/ -v`
193193+Expected: PASS (all connection tests, including the new ones).
194194+195195+- [ ] **Step 6: Commit**
196196+197197+```bash
198198+cd /Users/brittany/Documents/Collective/atmoquest
199199+git add internal/connection/
200200+git commit -m "feat(connection): SetEvent for in-place event tagging + FindForTarget"
201201+```
202202+203203+---
204204+205205+### Task 2: Add fields to `ProfileView` and render the checkbox
206206+207207+**Files:**
208208+- Modify: `features/connections/pages/profile.templ`
209209+210210+- [ ] **Step 1: Add the new fields to the `ProfileView` struct**
211211+212212+In `features/connections/pages/profile.templ`, extend the struct (after `FollowUp bool`):
213213+214214+```go
215215+ FollowUp bool // follow-up flag from SQLite
216216+ // Current-event association (only populated for ATProto viewers who are
217217+ // checked into an ongoing event AND have a PDS connection record here).
218218+ CurrentEventURI string // at-uri of the ongoing event, "" if not checked in
219219+ CurrentEventName string // display name for the checkbox label
220220+ ConnRecordURI string // at-uri (rkey) of the record to update, "" if none
221221+ AssociatedWithCurrent bool // initial checked state
222222+```
223223+224224+- [ ] **Step 2: Render the checkbox inside the notes section**
225225+226226+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:
227227+228228+```go
229229+ if v.CurrentEventURI != "" && v.ConnRecordURI != "" {
230230+ <label class="pv-event-toggle" data-target-did={ v.TargetDID }>
231231+ if v.AssociatedWithCurrent {
232232+ <input type="checkbox" id="pv-event-associate" checked/>
233233+ } else {
234234+ <input type="checkbox" id="pv-event-associate"/>
235235+ }
236236+ <span class="pv-event-text">📍 met at { v.CurrentEventName }</span>
237237+ <span class="pv-event-saved muted" id="pv-event-status"></span>
238238+ </label>
239239+ }
240240+```
241241+242242+- [ ] **Step 3: Add the script include**
243243+244244+In the same file, next to the existing `<script src="/static/js/notes.js" defer></script>` line, add below it:
245245+246246+```go
247247+ <script src="/static/js/connection-event.js" defer></script>
248248+```
249249+250250+- [ ] **Step 4: Regenerate the templ Go file**
251251+252252+Run: `cd /Users/brittany/Documents/Collective/atmoquest && go tool templ generate -f features/connections/pages/profile.templ`
253253+Expected: clean output, `features/connections/pages/profile_templ.go` updated.
254254+255255+- [ ] **Step 5: Verify it builds**
256256+257257+Run: `cd /Users/brittany/Documents/Collective/atmoquest && go build ./...`
258258+Expected: silent success.
259259+260260+- [ ] **Step 6: Commit**
261261+262262+```bash
263263+cd /Users/brittany/Documents/Collective/atmoquest
264264+git add features/connections/pages/profile.templ features/connections/pages/profile_templ.go
265265+git commit -m "feat(connections): render current-event association checkbox"
266266+```
267267+268268+---
269269+270270+### Task 3: Populate the new fields in the `View` handler
271271+272272+**Files:**
273273+- Modify: `features/connections/handlers.go` (the `View` method, around lines 420–445)
274274+275275+- [ ] **Step 1: Wire current-event + record lookup into `View`**
276276+277277+In `features/connections/handlers.go`, the `View` method already computes
278278+`viewerPDS := h.lookupPDSForDID(r, viewerDID)` and
279279+`entries, err := connection.List(r.Context(), viewerPDS, viewerDID)` and then
280280+loops over `entries` to set `view.ConnectedAt` / `view.EventName`.
281281+282282+Immediately **after** that existing loop (after the `for _, e := range entries { ... }` block that sets `ConnectedAt`), add:
283283+284284+```go
285285+ // Current-event association: only for ATProto viewers checked into an
286286+ // ongoing event who have a PDS connection record with this target.
287287+ if evURI, ok, cErr := checkin.Current(r.Context(), h.DB, viewerDID); cErr == nil && ok && evURI != "" {
288288+ if rec, found := connection.FindForTarget(entries, target); found {
289289+ view.CurrentEventURI = evURI
290290+ view.ConnRecordURI = rec.URI
291291+ view.AssociatedWithCurrent = rec.EventURI == evURI
292292+ if ev, gErr := event.Get(r.Context(), h.DB, evURI); gErr == nil {
293293+ view.CurrentEventName = ev.Name
294294+ }
295295+ }
296296+ }
297297+```
298298+299299+`checkin`, `connection`, and `event` are already imported in this file. No new imports.
300300+301301+- [ ] **Step 2: Verify it builds**
302302+303303+Run: `cd /Users/brittany/Documents/Collective/atmoquest && go build ./...`
304304+Expected: silent success.
305305+306306+- [ ] **Step 3: Commit**
307307+308308+```bash
309309+cd /Users/brittany/Documents/Collective/atmoquest
310310+git add features/connections/handlers.go
311311+git commit -m "feat(connections): pass current-event association data to profile view"
312312+```
313313+314314+---
315315+316316+### Task 4: `POST /connections/{did}/event` endpoint + route
317317+318318+**Files:**
319319+- Modify: `features/connections/handlers.go` (new `SetConnectionEvent` method)
320320+- Modify: `features/connections/routes.go`
321321+322322+- [ ] **Step 1: Add the handler method**
323323+324324+Append a new method to `features/connections/handlers.go`:
325325+326326+```go
327327+// SetConnectionEvent handles POST /connections/{did}/event — toggles whether
328328+// the viewer's connection record with {did} is tagged with the event the
329329+// viewer is currently checked into. Body: {"associate": bool}.
330330+//
331331+// The event is always re-derived server-side from checkin.Current; the client
332332+// only sends the boolean. Updates the most-recent connection record in place.
333333+func (h *Handlers) SetConnectionEvent(w http.ResponseWriter, r *http.Request) {
334334+ viewerDID, sess, ok := h.Auth.RequireSession(w, r)
335335+ if !ok {
336336+ return // RequireSession wrote the redirect/response
337337+ }
338338+339339+ targetStr, err := url.PathUnescape(chi.URLParam(r, "did"))
340340+ if err != nil {
341341+ http.Error(w, "invalid DID", http.StatusBadRequest)
342342+ return
343343+ }
344344+ target, err := syntax.ParseDID(targetStr)
345345+ if err != nil {
346346+ http.Error(w, "invalid DID", http.StatusBadRequest)
347347+ return
348348+ }
349349+350350+ var body struct {
351351+ Associate bool `json:"associate"`
352352+ }
353353+ if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4*1024)).Decode(&body); err != nil {
354354+ http.Error(w, "invalid JSON", http.StatusBadRequest)
355355+ return
356356+ }
357357+358358+ // Re-derive the current event server-side.
359359+ eventURI := ""
360360+ if body.Associate {
361361+ evURI, checked, cErr := checkin.Current(r.Context(), h.DB, viewerDID)
362362+ if cErr != nil {
363363+ slog.Warn("set connection event: checkin.Current", "err", cErr)
364364+ http.Error(w, "failed to resolve current event", http.StatusInternalServerError)
365365+ return
366366+ }
367367+ if !checked || evURI == "" {
368368+ http.Error(w, "not checked into an ongoing event", http.StatusBadRequest)
369369+ return
370370+ }
371371+ eventURI = evURI
372372+ }
373373+374374+ // Find the most-recent connection record for this target.
375375+ viewerPDS := h.lookupPDSForDID(r, viewerDID)
376376+ entries, err := connection.List(r.Context(), viewerPDS, viewerDID)
377377+ if err != nil {
378378+ slog.Warn("set connection event: list connections", "err", err)
379379+ http.Error(w, "failed to load connection", http.StatusInternalServerError)
380380+ return
381381+ }
382382+ rec, found := connection.FindForTarget(entries, target)
383383+ if !found {
384384+ http.Error(w, "no connection record found", http.StatusNotFound)
385385+ return
386386+ }
387387+388388+ if err := connection.SetEvent(r.Context(), sess, rec.URI, rec.With, rec.ConnectedAt, eventURI); err != nil {
389389+ slog.Warn("set connection event: putRecord", "uri", rec.URI, "err", err)
390390+ http.Error(w, "failed to update record", http.StatusInternalServerError)
391391+ return
392392+ }
393393+394394+ w.Header().Set("Content-Type", "application/json")
395395+ _ = json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
396396+}
397397+```
398398+399399+All referenced packages (`url`, `json`, `slog`, `http`, `chi`, `syntax`, `checkin`, `connection`) are already imported in this file.
400400+401401+- [ ] **Step 2: Register the route**
402402+403403+In `features/connections/routes.go`, add inside `SetupRoutes` after the notes route:
404404+405405+```go
406406+ router.Post("/connections/{did}/event", h.SetConnectionEvent)
407407+```
408408+409409+Update the doc comment above `SetupRoutes` to list the new route:
410410+411411+```go
412412+// - POST /connections/{did}/event — associate/clear the current event tag
413413+```
414414+415415+- [ ] **Step 3: Verify it builds**
416416+417417+Run: `cd /Users/brittany/Documents/Collective/atmoquest && go build ./...`
418418+Expected: silent success.
419419+420420+- [ ] **Step 4: Commit**
421421+422422+```bash
423423+cd /Users/brittany/Documents/Collective/atmoquest
424424+git add features/connections/handlers.go features/connections/routes.go
425425+git commit -m "feat(connections): POST /connections/{did}/event endpoint"
426426+```
427427+428428+---
429429+430430+### Task 5: Client script
431431+432432+**Files:**
433433+- Create: `web/resources/static/js/connection-event.js`
434434+435435+- [ ] **Step 1: Write the script**
436436+437437+Create `web/resources/static/js/connection-event.js`:
438438+439439+```javascript
440440+// atmo.quest — toggle whether a connection is tagged with the event you're
441441+// currently checked into. POSTs {associate: bool} to
442442+// /connections/{did}/event. The server re-derives which event; we only send
443443+// the boolean. On failure we revert the checkbox so the UI matches the PDS.
444444+(function () {
445445+ "use strict";
446446+447447+ var toggle = document.querySelector(".pv-event-toggle");
448448+ if (!toggle) return;
449449+450450+ var targetDID = toggle.getAttribute("data-target-did");
451451+ var checkbox = document.getElementById("pv-event-associate");
452452+ var status = document.getElementById("pv-event-status");
453453+ if (!targetDID || !checkbox) return;
454454+455455+ function showStatus(msg) {
456456+ if (!status) return;
457457+ status.textContent = msg;
458458+ clearTimeout(status._timer);
459459+ status._timer = setTimeout(function () { status.textContent = ""; }, 2000);
460460+ }
461461+462462+ checkbox.addEventListener("change", function () {
463463+ var desired = checkbox.checked;
464464+ checkbox.disabled = true;
465465+ fetch("/connections/" + encodeURI(targetDID) + "/event", {
466466+ method: "POST",
467467+ credentials: "same-origin",
468468+ headers: { "Content-Type": "application/json" },
469469+ body: JSON.stringify({ associate: desired })
470470+ })
471471+ .then(function (resp) {
472472+ if (resp.ok) {
473473+ showStatus("✓ saved");
474474+ } else {
475475+ checkbox.checked = !desired; // revert
476476+ showStatus("save failed");
477477+ }
478478+ })
479479+ .catch(function () {
480480+ checkbox.checked = !desired; // revert
481481+ showStatus("save failed");
482482+ })
483483+ .finally(function () {
484484+ checkbox.disabled = false;
485485+ });
486486+ });
487487+})();
488488+```
489489+490490+- [ ] **Step 2: Confirm the file is syntactically valid**
491491+492492+Run: `node --check /Users/brittany/Documents/Collective/atmoquest/web/resources/static/js/connection-event.js`
493493+Expected: silent success (exit 0).
494494+495495+- [ ] **Step 3: Commit**
496496+497497+```bash
498498+cd /Users/brittany/Documents/Collective/atmoquest
499499+git add web/resources/static/js/connection-event.js
500500+git commit -m "feat(connections): client toggle for current-event association"
501501+```
502502+503503+---
504504+505505+### Task 6: Full build, tests, and manual verification
506506+507507+**Files:** none (verification only)
508508+509509+- [ ] **Step 1: Build, vet, and test everything**
510510+511511+Run: `cd /Users/brittany/Documents/Collective/atmoquest && go tool templ generate && go build ./... && go vet ./... && go test ./...`
512512+Expected: templ clean, build silent, vet silent, all tests `ok`.
513513+514514+- [ ] **Step 2: Manual verification (requires a running instance + checked-in account)**
515515+516516+1. Start the app: `go tool task live` and sign in as an ATProto user.
517517+2. Check into an ongoing event (so `checkin.Current` returns it).
518518+3. Open `/connections/{did}` for a person you have a `quest.atmo.connection` record with. Confirm the `📍 met at <event>` checkbox appears.
519519+4. Check the box → confirm `✓ saved`. Verify on the PDS:
520520+ `com.atproto.repo.getRecord` for that record now shows the `event` field set to the current event's at-uri.
521521+5. Uncheck the box → confirm `✓ saved` and that the record's `event` field is gone.
522522+6. Sign out / use a local account → confirm the checkbox does **not** appear.
523523+7. Open a connection profile while **not** checked into any ongoing event → confirm the checkbox does **not** appear.
524524+525525+- [ ] **Step 3: Final commit (if templ regeneration changed anything)**
526526+527527+```bash
528528+cd /Users/brittany/Documents/Collective/atmoquest
529529+git add -A
530530+git commit -m "chore(connections): regenerate templ for current-event association" || echo "nothing to commit"
531531+```
532532+533533+---
534534+535535+## Notes for the implementer
536536+537537+- **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).
538538+- **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.
539539+- **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.
540540+- **Spec:** `docs/superpowers/specs/2026-06-01-associate-connection-with-current-event-design.md`.