feat: add basic stats page

This commit is contained in:
Anna 2021-10-10 19:43:29 -04:00
parent 1a3cecabe5
commit 85f743ceb3
15 changed files with 761 additions and 130 deletions

32
server/Cargo.lock generated
View File

@ -172,9 +172,9 @@ checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8"
[[package]]
name = "cc"
version = "1.0.70"
version = "1.0.71"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26a6ce4b6a484fa3edb70f7efa6fc430fd2b87285fe8b84304fd0936faa0dc0"
checksum = "79c2681d6594606957bbb8631c4b90a7fcaaa72cdb714743a437b156d6a7eedd"
[[package]]
name = "cfg-if"
@ -1024,9 +1024,9 @@ checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quote"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
checksum = "38bc8cc6a5f2e3655e0899c1b848643b2562f853f114bfec7be120678e3ace05"
dependencies = [
"proc-macro2",
]
@ -1273,9 +1273,9 @@ dependencies = [
[[package]]
name = "sestring"
version = "0.1.1"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e73e6e1c44bc1fcc8b6e2b3d656ac7760819aebe2a4a2e8c0d84e782b45c1de6"
checksum = "8a70039875b671400b27602fde8a970e2c9af44f44c764a613aab8e828ac6989"
dependencies = [
"byteorder",
"serde",
@ -1377,9 +1377,9 @@ checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
[[package]]
name = "syn"
version = "1.0.78"
version = "1.0.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4eac2e6c19f5c3abc0c229bea31ff0b9b091c7b14990e8924b92902a303a0c0"
checksum = "d010a1623fbd906d51d650a9916aaefc05ffa0e4053ff7fe601167f3e715d194"
dependencies = [
"proc-macro2",
"quote",
@ -1400,18 +1400,18 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "thiserror"
version = "1.0.29"
version = "1.0.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "602eca064b2d83369e2b2f34b09c70b605402801927c65c11071ac911d299b88"
checksum = "854babe52e4df1653706b98fcfc05843010039b406875930a70e4d9644e5c417"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.29"
version = "1.0.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bad553cc2c78e8de258400763a647e80e6d1b31ee237275d756f6836d204494c"
checksum = "aa32fd3f627f367fe16f893e2597ae3c05020f8bba2666a4e6ea73d377e5714b"
dependencies = [
"proc-macro2",
"quote",
@ -1524,9 +1524,9 @@ checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6"
[[package]]
name = "tracing"
version = "0.1.28"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84f96e095c0c82419687c20ddf5cb3eadb61f4e1405923c9dc8e53a1adacbda8"
checksum = "375a639232caf30edfc78e8d89b2d4c375515393e7af7e16f01cd96917fb2105"
dependencies = [
"cfg-if",
"log",
@ -1622,9 +1622,9 @@ dependencies = [
[[package]]
name = "unicode-bidi"
version = "0.3.6"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "246f4c42e67e7a4e3c6106ff716a5d067d4132a642840b242e357e468a2a0085"
checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f"
[[package]]
name = "unicode-normalization"

55
server/assets/common.css Normal file
View File

@ -0,0 +1,55 @@
:root {
--background: #2C2F34;
--text: #A0A0A0;
--local-duty-text: #E6A73A;
--cross-duty-text: #79C7EC;
--gold-text: #FFC240;
--light-blue-text: #5BE2FF;
--green-text: #37DE99;
--meta-text: #D3EEE9;
--ui-text: #EEE1C5;
--text-bright: #FFF;
--row-background: #2C2F34;
--row-background-alternate: #373A3E;
--slot-background: #868180;
--slot-empty: #AAA3A2;
--tank-blue: #455CCB;
--healer-green: #487B39;
--dps-red: #813B3C;
--icon-gold: #ECB7A2;
/*--slot-empty: #ADA99C;*/
/*--tank-blue: #4A5DC6;*/
/*--healer-green: #4A7939;*/
/*--dps-red: #7E3938;*/
}
body {
display: flex;
flex-direction: column;
margin: 0;
font-family: sans-serif;
background-color: var(--background);
color: var(--text);
}
.js body {
margin: 1em 0 0;
}
.no-js .requires-js {
display: none !important;
visibility: hidden !important;
}
details {
margin-top: .5em;
}
details > summary {
cursor: pointer;
}

View File

@ -1,59 +1,3 @@
:root {
--background: #2C2F34;
--text: #A0A0A0;
--local-duty-text: #E6A73A;
--cross-duty-text: #79C7EC;
--gold-text: #FFC240;
--light-blue-text: #5BE2FF;
--green-text: #37DE99;
--meta-text: #D3EEE9;
--ui-text: #EEE1C5;
--text-bright: #FFF;
--row-background: #2C2F34;
--row-background-alternate: #373A3E;
--slot-background: #868180;
--slot-empty: #AAA3A2;
--tank-blue: #455CCB;
--healer-green: #487B39;
--dps-red: #813B3C;
--icon-gold: #ECB7A2;
/*--slot-empty: #ADA99C;*/
/*--tank-blue: #4A5DC6;*/
/*--healer-green: #4A7939;*/
/*--dps-red: #7E3938;*/
}
body {
display: flex;
flex-direction: column;
margin: 0;
font-family: sans-serif;
background-color: var(--background);
color: var(--text);
}
.js body {
margin: 1em 0 0;
}
.no-js .requires-js {
display: none !important;
visibility: hidden !important;
}
details {
margin-top: .5em;
}
details > summary {
cursor: pointer;
}
#container {
margin: 0 1em;
}

58
server/assets/stats.css Normal file
View File

@ -0,0 +1,58 @@
body {
margin: 1em;
}
.total {
margin-bottom: 1em;
font-size: 2em;
font-weight: bold;
text-align: center;
}
.chart {
max-height: 50vh;
}
.chart canvas {
max-width: 100%;
max-height: 100%;
}
.chart-containers {
display: flex;
flex-direction: column;
}
.chart-containers .container {
flex-grow: 1;
display: flex;
flex-direction: column;
}
.chart-containers .container table {
margin-top: 1em;
}
.chart-containers .container table tr {
background-color: var(--row-background);
}
.chart-containers .container table tr:nth-child(2n) {
background-color: var(--row-background-alternate);
}
.chart-containers .container table tr td {
padding: .25em .5em;
}
.chart-containers .container h1 {
font-size: 1.5em;
font-weight: bold;
text-align: center;
margin-bottom: 1em;
}
.chart-containers .container:not(:last-child) {
border-bottom: 2px solid var(--text);
}

91
server/assets/stats.js Normal file
View File

@ -0,0 +1,91 @@
(function() {
const colours = [
'#D32F2F',
'#1976D2',
'#FBC02D',
'#388E3C',
'#7B1FA2',
'#F57C00',
'#5D4037',
'#455A64',
'#00796B',
'#E64A19',
'#C2185B',
'#512DA8',
'#0097A7',
];
const options = {
plugins: {
legend: {
display: false,
},
},
};
function combineUnderMedian(data) {
let midpoint = Math.trunc(data.length / 2);
let wasOdd = data.length % 2 === 1;
let median;
if (wasOdd) {
median = (data[midpoint].y + data[midpoint + 1].y) / 2;
} else {
median = data[midpoint].y;
}
let newData = [];
let other = {
x: 'Other',
y: 0,
};
for (let entry of data) {
if (entry.y <= median) {
other.y += 1;
continue;
}
newData.push(entry);
}
newData.push(other);
return newData;
}
function makeChart(tableId, chartId, chartType, combine = false) {
let table = document.getElementById(tableId);
let data = [];
for (let row of table.querySelectorAll('tbody > tr')) {
let cols = row.querySelectorAll('td');
data.push({
x: cols[0].innerText,
y: Number(cols[1].innerText),
});
}
if (combine) {
data = combineUnderMedian(data);
}
new Chart(
document.getElementById(chartId),
{
type: chartType,
data: {
datasets: [{
data: data.map(entry => entry.y),
backgroundColor: colours,
}],
labels: data.map(entry => entry.x),
},
options: options,
},
);
}
makeChart('duties', 'dutiesChart', 'pie', true);
makeChart('hosts', 'hostsChart', 'pie', true);
makeChart('hours', 'hoursChart', 'bar');
makeChart('days', 'daysChart', 'bar');
})();

View File

@ -20,6 +20,8 @@ use std::{
cmp::Ordering,
str::FromStr,
};
use std::borrow::Cow;
use crate::listing::{DutyCategory, DutyType};
#[derive(Debug, Copy, Clone)]
pub enum Language {
@ -95,3 +97,61 @@ impl LocalisedText {
}
}
}
pub fn duty_name<'a>(duty_type: DutyType, category: DutyCategory, duty: u16, lang: Language) -> Cow<'a, str> {
match (duty_type, category) {
(DutyType::Other, DutyCategory::Fates) => {
if let Some(name) = crate::ffxiv::TERRITORY_NAMES.get(&u32::from(duty)) {
return Cow::from(name.text(&lang));
}
return Cow::from("FATEs");
}
(DutyType::Other, DutyCategory::TheHunt) => return Cow::from(match lang {
Language::English => "The Hunt",
Language::Japanese => "モブハント",
Language::German => "Hohe Jagd",
Language::French => "Contrats de chasse",
}),
(DutyType::Other, DutyCategory::Duty) if duty == 0 => return Cow::from(match lang {
Language::English => "None",
Language::Japanese => "設定なし",
Language::German => "Nicht festgelegt",
Language::French => "Non spécifiée",
}),
(DutyType::Other, DutyCategory::DeepDungeons) if duty == 1 => return Cow::from(match lang {
Language::English => "The Palace of the Dead",
Language::Japanese => "死者の宮殿",
Language::German => "Palast der Toten",
Language::French => "Palais des morts",
}),
(DutyType::Other, DutyCategory::DeepDungeons) if duty == 2 => return Cow::from(match lang {
Language::English => "Heaven-on-High",
Language::Japanese => "アメノミハシラ",
Language::German => "Himmelssäule",
Language::French => "Pilier des Cieux",
}),
(DutyType::Normal, _) => {
if let Some(info) = crate::ffxiv::DUTIES.get(&u32::from(duty)) {
return Cow::from(info.name.text(&lang));
}
}
(DutyType::Roulette, _) => {
if let Some(info) = crate::ffxiv::ROULETTES.get(&u32::from(duty)) {
return Cow::from(info.name.text(&lang));
}
}
(_, DutyCategory::QuestBattles) => return Cow::from(match lang {
Language::English => "Quest Battles",
Language::Japanese => "クエストバトル",
Language::German => "Auftragskampf",
Language::French => "Batailles de quête",
}),
(_, DutyCategory::TreasureHunt) => if let Some(name) = crate::ffxiv::TREASURE_MAPS.get(&u32::from(duty)) {
return Cow::from(name.text(&lang));
}
_ => {}
}
Cow::from(format!("{:?}", category))
}

View File

@ -47,61 +47,7 @@ impl PartyFinderListing {
}
pub fn duty_name(&self, lang: &Language) -> Cow<str> {
match (&self.duty_type, &self.category) {
(DutyType::Other, DutyCategory::Fates) => {
if let Some(name) = crate::ffxiv::TERRITORY_NAMES.get(&u32::from(self.duty)) {
return Cow::from(name.text(lang));
}
return Cow::from("FATEs");
}
(DutyType::Other, DutyCategory::TheHunt) => return Cow::from(match lang {
Language::English => "The Hunt",
Language::Japanese => "モブハント",
Language::German => "Hohe Jagd",
Language::French => "Contrats de chasse",
}),
(DutyType::Other, DutyCategory::Duty) if self.duty == 0 => return Cow::from(match lang {
Language::English => "None",
Language::Japanese => "設定なし",
Language::German => "Nicht festgelegt",
Language::French => "Non spécifiée",
}),
(DutyType::Other, DutyCategory::DeepDungeons) if self.duty == 1 => return Cow::from(match lang {
Language::English => "The Palace of the Dead",
Language::Japanese => "死者の宮殿",
Language::German => "Palast der Toten",
Language::French => "Palais des morts",
}),
(DutyType::Other, DutyCategory::DeepDungeons) if self.duty == 2 => return Cow::from(match lang {
Language::English => "Heaven-on-High",
Language::Japanese => "アメノミハシラ",
Language::German => "Himmelssäule",
Language::French => "Pilier des Cieux",
}),
(DutyType::Normal, _) => {
if let Some(info) = crate::ffxiv::DUTIES.get(&u32::from(self.duty)) {
return Cow::from(info.name.text(lang));
}
}
(DutyType::Roulette, _) => {
if let Some(info) = crate::ffxiv::ROULETTES.get(&u32::from(self.duty)) {
return Cow::from(info.name.text(lang));
}
}
(_, DutyCategory::QuestBattles) => return Cow::from(match lang {
Language::English => "Quest Battles",
Language::Japanese => "クエストバトル",
Language::German => "Auftragskampf",
Language::French => "Batailles de quête",
}),
(_, DutyCategory::TreasureHunt) => if let Some(name) = crate::ffxiv::TREASURE_MAPS.get(&u32::from(self.duty)) {
return Cow::from(name.text(lang));
}
_ => {}
}
Cow::from(format!("{:?}", self.category))
crate::ffxiv::duty_name(self.duty_type, self.category, self.duty, *lang)
}
pub fn slots(&self) -> Vec<std::result::Result<ClassJob, (String, String)>> {
@ -295,6 +241,20 @@ impl DutyCategory {
pub fn as_u32(self) -> u32 {
unsafe { std::mem::transmute(self) }
}
pub fn from_u32(u: u32) -> Option<Self> {
Some(match u {
0 => Self::Duty,
1 => Self::QuestBattles,
2 => Self::Fates,
4 => Self::TreasureHunt,
8 => Self::TheHunt,
16 => Self::GatheringForays,
32 => Self::DeepDungeons,
64 => Self::AdventuringForays,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, Deserialize_repr, Serialize_repr, PartialEq)]
@ -309,6 +269,15 @@ impl DutyType {
pub fn as_u8(self) -> u8 {
unsafe { std::mem::transmute(self) }
}
pub fn from_u8(u: u8) -> Option<Self> {
Some(match u {
0 => Self::Other,
1 => Self::Roulette,
2 => Self::Normal,
_ => return None,
})
}
}
bitflags! {

View File

@ -13,6 +13,7 @@ mod listing;
mod listing_container;
mod base64_sestring;
mod sestring_ext;
mod stats;
mod web;
mod template;
mod ffxiv;

128
server/src/stats.rs Normal file
View File

@ -0,0 +1,128 @@
use std::borrow::Cow;
use std::collections::HashMap;
use sestring::SeString;
use serde::{Deserialize, Deserializer};
use crate::ffxiv::Language;
use crate::listing::{DutyCategory, DutyType};
#[derive(Debug, Clone, Deserialize)]
pub struct Statistics {
pub count: Vec<Count>,
#[serde(deserialize_with = "alias_de")]
pub aliases: HashMap<u32, Vec<Alias>>,
pub duties: Vec<DutyInfo>,
pub hosts: Vec<HostInfo>,
pub hours: Vec<HourInfo>,
pub days: Vec<DayInfo>,
}
fn alias_de<'de, D>(de: D) -> std::result::Result<HashMap<u32, Vec<Alias>>, D::Error>
where D: Deserializer<'de>
{
let aliases: Vec<AliasInfo> = Deserialize::deserialize(de)?;
let map = aliases
.into_iter()
.map(|info| (info.content_id_lower, info.aliases))
.collect();
Ok(map)
}
impl Statistics {
pub fn num_listings(&self) -> usize {
self.count[0].count
}
pub fn player_name(&self, cid: &u32) -> Cow<str> {
let aliases = match self.aliases.get(cid) {
Some(a) => a,
None => return "<unknown>".into(),
};
if aliases.is_empty() {
return "<unknown>".into();
}
let world = match crate::ffxiv::WORLDS.get(&aliases[0].home_world) {
Some(world) => world.name(),
None => "<unknown>",
};
format!("{} @ {}", aliases[0].name.text(), world).into()
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct Count {
pub count: usize,
}
#[derive(Debug, Clone, Deserialize)]
pub struct AliasInfo {
#[serde(rename = "_id")]
pub content_id_lower: u32,
pub aliases: Vec<Alias>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Alias {
#[serde(with = "crate::base64_sestring")]
pub name: SeString,
pub home_world: u32,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DutyInfo {
#[serde(rename = "_id")]
pub info: (u8, u32, u16),
pub count: usize,
}
impl DutyInfo {
pub fn name(&self, lang: &Language) -> Cow<str> {
let kind = match DutyType::from_u8(self.info.0) {
Some(k) => k,
None => return Cow::from("<unknown>"),
};
let category = match DutyCategory::from_u32(self.info.1) {
Some(c) => c,
None => return Cow::from("<unknown>"),
};
crate::ffxiv::duty_name(kind, category, self.info.2, *lang)
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct HostInfo {
#[serde(rename = "_id")]
pub content_id_lower: u32,
pub count: usize,
}
#[derive(Debug, Clone, Deserialize)]
pub struct HourInfo {
#[serde(rename = "_id")]
pub hour: u8,
pub count: usize,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DayInfo {
#[serde(rename = "_id")]
pub day: u8,
pub count: usize,
}
impl DayInfo {
pub fn name(&self) -> &'static str {
match self.day {
1 => "Sunday",
2 => "Monday",
3 => "Tuesday",
4 => "Wednesday",
5 => "Thursday",
6 => "Friday",
7 => "Saturday",
_ => "<unknown>",
}
}
}

View File

@ -1 +1,2 @@
pub mod listings;
pub mod stats;

View File

@ -0,0 +1,10 @@
use askama::Template;
use crate::ffxiv::Language;
use crate::stats::Statistics;
#[derive(Debug, Template)]
#[template(path = "stats.html")]
pub struct StatsTemplate {
pub stats: Statistics,
pub lang: Language,
}

View File

@ -1,11 +1,15 @@
mod stats;
use std::cmp::Ordering;
use std::convert::Infallible;
use anyhow::{Result, Context};
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use mongodb::{Client as MongoClient, Collection, IndexModel};
use mongodb::options::{IndexOptions, UpdateOptions};
use mongodb::results::UpdateResult;
use tokio::sync::RwLock;
use tokio_stream::StreamExt;
use warp::{Filter, Reply};
use warp::filters::BoxedFilter;
@ -14,7 +18,9 @@ use crate::config::Config;
use crate::ffxiv::Language;
use crate::listing::PartyFinderListing;
use crate::listing_container::{ListingContainer, QueriedListing};
use crate::stats::Statistics;
use crate::template::listings::ListingsTemplate;
use crate::template::stats::StatsTemplate;
pub async fn start(config: Arc<Config>) -> Result<()> {
let state = State::new(Arc::clone(&config)).await?;
@ -26,9 +32,10 @@ pub async fn start(config: Arc<Config>) -> Result<()> {
Ok(())
}
struct State {
pub struct State {
config: Arc<Config>,
mongo: MongoClient,
stats: RwLock<Option<Statistics>>,
}
impl State {
@ -40,6 +47,7 @@ impl State {
let state = Arc::new(Self {
config,
mongo,
stats: Default::default(),
});
state.collection()
@ -59,6 +67,23 @@ impl State {
.await
.context("could not create index")?;
let task_state = Arc::clone(&state);
tokio::task::spawn(async move {
loop {
let stats = match self::stats::get_stats(&*task_state).await {
Ok(stats) => stats,
Err(e) => {
eprintln!("error generating stats: {:#?}", e);
continue;
}
};
*task_state.stats.write().await = Some(stats);
tokio::time::sleep(Duration::from_secs(60 * 5)).await;
}
});
Ok(state)
}
@ -72,6 +97,7 @@ fn router(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
.or(listings(Arc::clone(&state)))
.or(contribute(Arc::clone(&state)))
.or(contribute_multiple(Arc::clone(&state)))
.or(stats(Arc::clone(&state)))
.or(assets())
.boxed()
}
@ -82,8 +108,11 @@ fn assets() -> BoxedFilter<(impl Reply, )> {
.and(
icons()
.or(minireset())
.or(common_css())
.or(listings_css())
.or(listings_js())
.or(stats_css())
.or(stats_js())
)
.boxed()
}
@ -102,6 +131,13 @@ fn minireset() -> BoxedFilter<(impl Reply, )> {
.boxed()
}
fn common_css() -> BoxedFilter<(impl Reply, )> {
warp::path("common.css")
.and(warp::path::end())
.and(warp::fs::file("./assets/common.css"))
.boxed()
}
fn listings_css() -> BoxedFilter<(impl Reply, )> {
warp::path("listings.css")
.and(warp::path::end())
@ -116,6 +152,20 @@ fn listings_js() -> BoxedFilter<(impl Reply, )> {
.boxed()
}
fn stats_css() -> BoxedFilter<(impl Reply, )> {
warp::path("stats.css")
.and(warp::path::end())
.and(warp::fs::file("./assets/stats.css"))
.boxed()
}
fn stats_js() -> BoxedFilter<(impl Reply, )> {
warp::path("stats.js")
.and(warp::path::end())
.and(warp::fs::file("./assets/stats.js"))
.boxed()
}
fn index() -> BoxedFilter<(impl Reply, )> {
let route = warp::path::end()
.map(|| warp::redirect(Uri::from_static("/listings")));
@ -168,8 +218,7 @@ fn listings(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
while let Ok(Some(container)) = cursor.try_next().await {
let res: Result<QueriedListing> = try {
let json = serde_json::to_vec(&container)?;
let result: QueriedListing = serde_json::from_slice(&json)?;
let result: QueriedListing = mongodb::bson::from_document(container)?;
result
};
if let Ok(listing) = res {
@ -215,6 +264,34 @@ fn listings(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
warp::get().and(route).boxed()
}
fn stats(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
async fn logic(state: Arc<State>, codes: Option<String>) -> std::result::Result<impl Reply, Infallible> {
let lang = Language::from_codes(codes.as_deref());
let stats = state.stats.read().await.clone();
Ok(match stats {
Some(stats) => StatsTemplate {
stats,
lang,
},
None => panic!(),
})
}
let route = warp::path("stats")
.and(warp::path::end())
.and(
warp::cookie::<String>("lang")
.or(warp::header::<String>("accept-language"))
.unify()
.map(Some)
.or(warp::any().map(|| None))
.unify()
)
.and_then(move |codes: Option<String>| logic(Arc::clone(&state), codes));
warp::get().and(route).boxed()
}
fn contribute(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
async fn logic(state: Arc<State>, listing: PartyFinderListing) -> std::result::Result<impl Reply, Infallible> {
if listing.seconds_remaining > 60 * 60 {

111
server/src/web/stats.rs Normal file
View File

@ -0,0 +1,111 @@
use anyhow::Result;
use mongodb::bson::{Document, doc};
use tokio_stream::StreamExt;
use crate::stats::Statistics;
use crate::web::State;
lazy_static::lazy_static! {
static ref QUERY: [Document; 1] = [
doc! {
"$facet": {
"count": [
{
"$count": "count",
},
],
"aliases": [
{
"$group": {
"_id": "$listing.content_id_lower",
"aliases": {
"$addToSet": {
"name": "$listing.name",
"home_world": "$listing.home_world",
},
},
}
}
],
"duties": [
{
"$group": {
"_id": [
"$listing.duty_type",
"$listing.category",
"$listing.duty",
],
"count": {
"$sum": 1
},
}
},
{
"$sort": {
"count": -1,
}
}
],
"hosts": [
{
"$group": {
"_id": "$listing.content_id_lower",
"count": {
"$sum": 1
},
}
},
{
"$sort": {
"count": -1
}
}
],
"hours": [
{
"$group": {
"_id": {
"$hour": "$created_at",
},
"count": {
"$sum": 1
},
}
},
{
"$sort": {
"_id": 1,
}
}
],
"days": [
{
"$group": {
"_id": {
"$dayOfWeek": "$created_at",
},
"count": {
"$sum": 1
},
}
},
{
"$sort": {
"_id": 1,
}
}
],
}
},
];
}
pub async fn get_stats(state: &State) -> Result<Statistics> {
let mut cursor = state
.collection()
.aggregate(QUERY.iter().cloned(), None)
.await?;
let doc = cursor.try_next().await?;
let doc = doc.ok_or_else(|| anyhow::anyhow!("missing document"))?;
let stats = mongodb::bson::from_document(doc)?;
Ok(stats)
}

View File

@ -5,6 +5,7 @@ Remote Party Finder
{%- endblock %}
{% block head %}
<link rel="stylesheet" href="/assets/common.css"/>
<link rel="stylesheet" href="/assets/listings.css"/>
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/list.js/2.3.1/list.min.js"></script>
<script defer src="/assets/listings.js"></script>

125
server/templates/stats.html Normal file
View File

@ -0,0 +1,125 @@
{% extends "_frame.html" %}
{% block title -%}
Remote Party Finder
{%- endblock %}
{% block head %}
<link rel="stylesheet" href="/assets/common.css"/>
<link rel="stylesheet" href="/assets/stats.css"/>
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.5.1/dist/chart.min.js"></script>
<script defer src="/assets/stats.js"></script>
{% endblock %}
{% block body %}
<div class="total">
Stats for {{ stats.num_listings() }} listings
</div>
<div class="chart-containers">
<div class="container">
<h1>Top categories</h1>
<div class="chart">
<canvas id="dutiesChart"></canvas>
</div>
<details>
<summary>Details</summary>
<table id="duties">
<thead>
<tr>
<th>Duty</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{%- for info in stats.duties %}
<tr>
<td>{{ info.name(lang) }}</td>
<td>{{ info.count }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
</details>
</div>
<div class="container">
<h1>Top hosts</h1>
<div class="chart">
<canvas id="hostsChart"></canvas>
</div>
<details>
<summary>Details</summary>
<table id="hosts">
<thead>
<tr>
<th>Name</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{%- for info in stats.hosts %}
<tr>
<td>{{ stats.player_name(info.content_id_lower) }}</td>
<td>{{ info.count }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
</details>
</div>
<div class="container">
<h1>Top hours (UTC)</h1>
<div class="chart">
<canvas id="hoursChart"></canvas>
</div>
<details>
<summary>Details</summary>
<table id="hours">
<thead>
<tr>
<th>Hour</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{%- for info in stats.hours %}
<tr>
<td>{{ info.hour }}</td>
<td>{{ info.count }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
</details>
</div>
<div class="container">
<h1>Top days (UTC)</h1>
<div class="chart">
<canvas id="daysChart"></canvas>
</div>
<details>
<summary>Details</summary>
<table id="days">
<thead>
<tr>
<th>Name</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{%- for info in stats.days %}
<tr>
<td>{{ info.name() }}</td>
<td>{{ info.count }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
</details>
</div>
</div>
{% endblock %}