feat: add basic stats page
This commit is contained in:
parent
1a3cecabe5
commit
85f743ceb3
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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');
|
||||
})();
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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! {
|
||||
|
|
|
@ -13,6 +13,7 @@ mod listing;
|
|||
mod listing_container;
|
||||
mod base64_sestring;
|
||||
mod sestring_ext;
|
||||
mod stats;
|
||||
mod web;
|
||||
mod template;
|
||||
mod ffxiv;
|
||||
|
|
|
@ -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>",
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1 +1,2 @@
|
|||
pub mod listings;
|
||||
pub mod stats;
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
Loading…
Reference in New Issue