storkit: merge 466_story_configurable_timezone_in_project_toml_for_timer_scheduling

This commit is contained in:
dave
2026-04-03 13:12:52 +00:00
parent e1cea8f958
commit e9954d244b
6 changed files with 147 additions and 32 deletions
+87 -26
View File
@@ -5,6 +5,7 @@
//! and command parsing / handling for the `timer` bot command.
use chrono::{DateTime, Duration, Local, NaiveTime, TimeZone, Utc};
use chrono_tz::Tz;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
@@ -375,6 +376,12 @@ pub async fn handle_timer_command(
store: &TimerStore,
project_root: &Path,
) -> String {
// Load the configured timezone (if any) from project.toml.
let config_tz: Option<String> = crate::config::ProjectConfig::load(project_root)
.ok()
.and_then(|c| c.timezone);
let tz_str: Option<&str> = config_tz.as_deref();
match cmd {
TimerCommand::Schedule {
story_number_or_id,
@@ -400,7 +407,7 @@ pub async fn handle_timer_command(
);
}
let scheduled_at = match next_occurrence_of_hhmm(&hhmm) {
let scheduled_at = match next_occurrence_of_hhmm(&hhmm, tz_str) {
Some(t) => t,
None => {
return format!(
@@ -411,10 +418,9 @@ pub async fn handle_timer_command(
match store.add(story_id.clone(), scheduled_at) {
Ok(()) => {
let local_time = scheduled_at.with_timezone(&Local);
let (display_time, tz_label) = format_in_timezone(scheduled_at, tz_str);
format!(
"Timer set for **{story_id}** at **{}** (server local time).",
local_time.format("%Y-%m-%d %H:%M")
"Timer set for **{story_id}** at **{display_time}** ({tz_label})."
)
}
Err(e) => format!("Failed to save timer: {e}"),
@@ -427,11 +433,11 @@ pub async fn handle_timer_command(
}
let mut lines = vec!["**Pending timers:**".to_string()];
for t in &timers {
let local_time = t.scheduled_at.with_timezone(&Local);
let (display_time, _) = format_in_timezone(t.scheduled_at, tz_str);
lines.push(format!(
"- **{}** → {}",
t.story_id,
local_time.format("%Y-%m-%d %H:%M")
display_time
));
}
lines.join("\n")
@@ -457,10 +463,10 @@ pub async fn handle_timer_command(
// ── Helpers ────────────────────────────────────────────────────────────────
/// Parse `HH:MM` and return the next UTC instant at which the server-local
/// clock will read that time. If the time has already passed today, returns
/// tomorrow's occurrence.
pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option<DateTime<Utc>> {
/// Parse `HH:MM` and return the next UTC instant at which the given timezone
/// (or the server-local clock when `timezone` is `None`) will read that time.
/// If the time has already passed today, returns tomorrow's occurrence.
pub fn next_occurrence_of_hhmm(hhmm: &str, timezone: Option<&str>) -> Option<DateTime<Utc>> {
let (hh, mm) = hhmm.split_once(':')?;
let hours: u32 = hh.parse().ok()?;
let minutes: u32 = mm.parse().ok()?;
@@ -468,17 +474,59 @@ pub fn next_occurrence_of_hhmm(hhmm: &str) -> Option<DateTime<Utc>> {
return None;
}
let target_time = NaiveTime::from_hms_opt(hours, minutes, 0)?;
let now_local = Local::now();
let today = now_local.date_naive();
let candidate = today.and_time(target_time);
let candidate_local = Local.from_local_datetime(&candidate).single()?;
if candidate_local > now_local {
Some(candidate_local.to_utc())
} else {
let tomorrow = today + Duration::days(1);
let tomorrow_candidate = tomorrow.and_time(target_time);
let tomorrow_local = Local.from_local_datetime(&tomorrow_candidate).single()?;
Some(tomorrow_local.to_utc())
match timezone.and_then(|s| s.parse::<Tz>().ok()) {
Some(tz) => {
let now_tz = Utc::now().with_timezone(&tz);
let today = now_tz.date_naive();
let candidate = today.and_time(target_time);
let candidate_tz = tz.from_local_datetime(&candidate).single()?;
if candidate_tz > now_tz {
Some(candidate_tz.to_utc())
} else {
let tomorrow = today + Duration::days(1);
let tomorrow_candidate = tomorrow.and_time(target_time);
let tomorrow_tz = tz.from_local_datetime(&tomorrow_candidate).single()?;
Some(tomorrow_tz.to_utc())
}
}
None => {
// Fall back to host/container local timezone.
let now_local = Local::now();
let today = now_local.date_naive();
let candidate = today.and_time(target_time);
let candidate_local = Local.from_local_datetime(&candidate).single()?;
if candidate_local > now_local {
Some(candidate_local.to_utc())
} else {
let tomorrow = today + Duration::days(1);
let tomorrow_candidate = tomorrow.and_time(target_time);
let tomorrow_local = Local.from_local_datetime(&tomorrow_candidate).single()?;
Some(tomorrow_local.to_utc())
}
}
}
}
/// Format a UTC instant for display in the given timezone (or local time when
/// `timezone` is `None`). Returns `(formatted_string, label)` where `label`
/// is either the IANA timezone name or `"server local time"`.
fn format_in_timezone(dt: DateTime<Utc>, timezone: Option<&str>) -> (String, String) {
match timezone.and_then(|s| s.parse::<Tz>().ok()) {
Some(tz) => {
let tz_time = dt.with_timezone(&tz);
(
tz_time.format("%Y-%m-%d %H:%M").to_string(),
tz.to_string(),
)
}
None => {
let local_time = dt.with_timezone(&Local);
(
local_time.format("%Y-%m-%d %H:%M").to_string(),
"server local time".to_string(),
)
}
}
}
@@ -544,31 +592,44 @@ mod tests {
#[test]
fn valid_hhmm_returns_some() {
let result = next_occurrence_of_hhmm("14:30");
let result = next_occurrence_of_hhmm("14:30", None);
assert!(result.is_some(), "valid HH:MM should return Some");
}
#[test]
fn invalid_hhmm_missing_colon_returns_none() {
assert!(next_occurrence_of_hhmm("1430").is_none());
assert!(next_occurrence_of_hhmm("1430", None).is_none());
}
#[test]
fn invalid_hhmm_bad_hours_returns_none() {
assert!(next_occurrence_of_hhmm("25:00").is_none());
assert!(next_occurrence_of_hhmm("25:00", None).is_none());
}
#[test]
fn invalid_hhmm_bad_minutes_returns_none() {
assert!(next_occurrence_of_hhmm("12:60").is_none());
assert!(next_occurrence_of_hhmm("12:60", None).is_none());
}
#[test]
fn next_occurrence_is_in_the_future() {
let result = next_occurrence_of_hhmm("14:30").unwrap();
let result = next_occurrence_of_hhmm("14:30", None).unwrap();
assert!(result > Utc::now(), "next occurrence must be in the future");
}
#[test]
fn next_occurrence_with_named_timezone_is_in_the_future() {
let result = next_occurrence_of_hhmm("14:30", Some("Europe/London")).unwrap();
assert!(result > Utc::now(), "next occurrence (Europe/London) must be in the future");
}
#[test]
fn next_occurrence_with_invalid_timezone_falls_back_to_local() {
// An unrecognised timezone name falls back to chrono::Local (returns Some).
let result = next_occurrence_of_hhmm("14:30", Some("Invalid/Zone"));
assert!(result.is_some(), "invalid timezone should fall back to local and return Some");
}
// ── extract_timer_command ───────────────────────────────────────────
#[test]