Skip to content

Commit b9746df

Browse files
bradygasterCopilot
andcommitted
feat: add feed sorting, squad filtering, and comment stats
Add sort controls (Latest / Most Discussed) and squad filter dropdown to the discovery feed. Sort and filter state is carried via query string parameters so URLs are shareable. - Sort by newest (default) or by comment count descending - Filter by squad via dropdown, preserves active sort - Show total comments in header stats alongside artifacts and squads - Use Primer CSS BtnGroup for sort toggles, form-select for squad filter Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4446fe3 commit b9746df

3 files changed

Lines changed: 93 additions & 6 deletions

File tree

.squad/agents/fenster/history.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,13 @@ Showed complete flow: Fenster publishes → Verbal sees in feed (SSE) → reacts
787787
- API design for agent social networks: artifact-first (decisions, learnings, code) vs. post-first (Twitter-style)
788788
- JWT + RS256: Squads sign tokens with private key, network verifies with public key from /.well-known/jwks.json
789789
- Cursor-based pagination: Encode last_item_id + sort_value + query_fingerprint for stable pagination
790+
791+
### Feed sorting & filtering (Index page)
792+
- `ListArtifactsAsync(Guid? squadId)` already supports server-side squad filtering — no need to filter in-memory
793+
- Primer CSS `BtnGroup` with `.selected` class is the right pattern for sort toggles; works cleanly with server-rendered links
794+
- Razor `selected` attribute gotcha: `selected="@(false)"` still renders as selected in HTML — must conditionally render the entire attribute via if/else
795+
- Query string params (`?sort=comments&squad={id}`) keep URLs shareable and compose well with form GET submissions
796+
- Comment counts need to be computed before sorting when "Most Discussed" sort is active — order of operations matters
790797
- SSE fanout with Redis pub/sub: Artifact published → Redis PUBLISH → all server instances → SSE to connected clients
791798
- Feed algorithm: Weighted blend of followed agents (1.0), trending in expertise (0.85), similar squads (0.7), adoptions (0.6)
792799
- Adoption tracking as reputation signal: Better than likes — proves you actually used the knowledge

src/SquadPlaces.Web/Pages/Index.cshtml

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,63 @@
1313
<span class="Counter mr-2">@Model.TotalArtifacts</span>
1414
<span class="color-fg-muted mr-3">artifacts ·</span>
1515
<span class="Counter mr-2">@Model.TotalSquads</span>
16-
<span class="color-fg-muted mr-3">squads</span>
16+
<span class="color-fg-muted mr-3">squads ·</span>
17+
<span class="Counter mr-2">@Model.TotalComments</span>
18+
<span class="color-fg-muted mr-3">comments</span>
1719
<span class="Label color-fg-muted">read-only</span>
1820
</div>
1921
</div>
2022

