···12291229 return idx.refCounts(ctx, "social.arabica.alpha.bean", "roasterRef", did)
12301230}
1231123112321232+// RatingStats holds aggregated rating statistics for an entity.
12331233+type RatingStats struct {
12341234+ Average float64
12351235+ Count int
12361236+}
12371237+12381238+// refAvgRatings returns a map of ref URI -> RatingStats for brew records,
12391239+// grouped by the given JSON reference field. If did is non-empty, only brews
12401240+// owned by that DID are included.
12411241+func (idx *FeedIndex) refAvgRatings(ctx context.Context, jsonField, did string) map[string]RatingStats {
12421242+ stats := make(map[string]RatingStats)
12431243+ var rows *sql.Rows
12441244+ var err error
12451245+ if did != "" {
12461246+ rows, err = idx.db.QueryContext(ctx, fmt.Sprintf(`
12471247+ SELECT json_extract(record, '$.%s') as ref_uri,
12481248+ AVG(json_extract(record, '$.rating')) as avg_rating,
12491249+ COUNT(*) as cnt
12501250+ FROM records
12511251+ WHERE collection = 'social.arabica.alpha.brew'
12521252+ AND did = ?
12531253+ AND ref_uri IS NOT NULL AND ref_uri != ''
12541254+ AND json_extract(record, '$.rating') IS NOT NULL
12551255+ GROUP BY ref_uri
12561256+ `, jsonField), did)
12571257+ } else {
12581258+ rows, err = idx.db.QueryContext(ctx, fmt.Sprintf(`
12591259+ SELECT json_extract(record, '$.%s') as ref_uri,
12601260+ AVG(json_extract(record, '$.rating')) as avg_rating,
12611261+ COUNT(*) as cnt
12621262+ FROM records
12631263+ WHERE collection = 'social.arabica.alpha.brew'
12641264+ AND ref_uri IS NOT NULL AND ref_uri != ''
12651265+ AND json_extract(record, '$.rating') IS NOT NULL
12661266+ GROUP BY ref_uri
12671267+ `, jsonField))
12681268+ }
12691269+ if err != nil {
12701270+ return stats
12711271+ }
12721272+ defer rows.Close()
12731273+ for rows.Next() {
12741274+ var uri string
12751275+ var avg float64
12761276+ var count int
12771277+ if err := rows.Scan(&uri, &avg, &count); err == nil {
12781278+ stats[uri] = RatingStats{Average: avg, Count: count}
12791279+ }
12801280+ }
12811281+ return stats
12821282+}
12831283+12841284+// AvgBrewRatingByBeanURI returns a map of bean AT-URI -> RatingStats from brew ratings.
12851285+// If did is non-empty, only brews owned by that DID are included.
12861286+func (idx *FeedIndex) AvgBrewRatingByBeanURI(ctx context.Context, did string) map[string]RatingStats {
12871287+ return idx.refAvgRatings(ctx, "beanRef", did)
12881288+}
12891289+12901290+// AvgBrewRatingByRoasterURI returns a map of roaster AT-URI -> RatingStats,
12911291+// aggregated from brew ratings through the bean's roaster reference.
12921292+// If did is non-empty, only brews owned by that DID are included.
12931293+func (idx *FeedIndex) AvgBrewRatingByRoasterURI(ctx context.Context, did string) map[string]RatingStats {
12941294+ stats := make(map[string]RatingStats)
12951295+ var rows *sql.Rows
12961296+ var err error
12971297+ if did != "" {
12981298+ rows, err = idx.db.QueryContext(ctx, `
12991299+ SELECT json_extract(beans.record, '$.roasterRef') as roaster_uri,
13001300+ AVG(json_extract(brews.record, '$.rating')) as avg_rating,
13011301+ COUNT(*) as cnt
13021302+ FROM records brews
13031303+ JOIN records beans
13041304+ ON beans.uri = json_extract(brews.record, '$.beanRef')
13051305+ AND beans.collection = 'social.arabica.alpha.bean'
13061306+ WHERE brews.collection = 'social.arabica.alpha.brew'
13071307+ AND brews.did = ?
13081308+ AND json_extract(brews.record, '$.rating') IS NOT NULL
13091309+ AND roaster_uri IS NOT NULL AND roaster_uri != ''
13101310+ GROUP BY roaster_uri
13111311+ `, did)
13121312+ } else {
13131313+ rows, err = idx.db.QueryContext(ctx, `
13141314+ SELECT json_extract(beans.record, '$.roasterRef') as roaster_uri,
13151315+ AVG(json_extract(brews.record, '$.rating')) as avg_rating,
13161316+ COUNT(*) as cnt
13171317+ FROM records brews
13181318+ JOIN records beans
13191319+ ON beans.uri = json_extract(brews.record, '$.beanRef')
13201320+ AND beans.collection = 'social.arabica.alpha.bean'
13211321+ WHERE brews.collection = 'social.arabica.alpha.brew'
13221322+ AND json_extract(brews.record, '$.rating') IS NOT NULL
13231323+ AND roaster_uri IS NOT NULL AND roaster_uri != ''
13241324+ GROUP BY roaster_uri
13251325+ `)
13261326+ }
13271327+ if err != nil {
13281328+ return stats
13291329+ }
13301330+ defer rows.Close()
13311331+ for rows.Next() {
13321332+ var uri string
13331333+ var avg float64
13341334+ var count int
13351335+ if err := rows.Scan(&uri, &avg, &count); err == nil {
13361336+ stats[uri] = RatingStats{Average: avg, Count: count}
13371337+ }
13381338+ }
13391339+ return stats
13401340+}
13411341+12321342func formatTimeAgo(t time.Time) string {
12331343 now := time.Now()
12341344 diff := now.Sub(t)
···6969 return fmt.Sprintf("%d/10", *rating)
7070}
71717272+// FormatAvgRating formats an average rating as "X.X/10".
7373+// Returns empty string if avg is zero (no ratings).
7474+func FormatAvgRating(avg float64) string {
7575+ if avg == 0 {
7676+ return ""
7777+ }
7878+ return fmt.Sprintf("%.1f/10", avg)
7979+}
8080+7281// PoursToJSON serializes a slice of pours to JSON for use in JavaScript.
7382func PoursToJSON(pours []*models.Pour) string {
7483 if len(pours) == 0 {
+50-24
internal/web/components/entity_tables.templ
···1515 return counts[atproto.BuildATURI(ownerDID, nsid, rkey)]
1616}
17171818+// entityAvgRating looks up an average rating for an entity by building its AT-URI.
1919+func entityAvgRating(ratings map[string]float64, ownerDID, nsid, rkey string) float64 {
2020+ if ratings == nil || ownerDID == "" {
2121+ return 0
2222+ }
2323+ return ratings[atproto.BuildATURI(ownerDID, nsid, rkey)]
2424+}
2525+1826// BeanCardsProps defines props for the bean cards grid
1927type BeanCardsProps struct {
2020- Beans []*models.Bean
2121- ShowActions bool // Whether to show Edit/Delete actions
2222- OwnerHandle string // If set, name links to view page with this owner
2323- BrewCounts map[string]int // bean AT-URI -> brew count (optional)
2424- OwnerDID string // DID of the entity owner (for count lookups)
2828+ Beans []*models.Bean
2929+ ShowActions bool // Whether to show Edit/Delete actions
3030+ OwnerHandle string // If set, name links to view page with this owner
3131+ BrewCounts map[string]int // bean AT-URI -> brew count (optional)
3232+ AvgBrewRatings map[string]float64 // bean AT-URI -> avg brew rating (optional)
3333+ OwnerDID string // DID of the entity owner (for count lookups)
2534}
26352736// BeanCards renders a grid of bean cards
···3140 } else {
3241 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
3342 for _, bean := range props.Beans {
3434- @BeanCard(bean, props.ShowActions, props.OwnerHandle, entityCount(props.BrewCounts, props.OwnerDID, atproto.NSIDBean, bean.RKey))
4343+ @BeanCard(bean, props.ShowActions, props.OwnerHandle, entityCount(props.BrewCounts, props.OwnerDID, atproto.NSIDBean, bean.RKey), entityAvgRating(props.AvgBrewRatings, props.OwnerDID, atproto.NSIDBean, bean.RKey))
3544 }
3645 </div>
3746 }
3847}
39484049// BeanCard renders a single bean as a compact card
4141-templ BeanCard(bean *models.Bean, showActions bool, ownerHandle string, brewCount int) {
5050+templ BeanCard(bean *models.Bean, showActions bool, ownerHandle string, brewCount int, avgBrewRating float64) {
4251 <div class="feed-card feed-card-bean">
4352 <div class="feed-content-box-sm">
4453 <div class="flex items-start justify-between gap-2 mb-2">
···112121 if bean.Description != "" {
113122 <div class="mt-2 text-sm text-brown-800 italic line-clamp-2">"{ bean.Description }"</div>
114123 }
115115- if brewCount > 0 {
124124+ if brewCount > 0 || avgBrewRating > 0 {
116125 <div class="flex items-center gap-3 pt-2 mt-2 border-t border-brown-200/60 text-xs text-brown-500">
117117- <span class="flex items-center gap-1">
118118- @IconCoffee()
119119- { fmt.Sprintf("%d brew%s", brewCount, entityPluralS(brewCount)) }
120120- </span>
126126+ if brewCount > 0 {
127127+ <span class="flex items-center gap-1">
128128+ @IconCoffee()
129129+ { fmt.Sprintf("%d brew%s", brewCount, entityPluralS(brewCount)) }
130130+ </span>
131131+ }
132132+ if avgBrewRating > 0 {
133133+ <span class="flex items-center gap-1">
134134+ @IconStar()
135135+ { bff.FormatAvgRating(avgBrewRating) } avg
136136+ </span>
137137+ }
121138 </div>
122139 }
123140 </div>
···134151135152// RoastersTableProps defines props for the shared roasters display
136153type RoastersTableProps struct {
137137- Roasters []*models.Roaster
138138- ShowActions bool // Whether to show Edit/Delete actions
139139- OwnerHandle string // If set, name links to view page with this owner
140140- BeanCounts map[string]int // roaster AT-URI -> bean count (optional)
141141- OwnerDID string // DID of the entity owner (for count lookups)
154154+ Roasters []*models.Roaster
155155+ ShowActions bool // Whether to show Edit/Delete actions
156156+ OwnerHandle string // If set, name links to view page with this owner
157157+ BeanCounts map[string]int // roaster AT-URI -> bean count (optional)
158158+ AvgBrewRatings map[string]float64 // roaster AT-URI -> avg brew rating (optional)
159159+ OwnerDID string // DID of the entity owner (for count lookups)
142160}
143161144162// RoastersTable renders a grid of roaster cards
···148166 } else {
149167 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
150168 for _, roaster := range props.Roasters {
151151- @RoasterCard(roaster, props.ShowActions, props.OwnerHandle, entityCount(props.BeanCounts, props.OwnerDID, atproto.NSIDRoaster, roaster.RKey))
169169+ @RoasterCard(roaster, props.ShowActions, props.OwnerHandle, entityCount(props.BeanCounts, props.OwnerDID, atproto.NSIDRoaster, roaster.RKey), entityAvgRating(props.AvgBrewRatings, props.OwnerDID, atproto.NSIDRoaster, roaster.RKey))
152170 }
153171 </div>
154172 }
155173}
156174157175// RoasterCard renders a single roaster as a compact card
158158-templ RoasterCard(roaster *models.Roaster, showActions bool, ownerHandle string, beanCount int) {
176176+templ RoasterCard(roaster *models.Roaster, showActions bool, ownerHandle string, beanCount int, avgBrewRating float64) {
159177 <div class="feed-card feed-card-roaster">
160178 <div class="feed-content-box-sm">
161179 <div class="flex items-start justify-between gap-2 mb-2">
···204222 </span>
205223 }
206224 </div>
207207- if beanCount > 0 {
225225+ if beanCount > 0 || avgBrewRating > 0 {
208226 <div class="flex items-center gap-3 pt-2 mt-2 border-t border-brown-200/60 text-xs text-brown-500">
209209- <span class="flex items-center gap-1">
210210- @IconLeaf()
211211- { fmt.Sprintf("%d bean%s", beanCount, entityPluralS(beanCount)) }
212212- </span>
227227+ if beanCount > 0 {
228228+ <span class="flex items-center gap-1">
229229+ @IconLeaf()
230230+ { fmt.Sprintf("%d bean%s", beanCount, entityPluralS(beanCount)) }
231231+ </span>
232232+ }
233233+ if avgBrewRating > 0 {
234234+ <span class="flex items-center gap-1">
235235+ @IconStar()
236236+ { bff.FormatAvgRating(avgBrewRating) } avg
237237+ </span>
238238+ }
213239 </div>
214240 }
215241 </div>