use std::time::{Duration, SystemTime};
use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Weekday};
#[derive(Clone, Copy, Debug)]
pub struct BootInstant(Duration);
impl BootInstant {
#[cfg(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "ios"))]
pub fn now() -> Self {
#[cfg(any(target_os = "linux", target_os = "android"))]
const CLOCK: libc::clockid_t = libc::CLOCK_BOOTTIME;
#[cfg(any(target_os = "macos", target_os = "ios"))]
const CLOCK: libc::clockid_t = libc::CLOCK_MONOTONIC;
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
let rc = unsafe { libc::clock_gettime(CLOCK, &raw mut ts) };
assert_eq!(rc, 0, "clock_gettime failed");
BootInstant(Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32))
}
#[cfg(not(any(target_os = "linux", target_os = "android", target_os = "macos", target_os = "ios")))]
pub fn now() -> Self {
static EPOCH: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
BootInstant(EPOCH.get_or_init(std::time::Instant::now).elapsed())
}
pub fn elapsed(self) -> Duration {
Self::now().0.saturating_sub(self.0)
}
}
pub struct Timer {
pub total: Duration,
mode: Mode,
}
enum Mode {
Span {
accumulated: Duration,
running_since: Option<BootInstant>,
},
Wall { started: SystemTime },
}
impl Timer {
pub fn new(total: Duration) -> Self {
Timer {
total,
mode: Mode::Span {
accumulated: Duration::ZERO,
running_since: Some(BootInstant::now()),
},
}
}
pub fn until(target: SystemTime) -> Self {
let started = SystemTime::now();
let total = target.duration_since(started).unwrap_or_default();
Timer {
total,
mode: Mode::Wall { started },
}
}
pub fn elapsed(&self) -> Duration {
match &self.mode {
Mode::Span {
accumulated,
running_since,
} => *accumulated + running_since.map_or(Duration::ZERO, BootInstant::elapsed),
Mode::Wall { started } => SystemTime::now().duration_since(*started).unwrap_or_default(),
}
}
pub fn toggle(&mut self) {
let elapsed = self.elapsed();
self.mode = match self.mode {
Mode::Span {
running_since: None,
accumulated,
} => Mode::Span {
accumulated,
running_since: Some(BootInstant::now()),
},
Mode::Span {
running_since: Some(_), ..
}
| Mode::Wall { .. } => Mode::Span {
accumulated: elapsed,
running_since: None,
},
};
}
pub fn paused(&self) -> bool {
matches!(
self.mode,
Mode::Span {
running_since: None,
..
}
)
}
pub fn synced(&self) -> bool {
matches!(self.mode, Mode::Wall { .. })
}
}
pub fn parse_until(s: &str) -> Result<SystemTime, String> {
resolve_until(&chrono::Local::now(), s).map(SystemTime::from)
}
pub fn resolve_until<Tz: TimeZone>(now: &DateTime<Tz>, s: &str) -> Result<DateTime<Tz>, String> {
let bad = || format!("can't understand time {s:?} (try 17:30, tomorrow 9:00, fri 17:00 or 2026-12-24 18:00)");
let text = s.trim().to_ascii_lowercase();
let text = match text.split_once('t') {
Some((d, t)) if NaiveDate::parse_from_str(d, "%Y-%m-%d").is_ok() => format!("{d} {t}"),
_ => text,
};
let words: Vec<&str> = text.split_whitespace().collect();
let (day, time) = match words.as_slice() {
[one] => match parse_time(one) {
Some(t) => return next_occurrence(now, t).ok_or_else(bad),
None => (*one, NaiveTime::MIN),
},
[day, time] => (*day, parse_time(time).ok_or_else(bad)?),
_ => return Err(bad()),
};
let today = now.date_naive();
let at = if let Ok(date) = NaiveDate::parse_from_str(day, "%Y-%m-%d") {
local_at(&now.timezone(), date.and_time(time)).ok_or_else(bad)?
} else if day == "today" || day == "tomorrow" {
let date = if day == "today" {
today
} else {
today.succ_opt().ok_or_else(bad)?
};
local_at(&now.timezone(), date.and_time(time)).ok_or_else(bad)?
} else {
let wd: Weekday = day.parse().map_err(|_| bad())?;
(0..=7)
.filter_map(|n| today.checked_add_days(chrono::Days::new(n)))
.filter(|d| d.weekday() == wd)
.filter_map(|d| local_at(&now.timezone(), d.and_time(time)))
.find(|at| at > now)
.ok_or_else(bad)?
};
if at <= *now {
return Err(format!("{s:?} is in the past"));
}
Ok(at)
}
fn parse_time(s: &str) -> Option<NaiveTime> {
NaiveTime::parse_from_str(s, "%H:%M:%S")
.or_else(|_| NaiveTime::parse_from_str(s, "%H:%M"))
.ok()
}
fn local_at<Tz: TimeZone>(tz: &Tz, local: NaiveDateTime) -> Option<DateTime<Tz>> {
let mut local = local;
for _ in 0..24 * 60 {
if let Some(at) = tz.from_local_datetime(&local).earliest() {
return Some(at);
}
local += chrono::Duration::minutes(1);
}
None
}
pub fn next_occurrence<Tz: TimeZone>(now: &DateTime<Tz>, t: NaiveTime) -> Option<DateTime<Tz>> {
let today = now.date_naive();
[today, today.succ_opt()?, today.succ_opt()?.succ_opt()?]
.into_iter()
.filter_map(|day| local_at(&now.timezone(), day.and_time(t)))
.find(|at| at > now)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono_tz::Europe::Berlin;
fn berlin(y: i32, mo: u32, d: u32, h: u32, mi: u32) -> DateTime<chrono_tz::Tz> {
Berlin.with_ymd_and_hms(y, mo, d, h, mi, 0).earliest().unwrap()
}
fn hm(h: u32, m: u32) -> NaiveTime {
NaiveTime::from_hms_opt(h, m, 0).unwrap()
}
#[test]
fn later_today_or_tomorrow() {
let now = berlin(2026, 6, 1, 12, 0);
assert_eq!(next_occurrence(&now, hm(17, 30)), Some(berlin(2026, 6, 1, 17, 30)));
assert_eq!(next_occurrence(&now, hm(9, 0)), Some(berlin(2026, 6, 2, 9, 0)));
assert_eq!(next_occurrence(&now, hm(12, 0)), Some(berlin(2026, 6, 2, 12, 0)));
}
#[test]
fn spring_forward_gap() {
let now = berlin(2026, 3, 28, 22, 0);
let at = next_occurrence(&now, hm(2, 30)).unwrap();
assert_eq!(at, berlin(2026, 3, 29, 3, 0));
assert_eq!((at - now).num_hours(), 4);
}
#[test]
fn fall_back_ambiguous() {
let now = berlin(2026, 10, 24, 23, 0);
let at = next_occurrence(&now, hm(2, 30)).unwrap();
assert_eq!(at.offset().to_string(), "CEST");
assert_eq!((at - now).num_minutes(), 3 * 60 + 30);
let at = next_occurrence(&now, hm(8, 0)).unwrap();
assert_eq!((at - now).num_hours(), 10);
}
#[test]
fn dates_weekdays_and_relative_days() {
let now = berlin(2026, 6, 5, 12, 0);
let at = |s: &str| resolve_until(&now, s);
assert_eq!(at("17:30"), Ok(berlin(2026, 6, 5, 17, 30)));
assert_eq!(at("tomorrow 9:00"), Ok(berlin(2026, 6, 6, 9, 0)));
assert_eq!(at("today 18:00"), Ok(berlin(2026, 6, 5, 18, 0)));
assert_eq!(at("fri 17:00"), Ok(berlin(2026, 6, 5, 17, 0))); assert_eq!(at("Friday 9:00"), Ok(berlin(2026, 6, 12, 9, 0))); assert_eq!(at("mon"), Ok(berlin(2026, 6, 8, 0, 0)));
assert_eq!(at("2026-12-24 18:00"), Ok(berlin(2026, 12, 24, 18, 0)));
assert_eq!(at("2026-12-24T18:00"), Ok(berlin(2026, 12, 24, 18, 0)));
assert_eq!(at("2026-12-24"), Ok(berlin(2026, 12, 24, 0, 0)));
assert!(at("today 9:00").unwrap_err().contains("in the past"));
assert!(at("2020-01-01").unwrap_err().contains("in the past"));
assert!(at("someday").unwrap_err().contains("can't understand"));
assert!(at("25:00").is_err());
}
#[test]
fn dates_across_dst() {
let now = berlin(2026, 3, 20, 12, 0);
assert_eq!(resolve_until(&now, "2026-03-29 02:30"), Ok(berlin(2026, 3, 29, 3, 0)));
}
#[test]
fn pausing_a_wall_timer_keeps_remaining() {
let mut t = Timer::until(SystemTime::now() + Duration::from_secs(600));
assert!(t.synced());
t.toggle();
assert!(t.paused() && !t.synced());
let left = t.total.saturating_sub(t.elapsed()).as_secs_f32();
assert!((599.0..=600.0).contains(&left), "{left}");
}
}