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 cfg = state.config().time_zone;
243 let sources = cfg.sources.clone();
244 let default_tz = cfg.default_tz();
245
246 for source in &sources {
247 if let Some(tz) = resolve_source(parts, source).await {
248 return Ok(Self(tz));
249 }
250 }
251 Ok(Self(default_tz))
252 }
253}
254
255async fn resolve_source(parts: &axum::http::request::Parts, source: &Source) -> Option<Tz> {
256 match source {
257 Source::User => parts.extensions.get::<UserTimeZone>().map(|utz| utz.0),
258 Source::Session => {
259 let session = parts.extensions.get::<crate::session::Session>().cloned()?;
260 let value: String = session.get(TIME_ZONE_SESSION_KEY).await?;
261 parse_iana(&value)
262 }
263 Source::Cookie => resolve_from_cookie(parts),
264 Source::Query => resolve_from_query(parts),
265 }
266}
267
268fn resolve_from_query(parts: &axum::http::request::Parts) -> Option<Tz> {
269 let query = parts.uri.query()?;
270 for pair in query.split('&') {
271 if let Some(value) = pair.strip_prefix("tz=")
272 && let Some(tz) = parse_iana(&percent_decode(value))
273 {
274 return Some(tz);
275 }
276 }
277 None
278}
279
280fn resolve_from_cookie(parts: &axum::http::request::Parts) -> Option<Tz> {
281 let cookie_header = parts
282 .headers
283 .get(axum::http::header::COOKIE)
284 .and_then(|h| h.to_str().ok())?;
285 for cookie in cookie_header.split(';') {
286 let cookie = cookie.trim();
287 if let Some(value) = cookie.strip_prefix("autumn_time_zone=")
288 && let Some(tz) = parse_iana(&percent_decode(value))
289 {
290 return Some(tz);
291 }
292 }
293 None
294}
295
296fn percent_decode(value: &str) -> std::borrow::Cow<'_, str> {
304 if !value.contains('%') {
305 return std::borrow::Cow::Borrowed(value);
306 }
307 let bytes = value.as_bytes();
308 let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
309 let mut i = 0;
310 while i < bytes.len() {
311 if bytes[i] == b'%'
312 && i + 2 < bytes.len()
313 && let Some(byte) = decode_hex_pair(bytes[i + 1], bytes[i + 2])
314 {
315 out.push(byte);
316 i += 3;
317 } else {
318 out.push(bytes[i]);
319 i += 1;
320 }
321 }
322 String::from_utf8(out).map_or_else(
324 |_| std::borrow::Cow::Owned(value.to_owned()),
325 std::borrow::Cow::Owned,
326 )
327}
328
329fn decode_hex_pair(hi: u8, lo: u8) -> Option<u8> {
332 let hi = (hi as char).to_digit(16)?;
333 let lo = (lo as char).to_digit(16)?;
334 u8::try_from(hi * 16 + lo).ok()
336}
337
338#[must_use]
345pub fn parse_iana(s: &str) -> Option<Tz> {
346 s.trim().parse::<Tz>().ok()
347}
348
349pub async fn set_time_zone_in_session(session: &crate::session::Session, iana: &str) {
355 session.insert(TIME_ZONE_SESSION_KEY, iana).await;
356}
357
358#[must_use]
363pub fn set_time_zone_cookie(iana: &str) -> String {
364 let safe = encode_tz_cookie_value(iana);
365 format!("autumn_time_zone={safe}; Path=/; Max-Age=31536000; SameSite=Lax")
366}
367
368fn encode_tz_cookie_value(value: &str) -> String {
369 let mut out = String::with_capacity(value.len());
370 for b in value.bytes() {
371 if is_tz_cookie_byte(b) {
372 out.push(char::from(b));
373 } else {
374 push_pct_encoded(&mut out, b);
375 }
376 }
377 out
378}
379
380const fn is_tz_cookie_byte(b: u8) -> bool {
381 b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'+' | b'/')
382}
383
384fn push_pct_encoded(out: &mut String, byte: u8) {
385 const HEX: &[u8; 16] = b"0123456789ABCDEF";
386 out.push('%');
387 out.push(char::from(HEX[(byte >> 4) as usize]));
388 out.push(char::from(HEX[(byte & 0x0f) as usize]));
389}
390
391#[cfg(feature = "maud")]
402#[must_use]
403pub fn local_datetime(dt: DateTime<Utc>, tz: Tz) -> maud::Markup {
404 let local = tz.from_utc_datetime(&dt.naive_utc());
405 let display = local.format("%Y-%m-%d %H:%M %Z").to_string();
406 let rfc = dt.to_rfc3339();
407 maud::html! {
408 time datetime=(rfc) { (display) }
409 }
410}
411
412#[cfg(feature = "maud")]
414#[must_use]
415pub fn local_date(dt: DateTime<Utc>, tz: Tz) -> maud::Markup {
416 let local = tz.from_utc_datetime(&dt.naive_utc());
417 let display = local.format("%Y-%m-%d").to_string();
418 let rfc = dt.to_rfc3339();
419 maud::html! {
420 time datetime=(rfc) { (display) }
421 }
422}
423
424#[cfg(feature = "maud")]
433#[must_use]
434pub fn time_ago(dt: DateTime<Utc>, now: DateTime<Utc>, tz: Tz) -> maud::Markup {
435 let relative = crate::format::relative_time_words(dt, now);
436 let rfc = dt.to_rfc3339();
437 let local = tz.from_utc_datetime(&dt.naive_utc());
438 let display = local.format("%Y-%m-%d %H:%M %Z").to_string();
439 maud::html! {
440 time datetime=(rfc) title=(display) { (relative) }
441 }
442}
443
444#[derive(Debug, thiserror::Error)]
448pub enum TimeZoneError {
449 #[error("invalid datetime-local input `{input}`: expected YYYY-MM-DDTHH:MM")]
451 InvalidFormat {
452 input: String,
454 },
455 #[error("local time `{input}` is ambiguous or non-existent in `{zone}`")]
457 AmbiguousLocalTime {
458 input: String,
460 zone: String,
462 },
463}
464
465pub fn parse_local_datetime(input: &str, tz: Tz) -> Result<DateTime<Utc>, TimeZoneError> {
477 use chrono::NaiveDateTime;
478 let naive = NaiveDateTime::parse_from_str(input.trim(), "%Y-%m-%dT%H:%M").map_err(|_| {
479 TimeZoneError::InvalidFormat {
480 input: input.to_owned(),
481 }
482 })?;
483 tz.from_local_datetime(&naive)
484 .earliest()
485 .ok_or_else(|| TimeZoneError::AmbiguousLocalTime {
486 input: input.to_owned(),
487 zone: tz.name().to_owned(),
488 })
489 .map(|dt| dt.with_timezone(&Utc))
490}
491
492#[must_use]
495pub fn to_local_input_value(dt: DateTime<Utc>, tz: Tz) -> String {
496 let local = tz.from_utc_datetime(&dt.naive_utc());
497 local.format("%Y-%m-%dT%H:%M").to_string()
498}
499
500#[cfg(feature = "maud")]
503#[must_use]
504pub fn datetime_local_input(
505 name: &str,
506 label: &str,
507 dt: Option<DateTime<Utc>>,
508 tz: Tz,
509) -> maud::Markup {
510 let value = dt.map(|d| to_local_input_value(d, tz)).unwrap_or_default();
511 maud::html! {
512 div.field {
513 label for=(name) { (label) }
514 input type="datetime-local" id=(name) name=(name) value=(value);
515 }
516 }
517}
518
519tokio::task_local! {
522 static AMBIENT_TZ: Tz;
523}
524
525pub async fn with_request_time_zone<F, R>(tz: Tz, fut: F) -> R
537where
538 F: std::future::Future<Output = R>,
539{
540 AMBIENT_TZ.scope(tz, fut).await
541}
542
543#[must_use]
546pub fn ambient_time_zone() -> Tz {
547 AMBIENT_TZ.try_with(|tz| *tz).unwrap_or(Tz::UTC)
548}
549
550#[cfg(test)]
553mod tests {
554 use super::*;
555 use axum::body::Body;
556 use axum::http::Request;
557 use chrono::Timelike;
558
559 fn parts(uri: &str, headers: &[(&str, &str)]) -> axum::http::request::Parts {
560 let mut req = Request::builder().uri(uri);
561 for (k, v) in headers {
562 req = req.header(*k, *v);
563 }
564 let (parts, _) = req.body(Body::empty()).unwrap().into_parts();
565 parts
566 }
567
568 #[test]
571 fn parse_iana_valid_zones() {
572 assert!(parse_iana("UTC").is_some());
573 assert!(parse_iana("America/New_York").is_some());
574 assert!(parse_iana("Asia/Tokyo").is_some());
575 assert!(parse_iana("Europe/London").is_some());
576 assert!(parse_iana("America/Sao_Paulo").is_some());
577 }
578
579 #[test]
580 fn parse_iana_invalid_zones() {
581 assert!(parse_iana("Mars/Phobos").is_none());
582 assert!(parse_iana("").is_none());
583 assert!(parse_iana("garbage").is_none());
584 assert!(parse_iana("Not/A/Zone").is_none());
585 }
586
587 #[test]
588 fn parse_iana_trims_whitespace() {
589 assert!(parse_iana(" UTC ").is_some());
590 assert!(parse_iana(" America/New_York ").is_some());
591 }
592
593 #[test]
596 fn config_default_is_utc() {
597 let cfg = TimeZoneConfig::default();
598 assert_eq!(cfg.identifier, "UTC");
599 assert_eq!(cfg.default_tz(), Tz::UTC);
600 }
601
602 #[test]
603 fn config_validate_accepts_valid_identifier() {
604 let cfg = TimeZoneConfig {
605 identifier: "America/New_York".to_owned(),
606 ..Default::default()
607 };
608 assert!(cfg.validate().is_ok());
609 }
610
611 #[test]
612 fn config_validate_rejects_unknown_identifier() {
613 let cfg = TimeZoneConfig {
614 identifier: "Mars/Phobos".to_owned(),
615 ..Default::default()
616 };
617 let err = cfg.validate().unwrap_err();
618 let msg = err.to_string();
619 assert!(
620 msg.contains("Mars/Phobos"),
621 "error should mention the bad identifier: {msg}"
622 );
623 }
624
625 #[test]
626 fn config_default_sources_order() {
627 let cfg = TimeZoneConfig::default();
628 assert_eq!(
629 cfg.sources,
630 vec![Source::User, Source::Session, Source::Cookie, Source::Query]
631 );
632 }
633
634 #[derive(serde::Deserialize)]
635 struct Wrapper {
636 #[serde(default)]
637 time_zone: TimeZoneConfig,
638 }
639
640 #[test]
641 fn config_deserializes_scalar_shorthand() {
642 let w: Wrapper = toml::from_str(r#"time_zone = "America/New_York""#).unwrap();
643 assert_eq!(w.time_zone.identifier, "America/New_York");
644 assert_eq!(w.time_zone.sources, default_time_zone_sources());
646 }
647
648 #[test]
649 fn config_deserializes_table_form() {
650 let w: Wrapper = toml::from_str(
651 r#"
652 [time_zone]
653 identifier = "Asia/Tokyo"
654 sources = ["query", "cookie"]
655 "#,
656 )
657 .unwrap();
658 assert_eq!(w.time_zone.identifier, "Asia/Tokyo");
659 assert_eq!(w.time_zone.sources, vec![Source::Query, Source::Cookie]);
660 }
661
662 #[test]
663 fn config_table_form_defaults_missing_fields() {
664 let w: Wrapper = toml::from_str(
665 r#"
666 [time_zone]
667 identifier = "Europe/London"
668 "#,
669 )
670 .unwrap();
671 assert_eq!(w.time_zone.identifier, "Europe/London");
672 assert_eq!(w.time_zone.sources, default_time_zone_sources());
673 }
674
675 #[test]
676 fn config_absent_uses_default() {
677 let w: Wrapper = toml::from_str("").unwrap();
678 assert_eq!(w.time_zone.identifier, "UTC");
679 }
680
681 #[test]
684 fn query_param_resolves_valid_zone() {
685 let p = parts("/?tz=Asia/Tokyo", &[]);
686 let result = resolve_from_query(&p);
687 assert_eq!(result, Some(Tz::Asia__Tokyo));
688 }
689
690 #[test]
691 fn query_param_ignores_invalid_zone() {
692 let p = parts("/?tz=Mars/Phobos", &[]);
693 assert!(resolve_from_query(&p).is_none());
694 }
695
696 #[test]
697 fn query_param_absent_returns_none() {
698 let p = parts("/", &[]);
699 assert!(resolve_from_query(&p).is_none());
700 }
701
702 #[test]
703 fn query_param_percent_encoded_slash() {
704 let p = parts("/?tz=America%2FNew_York", &[]);
705 assert_eq!(resolve_from_query(&p), Some(Tz::America__New_York));
706 }
707
708 #[test]
709 fn query_param_percent_encoded_plus() {
710 let p = parts("/?tz=Etc%2FGMT%2B5", &[]);
712 assert_eq!(resolve_from_query(&p), Some(Tz::Etc__GMTPlus5));
713 }
714
715 #[test]
716 fn percent_decode_passthrough_when_no_escapes() {
717 assert_eq!(percent_decode("America/New_York"), "America/New_York");
718 assert!(matches!(
719 percent_decode("UTC"),
720 std::borrow::Cow::Borrowed("UTC")
721 ));
722 }
723
724 #[test]
725 fn percent_decode_resolves_escapes() {
726 assert_eq!(percent_decode("America%2FNew_York"), "America/New_York");
727 assert_eq!(percent_decode("Etc%2FGMT%2B5"), "Etc/GMT+5");
728 }
729
730 #[test]
731 fn percent_decode_leaves_plus_literal() {
732 assert_eq!(percent_decode("Etc/GMT+5"), "Etc/GMT+5");
733 }
734
735 #[test]
736 fn percent_decode_keeps_trailing_partial_escape_literal() {
737 assert_eq!(percent_decode("UTC%2"), "UTC%2");
739 assert_eq!(percent_decode("UTC%"), "UTC%");
740 }
741
742 #[test]
745 fn cookie_resolves_valid_zone() {
746 let p = parts("/", &[("Cookie", "autumn_time_zone=America/Chicago")]);
747 let result = resolve_from_cookie(&p);
748 assert_eq!(result, Some(Tz::America__Chicago));
749 }
750
751 #[test]
752 fn cookie_ignores_other_cookies() {
753 let p = parts(
754 "/",
755 &[("Cookie", "session=abc; autumn_time_zone=UTC; other=x")],
756 );
757 let result = resolve_from_cookie(&p);
758 assert_eq!(result, Some(Tz::UTC));
759 }
760
761 #[test]
762 fn cookie_invalid_zone_returns_none() {
763 let p = parts("/", &[("Cookie", "autumn_time_zone=garbage")]);
764 assert!(resolve_from_cookie(&p).is_none());
765 }
766
767 #[test]
768 fn cookie_absent_returns_none() {
769 let p = parts("/", &[]);
770 assert!(resolve_from_cookie(&p).is_none());
771 }
772
773 #[test]
774 fn cookie_percent_encoded_value() {
775 let p = parts("/", &[("Cookie", "autumn_time_zone=America%2FNew_York")]);
777 assert_eq!(resolve_from_cookie(&p), Some(Tz::America__New_York));
778 }
779
780 #[test]
783 fn set_time_zone_cookie_produces_correct_header() {
784 let header = set_time_zone_cookie("America/New_York");
785 assert!(header.starts_with("autumn_time_zone=America/New_York"));
786 assert!(header.contains("Path=/"));
787 assert!(header.contains("Max-Age=31536000"));
788 assert!(header.contains("SameSite=Lax"));
789 }
790
791 #[test]
792 fn set_time_zone_cookie_encodes_special_chars() {
793 let header = set_time_zone_cookie("Etc/UTC");
795 assert!(header.contains("Etc/UTC"));
796 }
797
798 #[test]
801 fn user_time_zone_newtype_roundtrips() {
802 let utz = UserTimeZone(Tz::Asia__Tokyo);
803 assert_eq!(utz.0, Tz::Asia__Tokyo);
804 }
805
806 #[test]
809 fn time_zone_new_constructor() {
810 let tz = TimeZone::new(Tz::UTC);
811 assert_eq!(tz.tz(), Tz::UTC);
812 assert_eq!(*tz, Tz::UTC);
813 }
814
815 #[test]
816 fn time_zone_iana_returns_name() {
817 let tz = TimeZone::new(Tz::America__New_York);
818 assert_eq!(tz.iana(), "America/New_York");
819 }
820
821 #[test]
822 fn time_zone_convert_uses_given_zone() {
823 use chrono::TimeZone as ChrTz;
824 let utc = chrono::Utc.with_ymd_and_hms(2025, 6, 14, 12, 0, 0).unwrap();
825 let tz = TimeZone::new(Tz::America__New_York);
826 let local = tz.convert(utc);
827 assert_eq!(local.hour(), 8);
829 }
830
831 #[test]
834 fn parse_local_datetime_tokyo() {
835 let result = parse_local_datetime("2025-06-14T15:30", Tz::Asia__Tokyo).unwrap();
837 assert_eq!(result.hour(), 6);
838 assert_eq!(result.minute(), 30);
839 }
840
841 #[test]
842 fn parse_local_datetime_new_york_summer() {
843 let result = parse_local_datetime("2025-06-14T12:00", Tz::America__New_York).unwrap();
845 assert_eq!(result.hour(), 16);
846 assert_eq!(result.minute(), 0);
847 }
848
849 #[test]
850 fn parse_local_datetime_invalid_format() {
851 let err = parse_local_datetime("not-a-date", Tz::UTC).unwrap_err();
852 assert!(matches!(err, TimeZoneError::InvalidFormat { .. }));
853 }
854
855 #[test]
856 fn to_local_input_value_roundtrip() {
857 let zones = [Tz::UTC, Tz::America__New_York, Tz::Asia__Tokyo];
858 for tz in zones {
859 let original = "2025-06-14T15:30";
860 let utc = parse_local_datetime(original, tz).unwrap();
861 let back = to_local_input_value(utc, tz);
862 assert_eq!(back, original, "roundtrip failed for {}", tz.name());
863 }
864 }
865
866 #[test]
867 fn to_local_input_value_formats_correctly() {
868 use chrono::TimeZone as ChrTz;
869 let utc = chrono::Utc.with_ymd_and_hms(2025, 6, 14, 6, 30, 0).unwrap();
870 assert_eq!(
871 to_local_input_value(utc, Tz::Asia__Tokyo),
872 "2025-06-14T15:30"
873 );
874 assert_eq!(to_local_input_value(utc, Tz::UTC), "2025-06-14T06:30");
875 }
876
877 #[cfg(feature = "maud")]
880 mod maud_tests {
881 use super::*;
882 use chrono::TimeZone as ChrTz;
883
884 #[allow(clippy::many_single_char_names)]
885 fn utc(y: i32, mo: u32, d: u32, h: u32, m: u32, s: u32) -> DateTime<Utc> {
886 chrono::Utc.with_ymd_and_hms(y, mo, d, h, m, s).unwrap()
887 }
888
889 #[test]
890 fn local_datetime_uses_zone() {
891 let dt = utc(2025, 6, 14, 12, 0, 0);
892 let utc_html = local_datetime(dt, Tz::UTC).into_string();
893 let tokyo_html = local_datetime(dt, Tz::Asia__Tokyo).into_string();
894 let ny_html = local_datetime(dt, Tz::America__New_York).into_string();
895 assert!(utc_html.contains("12:00"), "UTC: {utc_html}");
897 assert!(tokyo_html.contains("21:00"), "Tokyo: {tokyo_html}");
898 assert!(ny_html.contains("08:00"), "New York: {ny_html}");
899 }
900
901 #[test]
902 fn local_datetime_datetime_attr_is_utc() {
903 let dt = utc(2025, 6, 14, 12, 0, 0);
904 let html = local_datetime(dt, Tz::Asia__Tokyo).into_string();
905 assert!(
907 html.contains("2025-06-14T12:00:00"),
908 "datetime attr must be UTC: {html}"
909 );
910 }
911
912 #[test]
913 fn local_date_uses_zone() {
914 let dt = utc(2025, 6, 14, 23, 30, 0); let utc_html = local_date(dt, Tz::UTC).into_string();
917 let tokyo_html = local_date(dt, Tz::Asia__Tokyo).into_string();
918 assert!(utc_html.contains("2025-06-14"), "UTC: {utc_html}");
919 assert!(tokyo_html.contains("2025-06-15"), "Tokyo: {tokyo_html}");
920 }
921
922 #[test]
923 fn time_ago_seconds() {
924 let now = utc(2025, 6, 14, 12, 0, 30);
925 let dt = utc(2025, 6, 14, 12, 0, 0);
926 let html = time_ago(dt, now, Tz::UTC).into_string();
927 assert!(html.contains("seconds ago"), "{html}");
928 }
929
930 #[test]
931 fn time_ago_minutes() {
932 let now = utc(2025, 6, 14, 12, 5, 0);
933 let dt = utc(2025, 6, 14, 12, 0, 0);
934 let html = time_ago(dt, now, Tz::UTC).into_string();
935 assert!(html.contains("minutes ago"), "{html}");
936 }
937
938 #[test]
939 fn time_ago_hours() {
940 let now = utc(2025, 6, 14, 14, 0, 0);
941 let dt = utc(2025, 6, 14, 12, 0, 0);
942 let html = time_ago(dt, now, Tz::UTC).into_string();
943 assert!(html.contains("hours ago"), "{html}");
944 }
945
946 #[test]
947 fn time_ago_days() {
948 let now = utc(2025, 6, 16, 12, 0, 0);
949 let dt = utc(2025, 6, 14, 12, 0, 0);
950 let html = time_ago(dt, now, Tz::UTC).into_string();
951 assert!(html.contains("days ago"), "{html}");
952 }
953
954 #[test]
955 fn time_ago_future_minutes() {
956 let now = utc(2025, 6, 14, 12, 0, 0);
957 let dt = utc(2025, 6, 14, 12, 5, 0);
958 let html = time_ago(dt, now, Tz::UTC).into_string();
959 assert!(html.contains("in "), "{html}");
960 assert!(html.contains("minutes"), "{html}");
961 }
962
963 #[test]
964 fn time_ago_preserves_utc_datetime_attr() {
965 let now = utc(2025, 6, 14, 12, 5, 0);
966 let dt = utc(2025, 6, 14, 12, 0, 0);
967 let html = time_ago(dt, now, Tz::UTC).into_string();
968 assert!(html.contains("datetime="), "{html}");
969 }
970 }
971
972 #[tokio::test]
975 async fn ambient_time_zone_defaults_to_utc() {
976 assert_eq!(ambient_time_zone(), Tz::UTC);
977 }
978
979 #[tokio::test]
980 async fn with_request_time_zone_sets_ambient() {
981 let result = with_request_time_zone(Tz::Asia__Tokyo, async { ambient_time_zone() }).await;
982 assert_eq!(result, Tz::Asia__Tokyo);
983 }
984
985 #[tokio::test]
986 async fn ambient_returns_utc_outside_scope() {
987 let inside =
989 with_request_time_zone(Tz::America__New_York, async { ambient_time_zone() }).await;
990 let outside = ambient_time_zone();
992 assert_eq!(inside, Tz::America__New_York);
993 assert_eq!(outside, Tz::UTC);
994 }
995}