diff --git a/features/common/layouts/bottomnav.templ b/features/common/layouts/bottomnav.templ new file mode 100644 index 0000000..420a1a0 --- /dev/null +++ b/features/common/layouts/bottomnav.templ @@ -0,0 +1,20 @@ +package layouts + +// BottomNav renders a fixed bottom navigation bar for authenticated pages. +// activePage should be "home", "connections", or "event". +templ BottomNav(activePage string) { + +} diff --git a/features/common/layouts/bottomnav_templ.go b/features/common/layouts/bottomnav_templ.go new file mode 100644 index 0000000..b19bf20 --- /dev/null +++ b/features/common/layouts/bottomnav_templ.go @@ -0,0 +1,108 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +package layouts + +//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" + +// BottomNav renders a fixed bottom navigation bar for authenticated pages. +// activePage should be "home", "connections", or "event". +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 + 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_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/connect/handlers.go b/features/connect/handlers.go index c07a347..6dbb94c 100644 --- a/features/connect/handlers.go +++ b/features/connect/handlers.go @@ -25,6 +25,7 @@ import ( "atmoquest/config" "atmoquest/features/auth" "atmoquest/features/connect/pages" + "atmoquest/internal/badge" "atmoquest/internal/checkin" "atmoquest/internal/connection" "atmoquest/internal/event" @@ -192,6 +193,22 @@ func (h *Handlers) ConnectConfirm(w http.ResponseWriter, r *http.Request) { 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) } + // 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) + } + } + + // 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) + } } http.Redirect(w, r, "/profile?connected="+target.String(), http.StatusSeeOther) @@ -249,6 +266,10 @@ func (h *Handlers) ConnectFlushLocal(w http.ResponseWriter, r *http.Request) { 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). + if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardFirstConnect, ""); err != nil { + slog.Info("flush-local: first-connect badge", "err", err) + } written++ } diff --git a/features/connections/handlers.go b/features/connections/handlers.go index b329eaa..6652835 100644 --- a/features/connections/handlers.go +++ b/features/connections/handlers.go @@ -86,6 +86,11 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { SortTime: entry.ConnectedAt, } + // Load follow-up flag from SQLite (soft-fail). + if note, err := notes.Get(r.Context(), h.DB, did.String(), entry.With.String()); err == nil { + item.FollowUp = note.FollowUp + } + targetPDS := h.lookupPDSForDID(r, entry.With) bsky, err := profile.FetchBluesky(r.Context(), targetPDS, entry.With) if err == nil && bsky != nil { @@ -120,6 +125,13 @@ func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { nj := strings.ToLower(displayOrDID(items[j])) return ni < nj }) + case "followup": + sort.SliceStable(items, func(i, j int) bool { + if items[i].FollowUp != items[j].FollowUp { + return items[i].FollowUp // follow-ups first + } + return items[i].SortTime.After(items[j].SortTime) + }) default: // "recent" sort.Slice(items, func(i, j int) bool { return items[i].SortTime.After(items[j].SortTime) diff --git a/features/connections/pages/connections.templ b/features/connections/pages/connections.templ index 235fd10..0488c82 100644 --- a/features/connections/pages/connections.templ +++ b/features/connections/pages/connections.templ @@ -29,6 +29,7 @@ type ConnectionItem struct { EventURI string EventName string SortTime time.Time // used for sorting, not rendered + FollowUp bool // follow-up flag from SQLite } // EventOption is an entry in the event filter dropdown. @@ -71,6 +72,7 @@ templ Connections(v ConnectionsView) { @@ -115,6 +117,7 @@ templ Connections(v ConnectionsView) { + @layouts.BottomNav("connections") } } @@ -142,6 +145,9 @@ templ connectionRow(item ConnectionItem) { if item.EventName != "" { { item.EventName } } + if item.FollowUp { + ⚑ follow up + } } diff --git a/features/connections/pages/connections_templ.go b/features/connections/pages/connections_templ.go index de31038..ff1fa78 100644 --- a/features/connections/pages/connections_templ.go +++ b/features/connections/pages/connections_templ.go @@ -37,6 +37,7 @@ type ConnectionItem struct { EventURI string EventName string SortTime time.Time // used for sorting, not rendered + FollowUp bool // follow-up flag from SQLite } // EventOption is an entry in the event filter dropdown. @@ -85,7 +86,7 @@ func Connections(v ConnectionsView) 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/connections/pages/connections.templ`, Line: 51, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 52, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -98,7 +99,7 @@ func Connections(v ConnectionsView) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(v.Query) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 61, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 62, Col: 22} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4) if templ_7745c5c3_Err != nil { @@ -116,7 +117,7 @@ func Connections(v ConnectionsView) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(ev.URI) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 68, Col: 30} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 69, Col: 30} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -139,7 +140,7 @@ func Connections(v ConnectionsView) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Name) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 68, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 69, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -170,12 +171,22 @@ func Connections(v ConnectionsView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">alphabetical

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">alphabetical

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if v.Total == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "no connections yet — go scan some QR codes!") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "no connections yet — go scan some QR codes!") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -183,59 +194,59 @@ func Connections(v ConnectionsView) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Total)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 81, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 83, Col: 22} } _, 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, 15, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(pluralize(v.Total, "connection", "connections")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 81, Col: 74} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 83, Col: 74} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "showing ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "showing ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(v.Showing)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 83, Col: 32} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 85, Col: 32} } _, 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, 17, " of ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " 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.Total)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 83, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 85, Col: 53} } _, 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, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(v.Items) > 0 { - 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 } @@ -245,22 +256,26 @@ func Connections(v ConnectionsView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else if v.Total > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

no connections match your search.

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

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, 22, "

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, 24, "

you haven't connected with anyone yet.

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

← back home
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
● reads from quest.atmo.connection
v0.1
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
● reads from quest.atmo.connection
v0.1
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = layouts.BottomNav("connections").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -295,157 +310,163 @@ func connectionRow(item ConnectionItem) templ.Component { templ_7745c5c3_Var11 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"connection-row\" role=\"listitem\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if item.AvatarURL != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\"")") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" loading=\"lazy\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
?
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "
?
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if item.DisplayName != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(item.DisplayName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 133, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 136, Col: 53} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") 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)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 135, Col: 77} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 138, Col: 77} } _, 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, 34, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, 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: 137, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 140, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if item.Bio != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "

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

") 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: 140, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 143, 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, 38, "

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

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if item.EventName != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var19 string templ_7745c5c3_Var19, 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: 143, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/connections/pages/connections.templ`, Line: 146, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if item.FollowUp { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "⚑ follow up") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/connections/pages/profile.templ b/features/connections/pages/profile.templ index 92fbbbb..5c38b93 100644 --- a/features/connections/pages/profile.templ +++ b/features/connections/pages/profile.templ @@ -174,6 +174,7 @@ templ ProfileViewPage(v ProfileView) { + @layouts.BottomNav("connections") } } diff --git a/features/connections/pages/profile_templ.go b/features/connections/pages/profile_templ.go index 07bc7c2..37672c1 100644 --- a/features/connections/pages/profile_templ.go +++ b/features/connections/pages/profile_templ.go @@ -450,7 +450,15 @@ func ProfileViewPage(v ProfileView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" class=\"btn btn-ghost btn-sm\">connect page →
● reads from quest.atmo.profile · notes saved to server
v0.1
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" class=\"btn btn-ghost btn-sm\">connect page →
● reads from quest.atmo.profile · notes saved to server
v0.1
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = layouts.BottomNav("connections").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/event/handlers.go b/features/event/handlers.go index ba2a8aa..5ff5654 100644 --- a/features/event/handlers.go +++ b/features/event/handlers.go @@ -21,6 +21,7 @@ import ( "atmoquest/config" "atmoquest/features/auth" "atmoquest/features/event/pages" + "atmoquest/internal/badge" "atmoquest/internal/checkin" "atmoquest/internal/event" "atmoquest/internal/qrcode" @@ -82,6 +83,11 @@ func (h *Handlers) EventScan(w http.ResponseWriter, r *http.Request) { IsOngoing: ev.IsOngoing(time.Now()), ViewerLoggedIn: didStr != "" && sid != "", } + if ev.Geofence != nil { + view.GeofenceLat = ev.Geofence.Lat + view.GeofenceLng = ev.Geofence.Lng + view.GeofenceRadius = ev.Geofence.RadiusMeters + } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := pages.EventScan(view).Render(r.Context(), w); err != nil { slog.Error("render event scan", "err", err) @@ -106,6 +112,14 @@ func (h *Handlers) EventScanCheckin(w http.ResponseWriter, r *http.Request) { http.Error(w, "failed to check in: "+err.Error(), http.StatusBadGateway) return } + // Award event-attendee badge (idempotent). + if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardEventAttendee, ev.URI); err != nil { + slog.Info("event scan: event-attendee badge", "err", err) + } + // Update event stats. + if err := event.IncrementCheckins(r.Context(), h.DB, ev.URI); err != nil { + slog.Info("event scan: increment checkins", "err", err) + } http.Redirect(w, r, "/?checkedin=1", http.StatusSeeOther) } @@ -151,6 +165,14 @@ func (h *Handlers) EventFlushLocal(w http.ResponseWriter, r *http.Request) { errs = append(errs, "checkin failed for "+truncate(t, 20)) continue } + // Award event-attendee badge (idempotent). + if _, err := badge.Award(r.Context(), viewerSess, h.DB, badge.AwardEventAttendee, ev.URI); err != nil { + slog.Info("event flush-local: event-attendee badge", "err", err) + } + // Update event stats. + if err := event.IncrementCheckins(r.Context(), h.DB, ev.URI); err != nil { + slog.Info("event flush-local: increment checkins", "err", err) + } written++ } diff --git a/features/event/pages/event.templ b/features/event/pages/event.templ index 4c4e877..547a776 100644 --- a/features/event/pages/event.templ +++ b/features/event/pages/event.templ @@ -1,6 +1,11 @@ package pages -import "atmoquest/features/common/layouts" +import ( + "fmt" + "strconv" + + "atmoquest/features/common/layouts" +) // EventScanView feeds the /e/{token} landing page. type EventScanView struct { @@ -11,6 +16,11 @@ type EventScanView struct { EventEndTime string IsOngoing bool ViewerLoggedIn bool + + // Geofence fields (zero values = no geofence configured). + GeofenceLat float64 + GeofenceLng float64 + GeofenceRadius int // meters } templ EventScan(v EventScanView) { @@ -43,6 +53,20 @@ templ EventScan(v EventScanView) { } if v.ViewerLoggedIn { + if v.GeofenceRadius > 0 { + + }
@@ -76,5 +100,6 @@ templ EventScan(v EventScanView) { } + } } diff --git a/features/event/pages/event_templ.go b/features/event/pages/event_templ.go index 8bf82aa..c650485 100644 --- a/features/event/pages/event_templ.go +++ b/features/event/pages/event_templ.go @@ -8,7 +8,12 @@ package pages import "github.com/a-h/templ" import templruntime "github.com/a-h/templ/runtime" -import "atmoquest/features/common/layouts" +import ( + "fmt" + "strconv" + + "atmoquest/features/common/layouts" +) // EventScanView feeds the /e/{token} landing page. type EventScanView struct { @@ -19,6 +24,11 @@ type EventScanView struct { EventEndTime string IsOngoing bool ViewerLoggedIn bool + + // Geofence fields (zero values = no geofence configured). + GeofenceLat float64 + GeofenceLng float64 + GeofenceRadius int // meters } func EventScan(v EventScanView) templ.Component { @@ -61,7 +71,7 @@ func EventScan(v EventScanView) 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/event/pages/event.templ`, Line: 27, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 37, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -74,7 +84,7 @@ func EventScan(v EventScanView) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.Token) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 28, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 38, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -87,7 +97,7 @@ func EventScan(v EventScanView) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 32, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 42, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -105,7 +115,7 @@ func EventScan(v EventScanView) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventLocation) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 34, Col: 50} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 44, Col: 50} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -123,7 +133,7 @@ func EventScan(v EventScanView) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventStartTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 36, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 46, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -136,7 +146,7 @@ func EventScan(v EventScanView) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(v.EventEndTime) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 36, Col: 80} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/event/pages/event.templ`, Line: 46, Col: 80} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -153,53 +163,98 @@ func EventScan(v EventScanView) templ.Component { } } if v.ViewerLoggedIn { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "
📍 heads up: checking your location…
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
cancel

this writes a quest.atmo.checkin record to your PDS.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\">
cancel

this writes a quest.atmo.checkin record to your PDS.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
heads up: you're not signed in. we've stashed this event in your browser — you'll be checked in automatically right after you sign in.
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "
heads up: you're not signed in. we've stashed this event in your browser — you'll be checked in automatically right after you sign in.
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
● event check-in
v0.2
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
● event check-in
v0.2
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if !v.ViewerLoggedIn { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" hidden>") 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, 21, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/features/events/handlers.go b/features/events/handlers.go new file mode 100644 index 0000000..c49618e --- /dev/null +++ b/features/events/handlers.go @@ -0,0 +1,380 @@ +package events + +import ( + "database/sql" + "fmt" + "log/slog" + "net/http" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/go-chi/chi/v5" + "github.com/starfederation/datastar-go/datastar" + + "atmoquest/features/auth" + "atmoquest/features/events/pages" + "atmoquest/internal/checkin" + "atmoquest/internal/connection" + "atmoquest/internal/event" + "atmoquest/internal/interest" + "atmoquest/internal/profile" +) + +// Handlers holds dependencies for the events feature. +type Handlers struct { + DB *sql.DB + Auth *auth.Handlers +} + +// NewHandlers wires the events feature. +func NewHandlers(conn *sql.DB, authH *auth.Handlers) *Handlers { + return &Handlers{DB: conn, Auth: authH} +} + +// List renders the /events page showing all events the user has checked into. +func (h *Handlers) List(w http.ResponseWriter, r *http.Request) { + viewerDID, _, ok := h.Auth.RequireSession(w, r) + if !ok { + return + } + + now := time.Now() + userEvents, err := checkin.ListForUser(r.Context(), h.DB, viewerDID, now) + if err != nil { + slog.Error("events: list for user", "did", viewerDID.String(), "err", err) + } + + var items []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{ + 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, + QRToken: event.QRTokenForURI(r.Context(), h.DB, ue.EventURI), + UniqueConnectors: stats.UniqueConnectors, + TotalCheckins: stats.TotalCheckins, + TotalAttendees: len(attendeeDIDs), + }) + } + + view := pages.EventsView{ + Events: items, + HasAny: len(items) > 0, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.Events(view).Render(r.Context(), w); err != nil { + slog.Error("render events", "err", err) + } +} + +// 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) + if !ok { + return + } + + token := chi.URLParam(r, "token") + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + http.NotFound(w, r) + return + } + + now := time.Now() + stats, _ := event.GetStats(r.Context(), h.DB, ev.URI) + filter := r.URL.Query().Get("filter") + + // Get all DIDs who checked into this event. + attendeeDIDs, err := checkin.ListAttendeesForEvent(r.Context(), h.DB, ev.URI) + if err != nil { + slog.Error("event detail: attendees", "err", err) + } + + // Get the viewer's connections to determine connected/not-connected. + viewerPDS := h.lookupPDSForDID(r, viewerDID) + viewerConns, _ := connection.List(r.Context(), viewerPDS, viewerDID) + connSet := make(map[string]bool, len(viewerConns)) + for _, c := range viewerConns { + connSet[c.With.String()] = true + } + + // Build attendee list with enrichment. + var attendees []pages.Attendee + // Connection counts for leaderboard. + type connCount struct { + did string + displayName string + avatarURL string + count int + } + connCounts := make(map[string]*connCount) + + for _, didStr := range attendeeDIDs { + did, err := syntax.ParseDID(didStr) + if err != nil { + continue + } + + isConnected := connSet[didStr] || didStr == viewerDID.String() + + // Filter: "mine" only shows connected attendees. + if filter == "mine" && !isConnected { + continue + } + + a := pages.Attendee{ + DID: didStr, + Connected: isConnected, + } + + // Enrich with Bluesky profile. + pds := h.lookupPDSForDID(r, did) + bsky, err := profile.FetchBluesky(r.Context(), pds, did) + if err == nil && bsky != nil { + a.DisplayName = bsky.DisplayName + if bsky.Avatar != nil { + a.AvatarURL = profile.AvatarURL(pds, did, bsky.Avatar.CID()) + } + } + attendees = append(attendees, a) + + // Count this attendee's connections within the event. + theirPDS := pds + theirConns, _ := connection.List(r.Context(), theirPDS, did) + eventConns := 0 + for _, c := range theirConns { + if c.EventURI == ev.URI { + eventConns++ + } + } + if eventConns > 0 { + connCounts[didStr] = &connCount{ + did: didStr, + displayName: a.DisplayName, + avatarURL: a.AvatarURL, + count: eventConns, + } + } + } + + // Build leaderboard (top 10). + var leaderboard []pages.LeaderboardRow + type ranked struct { + cc *connCount + rank int + } + // Simple sort: collect, sort by count desc, take top 10. + var sorted []*connCount + for _, cc := range connCounts { + sorted = append(sorted, cc) + } + // Sort manually without importing sort. + for i := 0; i < len(sorted); i++ { + for j := i + 1; j < len(sorted); j++ { + if sorted[j].count > sorted[i].count { + sorted[i], sorted[j] = sorted[j], sorted[i] + } + } + } + limit := 10 + if len(sorted) < limit { + limit = len(sorted) + } + for i, cc := range sorted[:limit] { + leaderboard = append(leaderboard, pages.LeaderboardRow{ + Rank: i + 1, + DID: cc.did, + DisplayName: cc.displayName, + AvatarURL: cc.avatarURL, + ConnectionCount: cc.count, + }) + } + + // Milestone thresholds based on total attendees. + totalAttendees := len(attendeeDIDs) + leaderboardThreshold := totalAttendees * 20 / 100 + interestThreshold := totalAttendees * 40 / 100 + if leaderboardThreshold < 1 && totalAttendees > 0 { + leaderboardThreshold = 1 + } + if interestThreshold < 1 && totalAttendees > 0 { + interestThreshold = 1 + } + leaderboardUnlocked := stats.UniqueConnectors >= leaderboardThreshold + interestUnlocked := stats.UniqueConnectors >= interestThreshold + + // Interest matching: find attendees with shared interests (if unlocked). + var interestMatches []pages.InterestMatch + if interestUnlocked { + // Load viewer's quest profile for their interests. + viewerQuest, _ := profile.FetchQuest(r.Context(), viewerPDS, viewerDID) + if viewerQuest != nil && len(viewerQuest.Interests) > 0 { + pdsLookup := func(did syntax.DID) string { return h.lookupPDSForDID(r, did) } + avatarFn := func(pds string, did syntax.DID) (string, string) { + // Re-use already enriched attendee data if available. + for _, a := range attendees { + if a.DID == did.String() { + return a.DisplayName, a.AvatarURL + } + } + bsky, err := profile.FetchBluesky(r.Context(), pds, did) + if err == nil && bsky != nil { + avatarURL := "" + if bsky.Avatar != nil { + avatarURL = profile.AvatarURL(pds, did, bsky.Avatar.CID()) + } + return bsky.DisplayName, avatarURL + } + return "", "" + } + rawMatches := interest.FindMatches( + r.Context(), viewerQuest.Interests, attendeeDIDs, + viewerDID, connSet, pdsLookup, avatarFn, + ) + for _, m := range rawMatches { + interestMatches = append(interestMatches, pages.InterestMatch{ + DID: m.DID.String(), + DisplayName: m.DisplayName, + AvatarURL: m.AvatarURL, + SharedInterests: m.SharedInterests, + Connected: m.Connected, + }) + } + } + } + + 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), + UniqueConnectors: stats.UniqueConnectors, + TotalCheckins: stats.TotalCheckins, + TotalAttendees: totalAttendees, + Attendees: attendees, + Filter: filter, + Leaderboard: leaderboard, + LeaderboardUnlocked: leaderboardUnlocked, + InterestMatches: interestMatches, + InterestUnlocked: interestUnlocked, + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pages.EventDetail(view).Render(r.Context(), w); err != nil { + slog.Error("render event detail", "err", err) + } +} + +// lookupPDSForDID returns the PDS host for a DID. Falls back to bsky.social. +func (h *Handlers) lookupPDSForDID(r *http.Request, did syntax.DID) string { + if h.DB == nil { + return "https://bsky.social" + } + var host string + err := h.DB.QueryRowContext(r.Context(), ` + SELECT json_extract(data, '$.host_url') + FROM oauth_sessions WHERE did = ? + ORDER BY updated_at DESC LIMIT 1 + `, did.String()).Scan(&host) + if err != nil || host == "" { + return "https://bsky.social" + } + return host +} + +// 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) { + token := chi.URLParam(r, "token") + ev, err := event.LookupByQRToken(r.Context(), h.DB, token) + if err != nil { + http.NotFound(w, r) + return + } + + sse := datastar.NewSSE(w, r) + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + // Send initial update immediately then loop. + for { + stats, _ := event.GetStats(r.Context(), h.DB, ev.URI) + attendeeDIDs, _ := checkin.ListAttendeesForEvent(r.Context(), h.DB, ev.URI) + totalAttendees := len(attendeeDIDs) + + statsHTML := fmt.Sprintf( + `
`+ + `%d unique connectors`+ + `%d check-ins`+ + `%d attendees`+ + `
`, + stats.UniqueConnectors, stats.TotalCheckins, totalAttendees, + ) + + pct := float64(0) + if totalAttendees > 0 { + pct = float64(stats.UniqueConnectors) / float64(totalAttendees) * 100.0 + if pct > 100 { + pct = 100 + } + } + m20 := totalAttendees * 20 / 100 + m40 := totalAttendees * 40 / 100 + if m20 < 1 && totalAttendees > 0 { + m20 = 1 + } + if m40 < 1 && totalAttendees > 0 { + m40 = 1 + } + r20 := "" + r40 := "" + if stats.UniqueConnectors >= m20 { + r20 = " reached" + } + if stats.UniqueConnectors >= m40 { + r40 = " reached" + } + progressHTML := fmt.Sprintf( + `
`+ + `
`+ + `
`+ + `
`+ + ``+ + `
`+ + `
`+ + ``+ + `
`+ + `
`+ + `
`+ + `20%% 🏆`+ + `40%% ✨`+ + `
`+ + `
`, + stats.UniqueConnectors, totalAttendees, pct, m20, r20, m40, r40, r20, r40, + ) + + if err := sse.PatchElements(statsHTML); err != nil { + slog.Debug("event live sse closed", "err", err) + return + } + if err := sse.PatchElements(progressHTML); err != nil { + slog.Debug("event live sse closed", "err", err) + return + } + + select { + case <-r.Context().Done(): + return + case <-ticker.C: + } + } +} diff --git a/features/events/pages/detail.templ b/features/events/pages/detail.templ new file mode 100644 index 0000000..294f76c --- /dev/null +++ b/features/events/pages/detail.templ @@ -0,0 +1,284 @@ +package pages + +import ( + "fmt" + + "atmoquest/features/common/layouts" +) + +// EventDetailView is the data for the event detail screen. +type EventDetailView struct { + Token string + Name string + Location string + StartTime string + EndTime string + IsOngoing bool + UniqueConnectors int + TotalCheckins int + TotalAttendees int // total checked-in attendees (for milestone %) + Attendees []Attendee + Filter string // "all" or "mine" + Leaderboard []LeaderboardRow + LeaderboardUnlocked bool // true when UniqueConnectors >= 20% of TotalAttendees + InterestMatches []InterestMatch + InterestUnlocked bool // true when UniqueConnectors >= 40% of TotalAttendees +} + +// Attendee is one person checked into the event. +type Attendee struct { + DID string + DisplayName string + AvatarURL string + Connected bool // whether the viewer has connected with this person +} + +// LeaderboardRow is one entry in the per-event leaderboard. +type LeaderboardRow struct { + Rank int + DID string + DisplayName string + AvatarURL string + ConnectionCount int +} + +// InterestMatch is an attendee matched by shared interests with the viewer. +type InterestMatch struct { + DID string + DisplayName string + AvatarURL string + SharedInterests []string + Connected bool +} + +templ EventDetail(v EventDetailView) { + @layouts.Base("event — atmo.quest", v.Name + " on atmo.quest") { +
+
+
+
+
+
~/atmo.quest — event detail
+
+
+
+ you{ "@" }atmo.quest:~$ + event --detail { v.Token } +
+ +
+
+ if v.IsOngoing { + ● live + } else { + ended + } +

{ v.Name }

+
+ 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 && len(v.Leaderboard) > 0 { +
+ +
+ for _, row := range v.Leaderboard { + @leaderboardRow(row) + } +
+
+ } else if !v.LeaderboardUnlocked { +
+ 🔒 + leaderboard unlocks at { itoa(milestone20(v.TotalAttendees)) } connectors ({ "20%" }) +
+ } + + if v.InterestUnlocked && len(v.InterestMatches) > 0 { +
+ +
+ for _, m := range v.InterestMatches { + @interestMatchCard(m) + } +
+
+ } else if !v.InterestUnlocked { +
+ 🔒 + interest matching unlocks at { itoa(milestone40(v.TotalAttendees)) } connectors ({ "40%" }) +
+ } + +
+ +
+ all + connected +
+ if len(v.Attendees) > 0 { +
+ for _, a := range v.Attendees { + @attendeeCard(a, v.Token) + } +
+ } else { +

no attendees to show.

+ } +
+
+
+
+
● event detail
+
v0.1
+
+
+ + + @layouts.BottomNav("event") + } +} + +templ attendeeCard(a Attendee, token string) { + + if a.AvatarURL != "" { + { + } else { +
?
+ } + + if a.DisplayName != "" { + { a.DisplayName } + } else { + { shortDID(a.DID) } + } + +
+} + +templ leaderboardRow(row LeaderboardRow) { +
+ { itoa(row.Rank) } + if row.AvatarURL != "" { + { + } else { +
?
+ } + + if row.DisplayName != "" { + { row.DisplayName } + } else { + { shortDID(row.DID) } + } + + { itoa(row.ConnectionCount) } connections +
+} + +templ interestMatchCard(m InterestMatch) { +
+
+ if m.AvatarURL != "" { + { + } else { +
?
+ } + + if m.DisplayName != "" { + { m.DisplayName } + } else { + { shortDID(m.DID) } + } + + if m.Connected { + ✓ connected + } +
+
+ for _, interest := range m.SharedInterests { + { interest } + } +
+
+} + +templ eventDetailProgress(connectors int, totalAttendees int) { +
+
+
+
+ = milestone20(totalAttendees)) }> +
+
+ = milestone40(totalAttendees)) }> +
+
+
+ = milestone20(totalAttendees)) } style="margin-left:16%">{ "20%" } 🏆 + = milestone40(totalAttendees)) } style="margin-left:12%">{ "40%" } ✨ +
+
+} + +func shortDID(did string) string { + if len(did) <= 20 { + return did + } + return did[:12] + "…" + did[len(did)-6:] +} + +func itoa(n int) string { + return fmt.Sprintf("%d", n) +} + +// connectPct returns the fill percentage: connectors / totalAttendees * 100. +func connectPct(connectors, total int) string { + if total == 0 { + return "0" + } + pct := float64(connectors) / float64(total) * 100.0 + if pct > 100 { + pct = 100 + } + return fmt.Sprintf("%.1f", pct) +} + +// milestone20 returns the absolute connector count for the 20% milestone. +func milestone20(total int) int { + m := total * 20 / 100 + if m < 1 && total > 0 { + return 1 + } + return m +} + +// milestone40 returns the absolute connector count for the 40% milestone. +func milestone40(total int) int { + m := total * 40 / 100 + if m < 1 && total > 0 { + return 1 + } + return m +} + +// 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 new file mode 100644 index 0000000..2a4fd04 --- /dev/null +++ b/features/events/pages/detail_templ.go @@ -0,0 +1,1156 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +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 ( + "fmt" + + "atmoquest/features/common/layouts" +) + +// EventDetailView is the data for the event detail screen. +type EventDetailView struct { + Token string + Name string + Location string + StartTime string + EndTime string + IsOngoing bool + UniqueConnectors int + TotalCheckins int + TotalAttendees int // total checked-in attendees (for milestone %) + Attendees []Attendee + Filter string // "all" or "mine" + Leaderboard []LeaderboardRow + LeaderboardUnlocked bool // true when UniqueConnectors >= 20% of TotalAttendees + InterestMatches []InterestMatch + InterestUnlocked bool // true when UniqueConnectors >= 40% of TotalAttendees +} + +// Attendee is one person checked into the event. +type Attendee struct { + DID string + DisplayName string + AvatarURL string + Connected bool // whether the viewer has connected with this person +} + +// LeaderboardRow is one entry in the per-event leaderboard. +type LeaderboardRow struct { + Rank int + DID string + DisplayName string + AvatarURL string + ConnectionCount int +} + +// InterestMatch is an attendee matched by shared interests with the viewer. +type InterestMatch struct { + DID string + DisplayName string + AvatarURL string + SharedInterests []string + Connected bool +} + +func EventDetail(v EventDetailView) 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_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := 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 { + 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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — event detail
you") + 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/events/pages/detail.templ`, Line: 65, 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, 2, "atmo.quest:~$ event --detail ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.Token) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 66, Col: 47} + } + _, 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, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.IsOngoing { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "● live") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "ended") + 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 + } + 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: 76, 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, 8, "

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

