diff --git a/src/model/logbook.rs b/src/model/logbook.rs index 7c278dd..4134cbb 100644 --- a/src/model/logbook.rs +++ b/src/model/logbook.rs @@ -31,6 +31,12 @@ impl PartialEq for Logbook { } } +pub(crate) enum Filter { + SingleDayOnly, + MultiDazOnly, + None, +} + #[derive(FromForm, Debug, Clone)] pub struct LogToAdd { pub boat_id: i32, @@ -293,6 +299,49 @@ ORDER BY departure DESC ret } + pub(crate) async fn completed_wanderfahrten_with_user_over_km_in_year( + db: &SqlitePool, + user: &User, + min_distance: i32, + year: i32, + filter: Filter, + ) -> Vec { + let logs: Vec = sqlx::query_as( + &format!(" + SELECT id, boat_id, shipmaster, steering_person, shipmaster_only_steering, departure, arrival, destination, distance_in_km, comments, logtype + FROM logbook + JOIN rower ON logbook.id = rower.logbook_id + WHERE arrival is not null AND rower_id = {} AND logtype = 1 AND distance_in_km >= {} AND arrival like '{}-%' + ORDER BY arrival DESC + ", user.id, min_distance, year) + ) + .fetch_all(db) + .await + .unwrap(); //TODO: fixme + + let mut ret = Vec::new(); + for log in logs { + let trip_days = log.arrival.unwrap() - log.departure; + let trip_days = trip_days.num_days(); + match filter { + Filter::SingleDayOnly => { + if trip_days == 0 { + ret.push(LogbookWithBoatAndRowers::from(db, log).await); + } + } + Filter::MultiDazOnly => { + if trip_days > 0 { + ret.push(LogbookWithBoatAndRowers::from(db, log).await); + } + } + Filter::None => { + ret.push(LogbookWithBoatAndRowers::from(db, log).await); + } + } + } + ret + } + pub async fn completed(db: &SqlitePool) -> Vec { let year = chrono::Local::now().year(); Self::completed_in_year(db, year).await diff --git a/src/model/mod.rs b/src/model/mod.rs index 1af7c21..46446db 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -25,6 +25,7 @@ pub mod logbook; pub mod logtype; pub mod mail; pub mod notification; +pub mod personal; pub mod role; pub mod rower; pub mod stat; diff --git a/src/model/personal/equatorprice.rs b/src/model/personal/equatorprice.rs new file mode 100644 index 0000000..e97c706 --- /dev/null +++ b/src/model/personal/equatorprice.rs @@ -0,0 +1,88 @@ +use serde::Serialize; +use sqlx::SqlitePool; + +#[derive(Serialize, PartialEq, Debug)] +pub(crate) enum Level { + NONE, + BRONZE, + SILVER, + GOLD, + DIAMOND, + DONE, +} + +impl Level { + fn required_km(&self) -> i32 { + match self { + Level::BRONZE => 40000, + Level::SILVER => 80000, + Level::GOLD => 100000, + Level::DIAMOND => 200000, + Level::DONE => 0, + Level::NONE => 0, + } + } + + fn next_level(km: i32) -> Self { + if km < Level::BRONZE.required_km() { + Level::BRONZE + } else if km < Level::SILVER.required_km() { + Level::SILVER + } else if km < Level::GOLD.required_km() { + Level::GOLD + } else if km < Level::DIAMOND.required_km() { + Level::BRONZE + } else { + Level::DONE + } + } + + pub(crate) fn curr_level(km: i32) -> Self { + if km < Level::BRONZE.required_km() { + Level::NONE + } else if km < Level::SILVER.required_km() { + Level::BRONZE + } else if km < Level::GOLD.required_km() { + Level::SILVER + } else if km < Level::DIAMOND.required_km() { + Level::GOLD + } else { + Level::DIAMOND + } + } + + pub(crate) fn desc(&self) -> &str { + match self { + Level::BRONZE => "Bronze", + Level::SILVER => "Silber", + Level::GOLD => "Gold", + Level::DIAMOND => "Diamant", + Level::DONE => "", + Level::NONE => "-", + } + } +} + +#[derive(Serialize)] +pub(crate) struct Next { + level: Level, + desc: String, + missing_km: i32, + required_km: i32, + rowed_km: i32, +} + +impl Next { + pub(crate) fn new(rowed_km: i32) -> Self { + let level = Level::next_level(rowed_km); + let required_km = level.required_km(); + let missing_km = required_km - rowed_km; + Self { + desc: level.desc().to_string(), + level, + missing_km, + required_km, + rowed_km, + } + } +} diff --git a/src/model/personal/mod.rs b/src/model/personal/mod.rs new file mode 100644 index 0000000..a91e551 --- /dev/null +++ b/src/model/personal/mod.rs @@ -0,0 +1,55 @@ +use chrono::{Datelike, Local}; +use equatorprice::Level; +use serde::Serialize; +use sqlx::SqlitePool; + +use super::{stat::Stat, user::User}; + +pub(crate) mod equatorprice; +pub(crate) mod rowingbadge; + +#[derive(Serialize)] +pub(crate) struct Achievements { + pub(crate) equatorprice: equatorprice::Next, + pub(crate) curr_equatorprice_name: String, + pub(crate) new_equatorprice_this_season: bool, + pub(crate) rowingbadge: Option, +} + +impl Achievements { + pub(crate) async fn for_user(db: &SqlitePool, user: &User) -> Self { + let rowed_km = Stat::total_km(db, user).await.rowed_km; + let rowed_km_this_season = if Local::now().month() == 1 { + Stat::person(db, Some(Local::now().year() - 1), user) + .await + .rowed_km + + Stat::person(db, Some(Local::now().year()), user) + .await + .rowed_km + } else { + Stat::person(db, Some(Local::now().year()), user) + .await + .rowed_km + }; + println!( + "old: {}; new: {}", + rowed_km, + rowed_km - rowed_km_this_season + ); + println!( + "old: {:?}; new: {:?}", + Level::curr_level(rowed_km), + Level::curr_level(rowed_km - rowed_km_this_season) + ); + let new_equatorprice_this_season = + Level::curr_level(rowed_km) != Level::curr_level(rowed_km - rowed_km_this_season); + println!("{new_equatorprice_this_season:?}"); + + Self { + equatorprice: equatorprice::Next::new(rowed_km), + curr_equatorprice_name: equatorprice::Level::curr_level(rowed_km).desc().to_string(), + new_equatorprice_this_season, + rowingbadge: rowingbadge::Status::for_user(db, user).await, + } + } +} diff --git a/src/model/personal/rowingbadge.rs b/src/model/personal/rowingbadge.rs new file mode 100644 index 0000000..32093b7 --- /dev/null +++ b/src/model/personal/rowingbadge.rs @@ -0,0 +1,163 @@ +use std::cmp; + +use chrono::{Datelike, Local, NaiveDate}; +use serde::Serialize; +use sqlx::SqlitePool; + +use crate::model::{ + logbook::{Filter, Logbook, LogbookWithBoatAndRowers}, + stat::Stat, + user::User, +}; + +enum AgeBracket { + Till14, + From14Till18, + From19Till30, + From31Till60, + From61Till75, + From76, +} + +impl AgeBracket { + fn cat(&self) -> &str { + match self { + AgeBracket::Till14 => "Schülerinnen und Schüler bis 14 Jahre", + AgeBracket::From14Till18 => "Juniorinnen und Junioren, Para-Ruderer bis 18 Jahre", + AgeBracket::From19Till30 => "Frauen und Männer, Para-Ruderer bis 30 Jahre", + AgeBracket::From31Till60 => "Frauen und Männer, Para-Ruderer von 31 bis 60 Jahre", + AgeBracket::From61Till75 => "Frauen und Männer, Para-Ruderer von 61 bis 75 Jahre", + AgeBracket::From76 => "Frauen und Männer, Para-Ruderer ab 76 Jahre", + } + } + + fn dist_in_km(&self) -> i32 { + match self { + AgeBracket::Till14 => 500, + AgeBracket::From14Till18 => 1000, + AgeBracket::From19Till30 => 1200, + AgeBracket::From31Till60 => 1000, + AgeBracket::From61Till75 => 800, + AgeBracket::From76 => 600, + } + } + + fn required_dist_multi_day_in_km(&self) -> i32 { + match self { + AgeBracket::Till14 => 60, + AgeBracket::From14Till18 => 60, + AgeBracket::From19Till30 => 80, + AgeBracket::From31Till60 => 80, + AgeBracket::From61Till75 => 80, + AgeBracket::From76 => 80, + } + } + + fn required_dist_single_day_in_km(&self) -> i32 { + match self { + AgeBracket::Till14 => 30, + AgeBracket::From14Till18 => 30, + AgeBracket::From19Till30 => 40, + AgeBracket::From31Till60 => 40, + AgeBracket::From61Till75 => 40, + AgeBracket::From76 => 40, + } + } +} + +impl TryFrom<&User> for AgeBracket { + type Error = String; + + fn try_from(value: &User) -> Result { + if let Some(birthdate) = value.birthdate.clone() { + let today = Local::now().date_naive(); + let birthdate = NaiveDate::parse_from_str(&birthdate, "%Y-%m-%d").unwrap(); + + let age = today.year() - birthdate.year(); + if age <= 14 { + Ok(AgeBracket::Till14) + } else if age <= 18 { + Ok(AgeBracket::From14Till18) + } else if age <= 30 { + Ok(AgeBracket::From19Till30) + } else if age <= 60 { + Ok(AgeBracket::From31Till60) + } else if age <= 75 { + Ok(AgeBracket::From61Till75) + } else { + Ok(AgeBracket::From76) + } + } else { + Err("User has no birthdate".to_string()) + } + } +} + +#[derive(Serialize)] +pub(crate) struct Status { + pub(crate) year: i32, + rowed_km: i32, + category: String, + required_km: i32, + missing_km: i32, + multi_day_trips_over_required_distance: Vec, + multi_day_trips_required_distance: i32, + single_day_trips_over_required_distance: Vec, + single_day_trips_required_distance: i32, + achieved: bool, +} + +impl Status { + pub(crate) async fn for_user(db: &SqlitePool, user: &User) -> Option { + let Ok(agebracket) = AgeBracket::try_from(user) else { + return None; + }; + let category = agebracket.cat().to_string(); + + let year = if Local::now().month() == 1 { + Local::now().year() - 1 + } else { + Local::now().year() + }; + + let rowed_km = Stat::person(db, Some(year), user).await.rowed_km; + let required_km = agebracket.dist_in_km(); + let missing_km = cmp::max(required_km - rowed_km, 0); + + let single_day_trips_over_required_distance = + Logbook::completed_wanderfahrten_with_user_over_km_in_year( + db, + user, + agebracket.required_dist_single_day_in_km(), + year, + Filter::SingleDayOnly, + ) + .await; + let multi_day_trips_over_required_distance = + Logbook::completed_wanderfahrten_with_user_over_km_in_year( + db, + user, + agebracket.required_dist_multi_day_in_km(), + year, + Filter::MultiDazOnly, + ) + .await; + + let achieved = missing_km == 0 + && (multi_day_trips_over_required_distance.len() >= 1 + || single_day_trips_over_required_distance.len() >= 2); + + Some(Self { + year, + rowed_km, + category, + required_km, + missing_km, + multi_day_trips_over_required_distance, + single_day_trips_over_required_distance, + multi_day_trips_required_distance: agebracket.required_dist_multi_day_in_km(), + single_day_trips_required_distance: agebracket.required_dist_single_day_in_km(), + achieved, + }) + } +} diff --git a/src/model/stat.rs b/src/model/stat.rs index c27a7dd..e79bbd9 100644 --- a/src/model/stat.rs +++ b/src/model/stat.rs @@ -195,6 +195,32 @@ ORDER BY rowed_km DESC, u.name; }) .collect() } + + pub async fn total_km(db: &SqlitePool, user: &User) -> Stat { + //TODO: switch to query! macro again (once upgraded to sqlite 3.42 on server) + let row = sqlx::query(&format!( + " +SELECT u.name, CAST(SUM(l.distance_in_km) AS INTEGER) AS rowed_km +FROM ( + SELECT * FROM user + WHERE id={} +) u +INNER JOIN rower r ON u.id = r.rower_id +INNER JOIN logbook l ON r.logbook_id = l.id +WHERE l.distance_in_km IS NOT NULL; +", + user.id + )) + .fetch_one(db) + .await + .unwrap(); + + Stat { + name: row.get("name"), + rowed_km: row.get("rowed_km"), + } + } + pub async fn person(db: &SqlitePool, year: Option, user: &User) -> Stat { let year = match year { Some(year) => year, diff --git a/src/tera/board/achievement.rs b/src/tera/board/achievement.rs new file mode 100644 index 0000000..0ac17c3 --- /dev/null +++ b/src/tera/board/achievement.rs @@ -0,0 +1,46 @@ +use crate::model::{ + personal::Achievements, + role::Role, + user::{User, UserWithDetails, VorstandUser}, +}; +use rocket::{get, request::FlashMessage, routes, Route, State}; +use rocket_dyn_templates::{tera::Context, Template}; +use sqlx::SqlitePool; + +#[get("/achievement")] +async fn index( + db: &State, + admin: VorstandUser, + flash: Option>, +) -> Template { + let mut context = Context::new(); + if let Some(msg) = flash { + context.insert("flash", &msg.into_inner()); + } + + let role = Role::find_by_name(&db, "Donau Linz").await.unwrap(); + let users = User::all_with_role(&db, &role).await; + let mut people = Vec::new(); + let mut rowingbadge_year = None; + for user in users { + let achievement = Achievements::for_user(&db, &user).await; + if let Some(badge) = &achievement.rowingbadge { + rowingbadge_year = Some(badge.year); + } + people.push((user, achievement)); + } + + context.insert("people", &people); + context.insert("rowingbadge_year", &rowingbadge_year.unwrap()); + + context.insert( + "loggedin_user", + &UserWithDetails::from_user(admin.into_inner(), db).await, + ); + + Template::render("achievement", context.into_json()) +} + +pub fn routes() -> Vec { + routes![index] +} diff --git a/src/tera/board/mod.rs b/src/tera/board/mod.rs index 4800fb8..8eff9d3 100644 --- a/src/tera/board/mod.rs +++ b/src/tera/board/mod.rs @@ -1,9 +1,11 @@ use rocket::Route; +pub mod achievement; pub mod boathouse; pub fn routes() -> Vec { let mut ret = Vec::new(); ret.append(&mut boathouse::routes()); + ret.append(&mut achievement::routes()); ret } diff --git a/src/tera/mod.rs b/src/tera/mod.rs index 7bcc1ba..e49e053 100644 --- a/src/tera/mod.rs +++ b/src/tera/mod.rs @@ -24,6 +24,7 @@ use crate::{ model::{ logbook::Logbook, notification::Notification, + personal::Achievements, role::Role, user::{User, UserWithDetails}, }, @@ -62,6 +63,7 @@ async fn index(db: &State, user: User, flash: Option +
+

