chore: improve stats page and cluster overview (#831)

* fix(stats): drop zero units from durations and cap the server pie at ten slices

* fix(stats): say out loud that "when you play" bars are lifetime totals

* feat(stats): paint every bar in the accent colour until one is hovered

* feat(stats): add a calendar range picker to daily playtime

* fix(stats): stop recovery inventing playtime and carry sessions over midnight

* feat(stats): more stats out of the data already recorded

* feat(stats): eight more personas, and let them see the server data

* fix(charts): stop axis labels being cut off to their own bar's width

* feat(stats): give the overview a headline stat instead of seven identical boxes

* feat(stats): turn personas into wrapping badges

* fix(stats): lay the overview out for the window it is actually in

* feat(stats): fold the monthly chart into one "Playtime by hour/day/month" card

* fix(stats): stop the total playtime card making the tile block so tall

* fix(stats): strip the persona badges back to plain tags
This commit is contained in:
LynithDev 2026-09-14 16:45:02 +02:00 • committed by GitHub
parent 2e0ba533fe
commit 80ba104861
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1360 additions and 271 deletions

View file

@ -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<usize> {
pub struct BarChart {
values: Vec<i64>,
labels: Vec<String>,
readout_labels: Option<Vec<String>>,
highlight: Option<usize>,
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<String>) -> Self {
self.readout_labels = Some(labels);
self
}
pub fn highlight(mut self, highlight: Option<usize>) -> 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(),
};

View file

@ -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,

View file

@ -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<Option<DateRange>>,
bounds: DateRange,
height: f32,
}
impl DateRangePicker {
pub fn new(range: State<Option<DateRange>>, 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::<NaiveDate>::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<SizedEventData>| {
trigger.set_if_modified(e.area);
})
.on_press(move |e: Event<PressEventData>| {
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<SizedEventData>| {
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<DateRange>,
pending: Option<NaiveDate>,
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<F: FnMut(NaiveDate) + Clone + 'static> {
day: NaiveDate,
outside: bool,
enabled: bool,
in_range: bool,
edge: bool,
pick: F,
}
impl<F: FnMut(NaiveDate) + Clone + 'static> PartialEq for DayCell<F> {
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<F: FnMut(NaiveDate) + Clone + 'static> Component for DayCell<F> {
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<PressEventData>| {
e.stop_propagation();
pick(day);
})
})
.child(
label()
.text(day.day().to_string())
.font_size(11.)
.color(color),
)
}
}
fn footer(has_range: bool, pending: Option<NaiveDate>, 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<PressEventData>| {
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)]
}

View file

@ -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};

View file

@ -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()

View file

@ -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<Element>, cols: usize, mut width: State<f32>, gap: f32) -> Element {
let cols = cols.max(1);

View file

@ -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")];

View file

@ -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<i64>,
per_hour: Vec<i64>,
peak_weekday: Option<usize>,
peak_hour: Option<usize>,
daily: Vec<DayPlaytime>,
}
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<i64> = months.iter().map(|(_, s)| *s).collect();
let labels: Vec<String> = months
.iter()
.map(|(m, _)| MONTH_ABBR[m.month0() as usize].to_string())
.collect();
let readout: Vec<String> = 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::<DateRange>::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<i64> = slice.iter().map(|(_, s)| *s).collect();
let labels: Vec<String> = 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<NaiveDate, i64> = 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<NaiveDate, i64> = 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<Element> {
if session_secs.len() < 2 && !force {
return None;
@ -253,6 +358,16 @@ pub(super) fn distribution_card(session_secs: &[i64], force: bool) -> Option<Ele
))
}
const WEEKDAY_FULL: [&str; 7] = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];
fn weekday_labels() -> Vec<String> {
WEEKDAY_LABELS.iter().map(|s| (*s).to_string()).collect()
}

View file

@ -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<Element> = 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<Element> = 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>) -> 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<Element>,
}
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<SizedEventData>| {
let w = e.area.width();
if (w - *width.peek()).abs() > 0.5 {
width.set(w);
}
})
}
grid.into_element()
}
pub(super) fn chart_card(

View file

@ -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::<usize>::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<i64> = 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<i64> = top.iter().map(|s| m.of(s)).collect();
let mut labels: Vec<String> = 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<usize>) -> Element {
fn legend(values: &[i64], labels: &[String], active: Option<usize>, 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<usize>) -> 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()),

View file

@ -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<Element> = 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<Element> = 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()
}

View file

@ -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<Element> {
let elapsed = elapsed_secs?;
let finished = !running && done >= total && total > 0;
let mut parts: Vec<String> = vec![format!("Elapsed {}", format_duration_hms(elapsed as i64))];
let mut parts: Vec<String> = 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<Element> {
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()),
}

View file

@ -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<Persona> {
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::<i64>() as f64 / total
};
let weekend = WEEKEND_DAYS
.iter()
.map(|&d| stats.per_weekday[d])
.sum::<i64>() 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<i64> = 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<DayPlaytime>,
pub session_secs: Vec<i64>,
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<usize>,
pub peak_weekday: Option<usize>,
pub night_share: f64,
pub personas: Vec<Persona>,
}
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<DayPlaytime> = Vec::new();
let mut by_day: BTreeMap<NaiveDate, i64> = BTreeMap::new();
let mut session_secs: Vec<i64> = 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<NaiveDate> = by_day.keys().copied().collect();
let (current_streak, longest_streak) = streaks(&dates, today);
let daily: Vec<DayPlaytime> = 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<Utc>,
end: DateTime<Utc>,
mut f: impl FnMut(DateTime<Local>, 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<Local>) -> DateTime<Utc> {
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<NaiveDate> = 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<usize> {
buckets
.iter()
@ -230,26 +397,38 @@ pub fn aggregate_servers(rows: &[GameSessionServerRow]) -> Vec<ServerStat> {
pub struct Analytics {
pub playtime: PlaytimeStats,
pub servers: Vec<ServerStat>,
pub personas: Vec<Persona>,
}
impl Analytics {
pub fn new(playtime: PlaytimeStats, servers: Vec<ServerStat>) -> 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<Analytics> {
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<Analytics> {
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::<i64>(),
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<ServerStat> = (0..12)
.map(|i| stat_server(&format!("s{i}.example.com"), 3600))
.collect();
assert_eq!(personas_for(&stats, &servers).len(), MAX_PERSONAS);
}
}

View file

@ -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<Utc>| (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,