use chrono::{DateTime, Datelike, FixedOffset, NaiveDate, TimeZone, Utc};
use serde::Deserialize;
use crate::fetcher::{FetchContext, RealtimeFetcher, Safety};
use crate::options::OptionSchema;
use crate::payload::{
Body, CalendarData, EntriesData, Entry, Payload, RatioData, TextBlockData, TextData,
};
use crate::render::Shape;
use crate::samples;
use super::common;
const SHAPES: &[Shape] = &[
Shape::Text,
Shape::TextBlock,
Shape::Entries,
Shape::Ratio,
Shape::Calendar,
];
const DEFAULT_WINDOW_DAYS: u32 = 30;
const OPTION_SCHEMAS: &[OptionSchema] = &[
OptionSchema {
name: "timezone",
type_hint: "IANA timezone (e.g. \"Asia/Tokyo\")",
required: false,
default: Some("system local"),
description: "Timezone used when interpreting `target` / `targets`. Omit to follow the system clock.",
},
OptionSchema {
name: "target",
type_hint: "RFC3339 datetime or YYYY-MM-DD",
required: false,
default: None,
description: "Single countdown target. Mutually exclusive with `targets`. Date-only values are treated as UTC midnight.",
},
OptionSchema {
name: "target_label",
type_hint: "string",
required: false,
default: None,
description: "Optional label prefixed to the single-target line (e.g. \"Ship:\").",
},
OptionSchema {
name: "targets",
type_hint: "array of `{ label, target }`",
required: false,
default: None,
description: "Multiple labelled countdowns. Rendered as `TextBlock` by default or `Entries` when the renderer expects a key/value shape.",
},
OptionSchema {
name: "window_days",
type_hint: "integer (1..=3650)",
required: false,
default: Some("30"),
description: "Approach window used by `Ratio`. Progress is `(now - (target - window))/window`, clamped to `0..=1`, so a 30-day window starts filling 30 days out from the target.",
},
];
pub struct ClockCountdownFetcher;
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Options {
#[serde(default)]
pub timezone: Option<String>,
#[serde(default)]
pub target: Option<String>,
#[serde(default)]
pub target_label: Option<String>,
#[serde(default)]
pub targets: Option<Vec<TargetEntry>>,
#[serde(default)]
pub window_days: Option<u32>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TargetEntry {
pub label: String,
pub target: String,
}
impl RealtimeFetcher for ClockCountdownFetcher {
fn name(&self) -> &str {
"clock_countdown"
}
fn safety(&self) -> Safety {
Safety::Safe
}
fn description(&self) -> &'static str {
"Time remaining until one configured `target` date or a list of labelled `targets`, formatted as `Nd Nh` / `Nh Nm` / `Nm`. Past targets keep rendering as `passed` so the widget survives the event boundary. `Ratio` exposes a 30-day approach progress (override with `window_days`); `Calendar` highlights the target day within its month."
}
fn shapes(&self) -> &[Shape] {
SHAPES
}
fn option_schemas(&self) -> &[OptionSchema] {
OPTION_SCHEMAS
}
fn sample_body(&self, shape: Shape) -> Option<Body> {
Some(match shape {
Shape::Text => samples::text("Ship v2: 42d 3h"),
Shape::TextBlock => samples::text_block(&["Ship v2: 42d 3h", "Release: 7d"]),
Shape::Entries => samples::entries(&[("Ship v2", "42d 3h"), ("Release", "7d")]),
Shape::Ratio => samples::ratio(0.77, "Ship v2 · 7d to go"),
Shape::Calendar => Body::Calendar(CalendarData {
year: 2026,
month: 12,
day: Some(31),
events: Vec::new(),
}),
_ => 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 now = common::now_in(opts.timezone.as_deref());
let shape = ctx.shape.unwrap_or(Shape::Text);
let window_days = opts
.window_days
.unwrap_or(DEFAULT_WINDOW_DAYS)
.clamp(1, 3650);
let primary_target = opts.target.as_deref().or_else(|| {
opts.targets
.as_ref()
.and_then(|v| v.first())
.map(|t| t.target.as_str())
});
let body = match shape {
Shape::Ratio => ratio_body(
&now,
primary_target,
opts.target_label.as_deref(),
&opts.targets,
window_days,
),
Shape::Calendar => calendar_body(&now, primary_target, &opts.targets),
Shape::Entries => match &opts.targets {
Some(list) => Body::Entries(EntriesData {
items: list
.iter()
.map(|t| Entry {
key: t.label.clone(),
value: Some(format_remaining(&now, &t.target)),
status: None,
})
.collect(),
}),
None => single_text_body(&now, &opts),
},
Shape::TextBlock => match &opts.targets {
Some(list) => Body::TextBlock(TextBlockData {
lines: list
.iter()
.map(|t| format!("{}: {}", t.label, format_remaining(&now, &t.target)))
.collect(),
}),
None => single_text_body(&now, &opts),
},
_ => single_text_body(&now, &opts),
};
Payload {
icon: None,
status: None,
format: None,
body,
}
}
}
fn single_text_body(now: &DateTime<FixedOffset>, opts: &Options) -> Body {
Body::Text(TextData {
value: match opts.target.as_deref() {
Some(target) => format_single(now, target, opts.target_label.as_deref()),
None => "no target configured".into(),
},
})
}
fn ratio_body(
now: &DateTime<FixedOffset>,
primary: Option<&str>,
label: Option<&str>,
targets: &Option<Vec<TargetEntry>>,
window_days: u32,
) -> Body {
let Some(target_str) = primary else {
return Body::Ratio(RatioData {
value: 0.0,
label: Some("no target configured".into()),
denominator: None,
});
};
let Some(target) = parse_target(target_str) else {
return Body::Ratio(RatioData {
value: 0.0,
label: Some("invalid target".into()),
denominator: None,
});
};
let value = approach_ratio(now, &target, window_days);
let label = label
.map(String::from)
.or_else(|| {
targets
.as_ref()
.and_then(|v| v.first())
.map(|t| t.label.clone())
})
.map(|l| format!("{l} · {}", format_remaining(now, target_str)))
.unwrap_or_else(|| format_remaining(now, target_str));
Body::Ratio(RatioData {
value,
label: Some(label),
denominator: None,
})
}
fn approach_ratio(
now: &DateTime<FixedOffset>,
target: &DateTime<FixedOffset>,
window_days: u32,
) -> f64 {
let secs = target.signed_duration_since(*now).num_seconds();
if secs <= 0 {
return 1.0;
}
let window = i64::from(window_days) * 86_400;
if window <= 0 {
return 0.0;
}
(1.0 - secs as f64 / window as f64).clamp(0.0, 1.0)
}
fn calendar_body(
now: &DateTime<FixedOffset>,
primary: Option<&str>,
targets: &Option<Vec<TargetEntry>>,
) -> Body {
let primary_dt = primary.and_then(parse_target);
let (year, month, day) = match primary_dt {
Some(dt) => (dt.year(), dt.month() as u8, Some(dt.day() as u8)),
None => (now.year(), now.month() as u8, Some(now.day() as u8)),
};
let events = targets
.as_ref()
.map(|list| {
list.iter()
.filter_map(|t| parse_target(&t.target))
.filter(|dt| dt.year() == year && dt.month() == u32::from(month))
.map(|dt| dt.day() as u8)
.collect()
})
.unwrap_or_default();
Body::Calendar(CalendarData {
year,
month,
day,
events,
})
}
fn format_single(now: &DateTime<FixedOffset>, target: &str, label: Option<&str>) -> String {
let remaining = format_remaining(now, target);
match label {
Some(l) => format!("{l}: {remaining}"),
None => remaining,
}
}
fn format_remaining(now: &DateTime<FixedOffset>, target: &str) -> String {
let Some(target) = parse_target(target) else {
return "invalid target".into();
};
let secs = target.signed_duration_since(*now).num_seconds();
if secs <= 0 {
return "passed".into();
}
humanize(secs)
}
fn parse_target(raw: &str) -> Option<DateTime<FixedOffset>> {
if let Ok(dt) = DateTime::parse_from_rfc3339(raw) {
return Some(dt);
}
let date = NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok()?;
let midnight = date.and_hms_opt(0, 0, 0)?;
Some(Utc.from_utc_datetime(&midnight).fixed_offset())
}
fn humanize(secs: i64) -> String {
const MIN: i64 = 60;
const HOUR: i64 = 60 * MIN;
const DAY: i64 = 24 * HOUR;
let days = secs / DAY;
let hours = (secs % DAY) / HOUR;
let minutes = (secs % HOUR) / MIN;
match (days, hours, minutes) {
(0, 0, m) => format!("{m}m"),
(0, h, m) => format!("{h}h {m}m"),
(d, h, _) => format!("{d}d {h}h"),
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::*;
fn at(y: i32, m: u32, d: u32, h: u32, min: u32, s: u32) -> DateTime<FixedOffset> {
Utc.with_ymd_and_hms(y, m, d, h, min, s)
.unwrap()
.fixed_offset()
}
fn ctx(shape: Option<Shape>, options: &str) -> FetchContext {
FetchContext {
widget_id: "c".into(),
timeout: Duration::from_secs(1),
shape,
options: Some(toml::from_str(options).unwrap()),
..Default::default()
}
}
#[test]
fn catalog_surface_matches_countdown_contract() {
let fetcher = ClockCountdownFetcher;
let schemas = fetcher.option_schemas();
assert_eq!(fetcher.name(), "clock_countdown");
assert_eq!(fetcher.safety(), Safety::Safe);
assert!(fetcher.description().contains("Time remaining"));
assert_eq!(fetcher.shapes(), SHAPES);
assert_eq!(
schemas.iter().map(|schema| schema.name).collect::<Vec<_>>(),
vec![
"timezone",
"target",
"target_label",
"targets",
"window_days"
]
);
assert!(matches!(
fetcher.sample_body(Shape::Text),
Some(Body::Text(_))
));
assert!(matches!(
fetcher.sample_body(Shape::TextBlock),
Some(Body::TextBlock(_))
));
assert!(matches!(
fetcher.sample_body(Shape::Entries),
Some(Body::Entries(_))
));
assert!(matches!(
fetcher.sample_body(Shape::Ratio),
Some(Body::Ratio(_))
));
assert!(matches!(
fetcher.sample_body(Shape::Calendar),
Some(Body::Calendar(_))
));
assert!(fetcher.sample_body(Shape::Badge).is_none());
}
#[test]
fn invalid_options_return_placeholder_body() {
let p = ClockCountdownFetcher.compute(&ctx(Some(Shape::Text), "bogus = true"));
let Body::TextBlock(d) = p.body else {
panic!("expected placeholder text block")
};
assert!(d.lines[0].contains("invalid options"));
assert_eq!(d.lines[1], "check [widget.options] in config");
}
#[test]
fn single_target_emits_text() {
let p = ClockCountdownFetcher.compute(&ctx(Some(Shape::Text), "target = \"2099-12-31\""));
match p.body {
Body::Text(d) => assert!(!d.value.is_empty()),
_ => panic!("expected text"),
}
}
#[test]
fn multi_entries_yields_one_per_target() {
let p = ClockCountdownFetcher.compute(&ctx(
Some(Shape::Entries),
r#"targets = [{label = "A", target = "2099-01-01"}, {label = "B", target = "2099-06-01"}]"#,
));
match p.body {
Body::Entries(d) => assert_eq!(d.items.len(), 2),
_ => panic!("expected entries"),
}
}
#[test]
fn entries_and_text_block_without_targets_fall_back_to_single_text_body() {
let entries = ClockCountdownFetcher.compute(&ctx(
Some(Shape::Entries),
"target = \"invalid\"\ntarget_label = \"Ship\"",
));
let Body::Text(text) = entries.body else {
panic!("expected text fallback")
};
assert_eq!(text.value, "Ship: invalid target");
let block = ClockCountdownFetcher.compute(&ctx(Some(Shape::TextBlock), ""));
let Body::Text(text) = block.body else {
panic!("expected text fallback")
};
assert_eq!(text.value, "no target configured");
}
#[test]
fn multi_target_text_block_formats_each_target_line() {
let p = ClockCountdownFetcher.compute(&ctx(
Some(Shape::TextBlock),
r#"targets = [{label = "A", target = "invalid"}, {label = "B", target = "2000-01-01"}]"#,
));
let Body::TextBlock(d) = p.body else {
panic!("expected text block")
};
assert_eq!(d.lines, vec!["A: invalid target", "B: passed"]);
}
#[test]
fn past_target_renders_passed() {
let p = ClockCountdownFetcher.compute(&ctx(Some(Shape::Text), "target = \"2000-01-01\""));
match p.body {
Body::Text(d) => assert!(d.value.contains("passed")),
_ => panic!("expected text"),
}
}
#[test]
fn humanize_buckets_by_granularity() {
assert_eq!(humanize(45 * 60), "45m");
assert_eq!(humanize(3 * 3600 + 5 * 60), "3h 5m");
assert_eq!(humanize(2 * 86_400 + 3 * 3600), "2d 3h");
}
#[test]
fn approach_ratio_starts_at_zero_and_completes_at_target() {
let now = Utc
.with_ymd_and_hms(2026, 1, 1, 0, 0, 0)
.unwrap()
.fixed_offset();
let far = Utc
.with_ymd_and_hms(2027, 1, 1, 0, 0, 0)
.unwrap()
.fixed_offset();
assert_eq!(approach_ratio(&now, &far, 30), 0.0);
let mid = Utc
.with_ymd_and_hms(2026, 1, 16, 0, 0, 0)
.unwrap()
.fixed_offset();
assert!((approach_ratio(&now, &mid, 30) - 0.5).abs() < 1e-6);
let past = Utc
.with_ymd_and_hms(2025, 12, 1, 0, 0, 0)
.unwrap()
.fixed_offset();
assert_eq!(approach_ratio(&now, &past, 30), 1.0);
}
#[test]
fn ratio_shape_emits_ratio_body() {
let p = ClockCountdownFetcher.compute(&ctx(Some(Shape::Ratio), "target = \"2099-12-31\""));
let Body::Ratio(d) = p.body else {
panic!("expected ratio")
};
assert!(d.value >= 0.0 && d.value <= 1.0);
assert!(d.label.is_some());
}
#[test]
fn ratio_with_no_target_label_says_so() {
let p = ClockCountdownFetcher.compute(&ctx(Some(Shape::Ratio), ""));
let Body::Ratio(d) = p.body else {
panic!("expected ratio")
};
assert_eq!(d.value, 0.0);
assert_eq!(d.label.as_deref(), Some("no target configured"));
}
#[test]
fn ratio_body_uses_first_target_label_and_handles_invalid_targets() {
let now = at(2026, 1, 1, 0, 0, 0);
let targets = Some(vec![TargetEntry {
label: "Ship".into(),
target: "2026-01-16".into(),
}]);
let Body::Ratio(ratio) = ratio_body(&now, Some("2026-01-16"), None, &targets, 30) else {
panic!("expected ratio")
};
assert!((ratio.value - 0.5).abs() < 1e-6);
assert_eq!(ratio.label.as_deref(), Some("Ship · 15d 0h"));
let Body::Ratio(invalid) = ratio_body(&now, Some("not-a-date"), None, &None, 30) else {
panic!("expected ratio")
};
assert_eq!(invalid.value, 0.0);
assert_eq!(invalid.label.as_deref(), Some("invalid target"));
}
#[test]
fn approach_ratio_returns_zero_for_non_positive_window() {
let now = at(2026, 1, 1, 0, 0, 0);
let target = at(2026, 1, 2, 0, 0, 0);
assert_eq!(approach_ratio(&now, &target, 0), 0.0);
}
#[test]
fn calendar_body_falls_back_to_now_and_filters_invalid_or_other_month_targets() {
let now = at(2026, 4, 15, 0, 0, 0);
let targets = Some(vec![
TargetEntry {
label: "A".into(),
target: "2026-04-29".into(),
},
TargetEntry {
label: "B".into(),
target: "invalid".into(),
},
TargetEntry {
label: "C".into(),
target: "2026-05-01".into(),
},
]);
let Body::Calendar(calendar) = calendar_body(&now, None, &targets) else {
panic!("expected calendar")
};
assert_eq!(calendar.year, 2026);
assert_eq!(calendar.month, 4);
assert_eq!(calendar.day, Some(15));
assert_eq!(calendar.events, vec![29]);
}
#[test]
fn parse_target_accepts_rfc3339_and_date_only() {
assert_eq!(parse_target("2026-12-31"), Some(at(2026, 12, 31, 0, 0, 0)));
assert_eq!(
parse_target("2026-12-31T18:00:00+09:00")
.unwrap()
.offset()
.local_minus_utc(),
9 * 60 * 60
);
assert!(parse_target("not-a-date").is_none());
}
#[test]
fn calendar_shape_pins_target_day() {
let p =
ClockCountdownFetcher.compute(&ctx(Some(Shape::Calendar), "target = \"2099-12-31\""));
let Body::Calendar(d) = p.body else {
panic!("expected calendar")
};
assert_eq!(d.year, 2099);
assert_eq!(d.month, 12);
assert_eq!(d.day, Some(31));
}
#[test]
fn calendar_with_multi_targets_marks_event_days_in_primary_month() {
let p = ClockCountdownFetcher.compute(&ctx(
Some(Shape::Calendar),
r#"targets = [
{label = "A", target = "2099-12-05"},
{label = "B", target = "2099-12-20"},
{label = "C", target = "2100-01-10"}
]"#,
));
let Body::Calendar(d) = p.body else {
panic!("expected calendar")
};
assert_eq!(d.year, 2099);
assert_eq!(d.month, 12);
assert_eq!(d.day, Some(5));
assert!(d.events.contains(&20));
assert!(!d.events.contains(&10));
}
}