Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee
17
fork

Configure Feed

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

feat: average rating across beans/roasters

authored by

Patrick Dewey and committed by tangled.org 045769ec 400a27d0

+400 -59
+110
internal/firehose/index.go
··· 1229 1229 return idx.refCounts(ctx, "social.arabica.alpha.bean", "roasterRef", did) 1230 1230 } 1231 1231 1232 + // RatingStats holds aggregated rating statistics for an entity. 1233 + type RatingStats struct { 1234 + Average float64 1235 + Count int 1236 + } 1237 + 1238 + // refAvgRatings returns a map of ref URI -> RatingStats for brew records, 1239 + // grouped by the given JSON reference field. If did is non-empty, only brews 1240 + // owned by that DID are included. 1241 + func (idx *FeedIndex) refAvgRatings(ctx context.Context, jsonField, did string) map[string]RatingStats { 1242 + stats := make(map[string]RatingStats) 1243 + var rows *sql.Rows 1244 + var err error 1245 + if did != "" { 1246 + rows, err = idx.db.QueryContext(ctx, fmt.Sprintf(` 1247 + SELECT json_extract(record, '$.%s') as ref_uri, 1248 + AVG(json_extract(record, '$.rating')) as avg_rating, 1249 + COUNT(*) as cnt 1250 + FROM records 1251 + WHERE collection = 'social.arabica.alpha.brew' 1252 + AND did = ? 1253 + AND ref_uri IS NOT NULL AND ref_uri != '' 1254 + AND json_extract(record, '$.rating') IS NOT NULL 1255 + GROUP BY ref_uri 1256 + `, jsonField), did) 1257 + } else { 1258 + rows, err = idx.db.QueryContext(ctx, fmt.Sprintf(` 1259 + SELECT json_extract(record, '$.%s') as ref_uri, 1260 + AVG(json_extract(record, '$.rating')) as avg_rating, 1261 + COUNT(*) as cnt 1262 + FROM records 1263 + WHERE collection = 'social.arabica.alpha.brew' 1264 + AND ref_uri IS NOT NULL AND ref_uri != '' 1265 + AND json_extract(record, '$.rating') IS NOT NULL 1266 + GROUP BY ref_uri 1267 + `, jsonField)) 1268 + } 1269 + if err != nil { 1270 + return stats 1271 + } 1272 + defer rows.Close() 1273 + for rows.Next() { 1274 + var uri string 1275 + var avg float64 1276 + var count int 1277 + if err := rows.Scan(&uri, &avg, &count); err == nil { 1278 + stats[uri] = RatingStats{Average: avg, Count: count} 1279 + } 1280 + } 1281 + return stats 1282 + } 1283 + 1284 + // AvgBrewRatingByBeanURI returns a map of bean AT-URI -> RatingStats from brew ratings. 1285 + // If did is non-empty, only brews owned by that DID are included. 1286 + func (idx *FeedIndex) AvgBrewRatingByBeanURI(ctx context.Context, did string) map[string]RatingStats { 1287 + return idx.refAvgRatings(ctx, "beanRef", did) 1288 + } 1289 + 1290 + // AvgBrewRatingByRoasterURI returns a map of roaster AT-URI -> RatingStats, 1291 + // aggregated from brew ratings through the bean's roaster reference. 1292 + // If did is non-empty, only brews owned by that DID are included. 1293 + func (idx *FeedIndex) AvgBrewRatingByRoasterURI(ctx context.Context, did string) map[string]RatingStats { 1294 + stats := make(map[string]RatingStats) 1295 + var rows *sql.Rows 1296 + var err error 1297 + if did != "" { 1298 + rows, err = idx.db.QueryContext(ctx, ` 1299 + SELECT json_extract(beans.record, '$.roasterRef') as roaster_uri, 1300 + AVG(json_extract(brews.record, '$.rating')) as avg_rating, 1301 + COUNT(*) as cnt 1302 + FROM records brews 1303 + JOIN records beans 1304 + ON beans.uri = json_extract(brews.record, '$.beanRef') 1305 + AND beans.collection = 'social.arabica.alpha.bean' 1306 + WHERE brews.collection = 'social.arabica.alpha.brew' 1307 + AND brews.did = ? 1308 + AND json_extract(brews.record, '$.rating') IS NOT NULL 1309 + AND roaster_uri IS NOT NULL AND roaster_uri != '' 1310 + GROUP BY roaster_uri 1311 + `, did) 1312 + } else { 1313 + rows, err = idx.db.QueryContext(ctx, ` 1314 + SELECT json_extract(beans.record, '$.roasterRef') as roaster_uri, 1315 + AVG(json_extract(brews.record, '$.rating')) as avg_rating, 1316 + COUNT(*) as cnt 1317 + FROM records brews 1318 + JOIN records beans 1319 + ON beans.uri = json_extract(brews.record, '$.beanRef') 1320 + AND beans.collection = 'social.arabica.alpha.bean' 1321 + WHERE brews.collection = 'social.arabica.alpha.brew' 1322 + AND json_extract(brews.record, '$.rating') IS NOT NULL 1323 + AND roaster_uri IS NOT NULL AND roaster_uri != '' 1324 + GROUP BY roaster_uri 1325 + `) 1326 + } 1327 + if err != nil { 1328 + return stats 1329 + } 1330 + defer rows.Close() 1331 + for rows.Next() { 1332 + var uri string 1333 + var avg float64 1334 + var count int 1335 + if err := rows.Scan(&uri, &avg, &count); err == nil { 1336 + stats[uri] = RatingStats{Average: avg, Count: count} 1337 + } 1338 + } 1339 + return stats 1340 + } 1341 + 1232 1342 func formatTimeAgo(t time.Time) string { 1233 1343 now := time.Now() 1234 1344 diff := now.Sub(t)
+173
internal/firehose/index_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "fmt" 5 6 "testing" 6 7 "time" 7 8 ··· 224 225 225 226 assert.Equal(t, "replyB1", comments[3].RKey) 226 227 assert.Equal(t, 1, comments[3].Depth) 228 + } 229 + 230 + func TestAvgBrewRatingByBeanURI(t *testing.T) { 231 + tmpDir := t.TempDir() 232 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 233 + assert.NoError(t, err) 234 + defer idx.Close() 235 + 236 + ctx := context.Background() 237 + did := "did:plc:user1" 238 + now := time.Now().Unix() 239 + beanURI := "at://did:plc:user1/social.arabica.alpha.bean/bean1" 240 + 241 + // Insert brews with ratings referencing the same bean 242 + for i, rating := range []int{7, 8, 9} { 243 + record := []byte(`{"$type":"social.arabica.alpha.brew","beanRef":"` + beanURI + `","rating":` + fmt.Sprintf("%d", rating) + `,"createdAt":"2025-01-0` + fmt.Sprintf("%d", i+1) + `T00:00:00Z"}`) 244 + err := idx.UpsertRecord(ctx, did, "social.arabica.alpha.brew", fmt.Sprintf("brew%d", i), "cid", record, now) 245 + assert.NoError(t, err) 246 + } 247 + 248 + // Per-user average 249 + stats := idx.AvgBrewRatingByBeanURI(ctx, did) 250 + assert.Len(t, stats, 1) 251 + assert.Equal(t, 3, stats[beanURI].Count) 252 + assert.InDelta(t, 8.0, stats[beanURI].Average, 0.01) 253 + 254 + // Cross-user average (empty DID) 255 + stats = idx.AvgBrewRatingByBeanURI(ctx, "") 256 + assert.Len(t, stats, 1) 257 + assert.Equal(t, 3, stats[beanURI].Count) 258 + assert.InDelta(t, 8.0, stats[beanURI].Average, 0.01) 259 + } 260 + 261 + func TestAvgBrewRatingByBeanURI_MultipleBeansAndUsers(t *testing.T) { 262 + tmpDir := t.TempDir() 263 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 264 + assert.NoError(t, err) 265 + defer idx.Close() 266 + 267 + ctx := context.Background() 268 + now := time.Now().Unix() 269 + bean1 := "at://did:plc:user1/social.arabica.alpha.bean/bean1" 270 + bean2 := "at://did:plc:user1/social.arabica.alpha.bean/bean2" 271 + 272 + // User1 rates bean1: 6, 8 273 + for i, rating := range []int{6, 8} { 274 + record := []byte(fmt.Sprintf(`{"$type":"social.arabica.alpha.brew","beanRef":"%s","rating":%d,"createdAt":"2025-01-01T00:00:00Z"}`, bean1, rating)) 275 + assert.NoError(t, idx.UpsertRecord(ctx, "did:plc:user1", "social.arabica.alpha.brew", fmt.Sprintf("u1b1_%d", i), "cid", record, now)) 276 + } 277 + 278 + // User2 rates bean1: 10 279 + record := []byte(fmt.Sprintf(`{"$type":"social.arabica.alpha.brew","beanRef":"%s","rating":10,"createdAt":"2025-01-01T00:00:00Z"}`, bean1)) 280 + assert.NoError(t, idx.UpsertRecord(ctx, "did:plc:user2", "social.arabica.alpha.brew", "u2b1_0", "cid", record, now)) 281 + 282 + // User1 rates bean2: 4 283 + record = []byte(fmt.Sprintf(`{"$type":"social.arabica.alpha.brew","beanRef":"%s","rating":4,"createdAt":"2025-01-01T00:00:00Z"}`, bean2)) 284 + assert.NoError(t, idx.UpsertRecord(ctx, "did:plc:user1", "social.arabica.alpha.brew", "u1b2_0", "cid", record, now)) 285 + 286 + // Per-user1: bean1 avg=7, bean2 avg=4 287 + stats := idx.AvgBrewRatingByBeanURI(ctx, "did:plc:user1") 288 + assert.Len(t, stats, 2) 289 + assert.InDelta(t, 7.0, stats[bean1].Average, 0.01) 290 + assert.Equal(t, 2, stats[bean1].Count) 291 + assert.InDelta(t, 4.0, stats[bean2].Average, 0.01) 292 + assert.Equal(t, 1, stats[bean2].Count) 293 + 294 + // Cross-user: bean1 avg=(6+8+10)/3=8, bean2 avg=4 295 + stats = idx.AvgBrewRatingByBeanURI(ctx, "") 296 + assert.Len(t, stats, 2) 297 + assert.InDelta(t, 8.0, stats[bean1].Average, 0.01) 298 + assert.Equal(t, 3, stats[bean1].Count) 299 + assert.InDelta(t, 4.0, stats[bean2].Average, 0.01) 300 + } 301 + 302 + func TestAvgBrewRatingByBeanURI_SkipsBrewsWithoutRating(t *testing.T) { 303 + tmpDir := t.TempDir() 304 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 305 + assert.NoError(t, err) 306 + defer idx.Close() 307 + 308 + ctx := context.Background() 309 + now := time.Now().Unix() 310 + beanURI := "at://did:plc:user1/social.arabica.alpha.bean/bean1" 311 + 312 + // Brew with rating 313 + record := []byte(fmt.Sprintf(`{"$type":"social.arabica.alpha.brew","beanRef":"%s","rating":7,"createdAt":"2025-01-01T00:00:00Z"}`, beanURI)) 314 + assert.NoError(t, idx.UpsertRecord(ctx, "did:plc:user1", "social.arabica.alpha.brew", "brew1", "cid", record, now)) 315 + 316 + // Brew without rating 317 + record = []byte(fmt.Sprintf(`{"$type":"social.arabica.alpha.brew","beanRef":"%s","createdAt":"2025-01-02T00:00:00Z"}`, beanURI)) 318 + assert.NoError(t, idx.UpsertRecord(ctx, "did:plc:user1", "social.arabica.alpha.brew", "brew2", "cid", record, now)) 319 + 320 + stats := idx.AvgBrewRatingByBeanURI(ctx, "") 321 + assert.Len(t, stats, 1) 322 + assert.Equal(t, 1, stats[beanURI].Count) 323 + assert.InDelta(t, 7.0, stats[beanURI].Average, 0.01) 324 + } 325 + 326 + func TestAvgBrewRatingByRoasterURI(t *testing.T) { 327 + tmpDir := t.TempDir() 328 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 329 + assert.NoError(t, err) 330 + defer idx.Close() 331 + 332 + ctx := context.Background() 333 + did := "did:plc:user1" 334 + now := time.Now().Unix() 335 + beanURI := "at://did:plc:user1/social.arabica.alpha.bean/bean1" 336 + roasterURI := "at://did:plc:user1/social.arabica.alpha.roaster/roaster1" 337 + 338 + // Insert the bean record with roaster reference 339 + beanRecord := []byte(fmt.Sprintf(`{"$type":"social.arabica.alpha.bean","name":"Ethiopia Yirgacheffe","roasterRef":"%s","createdAt":"2025-01-01T00:00:00Z"}`, roasterURI)) 340 + assert.NoError(t, idx.UpsertRecord(ctx, did, "social.arabica.alpha.bean", "bean1", "cid", beanRecord, now)) 341 + 342 + // Insert brews referencing that bean with ratings 343 + for i, rating := range []int{6, 8, 10} { 344 + record := []byte(fmt.Sprintf(`{"$type":"social.arabica.alpha.brew","beanRef":"%s","rating":%d,"createdAt":"2025-01-0%dT00:00:00Z"}`, beanURI, rating, i+1)) 345 + assert.NoError(t, idx.UpsertRecord(ctx, did, "social.arabica.alpha.brew", fmt.Sprintf("brew%d", i), "cid", record, now)) 346 + } 347 + 348 + // Per-user average for roaster 349 + stats := idx.AvgBrewRatingByRoasterURI(ctx, did) 350 + assert.Len(t, stats, 1) 351 + assert.Equal(t, 3, stats[roasterURI].Count) 352 + assert.InDelta(t, 8.0, stats[roasterURI].Average, 0.01) 353 + 354 + // Cross-user 355 + stats = idx.AvgBrewRatingByRoasterURI(ctx, "") 356 + assert.Len(t, stats, 1) 357 + assert.Equal(t, 3, stats[roasterURI].Count) 358 + assert.InDelta(t, 8.0, stats[roasterURI].Average, 0.01) 359 + } 360 + 361 + func TestAvgBrewRatingByRoasterURI_MultipleBeansSameRoaster(t *testing.T) { 362 + tmpDir := t.TempDir() 363 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 364 + assert.NoError(t, err) 365 + defer idx.Close() 366 + 367 + ctx := context.Background() 368 + did := "did:plc:user1" 369 + now := time.Now().Unix() 370 + roasterURI := "at://did:plc:user1/social.arabica.alpha.roaster/roaster1" 371 + bean1URI := "at://did:plc:user1/social.arabica.alpha.bean/bean1" 372 + bean2URI := "at://did:plc:user1/social.arabica.alpha.bean/bean2" 373 + 374 + // Two beans from the same roaster 375 + for _, b := range []struct{ uri, rkey string }{{bean1URI, "bean1"}, {bean2URI, "bean2"}} { 376 + record := []byte(fmt.Sprintf(`{"$type":"social.arabica.alpha.bean","name":"Bean","roasterRef":"%s","createdAt":"2025-01-01T00:00:00Z"}`, roasterURI)) 377 + assert.NoError(t, idx.UpsertRecord(ctx, did, "social.arabica.alpha.bean", b.rkey, "cid", record, now)) 378 + } 379 + 380 + // Brews: bean1 rated 6, bean2 rated 10 381 + record := []byte(fmt.Sprintf(`{"$type":"social.arabica.alpha.brew","beanRef":"%s","rating":6,"createdAt":"2025-01-01T00:00:00Z"}`, bean1URI)) 382 + assert.NoError(t, idx.UpsertRecord(ctx, did, "social.arabica.alpha.brew", "brew1", "cid", record, now)) 383 + record = []byte(fmt.Sprintf(`{"$type":"social.arabica.alpha.brew","beanRef":"%s","rating":10,"createdAt":"2025-01-01T00:00:00Z"}`, bean2URI)) 384 + assert.NoError(t, idx.UpsertRecord(ctx, did, "social.arabica.alpha.brew", "brew2", "cid", record, now)) 385 + 386 + stats := idx.AvgBrewRatingByRoasterURI(ctx, "") 387 + assert.Len(t, stats, 1) 388 + assert.Equal(t, 2, stats[roasterURI].Count) 389 + assert.InDelta(t, 8.0, stats[roasterURI].Average, 0.01) 390 + } 391 + 392 + func TestAvgBrewRatingByBeanURI_Empty(t *testing.T) { 393 + tmpDir := t.TempDir() 394 + idx, err := NewFeedIndex(tmpDir+"/test.db", 1*time.Hour) 395 + assert.NoError(t, err) 396 + defer idx.Close() 397 + 398 + stats := idx.AvgBrewRatingByBeanURI(context.Background(), "") 399 + assert.Empty(t, stats) 227 400 } 228 401 229 402 func TestDeleteRecord(t *testing.T) {
+18 -5
internal/handlers/profile.go
··· 601 601 brewLikedByUser := make(map[string]bool) 602 602 brewCIDs := make(map[string]string) 603 603 var beanBrewCounts, grinderBrewCounts, brewerBrewCounts, roasterBeanCounts map[string]int 604 + var beanAvgBrewRatings, roasterAvgBrewRatings map[string]float64 604 605 if h.feedIndex != nil && profile != nil { 605 606 // Collect all brew URIs for batch lookup 606 607 brewURIs := make([]string, 0, len(profileData.Brews)) ··· 634 635 grinderBrewCounts = h.feedIndex.BrewCountsByGrinderURI(ctx, did) 635 636 brewerBrewCounts = h.feedIndex.BrewCountsByBrewerURI(ctx, did) 636 637 roasterBeanCounts = h.feedIndex.BeanCountsByRoasterURI(ctx, did) 638 + 639 + // Average brew ratings 640 + beanAvgBrewRatings = make(map[string]float64) 641 + for uri, stats := range h.feedIndex.AvgBrewRatingByBeanURI(ctx, did) { 642 + beanAvgBrewRatings[uri] = stats.Average 643 + } 644 + roasterAvgBrewRatings = make(map[string]float64) 645 + for uri, stats := range h.feedIndex.AvgBrewRatingByRoasterURI(ctx, did) { 646 + roasterAvgBrewRatings[uri] = stats.Average 647 + } 637 648 } 638 649 639 650 if err := components.ProfileContentPartial(components.ProfileContentPartialProps{ ··· 649 660 BrewLikedByUser: brewLikedByUser, 650 661 BrewCIDs: brewCIDs, 651 662 IsAuthenticated: isAuthenticated, 652 - BeanBrewCounts: beanBrewCounts, 653 - GrinderBrewCounts: grinderBrewCounts, 654 - BrewerBrewCounts: brewerBrewCounts, 655 - RoasterBeanCounts: roasterBeanCounts, 656 - ProfileDID: did, 663 + BeanBrewCounts: beanBrewCounts, 664 + GrinderBrewCounts: grinderBrewCounts, 665 + BrewerBrewCounts: brewerBrewCounts, 666 + RoasterBeanCounts: roasterBeanCounts, 667 + BeanAvgBrewRatings: beanAvgBrewRatings, 668 + RoasterAvgBrewRatings: roasterAvgBrewRatings, 669 + ProfileDID: did, 657 670 }).Render(r.Context(), w); err != nil { 658 671 http.Error(w, "Failed to render content", http.StatusInternalServerError) 659 672 log.Error().Err(err).Msg("Failed to render profile partial")
+9
internal/web/bff/helpers.go
··· 69 69 return fmt.Sprintf("%d/10", *rating) 70 70 } 71 71 72 + // FormatAvgRating formats an average rating as "X.X/10". 73 + // Returns empty string if avg is zero (no ratings). 74 + func FormatAvgRating(avg float64) string { 75 + if avg == 0 { 76 + return "" 77 + } 78 + return fmt.Sprintf("%.1f/10", avg) 79 + } 80 + 72 81 // PoursToJSON serializes a slice of pours to JSON for use in JavaScript. 73 82 func PoursToJSON(pours []*models.Pour) string { 74 83 if len(pours) == 0 {
+50 -24
internal/web/components/entity_tables.templ
··· 15 15 return counts[atproto.BuildATURI(ownerDID, nsid, rkey)] 16 16 } 17 17 18 + // entityAvgRating looks up an average rating for an entity by building its AT-URI. 19 + func entityAvgRating(ratings map[string]float64, ownerDID, nsid, rkey string) float64 { 20 + if ratings == nil || ownerDID == "" { 21 + return 0 22 + } 23 + return ratings[atproto.BuildATURI(ownerDID, nsid, rkey)] 24 + } 25 + 18 26 // BeanCardsProps defines props for the bean cards grid 19 27 type BeanCardsProps struct { 20 - Beans []*models.Bean 21 - ShowActions bool // Whether to show Edit/Delete actions 22 - OwnerHandle string // If set, name links to view page with this owner 23 - BrewCounts map[string]int // bean AT-URI -> brew count (optional) 24 - OwnerDID string // DID of the entity owner (for count lookups) 28 + Beans []*models.Bean 29 + ShowActions bool // Whether to show Edit/Delete actions 30 + OwnerHandle string // If set, name links to view page with this owner 31 + BrewCounts map[string]int // bean AT-URI -> brew count (optional) 32 + AvgBrewRatings map[string]float64 // bean AT-URI -> avg brew rating (optional) 33 + OwnerDID string // DID of the entity owner (for count lookups) 25 34 } 26 35 27 36 // BeanCards renders a grid of bean cards ··· 31 40 } else { 32 41 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 33 42 for _, bean := range props.Beans { 34 - @BeanCard(bean, props.ShowActions, props.OwnerHandle, entityCount(props.BrewCounts, props.OwnerDID, atproto.NSIDBean, bean.RKey)) 43 + @BeanCard(bean, props.ShowActions, props.OwnerHandle, entityCount(props.BrewCounts, props.OwnerDID, atproto.NSIDBean, bean.RKey), entityAvgRating(props.AvgBrewRatings, props.OwnerDID, atproto.NSIDBean, bean.RKey)) 35 44 } 36 45 </div> 37 46 } 38 47 } 39 48 40 49 // BeanCard renders a single bean as a compact card 41 - templ BeanCard(bean *models.Bean, showActions bool, ownerHandle string, brewCount int) { 50 + templ BeanCard(bean *models.Bean, showActions bool, ownerHandle string, brewCount int, avgBrewRating float64) { 42 51 <div class="feed-card feed-card-bean"> 43 52 <div class="feed-content-box-sm"> 44 53 <div class="flex items-start justify-between gap-2 mb-2"> ··· 112 121 if bean.Description != "" { 113 122 <div class="mt-2 text-sm text-brown-800 italic line-clamp-2">"{ bean.Description }"</div> 114 123 } 115 - if brewCount > 0 { 124 + if brewCount > 0 || avgBrewRating > 0 { 116 125 <div class="flex items-center gap-3 pt-2 mt-2 border-t border-brown-200/60 text-xs text-brown-500"> 117 - <span class="flex items-center gap-1"> 118 - @IconCoffee() 119 - { fmt.Sprintf("%d brew%s", brewCount, entityPluralS(brewCount)) } 120 - </span> 126 + if brewCount > 0 { 127 + <span class="flex items-center gap-1"> 128 + @IconCoffee() 129 + { fmt.Sprintf("%d brew%s", brewCount, entityPluralS(brewCount)) } 130 + </span> 131 + } 132 + if avgBrewRating > 0 { 133 + <span class="flex items-center gap-1"> 134 + @IconStar() 135 + { bff.FormatAvgRating(avgBrewRating) } avg 136 + </span> 137 + } 121 138 </div> 122 139 } 123 140 </div> ··· 134 151 135 152 // RoastersTableProps defines props for the shared roasters display 136 153 type RoastersTableProps struct { 137 - Roasters []*models.Roaster 138 - ShowActions bool // Whether to show Edit/Delete actions 139 - OwnerHandle string // If set, name links to view page with this owner 140 - BeanCounts map[string]int // roaster AT-URI -> bean count (optional) 141 - OwnerDID string // DID of the entity owner (for count lookups) 154 + Roasters []*models.Roaster 155 + ShowActions bool // Whether to show Edit/Delete actions 156 + OwnerHandle string // If set, name links to view page with this owner 157 + BeanCounts map[string]int // roaster AT-URI -> bean count (optional) 158 + AvgBrewRatings map[string]float64 // roaster AT-URI -> avg brew rating (optional) 159 + OwnerDID string // DID of the entity owner (for count lookups) 142 160 } 143 161 144 162 // RoastersTable renders a grid of roaster cards ··· 148 166 } else { 149 167 <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> 150 168 for _, roaster := range props.Roasters { 151 - @RoasterCard(roaster, props.ShowActions, props.OwnerHandle, entityCount(props.BeanCounts, props.OwnerDID, atproto.NSIDRoaster, roaster.RKey)) 169 + @RoasterCard(roaster, props.ShowActions, props.OwnerHandle, entityCount(props.BeanCounts, props.OwnerDID, atproto.NSIDRoaster, roaster.RKey), entityAvgRating(props.AvgBrewRatings, props.OwnerDID, atproto.NSIDRoaster, roaster.RKey)) 152 170 } 153 171 </div> 154 172 } 155 173 } 156 174 157 175 // RoasterCard renders a single roaster as a compact card 158 - templ RoasterCard(roaster *models.Roaster, showActions bool, ownerHandle string, beanCount int) { 176 + templ RoasterCard(roaster *models.Roaster, showActions bool, ownerHandle string, beanCount int, avgBrewRating float64) { 159 177 <div class="feed-card feed-card-roaster"> 160 178 <div class="feed-content-box-sm"> 161 179 <div class="flex items-start justify-between gap-2 mb-2"> ··· 204 222 </span> 205 223 } 206 224 </div> 207 - if beanCount > 0 { 225 + if beanCount > 0 || avgBrewRating > 0 { 208 226 <div class="flex items-center gap-3 pt-2 mt-2 border-t border-brown-200/60 text-xs text-brown-500"> 209 - <span class="flex items-center gap-1"> 210 - @IconLeaf() 211 - { fmt.Sprintf("%d bean%s", beanCount, entityPluralS(beanCount)) } 212 - </span> 227 + if beanCount > 0 { 228 + <span class="flex items-center gap-1"> 229 + @IconLeaf() 230 + { fmt.Sprintf("%d bean%s", beanCount, entityPluralS(beanCount)) } 231 + </span> 232 + } 233 + if avgBrewRating > 0 { 234 + <span class="flex items-center gap-1"> 235 + @IconStar() 236 + { bff.FormatAvgRating(avgBrewRating) } avg 237 + </span> 238 + } 213 239 </div> 214 240 } 215 241 </div>
+40 -30
internal/web/components/profile_partial.templ
··· 28 28 GrinderBrewCounts map[string]int // grinder URI -> brew count 29 29 BrewerBrewCounts map[string]int // brewer URI -> brew count 30 30 RoasterBeanCounts map[string]int // roaster URI -> bean count 31 - ProfileDID string // DID of the profile being viewed 31 + // Average brew ratings (keyed by AT-URI) 32 + BeanAvgBrewRatings map[string]float64 // bean URI -> avg brew rating 33 + RoasterAvgBrewRatings map[string]float64 // roaster URI -> avg brew rating 34 + ProfileDID string // DID of the profile being viewed 32 35 } 33 36 34 37 // ProfileContentPartial renders the profile tabs content (for HTMX loading) ··· 52 55 <!-- Beans Tab --> 53 56 <div x-show="activeTab === 'beans'"> 54 57 @ProfileBeansTab(ProfileBeansTabProps{ 55 - Beans: props.Beans, 56 - Roasters: props.Roasters, 57 - IsOwnProfile: props.IsOwnProfile, 58 - ProfileHandle: props.ProfileHandle, 59 - BeanBrewCounts: props.BeanBrewCounts, 60 - RoasterBeanCounts: props.RoasterBeanCounts, 61 - ProfileDID: props.ProfileDID, 58 + Beans: props.Beans, 59 + Roasters: props.Roasters, 60 + IsOwnProfile: props.IsOwnProfile, 61 + ProfileHandle: props.ProfileHandle, 62 + BeanBrewCounts: props.BeanBrewCounts, 63 + RoasterBeanCounts: props.RoasterBeanCounts, 64 + BeanAvgBrewRatings: props.BeanAvgBrewRatings, 65 + RoasterAvgBrewRatings: props.RoasterAvgBrewRatings, 66 + ProfileDID: props.ProfileDID, 62 67 }) 63 68 </div> 64 69 <!-- Equipment Tab --> ··· 77 82 78 83 // ProfileBeansTabProps defines props for the beans tab 79 84 type ProfileBeansTabProps struct { 80 - Beans []*models.Bean 81 - Roasters []*models.Roaster 82 - IsOwnProfile bool 83 - ProfileHandle string 84 - BeanBrewCounts map[string]int 85 - RoasterBeanCounts map[string]int 86 - ProfileDID string 85 + Beans []*models.Bean 86 + Roasters []*models.Roaster 87 + IsOwnProfile bool 88 + ProfileHandle string 89 + BeanBrewCounts map[string]int 90 + RoasterBeanCounts map[string]int 91 + BeanAvgBrewRatings map[string]float64 92 + RoasterAvgBrewRatings map[string]float64 93 + ProfileDID string 87 94 } 88 95 89 96 // ProfileBeansTab renders the beans and roasters tab for profile ··· 106 113 } 107 114 } else { 108 115 @BeanCards(BeanCardsProps{ 109 - Beans: filterOpenBeans(props.Beans), 110 - ShowActions: false, 111 - OwnerHandle: props.ProfileHandle, 112 - BrewCounts: props.BeanBrewCounts, 113 - OwnerDID: props.ProfileDID, 116 + Beans: filterOpenBeans(props.Beans), 117 + ShowActions: false, 118 + OwnerHandle: props.ProfileHandle, 119 + BrewCounts: props.BeanBrewCounts, 120 + AvgBrewRatings: props.BeanAvgBrewRatings, 121 + OwnerDID: props.ProfileDID, 114 122 }) 115 123 } 116 124 </div> ··· 125 133 } 126 134 } else { 127 135 @RoastersTable(RoastersTableProps{ 128 - Roasters: props.Roasters, 129 - ShowActions: false, 130 - OwnerHandle: props.ProfileHandle, 131 - BeanCounts: props.RoasterBeanCounts, 132 - OwnerDID: props.ProfileDID, 136 + Roasters: props.Roasters, 137 + ShowActions: false, 138 + OwnerHandle: props.ProfileHandle, 139 + BeanCounts: props.RoasterBeanCounts, 140 + AvgBrewRatings: props.RoasterAvgBrewRatings, 141 + OwnerDID: props.ProfileDID, 133 142 }) 134 143 } 135 144 </div> ··· 142 151 }) 143 152 } else { 144 153 @BeanCards(BeanCardsProps{ 145 - Beans: filterClosedBeans(props.Beans), 146 - ShowActions: false, 147 - OwnerHandle: props.ProfileHandle, 148 - BrewCounts: props.BeanBrewCounts, 149 - OwnerDID: props.ProfileDID, 154 + Beans: filterClosedBeans(props.Beans), 155 + ShowActions: false, 156 + OwnerHandle: props.ProfileHandle, 157 + BrewCounts: props.BeanBrewCounts, 158 + AvgBrewRatings: props.BeanAvgBrewRatings, 159 + OwnerDID: props.ProfileDID, 150 160 }) 151 161 } 152 162 </div>