Skip to main content

max / makenotwork

Restore tag following on the discover drill-down The tag spine replaced the flat ten-tag facet list, which was the only surface rendering a tag follow control. The write path, the API, and the three feed queries all still worked; there was simply no button, so no new tag follow could be created and existing ones could not be removed. The control returns on the drill-down rungs, as a sibling of the label rather than inside it: a button nested in a label toggles the label's checkbox. Offered only on assignable rungs, and this is the substantive constraint. Following is exact-match — the feed queries join follows.target_id to item_tags.tag_id with no descendant expansion — while the tag filter sitting beside it does expand to descendants. Since item_tags holds only directly assigned tags and the write path enforces depth >= 3, following a category would match nothing, forever. Whether following should become hierarchical is filed as its own decision. Follow state now comes from the drill rows. It was previously computed against tag_filters, which the spine stopped rendering, so `following_subset` ran on every authenticated items-mode request and its result was discarded. The dead tag_filters follow plumbing goes with it. Adds the first tests tag following has ever had. It had none: no test posted to /api/follow/tag/{id}, none asserted a tag-followed item reaches a feed, none covered following_subset. That absence is why removing the entry point broke nothing visible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-18 20:15 UTC
Commit: 92167359b27075d770fd8730ca5b86990341c5aa
Parent: 28f99fa
6 files changed, +129 insertions, -11 deletions
@@ -75,6 +75,7 @@ pub struct DbTagCount {
75 75 /// zero and would render a sidebar full of noise.
76 76 #[derive(Debug, Clone, FromRow)]
77 77 pub struct DbTagChild {
78 + pub tag_id: TagId,
78 79 pub tag_name: String,
79 80 pub tag_slug: String,
80 81 pub count: i64,
@@ -281,6 +281,7 @@ pub async fn tag_children_with_counts(
281 281 let mut query = format!(
282 282 r#"
283 283 SELECT
284 + c.id AS tag_id,
284 285 c.name AS tag_name,
285 286 c.slug AS tag_slug,
286 287 {count_subquery} AS count,
@@ -112,10 +112,28 @@ async fn build_sidebar(
112 112 let browse_cursor = query.browse.as_deref().filter(|s| !s.is_empty());
113 113 let drill_rows =
114 114 db::tags::tag_children_with_counts(db, browse_cursor, &facet_filters).await?;
115 +
116 + // Follow state, for the rungs that can carry a follow control. Scoped to
117 + // the assignable rows rather than the viewer's whole followed set (fuzz
118 + // 2026-07-06 C5-1), and skipped entirely for anonymous viewers.
119 + let followed: std::collections::HashSet<db::TagId> = match viewer_id {
120 + Some(uid) => {
121 + let ids: Vec<_> = drill_rows
122 + .iter()
123 + .filter(|r| r.assignable)
124 + .map(|r| r.tag_id)
125 + .collect();
126 + db::follows::following_subset(db, uid, &ids).await?
127 + }
128 + None => std::collections::HashSet::new(),
129 + };
130 +
115 131 tag_drill = drill_rows
116 132 .into_iter()
117 133 .map(|r| TagDrillRow {
118 134 selected: tag_filter.iter().any(|t| t == &r.tag_slug),
135 + following: followed.contains(&r.tag_id),
136 + tag_id: r.tag_id.to_string(),
119 137 slug: r.tag_slug,
120 138 label: r.tag_name,
121 139 count: r.count as u32,
@@ -167,16 +185,6 @@ async fn build_sidebar(
167 185 }
168 186 })
169 187 .collect();
170 - // Only the first ~10 tag facets are rendered (see `.take(10)` below), so
171 - // test follow-membership against just those rather than fetching the
172 - // viewer's entire followed-tag set (fuzz 2026-07-06 C5-1).
173 - let followed_tag_ids = if let Some(uid) = viewer_id {
174 - let rendered_tag_ids: Vec<_> =
175 - tag_counts.iter().take(10).map(|tc| tc.tag_id).collect();
176 - db::follows::following_subset(db, uid, &rendered_tag_ids).await?
177 - } else {
178 - std::collections::HashSet::new()
179 - };
180 188
181 189 let mut type_filters: Vec<FilterCategory> = vec![FilterCategory {
182 190 name: "All".to_string(),
@@ -213,7 +221,7 @@ async fn build_sidebar(
213 221 count: tc.count as u32,
214 222 active: tag_filter.iter().any(|t| t == &tc.tag_slug),
215 223 id: tc.tag_id.to_string(),
216 - following: followed_tag_ids.contains(&tc.tag_id),
224 + following: false,
217 225 });
218 226 }
219 227
@@ -347,6 +355,7 @@ async fn build_sidebar(
347 355 browse_url_root,
348 356 has_source: has_source_code,
349 357 active_filter_count,
358 + viewer_authenticated: viewer_id.is_some(),
350 359 })
351 360 }
352 361
@@ -75,6 +75,8 @@ pub struct TagChip {
75 75
76 76 /// One rung of the tag drill-down.
77 77 pub struct TagDrillRow {
78 + /// Needed to address the follow endpoint, which is keyed on tag id.
79 + pub tag_id: String,
78 80 pub slug: String,
79 81 pub label: String,
80 82 pub count: u32,
@@ -83,6 +85,12 @@ pub struct TagDrillRow {
83 85 pub has_children: bool,
84 86 /// Already in the active selection, so the control renders as selected.
85 87 pub selected: bool,
88 + /// The viewer already follows this tag.
89 + ///
90 + /// Only ever true for assignable rows: following is exact-match, so a
91 + /// follow on a category would match nothing (items carry depth-3+ leaves
92 + /// only). The control is offered on leaves for the same reason.
93 + pub following: bool,
86 94 }
87 95
88 96 /// A rung of the drill-down's breadcrumb trail, for navigating back up.
@@ -141,4 +149,6 @@ pub struct SidebarView {
141 149 pub browse_url_root: String,
142 150 pub has_source: bool,
143 151 pub active_filter_count: u32,
152 + /// Follow controls are only meaningful for a signed-in viewer.
153 + pub viewer_authenticated: bool,
144 154 }
@@ -117,6 +117,26 @@
117 117 <span>{{ row.label }}</span>
118 118 <span class="count" aria-label="{{ row.count }} items">{{ row.count }}</span>
119 119 </label>
120 + {# Follow is a SIBLING of the label, never inside it:
121 + a control nested in a label would toggle the
122 + checkbox when clicked. Offered only on assignable
123 + leaves, because following is exact-match and a
124 + follow on a category would match nothing. #}
125 + {% if sidebar.viewer_authenticated %}
126 + {% if row.following %}
127 + <button class="tag-follow-btn is-selected"
128 + hx-delete="/api/follow/tag/{{ row.tag_id }}"
129 + hx-swap="outerHTML"
130 + aria-label="Unfollow {{ row.label }}"
131 + data-action="noop" data-stop>Following</button>
132 + {% else %}
133 + <button class="tag-follow-btn"
134 + hx-post="/api/follow/tag/{{ row.tag_id }}"
135 + hx-swap="outerHTML"
136 + aria-label="Follow {{ row.label }}"
137 + data-action="noop" data-stop>Follow</button>
138 + {% endif %}
139 + {% endif %}
120 140 {% endif %}
121 141 {% if row.has_children %}
122 142 <a class="tag-drill-into" href="{{ sidebar.browse_url_prefix }}{{ row.slug }}"
@@ -442,6 +442,83 @@ async fn price_filter_round_trips_into_its_inputs() {
442 442 }
443 443
444 444 // ---------------------------------------------------------------------------
445 + // Tag following.
446 + // ---------------------------------------------------------------------------
447 +
448 + /// The drill-down offers a follow control, and only where following works.
449 + ///
450 + /// Following is exact-match: `follows.target_id` joins `item_tags.tag_id`
451 + /// directly, with no descendant expansion. Items carry depth-3+ leaves only, so
452 + /// a follow on a category would match nothing forever. The control is therefore
453 + /// offered on assignable rungs and withheld on navigational ones, even though
454 + /// the tag filter beside it does expand to descendants.
455 + #[tokio::test]
456 + async fn follow_control_appears_only_on_assignable_rungs() {
457 + let mut h = TestHarness::new().await;
458 + h.signup("followui", "followui@example.com", "password123").await;
459 +
460 + // Root: every rung is a depth-1 type, none assignable, so no follow control.
461 + let root = h.client.get("/discover?mode=items").await;
462 + assert!(root.status.is_success(), "{}", root.status);
463 + assert!(
464 + !root.text.contains("tag-follow-btn"),
465 + "categories cannot be followed meaningfully, so offer no control"
466 + );
467 +
468 + // Drilled to leaves: the control appears.
469 + let leaves = h.client.get("/discover?mode=items&browse=audio.genre").await;
470 + assert!(leaves.status.is_success(), "{}", leaves.status);
471 + assert!(
472 + leaves.text.contains("tag-follow-btn"),
473 + "assignable leaves should offer a follow control"
474 + );
475 + assert!(
476 + leaves.text.contains("/api/follow/tag/"),
477 + "the control must address the tag follow endpoint"
478 + );
479 + }
480 +
481 + /// Anonymous visitors get no follow control, and cost no follow query.
482 + #[tokio::test]
483 + async fn follow_control_is_hidden_from_anonymous_visitors() {
484 + let mut h = TestHarness::new().await;
485 + let resp = h.client.get("/discover?mode=items&browse=audio.genre").await;
486 + assert!(resp.status.is_success(), "{}", resp.status);
487 + assert!(
488 + !resp.text.contains("tag-follow-btn"),
489 + "a signed-out visitor has nothing to follow with"
490 + );
491 + }
492 +
493 + /// Following a tag round-trips into the rendered control.
494 + #[tokio::test]
495 + async fn following_a_tag_is_reflected_in_the_sidebar() {
496 + let mut h = TestHarness::new().await;
497 + h.signup("followrt", "followrt@example.com", "password123").await;
498 +
499 + let tag_id: uuid::Uuid =
500 + sqlx::query_scalar("SELECT id FROM tags WHERE slug = 'audio.genre.electronic'")
501 + .fetch_one(&h.db)
502 + .await
503 + .expect("seeded leaf tag exists");
504 +
505 + let before = h.client.get("/discover?mode=items&browse=audio.genre").await;
506 + assert!(before.text.contains(">Follow<"), "should start unfollowed");
507 +
508 + let resp = h
509 + .client
510 + .post_form(&format!("/api/follow/tag/{tag_id}"), "")
511 + .await;
512 + assert!(resp.status.is_success(), "follow failed: {}", resp.status);
513 +
514 + let after = h.client.get("/discover?mode=items&browse=audio.genre").await;
515 + assert!(
516 + after.text.contains(">Following<"),
517 + "the sidebar must reflect the follow it just recorded"
518 + );
519 + }
520 +
521 + // ---------------------------------------------------------------------------
445 522 // Markup/stylesheet contract.
446 523 // ---------------------------------------------------------------------------
447 524