diff --git a/config/config.go b/config/config.go index bee1c8e..cce183b 100644 --- a/config/config.go +++ b/config/config.go @@ -88,7 +88,7 @@ func loadBase() *Config { }(), SessionSecret: getEnv("SESSION_SECRET", "session-secret"), PublicURL: getEnv("PUBLIC_URL", defaultPublicURL), - DatabaseURL: getEnv("DATABASE_URL", "file:data/atmoquest.db?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)"), + DatabaseURL: getEnv("DATABASE_URL", "file:data/atmoquest.db?_pragma=journal_mode(WAL)&_pragma=foreign_keys(ON)&_pragma=busy_timeout(5000)"), OAuthPrivateKeyPath: getEnv("OAUTH_PRIVATE_KEY_PATH", "data/oauth_key.pem"), } } diff --git a/features/auth/drain_middleware.go b/features/auth/drain_middleware.go new file mode 100644 index 0000000..65604e5 --- /dev/null +++ b/features/auth/drain_middleware.go @@ -0,0 +1,111 @@ +package auth + +import ( + "context" + "log/slog" + "net/http" + "strings" + "sync" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/internal/connection" +) + +// DrainMiddleware returns an http.Handler middleware that automatically +// flushes pending reciprocal connections for authenticated users on every +// request. To avoid hammering SQLite + PDS on every page load, it debounces +// per-DID: after a successful check it won't re-check for the same user +// until drainInterval has elapsed. +func DrainMiddleware(authH *Handlers, queue *connection.Queue, interval time.Duration) func(http.Handler) http.Handler { + dm := &drainState{ + interval: interval, + last: make(map[string]time.Time), + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Only drain on GET requests — POST/PUT/DELETE handlers often + // need the OAuth session themselves, and a concurrent token + // refresh from the drain goroutine causes DPoP nonce races + // that invalidate the refresh token. + // Also skip /c/ routes — the connect handler writes to the + // PDS itself, so a concurrent drain would race on the same + // refresh token. + if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/c/") { + didStr, sid := authH.Sessions.Get(r) + if didStr != "" && sid != "" { + did, err := syntax.ParseDID(didStr) + if err == nil && dm.shouldDrain(did) { + // Extract session data now — the goroutine must NOT + // use r or r.Context() because the request will be + // done by the time it runs. + go drainAsync(authH, queue, did, sid, dm) + } + } + } + next.ServeHTTP(w, r) + }) + } +} + +type drainState struct { + interval time.Duration + mu sync.Mutex + last map[string]time.Time +} + +func (d *drainState) shouldDrain(did syntax.DID) bool { + d.mu.Lock() + defer d.mu.Unlock() + now := time.Now() + if last, ok := d.last[did.String()]; ok && now.Sub(last) < d.interval { + return false + } + d.last[did.String()] = now + return true +} + +func (d *drainState) reset(did syntax.DID) { + d.mu.Lock() + defer d.mu.Unlock() + delete(d.last, did.String()) +} + +// drainAsync runs the queue drain in a background goroutine so it doesn't +// block the page render. Uses a fresh context because the originating HTTP +// request will be done by the time this runs. +func drainAsync(authH *Handlers, queue *connection.Queue, did syntax.DID, sid string, dm *drainState) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Check count first — cheap SQLite query, avoids expensive session resume + // when there's nothing pending. + count, err := queue.Count(ctx, did) + if err != nil || count == 0 { + return + } + + // There are pending connections — resume the OAuth session and drain. + sess, err := authH.OAuth.ResumeSession(ctx, did, sid) + if err != nil { + slog.Debug("drain middleware: session resume failed", "did", did.String(), "err", err) + dm.reset(did) // allow retry on next request + return + } + + res, err := connection.Drain(ctx, queue, sess, slog.Default()) + if err != nil { + slog.Warn("drain middleware: drain failed", "did", did.String(), "err", err) + dm.reset(did) // allow retry on next request + return + } + if res.Written > 0 { + slog.Info("drain middleware: flushed connections", + "did", did.String(), + "written", res.Written, + "skipped", res.Skipped, + ) + } +} diff --git a/features/common/layouts/bottomnav.templ b/features/common/layouts/bottomnav.templ index 31511d9..50c97a0 100644 --- a/features/common/layouts/bottomnav.templ +++ b/features/common/layouts/bottomnav.templ @@ -1,7 +1,7 @@ package layouts // BottomNav renders a fixed bottom navigation bar for mobile. -// activePage should be "home", "connections", or "event". +// activePage should be "home", "connections", "event", or "settings". templ BottomNav(activePage string) { } @@ -31,5 +35,8 @@ templ TopNav(activePage string) { ◉Events + + ⚙Settings + } diff --git a/features/common/layouts/bottomnav_templ.go b/features/common/layouts/bottomnav_templ.go index 5dbd4ce..dcc6746 100644 --- a/features/common/layouts/bottomnav_templ.go +++ b/features/common/layouts/bottomnav_templ.go @@ -9,7 +9,7 @@ import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" // BottomNav renders a fixed bottom navigation bar for mobile. -// activePage should be "home", "connections", or "event". +// activePage should be "home", "connections", "event", or "settings". func BottomNav(activePage string) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context @@ -97,7 +97,29 @@ func BottomNav(activePage string) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">◉ Events") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "\">◉ Events ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 = []any{"bottom-nav-item", templ.KV("bottom-nav-active", activePage == "settings")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var8...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "⚙ Settings") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -122,78 +144,100 @@ func TopNav(activePage string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var8 := templ.GetChildren(ctx) - if templ_7745c5c3_Var8 == nil { - templ_7745c5c3_Var8 = templ.NopComponent + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 = []any{"top-nav-item", templ.KV("top-nav-active", activePage == "home")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...) + var templ_7745c5c3_Var11 = []any{"top-nav-item", templ.KV("top-nav-active", activePage == "home")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "⌂Home ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">⌂Home ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 = []any{"top-nav-item", templ.KV("top-nav-active", activePage == "connections")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var11...) + var templ_7745c5c3_Var13 = []any{"top-nav-item", templ.KV("top-nav-active", activePage == "connections")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "⇄Connections ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\">⇄Connections ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 = []any{"top-nav-item", templ.KV("top-nav-active", activePage == "event")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...) + var templ_7745c5c3_Var15 = []any{"top-nav-item", templ.KV("top-nav-active", activePage == "event")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var15...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "◉Events ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var17 = []any{"top-nav-item", templ.KV("top-nav-active", activePage == "settings")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "◉Events
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\">⚙Settings
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/connect/handlers.go b/features/connect/handlers.go index 6dbb94c..b1cbb3d 100644 --- a/features/connect/handlers.go +++ b/features/connect/handlers.go @@ -1,8 +1,7 @@ // Package connect implements the QR-driven /c/{did} flow: // // - /profile/qr.svg — SVG QR encoding /c/ -// - /c/{did} — landing page rendered for the scanner -// - /c/{did}/confirm — POST that writes the connection record +// - /c/{did} — auto-connect: writes records + redirects // - /connect/flush-local — drain localStorage-stashed targets post-login // // Reciprocity is async: the viewer's own quest.atmo.connection record is @@ -13,7 +12,6 @@ package connect import ( "database/sql" "encoding/json" - "errors" "log/slog" "net/http" "strings" @@ -24,12 +22,10 @@ import ( "atmoquest/config" "atmoquest/features/auth" - "atmoquest/features/connect/pages" "atmoquest/internal/badge" "atmoquest/internal/checkin" "atmoquest/internal/connection" "atmoquest/internal/event" - "atmoquest/internal/profile" "atmoquest/internal/qrcode" ) @@ -67,16 +63,18 @@ func (h *Handlers) ProfileQR(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(svg) } -// Connect renders the landing page that a QR scan opens. Behavior depends on -// whether the viewer is logged in: +// Connect handles GET /c/{did} — the URL encoded in QR codes. // -// - Logged in: render a confirmation page with a "connect" button that -// POSTs to /c/{did}/confirm. -// - Not logged in: render a sign-in prompt + emit a [data-queue-did] -// marker so profile.js stashes the target DID in localStorage for the -// post-login flush. -// -// Self-connect is rejected up front with a friendly note. +// When scanned (or clicked during local dev), this handler automatically: +// 1. Requires authentication (redirects to /signin if not logged in). +// 2. Rejects self-connect with a redirect to /profile. +// 3. Detects if the target is checked into an ongoing event and uses that +// event URI for both the connection and an automatic check-in. +// 4. Writes a quest.atmo.connection record to the viewer's PDS (dedup'd). +// 5. Enqueues the target's reciprocal record. +// 6. Auto-checks the viewer into the target's current event (if any). +// 7. Awards badges and updates event stats. +// 8. Redirects to /connections/{did} to show the new connection. func (h *Handlers) Connect(w http.ResponseWriter, r *http.Request) { targetStr := chi.URLParam(r, "did") target, err := syntax.ParseDID(targetStr) @@ -85,133 +83,62 @@ func (h *Handlers) Connect(w http.ResponseWriter, r *http.Request) { return } - viewerDIDStr, viewerSID := h.Auth.Sessions.Get(r) - viewerLoggedIn := viewerDIDStr != "" && viewerSID != "" - - // Pull the target's public profile so we can show their display name and - // avatar on the confirm screen. Public records — no auth needed. - pds := h.lookupPDSForDID(r, target) - bsky, err := profile.FetchBluesky(r.Context(), pds, target) - if err != nil && !errors.Is(err, profile.ErrNotFound) { - slog.Info("connect: fetch bsky profile", "target", target.String(), "err", err) - bsky = nil - } - - view := pages.ConnectView{ - TargetDID: target.String(), - TargetDisplayName: blueskyDisplayName(bsky), - TargetAvatarURL: blueskyAvatarURL(pds, target, bsky), - TargetBio: blueskyBio(bsky), - ViewerLoggedIn: viewerLoggedIn, - SelfConnect: viewerLoggedIn && viewerDIDStr == target.String(), - } - - // If the target is currently checked into an ongoing event, surface it as - // a chip so a logged-in scanner can opt in to also-check-in. - if viewerLoggedIn && !view.SelfConnect { - if evURI, ok, err := checkin.Current(r.Context(), h.DB, target); err == nil && ok { - if ev, err := event.Get(r.Context(), h.DB, evURI); err == nil && ev.IsOngoing(time.Now()) { - view.TargetCurrentEvent = &pages.ConnectEventChip{ - Name: ev.Name, - Location: ev.Location, - EventURI: ev.URI, - } - } - } - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := pages.Connect(view).Render(r.Context(), w); err != nil { - slog.Error("render connect", "err", err) - } -} - -// ConnectConfirm handles the "yes, connect" button on the /c/{did} page. -// -// 1. Validate the target DID and reject self-connect. -// 2. Optionally also-check-in the viewer to the target's ongoing event. -// 3. Write a quest.atmo.connection record to the viewer's PDS (with an -// event ref when the also-check-in box was ticked). -// 4. Enqueue a reciprocal write for the target's next login. -// 5. If also-check-in was requested, write the check-in record. -// 6. Redirect back to /profile with a ?connected= flash. -func (h *Handlers) ConnectConfirm(w http.ResponseWriter, r *http.Request) { viewerDID, viewerSess, ok := h.Auth.RequireSession(w, r) if !ok { - return - } - targetStr := chi.URLParam(r, "did") - target, err := syntax.ParseDID(targetStr) - if err != nil { - http.Error(w, "invalid target DID", http.StatusBadRequest) - return + return // RequireSession redirected to /signin?next=... } + if target == viewerDID { - http.Redirect(w, r, "/profile?connected=self", http.StatusSeeOther) + http.Redirect(w, r, "/profile", http.StatusSeeOther) return } - // If the form requested also-check-in, validate the event URI matches a - // real ongoing event the target is currently in. We re-derive the - // target's current event server-side (don't trust the hidden field - // alone) — the hidden field is just an integrity hint. - var checkinEventURI string - if formCheckboxOn(r, "checkin") { - claimed := strings.TrimSpace(r.FormValue("event_uri")) - if claimed != "" { - if curr, ok, err := checkin.Current(r.Context(), h.DB, target); err == nil && ok && curr == claimed { - if ev, err := event.Get(r.Context(), h.DB, claimed); err == nil && ev.IsOngoing(time.Now()) { - checkinEventURI = claimed - } - } + // Auto-detect if the target is currently at an ongoing event. + var eventURI string + if evURI, ok, err := checkin.Current(r.Context(), h.DB, target); err == nil && ok { + if ev, err := event.Get(r.Context(), h.DB, evURI); err == nil && ev.IsOngoing(time.Now()) { + eventURI = evURI } } - // Step 1: viewer's own connection record. If we resolved a check-in - // event, attach it to the connection too — the lexicon allows optional - // event linkage and it's nice provenance. - connRec := connection.Record{With: target} - if checkinEventURI != "" { - connRec.EventURI = checkinEventURI - } - if _, _, err := connection.Put(r.Context(), viewerSess, connRec); err != nil { - slog.Warn("connect confirm: viewer record", "viewer", viewerDID.String(), "target", target.String(), "err", err) - http.Error(w, "couldn't write your connection record — try again", http.StatusBadGateway) - return + // Write the viewer's connection record (with event linkage if applicable). + connRec := connection.Record{With: target, EventURI: eventURI} + viewerPDS := h.lookupPDSForDID(r, viewerDID) + if connection.HasConnection(r.Context(), viewerPDS, viewerDID, target, eventURI) { + slog.Debug("connect: duplicate skipped", "viewer", viewerDID.String(), "target", target.String(), "event", eventURI) + } else if _, _, err := connection.Put(r.Context(), viewerSess, connRec); err != nil { + slog.Warn("connect: viewer record", "viewer", viewerDID.String(), "target", target.String(), "err", err) + // Fall through — still redirect so the user isn't stuck on an error page. + // The reciprocal enqueue below will also attempt, and the user can retry. } - // Step 2: enqueue the target's reciprocal record for next-login drain. - // Best-effort — a failure here doesn't roll back the viewer's record. - if err := h.Queue.Enqueue(r.Context(), target, viewerDID); err != nil { - slog.Info("connect confirm: target deferred", "target", target.String(), "err", err) + // Enqueue the target's reciprocal record for next-login drain. + if err := h.Queue.Enqueue(r.Context(), target, viewerDID, eventURI); err != nil { + slog.Info("connect: enqueue reciprocal", "target", target.String(), "err", err) } - // Step 3: if also-check-in was requested + validated, write the viewer's - // checkin record. Best-effort — the connection has already landed, so - // log + carry on rather than 500. - if checkinEventURI != "" { - if _, err := checkin.Put(r.Context(), viewerSess, h.DB, checkinEventURI, time.Time{}); err != nil { - slog.Warn("connect confirm: also-checkin", "event_uri", checkinEventURI, "err", err) + // Auto-checkin if the target is at an event. + if eventURI != "" { + if _, err := checkin.Put(r.Context(), viewerSess, h.DB, eventURI, time.Time{}); err != nil { + slog.Warn("connect: auto-checkin", "event_uri", eventURI, "err", err) } - // Award event-attendee badge for the check-in event. - if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardEventAttendee, checkinEventURI); err != nil { - slog.Info("connect confirm: event-attendee badge", "err", err) + if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardEventAttendee, eventURI); err != nil { + slog.Info("connect: event-attendee badge", "err", err) + } + if err := event.IncrementCheckins(r.Context(), h.DB, eventURI); err != nil { + slog.Info("connect: increment checkins", "err", err) + } + if err := event.IncrementConnectors(r.Context(), h.DB, eventURI); err != nil { + slog.Info("connect: increment connectors", "err", err) } } - // Award first-connect badge (idempotent — skips if already exists). - if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardFirstConnect, checkinEventURI); err != nil { - slog.Info("connect confirm: first-connect badge", "err", err) - } - - // Update event stats: this viewer made a connection at this event. - if checkinEventURI != "" { - if err := event.IncrementConnectors(r.Context(), h.DB, checkinEventURI); err != nil { - slog.Info("connect confirm: increment connectors", "err", err) - } + // Award first-connect badge (idempotent). + if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardFirstConnect, eventURI); err != nil { + slog.Info("connect: first-connect badge", "err", err) } - http.Redirect(w, r, "/profile?connected="+target.String(), http.StatusSeeOther) + http.Redirect(w, r, "/connections/"+target.String(), http.StatusSeeOther) } // ConnectFlushLocal accepts a POST with a JSON body listing target DIDs that @@ -263,7 +190,7 @@ func (h *Handlers) ConnectFlushLocal(w http.ResponseWriter, r *http.Request) { errs = append(errs, "write failed for "+target.String()) continue } - if err := h.Queue.Enqueue(r.Context(), target, viewerDID); err != nil { + if err := h.Queue.Enqueue(r.Context(), target, viewerDID, ""); err != nil { slog.Warn("flush-local: enqueue", "target", target.String(), "err", err) } // Award first-connect badge (idempotent). @@ -309,18 +236,6 @@ func (h *Handlers) lookupPDSForDID(r *http.Request, did syntax.DID) string { return "https://bsky.social" } -// formCheckboxOn reports whether an HTML checkbox with the given name was -// ticked. HTML checkboxes only submit when checked; we accept any of the -// common "on" values browsers send. -func formCheckboxOn(r *http.Request, name string) bool { - v := strings.TrimSpace(r.FormValue(name)) - switch v { - case "", "0", "off", "false", "no": - return false - } - return true -} - // truncate clips s to at most n runes, suffixing with an ellipsis if cut. func truncate(s string, n int) string { r := []rune(s) @@ -329,24 +244,3 @@ func truncate(s string, n int) string { } return string(r[:n]) + "…" } - -func blueskyDisplayName(b *profile.BlueskyRecord) string { - if b == nil { - return "" - } - return b.DisplayName -} - -func blueskyBio(b *profile.BlueskyRecord) string { - if b == nil { - return "" - } - return b.Description -} - -func blueskyAvatarURL(pds string, did syntax.DID, b *profile.BlueskyRecord) string { - if b == nil { - return "" - } - return profile.AvatarURL(pds, did, b.Avatar.CID()) -} diff --git a/features/connect/routes.go b/features/connect/routes.go index 289ed93..2523b67 100644 --- a/features/connect/routes.go +++ b/features/connect/routes.go @@ -12,14 +12,12 @@ import ( // SetupRoutes wires the connect feature's HTTP routes. // // - GET /profile/qr.svg — SVG QR for the signed-in user's connect URL -// - GET /c/{did} — landing page for a QR scan -// - POST /c/{did}/confirm — write the connection record(s) +// - GET /c/{did} — auto-connect: writes records + redirects // - POST /connect/flush-local — drain localStorage queue post-login func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers, queue *connection.Queue) { h := NewHandlers(conn, authH, queue) router.Get("/profile/qr.svg", h.ProfileQR) router.Get("/c/{did}", h.Connect) - router.Post("/c/{did}/confirm", h.ConnectConfirm) router.Post("/connect/flush-local", h.ConnectFlushLocal) } diff --git a/features/connections/handlers.go b/features/connections/handlers.go index 6652835..91f071e 100644 --- a/features/connections/handlers.go +++ b/features/connections/handlers.go @@ -10,12 +10,14 @@ import ( "net/http" "sort" "strings" + "time" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" "atmoquest/features/auth" "atmoquest/features/connections/pages" + "atmoquest/internal/checkin" "atmoquest/internal/connection" "atmoquest/internal/event" "atmoquest/internal/notes" @@ -49,16 +51,17 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { entries = nil } - // Load events for the filter dropdown. - events, err := event.ListAll(r.Context(), h.DB) + // Load events the user has checked into for the filter chips. + now := time.Now() + userEvents, err := checkin.ListForUser(r.Context(), h.DB, did, now) if err != nil { - slog.Warn("connections: list events", "err", err) + slog.Warn("connections: list user events", "err", err) } // Build event name lookup for enrichment. - eventNames := make(map[string]string, len(events)) - for _, ev := range events { - eventNames[ev.URI] = ev.Name + eventNames := make(map[string]string, len(userEvents)) + for _, ue := range userEvents { + eventNames[ue.EventURI] = ue.EventName } // Query params for search / filter / sort. @@ -138,12 +141,12 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { }) } - // Build event options for the filter dropdown. + // Build event options for the filter chips. var eventOpts []pages.EventOption - for _, ev := range events { + for _, ue := range userEvents { eventOpts = append(eventOpts, pages.EventOption{ - URI: ev.URI, - Name: ev.Name, + URI: ue.EventURI, + Name: ue.EventName, }) } @@ -291,8 +294,12 @@ func (h *Handlers) View(w http.ResponseWriter, r *http.Request) { // SaveNotes handles POST /connections/{did}/notes — persists the viewer's // private notes and follow-up flag for the given target to SQLite. func (h *Handlers) SaveNotes(w http.ResponseWriter, r *http.Request) { - viewerDID, _, ok := h.Auth.RequireSession(w, r) - if !ok { + // Notes are local-only (SQLite), so we only need the viewer's DID from + // the session cookie — no OAuth token resume required. This avoids + // token-refresh races with the drain middleware and is much faster. + didStr, _ := h.Auth.Sessions.Get(r) + if didStr == "" { + http.Error(w, "not authenticated", http.StatusUnauthorized) return } @@ -316,11 +323,11 @@ func (h *Handlers) SaveNotes(w http.ResponseWriter, r *http.Request) { body.Notes = string([]rune(body.Notes)[:2000]) } - if err := notes.Put(r.Context(), h.DB, viewerDID.String(), targetStr, notes.Note{ + if err := notes.Put(r.Context(), h.DB, didStr, targetStr, notes.Note{ Notes: body.Notes, FollowUp: body.FollowUp, }); err != nil { - slog.Warn("save notes", "viewer", viewerDID.String(), "target", targetStr, "err", err) + slog.Warn("save notes", "viewer", didStr, "target", targetStr, "err", err) http.Error(w, "failed to save", http.StatusInternalServerError) return } diff --git a/features/connections/pages/connections.templ b/features/connections/pages/connections.templ index 73eb63a..51db12b 100644 --- a/features/connections/pages/connections.templ +++ b/features/connections/pages/connections.templ @@ -49,63 +49,67 @@ templ Connections(v ConnectionsView) { @layouts.TopNav("connections")
-
- you{ "@" }atmo.quest:~$ - ls connections/ -
+
+
+
Your connections
+
+ if v.Total == 0 { + no connections yet — go scan some QR codes! + } else { + { itoa(v.Total) } total + if v.Showing != v.Total { + · showing { itoa(v.Showing) } + } + } +
+
-
-
- - +
+ + +
+ for _, ev := range v.Events { - + } - - - +
-

- if v.Total == 0 { - no connections yet — go scan some QR codes! - } else if v.Showing == v.Total { - { itoa(v.Total) } { pluralize(v.Total, "connection", "connections") } - } else { - showing { itoa(v.Showing) } of { itoa(v.Total) } - } -

-
- if len(v.Items) > 0 { -
- for _, item := range v.Items { - @connectionRow(item) - } -
- } else if v.Total > 0 { -
-

no connections match your search.

- clear filters -
- } else { -
-

you haven't connected with anyone yet.

-

scan someone's QR code at an event to get started.

- ← back home -
- } + if len(v.Items) > 0 { +
+ for _, item := range v.Items { + @connectionRow(item) + } +
+ } else if v.Total > 0 { +
+

no connections match your search.

+ clear filters +
+ } else { +
+

you haven't connected with anyone yet.

+

scan someone's QR code at an event to get started.

+
+ } + +
connections live in your repo · notes stay on this device
+
atmo.quest @@ -120,33 +124,36 @@ templ Connections(v ConnectionsView) { } templ connectionRow(item ConnectionItem) { - -
+ +
if item.AvatarURL != "" { - { + { } else { - + { avatarInitial(item.DisplayName, item.DID) } }
-
-
+
+
if item.DisplayName != "" { - { item.DisplayName } + { item.DisplayName } } else { - { shortDID(item.DID) } + { shortDID(item.DID) } } - { item.ConnectedAt }
- if item.Bio != "" { -

{ truncate(item.Bio, 120) }

- } - if item.EventName != "" { - { item.EventName } - } - if item.FollowUp { - ⚑ follow up + if item.Handle != "" { +
{ "@" + item.Handle }
} +
+ { item.ConnectedAt } + if item.EventName != "" { + { item.EventName } + } + if item.FollowUp { + follow up + } +
+
›
} @@ -187,3 +194,14 @@ func pluralize(n int, singular, plural string) string { } return plural } + +// avatarInitial returns the first character of displayName, or "?" if empty. +func avatarInitial(displayName, did string) string { + if displayName != "" { + return string([]rune(displayName)[0]) + } + if len(did) > 8 { + return string([]rune(did)[8:9]) + } + return "?" +} diff --git a/features/connections/pages/connections_templ.go b/features/connections/pages/connections_templ.go index 1bc319b..36f2673 100644 --- a/features/connections/pages/connections_templ.go +++ b/features/connections/pages/connections_templ.go @@ -87,174 +87,166 @@ func Connections(v ConnectionsView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
you") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Your connections
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 53, Col: 56} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "atmo.quest:~$ ls connections/
most recent
alphabetical
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(v.Items) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -264,22 +256,22 @@ func Connections(v ConnectionsView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if v.Total > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

no connections match your search.

clear filters
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

no connections match your search.

clear filters
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

you haven't connected with anyone yet.

scan someone's QR code at an event to get started.

← back home
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

you haven't connected with anyone yet.

scan someone's QR code at an event to get started.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
connections live in your repo · notes stay on this device
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -313,168 +305,169 @@ func connectionRow(item ConnectionItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var11 := templ.GetChildren(ctx) - if templ_7745c5c3_Var11 == nil { - templ_7745c5c3_Var11 = templ.NopComponent + templ_7745c5c3_Var14 := templ.GetChildren(ctx) + if templ_7745c5c3_Var14 == nil { + templ_7745c5c3_Var14 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"connection-row\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if item.AvatarURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" loading=\"lazy\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
?
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(avatarInitial(item.DisplayName, item.DID)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 132, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if item.DisplayName != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.DisplayName) if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 138, Col: 23} } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DisplayName) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 134, Col: 53} + return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + } else { + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(item.DID)) if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 140, Col: 25} } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " ") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if item.Handle != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(item.DID)) + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs("@" + item.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 136, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 144, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.ConnectedAt) + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(item.ConnectedAt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 138, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 147, Col: 28} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if item.Bio != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(truncate(item.Bio, 120)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 141, Col: 55} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } if item.EventName != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.EventName) + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(item.EventName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 144, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 149, Col: 45} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if item.FollowUp { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "⚑ follow up") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "follow up") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
›
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -520,4 +513,15 @@ func pluralize(n int, singular, plural string) string { return plural } +// avatarInitial returns the first character of displayName, or "?" if empty. +func avatarInitial(displayName, did string) string { + if displayName != "" { + return string([]rune(displayName)[0]) + } + if len(did) > 8 { + return string([]rune(did)[8:9]) + } + return "?" +} + var _ = templruntime.GeneratedTemplate diff --git a/features/connections/pages/profile.templ b/features/connections/pages/profile.templ index 703d737..69aa86a 100644 --- a/features/connections/pages/profile.templ +++ b/features/connections/pages/profile.templ @@ -161,7 +161,6 @@ templ ProfileViewPage(v ProfileView) {
diff --git a/features/connections/pages/profile_templ.go b/features/connections/pages/profile_templ.go index b75fb56..d6ca1ee 100644 --- a/features/connections/pages/profile_templ.go +++ b/features/connections/pages/profile_templ.go @@ -445,20 +445,7 @@ func ProfileViewPage(v ProfileView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "⚑ follow up after the conference
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "⚑ follow up after the conference") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -466,7 +453,7 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/events/handlers.go b/features/events/handlers.go index c49618e..46a9ca6 100644 --- a/features/events/handlers.go +++ b/features/events/handlers.go @@ -7,6 +7,7 @@ import ( "net/http" "time" + "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" "github.com/starfederation/datastar-go/datastar" @@ -22,13 +23,14 @@ import ( // Handlers holds dependencies for the events feature. type Handlers struct { - DB *sql.DB - Auth *auth.Handlers + DB *sql.DB + Auth *auth.Handlers + Directory identity.Directory } // NewHandlers wires the events feature. func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers { - return &Handlers{DB: conn, Auth: authH} + return &Handlers{DB: conn, Auth: authH, Directory: identity.DefaultDirectory()} } // List renders the /events page showing all events the user has checked into. @@ -44,27 +46,60 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { slog.Error("events: list for user", "did", viewerDID.String(), "err", err) } - var items []pages.EventItem + // Load the viewer's connections once from their PDS, then count per event. + viewerPDS := h.lookupPDSForDID(r, viewerDID) + viewerConns, err := connection.List(r.Context(), viewerPDS, viewerDID) + if err != nil { + slog.Debug("events: list viewer connections", "err", err) + } + connsByEvent := make(map[string]int) + for _, c := range viewerConns { + if c.EventURI != "" { + connsByEvent[c.EventURI]++ + } + } + + var happeningNow, upcoming, past []pages.EventItem for _, ue := range userEvents { - stats, _ := event.GetStats(r.Context(), h.DB, ue.EventURI) attendeeDIDs, _ := checkin.ListAttendeesForEvent(r.Context(), h.DB, ue.EventURI) - items = append(items, pages.EventItem{ + + var status string + if ue.IsOngoing { + status = "live" + } else if now.Before(ue.StartTime) { + status = "upcoming" + } else { + status = "past" + } + + item := pages.EventItem{ URI: ue.EventURI, Name: ue.EventName, Location: ue.Location, - StartTime: ue.StartTime.Format("Mon Jan 2 · 3:04 PM"), - EndTime: ue.EndTime.Format("Mon Jan 2 · 3:04 PM"), - IsOngoing: ue.IsOngoing, + StartTime: formatDateRange(ue.StartTime, ue.EndTime), + EndTime: ue.EndTime.Format("Jan 2"), + Status: status, QRToken: event.QRTokenForURI(r.Context(), h.DB, ue.EventURI), - UniqueConnectors: stats.UniqueConnectors, - TotalCheckins: stats.TotalCheckins, + UniqueConnectors: connsByEvent[ue.EventURI], + TotalCheckins: len(attendeeDIDs), TotalAttendees: len(attendeeDIDs), - }) + } + + switch status { + case "live": + happeningNow = append(happeningNow, item) + case "upcoming": + upcoming = append(upcoming, item) + default: + past = append(past, item) + } } view := pages.EventsView{ - Events: items, - HasAny: len(items) > 0, + HappeningNow: happeningNow, + Upcoming: upcoming, + Past: past, + HasAny: len(userEvents) > 0, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -73,6 +108,18 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { } } +// formatDateRange returns a compact date range like "jun 1–2" or "jun 1". +func formatDateRange(start, end time.Time) string { + s := start.Format("Jan 2") + if start.Year() == end.Year() && start.Month() == end.Month() && start.Day() == end.Day() { + return s + } + if start.Month() == end.Month() { + return fmt.Sprintf("%s–%d", s, end.Day()) + } + return fmt.Sprintf("%s – %s", s, end.Format("Jan 2")) +} + // Detail renders the event detail screen with attendees grid and leaderboard. func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { viewerDID, _, ok := h.Auth.RequireSession(w, r) @@ -111,6 +158,7 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { type connCount struct { did string displayName string + handle string avatarURL string count int } @@ -132,6 +180,7 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { a := pages.Attendee{ DID: didStr, Connected: isConnected, + Handle: h.resolveHandle(r, did), } // Enrich with Bluesky profile. @@ -158,6 +207,7 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { connCounts[didStr] = &connCount{ did: didStr, displayName: a.DisplayName, + handle: a.Handle, avatarURL: a.AvatarURL, count: eventConns, } @@ -192,23 +242,33 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { Rank: i + 1, DID: cc.did, DisplayName: cc.displayName, + Handle: cc.handle, AvatarURL: cc.avatarURL, ConnectionCount: cc.count, }) } - // Milestone thresholds based on total attendees. + // Milestone thresholds based on expected attendees (or checked-in count as fallback). totalAttendees := len(attendeeDIDs) - leaderboardThreshold := totalAttendees * 20 / 100 - interestThreshold := totalAttendees * 40 / 100 - if leaderboardThreshold < 1 && totalAttendees > 0 { + milestoneBase := totalAttendees + if ev.ExpectedAttendees > 0 { + milestoneBase = ev.ExpectedAttendees + } + leaderboardThreshold := milestoneBase * 20 / 100 + interestThreshold := milestoneBase * 40 / 100 + statsThreshold := milestoneBase * 80 / 100 + if leaderboardThreshold < 1 && milestoneBase > 0 { leaderboardThreshold = 1 } - if interestThreshold < 1 && totalAttendees > 0 { + if interestThreshold < 1 && milestoneBase > 0 { interestThreshold = 1 } + if statsThreshold < 1 && milestoneBase > 0 { + statsThreshold = 1 + } leaderboardUnlocked := stats.UniqueConnectors >= leaderboardThreshold interestUnlocked := stats.UniqueConnectors >= interestThreshold + statsUnlocked := stats.UniqueConnectors >= statsThreshold // Interest matching: find attendees with shared interests (if unlocked). var interestMatches []pages.InterestMatch @@ -239,9 +299,18 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { viewerDID, connSet, pdsLookup, avatarFn, ) for _, m := range rawMatches { + // Look up handle from already-enriched attendees. + var handle string + for _, a := range attendees { + if a.DID == m.DID.String() { + handle = a.Handle + break + } + } interestMatches = append(interestMatches, pages.InterestMatch{ DID: m.DID.String(), DisplayName: m.DisplayName, + Handle: handle, AvatarURL: m.AvatarURL, SharedInterests: m.SharedInterests, Connected: m.Connected, @@ -250,22 +319,55 @@ func (h *Handlers) Detail(w http.ResponseWriter, r *http.Request) { } } + // Compute event status text. + var status string + isOngoing := ev.IsOngoing(now) + if isOngoing { + status = "in progress" + } else if now.Before(ev.StartTime) { + status = "starting soon" + } else { + status = "complete" + } + + // Compute day number / total days for multi-day events. + dayNumber := 0 + totalDays := 0 + eventDuration := ev.EndTime.Sub(ev.StartTime) + if eventDuration.Hours() > 24 { + totalDays = int(eventDuration.Hours()/24) + 1 + if now.Before(ev.StartTime) { + dayNumber = 0 + } else { + dayNumber = int(now.Sub(ev.StartTime).Hours()/24) + 1 + if dayNumber > totalDays { + dayNumber = totalDays + } + } + } + + // Use the event's expected attendees for the total if set, otherwise + // fall back to the number of checked-in attendees. view := pages.EventDetailView{ Token: token, Name: ev.Name, Location: ev.Location, StartTime: ev.StartTime.Format("Mon Jan 2 · 3:04 PM"), EndTime: ev.EndTime.Format("Mon Jan 2 · 3:04 PM"), - IsOngoing: ev.IsOngoing(now), + IsOngoing: isOngoing, + Status: status, + DayNumber: dayNumber, + TotalDays: totalDays, UniqueConnectors: stats.UniqueConnectors, TotalCheckins: stats.TotalCheckins, - TotalAttendees: totalAttendees, + TotalAttendees: milestoneBase, Attendees: attendees, Filter: filter, Leaderboard: leaderboard, LeaderboardUnlocked: leaderboardUnlocked, InterestMatches: interestMatches, InterestUnlocked: interestUnlocked, + StatsUnlocked: statsUnlocked, } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -291,6 +393,23 @@ func (h *Handlers) lookupPDSForDID(r *http.Request, did syntax.DID) string { return host } +// resolveHandle returns the user's atproto handle, or empty string if +// resolution fails. Uses the identity directory (DNS + DID document). +func (h *Handlers) resolveHandle(r *http.Request, did syntax.DID) string { + if h.Directory == nil { + return "" + } + ident, err := h.Directory.LookupDID(r.Context(), did) + if err != nil || ident == nil { + return "" + } + hStr := ident.Handle.String() + if hStr == "" || hStr == "handle.invalid" { + return "" + } + return hStr +} + // Live streams live progress updates for the event detail page via SSE. // It patches the stats display and progress bar every 5 seconds. func (h *Handlers) Live(w http.ResponseWriter, r *http.Request) { diff --git a/features/events/pages/detail.templ b/features/events/pages/detail.templ index da660bc..e36da2e 100644 --- a/features/events/pages/detail.templ +++ b/features/events/pages/detail.templ @@ -14,6 +14,9 @@ type EventDetailView struct { StartTime string EndTime string IsOngoing bool + Status string // "in progress", "starting soon", or "complete" + DayNumber int // current day of event (1-based), 0 if not multi-day + TotalDays int // total days the event spans, 0 if not multi-day UniqueConnectors int TotalCheckins int TotalAttendees int // total checked-in attendees (for milestone %) @@ -23,12 +26,14 @@ type EventDetailView struct { LeaderboardUnlocked bool // true when UniqueConnectors >= 20% of TotalAttendees InterestMatches []InterestMatch InterestUnlocked bool // true when UniqueConnectors >= 40% of TotalAttendees + StatsUnlocked bool // true when UniqueConnectors >= 80% of TotalAttendees } // Attendee is one person checked into the event. type Attendee struct { DID string DisplayName string + Handle string AvatarURL string Connected bool // whether the viewer has connected with this person } @@ -38,6 +43,7 @@ type LeaderboardRow struct { Rank int DID string DisplayName string + Handle string AvatarURL string ConnectionCount int } @@ -46,6 +52,7 @@ type LeaderboardRow struct { type InterestMatch struct { DID string DisplayName string + Handle string AvatarURL string SharedInterests []string Connected bool @@ -64,29 +71,92 @@ templ EventDetail(v EventDetailView) {
you{ "@" }atmo.quest:~$ - event --detail { v.Token } + event --detail { v.Name }
-
- if v.IsOngoing { - ● live - } else { - ended - } -

{ v.Name }

+
+
+ if v.Status == "in progress" { + { v.Status } + } else if v.Status == "starting soon" { + { v.Status } + } else { + { v.Status } + } + if v.TotalDays > 1 && v.DayNumber > 0 { + · day { itoa(v.DayNumber) } of { itoa(v.TotalDays) } + } +
+
{ v.Name }
+
+ if v.Location != "" { + { v.Location } · + } + { v.StartTime } → { v.EndTime } +
- if v.Location != "" { -

📍 { v.Location }

- } -

{ v.StartTime } → { v.EndTime }

@eventDetailProgress(v.UniqueConnectors, v.TotalAttendees) -
- { itoa(v.UniqueConnectors) } unique connectors - { itoa(v.TotalCheckins) } check-ins - { itoa(v.TotalAttendees) } attendees +
+ if v.LeaderboardUnlocked { +
+
◆
+
+
Leaderboard
+
See who's met the most folks today
+
+
unlocked
+
+ } else { +
+
◇
+
+
Leaderboard
+
{ remaining(milestone20(v.TotalAttendees), v.UniqueConnectors) } more to unlock
+
+
locked
+
+ } + if v.InterestUnlocked { +
+
◆
+
+
Interest matching
+
Find attendees with shared interests
+
+
unlocked
+
+ } else { +
+
◇
+
+
Interest matching
+
{ remaining(milestone40(v.TotalAttendees), v.UniqueConnectors) } more to unlock
+
+
locked
+
+ } + if v.StatsUnlocked { +
+
◆
+
+
Event statistics
+
Full conference stats in your end-of-event summary
+
+
unlocked
+
+ } else { +
+
◇
+
+
Event statistics
+
{ remaining(milestone80(v.TotalAttendees), v.UniqueConnectors) } more to unlock
+
+
locked
+
+ }
if v.LeaderboardUnlocked && len(v.Leaderboard) > 0 { @@ -98,11 +168,6 @@ templ EventDetail(v EventDetailView) { }
- } else if !v.LeaderboardUnlocked { -
- 🔒 - leaderboard unlocks at { itoa(milestone20(v.TotalAttendees)) } connectors ({ "20%" }) -
} if v.InterestUnlocked && len(v.InterestMatches) > 0 { @@ -114,18 +179,15 @@ templ EventDetail(v EventDetailView) { }
- } else if !v.InterestUnlocked { -
- 🔒 - interest matching unlocks at { itoa(milestone40(v.TotalAttendees)) } connectors ({ "40%" }) -
}
- -
- all - connected +
+ +
+ all + mine +
if len(v.Attendees) > 0 {
@@ -166,6 +228,8 @@ templ attendeeCard(a Attendee, token string) { if a.DisplayName != "" { { a.DisplayName } + } else if a.Handle != "" { + { "@" + a.Handle } } else { { shortDID(a.DID) } } @@ -184,6 +248,8 @@ templ leaderboardRow(row LeaderboardRow) { if row.DisplayName != "" { { row.DisplayName } + } else if row.Handle != "" { + { "@" + row.Handle } } else { { shortDID(row.DID) } } @@ -203,6 +269,8 @@ templ interestMatchCard(m InterestMatch) { if m.DisplayName != "" { { m.DisplayName } + } else if m.Handle != "" { + { "@" + m.Handle } } else { { shortDID(m.DID) } } @@ -220,19 +288,26 @@ templ interestMatchCard(m InterestMatch) { } templ eventDetailProgress(connectors int, totalAttendees int) { -
-
-
-
- = milestone20(totalAttendees)) }> +
+
+
collective progress
+
{ itoa(connectors) }/{ itoa(totalAttendees) } attendees
+
+
+
+
+
+
0
+
= milestone20(totalAttendees)), templ.KV("next", connectors < milestone20(totalAttendees)) }> + { itoa(milestone20(totalAttendees)) }
leaderboard
-
- = milestone40(totalAttendees)) }> +
= milestone40(totalAttendees)), templ.KV("next", connectors >= milestone20(totalAttendees) && connectors < milestone40(totalAttendees)) }> + { itoa(milestone40(totalAttendees)) }
matching
-
-
- = milestone20(totalAttendees)) } style="margin-left:16%">{ "20%" } 🏆 - = milestone40(totalAttendees)) } style="margin-left:12%">{ "40%" } ✨ +
= milestone80(totalAttendees)), templ.KV("next", connectors >= milestone40(totalAttendees) && connectors < milestone80(totalAttendees)) }> + { itoa(milestone80(totalAttendees)) }
stats +
+
{ itoa(totalAttendees) }
} @@ -278,6 +353,25 @@ func milestone40(total int) int { return m } +// milestone80 returns the absolute connector count for the 80% milestone. +func milestone80(total int) int { + m := total * 80 / 100 + if m < 1 && total > 0 { + return 1 + } + return m +} + +// remaining returns how many more connectors are needed to reach a milestone. +// Floors at 0 so we never show negative numbers. +func remaining(milestone, current int) string { + r := milestone - current + if r < 0 { + r = 0 + } + return fmt.Sprintf("%d", r) +} + // sseAttr returns the datastar SSE attribute for live event updates. func sseAttr(token string) string { return "@get('/events/" + token + "/live')" diff --git a/features/events/pages/detail_templ.go b/features/events/pages/detail_templ.go index e787af0..3693cb0 100644 --- a/features/events/pages/detail_templ.go +++ b/features/events/pages/detail_templ.go @@ -22,6 +22,9 @@ type EventDetailView struct { StartTime string EndTime string IsOngoing bool + Status string // "in progress", "starting soon", or "complete" + DayNumber int // current day of event (1-based), 0 if not multi-day + TotalDays int // total days the event spans, 0 if not multi-day UniqueConnectors int TotalCheckins int TotalAttendees int // total checked-in attendees (for milestone %) @@ -31,12 +34,14 @@ type EventDetailView struct { LeaderboardUnlocked bool // true when UniqueConnectors >= 20% of TotalAttendees InterestMatches []InterestMatch InterestUnlocked bool // true when UniqueConnectors >= 40% of TotalAttendees + StatsUnlocked bool // true when UniqueConnectors >= 80% of TotalAttendees } // Attendee is one person checked into the event. type Attendee struct { DID string DisplayName string + Handle string AvatarURL string Connected bool // whether the viewer has connected with this person } @@ -46,6 +51,7 @@ type LeaderboardRow struct { Rank int DID string DisplayName string + Handle string AvatarURL string ConnectionCount int } @@ -54,6 +60,7 @@ type LeaderboardRow struct { type InterestMatch struct { DID string DisplayName string + Handle string AvatarURL string SharedInterests []string Connected bool @@ -107,7 +114,7 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 66, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 73, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -118,9 +125,9 @@ func EventDetail(v EventDetailView) templ.Component { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string - templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.Token) + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 67, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 74, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -133,310 +140,349 @@ func EventDetail(v EventDetailView) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(sseAttr(v.Token)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 70, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 77, Col: 61} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if v.IsOngoing { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "● live") + if v.Status == "in progress" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "ended") + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(v.Status) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 81, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(v.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 77, Col: 67} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if v.Location != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

