diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index da9f6ad..6efd222 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; +use chrono::Datelike; use iced::{Element, Subscription, Task}; use bds_core::db::Database; @@ -345,12 +346,13 @@ fn format_bytes(size: i64) -> String { #[cfg(test)] mod tests { - use super::{persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, PostStatus, SettingsMsg, POST_AUTO_SAVE_DELAY_MS}; + use super::{month_abbreviation, persist_media_editor_state_impl, persist_post_editor_state_impl, save_editor_settings_state_impl, save_script_editor_state_impl, save_template_editor_state_impl, BdsApp, Message, PersistedMediaState, PersistedPostState, PostStatus, SettingsMsg, POST_AUTO_SAVE_DELAY_MS}; use crate::views::media_editor::{MediaEditorMsg, MediaEditorState}; use crate::views::post_editor::PostEditorState; use crate::views::script_editor::ScriptEditorState; use crate::views::settings_view::SettingsViewState; use crate::views::template_editor::TemplateEditorState; + use chrono::Datelike; use bds_core::db::fts::ensure_fts_tables; use bds_core::db::queries::project::insert_project; use bds_core::db::Database; @@ -803,13 +805,17 @@ mod tests { let _ = app.refresh_counts(); let dash = app.dashboard_state.expect("dashboard state should be set"); + let now = chrono::Utc::now(); assert_eq!(dash.stats.total_posts, 2); 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.last().map(|month| month.year), Some(now.year())); + assert_eq!(dash.timeline.last().map(|month| month.label.clone()), Some(month_abbreviation(now.month()))); + assert_eq!(dash.timeline.iter().map(|month| month.count).sum::(), 2); assert_eq!(dash.recent_posts.len(), 2); assert!(!dash.category_cloud.is_empty()); - assert!(!dash.timeline.is_empty()); assert_eq!(dash.recent_posts[0].title, "Second"); assert_eq!(first.title, "First"); } @@ -2757,17 +2763,27 @@ impl BdsApp { let image_count = media.iter().filter(|item| item.mime_type.starts_with("image/")).count(); let total_media_size = media.iter().map(|item| item.size).sum::(); - let mut timeline = monthly_counts - .into_iter() + let now = chrono::Utc::now(); + let current_year = now.year(); + let current_month = now.month() as i32; + let timeline = (0..12) .rev() - .take(12) - .map(|((year, month), count)| DashboardTimelineMonth { - label: month_abbreviation(month), - year, - count, + .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, + } }) .collect::>(); - timeline.reverse(); let mut tag_cloud = tags .into_iter() diff --git a/crates/bds-ui/src/views/dashboard.rs b/crates/bds-ui/src/views/dashboard.rs index 069c4d4..56b51ee 100644 --- a/crates/bds-ui/src/views/dashboard.rs +++ b/crates/bds-ui/src/views/dashboard.rs @@ -1,4 +1,4 @@ -use iced::widget::{button, column, container, row, scrollable, text, Space}; +use iced::widget::{button, column, container, row, scrollable, text, tooltip, Space}; use iced::{Alignment, Background, Color, Element, Length, Theme}; use bds_core::i18n::UiLocale; @@ -191,20 +191,17 @@ fn stat_card<'a>(label: &str, value: String, details: Vec) -> Element<'a .into() } -fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], locale: UiLocale) -> Element<'a, Message> { - if months.is_empty() { - return text(t(locale, "dashboard.noTimelineData")) - .size(13) - .color(Color::from_rgb(0.6, 0.62, 0.68)) - .into(); - } - +fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], _locale: UiLocale) -> Element<'a, Message> { let max_count = months.iter().map(|month| month.count).max().unwrap_or(1).max(1); row( months .iter() .map(|month| { - let height = 24.0 + (month.count as f32 / max_count as f32) * 96.0; + let height = if month.count == 0 { + 8.0 + } else { + 24.0 + (month.count as f32 / max_count as f32) * 96.0 + }; container( column![ text(month.count.to_string()).size(12).color(Color::WHITE), @@ -231,33 +228,51 @@ fn timeline_chart<'a>(months: &'a [DashboardTimelineMonth], locale: UiLocale) -> } fn tag_cloud<'a>(tags: &'a [DashboardTag], overflow_count: usize, locale: UiLocale) -> Element<'a, Message> { - let items = tags.iter().fold(column![inputs::section_header(&t(locale, "dashboard.tagCloud"))].spacing(8), |column, tag| { - let bg = parse_color(tag.color.as_deref()).unwrap_or(Color::from_rgb(0.18, 0.21, 0.28)); - column.push( - container( - row![ - text(tag.name.clone()).size(scale_font(tag.count)).color(contrast_color(bg)), - text(tag.count.to_string()).size(12).color(contrast_color(bg)), - ] - .spacing(8) - .align_y(Alignment::Center), - ) - .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() - }), - ) - }); - - let with_overflow = if overflow_count > 0 { - items.push(text(format!("{} {}", t(locale, "dashboard.and"), overflow_count)).size(12).color(Color::from_rgb(0.6, 0.62, 0.68))) + 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 { - items + 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() }; - container(with_overflow).width(Length::FillPortion(1)).into() + 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 category_cloud<'a>(categories: &'a [DashboardCategory], locale: UiLocale) -> Element<'a, Message> { diff --git a/locales/ui/de.json b/locales/ui/de.json index 213a672..9d500ff 100644 --- a/locales/ui/de.json +++ b/locales/ui/de.json @@ -337,6 +337,7 @@ "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", diff --git a/locales/ui/en.json b/locales/ui/en.json index 170476e..e051b34 100644 --- a/locales/ui/en.json +++ b/locales/ui/en.json @@ -337,6 +337,7 @@ "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", diff --git a/locales/ui/es.json b/locales/ui/es.json index a9a756a..bf6d315 100644 --- a/locales/ui/es.json +++ b/locales/ui/es.json @@ -337,6 +337,7 @@ "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", diff --git a/locales/ui/fr.json b/locales/ui/fr.json index 96d9828..7fd1d9c 100644 --- a/locales/ui/fr.json +++ b/locales/ui/fr.json @@ -337,6 +337,7 @@ "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", diff --git a/locales/ui/it.json b/locales/ui/it.json index bf984f3..8f49d60 100644 --- a/locales/ui/it.json +++ b/locales/ui/it.json @@ -337,6 +337,7 @@ "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",