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

View file

@ -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"] }

View file

@ -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<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_WINDOW_SECS: u64 = 60;
@ -127,7 +189,7 @@ pub async fn get_status(
State(state): State<Arc<AppState>>,
) -> Result<Json<CacheStatusResponse>, 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<Arc<AppState>>) -> 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(),
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]

View file

@ -106,25 +106,11 @@ async fn handle_status(
let embed = match resp {
Ok(resp) if resp.status().is_success() => match resp.json::<CacheStatusResponse>().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()

View file

@ -81,6 +81,8 @@ pub struct CacheStatusResponse {
pub status: DoorStatus,
pub since: Option<u64>,
pub last_checked: Option<u64>,
#[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);
}
}