feat: add human readable output to the API

This commit is contained in:
Jet 2026-03-23 13:44:56 -07:00
parent f57ecd19aa
commit 183b2c2c88
No known key found for this signature in database
6 changed files with 221 additions and 20 deletions

121
Cargo.lock generated
View file

@ -17,6 +17,15 @@ dependencies = [
"memchr", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.102"
@ -49,6 +58,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.8" version = "0.8.8"
@ -168,6 +183,25 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@ -542,6 +576,30 @@ dependencies = [
"tracing", "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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.1.1" version = "2.1.1"
@ -810,6 +868,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"chrono",
"noisebell-common", "noisebell-common",
"reqwest", "reqwest",
"rusqlite", "rusqlite",
@ -862,6 +921,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" 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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@ -1973,12 +2041,65 @@ dependencies = [
"rustls-pki-types", "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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"

View file

@ -6,6 +6,7 @@ edition = "2021"
[dependencies] [dependencies]
anyhow = "1.0" anyhow = "1.0"
axum = "0.8" axum = "0.8"
chrono = "0.4"
noisebell-common = { path = "../noisebell-common" } noisebell-common = { path = "../noisebell-common" }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rusqlite = { version = "0.33", features = ["bundled"] } rusqlite = { version = "0.33", features = ["bundled"] }

View file

@ -4,6 +4,7 @@ use axum::extract::State;
use axum::http::{header, HeaderMap, StatusCode}; use axum::http::{header, HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json; use axum::Json;
use chrono::{DateTime, Utc};
use noisebell_common::{validate_bearer, CacheStatusResponse, DoorStatus, WebhookPayload}; use noisebell_common::{validate_bearer, CacheStatusResponse, DoorStatus, WebhookPayload};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{error, info}; use tracing::{error, info};
@ -35,6 +36,67 @@ fn unix_now() -> u64 {
.as_secs() .as_secs()
} }
fn format_full_timestamp(ts: u64) -> String {
DateTime::from_timestamp(ts as i64, 0)
.map(|dt: DateTime<Utc>| 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<u64>, last_checked: Option<u64>, 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_LIMIT: u32 = 10;
const WEBHOOK_RATE_WINDOW_SECS: u64 = 60; const WEBHOOK_RATE_WINDOW_SECS: u64 = 60;
@ -127,7 +189,7 @@ pub async fn get_status(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Result<Json<CacheStatusResponse>, StatusCode> { ) -> Result<Json<CacheStatusResponse>, StatusCode> {
let db = state.db.clone(); 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(); let conn = db.blocking_lock();
db::get_status(&conn) db::get_status(&conn)
}) })
@ -138,6 +200,8 @@ pub async fn get_status(
StatusCode::INTERNAL_SERVER_ERROR StatusCode::INTERNAL_SERVER_ERROR
})?; })?;
status.human_readable = status_summary(status.status, status.since, status.last_checked, unix_now());
Ok(Json(status)) Ok(Json(status))
} }
@ -205,3 +269,25 @@ pub async fn get_image(State(state): State<Arc<AppState>>) -> Response {
) )
.into_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"));
}
}

View file

@ -196,6 +196,7 @@ pub fn get_status(conn: &Connection) -> Result<CacheStatusResponse> {
status: row.state.status_for_api(), status: row.state.status_for_api(),
since: row.state.since_for_api(), since: row.state.since_for_api(),
last_checked: row.last_checked, last_checked: row.last_checked,
human_readable: String::new(),
}) })
} }
@ -359,11 +360,13 @@ mod tests {
status: DoorStatus::Open, status: DoorStatus::Open,
since: Some(1234), since: Some(1234),
last_checked: Some(5678), 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(); let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["status"], "open"); assert_eq!(json["status"], "open");
assert_eq!(json["since"], 1234); assert_eq!(json["since"], 1234);
assert_eq!(json["last_checked"], 5678); assert_eq!(json["last_checked"], 5678);
assert_eq!(json["human_readable"], resp.human_readable);
} }
#[test] #[test]

View file

@ -106,25 +106,11 @@ async fn handle_status(
let embed = match resp { let embed = match resp {
Ok(resp) if resp.status().is_success() => match resp.json::<CacheStatusResponse>().await { Ok(resp) if resp.status().is_success() => match resp.json::<CacheStatusResponse>().await {
Ok(data) => { Ok(data) => build_embed(
let mut embed = build_embed(
data.status, data.status,
data.since.unwrap_or(unix_now()), data.since.unwrap_or(unix_now()),
&state.image_base_url, &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
}
Err(e) => { Err(e) => {
error!(error = %e, "failed to parse status response"); error!(error = %e, "failed to parse status response");
CreateEmbed::new() CreateEmbed::new()

View file

@ -81,6 +81,8 @@ pub struct CacheStatusResponse {
pub status: DoorStatus, pub status: DoorStatus,
pub since: Option<u64>, pub since: Option<u64>,
pub last_checked: Option<u64>, pub last_checked: Option<u64>,
#[serde(default)]
pub human_readable: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -177,11 +179,13 @@ mod tests {
status: DoorStatus::Closed, status: DoorStatus::Closed,
since: Some(123), since: Some(123),
last_checked: Some(456), 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(); let json = serde_json::to_value(&response).unwrap();
assert_eq!(json["status"], "closed"); assert_eq!(json["status"], "closed");
assert_eq!(json["since"], 123); assert_eq!(json["since"], 123);
assert_eq!(json["last_checked"], 456); assert_eq!(json["last_checked"], 456);
assert_eq!(json["human_readable"], response.human_readable);
} }
} }