📍 ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if v.Status == "starting soon" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.Location) + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.Status) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 80, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 83, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.Status) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 85, Col: 48} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.StartTime) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 82, Col: 45} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + if v.TotalDays > 1 && v.DayNumber > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "· day ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.DayNumber)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 88, Col: 34} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " of ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.TotalDays)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 88, Col: 59} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, " → ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.EndTime) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 82, Col: 63} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.Name) if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 91, Col: 46} } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = eventDetailProgress(v.UniqueConnectors, v.TotalAttendees).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + if v.Location != "" { + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(v.Location) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 94, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " · ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.UniqueConnectors)) + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(v.StartTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 87, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 96, Col: 20} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, " unique connectors ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " → ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.TotalCheckins)) + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(v.EndTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 88, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 96, Col: 38} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " check-ins ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var12 string - templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.TotalAttendees)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 89, Col: 55} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + templ_7745c5c3_Err = eventDetailProgress(v.UniqueConnectors, v.TotalAttendees).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " attendees
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if v.LeaderboardUnlocked && len(v.Leaderboard) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + if v.LeaderboardUnlocked { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
◆
Leaderboard
See who's met the most folks today
unlocked
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - for _, row := range v.Leaderboard { - templ_7745c5c3_Err = leaderboardRow(row).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
◇
Leaderboard
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else if !v.LeaderboardUnlocked { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
🔒 leaderboard unlocks at ") + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestone20(v.TotalAttendees), v.UniqueConnectors)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 117, Col: 96} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var13 string - templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone20(v.TotalAttendees))) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " more to unlock
locked
") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 104, Col: 93} + return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + } + if v.InterestUnlocked { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
◆
Interest matching
Find attendees with shared interests
unlocked
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " connectors (") + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
◇
Interest matching
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs("20%") + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestone40(v.TotalAttendees), v.UniqueConnectors)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 104, Col: 115} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 136, Col: 96} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, ")
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, " more to unlock
locked
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - if v.InterestUnlocked && len(v.InterestMatches) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + if v.StatsUnlocked { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
◆
Event statistics
Full conference stats in your end-of-event summary
unlocked
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - for _, m := range v.InterestMatches { - templ_7745c5c3_Err = interestMatchCard(m).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
◇
Event statistics
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else if !v.InterestUnlocked { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
🔒 interest matching unlocks at ") + var templ_7745c5c3_Var17 string + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(remaining(milestone80(v.TotalAttendees), v.UniqueConnectors)) if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 155, Col: 96} } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone40(v.TotalAttendees))) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 120, Col: 99} + return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, " more to unlock
locked
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, " connectors (") + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.LeaderboardUnlocked && len(v.Leaderboard) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs("40%") + for _, row := range v.Leaderboard { + templ_7745c5c3_Err = leaderboardRow(row).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 120, Col: 121} + return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + } + if v.InterestUnlocked && len(v.InterestMatches) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ")
") + for _, m := range v.InterestMatches { + templ_7745c5c3_Err = interestMatchCard(m).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var17 = []any{"btn btn-ghost btn-sm", templ.KV("btn-primary", v.Filter == "all" || v.Filter == "")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var17...) + var templ_7745c5c3_Var18 = []any{"filter-tab", templ.KV("active", v.Filter == "all" || v.Filter == "")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "all ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\">all ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var20 = []any{"btn btn-ghost btn-sm", templ.KV("btn-primary", v.Filter == "mine")} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...) + var templ_7745c5c3_Var21 = []any{"filter-tab", templ.KV("active", v.Filter == "mine")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "connected
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">mine
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(v.Attendees) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -446,17 +492,17 @@ func EventDetail(v EventDetailView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

no attendees to show.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "

no attendees to show.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -490,140 +536,150 @@ func attendeeCard(a Attendee, token string) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var23 := templ.GetChildren(ctx) - if templ_7745c5c3_Var23 == nil { - templ_7745c5c3_Var23 = templ.NopComponent + templ_7745c5c3_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if a.AvatarURL != "" { - var templ_7745c5c3_Var26 = []any{"attendee-avatar", templ.KV("connected", a.Connected), templ.KV("not-connected", !a.Connected)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var26...) + var templ_7745c5c3_Var27 = []any{"attendee-avatar", templ.KV("connected", a.Connected), templ.KV("not-connected", !a.Connected)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var27...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\"") ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" loading=\"lazy\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var30 = []any{"attendee-avatar leaderboard-avatar-empty", templ.KV("connected", a.Connected), templ.KV("not-connected", !a.Connected)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var30...) + var templ_7745c5c3_Var31 = []any{"attendee-avatar leaderboard-avatar-empty", templ.KV("connected", a.Connected), templ.KV("not-connected", !a.Connected)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var31...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
?
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">?
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if a.DisplayName != "" { - var templ_7745c5c3_Var32 string - templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(a.DisplayName) + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(a.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 168, Col: 19} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 230, Col: 19} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if a.Handle != "" { + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs("@" + a.Handle) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 232, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var33 string - templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(a.DID)) + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(a.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 170, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 234, Col: 21} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -647,126 +703,136 @@ func leaderboardRow(row LeaderboardRow) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var34 := templ.GetChildren(ctx) - if templ_7745c5c3_Var34 == nil { - templ_7745c5c3_Var34 = templ.NopComponent + templ_7745c5c3_Var36 := templ.GetChildren(ctx) + if templ_7745c5c3_Var36 == nil { + templ_7745c5c3_Var36 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var35 = []any{"leaderboard-rank", templ.KV("top-3", row.Rank <= 3)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var35...) + var templ_7745c5c3_Var37 = []any{"leaderboard-rank", templ.KV("top-3", row.Rank <= 3)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var37...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var37 string - templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(row.Rank)) + var templ_7745c5c3_Var39 string + templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(row.Rank)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 178, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 242, Col: 87} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if row.AvatarURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\"") ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" loading=\"lazy\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "
?
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "
?
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if row.DisplayName != "" { - var templ_7745c5c3_Var40 string - templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(row.DisplayName) + var templ_7745c5c3_Var42 string + templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(row.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 250, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if row.Handle != "" { + var templ_7745c5c3_Var43 string + templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs("@" + row.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 186, Col: 21} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 252, Col: 22} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var41 string - templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(row.DID)) + var templ_7745c5c3_Var44 string + templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(row.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 188, Col: 23} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 254, Col: 23} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var42 string - templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(row.ConnectionCount)) + var templ_7745c5c3_Var45 string + templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(row.ConnectionCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 191, Col: 61} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 257, Col: 61} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, " connections
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " connections
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -790,129 +856,139 @@ func interestMatchCard(m InterestMatch) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var43 := templ.GetChildren(ctx) - if templ_7745c5c3_Var43 == nil { - templ_7745c5c3_Var43 = templ.NopComponent + templ_7745c5c3_Var46 := templ.GetChildren(ctx) + if templ_7745c5c3_Var46 == nil { + templ_7745c5c3_Var46 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var44 = []any{"interest-match-card", templ.KV("connected", m.Connected)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var44...) + var templ_7745c5c3_Var47 = []any{"interest-match-card", templ.KV("connected", m.Connected)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var47...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if m.AvatarURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"") ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" loading=\"lazy\"> ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "
?
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "
?
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if m.DisplayName != "" { - var templ_7745c5c3_Var48 string - templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(m.DisplayName) + var templ_7745c5c3_Var51 string + templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(m.DisplayName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 271, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if m.Handle != "" { + var templ_7745c5c3_Var52 string + templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs("@" + m.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 205, Col: 20} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 273, Col: 21} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - var templ_7745c5c3_Var49 string - templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(m.DID)) + var templ_7745c5c3_Var53 string + templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(shortDID(m.DID)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 207, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 275, Col: 22} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if m.Connected { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "✓ connected") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "✓ connected") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, interest := range m.SharedInterests { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var50 string - templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(interest) + var templ_7745c5c3_Var54 string + templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(interest) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 216, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 284, Col: 55} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -936,178 +1012,204 @@ func eventDetailProgress(connectors int, totalAttendees int) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var51 := templ.GetChildren(ctx) - if templ_7745c5c3_Var51 == nil { - templ_7745c5c3_Var51 = templ.NopComponent + templ_7745c5c3_Var55 := templ.GetChildren(ctx) + if templ_7745c5c3_Var55 == nil { + templ_7745c5c3_Var55 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "
collective progress
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var53 string - templ_7745c5c3_Var53, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width:" + connectPct(connectors, totalAttendees) + "%") + var templ_7745c5c3_Var57 string + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(connectors)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 225, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 294, Col: 49} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "\">
/") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var54 string - templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(milestone20(totalAttendees)) + " — leaderboard") + var templ_7745c5c3_Var58 string + templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(totalAttendees)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 226, Col: 115} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 294, Col: 91} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var54) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " attendees
= milestone20(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var55...) + var templ_7745c5c3_Var59 string + templ_7745c5c3_Var59, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width:" + connectPct(connectors, totalAttendees) + "%") if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 297, Col: 97} } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "
") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 1, Col: 0} + return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var56) + var templ_7745c5c3_Var60 = []any{"progress-tick", templ.KV("unlocked", true)} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var60...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "\">0
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var58 = []any{"event-progress-tick-dot", templ.KV("reached", connectors >= milestone40(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var58...) + var templ_7745c5c3_Var62 = []any{"progress-tick", templ.KV("unlocked", connectors >= milestone20(totalAttendees)), templ.KV("next", connectors < milestone20(totalAttendees))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var62...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var60 = []any{"event-progress-label", templ.KV("reached", connectors >= milestone20(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var60...) + var templ_7745c5c3_Var64 string + templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone20(totalAttendees))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 302, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "leaderboard
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var61 string - templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var60).String()) + var templ_7745c5c3_Var65 = []any{"progress-tick", templ.KV("unlocked", connectors >= milestone40(totalAttendees)), templ.KV("next", connectors >= milestone20(totalAttendees) && connectors < milestone40(totalAttendees))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var65...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var62 string - templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs("20%") + var templ_7745c5c3_Var67 string + templ_7745c5c3_Var67, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone40(totalAttendees))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 234, Col: 137} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 305, Col: 39} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var67)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, " 🏆 ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
matching
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var63 = []any{"event-progress-label", templ.KV("reached", connectors >= milestone40(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var63...) + var templ_7745c5c3_Var68 = []any{"progress-tick", templ.KV("unlocked", connectors >= milestone80(totalAttendees)), templ.KV("next", connectors >= milestone40(totalAttendees) && connectors < milestone80(totalAttendees))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var68...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var70 string + templ_7745c5c3_Var70, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone80(totalAttendees))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 308, Col: 39} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var70)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" style=\"margin-left:12%\">") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
stats
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var65 string - templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs("40%") + var templ_7745c5c3_Var71 string + templ_7745c5c3_Var71, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(totalAttendees)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 235, Col: 137} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 310, Col: 52} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var71)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, " ✨
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -1156,6 +1258,25 @@ func milestone40(total int) int { return m } +// milestone80 returns the absolute connector count for the 80% milestone. +func milestone80(total int) int { + m := total * 80 / 100 + if m < 1 && total > 0 { + return 1 + } + return m +} + +// remaining returns how many more connectors are needed to reach a milestone. +// Floors at 0 so we never show negative numbers. +func remaining(milestone, current int) string { + r := milestone - current + if r < 0 { + r = 0 + } + return fmt.Sprintf("%d", r) +} + // sseAttr returns the datastar SSE attribute for live event updates. func sseAttr(token string) string { return "@get('/events/" + token + "/live')" diff --git a/features/events/pages/events.templ b/features/events/pages/events.templ index d5499ac..24b8f72 100644 --- a/features/events/pages/events.templ +++ b/features/events/pages/events.templ @@ -1,13 +1,17 @@ package pages import ( + "fmt" + "atmoquest/features/common/layouts" ) // EventsView is the data for the /events page. type EventsView struct { - Events []EventItem - HasAny bool + HappeningNow []EventItem + Upcoming []EventItem + Past []EventItem + HasAny bool } // EventItem is one event the user has checked into. @@ -15,15 +19,19 @@ type EventItem struct { URI string Name string Location string - StartTime string // pre-formatted - EndTime string // pre-formatted - IsOngoing bool + StartTime string // pre-formatted date range (e.g. "jun 1–2") + EndTime string // pre-formatted end info + Status string // "live", "upcoming", "past" QRToken string // for linking to /events/{token} - UniqueConnectors int // event-wide progress metric + UniqueConnectors int // connections the viewer made at this event TotalCheckins int TotalAttendees int // total checked-in attendees } +func evItoa(n int) string { + return fmt.Sprintf("%d", n) +} + templ Events(v EventsView) { @layouts.Base("events — atmo.quest", "Your events on atmo.quest.") {
@@ -35,25 +43,64 @@ templ Events(v EventsView) { @layouts.TopNav("event")
-
- you{ "@" }atmo.quest:~$ - ls events/ -
+
+
+
Your events
+
+
+ checked in to { evItoa(len(v.HappeningNow)) } · attended { evItoa(len(v.Past)) } +
-
- if len(v.Events) > 0 { -
- for _, ev := range v.Events { - @eventCard(ev) - } -
- } else { + if !v.HasAny {

you haven't checked into any events yet.

scan an event QR code to get started.

← back home
} + + if len(v.HappeningNow) > 0 { +
+
+
Happening now
+
{ evItoa(len(v.HappeningNow)) }
+
+ for _, ev := range v.HappeningNow { + @eventListCard(ev) + } +
+ } + + if len(v.Upcoming) > 0 { +
+
+
Upcoming
+
{ evItoa(len(v.Upcoming)) }
+
+ for _, ev := range v.Upcoming { + @eventListCard(ev) + } +
+ } + + if len(v.Past) > 0 { +
+
+
Past events
+
{ evItoa(len(v.Past)) }
+
+ for _, ev := range v.Past { + @eventListCard(ev) + } +
+ } + +
+ +
event creation coming soon
+
@@ -68,53 +115,41 @@ templ Events(v EventsView) { } } -templ eventCard(ev EventItem) { -
-
- if ev.IsOngoing { - ● live - } else { - ended - } -

{ ev.Name }

-
- if ev.Location != "" { -

📍 { ev.Location }

- } -

{ ev.StartTime } → { ev.EndTime }

- @eventProgress(ev.UniqueConnectors, ev.TotalAttendees) -
- { itoa(ev.UniqueConnectors) } unique connectors - { itoa(ev.TotalCheckins) } check-ins -
+templ eventListCard(ev EventItem) { + - if ev.IsOngoing { - ▸ view event + href={ templ.SafeURL("/events/" + ev.QRToken) } + } + class={ "event-card", templ.KV("live", ev.Status == "live") } + > +
+
{ ev.Name }
+
-} - -// eventProgress renders a segmented progress bar with percentage-based unlock thresholds. -// Thresholds: 20% of attendees → leaderboard, 40% → interest matching. -templ eventProgress(connectors int, totalAttendees int) { -
-
-
-
- = milestone20(totalAttendees)) }> -
-
- = milestone40(totalAttendees)) }>
-
- = milestone20(totalAttendees)) } style="margin-left:16%">20% 🏆 - = milestone40(totalAttendees)) } style="margin-left:12%">40% ✨ +
+ { ev.StartTime } + · + { ev.Location }
-
+ if ev.Status == "live" || ev.Status == "past" { +
+
+
{ evItoa(ev.UniqueConnectors) }
+
your connections
+
+
+
{ evItoa(ev.TotalAttendees) }
+
checked in
+
+
+ } + } diff --git a/features/events/pages/events_templ.go b/features/events/pages/events_templ.go index 28bea4a..e67d5c6 100644 --- a/features/events/pages/events_templ.go +++ b/features/events/pages/events_templ.go @@ -9,13 +9,17 @@ import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" import ( + "fmt" + "atmoquest/features/common/layouts" ) // EventsView is the data for the /events page. type EventsView struct { - Events []EventItem - HasAny bool + HappeningNow []EventItem + Upcoming []EventItem + Past []EventItem + HasAny bool } // EventItem is one event the user has checked into. @@ -23,15 +27,19 @@ type EventItem struct { URI string Name string Location string - StartTime string // pre-formatted - EndTime string // pre-formatted - IsOngoing bool + StartTime string // pre-formatted date range (e.g. "jun 1–2") + EndTime string // pre-formatted end info + Status string // "live", "upcoming", "past" QRToken string // for linking to /events/{token} - UniqueConnectors int // event-wide progress metric + UniqueConnectors int // connections the viewer made at this event TotalCheckins int TotalAttendees int // total checked-in attendees } +func evItoa(n int) string { + return fmt.Sprintf("%d", n) +} + func Events(v EventsView) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context @@ -73,45 +81,130 @@ func Events(v EventsView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
you") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Your events
checked in to ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string - templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs("@") + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(evItoa(len(v.HappeningNow))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 39, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 51, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "atmo.quest:~$ ls events/
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, " · attended ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(evItoa(len(v.Past))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 51, Col: 85} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if len(v.Events) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + if !v.HasAny { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

you haven't checked into any events yet.

scan an event QR code to get started.

← back home
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(v.HappeningNow) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
Happening now
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - for _, ev := range v.Events { - templ_7745c5c3_Err = eventCard(ev).Render(ctx, templ_7745c5c3_Buffer) + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(evItoa(len(v.HappeningNow))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 66, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, ev := range v.HappeningNow { + templ_7745c5c3_Err = eventListCard(ev).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

you haven't checked into any events yet.

scan an event QR code to get started.

← back home
") + } + if len(v.Upcoming) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
Upcoming
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(evItoa(len(v.Upcoming))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 78, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, ev := range v.Upcoming { + templ_7745c5c3_Err = eventListCard(ev).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if len(v.Past) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
Past events
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(evItoa(len(v.Past))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 90, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, ev := range v.Past { + templ_7745c5c3_Err = eventListCard(ev).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
event creation coming soon
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -129,7 +222,7 @@ func Events(v EventsView) templ.Component { }) } -func eventCard(ev EventItem) templ.Component { +func eventListCard(ev EventItem) templ.Component { return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { @@ -145,350 +238,197 @@ func eventCard(ev EventItem) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var4 := templ.GetChildren(ctx) - if templ_7745c5c3_Var4 == nil { - templ_7745c5c3_Var4 = templ.NopComponent + templ_7745c5c3_Var8 := templ.GetChildren(ctx) + if templ_7745c5c3_Var8 == nil { + templ_7745c5c3_Var8 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - var templ_7745c5c3_Var5 = []any{"event-card-row", templ.KV("event-card-ongoing", ev.IsOngoing)} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var5...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "● live") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "ended") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var7 string - templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Name) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 79, Col: 40} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if ev.Location != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

📍 ") + if ev.QRToken != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " href=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var8 string - templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Location) + var templ_7745c5c3_Var10 templ.SafeURL + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/events/" + ev.QRToken)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 82, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 121, Col: 48} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " class=\"") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var9 string - templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(ev.StartTime) + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var9).String()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 84, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 1, Col: 0} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " → ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var10 string - templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(ev.EndTime) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 84, Col: 62} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

") + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Name) if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 126, Col: 41} } - templ_7745c5c3_Err = eventProgress(ev.UniqueConnectors, ev.TotalAttendees).Render(ctx, templ_7745c5c3_Buffer) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(ev.UniqueConnectors)) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 87, Col: 55} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + var templ_7745c5c3_Var13 = []any{"event-card-status", ev.Status} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var13...) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, " unique connectors ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if ev.QRToken != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if ev.IsOngoing { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "▸ view event") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "view details →") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "past") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - return nil - }) -} - -// eventProgress renders a segmented progress bar with percentage-based unlock thresholds. -// Thresholds: 20% of attendees → leaderboard, 40% → interest matching. -func eventProgress(connectors int, totalAttendees int) templ.Component { - return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { - templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context - if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { - return templ_7745c5c3_CtxErr - } - templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) - if !templ_7745c5c3_IsBuffer { - defer func() { - templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) - if templ_7745c5c3_Err == nil { - templ_7745c5c3_Err = templ_7745c5c3_BufErr - } - }() - } - ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var14 := templ.GetChildren(ctx) - if templ_7745c5c3_Var14 == nil { - templ_7745c5c3_Var14 = templ.NopComponent - } - ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string - templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("width:" + connectPct(connectors, totalAttendees) + "%") + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(ev.StartTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 107, Col: 99} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 138, Col: 23} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\">
· ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string - templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(itoa(milestone20(totalAttendees)) + " — leaderboard") - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 108, Col: 115} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\">") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var18 = []any{"event-progress-tick-dot", templ.KV("reached", connectors >= milestone20(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var21 = []any{"event-progress-tick-dot", templ.KV("reached", connectors >= milestone40(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var21...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var23 = []any{"event-progress-label", templ.KV("reached", connectors >= milestone20(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var23...) + templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Location) if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 140, Col: 22} } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" style=\"margin-left:16%\">20% 🏆 ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var25 = []any{"event-progress-label", templ.KV("reached", connectors >= milestone40(totalAttendees))} - templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var25...) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 = []any{"event-stat-num", templ.KV("accent", ev.Status == "live")} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(evItoa(ev.UniqueConnectors)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 145, Col: 107} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
your connections
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(evItoa(ev.TotalAttendees)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 149, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
checked in
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" style=\"margin-left:12%\">40% ✨
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/index/handlers.go b/features/index/handlers.go index 228293d..8ac836d 100644 --- a/features/index/handlers.go +++ b/features/index/handlers.go @@ -21,11 +21,13 @@ import ( "database/sql" "log/slog" "net/http" + "strings" "time" "github.com/bluesky-social/indigo/atproto/identity" "github.com/bluesky-social/indigo/atproto/syntax" + "atmoquest/config" "atmoquest/features/auth" "atmoquest/features/index/pages" "atmoquest/internal/checkin" @@ -113,6 +115,10 @@ func (h *Handlers) fillAuthedView(r *http.Request, did syntax.DID, view *pages.I // signed-in user. view.QRURL = "/profile/qr.svg" + // Debug: expose the connect URL so developers can copy-paste it into + // an incognito window to test connections without physically scanning. + view.ConnectURL = strings.TrimRight(config.Global.PublicURL, "/") + "/c/" + did.String() + // Current event: most recent ongoing check-in for this user. If the // user has no check-ins, no events, or no rows match, the card // renders an empty state instead. diff --git a/features/index/pages/index.templ b/features/index/pages/index.templ index cde06ae..e0337f7 100644 --- a/features/index/pages/index.templ +++ b/features/index/pages/index.templ @@ -21,6 +21,9 @@ type IndexView struct { // QRURL serves the SVG QR for this user's connect link, shown on the // back of the flip card. QRURL string + // ConnectURL is the full /c/ URL for testing/debugging — users + // can copy-paste it into another browser to simulate a QR scan. + ConnectURL string // CurrentEvent is the user's currently-ongoing check-in target, if any. // Nil pointer means "no active event right now" — the home page renders @@ -115,8 +118,13 @@ templ indexAuthed(v IndexView) { if v.ConnectURL != "" { +
+ connect link (for testing): + { v.ConnectURL } +
-
+ } @indexEventCard(v.CurrentEvent) @@ -140,18 +148,25 @@ templ indexAuthed(v IndexView) { // render it whether or not there's an active event so the slot stays stable // as users navigate. templ indexEventCard(ev *IndexEventCard) { - + } } // indexRecentConnections renders a small grid of recently-connected people. diff --git a/features/index/pages/index_templ.go b/features/index/pages/index_templ.go index f8e8dda..3950ce2 100644 --- a/features/index/pages/index_templ.go +++ b/features/index/pages/index_templ.go @@ -5,11 +5,11 @@ package pages //lint:file-ignore SA4006 This context is only used if a nested component is present. +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + import ( "atmoquest/features/common/layouts" - - "github.com/a-h/templ" - templruntime "github.com/a-h/templ/runtime" ) // IndexView passes session-aware values into the landing page. When a user @@ -29,6 +29,9 @@ type IndexView struct { // QRURL serves the SVG QR for this user's connect link, shown on the // back of the flip card. QRURL string + // ConnectURL is the full /c/ URL for testing/debugging — users + // can copy-paste it into another browser to simulate a QR scan. + ConnectURL string // CurrentEvent is the user's currently-ongoing check-in target, if any. // Nil pointer means "no active event right now" — the home page renders @@ -152,7 +155,7 @@ func indexAuthed(v IndexView) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs("@") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 83, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 86, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -170,7 +173,7 @@ func indexAuthed(v IndexView) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.AvatarURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 93, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 96, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -183,7 +186,7 @@ func indexAuthed(v IndexView) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(indexAvatarAlt(v.DisplayName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 93, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 96, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -211,7 +214,7 @@ func indexAuthed(v IndexView) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.QRURL) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 100, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 103, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) if templ_7745c5c3_Err != nil { @@ -234,7 +237,7 @@ func indexAuthed(v IndexView) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 108, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 111, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -258,7 +261,7 @@ func indexAuthed(v IndexView) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("@" + v.Handle) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 113, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 116, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -269,55 +272,42 @@ func indexAuthed(v IndexView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = indexEventCard(v.CurrentEvent).Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = indexRecentConnections(v.RecentConnections).Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
● atmo.quest · signed in as ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if v.Handle != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "") + if v.ConnectURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("@get('/demo/clock')") + templ_7745c5c3_Err = indexEventCard(v.CurrentEvent).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 133, Col: 70} + return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) + templ_7745c5c3_Err = indexRecentConnections(v.RecentConnections).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\">--:--:--
atmo.quest
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -325,7 +315,7 @@ func indexAuthed(v IndexView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -358,105 +348,89 @@ func indexEventCard(ev *IndexEventCard) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var12 := templ.GetChildren(ctx) - if templ_7745c5c3_Var12 == nil { - templ_7745c5c3_Var12 = templ.NopComponent + templ_7745c5c3_Var11 := templ.GetChildren(ctx) + if templ_7745c5c3_Var11 == nil { + templ_7745c5c3_Var11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } return nil }) } @@ -478,129 +452,129 @@ func indexRecentConnections(conns []RecentConnection) templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var18 := templ.GetChildren(ctx) - if templ_7745c5c3_Var18 == nil { - templ_7745c5c3_Var18 = templ.NopComponent + templ_7745c5c3_Var17 := templ.GetChildren(ctx) + if templ_7745c5c3_Var17 == nil { + templ_7745c5c3_Var17 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -632,12 +606,12 @@ func indexGuest() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var25 := templ.GetChildren(ctx) - if templ_7745c5c3_Var25 == nil { - templ_7745c5c3_Var25 = templ.NopComponent + templ_7745c5c3_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var26 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Var25 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) if !templ_7745c5c3_IsBuffer { @@ -649,65 +623,52 @@ func indexGuest() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
~/atmo.quest — zsh
you") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
~/atmo.quest — zsh
you") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var27 string - templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs("@") + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs("@") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 223, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 232, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "cascadiajs:~$ whoami
a person at a conference. you just met someone interesting and now their handle is gone.
init: v0.1 · CascadiaJS 2026

atmo.quest 

An event companion ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "cascadiajs:~$ whoami

a person at a conference. you just met someone interesting and now their handle is gone.
init: v0.1 · CascadiaJS 2026

atmo.quest 

An event companion ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var28 string - templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs("for") - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 234, Col: 56} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " the open social web. Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.

● atmo.quest · guest mode enabled
--:--:--
quest_log.md

Side Quests ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, " the open social web. Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.

quest_log.md

Side Quests ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var30 string - templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs("// available") + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs("// available") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 257, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 265, Col: 57} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "

◆
Make your first connection
Scan someone's QR code. A quest.atmo.connection record gets written to your PDS. They get one too. That's the protocol bit.
+1 badge first connect
◆
Check in to CascadiaJS
Drops a checkin record dated June 1–2, 2026. You'll find it in your repo forever.
+1 badge attendee
◆
Take a note while it's fresh
Private notes, attached to each connection. Read them Monday morning when you forget who Sarah was.
unlocks with first connection
◆
Help unlock the conference rewards
As more people join, new things appear — leaderboard, interest matching, end-of-conf stats. Earned together.
group quest see /unlocks
atmo.quest
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
◆
Make your first connection
Scan someone's QR code. A quest.atmo.connection record gets written to your PDS. They get one too. That's the protocol bit.
+1 badge first connect
◆
Check in to CascadiaJS
Drops a checkin record dated June 1–2, 2026. You'll find it in your repo forever.
+1 badge attendee
◆
Take a note while it's fresh
Private notes, attached to each connection. Read them Monday morning when you forget who Sarah was.
unlocks with first connection
◆
Help unlock the conference rewards
As more people join, new things appear — leaderboard, interest matching, end-of-conf stats. Earned together.
group quest see /unlocks
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = layouts.Base("atmo.quest — conference companion on ATProto", "Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var26), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = layouts.Base("atmo.quest — conference companion on ATProto", "Scan a QR, write a note, leave with a real follow-up list. Your data goes to your repo, not ours.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var25), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/settings/handlers.go b/features/settings/handlers.go index 3b720a3..d36c603 100644 --- a/features/settings/handlers.go +++ b/features/settings/handlers.go @@ -47,10 +47,39 @@ func (h *Handlers) Page(w http.ResponseWriter, r *http.Request) { isAdmin, _ := users.IsAdmin(r.Context(), h.DB, did) + // Count notes for the "local notes" row. + allNotes, _ := notes.ListAll(r.Context(), h.DB, did.String()) + noteCount := len(allNotes) + + // Display name initial for avatar. + avatarInitial := "?" + displayName := "" + if handle != "" && handle != "handle.invalid" { + avatarInitial = string([]rune(handle)[0]) + } + + // Extract PDS host from session. + pds := "" + if h.DB != nil { + var host string + err := h.DB.QueryRowContext(r.Context(), ` + SELECT data ->> 'host_url' FROM oauth_sessions + WHERE did = ? ORDER BY updated_at DESC LIMIT 1 + `, did.String()).Scan(&host) + if err == nil && host != "" { + // Extract hostname from URL. + pds = strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://") + } + } + view := pages.SettingsView{ - Handle: handle, - Deleted: deleted, - IsAdmin: isAdmin, + Handle: handle, + DisplayName: displayName, + AvatarInitial: avatarInitial, + PDS: pds, + NoteCount: noteCount, + Deleted: deleted, + IsAdmin: isAdmin, } w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/features/settings/pages/settings.templ b/features/settings/pages/settings.templ index c9e2363..e096b34 100644 --- a/features/settings/pages/settings.templ +++ b/features/settings/pages/settings.templ @@ -1,93 +1,183 @@ package pages -import "atmoquest/features/common/layouts" +import ( + "fmt" + + "atmoquest/features/common/layouts" +) // SettingsView is the data for the settings page. type SettingsView struct { - Handle string // user's atproto handle (used for delete confirmation) - Deleted bool // true after a successful delete - DeleteError string // non-empty when confirmation failed - IsAdmin bool // show admin link when true + Handle string // user's atproto handle + DisplayName string // user's display name + AvatarInitial string // first letter for avatar placeholder + PDS string // PDS hostname (e.g. "bsky.social") + NoteCount int // number of local notes + Deleted bool // true after a successful delete + DeleteError string // non-empty when confirmation failed + IsAdmin bool // show admin link when true +} + +func settingsItoa(n int) string { + return fmt.Sprintf("%d", n) } templ Settings(v SettingsView) { - @layouts.Base("settings — atmo.quest", "Export your notes or delete your data.") { + @layouts.Base("settings — atmo.quest", "Manage your account, privacy, and data.") {
~/atmo.quest — settings
- @layouts.TopNav("home") + @layouts.TopNav("settings")
-
- you{ "@" }atmo.quest:~$ - settings --data -
+
+
Settings
+
v0.1 · atmo.quest
+ + if v.Deleted { +
+ ✓ + all your notes and follow-up flags have been deleted. +
+ } - if v.Deleted { -
- ✓ - all your notes and follow-up flags have been deleted. + // ── Account card ── + - } - // ── Export section ── -
-

export your notes

-

- download a CSV of all your private notes and follow-up flags. - this includes every note you've written about your connections. -

- ▸ download CSV -
+ // ── Privacy ── +
+
Privacy
+
+
+
◆
+
+
Show me on event attendee lists
+
when off, you can still scan and connect
+
+
+
+
+
◇
+
+
Appear on the leaderboard
+
opt out anytime
+
+
+
+
+
- // ── Delete section ── -
-

delete your data

-

- permanently delete all your private notes and follow-up flags from - atmo.quest. this does not remove your ATProto - records (connections, check-ins, profile) — those live in your PDS - and are yours to keep. -

-

- to confirm, type your handle: { v.Handle } -

+ // ── Your data ── +
+
Your data
+
+ +
◆
+
+
Export connections & notes
+
download a CSV summary
+
+
›
+
+
+
◇
+
+
Local notes
+
stored on this device only
+
+
{ settingsItoa(v.NoteCount) } notes ›
+
+
+
- if v.DeleteError != "" { -
+ // ── Account (danger) ── +
+
Account
+
+
+ +
+ @deleteDataRow(v) +
+
- -
-
- -
-
+ +
atmo.quest @@ -97,6 +187,33 @@ templ Settings(v SettingsView) { - @layouts.BottomNav("home") + @layouts.BottomNav("settings") } } + +templ deleteDataRow(v SettingsView) { +
+
▲
+
+
Delete atmo.quest data
+
removes local notes and flags
+ if v.DeleteError != "" { + + } +
+ + +
+
+
+} diff --git a/features/settings/pages/settings_templ.go b/features/settings/pages/settings_templ.go index df02d08..ae0f8f2 100644 --- a/features/settings/pages/settings_templ.go +++ b/features/settings/pages/settings_templ.go @@ -8,14 +8,26 @@ package pages import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -import "atmoquest/features/common/layouts" +import ( + "fmt" + + "atmoquest/features/common/layouts" +) // SettingsView is the data for the settings page. type SettingsView struct { - Handle string // user's atproto handle (used for delete confirmation) - Deleted bool // true after a successful delete - DeleteError string // non-empty when confirmation failed - IsAdmin bool // show admin link when true + Handle string // user's atproto handle + DisplayName string // user's display name + AvatarInitial string // first letter for avatar placeholder + PDS string // PDS hostname (e.g. "bsky.social") + NoteCount int // number of local notes + Deleted bool // true after a successful delete + DeleteError string // non-empty when confirmation failed + IsAdmin bool // show admin link when true +} + +func settingsItoa(n int) string { + return fmt.Sprintf("%d", n) } func Settings(v SettingsView) templ.Component { @@ -55,103 +67,214 @@ func Settings(v SettingsView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = layouts.TopNav("home").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = layouts.TopNav("settings").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
Settings
v0.1 · atmo.quest
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
you") + if v.Deleted { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
✓ all your notes and follow-up flags have been deleted.
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "
Privacy
◆
Show me on event attendee lists
when off, you can still scan and connect
◇
Appear on the leaderboard
opt out anytime
Your data
◆
Export connections & notes
download a CSV summary
›
◇
Local notes
stored on this device only
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var6 string - templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.Handle) + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(settingsItoa(v.NoteCount)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 69, Col: 29} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 106, Col: 67} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" class=\"settings-confirm-input\" autocomplete=\"off\" required aria-label=\"Type your handle to confirm deletion\">
← back home ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " notes ›
Account
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = deleteDataRow(v).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.IsAdmin { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "admin console →") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
built on ATProto · open source · made for conferences
atmo.quest v0.1.0-pre
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = layouts.BottomNav("home").Render(ctx, templ_7745c5c3_Buffer) + templ_7745c5c3_Err = layouts.BottomNav("settings").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = layouts.Base("settings — atmo.quest", "Export your notes or delete your data.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = layouts.Base("settings — atmo.quest", "Manage your account, privacy, and data.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func deleteDataRow(v SettingsView) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var10 := templ.GetChildren(ctx) + if templ_7745c5c3_Var10 == nil { + templ_7745c5c3_Var10 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
▲
Delete atmo.quest data
removes local notes and flags
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.DeleteError != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.DeleteError) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/settings/pages/settings.templ`, Line: 202, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/checkin/checkin.go b/internal/checkin/checkin.go index ac665ea..e337cfd 100644 --- a/internal/checkin/checkin.go +++ b/internal/checkin/checkin.go @@ -47,6 +47,20 @@ func Put(ctx context.Context, sess *oauth.ClientSession, db *sql.DB, eventURI st at = time.Now().UTC() } + // Dedup: if this user already has a check-in for this event in the local + // cache, skip the PDS write and return the existing record URI. + if db != nil { + var existing string + err := db.QueryRowContext(ctx, ` + SELECT record_uri FROM checkins + WHERE did = ? AND event_uri = ? + LIMIT 1 + `, sess.Data.AccountDID.String(), eventURI).Scan(&existing) + if err == nil && existing != "" { + return existing, nil + } + } + value := map[string]any{ "$type": NSID, "event": eventURI, diff --git a/internal/checkin/list.go b/internal/checkin/list.go index 4864c70..6ddf470 100644 --- a/internal/checkin/list.go +++ b/internal/checkin/list.go @@ -39,10 +39,15 @@ func ListForUser(ctx context.Context, db *sql.DB, did syntax.DID, now time.Time) var out []UserEvent for rows.Next() { var ue UserEvent + var checkedInAtStr string if err := rows.Scan(&ue.EventURI, &ue.EventName, &ue.Location, - &ue.StartTime, &ue.EndTime, &ue.CheckedInAt); err != nil { + &ue.StartTime, &ue.EndTime, &checkedInAtStr); err != nil { return nil, err } + ue.CheckedInAt, _ = time.Parse(time.RFC3339, checkedInAtStr) + if ue.CheckedInAt.IsZero() { + ue.CheckedInAt, _ = time.Parse("2006-01-02 15:04:05", checkedInAtStr) + } ue.IsOngoing = !now.Before(ue.StartTime) && !now.After(ue.EndTime) out = append(out, ue) } diff --git a/internal/connection/drain.go b/internal/connection/drain.go index 667f845..b744a39 100644 --- a/internal/connection/drain.go +++ b/internal/connection/drain.go @@ -38,7 +38,7 @@ func Drain(ctx context.Context, q *Queue, sess *oauth.ClientSession, logger *slo return res, err } for _, item := range items { - _, _, err := Put(ctx, sess, Record{With: item.InitiatorDID}) + _, _, err := Put(ctx, sess, Record{With: item.InitiatorDID, EventURI: item.EventURI}) if err != nil { if logger != nil { logger.Warn("connection drain: write failed", diff --git a/internal/connection/list.go b/internal/connection/list.go index d2d7a5a..8eb11ee 100644 --- a/internal/connection/list.go +++ b/internal/connection/list.go @@ -83,3 +83,20 @@ func List(ctx context.Context, pdsHost string, did syntax.DID) ([]ListEntry, err } return all, nil } + +// HasConnection checks if the owner already has a connection record with +// the given target at the given event (or with no event if eventURI is empty). +// Returns true if a matching record exists. Uses the public listRecords +// endpoint (unauthenticated). +func HasConnection(ctx context.Context, pdsHost string, ownerDID, targetDID syntax.DID, eventURI string) bool { + entries, err := List(ctx, pdsHost, ownerDID) + if err != nil { + return false // can't tell — allow the write + } + for _, e := range entries { + if e.With == targetDID && e.EventURI == eventURI { + return true + } + } + return false +} diff --git a/internal/connection/queue.go b/internal/connection/queue.go index 3ccc938..3b5a97f 100644 --- a/internal/connection/queue.go +++ b/internal/connection/queue.go @@ -15,6 +15,7 @@ type PendingItem struct { ID int64 TargetDID syntax.DID InitiatorDID syntax.DID + EventURI string } // Queue is a SQLite-backed FIFO of reciprocal connection writes. Methods are @@ -30,9 +31,9 @@ func NewQueue(db *sql.DB) *Queue { } // Enqueue records that `target` owes a connection record listing `initiator`. -// Idempotent: re-enqueueing the same (target, initiator) is a no-op thanks to -// the unique index on (target_did, initiator_did). -func (q *Queue) Enqueue(ctx context.Context, target, initiator syntax.DID) error { +// Idempotent: re-enqueueing the same (target, initiator, eventURI) is a no-op +// thanks to the unique index. +func (q *Queue) Enqueue(ctx context.Context, target, initiator syntax.DID, eventURI string) error { if target == "" || initiator == "" { return errors.New("connection queue: empty DID") } @@ -40,10 +41,10 @@ func (q *Queue) Enqueue(ctx context.Context, target, initiator syntax.DID) error return errors.New("connection queue: self-connection") } _, err := q.db.ExecContext(ctx, ` - INSERT INTO pending_connections (target_did, initiator_did) - VALUES (?, ?) - ON CONFLICT(target_did, initiator_did) DO NOTHING - `, target.String(), initiator.String()) + INSERT INTO pending_connections (target_did, initiator_did, event_uri) + VALUES (?, ?, ?) + ON CONFLICT(target_did, initiator_did, event_uri) DO NOTHING + `, target.String(), initiator.String(), eventURI) if err != nil { return fmt.Errorf("enqueue: %w", err) } @@ -59,14 +60,14 @@ func (q *Queue) List(ctx context.Context, target syntax.DID, limit int) ([]Pendi ) if limit <= 0 { rows, err = q.db.QueryContext(ctx, ` - SELECT id, target_did, initiator_did + SELECT id, target_did, initiator_did, event_uri FROM pending_connections WHERE target_did = ? ORDER BY id `, target.String()) } else { rows, err = q.db.QueryContext(ctx, ` - SELECT id, target_did, initiator_did + SELECT id, target_did, initiator_did, event_uri FROM pending_connections WHERE target_did = ? ORDER BY id @@ -84,8 +85,9 @@ func (q *Queue) List(ctx context.Context, target syntax.DID, limit int) ([]Pendi id int64 targetStr string initiatorStr string + eventURI string ) - if err := rows.Scan(&id, &targetStr, &initiatorStr); err != nil { + if err := rows.Scan(&id, &targetStr, &initiatorStr, &eventURI); err != nil { return nil, fmt.Errorf("scan pending: %w", err) } tDID, err := syntax.ParseDID(targetStr) @@ -96,7 +98,7 @@ func (q *Queue) List(ctx context.Context, target syntax.DID, limit int) ([]Pendi if err != nil { return nil, fmt.Errorf("pending initiator DID parse: %w", err) } - out = append(out, PendingItem{ID: id, TargetDID: tDID, InitiatorDID: iDID}) + out = append(out, PendingItem{ID: id, TargetDID: tDID, InitiatorDID: iDID, EventURI: eventURI}) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate pending: %w", err) diff --git a/internal/connection/queue_test.go b/internal/connection/queue_test.go index 7b4809b..17dfe8e 100644 --- a/internal/connection/queue_test.go +++ b/internal/connection/queue_test.go @@ -46,7 +46,7 @@ func TestQueue_EnqueueAndList(t *testing.T) { target := did(t, didA) initiator := did(t, didB) - if err := q.Enqueue(ctx, target, initiator); err != nil { + if err := q.Enqueue(ctx, target, initiator, ""); err != nil { t.Fatalf("enqueue: %v", err) } items, err := q.List(ctx, target, 0) @@ -71,7 +71,7 @@ func TestQueue_EnqueueIdempotent(t *testing.T) { initiator := did(t, didB) for i := 0; i < 3; i++ { - if err := q.Enqueue(ctx, target, initiator); err != nil { + if err := q.Enqueue(ctx, target, initiator, ""); err != nil { t.Fatalf("enqueue #%d: %v", i, err) } } @@ -87,7 +87,7 @@ func TestQueue_EnqueueIdempotent(t *testing.T) { func TestQueue_RejectsSelfConnect(t *testing.T) { q := newTestQueue(t) target := did(t, didA) - err := q.Enqueue(context.Background(), target, target) + err := q.Enqueue(context.Background(), target, target, "") if err == nil { t.Fatal("expected error for self-connect") } @@ -95,10 +95,10 @@ func TestQueue_RejectsSelfConnect(t *testing.T) { func TestQueue_RejectsEmptyDID(t *testing.T) { q := newTestQueue(t) - if err := q.Enqueue(context.Background(), "", did(t, didA)); err == nil { + if err := q.Enqueue(context.Background(), "", did(t, didA), ""); err == nil { t.Error("expected error for empty target") } - if err := q.Enqueue(context.Background(), did(t, didA), ""); err == nil { + if err := q.Enqueue(context.Background(), did(t, didA), "", ""); err == nil { t.Error("expected error for empty initiator") } } @@ -109,7 +109,7 @@ func TestQueue_MultipleInitiatorsForSameTarget(t *testing.T) { target := did(t, didA) for _, init := range []syntax.DID{did(t, didB), did(t, didC)} { - if err := q.Enqueue(ctx, target, init); err != nil { + if err := q.Enqueue(ctx, target, init, ""); err != nil { t.Fatalf("enqueue: %v", err) } } @@ -126,8 +126,8 @@ func TestQueue_ListLimit(t *testing.T) { q := newTestQueue(t) ctx := context.Background() target := did(t, didA) - _ = q.Enqueue(ctx, target, did(t, didB)) - _ = q.Enqueue(ctx, target, did(t, didC)) + _ = q.Enqueue(ctx, target, did(t, didB), "") + _ = q.Enqueue(ctx, target, did(t, didC), "") items, err := q.List(ctx, target, 1) if err != nil { @@ -142,7 +142,7 @@ func TestQueue_Delete(t *testing.T) { q := newTestQueue(t) ctx := context.Background() target := did(t, didA) - if err := q.Enqueue(ctx, target, did(t, didB)); err != nil { + if err := q.Enqueue(ctx, target, did(t, didB), ""); err != nil { t.Fatalf("enqueue: %v", err) } items, _ := q.List(ctx, target, 0) @@ -161,7 +161,7 @@ func TestQueue_ListPreservesInsertionOrder(t *testing.T) { target := did(t, didA) order := []syntax.DID{did(t, didB), did(t, didC)} for _, init := range order { - if err := q.Enqueue(ctx, target, init); err != nil { + if err := q.Enqueue(ctx, target, init, ""); err != nil { t.Fatalf("enqueue: %v", err) } } @@ -176,8 +176,8 @@ func TestQueue_ListPreservesInsertionOrder(t *testing.T) { func TestQueue_CountIsolatedByTarget(t *testing.T) { q := newTestQueue(t) ctx := context.Background() - _ = q.Enqueue(ctx, did(t, didA), did(t, didB)) - _ = q.Enqueue(ctx, did(t, didC), did(t, didB)) + _ = q.Enqueue(ctx, did(t, didA), did(t, didB), "") + _ = q.Enqueue(ctx, did(t, didC), did(t, didB), "") if c, _ := q.Count(ctx, did(t, didA)); c != 1 { t.Errorf("count(A) = %d, want 1", c) } diff --git a/internal/db/migrations/008_pending_event_uri.sql b/internal/db/migrations/008_pending_event_uri.sql new file mode 100644 index 0000000..709e93c --- /dev/null +++ b/internal/db/migrations/008_pending_event_uri.sql @@ -0,0 +1,12 @@ +-- Add event_uri to pending_connections so reciprocal writes carry the event +-- linkage from the original connection. +-- +-- The unique index is expanded to include event_uri so the same pair can +-- queue at different events (but not twice for the same event). + +ALTER TABLE pending_connections ADD COLUMN event_uri TEXT NOT NULL DEFAULT ''; + +-- Recreate the unique index to include event_uri. +DROP INDEX IF EXISTS pending_connections_unique_idx; +CREATE UNIQUE INDEX pending_connections_unique_idx + ON pending_connections (target_did, initiator_did, event_uri); diff --git a/internal/event/event.go b/internal/event/event.go index 28266b2..247c519 100644 --- a/internal/event/event.go +++ b/internal/event/event.go @@ -41,6 +41,7 @@ type Record struct { Location string Geofence *Geofence OrganizerDID syntax.DID + ExpectedAttendees int // from the event record; 0 means unset } // ErrNotFound is returned by Get when no cached event matches. @@ -97,7 +98,8 @@ func Cache(ctx context.Context, db *sql.DB, r Record) error { func Get(ctx context.Context, db *sql.DB, uri string) (Record, error) { row := db.QueryRowContext(ctx, ` SELECT uri, name, start_time, end_time, location, - geofence_lat, geofence_lng, geofence_radius, organizer_did + geofence_lat, geofence_lng, geofence_radius, organizer_did, + expected_attendees FROM events WHERE uri = ? `, uri) @@ -119,6 +121,7 @@ func scanRow(s scanner) (Record, error) { err := s.Scan( &r.URI, &r.Name, &r.StartTime, &r.EndTime, &r.Location, &lat, &lng, &radius, &organizer, + &r.ExpectedAttendees, ) if err == sql.ErrNoRows { return Record{}, ErrNotFound diff --git a/internal/event/list.go b/internal/event/list.go index c095bc0..625fe8b 100644 --- a/internal/event/list.go +++ b/internal/event/list.go @@ -10,7 +10,8 @@ import ( func ListAll(ctx context.Context, db *sql.DB) ([]Record, error) { rows, err := db.QueryContext(ctx, ` SELECT uri, name, start_time, end_time, location, - geofence_lat, geofence_lng, geofence_radius, organizer_did + geofence_lat, geofence_lng, geofence_radius, organizer_did, + expected_attendees FROM events ORDER BY start_time DESC `) diff --git a/internal/event/put.go b/internal/event/put.go index acdc2ab..39b42f7 100644 --- a/internal/event/put.go +++ b/internal/event/put.go @@ -218,7 +218,8 @@ func LookupByQRToken(ctx context.Context, db *sql.DB, token string) (Record, err } row := db.QueryRowContext(ctx, ` SELECT uri, name, start_time, end_time, location, - geofence_lat, geofence_lng, geofence_radius, organizer_did + geofence_lat, geofence_lng, geofence_radius, organizer_did, + expected_attendees FROM events WHERE qr_token = ? `, token) diff --git a/router/router.go b/router/router.go index be86085..464721f 100644 --- a/router/router.go +++ b/router/router.go @@ -3,10 +3,15 @@ package router import ( "context" "database/sql" + "encoding/json" + "fmt" + "log/slog" "net/http" "sync" + "time" "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" "github.com/delaneyj/toolbelt/embeddednats" "github.com/go-chi/chi/v5" "github.com/starfederation/datastar-go/datastar" @@ -44,17 +49,23 @@ func SetupRoutes( signer *admincrypto.Signer, ) (err error) { + // Build the auth handlers once; other features depend on them for + // RequireSession / ResumeSession. + connQueue := connection.NewQueue(conn) + authH := auth.NewHandlers(conn, oauthApp, sess) + authH.ConnQueue = connQueue + + // Drain pending reciprocal connections on every authenticated page load, + // debounced to once per 30 seconds per user. + router.Use(auth.DrainMiddleware(authH, connQueue, 30*time.Second)) + if config.Global.Environment == config.Dev { setupReload(router) + setupDevDebug(router, authH, conn) } router.Handle("/static/*", resources.Handler()) - // Build the auth handlers once; other features depend on them for - // RequireSession / ResumeSession. - connQueue := connection.NewQueue(conn) - authH := auth.NewHandlers(conn, oauthApp, sess) - authH.ConnQueue = connQueue auth.SetupRoutes(router, authH) profile.SetupRoutes(router, conn, authH) connect.SetupRoutes(router, conn, authH, connQueue) @@ -99,3 +110,145 @@ func setupReload(router chi.Router) { w.Write([]byte("OK")) }) } + +// setupDevDebug registers development-only debug routes. These are NEVER +// available in production — the `config.Dev` gate in SetupRoutes ensures that. +func setupDevDebug(router chi.Router, authH *auth.Handlers, db *sql.DB) { + // POST /debug/purge-pds — delete all quest.atmo.* records from the + // currently logged-in user's PDS and clear local cache tables. + router.Post("/debug/purge-pds", func(w http.ResponseWriter, r *http.Request) { + did, sess, ok := authH.RequireSession(w, r) + if !ok { + return + } + + collections := []string{ + "quest.atmo.connection", + "quest.atmo.checkin", + "quest.atmo.badge", + "quest.atmo.event", + "quest.atmo.profile", + } + + type deleteResult struct { + Collection string `json:"collection"` + Deleted int `json:"deleted"` + Errors int `json:"errors"` + } + var results []deleteResult + + apiClient := sess.APIClient() + for _, coll := range collections { + dr := deleteResult{Collection: coll} + + // List all records in this collection. + cursor := "" + for { + params := map[string]any{ + "repo": did.String(), + "collection": coll, + "limit": 100, + } + if cursor != "" { + params["cursor"] = cursor + } + var resp struct { + Records []struct { + URI string `json:"uri"` + } `json:"records"` + Cursor string `json:"cursor,omitempty"` + } + if err := apiClient.Get(r.Context(), syntax.NSID("com.atproto.repo.listRecords"), params, &resp); err != nil { + slog.Warn("debug purge: list", "collection", coll, "err", err) + break + } + for _, rec := range resp.Records { + rkey := rkeyFromURI(rec.URI) + if rkey == "" { + dr.Errors++ + continue + } + input := map[string]any{ + "repo": did.String(), + "collection": coll, + "rkey": rkey, + } + if err := apiClient.Post(r.Context(), syntax.NSID("com.atproto.repo.deleteRecord"), input, nil); err != nil { + slog.Warn("debug purge: delete", "uri", rec.URI, "err", err) + dr.Errors++ + } else { + dr.Deleted++ + } + } + if resp.Cursor == "" || len(resp.Records) == 0 { + break + } + cursor = resp.Cursor + } + results = append(results, dr) + } + + // Also clear local cache tables. + for _, stmt := range []string{ + `DELETE FROM checkins WHERE did = ?`, + `DELETE FROM pending_connections WHERE target_did = ? OR initiator_did = ?`, + `DELETE FROM connection_notes WHERE viewer_did = ?`, + } { + if stmt == `DELETE FROM pending_connections WHERE target_did = ? OR initiator_did = ?` { + _, _ = db.ExecContext(r.Context(), stmt, did.String(), did.String()) + } else { + _, _ = db.ExecContext(r.Context(), stmt, did.String()) + } + } + + slog.Info("debug purge complete", "did", did.String(), "results", fmt.Sprintf("%+v", results)) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "did": did.String(), + "results": results, + }) + }) + + // GET /debug/purge-pds — simple confirmation page. + router.Get("/debug/purge-pds", func(w http.ResponseWriter, r *http.Request) { + didStr, _ := authH.Sessions.Get(r) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprintf(w, ` +debug: purge PDS + + +

⚠ Purge all quest.atmo.* records

+

DID: %s

+

This will delete all quest.atmo.connection, checkin, badge, event, and profile records from your PDS, plus clear local cache tables.

+ +

+`, didStr)
+	})
+}
+
+// rkeyFromURI extracts the rkey (last path segment) from an at-uri like
+// "at://did:plc:xxx/quest.atmo.connection/3abc123".
+func rkeyFromURI(uri string) string {
+	// at-uri format: at:////
+	for i := len(uri) - 1; i >= 0; i-- {
+		if uri[i] == '/' {
+			return uri[i+1:]
+		}
+	}
+	return ""
+}
diff --git a/web/resources/static/css/terminal.css b/web/resources/static/css/terminal.css
index f95f8e7..b7dcd9f 100644
--- a/web/resources/static/css/terminal.css
+++ b/web/resources/static/css/terminal.css
@@ -529,21 +529,22 @@ h1 .cursor {
 .profile-flip-inner:focus-visible {
   outline: 2px solid var(--peach);
   outline-offset: 6px;
-  border-radius: 50%;
+  border-radius: 18px;
 }
 .profile-flip-face {
   position: absolute;
   inset: 0;
   width: 100%;
   height: 100%;
-  border-radius: 50%;
+  border-radius: 18px;
   backface-visibility: hidden;
   -webkit-backface-visibility: hidden;
   display: flex;
   align-items: center;
   justify-content: center;
-  padding: 3px;
-  background: linear-gradient(135deg, var(--peach), var(--lavender) 60%, var(--blue));
+  padding: 0;
+  border: 3px solid var(--peach);
+  background: var(--surface);
   overflow: hidden;
 }
 .profile-flip-front .profile-avatar,
@@ -562,7 +563,7 @@ h1 .cursor {
   height: 100%;
   object-fit: contain;
   display: block;
-  border-radius: 50%;
+  border-radius: 12px;
   background: var(--text);
 }
 .profile-flip-hint {
@@ -580,16 +581,17 @@ h1 .cursor {
 .profile-avatar-wrap {
   width: 128px;
   height: 128px;
-  border-radius: 50%;
-  padding: 3px;
-  background: linear-gradient(135deg, var(--peach), var(--lavender) 60%, var(--blue));
+  border-radius: 18px;
+  padding: 0;
+  border: 3px solid var(--peach);
+  background: var(--surface);
   margin-bottom: 14px;
   flex-shrink: 0;
 }
 .profile-avatar {
   width: 100%;
   height: 100%;
-  border-radius: 50%;
+  border-radius: 14px;
   object-fit: cover;
   display: block;
   background: var(--surface);
@@ -1028,40 +1030,66 @@ footer a:hover { color: var(--lavender); }
 
 /* event card on the home dashboard */
 .home-event-card {
-  border: 1px solid var(--overlay);
-  border-left: 3px solid var(--peach);
-  border-radius: 6px;
-  padding: 18px 20px;
-  background: rgba(250, 179, 135, 0.04);
+  background: linear-gradient(135deg, rgba(250, 179, 135, 0.1) 0%, rgba(245, 194, 231, 0.05) 100%);
+  border: 1px solid rgba(250, 179, 135, 0.25);
+  border-radius: 12px;
+  padding: 14px 16px;
   display: flex;
   flex-direction: column;
-  gap: 8px;
+  gap: 6px;
+  cursor: pointer;
+  transition: transform 0.15s, border-color 0.15s;
+}
+.home-event-card:hover {
+  transform: translateY(-1px);
+  border-color: var(--peach);
 }
 .home-event-card.is-empty {
-  border-left-color: var(--overlay);
   background: transparent;
+  border-color: var(--overlay);
   text-align: center;
+  cursor: default;
+}
+.home-event-card.is-empty:hover {
+  transform: none;
+  border-color: var(--overlay);
 }
 .home-event-label {
-  font-size: 10px;
-  letter-spacing: 0.12em;
+  font-size: 9px;
+  letter-spacing: 0.15em;
   text-transform: uppercase;
-  color: var(--lavender);
+  color: var(--peach);
+  margin-bottom: 3px;
 }
 .home-event-name {
   margin: 0;
-  font-size: 18px;
-  font-weight: 600;
+  font-size: 13px;
+  font-weight: 500;
   color: var(--text);
 }
 .home-event-meta {
-  font-size: 12px;
-  color: var(--subtext);
-  display: flex;
-  flex-wrap: wrap;
-  gap: 4px 12px;
+  font-size: 10px;
+  color: var(--overlay);
+  margin: 0;
 }
 .home-event-meta-sep { color: var(--overlay); }
+.home-event-banner-row {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+.home-event-banner-body {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 3px;
+}
+.home-event-arrow {
+  color: var(--peach);
+  font-size: 16px;
+  flex-shrink: 0;
+}
 .home-event-ctas {
   display: flex;
   gap: 8px;
@@ -2224,56 +2252,271 @@ footer a:hover { color: var(--lavender); }
 .summary-conn-followup { color: var(--peach); font-size: 0.7rem; }
 
 /* ── Event Detail ───────────────────────────────── */
-.event-detail { margin: 12px 0; }
-.attendees-section { margin-top: 16px; }
-.attendees-filter { display: flex; gap: 8px; margin-bottom: 10px; }
+.event-detail { padding: 24px 20px; }
+.event-detail-header { margin-bottom: 24px; }
+.event-detail-meta {
+  font-size: 9px;
+  color: var(--overlay);
+  letter-spacing: 0.15em;
+  text-transform: uppercase;
+  margin-bottom: 8px;
+}
+.event-detail-meta .live {
+  color: var(--green);
+}
+.event-detail-meta .live::before {
+  content: '●';
+  margin-right: 4px;
+}
+.event-detail-meta .status-upcoming {
+  color: var(--peach);
+}
+.event-detail-meta .status-complete {
+  color: var(--overlay);
+}
+@keyframes event-blink { 50% { opacity: 0.3; } }
+.event-detail-title {
+  font-family: 'Instrument Serif', serif;
+  font-size: 30px;
+  color: var(--text);
+  line-height: 1.1;
+  margin-bottom: 6px;
+  font-weight: 400;
+}
+.event-detail-dates {
+  font-size: 11px;
+  color: var(--subtext1);
+}
+
+/* Progress card */
+.progress-card {
+  background: var(--mantle);
+  border: 1px solid var(--surface0);
+  border-radius: 12px;
+  padding: 18px;
+  margin-bottom: 20px;
+}
+.progress-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 10px;
+}
+.progress-label {
+  font-size: 10px;
+  color: var(--lavender);
+  letter-spacing: 0.1em;
+  text-transform: uppercase;
+}
+.progress-count {
+  font-family: 'Instrument Serif', serif;
+  font-size: 22px;
+  color: var(--peach);
+}
+.progress-count .of {
+  color: var(--overlay);
+  font-size: 14px;
+  font-family: 'JetBrains Mono', monospace;
+}
+.progress-bar-track {
+  height: 6px;
+  background: var(--surface0);
+  border-radius: 100px;
+  overflow: hidden;
+  position: relative;
+  margin-bottom: 16px;
+}
+.progress-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, var(--peach), var(--pink));
+  border-radius: 100px;
+  position: relative;
+  transition: width 0.4s ease;
+  animation: shimmer 3s ease-in-out infinite;
+}
+@keyframes shimmer {
+  0%, 100% { opacity: 1; }
+  50% { opacity: 0.85; }
+}
+.progress-bar-fill::after {
+  content: '';
+  position: absolute;
+  right: -2px;
+  top: -3px;
+  width: 8px;
+  height: 12px;
+  background: var(--peach);
+  border-radius: 100px;
+  box-shadow: 0 0 12px var(--peach);
+}
+.progress-ticks {
+  display: flex;
+  justify-content: space-between;
+  position: relative;
+  height: 24px;
+}
+.progress-tick {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  font-size: 9px;
+  color: var(--overlay);
+  letter-spacing: 0.05em;
+  position: relative;
+}
+.progress-tick::before {
+  content: '';
+  width: 1px;
+  height: 6px;
+  background: var(--surface1);
+  margin-bottom: 3px;
+}
+.progress-tick.unlocked { color: var(--green); }
+.progress-tick.unlocked::before { background: var(--green); }
+.progress-tick.next { color: var(--peach); }
+.progress-tick.next::before { background: var(--peach); }
+
+/* Rewards section */
+.rewards-section { margin-bottom: 20px; }
+.reward {
+  display: grid;
+  grid-template-columns: 36px 1fr auto;
+  gap: 14px;
+  align-items: center;
+  padding: 14px 16px;
+  margin-bottom: 8px;
+  background: var(--mantle);
+  border: 1px solid var(--surface0);
+  border-radius: 10px;
+  transition: border-color 0.15s;
+}
+.reward.unlocked { border-color: rgba(166, 227, 161, 0.3); }
+.reward.locked { opacity: 0.55; }
+.reward-icon {
+  width: 36px;
+  height: 36px;
+  border-radius: 8px;
+  background: var(--surface0);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 16px;
+}
+.reward.unlocked .reward-icon {
+  background: rgba(166, 227, 161, 0.15);
+  color: var(--green);
+}
+.reward-body { min-width: 0; }
+.reward-title {
+  font-size: 12px;
+  color: var(--text);
+  font-weight: 500;
+  margin-bottom: 2px;
+}
+.reward-desc {
+  font-size: 10px;
+  color: var(--overlay);
+  line-height: 1.4;
+}
+.reward-status {
+  font-size: 9px;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  color: var(--overlay);
+  padding: 3px 7px;
+  border-radius: 3px;
+  background: var(--surface0);
+}
+.reward.unlocked .reward-status {
+  color: var(--green);
+  background: rgba(166, 227, 161, 0.1);
+}
+
+/* Attendees */
+.attendees-section { margin-top: 8px; }
+.attendees-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+  padding-bottom: 8px;
+  border-bottom: 1px dashed var(--surface1);
+}
+.attendees-header h2,
+.attendees-header .summary-section-label {
+  font-size: 11px;
+  color: var(--lavender);
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+  font-weight: 500;
+  margin: 0;
+}
+.attendees-filter {
+  display: flex;
+  gap: 4px;
+  font-size: 9px;
+}
+.attendees-filter .btn {
+  font-size: 9px;
+  padding: 4px 8px;
+  border-radius: 4px;
+  letter-spacing: 0.05em;
+  text-transform: uppercase;
+}
 .attendees-grid {
   display: grid;
-  grid-template-columns: repeat(auto-fill, minmax(52px, 1fr));
-  gap: 8px;
+  grid-template-columns: repeat(5, 1fr);
+  gap: 10px;
 }
 .attendee-card {
   display: flex;
   flex-direction: column;
   align-items: center;
-  gap: 2px;
+  gap: 4px;
   text-decoration: none;
+  color: inherit;
+  transition: transform 0.15s;
 }
+.attendee-card:hover { transform: translateY(-2px); }
 .attendee-avatar {
-  width: 42px;
-  height: 42px;
+  width: 48px;
+  height: 48px;
   border-radius: 50%;
   object-fit: cover;
-  border: 2px solid transparent;
+  border: 1.5px solid transparent;
 }
 .attendee-avatar.connected { border-color: var(--peach); }
-.attendee-avatar.not-connected { filter: grayscale(100%); opacity: 0.6; }
+.attendee-avatar.not-connected { filter: grayscale(100%); opacity: 0.6; border-color: var(--surface1); }
 .attendee-name {
-  font-size: 0.55rem;
+  font-size: 9px;
   color: var(--subtext1);
   text-align: center;
-  max-width: 52px;
+  max-width: 60px;
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
+  line-height: 1.2;
 }
 
-/* ── Unlock Teasers ─────────────────────────────── */
-.unlock-teaser {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 10px 14px;
-  margin: 10px 0;
-  background: var(--surface0);
-  border-radius: 6px;
-  border: 1px dashed var(--surface1);
+/* Filter tabs */
+.filter-tab {
+  background: none;
+  border: 1px solid var(--surface0);
+  color: var(--overlay);
+  font-family: inherit;
+  font-size: 9px;
+  padding: 4px 8px;
+  border-radius: 4px;
+  cursor: pointer;
+  letter-spacing: 0.05em;
+  text-transform: uppercase;
+  text-decoration: none;
+  transition: color 0.15s, border-color 0.15s, background 0.15s;
 }
-.unlock-icon { font-size: 1rem; }
-.unlock-text {
-  font-size: 0.75rem;
-  color: var(--subtext0);
-  font-style: italic;
+.filter-tab.active {
+  color: var(--peach);
+  border-color: var(--peach);
+  background: rgba(250, 179, 135, 0.08);
 }
 
 /* ── Interest Matching ──────────────────────────── */
@@ -2353,3 +2596,541 @@ footer a:hover { color: var(--lavender); }
 .admin-badge-info { flex: 1; }
 .admin-badge-name { font-weight: 600; font-size: 0.85rem; }
 .admin-badge-meta { font-size: 0.7rem; color: var(--subtext0); }
+
+/* ── Connect URL Debug Helper ────────────────────── */
+.connect-url-debug {
+  margin-top: 10px;
+  padding: 8px 10px;
+  background: var(--crust);
+  border: 1px dashed var(--overlay);
+  border-radius: 6px;
+  font-size: 0.7rem;
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 6px;
+}
+.connect-url-label {
+  color: var(--muted);
+  font-size: 0.65rem;
+  letter-spacing: 0.05em;
+}
+.connect-url-value {
+  color: var(--peach);
+  word-break: break-all;
+  font-size: 0.65rem;
+  flex: 1;
+  min-width: 0;
+}
+
+/* ── Connections List (redesigned) ───────────────── */
+.connections-content { padding: 24px 20px; }
+.connections-header { margin-bottom: 18px; }
+.connections-title {
+  font-family: 'Instrument Serif', serif;
+  font-size: 28px;
+  color: var(--text);
+  font-weight: 400;
+  margin-bottom: 4px;
+}
+.connections-title em { color: var(--peach); }
+.connections-sub {
+  font-size: 11px;
+  color: var(--muted);
+  letter-spacing: 0.05em;
+}
+
+.search-bar {
+  position: relative;
+  margin-bottom: 14px;
+}
+.search-input {
+  width: 100%;
+  background: var(--mantle);
+  border: 1px solid var(--overlay);
+  color: var(--text);
+  font-family: 'JetBrains Mono', monospace;
+  font-size: 13px;
+  padding: 11px 14px 11px 36px;
+  border-radius: 8px;
+}
+.search-input:focus { outline: none; border-color: var(--peach); }
+.search-input::placeholder { color: var(--muted); }
+.search-icon {
+  position: absolute;
+  left: 12px;
+  top: 50%;
+  transform: translateY(-50%);
+  color: var(--muted);
+  font-size: 13px;
+  pointer-events: none;
+}
+
+.filter-row {
+  display: flex;
+  gap: 6px;
+  margin-bottom: 18px;
+  overflow-x: auto;
+  padding-bottom: 4px;
+  -webkit-overflow-scrolling: touch;
+}
+.filter-row::-webkit-scrollbar { display: none; }
+.filter-chip {
+  background: none;
+  border: 1px solid var(--surface);
+  color: var(--muted);
+  font-family: inherit;
+  font-size: 10px;
+  padding: 6px 12px;
+  border-radius: 100px;
+  cursor: pointer;
+  white-space: nowrap;
+  letter-spacing: 0.04em;
+  transition: all 0.15s;
+}
+.filter-chip:hover { border-color: var(--overlay); color: var(--subtext); }
+.filter-chip.active {
+  color: var(--peach);
+  border-color: var(--peach);
+  background: rgba(250, 179, 135, 0.08);
+}
+.filter-chip .count {
+  opacity: 0.65;
+  margin-left: 4px;
+}
+
+.connection-row {
+  display: grid;
+  grid-template-columns: 44px 1fr auto;
+  gap: 12px;
+  align-items: center;
+  padding: 12px 4px;
+  border-bottom: 1px solid rgba(69, 71, 90, 0.4);
+  cursor: pointer;
+  transition: background 0.12s;
+  text-decoration: none;
+  color: inherit;
+}
+.connection-row:hover { background: rgba(180, 190, 254, 0.04); }
+.connection-row:last-child { border-bottom: none; }
+
+.conn-avatar {
+  width: 44px;
+  height: 44px;
+  border-radius: 50%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-family: 'Instrument Serif', serif;
+  font-size: 18px;
+  color: var(--crust);
+  background: linear-gradient(135deg, var(--blue), var(--lavender));
+  overflow: hidden;
+}
+.conn-avatar-img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  border-radius: 50%;
+}
+.conn-info { min-width: 0; }
+.conn-name {
+  font-size: 13px;
+  color: var(--text);
+  font-weight: 500;
+  margin-bottom: 2px;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.conn-handle {
+  font-size: 10px;
+  color: var(--muted);
+  margin-bottom: 4px;
+}
+.conn-meta {
+  font-size: 9px;
+  color: var(--muted);
+  letter-spacing: 0.04em;
+  display: flex;
+  gap: 8px;
+  flex-wrap: wrap;
+}
+.conn-meta .event-ref { color: var(--lavender); }
+.conn-meta .followup-flag { color: var(--peach); }
+.conn-meta .followup-flag::before {
+  content: '◆';
+  margin-right: 3px;
+}
+.conn-arrow {
+  color: var(--overlay);
+  font-size: 14px;
+}
+.conn-empty-state {
+  text-align: center;
+  padding: 40px 20px;
+  color: var(--muted);
+  font-size: 12px;
+}
+
+/* Meta row (shared) */
+.meta-row {
+  font-size: 10px;
+  color: var(--muted);
+  letter-spacing: 0.05em;
+  text-align: center;
+  margin-top: 8px;
+}
+.meta-row .accent { color: var(--subtext); }
+
+/* ── Events List (redesigned) ────────────────────── */
+.events-content { padding: 24px 20px; }
+.events-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 4px;
+}
+.events-title {
+  font-family: 'Instrument Serif', serif;
+  font-size: 28px;
+  color: var(--text);
+  font-weight: 400;
+}
+.events-title em { color: var(--peach); }
+.events-sub {
+  font-size: 11px;
+  color: var(--muted);
+  letter-spacing: 0.05em;
+  margin-bottom: 24px;
+}
+.events-section { margin-bottom: 28px; }
+.events-section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 12px;
+  padding-bottom: 8px;
+  border-bottom: 1px dashed var(--overlay);
+}
+.events-section-title {
+  font-size: 11px;
+  color: var(--lavender);
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+  font-weight: 500;
+}
+.events-section-count {
+  font-size: 10px;
+  color: var(--muted);
+}
+
+.event-card {
+  display: block;
+  text-decoration: none;
+  color: inherit;
+  background: var(--mantle);
+  border: 1px solid var(--surface);
+  border-radius: 10px;
+  padding: 14px 16px;
+  margin-bottom: 10px;
+  cursor: pointer;
+  transition: border-color 0.15s, transform 0.15s;
+}
+.event-card:hover {
+  border-color: var(--lavender);
+  transform: translateY(-1px);
+}
+.event-card.live {
+  border-color: rgba(250, 179, 135, 0.35);
+  background: linear-gradient(135deg, rgba(250, 179, 135, 0.05) 0%, rgba(245, 194, 231, 0.02) 100%);
+}
+.event-card.live:hover { border-color: var(--peach); }
+
+.event-card-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: baseline;
+  margin-bottom: 6px;
+  gap: 8px;
+}
+.event-card-name {
+  font-size: 14px;
+  color: var(--text);
+  font-weight: 500;
+}
+.event-card-status {
+  font-size: 9px;
+  padding: 3px 7px;
+  border-radius: 3px;
+  letter-spacing: 0.08em;
+  text-transform: uppercase;
+  flex-shrink: 0;
+}
+.event-card-status.live {
+  color: var(--peach);
+  background: rgba(250, 179, 135, 0.12);
+}
+.event-card-status.live::before {
+  content: '●';
+  margin-right: 4px;
+}
+.event-card-status.past {
+  color: var(--muted);
+  background: var(--surface);
+}
+.event-card-status.upcoming {
+  color: var(--blue);
+  background: rgba(137, 180, 250, 0.12);
+}
+
+.event-card-meta {
+  font-size: 10px;
+  color: var(--muted);
+  margin-bottom: 10px;
+  display: flex;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+.event-card-meta .sep { color: var(--overlay); }
+
+.event-card-stats {
+  display: flex;
+  gap: 18px;
+  padding-top: 8px;
+  border-top: 1px solid rgba(69, 71, 90, 0.4);
+}
+.event-stat {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+}
+.event-stat-num {
+  font-family: 'Instrument Serif', serif;
+  font-size: 16px;
+  color: var(--text);
+  line-height: 1;
+}
+.event-stat-num.accent { color: var(--peach); }
+.event-stat-label {
+  font-size: 9px;
+  color: var(--muted);
+  letter-spacing: 0.05em;
+  text-transform: uppercase;
+}
+
+.create-event-btn {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  width: 100%;
+  background: none;
+  border: 1px dashed var(--overlay);
+  color: var(--muted);
+  font-family: inherit;
+  font-size: 12px;
+  padding: 16px;
+  border-radius: 10px;
+  cursor: pointer;
+  transition: all 0.15s;
+}
+.create-event-btn:hover {
+  border-color: var(--peach);
+  color: var(--peach);
+  border-style: solid;
+}
+.create-event-btn.disabled {
+  cursor: not-allowed;
+  opacity: 0.7;
+}
+.create-event-btn.disabled:hover {
+  border-color: var(--overlay);
+  color: var(--muted);
+  border-style: dashed;
+}
+.create-event-hint {
+  font-size: 9px;
+  color: var(--muted);
+  text-align: center;
+  margin-top: 8px;
+  letter-spacing: 0.04em;
+}
+
+/* ── Settings (redesigned) ───────────────────────── */
+.settings-content { padding: 24px 20px 32px; }
+.settings-title {
+  font-family: 'Instrument Serif', serif;
+  font-size: 28px;
+  color: var(--text);
+  font-weight: 400;
+  margin-bottom: 4px;
+}
+.settings-title em { color: var(--peach); }
+.settings-sub {
+  font-size: 11px;
+  color: var(--muted);
+  letter-spacing: 0.05em;
+  margin-bottom: 24px;
+}
+
+.settings-account-card {
+  background: var(--mantle);
+  border: 1px solid var(--surface);
+  border-radius: 12px;
+  padding: 16px;
+  margin-bottom: 24px;
+  display: grid;
+  grid-template-columns: 52px 1fr;
+  gap: 14px;
+  align-items: center;
+}
+.settings-account-avatar {
+  width: 52px;
+  height: 52px;
+  border-radius: 50%;
+  background: linear-gradient(135deg, var(--peach), var(--pink));
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-family: 'Instrument Serif', serif;
+  font-size: 24px;
+  color: var(--crust);
+  text-transform: uppercase;
+}
+.settings-account-name {
+  font-size: 15px;
+  color: var(--text);
+  font-weight: 500;
+  margin-bottom: 2px;
+}
+.settings-account-handle {
+  font-size: 11px;
+  color: var(--muted);
+  margin-bottom: 6px;
+}
+.settings-account-handle .at { color: var(--overlay); }
+.settings-account-pds {
+  font-size: 9px;
+  color: var(--muted);
+  letter-spacing: 0.04em;
+}
+.settings-account-pds .pds-name { color: var(--lavender); }
+
+.settings-group { margin-bottom: 24px; }
+.settings-group-label {
+  font-size: 10px;
+  color: var(--lavender);
+  letter-spacing: 0.12em;
+  text-transform: uppercase;
+  margin-bottom: 10px;
+  padding-left: 4px;
+}
+.settings-list {
+  background: var(--mantle);
+  border: 1px solid var(--surface);
+  border-radius: 10px;
+  overflow: hidden;
+}
+.settings-row {
+  display: grid;
+  grid-template-columns: 32px 1fr auto;
+  gap: 12px;
+  align-items: center;
+  padding: 13px 14px;
+  border-bottom: 1px solid rgba(69, 71, 90, 0.4);
+  cursor: pointer;
+  transition: background 0.12s;
+  text-decoration: none;
+  color: inherit;
+}
+.settings-row:last-child { border-bottom: none; }
+.settings-row:hover { background: rgba(180, 190, 254, 0.04); }
+.settings-row.danger:hover { background: rgba(243, 139, 168, 0.06); }
+.settings-row-icon {
+  color: var(--lavender);
+  font-size: 14px;
+  text-align: center;
+}
+.settings-row.danger .settings-row-icon { color: var(--red); }
+.settings-row-body { min-width: 0; }
+.settings-row-title {
+  font-size: 13px;
+  color: var(--text);
+  margin-bottom: 1px;
+}
+.settings-row.danger .settings-row-title { color: var(--red); }
+.settings-row-desc {
+  font-size: 10px;
+  color: var(--muted);
+  line-height: 1.4;
+}
+.settings-row-value {
+  font-size: 11px;
+  color: var(--muted);
+  display: flex;
+  align-items: center;
+  gap: 4px;
+}
+.settings-row-value .arrow {
+  color: var(--overlay);
+  font-size: 12px;
+}
+
+/* Toggle switch */
+.toggle {
+  width: 38px;
+  height: 22px;
+  border-radius: 100px;
+  background: var(--surface);
+  border: 1px solid var(--overlay);
+  position: relative;
+  cursor: pointer;
+  transition: background 0.2s;
+  flex-shrink: 0;
+}
+.toggle::after {
+  content: '';
+  position: absolute;
+  width: 16px;
+  height: 16px;
+  border-radius: 50%;
+  background: var(--muted);
+  top: 2px;
+  left: 2px;
+  transition: all 0.2s;
+}
+.toggle.on {
+  background: rgba(250, 179, 135, 0.25);
+  border-color: var(--peach);
+}
+.toggle.on::after {
+  background: var(--peach);
+  left: 17px;
+}
+
+.settings-footer {
+  text-align: center;
+  margin-top: 32px;
+  padding-top: 20px;
+  border-top: 1px dashed var(--surface);
+  font-size: 10px;
+  color: var(--muted);
+  line-height: 1.6;
+}
+.settings-footer a {
+  color: var(--lavender);
+  text-decoration: none;
+}
+.settings-footer .version {
+  font-family: 'JetBrains Mono', monospace;
+  color: var(--overlay);
+  margin-top: 6px;
+}
+
+.events-empty {
+  text-align: center;
+  padding: 40px 20px;
+  color: var(--muted);
+  font-size: 12px;
+}