📍 ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, 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: 79, Col: 50} + } + _, 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, 10, "

") + 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 + } + 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: 81, Col: 45} + } + _, 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, 12, " → ") + 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: 81, Col: 63} + } + _, 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, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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, 14, "
") + 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)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 86, Col: 57} + } + _, 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, " unique connectors ") + 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)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 87, Col: 54} + } + _, 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, 16, " check-ins ") + 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: 88, Col: 55} + } + _, 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, 17, " attendees
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if v.LeaderboardUnlocked && len(v.Leaderboard) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") + 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, 19, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if !v.LeaderboardUnlocked { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
🔒 leaderboard unlocks at ") + 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))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 103, Col: 93} + } + _, 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, 21, " connectors (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs("20%") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 103, Col: 115} + } + _, 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, 22, ")
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if v.InterestUnlocked && len(v.InterestMatches) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + 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, 24, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if !v.InterestUnlocked { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
🔒 interest matching unlocks at ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(milestone40(v.TotalAttendees))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 119, Col: 99} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " connectors (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs("40%") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 119, Col: 121} + } + _, 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, 27, ")
") + 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 + } + 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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "connected
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(v.Attendees) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, a := range v.Attendees { + templ_7745c5c3_Err = attendeeCard(a, v.Token).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "

no attendees to show.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
● event detail
v0.1
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = layouts.BottomNav("event").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("event — atmo.quest", v.Name+" on atmo.quest").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func attendeeCard(a Attendee, token 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 + 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_Var23 := templ.GetChildren(ctx) + if templ_7745c5c3_Var23 == nil { + templ_7745c5c3_Var23 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "") + 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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\"") ") + 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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
?
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "") + 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) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 168, Col: 19} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + 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)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 170, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func leaderboardRow(row LeaderboardRow) 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_Var34 := templ.GetChildren(ctx) + if templ_7745c5c3_Var34 == nil { + templ_7745c5c3_Var34 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + 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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") + 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)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 178, Col: 87} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if row.AvatarURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"") ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "
?
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "") + 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) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 186, Col: 21} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) + 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)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 188, Col: 23} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " ") + 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)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 191, Col: 61} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " connections
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func interestMatchCard(m InterestMatch) 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_Var43 := templ.GetChildren(ctx) + if templ_7745c5c3_Var43 == nil { + templ_7745c5c3_Var43 = 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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if m.AvatarURL != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\"") ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "
?
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "") + 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) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 205, Col: 20} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48)) + 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)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 207, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if m.Connected { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "✓ connected") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, interest := range m.SharedInterests { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var50 string + templ_7745c5c3_Var50, 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} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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, 73, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func eventDetailProgress(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_Var51 := templ.GetChildren(ctx) + if templ_7745c5c3_Var51 == nil { + templ_7745c5c3_Var51 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var55 = []any{"event-progress-tick-dot", templ.KV("reached", connectors >= milestone20(totalAttendees))} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var55...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "
") + 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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "
") + 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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var62 string + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs("20%") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 234, Col: 137} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, " 🏆 ") + 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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var65 string + templ_7745c5c3_Var65, templ_7745c5c3_Err = templ.JoinStringErrs("40%") + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/detail.templ`, Line: 235, Col: 137} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var65)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, " ✨
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func shortDID(did string) string { + if len(did) <= 20 { + return did + } + return did[:12] + "…" + did[len(did)-6:] +} + +func itoa(n int) string { + return fmt.Sprintf("%d", n) +} + +// connectPct returns the fill percentage: connectors / totalAttendees * 100. +func connectPct(connectors, total int) string { + if total == 0 { + return "0" + } + pct := float64(connectors) / float64(total) * 100.0 + if pct > 100 { + pct = 100 + } + return fmt.Sprintf("%.1f", pct) +} + +// milestone20 returns the absolute connector count for the 20% milestone. +func milestone20(total int) int { + m := total * 20 / 100 + if m < 1 && total > 0 { + return 1 + } + return m +} + +// milestone40 returns the absolute connector count for the 40% milestone. +func milestone40(total int) int { + m := total * 40 / 100 + if m < 1 && total > 0 { + return 1 + } + return m +} + +// sseAttr returns the datastar SSE attribute for live event updates. +func sseAttr(token string) string { + return "@get('/events/" + token + "/live')" +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/events/pages/events.templ b/features/events/pages/events.templ new file mode 100644 index 0000000..724af30 --- /dev/null +++ b/features/events/pages/events.templ @@ -0,0 +1,120 @@ +package pages + +import ( + "atmoquest/features/common/layouts" +) + +// EventsView is the data for the /events page. +type EventsView struct { + Events []EventItem + HasAny bool +} + +// EventItem is one event the user has checked into. +type EventItem struct { + URI string + Name string + Location string + StartTime string // pre-formatted + EndTime string // pre-formatted + IsOngoing bool + QRToken string // for linking to /events/{token} + UniqueConnectors int // event-wide progress metric + TotalCheckins int + TotalAttendees int // total checked-in attendees +} + +templ Events(v EventsView) { + @layouts.Base("events — atmo.quest", "Your events on atmo.quest.") { +
+
+
+
+
+
~/atmo.quest — events
+
+
+
+ you{ "@" }atmo.quest:~$ + ls events/ +
+ +
+ if len(v.Events) > 0 { +
+ for _, ev := range v.Events { + @eventCard(ev) + } +
+ } else { +
+

you haven't checked into any events yet.

+

scan an event QR code to get started.

+ ← back home +
+ } +
+
+
+
● your events
+
v0.1
+
+
+ + + @layouts.BottomNav("event") + } +} + +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 +
+ if ev.QRToken != "" { + + if ev.IsOngoing { + ▸ view event + } else { + view details → + } + + } +
+} + +// 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% ✨ +
+
+} diff --git a/features/events/pages/events_templ.go b/features/events/pages/events_templ.go new file mode 100644 index 0000000..3047ea6 --- /dev/null +++ b/features/events/pages/events_templ.go @@ -0,0 +1,491 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1020 +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" +) + +// EventsView is the data for the /events page. +type EventsView struct { + Events []EventItem + HasAny bool +} + +// EventItem is one event the user has checked into. +type EventItem struct { + URI string + Name string + Location string + StartTime string // pre-formatted + EndTime string // pre-formatted + IsOngoing bool + QRToken string // for linking to /events/{token} + UniqueConnectors int // event-wide progress metric + TotalCheckins int + TotalAttendees int // total checked-in attendees +} + +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 + 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_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := 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 { + 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_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
~/atmo.quest — events
you") + 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/events/pages/events.templ`, Line: 38, 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, 2, "atmo.quest:~$ ls events/
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(v.Events) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, ev := range v.Events { + templ_7745c5c3_Err = eventCard(ev).Render(ctx, templ_7745c5c3_Buffer) + 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 + } + } else { + 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 + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
● your events
v0.1
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = layouts.BottomNav("event").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = layouts.Base("events — atmo.quest", "Your events on atmo.quest.").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func eventCard(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 { + 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_Var4 := templ.GetChildren(ctx) + if templ_7745c5c3_Var4 == nil { + templ_7745c5c3_Var4 = 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, 7, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ev.IsOngoing { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "● live") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "ended") + 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 + } + 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, 12, "

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

📍 ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(ev.Location) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 82, 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, 14, "

") + 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 + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, 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: 84, Col: 43} + } + _, 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, 16, " → ") + 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, 17, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = eventProgress(ev.UniqueConnectors, ev.TotalAttendees).Render(ctx, templ_7745c5c3_Buffer) + 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 + } + 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)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, " unique connectors ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(itoa(ev.TotalCheckins)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/events/pages/events.templ`, Line: 88, Col: 52} + } + _, 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, 20, " check-ins
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ev.QRToken != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ev.IsOngoing { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "▸ view event") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "view details →") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") + 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, 27, "
") + 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, 31, "
") + 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, 34, "
") + 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...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "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, 38, "40% ✨
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/features/events/routes.go b/features/events/routes.go new file mode 100644 index 0000000..d4eed72 --- /dev/null +++ b/features/events/routes.go @@ -0,0 +1,17 @@ +package events + +import ( + "database/sql" + + "github.com/go-chi/chi/v5" + + "atmoquest/features/auth" +) + +// SetupRoutes registers the events feature routes. +func SetupRoutes(router chi.Router, conn *sql.DB, authH *auth.Handlers) { + h := NewHandlers(conn, authH) + router.Get("/events", h.List) + router.Get("/events/{token}", h.Detail) + router.Get("/events/{token}/live", h.Live) +} diff --git a/features/index/handlers.go b/features/index/handlers.go index 793291a..92caa9c 100644 --- a/features/index/handlers.go +++ b/features/index/handlers.go @@ -29,6 +29,7 @@ import ( "atmoquest/features/auth" "atmoquest/features/index/pages" "atmoquest/internal/checkin" + "atmoquest/internal/connection" "atmoquest/internal/event" "atmoquest/internal/profile" ) @@ -78,6 +79,7 @@ func (h *Handlers) IndexPage(w http.ResponseWriter, r *http.Request) { if did, err := syntax.ParseDID(didStr); err == nil { h.fillAuthedView(r, did, &view) + h.fillRecentConnections(r, did, &view) } } } @@ -139,6 +141,39 @@ func (h *Handlers) fillAuthedView(r *http.Request, did syntax.DID, view *pages.I } } +// fillRecentConnections fetches the user's most recent connections and +// enriches the top 8 with Bluesky profile data. Soft-fails entirely. +func (h *Handlers) fillRecentConnections(r *http.Request, did syntax.DID, view *pages.IndexView) { + pds := h.lookupPDSForDID(r, did) + if pds == "" { + return + } + entries, err := connection.List(r.Context(), pds, did) + if err != nil { + slog.Debug("index: connection.List", "did", did.String(), "err", err) + return + } + + const maxRecent = 8 + limit := len(entries) + if limit > maxRecent { + limit = maxRecent + } + + for _, entry := range entries[:limit] { + rc := pages.RecentConnection{DID: entry.With.String()} + targetPDS := h.lookupPDSForDID(r, entry.With) + bsky, err := profile.FetchBluesky(r.Context(), targetPDS, entry.With) + if err == nil && bsky != nil { + rc.DisplayName = bsky.DisplayName + if bsky.Avatar != nil { + rc.AvatarURL = profile.AvatarURL(targetPDS, entry.With, bsky.Avatar.CID()) + } + } + view.RecentConnections = append(view.RecentConnections, rc) + } +} + // formatEventTime renders an event time for the home card. We use the // server's local timezone for now (v1 events are CascadiaJS, Pacific). A // future enhancement could send the user's TZ from the browser. diff --git a/features/index/pages/index.templ b/features/index/pages/index.templ index 0c8dddd..a707515 100644 --- a/features/index/pages/index.templ +++ b/features/index/pages/index.templ @@ -26,6 +26,9 @@ type IndexView struct { // Nil pointer means "no active event right now" — the home page renders // an empty-state card in that case. CurrentEvent *IndexEventCard + + // RecentConnections is a small grid of the user's most recent connections. + RecentConnections []RecentConnection } // IndexEventCard is the small projection of an event we render on the home @@ -42,6 +45,13 @@ type IndexEventCard struct { EventURI string } +// RecentConnection is a small card for the home page grid. +type RecentConnection struct { + DID string + DisplayName string + AvatarURL string +} + // IndexPage renders either the authed dashboard or the guest landing // depending on v.LoggedIn. templ IndexPage(v IndexView) { @@ -88,7 +98,7 @@ templ indexAuthed(v IndexView) { } -

▸ tap your photo to share a QR · tap to flip back

+

▸ tap to flip

if v.DisplayName != "" { @@ -108,6 +118,7 @@ templ indexAuthed(v IndexView) {
@indexEventCard(v.CurrentEvent) + @indexRecentConnections(v.RecentConnections)
@@ -124,6 +135,7 @@ templ indexAuthed(v IndexView) { + @layouts.BottomNav("home") } } @@ -155,6 +167,45 @@ templ indexEventCard(ev *IndexEventCard) { } +// indexRecentConnections renders a small grid of recently-connected people. +templ indexRecentConnections(conns []RecentConnection) { + +} + +func shortDID(did string) string { + if len(did) <= 20 { + return did + } + return did[:12] + "…" + did[len(did)-6:] +} + // indexGuest is the original marketing landing page. templ indexGuest() { @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.") { diff --git a/features/index/pages/index_templ.go b/features/index/pages/index_templ.go index 0364020..503c83c 100644 --- a/features/index/pages/index_templ.go +++ b/features/index/pages/index_templ.go @@ -34,6 +34,9 @@ type IndexView struct { // Nil pointer means "no active event right now" — the home page renders // an empty-state card in that case. CurrentEvent *IndexEventCard + + // RecentConnections is a small grid of the user's most recent connections. + RecentConnections []RecentConnection } // IndexEventCard is the small projection of an event we render on the home @@ -50,6 +53,13 @@ type IndexEventCard struct { EventURI string } +// RecentConnection is a small card for the home page grid. +type RecentConnection struct { + DID string + DisplayName string + AvatarURL string +} + // IndexPage renders either the authed dashboard or the guest landing // depending on v.LoggedIn. func IndexPage(v IndexView) templ.Component { @@ -132,7 +142,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: 70, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 80, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -150,7 +160,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: 80, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 90, Col: 55} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -163,7 +173,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: 80, Col: 93} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 90, Col: 93} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -191,7 +201,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: 87, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 97, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) if templ_7745c5c3_Err != nil { @@ -202,7 +212,7 @@ func indexAuthed(v IndexView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

▸ tap your photo to share a QR · tap to flip back

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

▸ tap to flip

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -214,7 +224,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: 95, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 105, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -238,7 +248,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: 100, Col: 47} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 110, Col: 47} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -257,6 +267,10 @@ func indexAuthed(v IndexView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + 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, 17, "
● ATProto · OAuth ready · signed in as ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err @@ -269,7 +283,7 @@ func indexAuthed(v IndexView) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, 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: 117, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 128, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -287,13 +301,21 @@ func indexAuthed(v IndexView) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("@get('/demo/clock')") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 120, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 131, Col: 70} } _, 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, 21, "\">--:--:--
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\">--:--:--") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = layouts.BottomNav("home").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, " ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -331,84 +353,84 @@ func indexEventCard(ev *IndexEventCard) templ.Component { templ_7745c5c3_Var12 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -416,8 +438,8 @@ func indexEventCard(ev *IndexEventCard) templ.Component { }) } -// indexGuest is the original marketing landing page. -func indexGuest() templ.Component { +// indexRecentConnections renders a small grid of recently-connected people. +func indexRecentConnections(conns []RecentConnection) 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 { @@ -438,7 +460,161 @@ func indexGuest() templ.Component { templ_7745c5c3_Var17 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Var18 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +func shortDID(did string) string { + if len(did) <= 20 { + return did + } + return did[:12] + "…" + did[len(did)-6:] +} + +// indexGuest is the original marketing landing page. +func indexGuest() 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_Var24 := templ.GetChildren(ctx) + if templ_7745c5c3_Var24 == nil { + templ_7745c5c3_Var24 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + 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 { @@ -450,65 +626,65 @@ func indexGuest() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
~/atmo.quest — zsh
you") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
~/atmo.quest — zsh
you") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var19 string - templ_7745c5c3_Var19, 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: 170, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 221, Col: 56} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + _, 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, 33, "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, 48, "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_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs("for") + var templ_7745c5c3_Var27 string + templ_7745c5c3_Var27, 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: 181, 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_Var20)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, " 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.

● ATProto · OAuth ready · guest mode enabled
● ATProto · OAuth ready · guest mode enabled
--:--:--
quest_log.md

Side Quests ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\">--:--:--

quest_log.md

Side Quests ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs("// available") + var templ_7745c5c3_Var29 string + templ_7745c5c3_Var29, 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: 204, Col: 57} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/index/pages/index.templ`, Line: 255, Col: 57} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "

◆
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
built on atproto · open source · made by PDX ATProto
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
◆
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_Var18), 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/profile/handlers.go b/features/profile/handlers.go index f360266..98c3cb8 100644 --- a/features/profile/handlers.go +++ b/features/profile/handlers.go @@ -15,6 +15,7 @@ import ( "atmoquest/features/auth" "atmoquest/features/profile/pages" + "atmoquest/internal/badge" "atmoquest/internal/profile" ) @@ -55,6 +56,22 @@ func (h *Handlers) Profile(w http.ResponseWriter, r *http.Request) { view.QRURL = "/profile/qr.svg" view.ConnectedDID = strings.TrimSpace(r.URL.Query().Get("connected")) + // Load earned badges from local cache. + if earned, err := badge.ListEarned(r.Context(), h.DB, did.String()); err == nil { + for _, eb := range earned { + view.Badges = append(view.Badges, pages.ProfileBadge{ + Name: eb.Name, + Description: eb.Description, + Shape: eb.Shape, + PrimaryColor: eb.PrimaryColor, + AccentColor: eb.AccentColor, + RibbonColor: eb.RibbonColor, + Label: eb.Label, + EarnedAt: eb.EarnedAt.Format("Jan 2, 2006"), + }) + } + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := pages.Profile(view).Render(r.Context(), w); err != nil { slog.Error("render profile", "err", err) diff --git a/features/profile/pages/profile.templ b/features/profile/pages/profile.templ index 3803608..2fc54fe 100644 --- a/features/profile/pages/profile.templ +++ b/features/profile/pages/profile.templ @@ -39,6 +39,20 @@ type ProfileView struct { // set from the ?connected= query string after a successful scan // confirmation. ConnectedDID string + // Badges earned by this user (from local cache). + Badges []ProfileBadge +} + +// ProfileBadge is a badge to display on the profile. +type ProfileBadge struct { + Name string + Description string + Shape string + PrimaryColor string + AccentColor string + RibbonColor string + Label string + EarnedAt string // pre-formatted } templ Profile(v ProfileView) { @@ -84,7 +98,7 @@ templ Profile(v ProfileView) { } -

▸ tap your photo to share a QR · tap again to flip back

+

▸ tap to flip

if v.DisplayName != "" { @@ -171,6 +185,17 @@ templ Profile(v ProfileView) { } + if len(v.Badges) > 0 { +
+ +
+ for _, b := range v.Badges { + @profileBadge(b) + } +
+
+ } +
▸ edit profile
@@ -219,3 +244,12 @@ func avatarAlt(displayName string) string { } return displayName + "'s profile picture" } + +templ profileBadge(b ProfileBadge) { +
+
+ { b.Label } +
+ { b.Name } +
+} diff --git a/features/profile/pages/profile_templ.go b/features/profile/pages/profile_templ.go index 93f1f8c..46f2254 100644 --- a/features/profile/pages/profile_templ.go +++ b/features/profile/pages/profile_templ.go @@ -47,6 +47,20 @@ type ProfileView struct { // set from the ?connected= query string after a successful scan // confirmation. ConnectedDID string + // Badges earned by this user (from local cache). + Badges []ProfileBadge +} + +// ProfileBadge is a badge to display on the profile. +type ProfileBadge struct { + Name string + Description string + Shape string + PrimaryColor string + AccentColor string + RibbonColor string + Label string + EarnedAt string // pre-formatted } func Profile(v ProfileView) templ.Component { @@ -89,7 +103,7 @@ func Profile(v ProfileView) 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/profile/pages/profile.templ`, Line: 55, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 69, Col: 56} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -117,7 +131,7 @@ func Profile(v ProfileView) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(v.ConnectedDID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 66, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 80, Col: 63} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -145,7 +159,7 @@ func Profile(v ProfileView) 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/profile/pages/profile.templ`, Line: 74, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 88, Col: 54} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5) if templ_7745c5c3_Err != nil { @@ -158,7 +172,7 @@ func Profile(v ProfileView) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(avatarAlt(v.DisplayName)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 74, Col: 87} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 88, Col: 87} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6) if templ_7745c5c3_Err != nil { @@ -186,7 +200,7 @@ func Profile(v ProfileView) 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/profile/pages/profile.templ`, Line: 83, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 97, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7) if templ_7745c5c3_Err != nil { @@ -197,7 +211,7 @@ func Profile(v ProfileView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "

▸ tap your photo to share a QR · tap again to flip back

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

▸ tap to flip

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -209,7 +223,7 @@ func Profile(v ProfileView) 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/profile/pages/profile.templ`, Line: 91, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 105, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -260,7 +274,7 @@ func Profile(v ProfileView) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(v.Bio) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 118, Col: 51} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 132, Col: 51} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -279,7 +293,7 @@ func Profile(v ProfileView) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(v.WorksAt) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 124, Col: 42} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 138, Col: 42} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -298,7 +312,7 @@ func Profile(v ProfileView) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(v.Location) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 130, Col: 43} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 144, Col: 43} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { @@ -317,7 +331,7 @@ func Profile(v ProfileView) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(v.ContactMethod) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 136, Col: 48} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 150, Col: 48} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -357,7 +371,7 @@ func Profile(v ProfileView) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(t) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 154, Col: 45} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 168, Col: 45} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) if templ_7745c5c3_Err != nil { @@ -386,7 +400,7 @@ func Profile(v ProfileView) templ.Component { var templ_7745c5c3_Var14 templ.SafeURL templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(l.URL)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 165, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 179, Col: 58} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { @@ -399,7 +413,7 @@ func Profile(v ProfileView) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(l.Label) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 166, Col: 49} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 180, Col: 49} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { @@ -415,63 +429,79 @@ func Profile(v ProfileView) templ.Component { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
session details
DID
") + if len(v.Badges) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, b := range v.Badges { + templ_7745c5c3_Err = profileBadge(b).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
session details
DID
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var16 string templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(v.DID) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 183, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 208, Col: 37} } _, 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, 46, "
PDS
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
PDS
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var17 string templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(v.PDSHost) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 185, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 210, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
scopes
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "
scopes
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(v.Scopes) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "none") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "none") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { for _, s := range v.Scopes { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var18 string templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(s) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 192, Col: 38} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 217, Col: 38} } _, 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, 50, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
← home
● session bound · DPoP · refresh auto
v0.1
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "
← home
● session bound · DPoP · refresh auto
v0.1
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -493,4 +523,85 @@ func avatarAlt(displayName string) string { return displayName + "'s profile picture" } +func profileBadge(b ProfileBadge) 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_Var19 := templ.GetChildren(ctx) + if templ_7745c5c3_Var19 == nil { + templ_7745c5c3_Var19 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var22 string + templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(b.Label) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 251, Col: 46} + } + _, 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, 57, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var23 string + templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(b.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `features/profile/pages/profile.templ`, Line: 253, Col: 43} + } + _, 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, 58, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + var _ = templruntime.GeneratedTemplate diff --git a/features/settings/pages/settings.templ b/features/settings/pages/settings.templ index ea15adc..29d748b 100644 --- a/features/settings/pages/settings.templ +++ b/features/settings/pages/settings.templ @@ -93,5 +93,6 @@ templ Settings(v SettingsView) { + @layouts.BottomNav("home") } } diff --git a/features/settings/pages/settings_templ.go b/features/settings/pages/settings_templ.go index 7b6cd4a..16f706f 100644 --- a/features/settings/pages/settings_templ.go +++ b/features/settings/pages/settings_templ.go @@ -137,6 +137,10 @@ func Settings(v SettingsView) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } + templ_7745c5c3_Err = layouts.BottomNav("home").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) diff --git a/internal/badge/award.go b/internal/badge/award.go new file mode 100644 index 0000000..49398d6 --- /dev/null +++ b/internal/badge/award.go @@ -0,0 +1,134 @@ +package badge + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/bluesky-social/indigo/atproto/auth/oauth" + "github.com/bluesky-social/indigo/atproto/syntax" +) + +const ( + // AwardNSID is the lexicon for badge awards written to user repos. + AwardNSID = "quest.atmo.badge" + + nsidCreateRecord = "com.atproto.repo.createRecord" + nsidListRecords = "com.atproto.repo.listRecords" +) + +// AwardType identifies the kind of badge being issued. +type AwardType string + +const ( + AwardFirstConnect AwardType = "first-connect" + AwardEventAttendee AwardType = "event-attendee" +) + +// Award writes a quest.atmo.badge record to the user's PDS and caches +// the result in earned_badges. It is idempotent — if the user already +// has this badge (checked locally first, then PDS), it returns ("", nil). +// +// db may be nil (degrades to PDS-only dedup). +// eventURI may be empty for non-event badges. +func Award(ctx context.Context, sess *oauth.ClientSession, db *sql.DB, badgeType AwardType, eventURI string) (recordURI string, err error) { + if sess == nil { + return "", fmt.Errorf("badge.Award: nil session") + } + + did := sess.Data.AccountDID.String() + + // Fast path: check local earned_badges cache. + if db != nil { + def, err := GetDefinitionByTrigger(ctx, db, string(badgeType)) + if err == nil && HasEarned(ctx, db, did, def.ID) { + return "", nil + } + } + + // Slow path: check PDS for existing badge of this type+event. + if hasBadge(ctx, sess, badgeType, eventURI) { + return "", nil + } + + value := map[string]any{ + "$type": AwardNSID, + "type": string(badgeType), + "earnedAt": time.Now().UTC().Format(time.RFC3339), + } + if eventURI != "" { + value["event"] = eventURI + } + + input := map[string]any{ + "repo": did, + "collection": AwardNSID, + "record": value, + } + + var out struct { + URI string `json:"uri"` + CID string `json:"cid"` + } + if err := sess.APIClient().Post(ctx, syntax.NSID(nsidCreateRecord), input, &out); err != nil { + return "", fmt.Errorf("badge.Award createRecord %s: %w", badgeType, err) + } + + // Cache locally. + if db != nil { + def, err := GetDefinitionByTrigger(ctx, db, string(badgeType)) + if err == nil { + _ = RecordEarned(ctx, db, did, def.ID, out.URI) + } + } + + return out.URI, nil +} + +// hasBadge checks if the user already has a badge of the given type (and +// optionally matching event). Best-effort — returns false on any error so +// we err on the side of re-issuing rather than missing an award. +func hasBadge(ctx context.Context, sess *oauth.ClientSession, badgeType AwardType, eventURI string) bool { + type badgeValue struct { + Type string `json:"type"` + Event string `json:"event,omitempty"` + } + type entry struct { + URI string `json:"uri"` + Value badgeValue `json:"value"` + } + type resp struct { + Records []entry `json:"records"` + Cursor string `json:"cursor,omitempty"` + } + + cursor := "" + for { + params := map[string]any{ + "repo": sess.Data.AccountDID.String(), + "collection": AwardNSID, + "limit": 100, + } + if cursor != "" { + params["cursor"] = cursor + } + + var r resp + if err := sess.APIClient().Get(ctx, syntax.NSID(nsidListRecords), params, &r); err != nil { + return false + } + for _, rec := range r.Records { + if rec.Value.Type == string(badgeType) { + if eventURI == "" || rec.Value.Event == eventURI { + return true + } + } + } + if r.Cursor == "" || len(r.Records) == 0 { + break + } + cursor = r.Cursor + } + return false +} diff --git a/internal/badge/definitions.go b/internal/badge/definitions.go new file mode 100644 index 0000000..b3bd200 --- /dev/null +++ b/internal/badge/definitions.go @@ -0,0 +1,184 @@ +package badge + +import ( + "context" + "database/sql" + "time" +) + +// Definition is an admin-configured badge type. +type Definition struct { + ID int + Slug string + Name string + Description string + EventURI string // optional + Shape string + PrimaryColor string + AccentColor string + RibbonColor string + Label string + Trigger string // auto-award trigger, empty = manual only + CreatedAt time.Time +} + +// EarnedBadge is a badge a user has earned, joined with its definition. +type EarnedBadge struct { + Definition + RecordURI string + EarnedAt time.Time +} + +// ListDefinitions returns all badge definitions, ordered by creation time. +func ListDefinitions(ctx context.Context, db *sql.DB) ([]Definition, error) { + rows, err := db.QueryContext(ctx, ` + SELECT id, slug, name, description, COALESCE(event_uri,''), shape, + primary_color, accent_color, ribbon_color, label, + COALESCE(trigger,''), created_at + FROM badge_definitions + ORDER BY created_at ASC + `) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Definition + for rows.Next() { + var d Definition + if err := rows.Scan(&d.ID, &d.Slug, &d.Name, &d.Description, &d.EventURI, + &d.Shape, &d.PrimaryColor, &d.AccentColor, &d.RibbonColor, &d.Label, + &d.Trigger, &d.CreatedAt); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// GetDefinitionBySlug returns a badge definition by its slug. +func GetDefinitionBySlug(ctx context.Context, db *sql.DB, slug string) (Definition, error) { + var d Definition + err := db.QueryRowContext(ctx, ` + SELECT id, slug, name, description, COALESCE(event_uri,''), shape, + primary_color, accent_color, ribbon_color, label, + COALESCE(trigger,''), created_at + FROM badge_definitions WHERE slug = ? + `, slug).Scan(&d.ID, &d.Slug, &d.Name, &d.Description, &d.EventURI, + &d.Shape, &d.PrimaryColor, &d.AccentColor, &d.RibbonColor, &d.Label, + &d.Trigger, &d.CreatedAt) + if err == sql.ErrNoRows { + return d, ErrNotFound + } + return d, err +} + +// GetDefinitionByTrigger returns the badge definition for an auto-award trigger. +func GetDefinitionByTrigger(ctx context.Context, db *sql.DB, trigger string) (Definition, error) { + var d Definition + err := db.QueryRowContext(ctx, ` + SELECT id, slug, name, description, COALESCE(event_uri,''), shape, + primary_color, accent_color, ribbon_color, label, + COALESCE(trigger,''), created_at + FROM badge_definitions WHERE trigger = ? + `, trigger).Scan(&d.ID, &d.Slug, &d.Name, &d.Description, &d.EventURI, + &d.Shape, &d.PrimaryColor, &d.AccentColor, &d.RibbonColor, &d.Label, + &d.Trigger, &d.CreatedAt) + if err == sql.ErrNoRows { + return d, ErrNotFound + } + return d, err +} + +// CreateDefinition inserts a new badge definition. +func CreateDefinition(ctx context.Context, db *sql.DB, d Definition) (int64, error) { + var eventURI *string + if d.EventURI != "" { + eventURI = &d.EventURI + } + var trigger *string + if d.Trigger != "" { + trigger = &d.Trigger + } + label := d.Label + if label == "" { + label = d.Name + } + res, err := db.ExecContext(ctx, ` + INSERT INTO badge_definitions (slug, name, description, event_uri, shape, + primary_color, accent_color, ribbon_color, label, trigger) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, d.Slug, d.Name, d.Description, eventURI, d.Shape, + d.PrimaryColor, d.AccentColor, d.RibbonColor, label, trigger) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +// RecordEarned caches a badge earn in the local DB. +func RecordEarned(ctx context.Context, db *sql.DB, did string, badgeID int, recordURI string) error { + _, err := db.ExecContext(ctx, ` + INSERT INTO earned_badges (did, badge_id, record_uri, earned_at) + VALUES (?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(did, badge_id) DO NOTHING + `, did, badgeID, recordURI) + return err +} + +// ListEarned returns all badges a user has earned, joined with definitions. +func ListEarned(ctx context.Context, db *sql.DB, did string) ([]EarnedBadge, error) { + rows, err := db.QueryContext(ctx, ` + SELECT d.id, d.slug, d.name, d.description, COALESCE(d.event_uri,''), + d.shape, d.primary_color, d.accent_color, d.ribbon_color, d.label, + COALESCE(d.trigger,''), d.created_at, + e.record_uri, e.earned_at + FROM earned_badges e + JOIN badge_definitions d ON d.id = e.badge_id + WHERE e.did = ? + ORDER BY e.earned_at DESC + `, did) + if err != nil { + return nil, err + } + defer rows.Close() + var out []EarnedBadge + for rows.Next() { + var eb EarnedBadge + if err := rows.Scan(&eb.ID, &eb.Slug, &eb.Name, &eb.Description, &eb.EventURI, + &eb.Shape, &eb.PrimaryColor, &eb.AccentColor, &eb.RibbonColor, &eb.Label, + &eb.Trigger, &eb.CreatedAt, + &eb.RecordURI, &eb.EarnedAt); err != nil { + return nil, err + } + out = append(out, eb) + } + return out, rows.Err() +} + +// HasEarned checks if a user has earned a specific badge. +func HasEarned(ctx context.Context, db *sql.DB, did string, badgeID int) bool { + var n int + err := db.QueryRowContext(ctx, ` + SELECT 1 FROM earned_badges WHERE did = ? AND badge_id = ? + `, did, badgeID).Scan(&n) + return err == nil +} + +// CountConnectionsForDID counts how many quest.atmo.connection records a user +// has in the checkins cache. Used for connection-milestone badge triggers. +// Returns a rough lower bound from the local DB (connections with events). +func CountConnectionsForDID(ctx context.Context, db *sql.DB, did string) int { + var n int + _ = db.QueryRowContext(ctx, ` + SELECT COUNT(DISTINCT target_did) FROM connection_notes WHERE viewer_did = ? + `, did).Scan(&n) + return n +} + +// Leaderboard entry for an event. +type LeaderboardEntry struct { + DID string + DisplayName string + AvatarURL string + ConnectionCount int +} diff --git a/internal/checkin/list.go b/internal/checkin/list.go new file mode 100644 index 0000000..4864c70 --- /dev/null +++ b/internal/checkin/list.go @@ -0,0 +1,76 @@ +package checkin + +import ( + "context" + "database/sql" + "time" + + "github.com/bluesky-social/indigo/atproto/syntax" +) + +// UserEvent is an event a user has checked into, enriched with event metadata. +type UserEvent struct { + EventURI string + EventName string + Location string + StartTime time.Time + EndTime time.Time + CheckedInAt time.Time + IsOngoing bool +} + +// ListForUser returns all events a user has checked into, most recent first. +// Each event appears once (even if the user checked in multiple times). +func ListForUser(ctx context.Context, db *sql.DB, did syntax.DID, now time.Time) ([]UserEvent, error) { + rows, err := db.QueryContext(ctx, ` + SELECT e.uri, e.name, e.location, e.start_time, e.end_time, + MAX(c.checked_in_at) AS last_checkin + FROM checkins c + JOIN events e ON e.uri = c.event_uri + WHERE c.did = ? + GROUP BY e.uri + ORDER BY last_checkin DESC + `, did.String()) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []UserEvent + for rows.Next() { + var ue UserEvent + if err := rows.Scan(&ue.EventURI, &ue.EventName, &ue.Location, + &ue.StartTime, &ue.EndTime, &ue.CheckedInAt); err != nil { + return nil, err + } + ue.IsOngoing = !now.Before(ue.StartTime) && !now.After(ue.EndTime) + out = append(out, ue) + } + return out, rows.Err() +} + +// ListAttendeesForEvent returns distinct DIDs of users who checked into a +// specific event, ordered by check-in time (earliest first). +func ListAttendeesForEvent(ctx context.Context, db *sql.DB, eventURI string) ([]string, error) { + rows, err := db.QueryContext(ctx, ` + SELECT did + FROM checkins + WHERE event_uri = ? + GROUP BY did + ORDER BY MIN(checked_in_at) ASC + `, eventURI) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []string + for rows.Next() { + var did string + if err := rows.Scan(&did); err != nil { + return nil, err + } + out = append(out, did) + } + return out, rows.Err() +} diff --git a/internal/db/migrations/007_badge_definitions.sql b/internal/db/migrations/007_badge_definitions.sql new file mode 100644 index 0000000..a34896b --- /dev/null +++ b/internal/db/migrations/007_badge_definitions.sql @@ -0,0 +1,50 @@ +-- Badge definitions + earned badges cache. +-- +-- badge_definitions: admin-created badge types with visual designs. +-- Each badge has a slug (e.g. "first-connect"), display name, optional +-- description, optional event association, and a visual design spec. +-- Auto-award badges are identified by their trigger column. +-- +-- earned_badges: local cache of badges each user has earned. Source of +-- truth is the quest.atmo.badge record in the user's PDS; this table +-- is a read-fast mirror for profile rendering and leaderboards. + +CREATE TABLE badge_definitions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, -- e.g. 'first-connect', 'event-attendee-cascadiajs' + name TEXT NOT NULL, -- display name, e.g. 'First Connection' + description TEXT NOT NULL DEFAULT '', -- short description shown on hover/detail + event_uri TEXT, -- optional: ties badge to a specific event + -- Visual design (reuses the badge renderer palette). + shape TEXT NOT NULL DEFAULT 'circle', + primary_color TEXT NOT NULL DEFAULT '#fab387', + accent_color TEXT NOT NULL DEFAULT '#11111b', + ribbon_color TEXT NOT NULL DEFAULT '#f38ba8', + label TEXT NOT NULL DEFAULT '', -- text on badge face (defaults to name if empty) + -- Auto-award trigger. NULL means manually awarded only. + -- Values: 'first-connect', 'event-attendee', 'profile-complete', + -- 'connections-50', 'connections-100', 'connections-200' + trigger TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE earned_badges ( + did TEXT NOT NULL, + badge_id INTEGER NOT NULL, + record_uri TEXT NOT NULL DEFAULT '', -- at-uri of the PDS record (empty if write failed) + earned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (did, badge_id), + FOREIGN KEY (badge_id) REFERENCES badge_definitions (id) ON DELETE CASCADE +); + +CREATE INDEX earned_badges_did_idx ON earned_badges (did); + +-- Seed the default badge types. +INSERT INTO badge_definitions (slug, name, description, trigger, shape, primary_color, ribbon_color, label) +VALUES + ('first-connect', 'First Connection', 'Made your first connection at an event.', 'first-connect', 'star', '#fab387', '#f38ba8', '1st'), + ('event-attendee', 'Event Attendee', 'Checked in to an event.', 'event-attendee', 'shield', '#89b4fa', '#94e2d5', '✓'), + ('profile-complete', 'Profile Complete', 'Filled out all your profile fields.', 'profile-complete', 'hexagon', '#a6e3a1', '#f9e2af', '★'), + ('connections-50', '50 Connections', 'Connected with 50 people.', 'connections-50', 'circle', '#f9e2af', '#fab387', '50'), + ('connections-100', '100 Connections', 'Connected with 100 people.', 'connections-100', 'circle', '#cba6f7', '#f38ba8', '100'), + ('connections-200', '200 Connections', 'Connected with 200 people.', 'connections-200', 'circle', '#f38ba8', '#cba6f7', '200'); diff --git a/internal/event/list.go b/internal/event/list.go index 287e217..c095bc0 100644 --- a/internal/event/list.go +++ b/internal/event/list.go @@ -1,5 +1,4 @@ package event -package event import ( "context" @@ -30,3 +29,10 @@ func ListAll(ctx context.Context, db *sql.DB) ([]Record, error) { } return out, rows.Err() } + +// QRTokenForURI returns the qr_token for an event URI. Returns "" if not found. +func QRTokenForURI(ctx context.Context, db *sql.DB, uri string) string { + var token string + _ = db.QueryRowContext(ctx, `SELECT qr_token FROM events WHERE uri = ?`, uri).Scan(&token) + return token +} diff --git a/internal/event/stats.go b/internal/event/stats.go new file mode 100644 index 0000000..83f3067 --- /dev/null +++ b/internal/event/stats.go @@ -0,0 +1,57 @@ +package event + +import ( + "context" + "database/sql" +) + +// IncrementConnectors bumps the unique_connectors count for an event +// by 1. Called when a connection is made with an event ref. Uses +// INSERT OR REPLACE to handle the initial row creation. +func IncrementConnectors(ctx context.Context, db *sql.DB, eventURI string) error { + if eventURI == "" { + return nil + } + _, err := db.ExecContext(ctx, ` + INSERT INTO event_stats (event_uri, unique_connectors, total_checkins, updated_at) + VALUES (?, 1, 0, CURRENT_TIMESTAMP) + ON CONFLICT(event_uri) DO UPDATE SET + unique_connectors = unique_connectors + 1, + updated_at = CURRENT_TIMESTAMP + `, eventURI) + return err +} + +// IncrementCheckins bumps total_checkins for an event. +func IncrementCheckins(ctx context.Context, db *sql.DB, eventURI string) error { + if eventURI == "" { + return nil + } + _, err := db.ExecContext(ctx, ` + INSERT INTO event_stats (event_uri, unique_connectors, total_checkins, updated_at) + VALUES (?, 0, 1, CURRENT_TIMESTAMP) + ON CONFLICT(event_uri) DO UPDATE SET + total_checkins = total_checkins + 1, + updated_at = CURRENT_TIMESTAMP + `, eventURI) + return err +} + +// Stats returns the current stats for an event. Returns zero values if +// no stats row exists. +type Stats struct { + UniqueConnectors int + TotalCheckins int +} + +func GetStats(ctx context.Context, db *sql.DB, eventURI string) (Stats, error) { + var s Stats + err := db.QueryRowContext(ctx, ` + SELECT unique_connectors, total_checkins + FROM event_stats WHERE event_uri = ? + `, eventURI).Scan(&s.UniqueConnectors, &s.TotalCheckins) + if err == sql.ErrNoRows { + return Stats{}, nil + } + return s, err +} diff --git a/internal/interest/match.go b/internal/interest/match.go new file mode 100644 index 0000000..68db254 --- /dev/null +++ b/internal/interest/match.go @@ -0,0 +1,95 @@ +package interest +package interest + +import ( + "context" + + "github.com/bluesky-social/indigo/atproto/syntax" + + "atmoquest/internal/profile" +) + +// Match describes an attendee who shares interests with the viewer. +type Match struct { + DID syntax.DID + DisplayName string + AvatarURL string + SharedInterests []string + Connected bool +} + +// FindMatches returns attendees who share at least one interest with the +// viewer, sorted by number of shared interests (descending). It fetches +// each attendee's quest profile from their PDS to read interests. +func FindMatches( + ctx context.Context, + viewerInterests []string, + attendeeDIDs []string, + viewerDID syntax.DID, + connSet map[string]bool, + pdsLookup func(syntax.DID) string, + avatarFn func(pds string, did syntax.DID) (string, string), // returns (displayName, avatarURL) +) []Match { + if len(viewerInterests) == 0 { + return nil + } + + viewerSet := make(map[string]bool, len(viewerInterests)) + for _, i := range viewerInterests { + viewerSet[i] = true + } + + var matches []Match + for _, didStr := range attendeeDIDs { + if didStr == viewerDID.String() { + continue + } + + did, err := syntax.ParseDID(didStr) + if err != nil { + continue + } + + pds := pdsLookup(did) + quest, err := profile.FetchQuest(ctx, pds, did) + if err != nil || quest == nil || len(quest.Interests) == 0 { + continue + } + + var shared []string + for _, interest := range quest.Interests { + if viewerSet[interest] { + shared = append(shared, interest) + } + } + if len(shared) == 0 { + continue + } + + displayName, avatarURL := avatarFn(pds, did) + + matches = append(matches, Match{ + DID: did, + DisplayName: displayName, + AvatarURL: avatarURL, + SharedInterests: shared, + Connected: connSet[didStr], + }) + } + + // Sort by number of shared interests descending (bubble sort — small N). + for i := 0; i < len(matches); i++ { + for j := i + 1; j < len(matches); j++ { + if len(matches[j].SharedInterests) > len(matches[i].SharedInterests) { + matches[i], matches[j] = matches[j], matches[i] + } + } + } + + // Cap at 20 results. + if len(matches) > 20 { + matches = matches[:20] + } + + return matches +} diff --git a/router/router.go b/router/router.go index 91548ac..be86085 100644 --- a/router/router.go +++ b/router/router.go @@ -19,6 +19,7 @@ import ( "atmoquest/features/connections" "atmoquest/features/demo" "atmoquest/features/event" + "atmoquest/features/events" "atmoquest/features/index" "atmoquest/features/profile" "atmoquest/features/settings" @@ -59,6 +60,7 @@ func SetupRoutes( connect.SetupRoutes(router, conn, authH, connQueue) connections.SetupRoutes(router, conn, authH) event.SetupRoutes(router, conn, authH) + events.SetupRoutes(router, conn, authH) admin.SetupRoutes(router, conn, authH, signer) settings.SetupRoutes(router, conn, authH) about.SetupRoutes(router) diff --git a/web/resources/static/css/terminal.css b/web/resources/static/css/terminal.css index 078f927..24ae62a 100644 --- a/web/resources/static/css/terminal.css +++ b/web/resources/static/css/terminal.css @@ -1835,3 +1835,476 @@ footer a:hover { color: var(--lavender); } .settings-nav { margin: 12px 0 16px; } + +/* ── Events Page ────────────────────────────────── */ +.events-page { + margin: 12px 0; +} +.events-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.event-card-row { + background: var(--surface0); + border: 1px solid var(--surface1); + border-radius: 8px; + padding: 12px 16px; +} +.event-card-ongoing { + border-color: var(--peach); + background: rgba(250, 179, 135, 0.06); +} +.event-card-header { + display: flex; + align-items: center; + gap: 8px; +} +.event-card-name { + font-size: 1rem; + font-weight: 600; + margin: 0; +} +.event-status-live { + font-size: 0.7rem; + color: var(--green); + font-weight: 600; +} +.event-status-ended { + font-size: 0.7rem; + color: var(--subtext0); +} +.event-card-meta { + font-size: 0.8rem; + color: var(--subtext1); + margin: 4px 0 0; +} +.events-empty { + text-align: center; + padding: 24px 0; + color: var(--subtext0); +} + +/* Event progress bar */ +.event-progress { + margin: 10px 0 4px; +} +.event-progress-bar { + position: relative; + height: 6px; + background: var(--surface1); + border-radius: 3px; + overflow: visible; +} +.event-progress-fill { + height: 100%; + background: var(--peach); + border-radius: 3px; + transition: width 0.4s ease; + min-width: 2px; +} +.event-progress-tick { + position: absolute; + top: -3px; + transform: translateX(-50%); +} +.event-progress-tick-dot { + display: block; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--surface1); + border: 2px solid var(--surface0); +} +.event-progress-tick-dot.reached { + background: var(--peach); + border-color: var(--peach); +} +.event-progress-labels { + display: flex; + justify-content: space-between; + margin-top: 6px; + padding: 0 0 0 8%; +} +.event-progress-label { + font-size: 0.6rem; + color: var(--subtext0); +} +.event-progress-label.reached { + color: var(--peach); + font-weight: 600; +} +.event-card-stats { + display: flex; + gap: 16px; + margin-top: 6px; +} +.event-stat { + font-size: 0.7rem; + color: var(--subtext0); +} + +/* ── Bottom Nav ─────────────────────────────────── */ +.bottom-nav { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-around; + align-items: center; + background: var(--crust); + border-top: 1px solid var(--surface0); + padding: 6px 0 calc(6px + env(safe-area-inset-bottom, 0px)); + z-index: 900; +} +.bottom-nav-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + color: var(--subtext0); + text-decoration: none; + font-size: 0.7rem; + padding: 4px 16px; + transition: color 0.15s; +} +.bottom-nav-item:hover, +.bottom-nav-active { + color: var(--peach); +} +.bottom-nav-icon { + font-size: 1.2rem; + line-height: 1; +} +.bottom-nav-label { + font-family: "JetBrains Mono", monospace; +} +/* Add padding to page content so it doesn't sit behind the nav */ +.container { + padding-bottom: 72px; +} + +/* ── Recent Connections Grid (Home) ──────────────── */ +.home-recent-connections { + margin: 16px 0 0; + padding: 12px 0 0; + border-top: 1px solid var(--surface0); +} +.recent-connections-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-top: 8px; +} +.recent-conn-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + text-decoration: none; + color: var(--text); + padding: 6px 2px; + border-radius: 6px; + transition: background 0.15s; +} +.recent-conn-card:hover { + background: var(--surface0); +} +.recent-conn-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--surface1); +} +.recent-conn-avatar-empty { + width: 42px; + height: 42px; + border-radius: 50%; + background: var(--surface0); + display: flex; + align-items: center; + justify-content: center; + color: var(--subtext0); + font-size: 0.9rem; +} +.recent-conn-name { + font-size: 0.65rem; + text-align: center; + max-width: 70px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--subtext1); +} + +/* ── Follow-up pill (Connections list) ───────────── */ +.connection-followup-pill { + display: inline-block; + font-size: 0.7rem; + padding: 1px 8px; + border-radius: 999px; + background: rgba(250, 179, 135, 0.15); + color: var(--peach); + border: 1px solid rgba(250, 179, 135, 0.3); + margin-left: 4px; +} + +/* ── Profile Badges ─────────────────────────────── */ +.profile-badges-grid { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 6px; +} +.profile-badge { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + cursor: default; +} +.profile-badge-icon { + width: 44px; + height: 44px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid; + font-weight: 700; + font-size: 0.75rem; +} +.profile-badge-label { + color: var(--crust); + text-shadow: 0 1px 0 rgba(0,0,0,0.2); +} +.profile-badge-name { + font-size: 0.6rem; + color: var(--subtext0); + text-align: center; + max-width: 64px; + line-height: 1.2; +} + +/* ── Leaderboard ────────────────────────────────── */ +.leaderboard-list { margin: 12px 0; } +.leaderboard-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--surface0); +} +.leaderboard-rank { + font-size: 0.9rem; + font-weight: 700; + color: var(--peach); + min-width: 28px; + text-align: right; +} +.leaderboard-rank.top-3 { font-size: 1.1rem; } +.leaderboard-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; +} +.leaderboard-avatar-empty { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--surface0); + display: flex; + align-items: center; + justify-content: center; + color: var(--subtext0); + font-size: 0.7rem; +} +.leaderboard-name { flex: 1; font-size: 0.85rem; } +.leaderboard-count { font-size: 0.8rem; color: var(--subtext0); } + +/* ── Summary Page ───────────────────────────────── */ +.summary-section { + margin: 16px 0; + padding: 12px 0 0; + border-top: 1px solid var(--surface0); +} +.summary-section-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--subtext0); + margin-bottom: 8px; +} +.summary-stat-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; + margin-bottom: 12px; +} +.summary-stat { + text-align: center; + padding: 8px; + background: var(--surface0); + border-radius: 6px; +} +.summary-stat-value { + font-size: 1.4rem; + font-weight: 700; + color: var(--peach); +} +.summary-stat-label { font-size: 0.65rem; color: var(--subtext0); } +.summary-connection-row { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 0; + border-bottom: 1px solid var(--surface0); + text-decoration: none; + color: var(--text); +} +.summary-connection-row:hover { background: var(--surface0); } +.summary-conn-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; } +.summary-conn-name { flex: 1; font-size: 0.8rem; } +.summary-conn-note { + font-size: 0.7rem; + color: var(--subtext0); + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.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; } +.attendees-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(52px, 1fr)); + gap: 8px; +} +.attendee-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + text-decoration: none; +} +.attendee-avatar { + width: 42px; + height: 42px; + border-radius: 50%; + object-fit: cover; + border: 2px solid transparent; +} +.attendee-avatar.connected { border-color: var(--peach); } +.attendee-avatar.not-connected { filter: grayscale(100%); opacity: 0.6; } +.attendee-name { + font-size: 0.55rem; + color: var(--subtext1); + text-align: center; + max-width: 52px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* ── 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); +} +.unlock-icon { font-size: 1rem; } +.unlock-text { + font-size: 0.75rem; + color: var(--subtext0); + font-style: italic; +} + +/* ── Interest Matching ──────────────────────────── */ +.interest-matches-list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 10px 0; +} +.interest-match-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 12px; + background: var(--surface0); + border-radius: 6px; + border: 1px solid var(--surface1); +} +.interest-match-card.connected { + border-color: var(--peach); + border-style: solid; +} +.interest-match-header { + display: flex; + align-items: center; + gap: 8px; +} +.interest-match-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; +} +.interest-match-name { + flex: 1; + font-size: 0.8rem; + color: var(--text); +} +.interest-match-badge { + font-size: 0.65rem; + color: var(--peach); + background: rgba(250, 179, 135, 0.1); + padding: 2px 6px; + border-radius: 4px; +} +.interest-match-pills { + display: flex; + flex-wrap: wrap; + gap: 4px; +} +.pill-sm { + font-size: 0.6rem !important; + padding: 1px 6px !important; +} + +/* ── Admin Badge Management ─────────────────────── */ +.admin-badges-list { margin: 12px 0; } +.admin-badge-row { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid var(--surface0); +} +.admin-badge-icon { + width: 36px; + height: 36px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.7rem; + border: 2px solid; + flex-shrink: 0; +} +.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); } diff --git a/web/resources/static/js/geofence.js b/web/resources/static/js/geofence.js new file mode 100644 index 0000000..2ff4506 --- /dev/null +++ b/web/resources/static/js/geofence.js @@ -0,0 +1,61 @@ +// geofence.js — soft location check for event check-in pages. +// Shows a non-blocking warning when the user appears to be outside the +// event's geofence radius. Never prevents check-in. +(function () { + "use strict"; + + var el = document.getElementById("geofence-warning"); + if (!el) return; // no geofence configured for this event + + var lat = parseFloat(el.dataset.geoLat); + var lng = parseFloat(el.dataset.geoLng); + var radius = parseInt(el.dataset.geoRadius, 10); + if (isNaN(lat) || isNaN(lng) || isNaN(radius) || radius <= 0) return; + + var msg = document.getElementById("geofence-msg"); + + if (!navigator.geolocation) { + // Browser doesn't support geolocation — skip silently. + return; + } + + el.style.display = ""; + + navigator.geolocation.getCurrentPosition( + function (pos) { + var dist = haversine(lat, lng, pos.coords.latitude, pos.coords.longitude); + if (dist <= radius) { + // User is within the geofence — hide the warning. + el.style.display = "none"; + } else { + var km = (dist / 1000).toFixed(1); + msg.textContent = + "you appear to be ~" + km + " km from the event venue. " + + "you can still check in — this is just a heads-up."; + } + }, + function () { + // User denied or error — hide the warning, don't nag. + el.style.display = "none"; + }, + { enableHighAccuracy: false, timeout: 8000, maximumAge: 60000 } + ); + + // Haversine distance in meters. + function haversine(lat1, lon1, lat2, lon2) { + var R = 6371000; + var dLat = toRad(lat2 - lat1); + var dLon = toRad(lon2 - lon1); + var a = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRad(lat1)) * + Math.cos(toRad(lat2)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + } + + function toRad(deg) { + return (deg * Math.PI) / 180; + } +})();