use std::future::Future;
use chrono::{DateTime, FixedOffset, TimeZone, Utc};
tokio::task_local! {
static ACTIVE_TZ: FixedOffset;
}
#[must_use]
pub fn current_offset() -> FixedOffset {
ACTIVE_TZ
.try_with(|tz| *tz)
.unwrap_or_else(|_| FixedOffset::east_opt(0).expect("UTC offset"))
}
pub async fn with_offset<F>(offset: FixedOffset, future: F) -> F::Output
where
F: Future,
{
ACTIVE_TZ.scope(offset, future).await
}
pub fn activate(_offset: FixedOffset) -> bool {
false
}
#[must_use]
pub fn localtime(utc_dt: DateTime<Utc>) -> DateTime<FixedOffset> {
let off = current_offset();
utc_dt.with_timezone(&off)
}
#[must_use]
pub fn localtime_with_offset(utc_dt: DateTime<Utc>, offset: FixedOffset) -> DateTime<FixedOffset> {
utc_dt.with_timezone(&offset)
}
#[must_use]
pub fn parse_offset(s: &str) -> Option<FixedOffset> {
let s = s.trim();
if s.eq_ignore_ascii_case("z") || s.eq_ignore_ascii_case("utc") {
return Some(FixedOffset::east_opt(0).expect("UTC offset"));
}
if let Some(stripped) = s.strip_prefix('+').or_else(|| s.strip_prefix('-')) {
if let Some((hh, mm)) = stripped.split_once(':') {
let h: i32 = hh.parse().ok()?;
let m: i32 = mm.parse().ok()?;
if !(0..=23).contains(&h) || !(0..=59).contains(&m) {
return None;
}
let total = h * 3600 + m * 60;
let signed = if s.starts_with('-') { -total } else { total };
return FixedOffset::east_opt(signed);
}
if stripped.len() == 4 && stripped.chars().all(|c| c.is_ascii_digit()) {
let h: i32 = stripped[..2].parse().ok()?;
let m: i32 = stripped[2..].parse().ok()?;
let total = h * 3600 + m * 60;
let signed = if s.starts_with('-') { -total } else { total };
return FixedOffset::east_opt(signed);
}
}
if let Ok(mins) = s.parse::<i32>() {
return FixedOffset::east_opt(mins.saturating_mul(60));
}
None
}
pub fn from_cookie(headers: &axum::http::HeaderMap, cookie_name: &str) -> Option<FixedOffset> {
let raw = headers
.get(axum::http::header::COOKIE)
.and_then(|h| h.to_str().ok())?;
for pair in raw.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix(&format!("{cookie_name}=")) {
return parse_offset(value);
}
}
None
}
pub fn from_request_headers(
headers: &axum::http::HeaderMap,
cookie_name: &str,
) -> Option<FixedOffset> {
from_cookie(headers, cookie_name).or_else(|| {
headers
.get("time-zone")
.or_else(|| headers.get("x-timezone"))
.and_then(|v| v.to_str().ok())
.and_then(parse_offset)
})
}
#[cfg(feature = "template_views")]
pub fn register_filters(tera: &mut tera::Tera) {
tera.register_filter("localtime", localtime_filter);
}
#[cfg(feature = "template_views")]
fn localtime_filter(
value: &tera::Value,
args: &std::collections::HashMap<String, tera::Value>,
) -> tera::Result<tera::Value> {
let format = args
.get("format")
.and_then(tera::Value::as_str)
.unwrap_or("%Y-%m-%d %H:%M:%S %z");
let utc_dt: Option<DateTime<Utc>> = match value {
tera::Value::String(s) => DateTime::parse_from_rfc3339(s)
.ok()
.map(|d| d.with_timezone(&Utc)),
tera::Value::Number(n) => n
.as_i64()
.and_then(|secs| Utc.timestamp_opt(secs, 0).single()),
_ => None,
};
let Some(utc_dt) = utc_dt else {
return Ok(value.clone());
};
let local = utc_dt.with_timezone(¤t_offset());
Ok(tera::Value::String(local.format(format).to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn current_offset_defaults_to_utc_outside_scope() {
let off = current_offset();
assert_eq!(off.local_minus_utc(), 0);
}
#[tokio::test]
async fn with_offset_installs_task_local() {
let target = FixedOffset::east_opt(5 * 3600).unwrap();
with_offset(target, async move {
let inside = current_offset();
assert_eq!(inside, target);
})
.await;
assert_eq!(current_offset().local_minus_utc(), 0);
}
#[tokio::test]
async fn nested_with_offset_replaces_outer_scope() {
let outer = FixedOffset::east_opt(2 * 3600).unwrap();
let inner = FixedOffset::east_opt(9 * 3600).unwrap();
with_offset(outer, async move {
assert_eq!(current_offset(), outer);
with_offset(inner, async move {
assert_eq!(current_offset(), inner);
})
.await;
assert_eq!(current_offset(), outer);
})
.await;
}
#[tokio::test]
async fn localtime_converts_utc_to_active_offset() {
let target = FixedOffset::east_opt(3 * 3600).unwrap();
let utc = Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap();
with_offset(target, async move {
let local = localtime(utc);
assert_eq!(local.hour(), 15); assert_eq!(local.offset(), &target);
})
.await;
}
#[test]
fn localtime_with_explicit_offset_ignores_task_local() {
let east8 = FixedOffset::east_opt(8 * 3600).unwrap();
let utc = Utc.with_ymd_and_hms(2026, 1, 15, 0, 0, 0).unwrap();
let local = localtime_with_offset(utc, east8);
assert_eq!(local.hour(), 8);
}
use chrono::Timelike;
#[test]
fn parse_offset_handles_colon_and_unsigned_shapes() {
assert_eq!(
parse_offset("+05:30"),
Some(FixedOffset::east_opt(5 * 3600 + 30 * 60).unwrap())
);
assert_eq!(
parse_offset("-08:00"),
Some(FixedOffset::west_opt(8 * 3600).unwrap())
);
assert_eq!(
parse_offset("+0530"),
Some(FixedOffset::east_opt(5 * 3600 + 30 * 60).unwrap())
);
assert_eq!(parse_offset("Z"), Some(FixedOffset::east_opt(0).unwrap()));
assert_eq!(parse_offset("UTC"), Some(FixedOffset::east_opt(0).unwrap()));
assert_eq!(parse_offset("utc"), Some(FixedOffset::east_opt(0).unwrap()));
}
#[test]
fn parse_offset_handles_signed_minutes() {
assert_eq!(
parse_offset("-300"),
Some(FixedOffset::west_opt(5 * 3600).unwrap())
);
assert_eq!(
parse_offset("60"),
Some(FixedOffset::east_opt(60 * 60).unwrap())
);
assert_eq!(parse_offset("0"), Some(FixedOffset::east_opt(0).unwrap()));
}
#[test]
fn parse_offset_rejects_garbage() {
assert_eq!(parse_offset(""), None);
assert_eq!(parse_offset("not a tz"), None);
assert_eq!(parse_offset("+25:00"), None); assert_eq!(parse_offset("+05:99"), None); }
#[test]
fn from_cookie_finds_named_cookie() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
axum::http::HeaderValue::from_static("foo=bar; tz_offset=+05:30; baz=qux"),
);
let off = from_cookie(&headers, "tz_offset").unwrap();
assert_eq!(off.local_minus_utc(), 5 * 3600 + 30 * 60);
}
#[test]
fn from_cookie_returns_none_when_absent() {
let headers = axum::http::HeaderMap::new();
assert!(from_cookie(&headers, "tz_offset").is_none());
}
#[test]
fn from_request_headers_falls_back_to_time_zone_header() {
let mut headers = axum::http::HeaderMap::new();
headers.insert("time-zone", "+09:00".parse().unwrap());
let off = from_request_headers(&headers, "tz_offset").unwrap();
assert_eq!(off.local_minus_utc(), 9 * 3600);
}
#[test]
fn from_request_headers_prefers_cookie_over_header() {
let mut headers = axum::http::HeaderMap::new();
headers.insert(
axum::http::header::COOKIE,
"tz_offset=+05:00".parse().unwrap(),
);
headers.insert("time-zone", "+09:00".parse().unwrap());
let off = from_request_headers(&headers, "tz_offset").unwrap();
assert_eq!(off.local_minus_utc(), 5 * 3600);
}
#[cfg(feature = "template_views")]
#[test]
fn localtime_filter_renders_in_active_offset_default_format() {
let mut tera = tera::Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ ts | localtime }}").unwrap();
let mut ctx = tera::Context::new();
ctx.insert("ts", "2026-01-15T12:00:00Z");
let out = tera.render("t", &ctx).unwrap();
assert!(out.starts_with("2026-01-15 12:00:00"));
}
#[cfg(feature = "template_views")]
#[tokio::test]
async fn localtime_filter_uses_active_task_local_offset() {
let target = FixedOffset::east_opt(3 * 3600).unwrap();
with_offset(target, async {
let mut tera = tera::Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ ts | localtime(format=\"%H:%M\") }}")
.unwrap();
let mut ctx = tera::Context::new();
ctx.insert("ts", "2026-01-15T12:00:00Z");
let out = tera.render("t", &ctx).unwrap();
assert_eq!(out, "15:00");
})
.await;
}
#[cfg(feature = "template_views")]
#[test]
fn localtime_filter_passes_through_non_datetime_input() {
let mut tera = tera::Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ s | localtime }}").unwrap();
let mut ctx = tera::Context::new();
ctx.insert("s", "not a date");
let out = tera.render("t", &ctx).unwrap();
assert_eq!(out, "not a date");
}
#[cfg(feature = "template_views")]
#[test]
fn localtime_filter_accepts_unix_seconds() {
let mut tera = tera::Tera::default();
register_filters(&mut tera);
tera.add_raw_template("t", "{{ ts | localtime(format=\"%Y-%m-%d %H:%M:%S\") }}")
.unwrap();
let mut ctx = tera::Context::new();
let dt = Utc.with_ymd_and_hms(2026, 1, 15, 12, 0, 0).unwrap();
ctx.insert("ts", &dt.timestamp());
let out = tera.render("t", &ctx).unwrap();
assert_eq!(out, "2026-01-15 12:00:00");
}
}