Files
Kurt_kalendar/ht_booking/src/controllers/calendar.rs
2026-05-16 22:00:39 +02:00

360 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![allow(clippy::missing_errors_doc)]
#![allow(clippy::unused_async)]
//! Public, read-only week-view calendar.
//!
//! Shows a 7-day grid (one column per day, one row per hour) for a single
//! court. Booked slots are coloured; free slots are blank. The same grid is
//! reused by the admin dashboard with `is_admin = true`.
use axum::http::{header, HeaderMap};
use axum::response::Redirect;
use axum_extra::extract::cookie::CookieJar;
use chrono::{Datelike, Duration, NaiveDate, Utc};
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
use serde::{Deserialize, Serialize};
use crate::models::_entities::{bookings, courts};
/// First bookable hour (06:00).
pub const FIRST_HOUR: i32 = 6;
/// Last bookable hour slot start (21:00 — i.e. the 21:00-22:00 slot).
pub const LAST_HOUR: i32 = 21;
const DAY_KEYS: [&str; 7] = [
"day-mon", "day-tue", "day-wed", "day-thu", "day-fri", "day-sat", "day-sun",
];
#[derive(Debug, Deserialize)]
pub struct CalQuery {
pub court: Option<i32>,
pub week: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CourtOpt {
pub id: i32,
pub name: String,
pub selected: bool,
}
#[derive(Debug, Serialize)]
pub struct DayHead {
pub date: String,
pub key: String,
pub num: u32,
}
#[derive(Debug, Serialize)]
pub struct Cell {
pub date: String,
pub hour: i32,
pub booked: bool,
pub color: String,
pub booking_id: i32,
/// Private — admin only. Never rendered on the public calendar.
pub name: String,
/// Public-facing label shown on the calendar when set.
pub title: String,
/// Private — admin only. Shown in the dashboard's detailed view.
pub contact: String,
/// Private — admin only. Shown in the dashboard's detailed view.
pub note: String,
}
#[derive(Debug, Serialize)]
pub struct Row {
pub hour_label: String,
pub cells: Vec<Cell>,
/// Public view only: `true` when this row collapses a run of fully-free
/// hours into one compact strip. The admin grid never sets it.
pub free_group: bool,
}
#[derive(Debug, Serialize)]
pub struct CalendarPage {
pub lang: String,
/// `true` only on the admin dashboard — enables the editable cells.
pub is_admin: bool,
/// `true` whenever the admin is signed in — controls the nav links.
pub logged_in: bool,
pub base_path: String,
pub has_courts: bool,
pub courts: Vec<CourtOpt>,
pub court_id: i32,
pub court_name: String,
pub week: String,
pub week_label: String,
pub prev_week: String,
pub this_week: String,
pub next_week: String,
/// `false` on the public calendar once it reaches the two-week look-back
/// limit, which disables the "previous week" button. Always `true` for admin.
pub can_prev: bool,
/// Index (06, MonSun) of the day shown first in the mobile single-day
/// view — today when the displayed week contains it, otherwise Monday.
pub mobile_day: i64,
pub days: Vec<DayHead>,
pub rows: Vec<Row>,
}
/// Resolves the UI language from the `lang` cookie. Slovak is the default;
/// English applies only when the cookie explicitly asks for it.
#[must_use]
pub fn current_lang(jar: &CookieJar) -> String {
match jar.get("lang").map(|c| c.value().to_string()) {
Some(ref l) if l == "en" => "en".to_string(),
_ => "sk".to_string(),
}
}
fn monday_of(date: NaiveDate) -> NaiveDate {
date - Duration::days(i64::from(date.weekday().num_days_from_monday()))
}
fn week_monday(week: Option<&str>) -> NaiveDate {
let base = week
.and_then(|w| NaiveDate::parse_from_str(w, "%Y-%m-%d").ok())
.unwrap_or_else(|| Utc::now().date_naive());
monday_of(base)
}
/// Collapses runs of fully-free hour rows into one compact strip so a court
/// that isn't booked solid doesn't waste vertical space. Rows holding at
/// least one booking are kept full-size so bookings stay easy to read.
/// Public calendar only — the admin grid always shows every hour.
fn group_free_rows(rows: Vec<Row>) -> Vec<Row> {
let mut out: Vec<Row> = Vec::new();
let mut run: Vec<Row> = Vec::new();
for row in rows {
if row.cells.iter().all(|c| !c.booked) {
run.push(row);
} else {
flush_free_run(&mut run, &mut out);
out.push(row);
}
}
flush_free_run(&mut run, &mut out);
out
}
/// Drains a pending run of free rows into `out` as a single collapsed row.
fn flush_free_run(run: &mut Vec<Row>, out: &mut Vec<Row>) {
if run.is_empty() {
return;
}
let hour_of = |r: &Row| r.cells.first().map_or(FIRST_HOUR, |c| c.hour);
let first = run.first().map_or(FIRST_HOUR, hour_of);
let last = run.last().map_or(first, hour_of);
let hour_label = if run.len() > 1 {
format!("{first:02}:00{:02}:00", last + 1)
} else {
format!("{first:02}:00")
};
out.push(Row {
hour_label,
cells: Vec::new(),
free_group: true,
});
run.clear();
}
/// Builds the calendar grid for the selected court and week.
pub async fn build_calendar(
ctx: &AppContext,
lang: &str,
is_admin: bool,
logged_in: bool,
q_court: Option<i32>,
q_week: Option<String>,
) -> Result<CalendarPage> {
let court_list = courts::Entity::find()
.order_by_asc(courts::Column::Id)
.all(&ctx.db)
.await?;
let selected = q_court
.filter(|id| court_list.iter().any(|c| c.id == *id))
.or_else(|| court_list.first().map(|c| c.id))
.unwrap_or(0);
let courts_opts: Vec<CourtOpt> = court_list
.iter()
.map(|c| CourtOpt {
id: c.id,
name: c.name.clone().unwrap_or_else(|| format!("Court {}", c.id)),
selected: c.id == selected,
})
.collect();
let court_name = courts_opts
.iter()
.find(|c| c.selected)
.map(|c| c.name.clone())
.unwrap_or_default();
let monday = week_monday(q_week.as_deref());
// The public calendar may look back at most two weeks; the admin has no limit.
let min_monday = monday_of(Utc::now().date_naive()) - Duration::weeks(2);
let monday = if is_admin { monday } else { monday.max(min_monday) };
let can_prev = is_admin || monday > min_monday;
// The mobile day-window opens on the requested day when a week was given
// (so returning from the booking editor lands back on it), else on today.
let pivot = q_week
.as_deref()
.and_then(|w| NaiveDate::parse_from_str(w, "%Y-%m-%d").ok())
.unwrap_or_else(|| Utc::now().date_naive());
let day_offset = (pivot - monday).num_days();
let mobile_day = if (0..7).contains(&day_offset) { day_offset } else { 0 };
let sunday = monday + Duration::days(6);
let days: Vec<DayHead> = (0..7i64)
.map(|i| {
let d = monday + Duration::days(i);
DayHead {
date: d.format("%Y-%m-%d").to_string(),
key: DAY_KEYS[i as usize].to_string(),
num: d.day(),
}
})
.collect();
let week_bookings = if selected == 0 {
Vec::new()
} else {
bookings::Entity::find()
.filter(bookings::Column::CourtId.eq(selected))
.filter(bookings::Column::Date.gte(monday))
.filter(bookings::Column::Date.lte(sunday))
.all(&ctx.db)
.await?
};
let rows: Vec<Row> = (FIRST_HOUR..=LAST_HOUR)
.map(|hour| {
let cells = (0..7i64)
.map(|i| {
let d = monday + Duration::days(i);
let iso = d.format("%Y-%m-%d").to_string();
match week_bookings.iter().find(|b| b.date == d && b.hour == hour) {
Some(b) => Cell {
date: iso,
hour,
booked: true,
color: b.color.clone(),
booking_id: b.id,
name: b.name.clone(),
title: b.title.clone().unwrap_or_default(),
contact: b.contact.clone().unwrap_or_default(),
note: b.note.clone().unwrap_or_default(),
},
None => Cell {
date: iso,
hour,
booked: false,
color: String::new(),
booking_id: 0,
name: String::new(),
title: String::new(),
contact: String::new(),
note: String::new(),
},
}
})
.collect();
Row {
hour_label: format!("{hour:02}:00"),
cells,
free_group: false,
}
})
.collect();
// The public calendar collapses empty stretches to stay compact; the admin
// grid always shows every hour so each slot stays individually editable.
let rows = if is_admin { rows } else { group_free_rows(rows) };
Ok(CalendarPage {
lang: lang.to_string(),
is_admin,
logged_in,
base_path: if is_admin { "/admin" } else { "/" }.to_string(),
has_courts: !courts_opts.is_empty(),
courts: courts_opts,
court_id: selected,
court_name,
week: monday.format("%Y-%m-%d").to_string(),
week_label: format!("{} {}", monday.format("%d %b"), sunday.format("%d %b %Y")),
prev_week: (monday - Duration::days(7)).format("%Y-%m-%d").to_string(),
this_week: monday_of(Utc::now().date_naive())
.format("%Y-%m-%d")
.to_string(),
next_week: (monday + Duration::days(7)).format("%Y-%m-%d").to_string(),
can_prev,
mobile_day,
days,
rows,
})
}
#[debug_handler]
pub async fn index(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
jar: CookieJar,
Query(q): Query<CalQuery>,
) -> Result<Response> {
let lang = current_lang(&jar);
// The public calendar is read-only for everyone, admin included. When an
// admin is signed in we still flag it so the nav keeps the admin links.
let logged_in = crate::controllers::admin::current_admin(&ctx, &jar)
.await
.is_some();
let page = build_calendar(&ctx, &lang, false, logged_in, q.court, q.week).await?;
format::render().view(&v, "calendar/week.html", &page)
}
#[derive(Debug, Deserialize)]
pub struct LangForm {
pub lang: String,
}
/// Switches the UI language. The navbar's language buttons post here; the
/// `lang` cookie is set server-side and the visitor is bounced back to the
/// page they came from. This replaces the old client-side `setLang` script.
#[debug_handler]
pub async fn set_lang(headers: HeaderMap, Form(form): Form<LangForm>) -> Result<Response> {
// Only the two supported languages; anything else falls back to Slovak,
// matching `current_lang`.
let lang = if form.lang == "en" { "en" } else { "sk" };
let cookie = format!("lang={lang}; Path=/; Max-Age=31536000; SameSite=Lax");
Ok((
[(header::SET_COOKIE, cookie)],
Redirect::to(&back_path(&headers)),
)
.into_response())
}
/// On-site path of the page that submitted the form, read from `Referer`.
/// Scheme and host are stripped so a stale or foreign header can only ever
/// bounce the visitor to a path on this site, never off it.
fn back_path(headers: &HeaderMap) -> String {
let raw = headers
.get(header::REFERER)
.and_then(|v| v.to_str().ok())
.unwrap_or("/");
match raw.split_once("://") {
Some((_, rest)) => match rest.find('/') {
Some(i) => rest[i..].to_string(),
None => "/".to_string(),
},
None => raw.to_string(),
}
}
pub fn routes() -> Routes {
Routes::new()
.add("/", get(index))
.add("/lang", post(set_lang))
}