diff --git a/Cargo.lock b/Cargo.lock index 0a20e9d..c03f31a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -49,6 +58,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "axum" version = "0.8.8" @@ -168,6 +183,25 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -542,6 +576,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -810,6 +868,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "chrono", "noisebell-common", "reqwest", "rusqlite", @@ -862,6 +921,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1973,12 +2041,65 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/remote/cache-service/Cargo.toml b/remote/cache-service/Cargo.toml index ebaefba..ece06ae 100644 --- a/remote/cache-service/Cargo.toml +++ b/remote/cache-service/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] anyhow = "1.0" axum = "0.8" +chrono = "0.4" noisebell-common = { path = "../noisebell-common" } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } rusqlite = { version = "0.33", features = ["bundled"] } diff --git a/remote/cache-service/src/api.rs b/remote/cache-service/src/api.rs index 9d0f128..e0b4450 100644 --- a/remote/cache-service/src/api.rs +++ b/remote/cache-service/src/api.rs @@ -4,6 +4,7 @@ use axum::extract::State; use axum::http::{header, HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; +use chrono::{DateTime, Utc}; use noisebell_common::{validate_bearer, CacheStatusResponse, DoorStatus, WebhookPayload}; use tokio::sync::Mutex; use tracing::{error, info}; @@ -35,6 +36,67 @@ fn unix_now() -> u64 { .as_secs() } +fn format_full_timestamp(ts: u64) -> String { + DateTime::from_timestamp(ts as i64, 0) + .map(|dt: DateTime| dt.format("%A, %B %-d, %Y at %-I:%M:%S %p UTC").to_string()) + .unwrap_or_else(|| format!("unix timestamp {ts}")) +} + +fn format_duration(seconds: u64) -> String { + let units = [ + (86_400, "day"), + (3_600, "hour"), + (60, "minute"), + (1, "second"), + ]; + + let mut remaining = seconds; + let mut parts = Vec::new(); + + for (unit_seconds, name) in units { + if remaining >= unit_seconds { + let count = remaining / unit_seconds; + remaining %= unit_seconds; + let suffix = if count == 1 { "" } else { "s" }; + parts.push(format!("{count} {name}{suffix}")); + } + + if parts.len() == 2 { + break; + } + } + + if parts.is_empty() { + "0 seconds".to_string() + } else if parts.len() == 1 { + parts.remove(0) + } else { + format!("{} and {}", parts[0], parts[1]) + } +} + +fn status_summary(status: DoorStatus, since: Option, last_checked: Option, now: u64) -> String { + let since_text = since + .map(|ts| { + format!( + "We've been {} since {}, which was {} ago.", + status, + format_full_timestamp(ts), + format_duration(now.saturating_sub(ts)), + ) + }) + .unwrap_or_else(|| format!("We're currently {}, but the start time is unknown.", status)); + + match last_checked { + Some(ts) => format!( + "{since_text} Last checked {}, which was {} ago.", + format_full_timestamp(ts), + format_duration(now.saturating_sub(ts)), + ), + None => format!("{since_text} Last checked time is unknown."), + } +} + const WEBHOOK_RATE_LIMIT: u32 = 10; const WEBHOOK_RATE_WINDOW_SECS: u64 = 60; @@ -127,7 +189,7 @@ pub async fn get_status( State(state): State>, ) -> Result, StatusCode> { let db = state.db.clone(); - let status = tokio::task::spawn_blocking(move || { + let mut status = tokio::task::spawn_blocking(move || { let conn = db.blocking_lock(); db::get_status(&conn) }) @@ -138,6 +200,8 @@ pub async fn get_status( StatusCode::INTERNAL_SERVER_ERROR })?; + status.human_readable = status_summary(status.status, status.since, status.last_checked, unix_now()); + Ok(Json(status)) } @@ -205,3 +269,25 @@ pub async fn get_image(State(state): State>) -> Response { ) .into_response() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_uses_two_units() { + assert_eq!(format_duration(57), "57 seconds"); + assert_eq!(format_duration(125), "2 minutes and 5 seconds"); + assert_eq!(format_duration(3_723), "1 hour and 2 minutes"); + } + + #[test] + fn status_summary_includes_since_and_last_checked() { + let summary = status_summary(DoorStatus::Open, Some(1_000), Some(1_125), 1_180); + + assert!(summary.contains("We've been open since")); + assert!(summary.contains("which was 3 minutes ago")); + assert!(summary.contains("Last checked")); + assert!(summary.contains("55 seconds ago")); + } +} diff --git a/remote/cache-service/src/db.rs b/remote/cache-service/src/db.rs index a97f2fd..c5449e1 100644 --- a/remote/cache-service/src/db.rs +++ b/remote/cache-service/src/db.rs @@ -196,6 +196,7 @@ pub fn get_status(conn: &Connection) -> Result { status: row.state.status_for_api(), since: row.state.since_for_api(), last_checked: row.last_checked, + human_readable: String::new(), }) } @@ -359,11 +360,13 @@ mod tests { status: DoorStatus::Open, since: Some(1234), last_checked: Some(5678), + human_readable: "We've been open since Thursday, January 1, 1970 at 12:20 AM UTC, which was 20 minutes and 34 seconds ago. Last checked Thursday, January 1, 1970 at 1:34 AM UTC, which was 1 hour, 34 minutes and 38 seconds ago.".into(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["status"], "open"); assert_eq!(json["since"], 1234); assert_eq!(json["last_checked"], 5678); + assert_eq!(json["human_readable"], resp.human_readable); } #[test] diff --git a/remote/discord-bot/src/main.rs b/remote/discord-bot/src/main.rs index 8b27a8c..744ddec 100644 --- a/remote/discord-bot/src/main.rs +++ b/remote/discord-bot/src/main.rs @@ -106,25 +106,11 @@ async fn handle_status( let embed = match resp { Ok(resp) if resp.status().is_success() => match resp.json::().await { - Ok(data) => { - let mut embed = build_embed( - data.status, - data.since.unwrap_or(unix_now()), - &state.image_base_url, - ); - - let mut fields = Vec::new(); - if let Some(ts) = data.since { - fields.push(("Since", format_timestamp(ts), true)); - } - if let Some(ts) = data.last_checked { - fields.push(("Last Checked", format_timestamp(ts), true)); - } - if !fields.is_empty() { - embed = embed.fields(fields); - } - embed - } + Ok(data) => build_embed( + data.status, + data.since.unwrap_or(unix_now()), + &state.image_base_url, + ), Err(e) => { error!(error = %e, "failed to parse status response"); CreateEmbed::new() diff --git a/remote/noisebell-common/src/lib.rs b/remote/noisebell-common/src/lib.rs index 8f70a86..6c977bb 100644 --- a/remote/noisebell-common/src/lib.rs +++ b/remote/noisebell-common/src/lib.rs @@ -81,6 +81,8 @@ pub struct CacheStatusResponse { pub status: DoorStatus, pub since: Option, pub last_checked: Option, + #[serde(default)] + pub human_readable: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -177,11 +179,13 @@ mod tests { status: DoorStatus::Closed, since: Some(123), last_checked: Some(456), + human_readable: "We've been closed since Thursday, January 1, 1970 at 12:02 AM UTC, which was 2 minutes and 3 seconds ago. Last checked Thursday, January 1, 1970 at 12:07 AM UTC, which was 7 minutes and 36 seconds ago.".into(), }; let json = serde_json::to_value(&response).unwrap(); assert_eq!(json["status"], "closed"); assert_eq!(json["since"], 123); assert_eq!(json["last_checked"], 456); + assert_eq!(json["human_readable"], response.human_readable); } }