1use chrono::TimeZone as _;
36use chrono::{DateTime, Utc};
37use chrono_tz::Tz;
38use serde::Deserialize;
39
40#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum Source {
46 User,
48 Session,
50 Cookie,
52 Query,
54}
55
56#[derive(Debug, Clone)]
71pub struct TimeZoneConfig {
72 pub identifier: String,
75 pub sources: Vec<Source>,
77}
78
79fn default_time_zone_identifier() -> String {
80 "UTC".to_owned()
81}
82
83fn default_time_zone_sources() -> Vec<Source> {
84 vec![Source::User, Source::Session, Source::Cookie, Source::Query]
85}
86
87impl Default for TimeZoneConfig {
88 fn default() -> Self {
89 Self {
90 identifier: default_time_zone_identifier(),
91 sources: default_time_zone_sources(),
92 }
93 }
94}
95
96impl<'de> Deserialize<'de> for TimeZoneConfig {
97 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
101 where
102 D: serde::Deserializer<'de>,
103 {
104 #[derive(Deserialize)]
105 #[serde(untagged)]
106 enum Repr {
107 Scalar(String),
108 Table {
111 #[serde(default = "default_time_zone_identifier")]
112 identifier: String,
113 #[serde(default = "default_time_zone_sources")]
114 sources: Vec<Source>,
115 },
116 }
117
118 Ok(match Repr::deserialize(deserializer)? {
119 Repr::Scalar(identifier) => Self {
120 identifier,
121 sources: default_time_zone_sources(),
122 },
123 Repr::Table {
124 identifier,
125 sources,
126 } => Self {
127 identifier,
128 sources,
129 },
130 })
131 }
132}
133
134impl TimeZoneConfig {
135 pub fn validate(&self) -> Result<(), crate::config::ConfigError> {
144 parse_iana(&self.identifier).ok_or_else(|| {
145 crate::config::ConfigError::Validation(format!(
146 "time_zone identifier `{}` is not a valid IANA time zone",
147 self.identifier
148 ))
149 })?;
150 Ok(())
151 }
152
153 #[must_use]
156 pub fn default_tz(&self) -> Tz {
157 parse_iana(&self.identifier).unwrap_or(Tz::UTC)
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub struct UserTimeZone(pub Tz);
175
176pub const TIME_ZONE_SESSION_KEY: &str = "autumn_time_zone";
180
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
199pub struct TimeZone(pub Tz);
200
201impl TimeZone {
202 #[must_use]
204 pub const fn new(tz: Tz) -> Self {
205 Self(tz)
206 }
207
208 #[must_use]
210 pub const fn tz(&self) -> Tz {
211 self.0
212 }
213
214 #[must_use]
216 pub fn iana(&self) -> &'static str {
217 self.0.name()
218 }
219
220 #[must_use]
222 pub fn convert(&self, dt: DateTime<Utc>) -> chrono::DateTime<Tz> {
223 use chrono::TimeZone as _;
224 self.0.from_utc_datetime(&dt.naive_utc())
225 }
226}
227
228impl std::ops::Deref for TimeZone {
229 type Target = Tz;
230 fn deref(&self) -> &Self::Target {
231 &self.0
232 }
233}
234
235impl axum::extract::FromRequestParts<crate::state::AppState> for TimeZone {
236 type Rejection = std::convert::Infallible;
237
238 async fn from_request_parts(
239 parts: &mut axum::http::request::Parts,
240 state: &crate::state::AppState,
241 ) -> Result<Self, Self::Rejection> {
242 let config = state.config_arc();
243 let cfg = &config.time_zone;
244 let sources = cfg.sources.clone();
245 let default_tz = cfg.default_tz();
246
247 for source in &sources {
248 if let Some(tz) = resolve_source(parts, source).await {
249 return Ok(Self(tz));
250 }
251 }
252 Ok(Self(default_tz))
253 }
254}
255
256async fn resolve_source(parts: &axum::http::request::Parts, source: &Source) -> Option<Tz> {
257 match source {
258 Source::User => parts.extensions.get::<UserTimeZone>().map(|utz| utz.0),
259 Source::Session => {
260 let session = parts.extensions.get::<crate::session::Session>().cloned()?;
261 let value: String = session.get(TIME_ZONE_SESSION_KEY).await?;
262 parse_iana(&value)
263 }
264 Source::Cookie => resolve_from_cookie(parts),
265 Source::Query => resolve_from_query(parts),
266 }
267}
268
269fn resolve_from_query(parts: &axum::http::request::Parts) -> Option<Tz> {
270 let query = parts.uri.query()?;
271 for pair in query.split('&') {
272 if let Some(value) = pair.strip_prefix("tz=")
273 && let Some(tz) = parse_iana(&percent_decode(value))
274 {
275 return Some(tz);
276 }
277 }
278 None
279}
280
281fn resolve_from_cookie(parts: &axum::http::request::Parts) -> Option<Tz> {
282 let cookie_header = parts
283 .headers
284 .get(axum::http::header::COOKIE)
285 .and_then(|h| h.to_str().ok())?;
286 for cookie in cookie_header.split(';') {
287 let cookie = cookie.trim();
288 if let Some(value) = cookie.strip_prefix("autumn_time_zone=")
289 && let Some(tz) = parse_iana(&percent_decode(value))
290 {
291 return Some(tz);
292 }
293 }
294 None
295}
296
297fn percent_decode(value: &str) -> std::borrow::Cow<'_, str> {
305 if !value.contains('%') {
306 return std::borrow::Cow::Borrowed(value);
307 }
308 let bytes = value.as_bytes();
309 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
310 let mut i = 0;
311 while i < bytes.len() {
312 if bytes[i] == b'%'
313 && i + 2 < bytes.len()
314 && let Some(byte) = decode_hex_pair(bytes[i + 1], bytes[i + 2])
315 {
316 out.push(byte);
317 i += 3;
318 } else {
319 out.push(bytes[i]);
320 i += 1;
321 }
322 }
323 String::from_utf8(out).map_or_else(
325 |_| std::borrow::Cow::Owned(value.to_owned()),
326 std::borrow::Cow::Owned,
327 )
328}
329
330fn decode_hex_pair(hi: u8, lo: u8) -> Option<u8> {
333 let hi = (hi as char).to_digit(16)?;
334 let lo = (lo as char).to_digit(16)?;
335 u8::try_from(hi * 16 + lo).ok()
337}
338
339#[must_use]
346pub fn parse_iana(s: &str) -> Option<Tz> {
347 s.trim().parse::<Tz>().ok()
348}
349
350pub async fn set_time_zone_in_session(session: &crate::session::Session, iana: &str) {
356 session.insert(TIME_ZONE_SESSION_KEY, iana).await;
357}
358
359#[must_use]
364pub fn set_time_zone_cookie(iana: &str) -> String {
365 let safe = encode_tz_cookie_value(iana);
366 format!("autumn_time_zone={safe}; Path=/; Max-Age=31536000; SameSite=Lax")
367}
368
369fn encode_tz_cookie_value(value: &str) -> String {
370 let mut out = String::with_capacity(value.len());
371 for b in value.bytes() {
372 if is_tz_cookie_byte(b) {
373 out.push(char::from(b));
374 } else {
375 push_pct_encoded(&mut out, b);
376 }
377 }
378 out
379}
380
381const fn is_tz_cookie_byte(b: u8) -> bool {
382 b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'+' | b'/')
383}
384
385fn push_pct_encoded(out: &mut String, byte: u8) {
386 const HEX: &[u8; 16] = b"0123456789ABCDEF";
387 out.push('%');
388 out.push(char::from(HEX[(byte >> 4) as usize]));
389 out.push(char::from(HEX[(byte & 0x0f) as usize]));
390}
391
392#[cfg(feature = "maud")]
403#[must_use]
404pub fn local_datetime(dt: DateTime<Utc>, tz: Tz) -> maud::Markup {
405 let local = tz.from_utc_datetime(&dt.naive_utc());
406 let display = local.format("%Y-%m-%d %H:%M %Z").to_string();
407 let rfc = dt.to_rfc3339();
408 maud::html! {
409 time datetime=(rfc) { (display) }
410 }
411}
412
413#[cfg(feature = "maud")]
415#[must_use]
416pub fn local_date(dt: DateTime<Utc>, tz: Tz) -> maud::Markup {
417 let local = tz.from_utc_datetime(&dt.naive_utc());
418 let display = local.format("%Y-%m-%d").to_string();
419 let rfc = dt.to_rfc3339();
420 maud::html! {
421 time datetime=(rfc) { (display) }
422 }
423}
424
425#[cfg(feature = "maud")]
434#[must_use]
435pub fn time_ago(dt: DateTime<Utc>, now: DateTime<Utc>, tz: Tz) -> maud::Markup {
436 let relative = crate::format::relative_time_words(dt, now);
437 let rfc = dt.to_rfc3339();
438 let local = tz.from_utc_datetime(&dt.naive_utc());
439 let display = local.format("%Y-%m-%d %H:%M %Z").to_string();
440 maud::html! {
441 time datetime=(rfc) title=(display) { (relative) }
442 }
443}
444
445#[derive(Debug, thiserror::Error)]
449pub enum TimeZoneError {
450 #[error("invalid datetime-local input `{input}`: expected YYYY-MM-DDTHH:MM")]
452 InvalidFormat {
453 input: String,
455 },
456 #[error("local time `{input}` is ambiguous or non-existent in `{zone}`")]
458 AmbiguousLocalTime {
459 input: String,
461 zone: String,
463 },
464}
465
466pub fn parse_local_datetime(input: &str, tz: Tz) -> Result<DateTime<Utc>, TimeZoneError> {
478 use chrono::NaiveDateTime;
479 let naive = NaiveDateTime::parse_from_str(input.trim(), "%Y-%m-%dT%H:%M").map_err(|_| {
480 TimeZoneError::InvalidFormat {
481 input: input.to_owned(),
482 }
483 })?;
484 tz.from_local_datetime(&naive)
485 .earliest()
486 .ok_or_else(|| TimeZoneError::AmbiguousLocalTime {
487 input: input.to_owned(),
488 zone: tz.name().to_owned(),
489 })
490 .map(|dt| dt.with_timezone(&Utc))
491}
492
493#[must_use]
496pub fn to_local_input_value(dt: DateTime<Utc>, tz: Tz) -> String {
497 let local = tz.from_utc_datetime(&dt.naive_utc());
498 local.format("%Y-%m-%dT%H:%M").to_string()
499}
500
501#[cfg(feature = "maud")]
504#[must_use]
505pub fn datetime_local_input(
506 name: &str,
507 label: &str,
508 dt: Option<DateTime<Utc>>,
509 tz: Tz,
510) -> maud::Markup {
511 let value = dt.map(|d| to_local_input_value(d, tz)).unwrap_or_default();
512 maud::html! {
513 div.field {
514 label for=(name) { (label) }
515 input type="datetime-local" id=(name) name=(name) value=(value);
516 }
517 }
518}
519
520tokio::task_local! {
523 static AMBIENT_TZ: Tz;
524}
525
526pub async fn with_request_time_zone<F, R>(tz: Tz, fut: F) -> R
538where
539 F: std::future::Future<Output = R>,
540{
541 AMBIENT_TZ.scope(tz, fut).await
542}
543
544#[must_use]
547pub fn ambient_time_zone() -> Tz {
548 AMBIENT_TZ.try_with(|tz| *tz).unwrap_or(Tz::UTC)
549}
550
551#[cfg(test)]
554mod tests {
555 use super::*;
556 use axum::body::Body;
557 use axum::http::Request;
558 use chrono::Timelike;
559
560 fn parts(uri: &str, headers: &[(&str, &str)]) -> axum::http::request::Parts {
561 let mut req = Request::builder().uri(uri);
562 for (k, v) in headers {
563 req = req.header(*k, *v);
564 }
565 let (parts, _) = req.body(Body::empty()).unwrap().into_parts();
566 parts
567 }
568
569 #[test]
572 fn parse_iana_valid_zones() {
573 assert!(parse_iana("UTC").is_some());
574 assert!(parse_iana("America/New_York").is_some());
575 assert!(parse_iana("Asia/Tokyo").is_some());
576 assert!(parse_iana("Europe/London").is_some());
577 assert!(parse_iana("America/Sao_Paulo").is_some());
578 }
579
580 #[test]
581 fn parse_iana_invalid_zones() {
582 assert!(parse_iana("Mars/Phobos").is_none());
583 assert!(parse_iana("").is_none());
584 assert!(parse_iana("garbage").is_none());
585 assert!(parse_iana("Not/A/Zone").is_none());
586 }
587
588 #[test]
589 fn parse_iana_trims_whitespace() {
590 assert!(parse_iana(" UTC ").is_some());
591 assert!(parse_iana(" America/New_York ").is_some());
592 }
593
594 #[test]
597 fn config_default_is_utc() {
598 let cfg = TimeZoneConfig::default();
599 assert_eq!(cfg.identifier, "UTC");
600 assert_eq!(cfg.default_tz(), Tz::UTC);
601 }
602
603 #[test]
604 fn config_validate_accepts_valid_identifier() {
605 let cfg = TimeZoneConfig {
606 identifier: "America/New_York".to_owned(),
607 ..Default::default()
608 };
609 assert!(cfg.validate().is_ok());
610 }
611
612 #[test]
613 fn config_validate_rejects_unknown_identifier() {
614 let cfg = TimeZoneConfig {
615 identifier: "Mars/Phobos".to_owned(),
616 ..Default::default()
617 };
618 let err = cfg.validate().unwrap_err();
619 let msg = err.to_string();
620 assert!(
621 msg.contains("Mars/Phobos"),
622 "error should mention the bad identifier: {msg}"
623 );
624 }
625
626 #[test]
627 fn config_default_sources_order() {
628 let cfg = TimeZoneConfig::default();
629 assert_eq!(
630 cfg.sources,
631 vec![Source::User, Source::Session, Source::Cookie, Source::Query]
632 );
633 }
634
635 #[derive(serde::Deserialize)]
636 struct Wrapper {
637 #[serde(default)]
638 time_zone: TimeZoneConfig,
639 }
640
641 #[test]
642 fn config_deserializes_scalar_shorthand() {
643 let w: Wrapper = toml::from_str(r#"time_zone = "America/New_York""#).unwrap();
644 assert_eq!(w.time_zone.identifier, "America/New_York");
645 assert_eq!(w.time_zone.sources, default_time_zone_sources());
647 }
648
649 #[test]
650 fn config_deserializes_table_form() {
651 let w: Wrapper = toml::from_str(
652 r#"
653 [time_zone]
654 identifier = "Asia/Tokyo"
655 sources = ["query", "cookie"]
656 "#,
657 )
658 .unwrap();
659 assert_eq!(w.time_zone.identifier, "Asia/Tokyo");
660 assert_eq!(w.time_zone.sources, vec![Source::Query, Source::Cookie]);
661 }
662
663 #[test]
664 fn config_table_form_defaults_missing_fields() {
665 let w: Wrapper = toml::from_str(
666 r#"
667 [time_zone]
668 identifier = "Europe/London"
669 "#,
670 )
671 .unwrap();
672 assert_eq!(w.time_zone.identifier, "Europe/London");
673 assert_eq!(w.time_zone.sources, default_time_zone_sources());
674 }
675
676 #[test]
677 fn config_absent_uses_default() {
678 let w: Wrapper = toml::from_str("").unwrap();
679 assert_eq!(w.time_zone.identifier, "UTC");
680 }
681
682 #[test]
685 fn query_param_resolves_valid_zone() {
686 let p = parts("/?tz=Asia/Tokyo", &[]);
687 let result = resolve_from_query(&p);
688 assert_eq!(result, Some(Tz::Asia__Tokyo));
689 }
690
691 #[test]
692 fn query_param_ignores_invalid_zone() {
693 let p = parts("/?tz=Mars/Phobos", &[]);
694 assert!(resolve_from_query(&p).is_none());
695 }
696
697 #[test]
698 fn query_param_absent_returns_none() {
699 let p = parts("/", &[]);
700 assert!(resolve_from_query(&p).is_none());
701 }
702
703 #[test]
704 fn query_param_percent_encoded_slash() {
705 let p = parts("/?tz=America%2FNew_York", &[]);
706 assert_eq!(resolve_from_query(&p), Some(Tz::America__New_York));
707 }
708
709 #[test]
710 fn query_param_percent_encoded_plus() {
711 let p = parts("/?tz=Etc%2FGMT%2B5", &[]);
713 assert_eq!(resolve_from_query(&p), Some(Tz::Etc__GMTPlus5));
714 }
715
716 #[test]
717 fn percent_decode_passthrough_when_no_escapes() {
718 assert_eq!(percent_decode("America/New_York"), "America/New_York");
719 assert!(matches!(
720 percent_decode("UTC"),
721 std::borrow::Cow::Borrowed("UTC")
722 ));
723 }
724
725 #[test]
726 fn percent_decode_resolves_escapes() {
727 assert_eq!(percent_decode("America%2FNew_York"), "America/New_York");
728 assert_eq!(percent_decode("Etc%2FGMT%2B5"), "Etc/GMT+5");
729 }
730
731 #[test]
732 fn percent_decode_leaves_plus_literal() {
733 assert_eq!(percent_decode("Etc/GMT+5"), "Etc/GMT+5");
734 }
735
736 #[test]
737 fn percent_decode_keeps_trailing_partial_escape_literal() {
738 assert_eq!(percent_decode("UTC%2"), "UTC%2");
740 assert_eq!(percent_decode("UTC%"), "UTC%");
741 }
742
743 #[test]
746 fn cookie_resolves_valid_zone() {
747 let p = parts("/", &[("Cookie", "autumn_time_zone=America/Chicago")]);
748 let result = resolve_from_cookie(&p);
749 assert_eq!(result, Some(Tz::America__Chicago));
750 }
751
752 #[test]
753 fn cookie_ignores_other_cookies() {
754 let p = parts(
755 "/",
756 &[("Cookie", "session=abc; autumn_time_zone=UTC; other=x")],
757 );
758 let result = resolve_from_cookie(&p);
759 assert_eq!(result, Some(Tz::UTC));
760 }
761
762 #[test]
763 fn cookie_invalid_zone_returns_none() {
764 let p = parts("/", &[("Cookie", "autumn_time_zone=garbage")]);
765 assert!(resolve_from_cookie(&p).is_none());
766 }
767
768 #[test]
769 fn cookie_absent_returns_none() {
770 let p = parts("/", &[]);
771 assert!(resolve_from_cookie(&p).is_none());
772 }
773
774 #[test]
775 fn cookie_percent_encoded_value() {
776 let p = parts("/", &[("Cookie", "autumn_time_zone=America%2FNew_York")]);
778 assert_eq!(resolve_from_cookie(&p), Some(Tz::America__New_York));
779 }
780
781 #[test]
784 fn set_time_zone_cookie_produces_correct_header() {
785 let header = set_time_zone_cookie("America/New_York");
786 assert!(header.starts_with("autumn_time_zone=America/New_York"));
787 assert!(header.contains("Path=/"));
788 assert!(header.contains("Max-Age=31536000"));
789 assert!(header.contains("SameSite=Lax"));
790 }
791
792 #[test]
793 fn set_time_zone_cookie_encodes_special_chars() {
794 let header = set_time_zone_cookie("Etc/UTC");
796 assert!(header.contains("Etc/UTC"));
797 }
798
799 #[test]
802 fn user_time_zone_newtype_roundtrips() {
803 let utz = UserTimeZone(Tz::Asia__Tokyo);
804 assert_eq!(utz.0, Tz::Asia__Tokyo);
805 }
806
807 #[test]
810 fn time_zone_new_constructor() {
811 let tz = TimeZone::new(Tz::UTC);
812 assert_eq!(tz.tz(), Tz::UTC);
813 assert_eq!(*tz, Tz::UTC);
814 }
815
816 #[test]
817 fn time_zone_iana_returns_name() {
818 let tz = TimeZone::new(Tz::America__New_York);
819 assert_eq!(tz.iana(), "America/New_York");
820 }
821
822 #[test]
823 fn time_zone_convert_uses_given_zone() {
824 use chrono::TimeZone as ChrTz;
825 let utc = chrono::Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap();
826 let tz = TimeZone::new(Tz::America__New_York);
827 let local = tz.convert(utc);
828 assert_eq!(local.hour(), 8);
830 }
831
832 #[test]
835 fn parse_local_datetime_tokyo() {
836 let result = parse_local_datetime("2025-06-14T15:30", Tz::Asia__Tokyo).unwrap();
838 assert_eq!(result.hour(), 6);
839 assert_eq!(result.minute(), 30);
840 }
841
842 #[test]
843 fn parse_local_datetime_new_york_summer() {
844 let result = parse_local_datetime("2025-06-14T12:00", Tz::America__New_York).unwrap();
846 assert_eq!(result.hour(), 16);
847 assert_eq!(result.minute(), 0);
848 }
849
850 #[test]
851 fn parse_local_datetime_invalid_format() {
852 let err = parse_local_datetime("not-a-date", Tz::UTC).unwrap_err();
853 assert!(matches!(err, TimeZoneError::InvalidFormat { .. }));
854 }
855
856 #[test]
857 fn to_local_input_value_roundtrip() {
858 let zones = [Tz::UTC, Tz::America__New_York, Tz::Asia__Tokyo];
859 for tz in zones {
860 let original = "2025-06-14T15:30";
861 let utc = parse_local_datetime(original, tz).unwrap();
862 let back = to_local_input_value(utc, tz);
863 assert_eq!(back, original, "roundtrip failed for {}", tz.name());
864 }
865 }
866
867 #[test]
868 fn to_local_input_value_formats_correctly() {
869 use chrono::TimeZone as ChrTz;
870 let utc = chrono::Utc.with_ymd_and_hms(2025, 6, 14, 6, 30, 0).unwrap();
871 assert_eq!(
872 to_local_input_value(utc, Tz::Asia__Tokyo),
873 "2025-06-14T15:30"
874 );
875 assert_eq!(to_local_input_value(utc, Tz::UTC), "2025-06-14T06:30");
876 }
877
878 #[cfg(feature = "maud")]
881 mod maud_tests {
882 use super::*;
883 use chrono::TimeZone as ChrTz;
884
885 #[allow(clippy::many_single_char_names)]
886 fn utc(y: i32, mo: u32, d: u32, h: u32, m: u32, s: u32) -> DateTime<Utc> {
887 chrono::Utc.with_ymd_and_hms(y, mo, d, h, m, s).unwrap()
888 }
889
890 #[test]
891 fn local_datetime_uses_zone() {
892 let dt = utc(2025, 6, 14, 12, 0, 0);
893 let utc_html = local_datetime(dt, Tz::UTC).into_string();
894 let tokyo_html = local_datetime(dt, Tz::Asia__Tokyo).into_string();
895 let ny_html = local_datetime(dt, Tz::America__New_York).into_string();
896 assert!(utc_html.contains("12:00"), "UTC: {utc_html}");
898 assert!(tokyo_html.contains("21:00"), "Tokyo: {tokyo_html}");
899 assert!(ny_html.contains("08:00"), "New York: {ny_html}");
900 }
901
902 #[test]
903 fn local_datetime_datetime_attr_is_utc() {
904 let dt = utc(2025, 6, 14, 12, 0, 0);
905 let html = local_datetime(dt, Tz::Asia__Tokyo).into_string();
906 assert!(
908 html.contains("2025-06-14T12:00:00"),
909 "datetime attr must be UTC: {html}"
910 );
911 }
912
913 #[test]
914 fn local_date_uses_zone() {
915 let dt = utc(2025, 6, 14, 23, 30, 0); let utc_html = local_date(dt, Tz::UTC).into_string();
918 let tokyo_html = local_date(dt, Tz::Asia__Tokyo).into_string();
919 assert!(utc_html.contains("2025-06-14"), "UTC: {utc_html}");
920 assert!(tokyo_html.contains("2025-06-15"), "Tokyo: {tokyo_html}");
921 }
922
923 #[test]
924 fn time_ago_seconds() {
925 let now = utc(2025, 6, 14, 12, 0, 30);
926 let dt = utc(2025, 6, 14, 12, 0, 0);
927 let html = time_ago(dt, now, Tz::UTC).into_string();
928 assert!(html.contains("seconds ago"), "{html}");
929 }
930
931 #[test]
932 fn time_ago_minutes() {
933 let now = utc(2025, 6, 14, 12, 5, 0);
934 let dt = utc(2025, 6, 14, 12, 0, 0);
935 let html = time_ago(dt, now, Tz::UTC).into_string();
936 assert!(html.contains("minutes ago"), "{html}");
937 }
938
939 #[test]
940 fn time_ago_hours() {
941 let now = utc(2025, 6, 14, 14, 0, 0);
942 let dt = utc(2025, 6, 14, 12, 0, 0);
943 let html = time_ago(dt, now, Tz::UTC).into_string();
944 assert!(html.contains("hours ago"), "{html}");
945 }
946
947 #[test]
948 fn time_ago_days() {
949 let now = utc(2025, 6, 16, 12, 0, 0);
950 let dt = utc(2025, 6, 14, 12, 0, 0);
951 let html = time_ago(dt, now, Tz::UTC).into_string();
952 assert!(html.contains("days ago"), "{html}");
953 }
954
955 #[test]
956 fn time_ago_future_minutes() {
957 let now = utc(2025, 6, 14, 12, 0, 0);
958 let dt = utc(2025, 6, 14, 12, 5, 0);
959 let html = time_ago(dt, now, Tz::UTC).into_string();
960 assert!(html.contains("in "), "{html}");
961 assert!(html.contains("minutes"), "{html}");
962 }
963
964 #[test]
965 fn time_ago_preserves_utc_datetime_attr() {
966 let now = utc(2025, 6, 14, 12, 5, 0);
967 let dt = utc(2025, 6, 14, 12, 0, 0);
968 let html = time_ago(dt, now, Tz::UTC).into_string();
969 assert!(html.contains("datetime="), "{html}");
970 }
971 }
972
973 #[tokio::test]
976 async fn ambient_time_zone_defaults_to_utc() {
977 assert_eq!(ambient_time_zone(), Tz::UTC);
978 }
979
980 #[tokio::test]
981 async fn with_request_time_zone_sets_ambient() {
982 let result = with_request_time_zone(Tz::Asia__Tokyo, async { ambient_time_zone() }).await;
983 assert_eq!(result, Tz::Asia__Tokyo);
984 }
985
986 #[tokio::test]
987 async fn ambient_returns_utc_outside_scope() {
988 let inside =
990 with_request_time_zone(Tz::America__New_York, async { ambient_time_zone() }).await;
991 let outside = ambient_time_zone();
993 assert_eq!(inside, Tz::America__New_York);
994 assert_eq!(outside, Tz::UTC);
995 }
996}