diff --git a/server/src/html/garden/home.rs b/server/src/html/garden/home.rs index be464aa0ed27f7811d5899d1880b4579ee04ab67..69a65c907689146029909a6a90f6fcaa72368412 100644 --- a/server/src/html/garden/home.rs +++ b/server/src/html/garden/home.rs @@ -83,24 +83,38 @@ fn question_activity( thread_ts.max(vote_ts) } -fn prompted_aspects(content: &ContentState, collection: &ItemId) -> Vec { - let mut slugs: Vec = content - .aspect_prompts - .iter() - .filter(|(_, p)| !p.trim().is_empty()) - .map(|(slug, _)| slug.clone()) +/// Aspects that have actually been voted under this scope. Aspect prompts recorded +/// elsewhere in the room must not fabricate rows here (no scope × prompt cross-product). +fn scope_aspects(content: &ContentState, collection: &ItemId) -> Vec { + let mut slugs: Vec = aspects_for_scope(content, collection) + .into_iter() + .map(|(slug, _)| slug) .filter(|slug| is_valid_aspect_slug(slug)) .collect(); - for (slug, prompt) in aspects_for_scope(content, collection) { - if prompt.as_deref().is_some_and(|p| !p.is_empty()) && is_valid_aspect_slug(&slug) { - slugs.push(slug); - } - } slugs.sort(); slugs.dedup(); slugs } +/// At least one recorded canonical vote between two members of this scope. +fn scope_has_voted_pair(content: &ContentState, collection: &ItemId) -> bool { + let g = &content.ranking_group; + let idxs: Vec = content + .members_of(collection) + .iter() + .filter_map(|m| g.item_to_idx.get(m).copied()) + .collect(); + for (i, &a) in idxs.iter().enumerate() { + for &b in &idxs[i + 1..] { + let key = if a < b { (a, b) } else { (b, a) }; + if g.voted_pairs.contains(&key) { + return true; + } + } + } + false +} + fn entry_from_scope( content: &ContentState, nav: &ThreadNav, @@ -138,8 +152,10 @@ fn entry_from_scope( }) } -/// Public scopes with ≥2 comparable members, plus prompted aspects under those scopes. -/// Bump-ordered by thread / vote activity, then member count descending. +/// Public scopes with ≥2 comparable members that show some effort — a written +/// prompt body, a recorded vote between members, or a voted aspect — plus the +/// aspects actually voted under each scope. Bump-ordered by thread / vote +/// activity, then member count descending. pub(super) fn collect_question_entries( reduced: &ReducerState, content: &ContentState, @@ -165,10 +181,18 @@ pub(super) fn collect_question_entries( let mut entries = Vec::new(); for (scope, member_count) in scopes { + let aspects = scope_aspects(content, &scope); + let has_prompt = matches!( + question_headline(content, &scope, None), + QuestionHeadline::Body(_) + ); + if !has_prompt && aspects.is_empty() && !scope_has_voted_pair(content, &scope) { + continue; + } if let Some(e) = entry_from_scope(content, nav, &scope, None, member_count) { entries.push(e); } - for slug in prompted_aspects(content, &scope) { + for slug in aspects { if let Some(e) = entry_from_scope(content, nav, &scope, Some(&slug), member_count) { entries.push(e); }