diff --git a/packages/oneclient_app/src/components/charts.rs b/packages/oneclient_app/src/components/charts.rs index 8e94c22b..7b6d89c1 100644 --- a/packages/oneclient_app/src/components/charts.rs +++ b/packages/oneclient_app/src/components/charts.rs @@ -2,7 +2,7 @@ use freya::engine::prelude::{Paint, PaintStyle, PathBuilder, SkColor, SkRect}; use freya::prelude::*; use crate::theme::colors; -use crate::utils::format_duration_hm; +use crate::utils::format_duration; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ValueUnit { @@ -13,7 +13,7 @@ pub enum ValueUnit { impl ValueUnit { pub fn format(self, value: i64) -> String { match self { - ValueUnit::Duration => format_duration_hm(value), + ValueUnit::Duration => format_duration(value), ValueUnit::Count => format!("{value}×"), } } @@ -264,6 +264,7 @@ fn slice_at(values: &[i64], x: f32, y: f32, size: f32) -> Option { pub struct BarChart { values: Vec, labels: Vec, + readout_labels: Option>, highlight: Option, unit: ValueUnit, height: f32, @@ -275,6 +276,7 @@ impl BarChart { Self { values, labels, + readout_labels: None, highlight: None, unit: ValueUnit::Duration, height: DEFAULT_HEIGHT, @@ -282,6 +284,11 @@ impl BarChart { } } + pub fn readout_labels(mut self, labels: Vec) -> Self { + self.readout_labels = Some(labels); + self + } + pub fn highlight(mut self, highlight: Option) -> Self { self.highlight = highlight; self @@ -304,7 +311,9 @@ impl BarChart { } fn readout_name(&self, i: usize) -> String { - self.labels + self.readout_labels + .as_ref() + .unwrap_or(&self.labels) .get(i) .cloned() .filter(|s| !s.is_empty()) @@ -343,13 +352,9 @@ impl Component for BarChart { for (i, &v) in self.values.iter().enumerate() { let frac = if max > 0 { v as f32 / max as f32 } else { 0.0 }; let h = if v > 0 { (frac * height).max(4.0) } else { 0.0 }; - let is_active = Some(i) == focus; - let color = if is_active { - colors::brand() - } else if active.is_some() { - colors::component_bg_hover().with_a(150) - } else { - colors::component_bg_hover() + let color = match active { + Some(a) if a != i => colors::component_bg_hover(), + _ => colors::brand(), }; bars = bars.child( @@ -381,14 +386,13 @@ impl Component for BarChart { label_row = label_row.child( rect() .width(Size::flex(1.0)) + .horizontal() .main_align(Alignment::Center) .child( label() .text(if shown { text.clone() } else { String::new() }) .font_size(10.) .max_lines(1) - .width(Size::fill()) - .text_align(TextAlign::Center) .color(colors::fg_secondary()), ), ); @@ -455,7 +459,7 @@ fn label_stride(labels: &[String], plot_width: f32, gap: f32) -> usize { .unwrap_or(0) .max(1); // ~6.2px per char at font_size 10 - let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 8.0; + let slot_px = max_chars as f32 * 6.2 + gap.max(8.0) + 12.0; let max_labels = (plot_width / slot_px).floor().max(1.0) as usize; if n <= max_labels { @@ -487,7 +491,7 @@ fn gridlines(height: f32) -> Element { fn y_axis(max: i64, unit: ValueUnit, height: f32) -> Element { let fmt = |v: i64| match unit { - ValueUnit::Duration => format_duration_hm(v), + ValueUnit::Duration => format_duration(v), ValueUnit::Count => v.to_string(), }; diff --git a/packages/oneclient_app/src/components/context_menu.rs b/packages/oneclient_app/src/components/context_menu.rs index 7f0edbe8..6f41d9c7 100644 --- a/packages/oneclient_app/src/components/context_menu.rs +++ b/packages/oneclient_app/src/components/context_menu.rs @@ -2,6 +2,7 @@ use freya::prelude::*; use crate::components::{Icon, IconType, OVERLAY_BASE_LEVEL, OverlayPopup}; use crate::theme::colors; +use crate::ui::clamp_to_window; const MENU_BG: Color = Color::from_rgb(25, 32, 38); const MENU_BORDER: Color = Color::from_argb(26, 255, 255, 255); @@ -10,8 +11,6 @@ const MENU_DANGER: Color = Color::from_rgb(242, 84, 90); /// Panel padding and border on both axes, which the measured list sits inside of const PANEL_INSET: f32 = 14.; -/// Gap the clamped menu keeps from the window edges -const EDGE_MARGIN: f32 = 8.; enum Entry { Action { @@ -215,26 +214,6 @@ impl Component for ContextMenu { } } -/// `root_size` is physical while the press position is logical, so it has to be -/// scaled down before the two are compared -fn clamp_to_window(x: f32, y: f32, menu_width: f32, menu_height: f32) -> (f32, f32) { - let platform = Platform::get(); - let scale = *platform.scale_factor.peek() as f32; - if scale <= 0. { - return (x, y); - } - - let window = *platform.root_size.peek(); - let clamp = |pos: f32, len: f32, limit: f32| { - pos.clamp(EDGE_MARGIN, (limit - len - EDGE_MARGIN).max(EDGE_MARGIN)) - }; - - ( - clamp(x, menu_width, window.width / scale), - clamp(y, menu_height, window.height / scale), - ) -} - #[derive(PartialEq)] struct ContextMenuRow { icon: IconType, diff --git a/packages/oneclient_app/src/components/date_range_picker.rs b/packages/oneclient_app/src/components/date_range_picker.rs new file mode 100644 index 00000000..56410f5c --- /dev/null +++ b/packages/oneclient_app/src/components/date_range_picker.rs @@ -0,0 +1,410 @@ +use chrono::{Datelike, Days, Months, NaiveDate}; +use freya::prelude::*; + +use crate::components::{Icon, IconType, OverlayPopup}; +use crate::theme::colors; +use crate::ui::{border_all_color, clamp_to_window}; +use crate::utils::format_day; + +const CELL: f32 = 30.; +const CELL_GAP: f32 = 2.; +const WEEKS: u32 = 6; +const POPUP_WIDTH: f32 = 7. * CELL + 6. * CELL_GAP + 24. + 2.; +const POPUP_OFFSET: f32 = 4.; + +pub type DateRange = (NaiveDate, NaiveDate); + +#[derive(PartialEq)] +pub struct DateRangePicker { + range: State>, + bounds: DateRange, + height: f32, +} + +impl DateRangePicker { + pub fn new(range: State>, bounds: DateRange) -> Self { + Self { + range, + bounds, + height: 30., + } + } + + pub fn height(mut self, height: f32) -> Self { + self.height = height; + self + } +} + +impl Component for DateRangePicker { + fn render(&self) -> impl IntoElement { + let mut range = self.range; + let (min, max) = self.bounds; + + let mut open = use_state(|| false); + let mut anchor = use_state(|| Option::::None); + let mut month = use_state(move || first_of_month(max)); + let mut hovering = use_state(|| false); + let mut trigger = use_state(Area::zero); + let mut panel_height = use_state(|| 0f32); + + let is_open = open(); + + let selected = *range.read(); + let pending = *anchor.read(); + let shown = *month.read(); + + let can_prev = shown > first_of_month(min); + let can_next = shown < first_of_month(max); + + let pick = move |day: NaiveDate| { + let started = *anchor.peek(); + match started { + Some(start) => { + range.set(Some((start.min(day), start.max(day)))); + anchor.set(None); + open.set(false); + } + None => anchor.set(Some(day)), + } + }; + + let trigger_text = match selected { + Some((a, b)) if a == b => format_day(a), + Some((a, b)) => format!("{} – {}", format_day(a), format_day(b)), + None => "Pick dates".to_string(), + }; + + rect() + .child( + rect() + .horizontal() + .height(Size::px(self.height)) + .cross_align(Alignment::Center) + .spacing(6.) + .padding(Gaps::new_symmetric(0., 10.)) + .corner_radius(CornerRadius::new_all(9.)) + .background(if hovering() { + colors::component_bg_hover() + } else { + colors::component_bg() + }) + .border(border_all_color( + 1., + if selected.is_some() { + colors::brand() + } else { + colors::component_border() + }, + )) + .cursor(CursorIcon::Pointer) + .a11y_role(AccessibilityRole::Button) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_sized(move |e: Event| { + trigger.set_if_modified(e.area); + }) + .on_press(move |e: Event| { + e.stop_propagation(); + anchor.set_if_modified(None); + open.toggle(); + }) + .child( + Icon::new(IconType::Calendar) + .size(13.) + .color(colors::fg_secondary()), + ) + .child( + label() + .text(trigger_text) + .font_size(12.) + .max_lines(1) + .color(colors::fg_primary()), + ), + ) + .maybe_child(is_open.then(|| { + let area = *trigger.read(); + let height = *panel_height.read(); + let (x, y) = clamp_to_window( + area.max_x() - POPUP_WIDTH, + area.max_y() + POPUP_OFFSET, + POPUP_WIDTH, + height, + ); + + let panel = rect() + .width(Size::px(POPUP_WIDTH)) + .vertical() + .spacing(8.) + .padding(12.) + .corner_radius(CornerRadius::new_all(10.)) + .border(border_all_color(1., colors::component_border())) + .background(colors::page_elevated()) + .on_sized(move |e: Event| { + panel_height.set_if_modified(e.area.height()); + }) + .opacity(if height > 0. { 1. } else { 0. }) + .child(month_header(shown, can_prev, can_next, move |step| { + let next = if step < 0 { + shown.checked_sub_months(Months::new(1)) + } else { + shown.checked_add_months(Months::new(1)) + }; + if let Some(next) = next { + month.set(next.clamp(first_of_month(min), first_of_month(max))); + } + })) + .child(weekday_header()) + .child(month_grid(shown, min, max, selected, pending, pick)) + .child(footer(selected.is_some(), pending, move || { + range.set(None); + anchor.set(None); + open.set(false); + })); + + OverlayPopup::new() + .backdrop(false) + .position(Position::new_global().top(y).left(x)) + .on_close(move |()| { + anchor.set(None); + open.set(false); + }) + .child(panel.into_element()) + .into_element() + })) + } +} + +fn first_of_month(date: NaiveDate) -> NaiveDate { + date.with_day(1).unwrap_or(date) +} + +fn month_header( + shown: NaiveDate, + can_prev: bool, + can_next: bool, + step: impl FnMut(i32) + Clone + 'static, +) -> Element { + let back = step.clone(); + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .child(arrow(IconType::ChevronsLeft, can_prev, back, -1)) + .child( + label() + .text(format!("{} {}", month_name(shown.month0()), shown.year())) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .width(Size::flex(1.0)) + .text_align(TextAlign::Center) + .color(colors::fg_primary()), + ) + .child(arrow(IconType::ChevronsRight, can_next, step, 1)) + .into_element() +} + +fn arrow( + icon: IconType, + enabled: bool, + mut step: impl FnMut(i32) + 'static, + delta: i32, +) -> Element { + rect() + .width(Size::px(24.)) + .height(Size::px(24.)) + .center() + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) + .maybe(enabled, |el| { + el.cursor(CursorIcon::Pointer) + .on_press(move |_| step(delta)) + }) + .child(Icon::new(icon).size(12.).color(if enabled { + colors::fg_secondary() + } else { + colors::fg_secondary().with_a(70) + })) + .into_element() +} + +fn weekday_header() -> Element { + let mut row = rect().horizontal().spacing(CELL_GAP); + for day in ["M", "T", "W", "T", "F", "S", "S"] { + row = row.child( + rect().width(Size::px(CELL)).center().child( + label() + .text(day.to_string()) + .font_size(10.) + .color(colors::fg_secondary()), + ), + ); + } + row.into_element() +} + +fn month_grid( + shown: NaiveDate, + min: NaiveDate, + max: NaiveDate, + selected: Option, + pending: Option, + pick: impl FnMut(NaiveDate) + Clone + 'static, +) -> Element { + let lead = shown.weekday().num_days_from_monday(); + let first_cell = shown + .checked_sub_days(Days::new(u64::from(lead))) + .unwrap_or(shown); + + let mut grid = rect().vertical().spacing(CELL_GAP); + for week in 0..WEEKS { + let mut row = rect().horizontal().spacing(CELL_GAP); + for weekday in 0..7u32 { + let offset = u64::from(week * 7 + weekday); + let Some(day) = first_cell.checked_add_days(Days::new(offset)) else { + continue; + }; + row = row.child(DayCell { + day, + outside: day.month0() != shown.month0(), + enabled: (min..=max).contains(&day), + in_range: selected.is_some_and(|(a, b)| (a..=b).contains(&day)), + edge: selected.is_some_and(|(a, b)| day == a || day == b) + || pending == Some(day), + pick: pick.clone(), + }); + } + grid = grid.child(row); + } + grid.into_element() +} + +struct DayCell { + day: NaiveDate, + outside: bool, + enabled: bool, + in_range: bool, + edge: bool, + pick: F, +} + +impl PartialEq for DayCell { + fn eq(&self, other: &Self) -> bool { + self.day == other.day + && self.outside == other.outside + && self.enabled == other.enabled + && self.in_range == other.in_range + && self.edge == other.edge + } +} + +impl Component for DayCell { + fn render(&self) -> impl IntoElement { + let mut hovering = use_state(|| false); + + let day = self.day; + let enabled = self.enabled; + let mut pick = self.pick.clone(); + + let background = if self.edge { + colors::brand() + } else if self.in_range { + colors::brand().with_a(50) + } else if hovering() && enabled { + colors::component_bg_hover() + } else { + Color::TRANSPARENT + }; + + let color = if self.edge { + colors::fg_primary() + } else if !enabled || self.outside { + colors::fg_secondary().with_a(90) + } else { + colors::fg_primary() + }; + + rect() + .width(Size::px(CELL)) + .height(Size::px(CELL - 2.)) + .center() + .corner_radius(CornerRadius::new_all(6.)) + .background(background) + .maybe(enabled, |el| { + el.cursor(CursorIcon::Pointer) + .on_pointer_enter(move |_| hovering.set(true)) + .on_pointer_leave(move |_| hovering.set(false)) + .on_press(move |e: Event| { + e.stop_propagation(); + pick(day); + }) + }) + .child( + label() + .text(day.day().to_string()) + .font_size(11.) + .color(color), + ) + } +} + +fn footer(has_range: bool, pending: Option, mut clear: impl FnMut() + 'static) -> Element { + let hint = match pending { + Some(day) => format!("From {} — pick an end date", format_day(day)), + None => "Click a start date, then an end date".to_string(), + }; + + rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Center) + .spacing(8.) + .child( + label() + .text(hint) + .font_size(10.) + .max_lines(2) + .width(Size::flex(1.0)) + .color(colors::fg_secondary()), + ) + .maybe_child(has_range.then(|| { + rect() + .padding(Gaps::new_symmetric(4., 8.)) + .corner_radius(CornerRadius::new_all(6.)) + .background(colors::component_bg()) + .cursor(CursorIcon::Pointer) + .a11y_role(AccessibilityRole::Button) + .on_press(move |e: Event| { + e.stop_propagation(); + clear(); + }) + .child( + label() + .text("Reset") + .font_size(11.) + .color(colors::fg_secondary()), + ) + .into_element() + })) + .into_element() +} + +fn month_name(month0: u32) -> &'static str { + const MONTHS: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + MONTHS[(month0 as usize).min(11)] +} diff --git a/packages/oneclient_app/src/components/mod.rs b/packages/oneclient_app/src/components/mod.rs index e3bf43ad..61f22f57 100644 --- a/packages/oneclient_app/src/components/mod.rs +++ b/packages/oneclient_app/src/components/mod.rs @@ -7,6 +7,7 @@ mod checkbox; mod cluster_landscape_art; mod cluster_update_popup; mod context_menu; +mod date_range_picker; mod optional_mods_popup; mod dropdown; mod dynamic_art; @@ -54,6 +55,7 @@ pub use checkbox::checkbox_labeled; pub use cluster_landscape_art::ClusterLandscapeArt; pub use cluster_update_popup::ClusterUpdatePopup; pub use context_menu::ContextMenu; +pub use date_range_picker::{DateRange, DateRangePicker}; pub use optional_mods_popup::OptionalModsPopup; pub use dropdown::Dropdown; pub use dynamic_art::{ART_PREVIEW_EDGE, DynamicArt}; diff --git a/packages/oneclient_app/src/components/notifications.rs b/packages/oneclient_app/src/components/notifications.rs index a960a129..b8d40b1d 100644 --- a/packages/oneclient_app/src/components/notifications.rs +++ b/packages/oneclient_app/src/components/notifications.rs @@ -11,7 +11,7 @@ use crate::{ notifications::{InboxEntry, NotificationActionKind}, theme::colors, transfer::TransferStats, - utils::{format_duration_hms, format_size}, + utils::{format_durations, format_size}, }; #[derive(PartialEq)] @@ -445,7 +445,7 @@ fn transfer_footer(stats: TransferStats) -> Element { let speed = format!("{}/s", format_size(stats.speed_bps as u64)); let eta = stats .eta_secs - .map(|secs| format!("{} left", format_duration_hms(secs as i64))); + .map(|secs| format!("{} left", format_durations(secs as i64))); rect() .horizontal() diff --git a/packages/oneclient_app/src/ui.rs b/packages/oneclient_app/src/ui.rs index 6875f759..e79f3b7c 100644 --- a/packages/oneclient_app/src/ui.rs +++ b/packages/oneclient_app/src/ui.rs @@ -92,6 +92,29 @@ pub fn relative_time(created_at: Instant) -> String { } } +/// Gap the clamped menu keeps from the window edges +pub const EDGE_MARGIN: f32 = 8.; + +/// `root_size` is physical while the press position is logical, so it has to be +/// scaled down before the two are compared +pub fn clamp_to_window(x: f32, y: f32, width: f32, height: f32) -> (f32, f32) { + let platform = Platform::get(); + let scale = *platform.scale_factor.peek() as f32; + if scale <= 0. { + return (x, y); + } + + let window = *platform.root_size.peek(); + let clamp = |pos: f32, len: f32, limit: f32| { + pos.clamp(EDGE_MARGIN, (limit - len - EDGE_MARGIN).max(EDGE_MARGIN)) + }; + + ( + clamp(x, width, window.width / scale), + clamp(y, height, window.height / scale), + ) +} + /// Returns the `Rect` not an `Element` so callers can inset or round it pub fn divider() -> Rect { rect() @@ -114,6 +137,14 @@ pub fn centered_note(text: &str) -> Element { .into_element() } +pub fn columns_for(width: f32, min_cell: f32, max: usize, gap: f32) -> usize { + let max = max.max(1); + if width <= 0. { + return max; + } + (((width + gap) / (min_cell + gap)).floor() as usize).clamp(1, max) +} + /// Short final rows are padded with empty flex cells so tiles keep the column width pub fn flow_grid(items: Vec, cols: usize, mut width: State, gap: f32) -> Element { let cols = cols.max(1); diff --git a/packages/oneclient_app/src/utils.rs b/packages/oneclient_app/src/utils.rs index ada19be8..15ac17cb 100644 --- a/packages/oneclient_app/src/utils.rs +++ b/packages/oneclient_app/src/utils.rs @@ -245,22 +245,28 @@ pub fn format_memory_gb(mb: u32) -> String { } } -/// `7384` -> `2h 3m` `540` -> `9m` `0` -> `0m` -pub fn format_duration_hm(secs: i64) -> String { +/// `45` -> `45s` `540` -> `9m` `7200` -> `2h` `7384` -> `2h 3m` `180000` -> `2d 2h` +pub fn format_duration(secs: i64) -> String { if secs <= 0 { return "0m".to_string(); } - let hours = secs / 3600; + + let days = secs / 86_400; + let hours = (secs % 86_400) / 3600; let minutes = (secs % 3600) / 60; - if hours > 0 { - format!("{hours}h {minutes}m") - } else { - format!("{minutes}m") + + match (days, hours, minutes) { + (0, 0, 0) => format!("{secs}s"), + (0, 0, m) => format!("{m}m"), + (0, h, 0) => format!("{h}h"), + (0, h, m) => format!("{h}h {m}m"), + (d, 0, _) => format!("{d}d"), + (d, h, _) => format!("{d}d {h}h"), } } /// `3723` -> `1h 2m` `83` -> `1m 23s` `45` -> `45s` -pub fn format_duration_hms(secs: i64) -> String { +pub fn format_durations(secs: i64) -> String { if secs <= 0 { return "0s".to_string(); } @@ -466,6 +472,17 @@ mod tests { assert_eq!(format_memory_gb(1536), "1.5 GB"); } + #[test] + fn durations_never_show_a_zero_unit() { + assert_eq!(format_duration(45), "45s"); + assert_eq!(format_duration(540), "9m"); + assert_eq!(format_duration(7200), "2h"); + assert_eq!(format_duration(7384), "2h 3m"); + assert_eq!(format_duration(180_000), "2d 2h"); + assert_eq!(format_duration(172_800), "2d"); + assert_eq!(format_duration(0), "0m"); + } + #[test] fn a_bare_legacy_version_keeps_its_line_name() { let clusters = [versioned(1, "1.21")]; diff --git a/packages/oneclient_app/src/view/app/analytics/charts.rs b/packages/oneclient_app/src/view/app/analytics/charts.rs index 7eb925a3..e349fae6 100644 --- a/packages/oneclient_app/src/view/app/analytics/charts.rs +++ b/packages/oneclient_app/src/view/app/analytics/charts.rs @@ -1,83 +1,131 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; -use chrono::{Local, NaiveDate}; +use chrono::{Datelike, Local, Months, NaiveDate}; use freya::prelude::*; use oneclient_core::game::{DayPlaytime, PlaytimeStats, WEEKDAY_LABELS}; -use crate::components::{BarChart, IconType, Segment, SegmentedControl, ValueUnit}; -use crate::utils::{format_day, format_duration_hm, format_hour, parse_day}; +use crate::components::{ + BarChart, DateRange, DateRangePicker, IconType, Segment, SegmentedControl, ValueUnit, +}; +use crate::utils::{format_day, format_duration, format_hour, parse_day}; use super::{chart_card, nav_button}; #[derive(Clone, Copy, PartialEq)] -enum WhenMode { - Weekday, +enum PlaytimeMode { Hour, + Day, + Month, } #[derive(PartialEq)] -pub(super) struct WhenChart { +pub(super) struct PlaytimeChart { per_weekday: Vec, per_hour: Vec, peak_weekday: Option, peak_hour: Option, + daily: Vec, } -impl WhenChart { +impl PlaytimeChart { pub(super) fn from_stats(stats: &PlaytimeStats) -> Self { Self { per_weekday: stats.per_weekday.to_vec(), per_hour: stats.per_hour.to_vec(), peak_weekday: stats.peak_weekday, peak_hour: stats.peak_hour, + daily: stats.daily.clone(), } } } -impl Component for WhenChart { +impl Component for PlaytimeChart { fn render(&self) -> impl IntoElement { - let mode = use_state(|| WhenMode::Weekday); + let mode = use_state(|| PlaytimeMode::Day); let m = *mode.read(); let control = SegmentedControl::new(mode) .height(30.) - .segment(Segment::new(WhenMode::Weekday).label("Day")) - .segment(Segment::new(WhenMode::Hour).label("Hour")) + .segment(Segment::new(PlaytimeMode::Hour).label("Hour")) + .segment(Segment::new(PlaytimeMode::Day).label("Day")) + .segment(Segment::new(PlaytimeMode::Month).label("Month")) .into_element(); - let (subtitle, chart) = match m { - WhenMode::Weekday => { - let day = self - .peak_weekday - .map(|i| WEEKDAY_LABELS[i].to_string()) - .unwrap_or_else(|| "—".to_string()); - ( - format!("Most active on {day}"), - BarChart::new(self.per_weekday.clone(), weekday_labels()) - .highlight(self.peak_weekday) - .unit(ValueUnit::Duration) - .gap(6.) - .into_element(), - ) - } - WhenMode::Hour => { + let (title, subtitle, chart) = match m { + PlaytimeMode::Hour => { let hour = self .peak_hour .map(format_hour) .unwrap_or_else(|| "—".to_string()); ( - format!("Peak around {hour}"), + "Playtime by hour", + format!("Every day's {hour} hour, added up"), BarChart::new(self.per_hour.clone(), (0..24).map(format_hour).collect()) + .readout_labels( + (0..24) + .map(|h| format!("All {} hours", format_hour(h))) + .collect(), + ) .highlight(self.peak_hour) .unit(ValueUnit::Duration) .gap(3.) .into_element(), ) } + PlaytimeMode::Day => { + let day = self + .peak_weekday + .map(|i| WEEKDAY_FULL[i].to_string()) + .unwrap_or_else(|| "—".to_string()); + ( + "Playtime by day", + format!("Every {day} you have played, added up"), + BarChart::new(self.per_weekday.clone(), weekday_labels()) + .readout_labels( + WEEKDAY_FULL.iter().map(|d| format!("All {d}s")).collect(), + ) + .highlight(self.peak_weekday) + .unit(ValueUnit::Duration) + .gap(6.) + .into_element(), + ) + } + PlaytimeMode::Month => { + let months = monthly_series(&self.daily); + let values: Vec = months.iter().map(|(_, s)| *s).collect(); + let labels: Vec = months + .iter() + .map(|(m, _)| MONTH_ABBR[m.month0() as usize].to_string()) + .collect(); + let readout: Vec = months + .iter() + .map(|(m, _)| format!("{} {}", MONTH_ABBR[m.month0() as usize], m.year())) + .collect(); + let best = values + .iter() + .enumerate() + .max_by_key(|(_, v)| **v) + .filter(|(_, v)| **v > 0) + .map(|(i, _)| i); + let subtitle = match best { + Some(i) => format!("Best month so far: {}", readout[i]), + None => "No sessions recorded yet".to_string(), + }; + ( + "Playtime by month", + subtitle, + BarChart::new(values, labels) + .readout_labels(readout) + .highlight(best) + .unit(ValueUnit::Duration) + .gap(4.) + .into_element(), + ) + } }; - chart_card("When you play", subtitle, Some(control), chart) + chart_card(title, subtitle, Some(control), chart) } } @@ -113,25 +161,36 @@ impl Component for DailyChart { fn render(&self) -> impl IntoElement { let range = use_state(|| Range::TwoWeeks); let mut offset = use_state(|| 0usize); + let custom = use_state(|| Option::::None); let series = continuous_series(&self.daily); - if series.is_empty() { + let (Some((first_day, _)), Some((last_day, _))) = (series.first(), series.last()) else { return chart_card( "Daily playtime", "No sessions recorded yet".to_string(), None, rect().height(Size::px(120.)).into_element(), ); - } + }; + let (first_day, last_day) = (*first_day, *last_day); - let window = range.read().days(); let total = series.len(); - let max_offset = total.saturating_sub(1) / window; - let off = (*offset.read()).min(max_offset); + let picked = *custom.read(); - let end = total.saturating_sub(off * window); - let start = end.saturating_sub(window); - let slice = &series[start..end]; + let (start, end) = match picked { + Some((from, to)) => ( + day_index(first_day, from).min(total), + (day_index(first_day, to) + 1).min(total), + ), + None => { + let window = range.read().days(); + let max_offset = total.saturating_sub(1) / window; + let off = (*offset.read()).min(max_offset); + let end = total.saturating_sub(off * window); + (end.saturating_sub(window), end) + } + }; + let slice = &series[start.min(end)..end]; let values: Vec = slice.iter().map(|(_, s)| *s).collect(); let labels: Vec = slice.iter().map(|(d, _)| format_day(*d)).collect(); @@ -141,16 +200,21 @@ impl Component for DailyChart { (Some((a, _)), Some((b, _))) => { format!( "{} · {} – {}", - format_duration_hm(win_total), + format_duration(win_total), format_day(*a), format_day(*b) ) } - _ => format_duration_hm(win_total), + _ => format_duration(win_total), }; - let can_older = off < max_offset; - let can_newer = off > 0; + let paging = picked.is_none(); + let window = range.read().days(); + let max_offset = total.saturating_sub(1) / window; + let off = (*offset.read()).min(max_offset); + let can_older = paging && off < max_offset; + let can_newer = paging && off > 0; + let nav = rect() .horizontal() .cross_align(Alignment::Center) @@ -168,11 +232,13 @@ impl Component for DailyChart { .child( SegmentedControl::new(range) .height(30.) + .disabled(!paging) .segment(Segment::new(Range::TwoWeeks).label("2W")) .segment(Segment::new(Range::Month).label("1M")) .segment(Segment::new(Range::Quarter).label("3M")) .into_element(), ) + .child(DateRangePicker::new(custom, (first_day, last_day)).height(30.)) .into_element(); chart_card( @@ -187,6 +253,10 @@ impl Component for DailyChart { } } +fn day_index(first: NaiveDate, day: NaiveDate) -> usize { + (day - first).num_days().max(0) as usize +} + fn continuous_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { let map: HashMap = daily .iter() @@ -211,6 +281,41 @@ fn continuous_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { out } +fn monthly_series(daily: &[DayPlaytime]) -> Vec<(NaiveDate, i64)> { + let mut totals: BTreeMap = BTreeMap::new(); + for day in daily { + let Some(date) = parse_day(&day.date) else { + continue; + }; + let Some(month) = date.with_day(1) else { + continue; + }; + *totals.entry(month).or_insert(0) += day.secs; + } + + let (Some(first), Some(last)) = ( + totals.keys().min().copied(), + totals.keys().max().copied(), + ) else { + return Vec::new(); + }; + + let mut out = Vec::new(); + let mut cur = first; + while cur <= last { + out.push((cur, totals.get(&cur).copied().unwrap_or(0))); + cur = match cur.checked_add_months(Months::new(1)) { + Some(next) => next, + None => break, + }; + } + out +} + +const MONTH_ABBR: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option { if session_secs.len() < 2 && !force { return None; @@ -253,6 +358,16 @@ pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option Vec { WEEKDAY_LABELS.iter().map(|s| (*s).to_string()).collect() } diff --git a/packages/oneclient_app/src/view/app/analytics/mod.rs b/packages/oneclient_app/src/view/app/analytics/mod.rs index 52ee6097..3ffb5255 100644 --- a/packages/oneclient_app/src/view/app/analytics/mod.rs +++ b/packages/oneclient_app/src/view/app/analytics/mod.rs @@ -5,13 +5,13 @@ use oneclient_core::game::{Analytics, DayPlaytime, PlaytimeStats}; use crate::components::{Icon, IconType}; use crate::theme::colors; -use crate::ui::border_all_color; +use crate::ui::{border_all_color, columns_for}; mod charts; mod servers; mod tiles; -use charts::{DailyChart, WhenChart, distribution_card}; +use charts::{DailyChart, PlaytimeChart, distribution_card}; use servers::servers_section; use tiles::{personas_row, tiles_row}; @@ -27,14 +27,13 @@ fn analytics_body_inner(analytics: &Analytics, force_all: bool) -> Element { root = root.child(tiles_row(analytics, force_all)); - if !stats.personas.is_empty() { - root = root.child(personas_row(&stats.personas)); + if !analytics.personas.is_empty() { + root = root.child(personas_row(&analytics.personas)); } - let mut charts: Vec = vec![ - WhenChart::from_stats(stats).into_element(), - DailyChart::new(stats.daily.clone()).into_element(), - ]; + root = root.child(DailyChart::new(stats.daily.clone())); + + let mut charts: Vec = vec![PlaytimeChart::from_stats(stats).into_element()]; if let Some(dist) = distribution_card(&stats.session_secs, force_all) { charts.push(dist); } @@ -127,40 +126,64 @@ fn empty_analytics() -> Analytics { per_hour: [0; 24], daily, session_secs: Vec::new(), + longest_session_secs: 0, active_days: 0, + current_streak: 0, + longest_streak: 0, avg_secs_per_active_day: 0.0, peak_hour: None, peak_weekday: None, night_share: 0.0, - personas: Vec::new(), }; Analytics { playtime, servers: Vec::new(), + personas: Vec::new(), } } +const CHART_GAP: f32 = 16.; +const CHART_MIN_W: f32 = 420.; + fn charts_grid(cards: Vec) -> Element { - let mut grid = rect().vertical().width(Size::fill()).spacing(24.); - for pair in cards.chunks(2) { - if pair.len() == 1 { - grid = grid.child(rect().width(Size::fill()).child(pair[0].clone())); - continue; + ChartsGrid { cards }.into_element() +} + +#[derive(PartialEq)] +struct ChartsGrid { + cards: Vec, +} + +impl Component for ChartsGrid { + fn render(&self) -> impl IntoElement { + let mut width = use_state(|| 0f32); + let cols = columns_for(*width.read(), CHART_MIN_W, 2, CHART_GAP); + + let mut grid = rect().vertical().width(Size::fill()).spacing(24.); + for chunk in self.cards.chunks(cols) { + let mut row = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .cross_align(Alignment::Start) + .spacing(CHART_GAP); + for card in chunk { + row = row.child(rect().width(Size::flex(1.0)).child(card.clone())); + } + for _ in chunk.len()..cols { + row = row.child(rect().width(Size::flex(1.0))); + } + grid = grid.child(row); } - let mut row = rect() - .horizontal() - .content(Content::Flex) - .width(Size::fill()) - .cross_align(Alignment::Start) - .spacing(16.); - for card in pair { - row = row.child(rect().width(Size::flex(1.0)).child(card.clone())); - } - grid = grid.child(row); + grid.on_sized(move |e: Event| { + let w = e.area.width(); + if (w - *width.peek()).abs() > 0.5 { + width.set(w); + } + }) } - grid.into_element() } pub(super) fn chart_card( diff --git a/packages/oneclient_app/src/view/app/analytics/servers.rs b/packages/oneclient_app/src/view/app/analytics/servers.rs index c08d9827..71f5ceb6 100644 --- a/packages/oneclient_app/src/view/app/analytics/servers.rs +++ b/packages/oneclient_app/src/view/app/analytics/servers.rs @@ -3,17 +3,39 @@ use freya::prelude::*; use oneclient_core::game::ServerStat; use crate::components::{ - Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, ValueUnit, slice_color, + Button, Icon, IconType, OverlayPopup, PieChart, ScrollArea, Segment, SegmentedControl, + ValueUnit, slice_color, }; use crate::hooks::{loaded_image, use_cached_image}; use crate::theme::colors; use crate::ui::{ImageFallbackExt, border_all_color}; -use crate::utils::{format_duration_hm, plural}; +use crate::utils::{format_duration, plural}; use super::{card, card_header}; -/// Number of servers charted the rest are grouped into an "Other" slice -const TOP_SERVERS: usize = 10; +const MAX_SLICES: usize = 10; + +#[derive(Clone, Copy, PartialEq)] +enum ServerMetric { + Playtime, + Joins, +} + +impl ServerMetric { + fn of(self, s: &ServerStat) -> i64 { + match self { + ServerMetric::Playtime => s.total_secs, + ServerMetric::Joins => s.joins, + } + } + + fn unit(self) -> ValueUnit { + match self { + ServerMetric::Playtime => ValueUnit::Duration, + ServerMetric::Joins => ValueUnit::Count, + } + } +} pub(super) fn servers_section(servers: &[ServerStat]) -> Element { ServersSection { @@ -31,27 +53,46 @@ impl Component for ServersSection { fn render(&self) -> impl IntoElement { let hovered = use_state(|| Option::::None); let mut show_all = use_state(|| false); + let metric = use_state(|| ServerMetric::Playtime); + let m = *metric.read(); let total_joins: i64 = self.servers.iter().map(|s| s.joins).sum(); let mut sorted = self.servers.clone(); - sorted.sort_by_key(|s| std::cmp::Reverse(s.total_secs)); + sorted.sort_by_key(|s| std::cmp::Reverse(m.of(s))); - let (top, rest) = sorted.split_at(sorted.len().min(TOP_SERVERS)); - let mut values: Vec = top.iter().map(|s| s.total_secs).collect(); + let keep = if sorted.len() > MAX_SLICES { + MAX_SLICES - 1 + } else { + sorted.len() + }; + let (top, rest) = sorted.split_at(keep); + let mut values: Vec = top.iter().map(|s| m.of(s)).collect(); let mut labels: Vec = top.iter().map(server_label).collect(); if !rest.is_empty() { - values.push(rest.iter().map(|s| s.total_secs).sum()); - labels.push(format!("{} more", rest.len())); + values.push(rest.iter().map(|s| m.of(s)).sum()); + labels.push("Other".to_string()); } let details = (!self.servers.is_empty()).then(|| { - Button::new() - .ghost() - .small() - .on_press(move |_| show_all.set(true)) - .child(Icon::new(IconType::Eye).size(12.)) - .text("Details") + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child( + SegmentedControl::new(metric) + .height(30.) + .segment(Segment::new(ServerMetric::Playtime).label("Playtime")) + .segment(Segment::new(ServerMetric::Joins).label("Joins")), + ) + .child( + Button::new() + .ghost() + .small() + .on_press(move |_| show_all.set(true)) + .child(Icon::new(IconType::Eye).size(12.)) + .text("Details"), + ) .into_element() }); @@ -78,13 +119,14 @@ impl Component for ServersSection { .spacing(20.) .child( PieChart::new(values.clone(), labels.clone()) - .unit(ValueUnit::Duration) + .unit(m.unit()) .hovered(hovered), ) .child(rect().width(Size::flex(1.0)).child(legend( &values, &labels, *hovered.read(), + m.unit(), ))), ) .maybe_child(show_all.read().then(|| { @@ -105,7 +147,7 @@ impl Component for ServersSection { } } -fn legend(values: &[i64], labels: &[String], active: Option) -> Element { +fn legend(values: &[i64], labels: &[String], active: Option, unit: ValueUnit) -> Element { let total: i64 = values.iter().sum(); let mut list = rect().vertical().width(Size::fill()).spacing(4.); @@ -155,7 +197,7 @@ fn legend(values: &[i64], labels: &[String], active: Option) -> Element { ) .child( label() - .text(format_duration_hm(*value)) + .text(unit.format(*value)) .font_size(11.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), @@ -223,7 +265,7 @@ impl Component for ServerDetailsPopup { .child( label() .text(format!( - "{} server{} by playtime", + "All {} server{} by playtime", servers.len(), plural(servers.len() as i64) )) @@ -332,7 +374,7 @@ impl Component for ServerRow { ) .child( label() - .text(format_duration_hm(s.total_secs)) + .text(format_duration(s.total_secs)) .font_size(12.) .font_weight(FontWeight::SEMI_BOLD) .color(colors::fg_primary()), diff --git a/packages/oneclient_app/src/view/app/analytics/tiles.rs b/packages/oneclient_app/src/view/app/analytics/tiles.rs index bdfa60a5..97541f48 100644 --- a/packages/oneclient_app/src/view/app/analytics/tiles.rs +++ b/packages/oneclient_app/src/view/app/analytics/tiles.rs @@ -4,134 +4,278 @@ use oneclient_core::game::{Analytics, Persona}; use crate::components::{Icon, IconType}; use crate::theme::colors; -use crate::ui::border_all_color; -use crate::utils::format_duration_hm; +use crate::ui::{border_all_color, columns_for, flow_grid}; +use crate::utils::{format_duration, plural}; use super::card; -pub(super) fn tiles_row(analytics: &Analytics, force_all: bool) -> Element { - let stats = &analytics.playtime; - let avg_session = if stats.session_count > 0 { - stats.total_secs / stats.session_count as i64 - } else { - 0 - }; - let total_joins: i64 = analytics.servers.iter().map(|s| s.joins).sum(); +pub(super) fn tiles_row(analytics: &Analytics, _force_all: bool) -> Element { + TilesRow { + analytics: analytics.clone(), + } + .into_element() +} - let mut tiles = vec![ - stat_tile( +const TILE_GAP: f32 = 14.; +const TILE_MIN_W: f32 = 190.; +const TILE_H: f32 = 104.; + +#[derive(PartialEq)] +struct TilesRow { + analytics: Analytics, +} + +impl Component for TilesRow { + fn render(&self) -> impl IntoElement { + let width = use_state(|| 0f32); + let cols = columns_for(*width.read(), TILE_MIN_W, 4, TILE_GAP); + + let stats = &self.analytics.playtime; + let avg_session = if stats.session_count > 0 { + stats.total_secs / stats.session_count as i64 + } else { + 0 + }; + + let hero = hero_tile( IconType::ClockRewind, "Total playtime", - format_duration_hm(stats.total_secs), - ), - stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()), - stat_tile( - IconType::Calendar, - "Avg / session", - format_duration_hm(avg_session), - ), - stat_tile( - IconType::Rocket02, - "Active days", - stats.active_days.to_string(), - ), - ]; - if force_all || !analytics.servers.is_empty() { - tiles.push(stat_tile( - IconType::Globe01, - "Server joins", - format!("{total_joins}"), - )); - } + format_duration(stats.total_secs), + format!( + "over {} session{}", + stats.session_count, + plural(stats.session_count as i64) + ), + ); + let mut rest = vec![ + stat_tile(IconType::Play, "Sessions", stats.session_count.to_string()), + stat_tile( + IconType::Sliders04, + "Avg / session", + format_duration(avg_session), + ), + stat_tile( + IconType::Maximize01, + "Longest session", + format_duration(stats.longest_session_secs), + ), + stat_tile( + IconType::Rocket02, + "Day streak", + streak_value(stats.current_streak), + ), + stat_tile( + IconType::CheckCircle, + "Best streak", + streak_value(stats.longest_streak), + ), + stat_tile( + IconType::Calendar, + "Days played", + stats.active_days.to_string(), + ), + ]; + + let mut root = rect().vertical().width(Size::fill()).spacing(TILE_GAP); + + if cols >= 3 { + let alongside: Vec = rest.drain(..(cols - 2).min(rest.len())).collect(); + let mut lead = rect() + .horizontal() + .content(Content::Flex) + .width(Size::fill()) + .height(Size::px(TILE_H)) + .spacing(TILE_GAP) + .child( + rect() + .width(Size::flex(2.0)) + .height(Size::fill()) + .child(hero), + ); + for tile in alongside { + lead = lead.child( + rect() + .width(Size::flex(1.0)) + .height(Size::fill()) + .child(tile), + ); + } + root = root.child(lead); + } else { + root = root.child( + rect() + .width(Size::fill()) + .height(Size::px(TILE_H)) + .child(hero), + ); + } + + let rows: Vec = rest + .into_iter() + .map(|tile| { + rect() + .height(Size::px(TILE_H)) + .width(Size::flex(1.0)) + .child(tile) + .into_element() + }) + .collect(); + + root.child(flow_grid(rows, cols, width, TILE_GAP)) + } +} + +fn streak_value(days: usize) -> String { + format!("{days}d") +} + +fn icon_chip(icon: IconType, size: f32, tint: Color) -> Element { rect() - .horizontal() - .content(Content::Flex) + .width(Size::px(size)) + .height(Size::px(size)) + .corner_radius(CornerRadius::new_all(size * 0.5)) + .background(tint.with_a(38)) + .center() + .child(Icon::new(icon).size(size * 0.52).color(tint)) + .into_element() +} + +fn hero_tile(icon: IconType, caption: &str, value: String, note: String) -> Element { + card() .width(Size::fill()) - .spacing(16.) - .children(tiles) + .height(Size::fill()) + .padding(Gaps::new_symmetric(16., 20.)) + .spacing(10.) + .background(colors::brand().with_a(18)) + .border(border_all_color(1., colors::brand().with_a(90))) + .child( + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(8.) + .child(icon_chip(icon, 24., colors::brand())) + .child( + label() + .text(caption.to_string()) + .font_size(12.) + .font_weight(FontWeight::MEDIUM) + .color(colors::fg_secondary()), + ), + ) + .child( + rect() + .horizontal() + .cross_align(Alignment::End) + .spacing(8.) + .child( + label() + .text(value) + .font_size(30.) + .font_weight(FontWeight::BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + rect().margin(Gaps::new(0., 0., 5., 0.)).child( + label() + .text(note) + .font_size(12.) + .max_lines(1) + .color(colors::fg_secondary()), + ), + ), + ) .into_element() } fn stat_tile(icon: IconType, caption: &str, value: String) -> Element { card() - .width(Size::flex(1.0)) + .width(Size::fill()) + .height(Size::fill()) .spacing(10.) .child( rect() .horizontal() .cross_align(Alignment::Center) .spacing(8.) - .child(Icon::new(icon).size(16.).color(colors::fg_secondary())) + .child(icon_chip(icon, 24., colors::fg_secondary())) .child( label() .text(caption.to_string()) .font_size(12.) + .max_lines(1) + .width(Size::fill()) .color(colors::fg_secondary()), ), ) .child( label() .text(value) - .font_size(26.) + .font_size(24.) .font_weight(FontWeight::BOLD) + .max_lines(1) .color(colors::fg_primary()), ) .into_element() } pub(super) fn personas_row(personas: &[Persona]) -> Element { - let mut row = rect().horizontal().width(Size::fill()).spacing(16.); - for persona in personas { - row = row.child(persona_card(*persona)); - } - row.into_element() + rect() + .horizontal() + .content(Content::Wrap { + wrap_spacing: Some(10.), + }) + .width(Size::fill()) + .spacing(10.) + .children(personas.iter().map(|p| persona_badge(*p))) + .into_element() +} + +fn persona_badge(persona: Persona) -> Element { + rect() + .horizontal() + .cross_align(Alignment::Center) + .spacing(7.) + .padding(Gaps::new_symmetric(6., 11.)) + .corner_radius(CornerRadius::new_all(8.)) + .background(colors::component_bg()) + .border(border_all_color(1., colors::component_border())) + .child( + Icon::new(persona_icon(persona)) + .size(13.) + .color(colors::brand()), + ) + .child( + label() + .text(persona.title().to_string()) + .font_size(13.) + .font_weight(FontWeight::SEMI_BOLD) + .max_lines(1) + .color(colors::fg_primary()), + ) + .child( + label() + .text(persona.description().to_string()) + .font_size(11.) + .max_lines(1) + .color(colors::fg_secondary()), + ) + .into_element() } fn persona_icon(persona: Persona) -> IconType { match persona { - Persona::NightOwl => IconType::ClockRewind, + Persona::Veteran => IconType::ClockRewind, + Persona::Marathoner => IconType::Maximize01, + Persona::Regular => IconType::CheckCircle, + Persona::Loyalist => IconType::Key01, + Persona::Explorer => IconType::Globe01, + Persona::NightOwl => IconType::Eye, + Persona::EarlyBird => IconType::Bell01, + Persona::WeekendWarrior => IconType::Calendar, Persona::Gamer => IconType::Rocket02, + Persona::Sprinter => IconType::Play, } } -fn persona_card(persona: Persona) -> Element { - card() - .width(Size::flex(1.0)) - .horizontal() - .cross_align(Alignment::Center) - .spacing(14.) - .border(border_all_color(1., colors::brand())) - .child( - rect() - .width(Size::px(44.)) - .height(Size::px(44.)) - .corner_radius(CornerRadius::new_all(22.)) - .background(colors::brand().with_a(40)) - .center() - .child( - Icon::new(persona_icon(persona)) - .size(22.) - .color(colors::brand()), - ), - ) - .child( - rect() - .vertical() - .spacing(2.) - .child( - label() - .text(persona.title().to_string()) - .font_size(18.) - .font_weight(FontWeight::SEMI_BOLD) - .color(colors::fg_primary()), - ) - .child( - label() - .text(persona.description().to_string()) - .font_size(12.) - .color(colors::fg_secondary()), - ), - ) - .into_element() -} diff --git a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs index d7b5920a..f9246e4f 100644 --- a/packages/oneclient_app/src/view/onboarding/downloading/progress.rs +++ b/packages/oneclient_app/src/view/onboarding/downloading/progress.rs @@ -3,7 +3,7 @@ use super::*; use crate::components::{Button, progress_track}; use crate::theme::colors; use crate::ui::border_all_color; -use crate::utils::{format_duration_hms, format_size}; +use crate::utils::{format_durations, format_size}; pub(super) fn failure_panel( failures: &[InstallFailure], @@ -244,7 +244,7 @@ fn build_stats(view: StatsView) -> Option { let elapsed = elapsed_secs?; let finished = !running && done >= total && total > 0; - let mut parts: Vec = vec![format!("Elapsed {}", format_duration_hms(elapsed as i64))]; + let mut parts: Vec = vec![format!("Elapsed {}", format_durations(elapsed as i64))]; if finished { parts.push("Complete".to_string()); @@ -253,7 +253,7 @@ fn build_stats(view: StatsView) -> Option { match transfer.and_then(|t| t.eta_secs) { Some(remaining) => parts.push(format!( "~{} left", - format_duration_hms(remaining as i64) + format_durations(remaining as i64) )), None => parts.push("~- left".to_string()), } diff --git a/packages/oneclient_core/src/game/analytics.rs b/packages/oneclient_core/src/game/analytics.rs index 653d091e..fd4fdf2a 100644 --- a/packages/oneclient_core/src/game/analytics.rs +++ b/packages/oneclient_core/src/game/analytics.rs @@ -1,15 +1,30 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::net::IpAddr; -use chrono::{DateTime, Datelike, Local, Timelike}; +use chrono::{DateTime, Datelike, Duration, Local, NaiveDate, TimeZone, Timelike, Utc}; use oneclient_db::dao::game_session as session_dao; use oneclient_db::models::{GameSessionServerRow, SessionSpan}; use crate::error::LauncherResult; const NIGHT_HOURS: [usize; 8] = [22, 23, 0, 1, 2, 3, 4, 5]; +const MORNING_HOURS: [usize; 4] = [5, 6, 7, 8]; +const WEEKEND_DAYS: [usize; 2] = [5, 6]; + const NIGHT_OWL_SHARE: f64 = 0.35; +const EARLY_BIRD_SHARE: f64 = 0.30; +const WEEKEND_SHARE: f64 = 0.55; +const LOYALIST_SHARE: f64 = 0.70; + const GAMER_SECS_PER_DAY: f64 = 5.0 * 3600.0; +const MARATHON_SECS: i64 = 8 * 3600; +const VETERAN_SECS: i64 = 500 * 3600; +const REGULAR_STREAK: usize = 7; +const EXPLORER_SERVERS: usize = 10; +const SPRINTER_SECS: i64 = 30 * 60; +const SPRINTER_SESSIONS: usize = 10; + +const MAX_PERSONAS: usize = 6; #[derive(Debug, Clone, PartialEq)] pub struct DayPlaytime { @@ -19,26 +34,112 @@ pub struct DayPlaytime { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Persona { + Veteran, + Marathoner, + Regular, + Loyalist, + Explorer, NightOwl, + EarlyBird, + WeekendWarrior, Gamer, + Sprinter, } impl Persona { pub fn title(self) -> &'static str { match self { + Persona::Veteran => "Veteran", + Persona::Marathoner => "Marathoner", + Persona::Regular => "Regular", + Persona::Loyalist => "Loyalist", + Persona::Explorer => "Explorer", Persona::NightOwl => "Night Owl", + Persona::EarlyBird => "Early Bird", + Persona::WeekendWarrior => "Weekend Warrior", Persona::Gamer => "Gamer", + Persona::Sprinter => "Sprinter", } } pub fn description(self) -> &'static str { match self { - Persona::NightOwl => "Most of your playtime happens after dark.", - Persona::Gamer => "You average over 5 hours on the days you play.", + Persona::Veteran => "500h+ played", + Persona::Marathoner => "An 8h+ session", + Persona::Regular => "A week straight", + Persona::Loyalist => "Mostly one server", + Persona::Explorer => "10+ servers joined", + Persona::NightOwl => "Mostly after dark", + Persona::EarlyBird => "Mostly before 9am", + Persona::WeekendWarrior => "Mostly weekends", + Persona::Gamer => "5h+ per day played", + Persona::Sprinter => "Short and frequent", } } } +fn personas_for(stats: &PlaytimeStats, servers: &[ServerStat]) -> Vec { + if stats.total_secs <= 0 { + return Vec::new(); + } + + let total = stats.total_secs as f64; + let share = |hours: &[usize]| -> f64 { + hours.iter().map(|&h| stats.per_hour[h]).sum::() as f64 / total + }; + let weekend = WEEKEND_DAYS + .iter() + .map(|&d| stats.per_weekday[d]) + .sum::() as f64 + / total; + + let server_secs: i64 = servers.iter().map(|s| s.total_secs).sum(); + let top_server_share = servers + .iter() + .map(|s| s.total_secs) + .max() + .filter(|_| server_secs > 0) + .map_or(0.0, |top| top as f64 / server_secs as f64); + + let mut lengths: Vec = stats.session_secs.clone(); + lengths.sort_unstable(); + let median = lengths.get(lengths.len() / 2).copied().unwrap_or(0); + + let earned = [ + (Persona::Veteran, stats.total_secs >= VETERAN_SECS), + ( + Persona::Marathoner, + stats.longest_session_secs >= MARATHON_SECS, + ), + (Persona::Regular, stats.longest_streak >= REGULAR_STREAK), + ( + Persona::Loyalist, + servers.len() > 1 && top_server_share >= LOYALIST_SHARE, + ), + (Persona::Explorer, servers.len() >= EXPLORER_SERVERS), + (Persona::NightOwl, share(&NIGHT_HOURS) >= NIGHT_OWL_SHARE), + ( + Persona::EarlyBird, + share(&MORNING_HOURS) >= EARLY_BIRD_SHARE, + ), + (Persona::WeekendWarrior, weekend >= WEEKEND_SHARE), + ( + Persona::Gamer, + stats.avg_secs_per_active_day > GAMER_SECS_PER_DAY, + ), + ( + Persona::Sprinter, + stats.session_count >= SPRINTER_SESSIONS && median < SPRINTER_SECS, + ), + ]; + + earned + .into_iter() + .filter_map(|(persona, earned)| earned.then_some(persona)) + .take(MAX_PERSONAS) + .collect() +} + pub const WEEKDAY_LABELS: [&str; 7] = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; #[derive(Debug, Clone, PartialEq)] @@ -49,22 +150,28 @@ pub struct PlaytimeStats { pub per_hour: [i64; 24], pub daily: Vec, pub session_secs: Vec, + pub longest_session_secs: i64, pub active_days: usize, + pub current_streak: usize, + pub longest_streak: usize, pub avg_secs_per_active_day: f64, pub peak_hour: Option, pub peak_weekday: Option, pub night_share: f64, - pub personas: Vec, } impl PlaytimeStats { pub fn from_spans(spans: &[SessionSpan]) -> Self { + Self::from_spans_on(spans, Local::now().date_naive()) + } + + fn from_spans_on(spans: &[SessionSpan], today: NaiveDate) -> Self { let mut per_weekday = [0i64; 7]; let mut per_hour = [0i64; 24]; let mut total_secs = 0i64; let mut night_secs = 0i64; let mut session_count = 0usize; - let mut daily: Vec = Vec::new(); + let mut by_day: BTreeMap = BTreeMap::new(); let mut session_secs: Vec = Vec::new(); for span in spans { @@ -83,26 +190,36 @@ impl PlaytimeStats { continue; } - let local = start.with_timezone(&Local); - let hour = local.hour() as usize; - let weekday = local.weekday().num_days_from_monday() as usize; - total_secs += secs; session_count += 1; session_secs.push(secs); - per_hour[hour] += secs; - per_weekday[weekday] += secs; - if NIGHT_HOURS.contains(&hour) { - night_secs += secs; - } - let date = local.format("%Y-%m-%d").to_string(); - match daily.last_mut() { - Some(last) if last.date == date => last.secs += secs, - _ => daily.push(DayPlaytime { date, secs }), - } + for_each_local_hour( + start.with_timezone(&Utc), + end.with_timezone(&Utc), + |at, slice| { + let hour = at.hour() as usize; + per_hour[hour] += slice; + per_weekday[at.weekday().num_days_from_monday() as usize] += slice; + if NIGHT_HOURS.contains(&hour) { + night_secs += slice; + } + *by_day.entry(at.date_naive()).or_insert(0) += slice; + }, + ); } + let longest_session_secs = session_secs.iter().copied().max().unwrap_or(0); + let dates: Vec = by_day.keys().copied().collect(); + let (current_streak, longest_streak) = streaks(&dates, today); + let daily: Vec = by_day + .iter() + .map(|(date, secs)| DayPlaytime { + date: date.format("%Y-%m-%d").to_string(), + secs: *secs, + }) + .collect(); + let active_days = daily.len(); let avg_secs_per_active_day = if active_days > 0 { total_secs as f64 / active_days as f64 @@ -118,14 +235,6 @@ impl PlaytimeStats { let peak_hour = argmax(&per_hour); let peak_weekday = argmax(&per_weekday); - let mut personas = Vec::new(); - if total_secs > 0 && night_share >= NIGHT_OWL_SHARE { - personas.push(Persona::NightOwl); - } - if avg_secs_per_active_day > GAMER_SECS_PER_DAY { - personas.push(Persona::Gamer); - } - Self { total_secs, session_count, @@ -133,16 +242,74 @@ impl PlaytimeStats { per_hour, daily, session_secs, + longest_session_secs, active_days, + current_streak, + longest_streak, avg_secs_per_active_day, peak_hour, peak_weekday, night_share, - personas, } } } +fn for_each_local_hour( + start: DateTime, + end: DateTime, + mut f: impl FnMut(DateTime, i64), +) { + let mut cur = start; + while cur < end { + let local = cur.with_timezone(&Local); + let boundary = next_local_hour(local); + let next = if boundary > cur { + boundary.min(end) + } else { + (cur + Duration::hours(1)).min(end) + }; + f(local, (next - cur).num_seconds()); + cur = next; + } +} + +fn next_local_hour(local: DateTime) -> DateTime { + let naive = local.naive_local(); + let top = match naive.date().and_hms_opt(naive.hour(), 0, 0) { + Some(top) => top + Duration::hours(1), + None => return (local + Duration::hours(1)).with_timezone(&Utc), + }; + + Local + .from_local_datetime(&top) + .earliest() + .map(|at| at.with_timezone(&Utc)) + .unwrap_or_else(|| (local + Duration::hours(1)).with_timezone(&Utc)) +} + +fn streaks(days: &[NaiveDate], today: NaiveDate) -> (usize, usize) { + let mut longest = 0usize; + let mut run = 0usize; + let mut prev: Option = None; + + for &day in days { + run = match prev { + Some(p) if p == day => run, + Some(p) if p.succ_opt() == Some(day) => run + 1, + _ => 1, + }; + longest = longest.max(run); + prev = Some(day); + } + + let current = match days.last() { + Some(last) if *last == today || Some(*last) == today.pred_opt() => run, + _ => 0, + }; + + (current, longest) +} + fn argmax(buckets: &[i64]) -> Option { buckets .iter() @@ -230,26 +397,38 @@ pub fn aggregate_servers(rows: &[GameSessionServerRow]) -> Vec { pub struct Analytics { pub playtime: PlaytimeStats, pub servers: Vec, + pub personas: Vec, +} + +impl Analytics { + pub fn new(playtime: PlaytimeStats, servers: Vec) -> Self { + let personas = personas_for(&playtime, &servers); + Self { + playtime, + servers, + personas, + } + } } #[tracing::instrument(level = "debug", skip(db))] pub async fn global_analytics(db: &oneclient_db::DbPool) -> LauncherResult { let spans = session_dao::all_session_spans(db).await?; let servers = session_dao::all_session_servers(db).await?; - Ok(Analytics { - playtime: PlaytimeStats::from_spans(&spans), - servers: aggregate_servers(&servers), - }) + Ok(Analytics::new( + PlaytimeStats::from_spans(&spans), + aggregate_servers(&servers), + )) } #[tracing::instrument(level = "debug", skip(db))] pub async fn cluster_analytics(db: &oneclient_db::DbPool, cluster_id: i64) -> LauncherResult { let spans = session_dao::session_spans_for_cluster(db, cluster_id).await?; let servers = session_dao::session_servers_for_cluster(db, cluster_id).await?; - Ok(Analytics { - playtime: PlaytimeStats::from_spans(&spans), - servers: aggregate_servers(&servers), - }) + Ok(Analytics::new( + PlaytimeStats::from_spans(&spans), + aggregate_servers(&servers), + )) } #[cfg(test)] @@ -267,7 +446,7 @@ mod tests { fn empty_has_no_personas() { let stats = PlaytimeStats::from_spans(&[]); assert_eq!(stats.total_secs, 0); - assert!(stats.personas.is_empty()); + assert!(personas_for(&stats, &[]).is_empty()); assert_eq!(stats.peak_hour, None); } @@ -331,11 +510,146 @@ mod tests { assert!(servers[1].is_ip); } + fn day(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").expect("a date") + } + + fn local_span(start: &str, end: &str) -> SessionSpan { + let at = |s: &str| { + Local + .from_local_datetime( + &chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S").expect("a time"), + ) + .earliest() + .expect("an unambiguous local time") + .to_rfc3339() + }; + SessionSpan { + started_at: at(start), + ended_at: Some(at(end)), + } + } + + #[test] + fn an_overnight_session_is_split_across_both_days() { + let spans = vec![local_span("2026-07-16 22:00:00", "2026-07-17 04:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-17")); + + assert_eq!(stats.total_secs, 6 * 3600); + assert_eq!(stats.session_count, 1, "still one session"); + assert_eq!(stats.daily.len(), 2); + assert_eq!(stats.daily[0].date, "2026-07-16"); + assert_eq!(stats.daily[0].secs, 2 * 3600); + assert_eq!(stats.daily[1].date, "2026-07-17"); + assert_eq!(stats.daily[1].secs, 4 * 3600); + } + + #[test] + fn a_multi_day_session_books_a_full_day_per_day() { + let spans = vec![local_span("2026-07-16 12:00:00", "2026-07-19 12:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-19")); + + assert_eq!(stats.daily.len(), 4, "three nights means four days touched"); + assert_eq!(stats.daily[1].secs, 24 * 3600); + assert_eq!(stats.daily[2].secs, 24 * 3600); + assert_eq!( + stats.daily.iter().map(|d| d.secs).sum::(), + stats.total_secs + ); + assert!(stats.per_hour.iter().all(|&h| h > 0)); + } + + #[test] + fn weekday_totals_follow_the_hours_played_not_the_start() { + let spans = vec![local_span("2026-07-17 23:00:00", "2026-07-18 09:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-07-18")); + + assert_eq!(stats.per_weekday[4], 3600, "Friday keeps only its one hour"); + assert_eq!(stats.per_weekday[5], 9 * 3600); + assert_eq!(stats.peak_weekday, Some(5)); + } + + #[test] + fn a_streak_survives_an_empty_today_but_not_an_empty_yesterday() { + let days = [day("2026-07-15"), day("2026-07-16"), day("2026-07-17")]; + + assert_eq!(streaks(&days, day("2026-07-17")), (3, 3)); + assert_eq!(streaks(&days, day("2026-07-18")), (3, 3), "today is not over"); + assert_eq!(streaks(&days, day("2026-07-19")), (0, 3), "yesterday was missed"); + } + + #[test] + fn a_gap_ends_the_run_but_keeps_the_record() { + let days = [ + day("2026-07-01"), + day("2026-07-02"), + day("2026-07-03"), + day("2026-07-10"), + ]; + assert_eq!(streaks(&days, day("2026-07-10")), (1, 3)); + assert_eq!(streaks(&[], day("2026-07-10")), (0, 0)); + } + #[test] fn gamer_needs_five_hour_average() { - let spans = vec![span("2026-01-01T12:00:00+00:00", "2026-01-01T18:00:00+00:00")]; - let stats = PlaytimeStats::from_spans(&spans); + let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); assert_eq!(stats.active_days, 1); - assert!(stats.personas.contains(&Persona::Gamer)); + assert!(personas_for(&stats, &[]).contains(&Persona::Gamer)); + } + + fn stat_server(address: &str, total_secs: i64) -> ServerStat { + ServerStat { + address: address.into(), + port: None, + joins: 1, + total_secs, + last_played: None, + is_ip: false, + } + } + + #[test] + fn loyalty_needs_somewhere_else_to_have_been() { + let spans = vec![local_span("2026-01-01 12:00:00", "2026-01-01 18:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-01")); + + let only_one = [stat_server("a.example.com", 3600)]; + assert!( + !personas_for(&stats, &only_one).contains(&Persona::Loyalist), + "a single server is loyalty to nothing" + ); + + let favourite = [ + stat_server("a.example.com", 9000), + stat_server("b.example.com", 1000), + ]; + assert!(personas_for(&stats, &favourite).contains(&Persona::Loyalist)); + } + + #[test] + fn an_overnight_session_still_reads_as_a_night_owl() { + let spans = vec![local_span("2026-01-01 22:00:00", "2026-01-02 06:00:00")]; + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-02")); + + assert!(personas_for(&stats, &[]).contains(&Persona::NightOwl)); + assert!(personas_for(&stats, &[]).contains(&Persona::Marathoner)); + } + + #[test] + fn no_more_personas_than_fit() { + let mut spans = Vec::new(); + for day_of in 1..=20 { + spans.push(local_span( + &format!("2026-01-{day_of:02} 22:00:00"), + &format!("2026-01-{:02} 08:00:00", day_of + 1), + )); + } + let stats = PlaytimeStats::from_spans_on(&spans, day("2026-01-21")); + let servers: Vec = (0..12) + .map(|i| stat_server(&format!("s{i}.example.com"), 3600)) + .collect(); + + assert_eq!(personas_for(&stats, &servers).len(), MAX_PERSONAS); } } diff --git a/packages/oneclient_core/src/game/reattach.rs b/packages/oneclient_core/src/game/reattach.rs index ef1757da..54843134 100644 --- a/packages/oneclient_core/src/game/reattach.rs +++ b/packages/oneclient_core/src/game/reattach.rs @@ -92,10 +92,16 @@ async fn classify( return None; }; - let alive = session - .pid - .and_then(|pid| u32::try_from(pid).ok()) - .filter(|pid| is_process_alive(*pid, session.pid_started_at.map(|t| t as u64))); + let alive = match (session.pid, session.pid_started_at) { + (Some(pid), Some(pid_started_at)) => u32::try_from(pid) + .ok() + .filter(|pid| is_process_alive(*pid, Some(pid_started_at as u64))), + (Some(pid), None) => { + tracing::debug!(cluster_id, pid, "session has no process start time; not re-adopting"); + None + } + (None, _) => None, + }; Some((cluster, session, started_at, alive)) } @@ -227,14 +233,16 @@ async fn reconcile( let now = Utc::now().max(started_at); + let plausible = |at: DateTime| (started_at..=now).contains(&at).then_some(at); + // Prefer the log's own timestamps over mtime mtime only helps when nothing // in the log carries a time let ended_at = replay .stopped_at - .or(replay.last_activity) - .or_else(|| log.as_ref().and_then(|log| log.modified)) - .unwrap_or(started_at) - .clamp(started_at, now); + .and_then(plausible) + .or_else(|| replay.last_activity.and_then(plausible)) + .or_else(|| log.as_ref().and_then(|log| log.modified).and_then(plausible)) + .unwrap_or(started_at); tracing::info!( cluster_id,