23+
@{
24+
string BuildFeedUrl(string? sort = null, Guid? squad = null)
25+
{
26+
var qs = new List<string>();
27+
var effectiveSort = sort ?? Model.ActiveSort;
28+
var effectiveSquad = squad.HasValue ? squad : Model.SquadFilter;
29+
30+
if (effectiveSort != "latest")
31+
qs.Add($"sort={effectiveSort}");
32+
if (effectiveSquad.HasValue)
33+
qs.Add($"squad={effectiveSquad.Value}");
34+
35+
return qs.Count > 0 ? $"/?{string.Join("&", qs)}" : "/";
36+
}
37+
}
38+
39+
<div class="d-flex flex-items-center flex-justify-between mb-4 flex-wrap" style="gap: 0.5rem;">
40+
<div class="BtnGroup">
41+
<a href="@BuildFeedUrl(sort: "latest")"
42+
class="BtnGroup-item btn btn-sm @(Model.ActiveSort == "latest" ? "selected" : "")">
43+
🕐 Latest
44+
</a>
45+
<a href="@BuildFeedUrl(sort: "comments")"
46+
class="BtnGroup-item btn btn-sm @(Model.ActiveSort == "comments" ? "selected" : "")">
47+
💬 Most Discussed
48+
</a>
49+
</div>
50+
51+
<form method="get" class="d-flex flex-items-center">
52+
@if (Model.ActiveSort != "latest")
53+
{
54+
<input type="hidden" name="sort" value="@Model.ActiveSort" />
55+
}
56+
<select name="squad" class="form-select form-select-sm" onchange="this.form.submit()">
57+
<option value="">All Squads</option>
58+
@foreach (var s in Model.AllSquads.OrderBy(x => x.Name))
59+
{
60+
if (Model.SquadFilter == s.Id)
61+
{
62+
<option value="@s.Id" selected>@s.Name</option>
63+
}
64+
else
65+
{
66+
<option value="@s.Id">@s.Name</option>
67+
}
68+
}
69+
</select>
70+
</form>
71+
</div>
72+
2173
@if (Model.Artifacts.Count == 0)
2274
{
2375
<div class="blankslate">
Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Microsoft.AspNetCore.Mvc;
12
using Microsoft.AspNetCore.Mvc.RazorPages;
23
using SquadPlaces.Data;
34
using SquadPlaces.Data.Models;
@@ -9,25 +10,52 @@ public class IndexModel(IBlobStorageService storage) : PageModel
910
public List<KnowledgeArtifact> Artifacts { get; set; } = [];
1011
public Dictionary<Guid, string> SquadNames { get; set; } = [];
1112
public Dictionary<Guid, int> CommentCounts { get; set; } = [];
13+
public List<Squad> AllSquads { get; set; } = [];
1214
public int TotalArtifacts { get; set; }
1315
public int TotalSquads { get; set; }
16+
public int TotalComments { get; set; }
17+
18+
[BindProperty(SupportsGet = true)]
19+
[FromQuery(Name = "sort")]
20+
public string? Sort { get; set; }
21+
22+
[BindProperty(SupportsGet = true)]
23+
[FromQuery(Name = "squad")]
24+
public Guid? SquadFilter { get; set; }
25+
26+
public string ActiveSort => string.IsNullOrEmpty(Sort) ? "latest" : Sort;
1427

1528
public async Task OnGetAsync()
1629
{
17-
var allArtifacts = await storage.ListArtifactsAsync();
18-
Artifacts = allArtifacts.Take(50).ToList();
19-
TotalArtifacts = allArtifacts.Count;
20-
2130
var allSquads = await storage.ListSquadsAsync();
31+
AllSquads = allSquads;
2232
TotalSquads = allSquads.Count;
2333
SquadNames = allSquads.ToDictionary(s => s.Id, s => s.Name);
2434

25-
var commentTasks = Artifacts.Select(async a =>
35+
var allArtifacts = SquadFilter.HasValue
36+
? await storage.ListArtifactsAsync(SquadFilter.Value)
37+
: await storage.ListArtifactsAsync();
38+
39+
TotalArtifacts = allArtifacts.Count;
40+
41+
// Compute comment counts for visible artifacts
42+
var commentTasks = allArtifacts.Select(async a =>
2643
{
2744
var comments = await storage.ListCommentsAsync(a.Id);
2845
return (a.Id, Count: comments.Count);
2946
});
3047
var results = await Task.WhenAll(commentTasks);
3148
CommentCounts = results.ToDictionary(r => r.Id, r => r.Count);
49+
TotalComments = CommentCounts.Values.Sum();
50+
51+
// Apply sorting
52+
var sorted = ActiveSort switch
53+
{
54+
"comments" => allArtifacts.OrderByDescending(a => CommentCounts.GetValueOrDefault(a.Id, 0))
55+
.ThenByDescending(a => a.CreatedAt),
56+
_ => allArtifacts.OrderByDescending(a => a.CreatedAt),
57+
};
58+
59+
Artifacts = sorted.Take(50).ToList();
3260
}
3361
}

0 commit comments

Comments
 (0)