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:
parent
2e0ba533fe
commit
80ba104861
14 changed files with 1360 additions and 271 deletions
|
|
@ -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(),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
410
packages/oneclient_app/src/components/date_range_picker.rs
Normal file
410
packages/oneclient_app/src/components/date_range_picker.rs
Normal 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)]
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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")];
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue