diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index f752b61..a110565 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use base64::Engine as _; use bds_core::db::DbQueryError as SqlError; -use chrono::Datelike; use iced::{Element, Subscription, Task, window}; use rayon::prelude::*; use serde_json::json; @@ -2658,10 +2657,16 @@ impl BdsApp { *monthly_counts.entry((year, month)).or_insert(0) += 1; for category in &post.categories { - *category_counts.entry(category.clone()).or_insert(0) += 1; + let trimmed = category.trim(); + if !trimmed.is_empty() { + *category_counts.entry(trimmed.to_string()).or_insert(0) += 1; + } } for tag in &post.tags { - *tag_counts.entry(tag.to_lowercase()).or_insert(0) += 1; + let trimmed = tag.trim(); + if !trimmed.is_empty() { + *tag_counts.entry(trimmed.to_string()).or_insert(0) += 1; + } } } @@ -2671,48 +2676,62 @@ impl BdsApp { .count(); let total_media_size = media.iter().map(|item| item.size).sum::(); - let now = chrono::Utc::now(); - let current_year = now.year(); - let current_month = now.month() as i32; - let timeline = (0..12) + // Per bDS2: only the most recent 12 months that actually have posts. + let timeline = monthly_counts + .iter() .rev() - .map(|offset| { - let total_month_index = current_year * 12 + (current_month - 1) - offset; - let year = total_month_index.div_euclid(12); - let month = total_month_index.rem_euclid(12) + 1; - let count = monthly_counts - .get(&(year, month as u32)) - .copied() - .unwrap_or(0); - - DashboardTimelineMonth { - label: month_abbreviation(month as u32), - year, - count, - } + .take(12) + .map(|(&(year, month), &count)| DashboardTimelineMonth { + label: month_abbreviation(month), + year, + count, }) + .rev() .collect::>(); - let mut tag_cloud = tags + let tag_colors = tags .into_iter() - .map(|tag| DashboardTag { - count: tag_counts - .get(&tag.name.to_lowercase()) - .copied() - .unwrap_or(0), - name: tag.name, - color: tag.color, + .filter_map(|tag| match tag.color { + Some(color) if !color.is_empty() => Some((tag.name, color)), + _ => None, + }) + .collect::>(); + let distinct_tag_count = tag_counts.len(); + + // Per bDS2: top 40 tags by count, font scaled 11-22px relative to the + // min/max counts of the visible set, then sorted alphabetically. + let mut tag_items = tag_counts.into_iter().collect::>(); + tag_items.sort_by(|left, right| { + right + .1 + .cmp(&left.1) + .then_with(|| left.0.to_lowercase().cmp(&right.0.to_lowercase())) + }); + tag_items.truncate(40); + let max_count = tag_items.iter().map(|item| item.1).max().unwrap_or(1).max(1); + let min_count = tag_items.iter().map(|item| item.1).min().unwrap_or(max_count); + let range = (max_count - min_count).max(1) as f32; + let mut tag_cloud = tag_items + .into_iter() + .map(|(name, count)| DashboardTag { + font_size: 11.0 + (count - min_count) as f32 / range * 11.0, + color: tag_colors.get(&name).cloned(), + name, + count, }) .collect::>(); - tag_cloud.sort_by_key(|left| left.name.to_lowercase()); - let tag_overflow_count = tag_cloud.len().saturating_sub(40); - tag_cloud.truncate(40); + tag_cloud.sort_by_key(|tag| tag.name.to_lowercase()); let mut category_cloud = category_counts .into_iter() .map(|(name, count)| DashboardCategory { name, count }) .collect::>(); - category_cloud.sort_by_key(|left| left.name.to_lowercase()); + category_cloud.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + }); let mut sorted_posts = posts; sorted_posts.sort_by_key(|post| std::cmp::Reverse(post.updated_at)); @@ -2745,12 +2764,11 @@ impl BdsApp { media_count: media.len(), image_count, total_media_size: format_bytes(total_media_size), - tag_count: tag_counts.len(), + tag_count: distinct_tag_count, category_count: category_cloud.len(), }, timeline, tag_cloud, - tag_overflow_count, category_cloud, recent_posts, } @@ -6963,7 +6981,7 @@ mod tests { assert_eq!(dash.stats.published_count, 1); assert_eq!(dash.stats.media_count, 1); assert_eq!(dash.stats.tag_count, 2); - assert_eq!(dash.timeline.len(), 12); + assert_eq!(dash.timeline.len(), 1); assert_eq!( dash.timeline.last().map(|month| month.year), Some(now.year()) @@ -6980,6 +6998,83 @@ mod tests { assert!(!dash.category_cloud.is_empty()); assert_eq!(dash.recent_posts[0].title, "Second"); assert_eq!(first.title, "First"); + + // TagCloud guarantee: alphabetical display order, font size relative + // to the min/max counts of the visible set (equal counts -> 11px). + let names = dash + .tag_cloud + .iter() + .map(|tag| tag.name.as_str()) + .collect::>(); + assert_eq!(names, vec!["lua", "rust"]); + assert!(dash.tag_cloud.iter().all(|tag| tag.font_size == 11.0)); + } + + #[test] + fn dashboard_tag_cloud_takes_most_used_tags_and_scales_fonts() { + let (db, project, tmp) = setup(); + // 42 distinct tags; "big" appears on 3 posts, "mid" on 2, the rest once. + for index in 0..3 { + post::create_post( + db.conn(), + tmp.path(), + &project.id, + &format!("Post {index}"), + Some("Body"), + vec![ + "big".to_string(), + format!("solo-{index:02}"), + format!("solo-x{index:02}"), + ], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + } + for index in 3..20 { + post::create_post( + db.conn(), + tmp.path(), + &project.id, + &format!("Post {index}"), + Some("Body"), + vec![ + format!("solo-{index:02}"), + format!("solo-x{index:02}"), + if index < 5 { "mid" } else { "big" }.to_string(), + ], + vec![], + None, + Some("en"), + None, + ) + .unwrap(); + } + + let app = make_app(db, project, &tmp); + let dash = app.hydrate_dashboard_state(); + + // 42 distinct tags overall, but only the 40 most-used are displayed. + assert_eq!(dash.stats.tag_count, 42); + assert_eq!(dash.tag_cloud.len(), 40); + let big = dash.tag_cloud.iter().find(|tag| tag.name == "big").unwrap(); + let mid = dash.tag_cloud.iter().find(|tag| tag.name == "mid").unwrap(); + assert_eq!(big.count, 18); + assert_eq!(mid.count, 2); + // Relative scaling over the visible set: max count -> 22px, min -> 11px. + assert_eq!(big.font_size, 22.0); + assert!(dash.tag_cloud.iter().any(|tag| tag.font_size == 11.0)); + // Display order is alphabetical. + let mut sorted = dash + .tag_cloud + .iter() + .map(|tag| tag.name.to_lowercase()) + .collect::>(); + let displayed = sorted.clone(); + sorted.sort(); + assert_eq!(displayed, sorted); } #[test] @@ -7018,19 +7113,13 @@ mod tests { let app = make_app(db, project, &tmp); let dash = app.hydrate_dashboard_state(); - let march = dash - .timeline - .iter() - .find(|month| month.year == 2026 && month.label == "Mar") - .expect("march bucket should exist"); - let april = dash - .timeline - .iter() - .find(|month| month.year == 2026 && month.label == "Apr") - .expect("april bucket should exist"); - + // Only months with posts appear in the timeline: the created_at month + // (March), not the updated_at month (April). + assert_eq!(dash.timeline.len(), 1); + let march = &dash.timeline[0]; + assert_eq!(march.year, 2026); + assert_eq!(march.label, "Mar"); assert_eq!(march.count, 1); - assert_eq!(april.count, 0); } #[test] diff --git a/crates/bds-ui/src/views/dashboard.rs b/crates/bds-ui/src/views/dashboard.rs index 0835e9a..8d0ce32 100644 --- a/crates/bds-ui/src/views/dashboard.rs +++ b/crates/bds-ui/src/views/dashboard.rs @@ -5,7 +5,7 @@ use bds_core::i18n::UiLocale; use crate::app::Message; use crate::components::inputs; -use crate::i18n::t; +use crate::i18n::{t, tw}; use crate::state::tabs::{Tab, TabType}; #[derive(Debug, Clone)] @@ -33,6 +33,8 @@ pub struct DashboardTag { pub name: String, pub count: usize, pub color: Option, + /// 11–22 px, scaled relative to the min/max counts of the displayed tags. + pub font_size: f32, } #[derive(Debug, Clone)] @@ -57,7 +59,6 @@ pub struct DashboardState { pub stats: DashboardStats, pub timeline: Vec, pub tag_cloud: Vec, - pub tag_overflow_count: usize, pub category_cloud: Vec, pub recent_posts: Vec, } @@ -80,7 +81,6 @@ impl DashboardState { }, timeline: Vec::new(), tag_cloud: Vec::new(), - tag_overflow_count: 0, category_cloud: Vec::new(), recent_posts: Vec::new(), } @@ -146,34 +146,37 @@ pub fn view<'a>(state: &'a DashboardState, locale: UiLocale) -> Element<'a, Mess ] .spacing(16); - let timeline = timeline_chart(&state.timeline, locale); - let tags = tag_cloud(&state.tag_cloud, state.tag_overflow_count, locale); - let categories = category_cloud(&state.category_cloud, locale); - let recent = recent_posts(&state.recent_posts, locale); + let mut content = column![header, project_label, Space::with_height(16), counts_row].spacing(8); - scrollable(container( - column![ - header, - project_label, - Space::with_height(16), - inputs::section_header(&t(locale, "dashboard.statCards")), - counts_row, - Space::with_height(20), - inputs::section_header(&t(locale, "dashboard.timeline")), - timeline, - Space::with_height(20), - row![tags, categories].spacing(16), - Space::with_height(20), - inputs::section_header(&t(locale, "dashboard.recentPosts")), - recent, - ] - .spacing(8) - .padding(24) - .width(Length::Fill), - )) - .width(Length::Fill) - .height(Length::Fill) - .into() + if !state.timeline.is_empty() { + content = content + .push(Space::with_height(20)) + .push(inputs::section_header(&t(locale, "dashboard.timeline"))) + .push(timeline_chart(&state.timeline, locale)); + } + if !state.tag_cloud.is_empty() { + content = content + .push(Space::with_height(20)) + .push(inputs::section_header(&t(locale, "dashboard.tags"))) + .push(tag_cloud(&state.tag_cloud, locale)); + } + if !state.category_cloud.is_empty() { + content = content + .push(Space::with_height(20)) + .push(inputs::section_header(&t(locale, "dashboard.categories"))) + .push(category_cloud(&state.category_cloud, locale)); + } + if !state.recent_posts.is_empty() { + content = content + .push(Space::with_height(20)) + .push(inputs::section_header(&t(locale, "dashboard.recentPosts"))) + .push(recent_posts(&state.recent_posts, locale)); + } + + scrollable(container(content.padding(24).width(Length::Fill))) + .width(Length::Fill) + .height(Length::Fill) + .into() } fn stat_card<'a>(label: &str, value: String, details: Vec) -> Element<'a, Message> { @@ -258,78 +261,41 @@ fn timeline_chart<'a>( .into() } -fn tag_cloud<'a>( - tags: &'a [DashboardTag], - overflow_count: usize, - locale: UiLocale, -) -> Element<'a, Message> { - let cloud = if tags.is_empty() { - row![ - text(t(locale, "dashboard.noTags")) - .size(13) - .color(Color::from_rgb(0.6, 0.62, 0.68)) - ] - .spacing(8) - .wrap() - } else { - let words = tags - .iter() - .map(|tag| { - let bg = - parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28)); - let fg = contrast_color(bg); - tooltip( - container(text(tag.name.clone()).size(scale_font(tag.count)).color(fg)) - .padding([6, 10]) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(bg)), - border: iced::Border { - radius: 999.0.into(), - ..iced::Border::default() - }, - ..container::Style::default() - }), - text(format!("{} {}", tag.name, tag.count)).size(12), - tooltip::Position::Top, - ) - .into() - }) - .collect::>(); - row(words).spacing(8).wrap() - }; - - let content = if overflow_count > 0 { - column![ - inputs::section_header(&t(locale, "dashboard.tagCloud")), - cloud, - text(format!("{} {}", t(locale, "dashboard.and"), overflow_count)) - .size(12) - .color(Color::from_rgb(0.6, 0.62, 0.68)), - ] - .spacing(8) - } else { - column![ - inputs::section_header(&t(locale, "dashboard.tagCloud")), - cloud - ] - .spacing(8) - }; - - container(content).width(Length::FillPortion(1)).into() +fn tag_cloud<'a>(tags: &'a [DashboardTag], locale: UiLocale) -> Element<'a, Message> { + let words = tags + .iter() + .map(|tag| { + let bg = + parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28)); + let fg = contrast_color(bg); + tooltip( + container(text(tag.name.clone()).size(tag.font_size).color(fg)) + .padding([6, 10]) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(bg)), + border: iced::Border { + radius: 999.0.into(), + ..iced::Border::default() + }, + ..container::Style::default() + }), + text(post_count_label(locale, tag.count)).size(12), + tooltip::Position::Top, + ) + .into() + }) + .collect::>(); + row(words).spacing(8).wrap().into() } fn category_cloud<'a>( categories: &'a [DashboardCategory], locale: UiLocale, ) -> Element<'a, Message> { - let items = categories.iter().fold( - column![inputs::section_header(&t( - locale, - "dashboard.categoryCloud" - ))] - .spacing(8), - |column, category| { - column.push( + let badges = categories + .iter() + .map(|category| { + tooltip( container( row![ text(category.name.clone()).size(13).color(Color::WHITE), @@ -348,20 +314,20 @@ fn category_cloud<'a>( }, ..container::Style::default() }), + text(post_count_label(locale, category.count)).size(12), + tooltip::Position::Top, ) - }, - ); - container(items).width(Length::FillPortion(1)).into() + .into() + }) + .collect::>(); + row(badges).spacing(8).wrap().into() +} + +fn post_count_label(locale: UiLocale, count: usize) -> String { + tw(locale, "dashboard.postCount", &[("count", &count.to_string())]) } fn recent_posts<'a>(posts: &'a [DashboardRecentPost], locale: UiLocale) -> Element<'a, Message> { - if posts.is_empty() { - return text(t(locale, "dashboard.noRecentPosts")) - .size(13) - .color(Color::from_rgb(0.6, 0.62, 0.68)) - .into(); - } - column( posts .iter() @@ -435,11 +401,6 @@ fn status_badge<'a>(status: &str) -> Element<'a, Message> { .into() } -fn scale_font(count: usize) -> u16 { - let clamped = count.min(20) as f32; - (11.0 + (clamped / 20.0) * 11.0).round() as u16 -} - fn parse_color(value: Option<&str>) -> Option { let value = value?.trim_start_matches('#'); if value.len() != 6 { diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index e8dac5f..7755cb1 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -397,15 +397,9 @@ dashboard-images = Bilder dashboard-drafts = Entwürfe dashboard-published = Veröffentlicht dashboard-archived = Archiviert -dashboard-statCards = Überblick -dashboard-timeline = Zeitachse -dashboard-noTimelineData = Noch keine Zeitachsendaten -dashboard-tagCloud = Tag-Wolke -dashboard-noTags = Noch keine Tags -dashboard-categoryCloud = Kategorie-Wolke -dashboard-recentPosts = Letzte Beiträge -dashboard-noRecentPosts = Keine aktuellen Beiträge -dashboard-and = und +dashboard-timeline = Beiträge im Zeitverlauf +dashboard-recentPosts = Kürzlich aktualisiert +dashboard-postCount = { $count } Beiträge dashboard-pin = Anheften common-tabNotImplemented = Dieser Bereich wird in einem späteren Meilenstein implementiert. editor-noPreviewAvailable = Keine Vorschau verfügbar diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index f0ba63f..896cbc1 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -397,15 +397,9 @@ dashboard-images = Images dashboard-drafts = Drafts dashboard-published = Published dashboard-archived = Archived -dashboard-statCards = Snapshot -dashboard-timeline = Timeline -dashboard-noTimelineData = No timeline data yet -dashboard-tagCloud = Tag Cloud -dashboard-noTags = No tags yet -dashboard-categoryCloud = Category Cloud -dashboard-recentPosts = Recent Posts -dashboard-noRecentPosts = No recent posts -dashboard-and = and +dashboard-timeline = Posts Over Time +dashboard-recentPosts = Recently Updated +dashboard-postCount = { $count } posts dashboard-pin = Pin common-tabNotImplemented = This area will be implemented in a later milestone. editor-noPreviewAvailable = No preview available diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index cee73fd..240a35a 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -397,15 +397,9 @@ dashboard-images = Imágenes dashboard-drafts = Borradores dashboard-published = Publicados dashboard-archived = Archivados -dashboard-statCards = Resumen -dashboard-timeline = Cronología -dashboard-noTimelineData = Todavía no hay datos de cronología -dashboard-tagCloud = Nube de etiquetas -dashboard-noTags = Todavía no hay etiquetas -dashboard-categoryCloud = Nube de categorías -dashboard-recentPosts = Publicaciones recientes -dashboard-noRecentPosts = No hay publicaciones recientes -dashboard-and = y +dashboard-timeline = Entradas a lo largo del tiempo +dashboard-recentPosts = Actualizadas recientemente +dashboard-postCount = { $count } entradas dashboard-pin = Fijar common-tabNotImplemented = Esta sección se implementará en un hito posterior. editor-noPreviewAvailable = No hay vista previa disponible diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 3d649ae..321a7bf 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -397,15 +397,9 @@ dashboard-images = Images dashboard-drafts = Brouillons dashboard-published = Publiés dashboard-archived = Archivés -dashboard-statCards = Instantané -dashboard-timeline = Chronologie -dashboard-noTimelineData = Aucune donnée de chronologie -dashboard-tagCloud = Nuage de tags -dashboard-noTags = Aucun tag pour le moment -dashboard-categoryCloud = Nuage de catégories -dashboard-recentPosts = Articles récents -dashboard-noRecentPosts = Aucun article récent -dashboard-and = et +dashboard-timeline = Articles dans le temps +dashboard-recentPosts = Récemment mis à jour +dashboard-postCount = { $count } articles dashboard-pin = Épingler common-tabNotImplemented = Cette section sera mise en œuvre lors d’une étape ultérieure. editor-noPreviewAvailable = Aucun aperçu disponible diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 6176145..c0b481d 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -397,15 +397,9 @@ dashboard-images = Immagini dashboard-drafts = Bozze dashboard-published = Pubblicati dashboard-archived = Archiviati -dashboard-statCards = Riepilogo -dashboard-timeline = Cronologia -dashboard-noTimelineData = Nessun dato cronologico disponibile -dashboard-tagCloud = Nuvola di tag -dashboard-noTags = Nessun tag per ora -dashboard-categoryCloud = Nuvola di categorie -dashboard-recentPosts = Post recenti -dashboard-noRecentPosts = Nessun post recente -dashboard-and = e +dashboard-timeline = Post nel tempo +dashboard-recentPosts = Aggiornati di recente +dashboard-postCount = { $count } post dashboard-pin = Fissa common-tabNotImplemented = Questa sezione verrà implementata in una fase successiva. editor-noPreviewAvailable = Nessuna anteprima disponibile diff --git a/specs/editor_misc.allium b/specs/editor_misc.allium index 2aaab81..458560c 100644 --- a/specs/editor_misc.allium +++ b/specs/editor_misc.allium @@ -50,7 +50,6 @@ value DashboardTimelineMonth { value DashboardTagCloud { tags: List - overflow_count: Integer? -- "and N more" when > config.dashboard_max_tags } value DashboardTag { @@ -106,7 +105,6 @@ surface DashboardSurface { t.name t.count t.color - dash.tag_cloud.overflow_count when dash.tag_cloud.overflow_count != null for c in dash.category_cloud.categories: c.name c.count @@ -131,16 +129,18 @@ surface DashboardSurface { -- Bar height proportional to max count. @guarantee TagCloud - -- Up to config.dashboard_max_tags tags, sorted alphabetically. + -- Tags counted from post tags (trimmed, non-empty entries). + -- The config.dashboard_max_tags most-used tags are shown, sorted alphabetically. -- Font size scaled config.dashboard_tag_min_font to config.dashboard_tag_max_font px - -- based on post count. + -- relative to the min/max post counts of the displayed tags. -- Tags with colours get coloured background with contrast text. -- Hover title shows post count. - -- "and N more" text when overflow_count > 0. + -- Section hidden when there are no tags. @guarantee CategoryCloud - -- All categories as badge-like tags. - -- Each shows category name + count. + -- All categories as badge-like tags, sorted by post count descending + -- then name ascending. + -- Each shows category name + count. Section hidden when empty. @guarantee RecentPosts -- Last config.dashboard_recent_count posts by updatedAt descending.