Skip to content
This repository has been archived by the owner on Dec 22, 2023. It is now read-only.

Commit

Permalink
🚧 Preparations for the Discover tab
Browse files Browse the repository at this point in the history
- Add the «Most liked» section
- Set the image aspect ratio
- Make `VehicleStats::last_battle_time` a proper `DateTime`
  • Loading branch information
eigenein committed Jun 27, 2023
1 parent 16292e7 commit 262e6f5
Show file tree
Hide file tree
Showing 9 changed files with 75 additions and 49 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ reqwest = { version = "0.11.18", default-features = false, features = ["gzip", "
sentry = { version = "0.31.3", default-features = false, features = ["anyhow", "backtrace", "contexts", "panic", "tracing", "reqwest", "rustls", "profiling", "tower", "tower-http"] }
serde = "1.0.163"
serde_json = "1.0.96"
serde_with = { version = "3.0.0", features = ["indexmap"] }
serde_with = { version = "3.0.0", features = ["chrono", "indexmap"] }
thiserror = "1.0.40"
tokio = { version = "1.28.1", default-features = false, features = ["macros", "rt-multi-thread"] }
tower = { version = "0.4.13", default-features = false }
Expand Down
6 changes: 5 additions & 1 deletion src/db/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ impl Models {

#[instrument(skip_all)]
pub async fn get_latest(&self) -> Result<Option<Model>> {
info!("📥 Loading the model…");
let options = FindOneOptions::builder().sort(doc! { "_id": -1 }).build();
self.0.find_one(None, options).await.context("failed to load the latest model")
self.0
.find_one(None, options)
.await
.context("failed to load the latest model (may need a refit)")
}
}
26 changes: 18 additions & 8 deletions src/trainer/item_item.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
use std::collections::HashMap;

use clap::Args;
use indexmap::IndexMap;
use itertools::{merge_join_by, EitherOrBoth, Itertools};
use mongodb::bson::serde_helpers;
use rayon::prelude::*;
Expand Down Expand Up @@ -30,11 +29,13 @@ impl Params {
Self::sort_votes(&mut votes);
let biases = Self::calculate_biases(&votes);
let similarities = Self::calculate_similarities(&votes, &biases);
let top_vehicles = Self::get_top_vehicles(&biases);
Model {
created_at: Utc::now(),
params: self,
biases,
similarities,
top_vehicles,
}
}

Expand All @@ -51,17 +52,15 @@ impl Params {
///
/// Mapping from tank ID to its mean rating.
#[must_use]
fn calculate_biases<'a>(votes: &'a HashMap<u16, Vec<&'a Vote>>) -> IndexMap<u16, f64> {
let mut biases: IndexMap<_, _> = votes
fn calculate_biases<'a>(votes: &'a HashMap<u16, Vec<&'a Vote>>) -> HashMap<u16, f64> {
votes
.par_iter()
.map(|(tank_id, votes)| {
let bias = votes.iter().map(|vote| f64::from(vote.rating)).sum::<f64>()
/ votes.len() as f64;
(*tank_id, bias)
})
.collect();
biases.sort_unstable_by(|_, lhs, _, rhs| rhs.total_cmp(lhs));
biases
.collect()
}

/// Calculate similarities between different vehicles.
Expand All @@ -73,7 +72,7 @@ impl Params {
#[must_use]
fn calculate_similarities(
votes: &HashMap<u16, Vec<&Vote>>,
biases: &IndexMap<u16, f64>,
biases: &HashMap<u16, f64>,
) -> HashMap<u16, Box<[(u16, f64)]>> {
let mut similarities: HashMap<_, _> = biases
.par_iter()
Expand Down Expand Up @@ -126,20 +125,31 @@ impl Params {
0.0
}
}

fn get_top_vehicles(biases: &HashMap<u16, f64>) -> Box<[u16]> {
biases
.iter()
.sorted_unstable_by(|(_, lhs), (_, rhs)| rhs.total_cmp(lhs))
.map(|(tank_id, _)| *tank_id)
.take(biases.len() / 20)
.collect()
}
}

/// Item-item kNN collaborative filtering.
#[must_use]
#[serde_with::serde_as]
#[derive(Serialize, Deserialize)]
pub struct Model {
pub top_vehicles: Box<[u16]>,

#[serde(with = "serde_helpers::chrono_datetime_as_bson_datetime")]
created_at: DateTime,

params: Params,

#[serde_as(as = "Vec<(_, _)>")]
biases: IndexMap<u16, f64>,
biases: HashMap<u16, f64>,

/// Mapping from vehicle's tank ID to other vehicles' similarities.
#[serde_as(as = "Vec<(_, _)>")]
Expand Down
18 changes: 10 additions & 8 deletions src/web/state.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{collections::HashMap, sync::Arc, time::Duration};

use anyhow::bail;
use indexmap::IndexMap;
use itertools::Itertools;
use moka::future::Cache;
Expand All @@ -9,6 +10,7 @@ use crate::{
db::{sessions::Sessions, votes::Votes, Db},
models::vehicle::Vehicle,
prelude::*,
trainer::item_item::Model,
wg::{VehicleStats, Wg},
};

Expand All @@ -18,6 +20,7 @@ pub struct AppState {

pub wg: Wg,
pub tankopedia: Arc<HashMap<u16, Vehicle>>,
pub model: Arc<Model>,

pub session_manager: Sessions,
pub vote_manager: Votes,
Expand All @@ -26,24 +29,22 @@ pub struct AppState {
}

impl AppState {
pub async fn new(
db: &Db,
frontend_application_id: &str,
wg: Wg,
public_address: &str,
) -> Result<Self> {
pub async fn new(db: &Db, application_id: &str, wg: Wg, public_address: &str) -> Result<Self> {
let tankopedia = Arc::new(db.tankopedia().await?.load().await?);
if tankopedia.is_empty() {
warn!("⚠️ Tankopedia database is empty, please re-run with `--update-tankopedia`");
}

let sign_in_url = Arc::new(format!(
"https://api.worldoftanks.eu/wot/auth/login/?application_id={frontend_application_id}&redirect_uri=//{public_address}/welcome"
"https://api.worldoftanks.eu/wot/auth/login/?application_id={application_id}&redirect_uri=//{public_address}/welcome"
));
let stats_cache = Cache::builder()
.max_capacity(1000)
.time_to_idle(Duration::from_secs(300))
.build();
let Some(model) = db.models().await?.get_latest().await? else {
bail!("❌ No recommendation model found, please run the trainer first");
};

Ok(Self {
sign_in_url,
Expand All @@ -52,6 +53,7 @@ impl AppState {
session_manager: db.sessions().await?,
vote_manager: db.votes().await?,
stats_cache,
model: Arc::new(model),
})
}

Expand All @@ -74,7 +76,7 @@ impl AppState {
.await?
.into_iter()
.filter(VehicleStats::is_played)
.sorted_unstable_by_key(|stats| -stats.last_battle_time)
.sorted_unstable_by(|lhs, rhs| rhs.last_battle_time.cmp(&lhs.last_battle_time))
.map(|stats| (stats.tank_id, stats))
.collect();
Ok(Arc::new(map))
Expand Down
7 changes: 2 additions & 5 deletions src/web/static.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,4 @@
//! ```text
//! Add this to your HTML <head>:
//!
//! <link rel="icon" href="/favicon.ico" sizes="any">
//! <link rel="apple-touch-icon" href="/apple-touch-icon.png">
//!
//! Add this to your app's manifest.json:
//!
//! ...
Expand Down Expand Up @@ -69,6 +64,8 @@ pub async fn get_home_icon() -> impl IntoResponse {
pub async fn get_bulma_patches() -> impl IntoResponse {
// language=css
const CSS: &str = r#"
.has-object-fit-cover { object-fit: cover; }
@media (prefers-color-scheme: dark) {
.has-background-success-light { background-color: hsl(141, 53%, 14%) !important; }
.has-background-danger-light { background-color: hsl(348, 86%, 14%) !important; }
Expand Down
14 changes: 13 additions & 1 deletion src/web/views/discover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,19 @@ pub async fn get(

section.section {
div.container {
div.columns.is-multiline.is-tablet {}
h1.title { "Most liked" }

div.columns.is-multiline.is-tablet {
@for vehicle_id in state.model.top_vehicles.iter() {
div.column."is-4-tablet"."is-3-desktop"."is-2-widescreen" {
div.card {
@let vehicle = state.tankopedia.get(vehicle_id);
(vehicle_card_image(vehicle))
(vehicle_card_content(*vehicle_id, vehicle, None, "is-6"))
}
}
}
}
}
}

Expand Down
29 changes: 16 additions & 13 deletions src/web/views/partials.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
use chrono::LocalResult;
use chrono_humanize::HumanTime;
use clap::crate_version;
use maud::{html, Markup, PreEscaped, DOCTYPE};

use crate::{
models::{rating::Rating, user::User, vehicle::Vehicle},
wg::VehicleStats,
prelude::DateTime,
};

pub fn head() -> Markup {
Expand Down Expand Up @@ -149,48 +148,52 @@ pub fn footer() -> Markup {
pub fn vehicle_card_image(vehicle: Option<&Vehicle>) -> Markup {
html! {
div.card-image {
figure.image {
figure.image."is-3by2".has-object-fit-cover {
@let url = vehicle
.and_then(|d| d.images.normal_url.as_ref())
.map_or("https://dummyimage.com/1060x774", |url| url.as_str());
.map_or("https://dummyimage.com/1080x720", |url| url.as_str());
img src=(url) loading="lazy";
}
}
}
}

pub fn vehicle_card_content(vehicle: Option<&Vehicle>, stats: &VehicleStats) -> Markup {
pub fn vehicle_card_content(
tank_id: u16,
vehicle: Option<&Vehicle>,
last_battle_time: Option<DateTime>,
title_style: &str,
) -> Markup {
html! {
div.card-content {
div.media {
div.media-content {
p.title."is-5" {
span.icon-text {
p.title.(title_style) {
span.icon-text.is-flex-wrap-nowrap {
span {
@match vehicle {
Some(vehicle) => {
span.has-text-warning-dark[vehicle.is_premium] { (vehicle.name) }
},
None => {
"#" (stats.tank_id)
},
None => { "#" (tank_id) },
}
}
span.icon {
a
title="View in Armor Inspector"
href=(format!("https://armor.wotinspector.com/en/blitz/{}-/", stats.tank_id))
href=(format!("https://armor.wotinspector.com/en/blitz/{tank_id}-/"))
{
i.fa-solid.fa-arrow-up-right-from-square {}
}
}
}
}
@if let LocalResult::Single(timestamp) = stats.last_battle_time() {

@if let Some(last_battle_time) = last_battle_time {
p.subtitle."is-6" {
span.has-text-grey { "Last played" }
" "
span.has-text-weight-medium title=(timestamp) { (HumanTime::from(timestamp)) }
span.has-text-weight-medium title=(last_battle_time) { (HumanTime::from(last_battle_time)) }
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/web/views/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ pub async fn get(
div.card {
@let vehicle = state.tankopedia.get(&stats.tank_id);
(vehicle_card_image(vehicle))
(vehicle_card_content(vehicle, stats))
(vehicle_card_content(stats.tank_id, vehicle, Some(stats.last_battle_time), "is-5"))
(vehicle_card_footer(user.account_id, stats.tank_id, votes.get(&stats.tank_id).copied()))
}
}
Expand Down
20 changes: 9 additions & 11 deletions src/wg.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
use std::{collections::HashMap, sync::Arc, time::Duration};

use chrono::LocalResult;
use reqwest::{Client, ClientBuilder};
use serde::Deserialize;
use tracing::{info, instrument};
Expand Down Expand Up @@ -84,17 +83,17 @@ impl Wg {

#[cfg(test)]
pub async fn get_vehicles_stats(&self, _account_id: u32) -> Result<Vec<VehicleStats>> {
const FAKE_NON_PLAYED: VehicleStats = VehicleStats {
let fake_non_played = VehicleStats {
tank_id: 2,
last_battle_time: 0,
last_battle_time: Utc.timestamp_opt(0, 0).unwrap(),
inner: InnerVehicleStats { n_battles: 0 },
};
const FAKE_PLAYED: VehicleStats = VehicleStats {
let fake_played = VehicleStats {
tank_id: 1,
last_battle_time: 0,
last_battle_time: Utc.timestamp_opt(0, 0).unwrap(),
inner: InnerVehicleStats { n_battles: 1 },
};
Ok(vec![FAKE_PLAYED, FAKE_NON_PLAYED])
Ok(vec![fake_played, fake_non_played])
}

/// Retrieve the [tankopedia][1].
Expand Down Expand Up @@ -151,20 +150,19 @@ impl Wg {
}

/// Partial user's vehicle statistics.
#[serde_with::serde_as]
#[derive(Deserialize)]
pub struct VehicleStats {
pub tank_id: u16,
pub last_battle_time: i64,

#[serde_as(as = "serde_with::TimestampSeconds<i64>")]
pub last_battle_time: DateTime,

#[serde(rename = "all")]
pub inner: InnerVehicleStats,
}

impl VehicleStats {
pub fn last_battle_time(&self) -> LocalResult<DateTime> {
Utc.timestamp_opt(self.last_battle_time, 0)
}

pub const fn is_played(&self) -> bool {
self.inner.n_battles != 0
}
Expand Down

0 comments on commit 262e6f5

Please sign in to comment.