use chrono::{DateTime, FixedOffset, Utc};
use serde::Deserialize;
use crate::fetcher::{FetchContext, RealtimeFetcher, Safety};
use crate::payload::{Body, EntriesData, Entry, Payload, TextBlockData};
use crate::render::Shape;
use crate::samples;
use super::common;
const SHAPES: &[Shape] = &[Shape::TextBlock, Shape::Entries];
pub struct ClockTimezonesFetcher;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
#[serde(default)]
pub timezones: Vec<TimezoneSpec>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum TimezoneSpec {
Plain(String),
Named {
tz: String,
#[serde(default)]
label: Option<String>,
},
}
impl TimezoneSpec {
fn tz(&self) -> &str {
match self {
Self::Plain(s) => s,
Self::Named { tz, .. } => tz,
}
}
fn label(&self) -> &str {
match self {
Self::Plain(s) => s,
Self::Named { tz, label } => label.as_deref().unwrap_or(tz),
}
}
}
impl RealtimeFetcher for ClockTimezonesFetcher {
fn name(&self) -> &str {
"clock_timezones"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"A world-clock strip showing the current time in each of the configured IANA `timezones`, one row per zone. Use this for at-a-glance multi-region time; pick the base `clock` fetcher for a single local time."
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::TextBlock => samples::text_block(&[
"UTC 14:35",
"Tokyo 23:35",
"New York 10:35",
]),
Shape::Entries => {
samples::entries(&[("UTC", "14:35"), ("Tokyo", "23:35"), ("New York", "10:35")])
}
_ => return None,
})
}
fn compute(&self, ctx: &FetchContext) -> Payload {
let opts: Options = match common::parse_options(ctx.options.as_ref()) {
Ok(o) => o,
Err(msg) => return common::placeholder(&msg),
};
let fmt = ctx.format.as_deref().unwrap_or(common::DEFAULT_FORMAT);
let locale = ctx.locale.as_deref();
let shape = ctx.shape.unwrap_or(Shape::TextBlock);
let rows: Vec<(String, String)> = opts
.timezones
.iter()
.map(|spec| {
(
spec.label().to_string(),
format_time(spec.tz(), fmt, locale),
)
})
.collect();
let body = match shape {
Shape::Entries => Body::Entries(EntriesData {
items: rows
.into_iter()
.map(|(k, v)| Entry {
key: k,
value: Some(v),
status: None,
})
.collect(),
}),
_ => Body::TextBlock(TextBlockData {
lines: rows.into_iter().map(|(n, t)| format!("{n} {t}")).collect(),
}),
};
Payload {
icon: None,
status: None,
format: None,
body,
}
}
}
fn format_time(zone: &str, fmt: &str, locale: Option<&str>) -> String {
resolve(zone)
.map(|dt| common::safe_format_with_locale(&dt, fmt, locale))
.unwrap_or_else(|| "??".into())
}
fn resolve(name: &str) -> Option<DateTime<FixedOffset>> {
let tz = common::parse_tz(name)?;
Some(Utc::now().with_timezone(&tz).fixed_offset())
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
fn ctx(shape: Option<Shape>, options: &str) -> FetchContext {
FetchContext {
widget_id: "tz".into(),
timeout: Duration::from_secs(1),
shape,
options: Some(toml::from_str(options).unwrap()),
..Default::default()
}
}
#[test]
fn text_block_emits_one_row_per_zone() {
let p = ClockTimezonesFetcher.compute(&ctx(
Some(Shape::TextBlock),
"timezones = [\"Asia/Tokyo\", \"UTC\"]",
));
match p.body {
Body::TextBlock(d) => assert_eq!(d.lines.len(), 2),
_ => panic!("expected text_block"),
}
}
#[test]
fn entries_emits_one_entry_per_zone() {
let p = ClockTimezonesFetcher.compute(&ctx(
Some(Shape::Entries),
"timezones = [\"Asia/Tokyo\", \"Europe/London\"]",
));
match p.body {
Body::Entries(d) => assert_eq!(d.items.len(), 2),
_ => panic!("expected entries"),
}
}
#[test]
fn invalid_zone_renders_placeholder_time() {
let p = ClockTimezonesFetcher
.compute(&ctx(Some(Shape::TextBlock), "timezones = [\"Not/AZone\"]"));
match p.body {
Body::TextBlock(d) => assert!(d.lines[0].ends_with("??")),
_ => panic!("expected text_block"),
}
}
#[test]
fn timezone_spec_named_uses_tz_and_label_fallbacks() {
let labelled = TimezoneSpec::Named {
tz: "Asia/Tokyo".into(),
label: Some("Tokyo".into()),
};
assert_eq!(labelled.tz(), "Asia/Tokyo");
assert_eq!(labelled.label(), "Tokyo");
let unlabeled = TimezoneSpec::Named {
tz: "UTC".into(),
label: None,
};
assert_eq!(unlabeled.tz(), "UTC");
assert_eq!(unlabeled.label(), "UTC");
}
#[test]
fn fetcher_contract_and_samples_cover_catalog_surface() {
let fetcher = ClockTimezonesFetcher;
assert_eq!(fetcher.name(), "clock_timezones");
assert_eq!(fetcher.safety(), Safety::Safe);
assert!(fetcher.description().contains("world-clock strip"));
assert_eq!(fetcher.shapes(), SHAPES);
assert!(matches!(
fetcher.sample_body(Shape::TextBlock),
Some(Body::TextBlock(_))
));
assert!(matches!(
fetcher.sample_body(Shape::Entries),
Some(Body::Entries(_))
));
assert!(fetcher.sample_body(Shape::Text).is_none());
}
#[test]
fn default_shape_uses_text_block_and_named_labels() {
let p = ClockTimezonesFetcher.compute(&ctx(
None,
r#"timezones = [{ tz = "UTC", label = "Zulu" }, { tz = "Europe/London" }]"#,
));
match p.body {
Body::TextBlock(d) => {
assert!(d.lines[0].starts_with("Zulu "));
assert!(d.lines[1].starts_with("Europe/London "));
}
_ => panic!("expected text_block"),
}
}
#[test]
fn invalid_options_return_placeholder() {
let p = ClockTimezonesFetcher.compute(&ctx(Some(Shape::TextBlock), "unexpected = 1"));
match p.body {
Body::TextBlock(d) => assert!(d.lines[0].contains("invalid options")),
_ => panic!("expected text_block"),
}
}
}