Skip to main content

max / makenotwork

Make a bare tag link filter items instead of showing every project Found walking testnot for the demo Phase 4 pass. Clicking a tag on an item page goes to /discover?tag={slug} with no mode, which defaults to projects, and projects mode has no tag dimension at all: discover_projects takes no tag argument and the sidebar only builds tag filters in items mode. So the URL claimed a filter the query could not apply and the page answered with the complete unfiltered project list, which reads as "this tag matches everything". Measured on testnot: /discover?tag=writing.format.essay returned all 5 projects, the same as no filter, while the same tag with mode=items correctly returned 1. Fixed at the handler rather than in the links, because six templates emit the bare form (item, project, both players, and the two reader views) and hand-typed or shared links have it too. An unset mode now means projects as before, except when a tag is selected, where it means items. The doc comment on DiscoverQuery::mode claimed items was the default. It never was; the handler has always defaulted to projects. Corrected rather than left to mislead the next reader the way it misled this one.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-06 16:59 UTC
Signed with PGP, not checked
Commit: 08fabb5fcd077c3eba68d697ad3fe200693eefc7
Parent: 3fa5dc1
2 files changed, +68 insertions, -3 deletions
@@ -1282,6 +1282,52 @@
1282 1282 );
1283 1283 }
1284 1284
1285 + /// A tag link that carries no `mode` filters items rather than landing on the
1286 + /// unfiltered project list.
1287 + ///
1288 + /// Six templates emit `/discover?tag={slug}` with no mode (item, project, both
1289 + /// players, and the two reader views), so this is every tag click from a
1290 + /// content page. Projects mode has no tag dimension at all — `discover_projects`
1291 + /// takes no tag argument and the sidebar only builds tag filters in items mode
1292 + /// — so the URL used to claim a filter the query could not apply, and the page
1293 + /// answered with every project, which reads as "this tag matches everything".
1294 + #[tokio::test]
1295 + async fn a_tag_link_without_a_mode_filters_items() {
1296 + let mut h = TestHarness::new().await;
1297 + let (_, electronic) =
1298 + make_discoverable_item(&mut h, "bareelec", "Electronic Pick", "audio").await;
1299 + let (_, folk) = make_discoverable_item(&mut h, "barefolk", "Folk Pick", "audio").await;
1300 + tag_item(&h, &electronic, "audio.genre.electronic").await;
1301 + tag_item(&h, &folk, "audio.genre.folk").await;
1302 +
1303 + let resp = h.client.get("/discover?tag=audio.genre.electronic").await;
1304 + assert_eq!(resp.status, 200);
1305 + assert!(
1306 + resp.text.contains("Electronic Pick"),
1307 + "a bare tag link must return the tagged item",
1308 + );
1309 + assert!(
1310 + !resp.text.contains("Folk Pick"),
1311 + "a bare tag link must still exclude an unselected tag",
1312 + );
1313 + }
1314 +
1315 + /// And the default is otherwise unchanged: no tag, no mode, still projects.
1316 + /// Pinned because the tag rule above is a second default rather than a
1317 + /// replacement, and the two are easy to collapse into one by accident.
1318 + #[tokio::test]
1319 + async fn discover_without_a_tag_still_defaults_to_projects() {
1320 + let mut h = TestHarness::new().await;
1321 + make_discoverable_item(&mut h, "defmode", "Mode Default Pick", "audio").await;
1322 +
1323 + let resp = h.client.get("/discover").await;
1324 + assert_eq!(resp.status, 200);
1325 + assert!(
1326 + resp.text.contains("project-row"),
1327 + "the bare landing view is the project list",
1328 + );
1329 + }
1330 +
1285 1331 /// Repeated `item_type=` params are OR'd.
1286 1332 #[tokio::test]
1287 1333 async fn repeated_item_type_params_are_ored_within_the_facet() {
@@ -642,7 +642,10 @@
642 642 pub sort: Option<String>,
643 643 #[serde(default, deserialize_with = "empty_string_as_none")]
644 644 pub page: Option<u32>,
645 - pub mode: Option<String>, // "items" (default) or "projects"
645 + /// `"items"` or `"projects"`. Unset means projects, except when a tag is
646 + /// selected, which means items: see the handler for why. The comment here
647 + /// used to say items was the default and it never was.
648 + pub mode: Option<String>,
646 649 pub ai_tier: Option<String>,
647 650 pub has_source: Option<String>,
648 651 /// Drill-down cursor: which tag's children the sidebar is showing. Distinct
@@ -1171,9 +1174,25 @@
1171 1174 let page = query.page.unwrap_or(1).clamp(1, 1_000_000_000);
1172 1175 let limit = constants::DISCOVER_PAGE_SIZE as i64;
1173 1176 let offset = ((page - 1) as i64) * limit;
1174 - let mode = query.mode.as_deref().unwrap_or("projects");
1175 -
1176 1177 let f = query.filter_selection();
1178 +
1179 + // A tag is an item-level facet and nothing else: `discover_projects` takes
1180 + // no tag argument, and the sidebar only builds tag filters in items mode
1181 + // (see `build_sidebar`). So a tag selected in projects mode is a filter the
1182 + // URL claims and the query cannot apply, which renders as the full
1183 + // unfiltered project list and reads as "this tag matches everything".
1184 + //
1185 + // That is the common path rather than a corner. Six templates link
1186 + // `/discover?tag={slug}` with no mode (item, project, both players, and the
1187 + // two reader views), so every tag click from a content page landed there.
1188 + // Defaulting to items whenever a tag is selected fixes all of them at once,
1189 + // and shared or hand-typed links with it.
1190 + let mode = query.mode.as_deref().unwrap_or(if f.tags.is_empty() {
1191 + "projects"
1192 + } else {
1193 + "items"
1194 + });
1195 +
1177 1196 let item_type_filter = f.item_types;
1178 1197 let tag_filter = f.tags;
1179 1198 let search_filter = f.search;