Abzeichen für {{ rowingbadge_year }}

+
+ + + + + + + + + + + + + + {% for person in people %} + {% set user = person.0 %} + {% set achievement = person.1 %} + + + + {% if achievement.rowingbadge %} + {% set badge = achievement.rowingbadge %} + + + + + + {% else %} + + + + + + {% endif %} + + {% endfor %} + +
NameÄquatorpreisFahrtenabzeichen (FA) geschafftFA - KMFA - fehlende KMEintagesausfahrtenMehrtagesausfahrten
{{ user.name }}{% if achievement.new_equatorprice_this_season %}(NEU!) {% endif %}{{ achievement.curr_equatorprice_name }} + {% if badge.achieved %} + ja + {% else %} + nein + {% endif %} + {{ badge.rowed_km }} / {{ badge.required_km }}{{ badge.missing_km }} +
+ + > {{ badge.single_day_trips_required_distance }} km: {{ badge.single_day_trips_over_required_distance | length }} / 2 + + {% for log in badge.single_day_trips_over_required_distance %} + {{ log::show_old(log=log, state="completed", only_ones=false, index=loop.index, hide_type=true) }} + {% endfor %} +
+
+
+ + > {{ badge.multi_day_trips_required_distance }} km: {{ badge.multi_day_trips_over_required_distance | length }} / 1 + + {% for log in badge.multi_day_trips_over_required_distance %} + {{ log::show_old(log=log, state="completed", only_ones=false, index=loop.index, hide_type=true) }} + {% endfor %} +
+
no birthdate of this personno birthdate of this personno birthdate of this personno birthdate of this personno birthdate of this person
+
+
+ + +{% endblock content %} diff --git a/templates/includes/forms/log.html.tera b/templates/includes/forms/log.html.tera index ae9d752..552c645 100644 --- a/templates/includes/forms/log.html.tera +++ b/templates/includes/forms/log.html.tera @@ -173,13 +173,13 @@ {% endmacro show %} -{% macro show_old(log, state, allowed_to_close=false, allowed_to_edit=false, index) %} +{% macro show_old(log, state, allowed_to_close=false, allowed_to_edit=false, index, hide_type=false) %}
- {% if log.logtype %} + {% if log.logtype and not hide_type %}
{% if log.logtype == 1 %} Wanderfahrt diff --git a/templates/index.html.tera b/templates/index.html.tera index a9cd032..40286c6 100644 --- a/templates/index.html.tera +++ b/templates/index.html.tera @@ -74,6 +74,86 @@
{% endif %} {% if "Donau Linz" in loggedin_user.roles and "Unterstützend" not in loggedin_user.roles and "Förderndes Mitglied" not in loggedin_user.roles %} + {% endif %}