diff --git a/ht_booking/assets/views/calendar/week.html b/ht_booking/assets/views/calendar/week.html
index daf2381..9aba281 100644
--- a/ht_booking/assets/views/calendar/week.html
+++ b/ht_booking/assets/views/calendar/week.html
@@ -95,7 +95,7 @@
{{ court_name }} · {{ week_label }}
-
+
@@ -110,6 +110,14 @@
{% for row in rows %}
+ {% if row.free_group %}
+
+ | {{ row.hour_label }} |
+
+ {{ t(key="free", lang=lang) }}
+ |
+
+ {% else %}
| {{ row.hour_label }} |
{% for cell in row.cells %}
@@ -147,6 +155,7 @@
{% endfor %}
+ {% endif %}
{% endfor %}
diff --git a/ht_booking/src/controllers/calendar.rs b/ht_booking/src/controllers/calendar.rs
index c9ae2b4..27f94b9 100644
--- a/ht_booking/src/controllers/calendar.rs
+++ b/ht_booking/src/controllers/calendar.rs
@@ -64,6 +64,9 @@ pub struct Cell {
pub struct Row {
pub hour_label: String,
pub cells: Vec
,
+ /// 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)]
@@ -107,6 +110,46 @@ fn week_monday(week: Option<&str>) -> NaiveDate {
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) -> Vec {
+ let mut out: Vec = Vec::new();
+ let mut run: Vec = 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, out: &mut Vec) {
+ 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,
@@ -201,10 +244,15 @@ pub async fn build_calendar(
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,
|