diff --git a/Cargo.lock b/Cargo.lock index 72ae1baf..9326263b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -348,6 +348,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "clap" version = "4.6.1" @@ -1029,7 +1039,7 @@ dependencies = [ "indexmap", "lasso", "once_cell", - "phf", + "phf 0.11.3", "rand", ] @@ -1787,6 +1797,7 @@ dependencies = [ "async-trait", "axum", "chrono", + "chrono-tz", "colored", "config", "figlet-rs", @@ -2026,7 +2037,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", ] [[package]] @@ -2035,7 +2055,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand", ] @@ -2046,7 +2066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn", @@ -2061,6 +2081,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" diff --git a/Cargo.toml b/Cargo.toml index 21e081ca..25dacfb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ axum = { version = "0.8" } base64ct = { version = "1.8", features = ["alloc"] } change-detection = "1.2" chrono = "0.4" +chrono-tz = "0.10" colored = "3.1" config = { version = "0.15", default-features = false, features = ["toml"] } figlet-rs = "1.0" @@ -111,6 +112,7 @@ testing = [] async-trait.workspace = true axum.workspace = true chrono.workspace = true +chrono-tz.workspace = true colored.workspace = true config.workspace = true figlet-rs.workspace = true diff --git a/examples/intro-datetime.rs b/examples/intro-datetime.rs new file mode 100644 index 00000000..c15afb0b --- /dev/null +++ b/examples/intro-datetime.rs @@ -0,0 +1,270 @@ +use pagetop::prelude::*; + +include_locales!(LOC from "examples/locale"); + +struct IntroDatetime; + +#[async_trait] +impl Extension for IntroDatetime { + fn configure_router(&self, router: Router) -> Router { + router.route("/", web::get(intro_datetime)) + } +} + +async fn intro_datetime(request: HttpRequest) -> Result { + Page::new(request) + .with_child( + Intro::custom() + .with_title(Lc::n("PageTop")) + .with_slogan(Lc::t("datetime_slogan", &LOC)) + .with_child(world_block()) + .with_child(formats_block()) + .with_child(relative_block()) + .with_child(since_until_block()), + ) + .render() + .await +} + +// Zonas IANA de la demostración, con la clave de su etiqueta traducible. +const ZONES: [(&str, &str); 4] = [ + ("datetime_zone_utc", "UTC"), + ("datetime_zone_madrid", "Europe/Madrid"), + ("datetime_zone_mexico", "America/Mexico_City"), + ("datetime_zone_tokyo", "Asia/Tokyo"), +]; + +// El mismo instante (`Utc::now()`) mostrado en cada zona de `ZONES`: fecha y hora combinadas +// (`DateFormat::Medium` + `TimeFormat::Short`) y su equivalente ISO 8601, con el offset propio de +// cada zona. +fn world_block() -> Block { + Block::new() + .with_title(Lc::t("datetime_block_world", &LOC)) + .with_child(Html::with(|cx| { + let now = Utc::now(); + let rows: Vec<(Lc, String, String)> = ZONES + .into_iter() + .map(|(label_key, zone)| { + let tz: Tz = zone.parse().expect("valid IANA timezone"); + let zone_cx = Context::default().with_langid(cx).with_timezone(tz); + ( + Lc::t(label_key, &LOC), + zone_cx.format_datetime(now, DateFormat::Medium, TimeFormat::Short), + zone_cx.format_iso_datetime(now), + ) + }) + .collect(); + + html! { + ul { + @for (label, combined, iso) in &rows { + li { + strong { (label.using(cx)) ": " } (combined) + " (ISO " code { (iso) } ")" + } + } + } + } + })) +} + +// Combinaciones de `DateFormat`/`TimeFormat` sobre la zona horaria efectiva del visitante. Para +// añadir un nuevo formato basta con ampliar el array correspondiente. +fn formats_block() -> Block { + Block::new() + .with_title(Lc::t("datetime_block_formats", &LOC)) + .with_child(Html::with(|cx| { + let now = Utc::now(); + let today = now.with_timezone(&cx.timezone()).date_naive(); + + let dates: Vec<(&str, String)> = [ + ("Short", DateFormat::Short), + ("Medium", DateFormat::Medium), + ("Long", DateFormat::Long), + ("Iso", DateFormat::Iso), + ("Custom(\"%d.%m.%Y\")", DateFormat::Custom("%d.%m.%Y")), + ] + .into_iter() + .map(|(label, format)| (label, cx.format_date(today, format))) + .collect(); + + let times: Vec<(&str, String)> = [ + ("Short", TimeFormat::Short), + ("Long", TimeFormat::Long), + ("Custom(\"%I:%M %p\")", TimeFormat::Custom("%I:%M %p")), + ] + .into_iter() + .map(|(label, format)| (label, cx.format_time(now, format))) + .collect(); + + let combos: Vec<(&str, String)> = [ + ("Short + Short", DateFormat::Short, TimeFormat::Short), + ("Medium + Short", DateFormat::Medium, TimeFormat::Short), + ("Long + Long", DateFormat::Long, TimeFormat::Long), + ("Long + Short", DateFormat::Long, TimeFormat::Short), + ("Iso + Long", DateFormat::Iso, TimeFormat::Long), + ] + .into_iter() + .map(|(label, date, time)| (label, cx.format_datetime(now, date, time))) + .collect(); + + html! { + h3 { (Lc::t("datetime_formats_date", &LOC).using(cx)) } + ul { + @for (label, value) in &dates { + li { code { (label) } ": " (value) } + } + } + h3 { (Lc::t("datetime_formats_time", &LOC).using(cx)) } + ul { + @for (label, value) in × { + li { code { (label) } ": " (value) } + } + } + h3 { (Lc::t("datetime_formats_combos", &LOC).using(cx)) } + ul { + @for (label, value) in &combos { + li { code { (label) } ": " (value) } + } + } + p { + (Lc::t("datetime_formats_iso", &LOC).using(cx)) + " " code { (cx.format_iso_datetime(now)) } + } + } + })) +} + +// Casos ilustrativos de `RelativeFormat` sobre la zona horaria efectiva del visitante: hoy, un +// desplazamiento simple de días, uno con el componente de meses en cero, uno con los tres +// componentes y uno en el futuro -- siempre respecto al día actual. Añadir un caso nuevo es sólo +// ampliar `samples`. La primera columna muestra la fecha seleccionada (no una etiqueta fija), +// formateada con `DateFormat::Medium` en el idioma efectivo, para que se vea a qué fecha concreta +// corresponde cada resultado relativo. +fn relative_block() -> Block { + Block::new() + .with_title(Lc::t("datetime_block_relative", &LOC)) + .with_child(Html::with(|cx| { + let today = Utc::now().date_naive(); + + // Desplazamientos de meses/años construidos con `checked_sub_months()` (mismo mecanismo + // que usa `RelativeFormat` por dentro), para que el resultado coincida exactamente con + // el criterio de cada caso. + let two_years_and_3_days_ago = today + .checked_sub_months(Months::new(24)) + .expect("valid date") + - Duration::days(3); + let three_years_2_months_10_days_ago = today + .checked_sub_months(Months::new(38)) + .expect("valid date") + - Duration::days(10); + + let samples = [ + today, + today - Duration::days(3), + two_years_and_3_days_ago, + three_years_2_months_10_days_ago, + today + Duration::days(5), + ]; + + let rows: Vec<(String, String, String, String)> = samples + .into_iter() + .map(|date| { + let dt = at_noon(date); + ( + cx.format_date(date, DateFormat::Medium), + cx.format_relative(dt, RelativeFormat::Short), + cx.format_relative(dt, RelativeFormat::Medium), + cx.format_relative(dt, RelativeFormat::Long), + ) + }) + .collect(); + + html! { + table style="width: 100%; border: 1px solid black;" { + thead { + tr { + th { (Lc::t("datetime_relative_col_date", &LOC).using(cx)) } + th { "Short" } + th { "Medium" } + th { "Long" } + } + } + tbody { + @for (date, short, medium, long) in &rows { + tr { + td { (date) } + td { (short) } + td { (medium) } + td { (long) } + } + } + } + } + } + })) +} + +// Niveles de `DatePrecision` para `format_since()`/`format_until()`. Añadir un nivel nuevo es sólo +// ampliar este array. +const PRECISIONS: [(&str, DatePrecision); 3] = [ + ("Short", DatePrecision::Short), + ("Medium", DatePrecision::Medium), + ("Long", DatePrecision::Long), +]; + +// `format_since()`/`format_until()` sobre una única fecha de ejemplo: a diferencia de `DateFormat`, +// lo que cambia entre niveles no es el estilo, sino la propia precisión revelada (sólo el mes, mes +// y año, o fecha completa). +fn since_until_block() -> Block { + Block::new() + .with_title(Lc::t("datetime_block_since_until", &LOC)) + .with_child(Html::with(|cx| { + let date = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap(); + + let rows: Vec<(&str, String, String)> = PRECISIONS + .into_iter() + .map(|(label, precision)| { + ( + label, + cx.format_since(date, precision), + cx.format_until(date, precision), + ) + }) + .collect(); + + html! { + table style="width: 100%; border: 1px solid black;" { + thead { + tr { + th { (Lc::t("datetime_since_until_col_precision", &LOC).using(cx)) } + th { (Lc::t("datetime_since_until_col_since", &LOC).using(cx)) } + th { (Lc::t("datetime_since_until_col_until", &LOC).using(cx)) } + } + } + tbody { + @for (label, since, until) in &rows { + tr { + td { (*label) } + td { (since) } + td { (until) } + } + } + } + } + } + })) +} + +// Mediodía, para evitar que el redondeo horario de la conversión de zona horaria empuje la fecha +// civil resultante al día anterior o siguiente en zonas con un offset amplio. +fn at_noon(date: NaiveDate) -> DateTime { + date.and_hms_opt(12, 0, 0) + .expect("noon is always a valid time") + .and_utc() +} + +#[pagetop::main] +async fn main() -> std::io::Result<()> { + Application::prepare(&IntroDatetime).await.run().await +} diff --git a/examples/locale/en-US/intro-datetime.ftl b/examples/locale/en-US/intro-datetime.ftl new file mode 100644 index 00000000..5b0fd436 --- /dev/null +++ b/examples/locale/en-US/intro-datetime.ftl @@ -0,0 +1,21 @@ +datetime_slogan = Timezones and date/time formats + +datetime_block_world = The same instant, around the world +datetime_zone_utc = UTC +datetime_zone_madrid = Madrid (Europe/Madrid) +datetime_zone_mexico = Mexico City (America/Mexico_City) +datetime_zone_tokyo = Tokyo (Asia/Tokyo) + +datetime_block_formats = DateFormat / TimeFormat combinations +datetime_formats_date = DateFormat (date only) +datetime_formats_time = TimeFormat (time only) +datetime_formats_combos = format_datetime() combinations (date + time) +datetime_formats_iso = format_iso_datetime(): + +datetime_block_relative = RelativeFormat +datetime_relative_col_date = Date + +datetime_block_since_until = DatePrecision (format_since / format_until) +datetime_since_until_col_precision = Precision +datetime_since_until_col_since = since +datetime_since_until_col_until = until diff --git a/examples/locale/es-ES/intro-datetime.ftl b/examples/locale/es-ES/intro-datetime.ftl new file mode 100644 index 00000000..6800682e --- /dev/null +++ b/examples/locale/es-ES/intro-datetime.ftl @@ -0,0 +1,21 @@ +datetime_slogan = Zonas horarias y formatos de fecha y hora + +datetime_block_world = El mismo instante, alrededor del mundo +datetime_zone_utc = UTC +datetime_zone_madrid = Madrid (Europe/Madrid) +datetime_zone_mexico = Ciudad de México (America/Mexico_City) +datetime_zone_tokyo = Tokio (Asia/Tokyo) + +datetime_block_formats = Combinaciones de DateFormat / TimeFormat +datetime_formats_date = DateFormat (sólo fecha) +datetime_formats_time = TimeFormat (sólo hora) +datetime_formats_combos = Combinaciones de format_datetime() (fecha + hora) +datetime_formats_iso = format_iso_datetime(): + +datetime_block_relative = RelativeFormat +datetime_relative_col_date = Fecha + +datetime_block_since_until = DatePrecision (format_since / format_until) +datetime_since_until_col_precision = Precisión +datetime_since_until_col_since = desde +datetime_since_until_col_until = hasta diff --git a/src/app.rs b/src/app.rs index 7b4d197e..061c1b2e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -3,6 +3,7 @@ mod figfont; use crate::core::{extension, extension::ExtensionRef}; +use crate::datetime::Timezone; use crate::locale::Locale; use crate::response::{render_error_pages, response_for_panic, route_not_found}; use crate::web::Router; @@ -63,6 +64,9 @@ impl Application { // Inicializa el idioma predeterminado. Locale::init(); + // Inicializa la zona horaria predeterminada. + Timezone::init(); + // Registra las extensiones de la aplicación. extension::all::register_extensions(root_extension); diff --git a/src/auth.rs b/src/auth.rs index d12135e8..9a521869 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -17,6 +17,7 @@ //! [`Context`]: crate::core::component::Context use crate::core::action::{ActionDispatcher, try_dispatch_actions}; +use crate::datetime::{Timezone, Tz}; use crate::locale::Lc; use crate::response::ErrorPage; use crate::web::HttpRequest; @@ -49,6 +50,10 @@ pub enum CurrentUser { id: i32, /// Nombre visible del usuario. display_name: String, + /// Zona horaria del usuario, si tiene una configurada y es válida. En otro caso valdrá + /// `None` y [`timezone()`](Self::timezone) devolverá la zona horaria predeterminada de la + /// aplicación. + timezone: Option, }, } @@ -78,6 +83,23 @@ impl CurrentUser { CurrentUser::Authenticated { display_name, .. } => Some(display_name), } } + + /// Devuelve la zona horaria efectiva del usuario. + /// + /// Un usuario autenticado devuelve la suya si tiene una configurada y es válida; en cualquier + /// otro caso (incluido el usuario anónimo), devuelve [`Timezone::default_tz()`]. + /// + /// Normalmente se resuelve una sola vez, al construir el `Context` de la petición. A partir de + /// ese momento el renderizado del documento no vuelve a llamarlo porque usa el valor ya + /// resuelto vía [`Contextual::timezone()`](crate::core::component::Contextual::timezone). + pub fn timezone(&self) -> Tz { + match self { + CurrentUser::Anonymous => Timezone::default_tz(), + CurrentUser::Authenticated { timezone, .. } => { + timezone.unwrap_or_else(Timezone::default_tz) + } + } + } } // **< Permission >********************************************************************************* @@ -283,7 +305,6 @@ pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool { /// ``` // `ErrorPage` incluye `Option` en cada variante y es el tipo de error ya establecido // para toda la respuesta HTTP; boxearlo aquí sólo para esta función no compensa. -#[allow(clippy::result_large_err)] pub fn require_permission(request: &HttpRequest, perm: PermissionRef) -> Result<(), ErrorPage> { if has_permission(request, perm) { Ok(()) diff --git a/src/core/component/context.rs b/src/core/component/context.rs index 66ce4e08..03c050c9 100644 --- a/src/core/component/context.rs +++ b/src/core/component/context.rs @@ -4,6 +4,7 @@ use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage}; use crate::core::theme::all::DEFAULT_THEME; use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates}; use crate::core::theme::{RegionRef, TemplateRef, ThemeRef}; +use crate::datetime::Tz; use crate::html::{Assets, Favicon, JavaScript, Preload, ResponsiveStyles, StyleSheet}; use crate::html::{Markup, Props, PropsOp, RoutePath, html}; use crate::locale::Lc; @@ -30,7 +31,7 @@ pub use contextual::Contextual; /// Se crea una sola vez por petición usando [`Context::new()`] (típicamente a través de /// [`Page::new()`](crate::response::Page::new) o [`Page::admin()`](crate::response::Page::admin)), /// y es la única vía por la que un componente, una acción o el tema activo conocen: la petición -/// HTTP de origen, el idioma negociado, el usuario autenticado +/// HTTP de origen, el idioma negociado y la zona horaria efectiva, el usuario autenticado /// ([`current_user()`](Contextual::current_user)), la plantilla y el tema en uso, y los recursos /// (favicon, hojas de estilo, scripts) acumulados hasta ese momento. Otros datos que los /// componentes necesiten durante el renderizado pueden ser parámetros dinámicos tipados con @@ -97,6 +98,7 @@ pub struct Context { request : Option, // Petición HTTP de origen. locale : RequestLocale, // Idioma asociado a la petición. current_user: CurrentUser, // Identidad del usuario actual. + timezone : Tz, // Zona horaria efectiva del documento. template : TemplateRef, // Plantilla usada para renderizar. theme : ThemeRef, // Referencia al tema usado para renderizar. favicon : Option, // Favicon, si se ha definido. @@ -126,10 +128,12 @@ impl Context { fn base(request: Option, template: TemplateRef) -> Self { let locale = RequestLocale::from_request(request.as_ref()); let current_user = Self::resolve_current_user(request.as_ref()); + let timezone = current_user.timezone(); Context { request, locale, current_user, + timezone, template, theme : *DEFAULT_THEME, favicon : None, @@ -340,10 +344,11 @@ impl Contextual for Context { fn with_request(mut self, request: Option) -> Self { self.request = request; - // Recalcula el *locale* y el usuario actual según la nueva petición y la política de - // negociación configurada. + // Recalcula el *locale*, el usuario actual y la zona horaria según la nueva petición y la + // política de negociación configurada. self.locale = RequestLocale::from_request(self.request.as_ref()); self.current_user = Self::resolve_current_user(self.request.as_ref()); + self.timezone = self.current_user.timezone(); self } @@ -352,6 +357,11 @@ impl Contextual for Context { self } + fn with_timezone(mut self, tz: Tz) -> Self { + self.timezone = tz; + self + } + fn with_template(mut self, template: TemplateRef) -> Self { self.template = template; self @@ -438,6 +448,10 @@ impl Contextual for Context { &self.current_user } + fn timezone(&self) -> Tz { + self.timezone + } + fn template(&self) -> TemplateRef { self.template } diff --git a/src/core/component/context/contextual.rs b/src/core/component/context/contextual.rs index 3d436a81..4312999d 100644 --- a/src/core/component/context/contextual.rs +++ b/src/core/component/context/contextual.rs @@ -4,20 +4,31 @@ use crate::auth::CurrentUser; use crate::builder_impl; use crate::core::component::ChildOp; use crate::core::theme::{RegionRef, TemplateRef, ThemeRef}; +use crate::datetime::{DateFormat, DatePrecision, RelativeFormat, TimeFormat}; +use crate::datetime::{DateTime, NaiveDate, Tz, Utc}; use crate::html::{Assets, Favicon, JavaScript, Props, PropsOp, ResponsiveStyles, StyleSheet}; -use crate::locale::LangId; +use crate::locale::{LangId, Lc}; use crate::web::HttpRequest; +// RFC 3339 con el offset de la zona horaria activa (no UTC fijo): igual en todos los idiomas, no +// es una clave Fluent. Usado sólo por `format_iso_datetime()`. +const ISO_DATETIME: &str = "%Y-%m-%dT%H:%M:%S%:z"; + /// Interfaz para gestionar el **contexto de renderizado** de un documento HTML. /// /// `Contextual` extiende [`LangId`] para establecer el idioma del documento y añade métodos para: /// /// - Almacenar la **petición HTTP** de origen. +/// - Conocer la **identidad del usuario actual** ([`current_user()`](Self::current_user)) y la +/// **zona horaria efectiva** del documento ([`timezone()`](Self::timezone)). /// - Seleccionar la **plantilla** y el **tema** de renderizado. /// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo /// [`StyleSheet`] o los scripts [`JavaScript`], directamente o mediante una operación /// [`AssetsOp`]. /// - Leer y mantener **parámetros dinámicos tipados** de contexto. +/// - Formatear **fechas y horas** ([`format_date()`](Self::format_date), +/// [`format_time()`](Self::format_time), [`format_datetime()`](Self::format_datetime) y demás) +/// para la zona horaria e idioma del documento. /// /// Lo implementan, típicamente, estructuras que manejan el contexto de renderizado, como /// [`Context`](crate::core::component::Context) o [`Page`](crate::response::Page). @@ -44,15 +55,26 @@ pub trait Contextual: LangId { /// Establece el idioma del documento. fn with_langid(self, language: &impl LangId) -> Self; + /// Fuerza la zona horaria que se utilizará para mostrar fechas y horas en el documento. + /// + /// Sustituye la zona horaria aplicada (por el usuario actual o por configuración de la + /// aplicación) por otra explícita. Ver [`CurrentUser::timezone()`]. + /// + /// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone + fn with_timezone(self, tz: Tz) -> Self; + /// Almacena la petición HTTP de origen en el contexto. /// - /// También recalcula el idioma ([`RequestLocale::from_request()`]) y - /// [`current_user()`](Self::current_user) a partir de la petición indicada, descartando - /// cualquier idioma forzado antes con [`with_langid()`](Self::with_langid) o el usuario ya - /// resuelto. Si necesitas forzar el idioma o el usuario, llama a `with_request()` primero en + /// Al asociar la petición, recalcula el idioma ([`RequestLocale::from_request()`]), establece + /// el usuario actual ([`current_user()`]) y, a partir de éste, asigna la zona horaria efectiva + /// ([`timezone()`]), descartando en el proceso cualquier idioma o zona horaria anteriores. + /// + /// Si sabes que vas a forzar el idioma o la zona horaria, llama a `with_request()` primero en /// la cadena de construcción, nunca después. /// /// [`RequestLocale::from_request()`]: crate::locale::RequestLocale::from_request + /// [`current_user()`]: Self::current_user + /// [`timezone()`]: Self::timezone fn with_request(self, request: Option) -> Self; /// Especifica la plantilla para renderizar el documento. @@ -119,6 +141,19 @@ pub trait Contextual: LangId { /// ``` fn current_user(&self) -> &CurrentUser; + /// Devuelve la zona horaria efectiva para mostrar fechas y horas en el documento. + /// + /// Se resuelve una sola vez, al construir el `Context` o al llamar a [`with_request()`], y + /// queda guardado para consultar. Llamarlo dentro de un bucle que renderiza miles de filas (p. + /// ej. `format_date()`/`format_time()`/`format_datetime()` en cada celda de una tabla) no + /// repite esa resolución. + /// + /// Ver [`CurrentUser::timezone()`] para el orden de resolución. + /// + /// [`with_request()`]: Self::with_request + /// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone + fn timezone(&self) -> Tz; + /// Devuelve la plantilla configurada para renderizar el documento. fn template(&self) -> TemplateRef; @@ -181,6 +216,187 @@ pub trait Contextual: LangId { // **< Contextual HELPERS >********************************************************************* + /// Formatea una fecha y hora completas en la zona horaria efectiva del documento + /// ([`timezone()`](Self::timezone)), combinando un [`DateFormat`] y un [`TimeFormat`] + /// independientes (posiblemente distintos entre sí, como una fecha larga con la hora corta), + /// con el separador de la clave Fluent `datetime_join` del idioma efectivo. + /// + /// `dt` se graba siempre en UTC. Aquí la conversión a la zona horaria de visualización se hace + /// una sola vez, antes de aplicar `date`/`time` por separado (evita convertir la misma fecha y + /// hora dos veces). + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// # fn show(cx: &Context, dt: DateTime) { + /// let text = cx.format_datetime(dt, DateFormat::Medium, TimeFormat::Short); + /// # } + /// ``` + fn format_datetime( + &self, + dt: DateTime, + date: DateFormat<'_>, + time: TimeFormat<'_>, + ) -> String + where + Self: Sized, + { + let local = dt.with_timezone(&self.timezone()); + let date = date.apply(local.date_naive(), self); + let time = time.apply(local.time(), self); + Lc::l("datetime_join") + .with_arg("date", date.clone()) + .with_arg("time", time.clone()) + .lookup(self) + .unwrap_or_else(|| format!("{date} {time}")) + } + + /// Formatea una fecha sin hora ([`NaiveDate`]). + /// + /// A diferencia de [`format_datetime()`] y [`format_time()`], que reciben [`DateTime`] y + /// convierten a la zona horaria efectiva, aquí no hay ninguna conversión que aplicar. El + /// argumento `date` ya es la fecha civil a mostrar. No es una inconsistencia de tipos entre + /// métodos de la misma familia. Un `DateTime` es un instante grabado en UTC ("qué día es" + /// depende de la zona horaria de quien mira); un `NaiveDate` es una fecha civil sin hora ni + /// zona horaria asociada (una fecha de nacimiento, "socio desde junio de 2020", etc.), es el + /// mismo dato en cualquier sitio desde el que se mire. Forzar aquí un `DateTime` obligaría + /// a inventar una hora falsa para encajar en el tipo, y expondría el dato al error que se + /// quiere evitar. Si se convierte a un huso horario negativo, esa hora inventada puede hacer + /// caer la fecha en el día anterior, por lo que dos usuarios en zonas distintas verían un día + /// distinto para lo que en el dominio es un único hecho fijo. + /// + /// Usa [`DateFormat`], cuyos patrones por defecto no incluyen componentes de hora. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// # fn show(cx: &Context, date: NaiveDate) { + /// let text = cx.format_date(date, DateFormat::Medium); + /// # } + /// ``` + /// + /// Si el dato de origen es un instante (`DateTime`) y sólo hace falta mostrar su fecha, + /// conviértelo explícitamente a la zona horaria efectiva antes de llamar: + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// # fn show(cx: &Context, dt: DateTime) { + /// // `dt`, p. ej. `created_at`: un instante real, con hora, no una fecha civil fabricada. + /// let local_date = dt.with_timezone(&cx.timezone()).date_naive(); + /// let text = cx.format_date(local_date, DateFormat::Medium); + /// # } + /// ``` + /// + /// [`format_datetime()`]: Self::format_datetime + /// [`format_time()`]: Self::format_time + /// [`DateTime`]: chrono::DateTime + fn format_date(&self, date: NaiveDate, format: DateFormat<'_>) -> String + where + Self: Sized, + { + format.apply(date, self) + } + + /// Formatea sólo la hora del día de `dt`, convertida a la zona horaria efectiva + /// ([`timezone()`](Self::timezone)). Usa [`TimeFormat`]. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// # fn show(cx: &Context, dt: DateTime) { + /// let text = cx.format_time(dt, TimeFormat::Short); + /// # } + /// ``` + fn format_time(&self, dt: DateTime, format: TimeFormat<'_>) -> String + where + Self: Sized, + { + format.apply(dt.with_timezone(&self.timezone()).time(), self) + } + + /// Formatea `dt` como ISO 8601 (RFC 3339), con el offset de la zona horaria efectiva + /// ([`timezone()`](Self::timezone)); igual para todos los idiomas. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// # fn show(cx: &Context, dt: DateTime) { + /// let text = cx.format_iso_datetime(dt); + /// # } + /// ``` + fn format_iso_datetime(&self, dt: DateTime) -> String + where + Self: Sized, + { + dt.with_timezone(&self.timezone()) + .format(ISO_DATETIME) + .to_string() + } + + /// Formatea `dt` en relación al momento actual ("hace 3 años", "dentro de 5 días"), en la zona + /// horaria efectiva del documento ([`timezone()`](Self::timezone)). Usa [`RelativeFormat`]. + /// + /// La comparación es entre fechas civiles (la de `dt` y la de "ahora", ambas convertidas a la + /// zona horaria efectiva), no entre instantes. Un valor de hace unos minutos o dentro de unos + /// minutos se muestra como "hoy" si cae en la misma fecha civil que "ahora". + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// # fn show(cx: &Context, dt: DateTime) { + /// let text = cx.format_relative(dt, RelativeFormat::Medium); + /// # } + /// ``` + fn format_relative(&self, dt: DateTime, format: RelativeFormat) -> String + where + Self: Sized, + { + let today = Utc::now().with_timezone(&self.timezone()).date_naive(); + let target = dt.with_timezone(&self.timezone()).date_naive(); + format.apply(target, today, self) + } + + /// Formatea `date` como fecha de **inicio**, con la precisión indicada ("desde junio", "desde + /// junio de 2026", "desde el 3 de junio de 2026"). Usa [`DatePrecision`]. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// # fn show(cx: &Context, date: NaiveDate) { + /// let text = cx.format_since(date, DatePrecision::Medium); + /// # } + /// ``` + fn format_since(&self, date: NaiveDate, precision: DatePrecision) -> String + where + Self: Sized, + { + precision.apply_since(date, self) + } + + /// Formatea `date` como fecha de **fin**, con la precisión indicada ("hasta junio", "hasta + /// junio de 2026", "hasta el 3 de junio de 2026"). Usa [`DatePrecision`]. + /// + /// # Ejemplo + /// + /// ```rust,no_run + /// # use pagetop::prelude::*; + /// # fn show(cx: &Context, date: NaiveDate) { + /// let text = cx.format_until(date, DatePrecision::Medium); + /// # } + /// ``` + fn format_until(&self, date: NaiveDate, precision: DatePrecision) -> String + where + Self: Sized, + { + precision.apply_until(date, self) + } + /// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó. /// /// # Ejemplo diff --git a/src/datetime.rs b/src/datetime.rs index b06dc3c1..ca1058f5 100644 --- a/src/datetime.rs +++ b/src/datetime.rs @@ -1,9 +1,70 @@ -//! Soporte a fechas y horas según estándar [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) -//! (basado en [chrono](https://docs.rs/chrono)). +//! Soporte a fechas y horas según estándar [ISO 8601]. +//! +//! Basado en [chrono] y zonas horarias de [chrono-tz] con formatos de visualización según idioma. +//! +//! Las fechas y horas se graban siempre en UTC y se muestran en la zona horaria efectiva del +//! usuario actual ([`CurrentUser::timezone()`]), ya sea la suya propia si tiene una configurada y +//! válida; y si no, la configurada para la aplicación ([`Timezone`]); o, en su defecto, UTC. La +//! conversión sólo ocurre al mostrar ([`Contextual::format_datetime()`]), nunca al guardar. +//! +//! [`DateFormat`] (fecha) y [`TimeFormat`] (hora) son tipos independientes: no existe un formato +//! combinado de fecha y hora. Para mostrar ambas, [`Contextual::format_datetime()`] junta el +//! resultado de cada uno (posiblemente con formatos distintos, p. ej. fecha larga con hora corta) +//! mediante la clave Fluent `datetime_join` del idioma efectivo. El orden día/mes/año, el nombre del +//! mes y el separador de fecha y hora son una propiedad del idioma, no de la configuración de la +//! aplicación (a diferencia de la zona horaria). Se resuelven como claves Fluent normales +//! (`src/locale/{lang}/datetime.ftl`), con el mismo mecanismo que cualquier otro texto traducido de +//! PageTop. +//! +//! [`RelativeFormat`] muestra una fecha en relación al momento actual ("hace 3 años", "dentro de 5 +//! días") en vez de como fecha absoluta, con el mismo mecanismo de claves Fluent, vía +//! [`Contextual::format_relative()`]. +//! +//! [`DatePrecision`] muestra una fecha de inicio o fin con precisión reducida ("desde junio", +//! "hasta junio de 2026"), vía [`Contextual::format_since()`] y [`Contextual::format_until()`]. +//! +//! [ISO 8601]: https://en.wikipedia.org/wiki/ISO_8601 +//! [chrono]: https://docs.rs/chrono +//! [chrono-tz]: https://docs.rs/chrono-tz +//! [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone +//! [`Contextual::format_datetime()`]: crate::core::component::Contextual::format_datetime +//! [`Contextual::format_relative()`]: crate::core::component::Contextual::format_relative +//! [`Contextual::format_since()`]: crate::core::component::Contextual::format_since +//! [`Contextual::format_until()`]: crate::core::component::Contextual::format_until -pub use chrono::prelude::*; +// Reexportado para que el resto del código y las extensiones se refieran siempre a `datetime::X`, +// nunca a `chrono` directamente (mismo criterio que `Tz`/`TZ_VARIANTS` más abajo). No es todo el +// `chrono::prelude` porque se excluyen `Local` (incompatible con el invariante "siempre UTC" de +// este módulo), `Month` (su nombre de mes es inglés fijo, contradice el mecanismo Fluent de este +// módulo) y `SubsecRound`/`SecondsFormat`/`Offset` (sin ningún consumidor, ni directo ni +// indirecto). `Weekday` y `FixedOffset` se mantienen pese a no tener tampoco uso propio: `Weekday` +// es el tipo que ya devuelve `Datelike::weekday()` (en uso interno); `FixedOffset` es el tipo que +// devolvería cualquier futuro *parsing* de fecha ISO (`DateTime::parse_from_rfc3339()`). +pub use chrono::DateTime; +pub use chrono::TimeZone; +pub use chrono::{Datelike, Timelike, Weekday}; +pub use chrono::{FixedOffset, Utc}; +pub use chrono::{NaiveDate, NaiveDateTime, NaiveTime}; -// `Duration` no forma parte de `chrono::prelude`, pero es de uso tan habitual junto al resto de -// tipos de este módulo (sumar/restar intervalos a un `NaiveDateTime`, calcular expiraciones...) que -// se reexporta igualmente. -pub use chrono::Duration; +// `Duration` y `Months` no forman parte de `chrono::prelude`, pero son de uso habitual junto al +// resto de tipos de este módulo (sumar/restar intervalos a un `NaiveDateTime`, calcular +// expiraciones, desglosar una diferencia de calendario en años/meses, etc.) que se reexportan +// igualmente. +pub use chrono::{Duration, Months}; + +// Reexportado para que el resto del código y las extensiones se refieran siempre a `datetime::Tz`/ +// `datetime::TZ_VARIANTS`, sin depender directamente de `chrono_tz` (mismo criterio que +// `locale::LanguageIdentifier` sobre `unic_langid`). +pub use chrono_tz::{TZ_VARIANTS, Tz}; + +mod definition; +pub use definition::Timezone; + +mod format; +pub use format::{DateFormat, TimeFormat}; + +mod relative; +pub use relative::RelativeFormat; + +mod precision; +pub use precision::DatePrecision; diff --git a/src/datetime/definition.rs b/src/datetime/definition.rs new file mode 100644 index 00000000..739cab5c --- /dev/null +++ b/src/datetime/definition.rs @@ -0,0 +1,72 @@ +use crate::{global, trace, util}; + +use super::Tz; + +use std::sync::LazyLock; + +// Identificador de zona horaria configurado para la aplicación, si es válido. +static CONFIG_TZ: LazyLock> = LazyLock::new(|| { + global::SETTINGS + .app + .timezone + .as_deref() + .and_then(util::non_blank) + .and_then(|raw| raw.parse().ok()) +}); + +// Zona horaria de respaldo, garantizada incluso sin configuración válida. +const FALLBACK_TZ: Tz = Tz::UTC; + +/// Zona horaria configurada para la aplicación. +/// +/// Resuelve [`global::SETTINGS.app.timezone`](crate::global::App::timezone) contra la base IANA de +/// zonas horarias. Si no se ha configurado o el valor no es válido, se aplica la zona horaria de +/// respaldo (`UTC`). +pub struct Timezone; + +impl Timezone { + /// Inicializa la zona horaria por defecto que utilizará la aplicación. + /// + /// Debe llamarse durante la inicialización para indicar si la zona horaria por defecto procede + /// de la configuración, de una configuración no válida o de la zona horaria de respaldo. + pub(crate) fn init() { + match global::SETTINGS + .app + .timezone + .as_deref() + .and_then(util::non_blank) + { + Some(raw) => { + if let Some(tz) = *CONFIG_TZ { + trace::debug!("Default timezone \"{tz}\" (from config: \"{raw}\")"); + } else { + trace::warn!( + "Default timezone \"{FALLBACK_TZ}\" (fallback, invalid config: \"{raw}\")" + ); + } + } + _ => trace::debug!("Default timezone \"{FALLBACK_TZ}\" (fallback, no config)"), + } + } + + /// Devuelve la zona horaria configurada explícitamente, si es válida. + /// + /// Si no se ha configurado una zona horaria por defecto o el valor no es válido, devuelve + /// `None`. + pub fn try_tz() -> Option { + *CONFIG_TZ + } + + /// Devuelve siempre la zona horaria de respaldo (`UTC`). + /// + /// Es la zona horaria garantizada incluso cuando no haya configuración de la aplicación o + /// cuando el valor configurado no sea válido. + pub fn fallback_tz() -> Tz { + FALLBACK_TZ + } + + /// Devuelve la zona horaria configurada o, en su defecto, la de respaldo (`UTC`). + pub fn default_tz() -> Tz { + Self::try_tz().unwrap_or(FALLBACK_TZ) + } +} diff --git a/src/datetime/format.rs b/src/datetime/format.rs new file mode 100644 index 00000000..6f111ef8 --- /dev/null +++ b/src/datetime/format.rs @@ -0,0 +1,150 @@ +use crate::locale::{LangId, Lc}; + +use chrono::{Datelike, NaiveDate, NaiveTime, Timelike}; + +// Nombre del mes (1-12) traducido contra `LOCALES_PAGETOP` para `language` (claves +// `month_01`..`month_12`). `chrono` sólo traduce `%B`/`%A` con la *feature* `unstable-locales`, +// redundante con Fluent y descartada para todo el proyecto; el nombre se resuelve aquí y se pasa +// como argumento `{ $month }` a `date_format_long` y, desde `precision.rs`, a `since_*`/`until_*`. +pub(crate) fn month_name(month: u32, language: &impl LangId) -> String { + Lc::l(format!("month_{month:02}")) + .lookup(language) + .unwrap_or_else(|| month.to_string()) +} + +// **< DateFormat >********************************************************************************* + +// Patrones `strftime` de respaldo, sólo si faltase la clave Fluent correspondiente (no debería +// ocurrir porque el idioma de respaldo `en-US` siempre las define). +const FALLBACK_DATE_SHORT: &str = "%d/%m/%y"; +const FALLBACK_DATE_MEDIUM: &str = "%d/%m/%Y"; +const FALLBACK_DATE_LONG: &str = "%d/%m/%Y"; + +// Fecha en formato ISO 8601; igual en todos los idiomas (no es una clave Fluent). +const ISO_DATE: &str = "%Y-%m-%d"; + +/// Tipo de formato a aplicar al mostrar una fecha **sin hora** ([`NaiveDate`](chrono::NaiveDate)). +/// +/// `Short`, `Medium` y `Long` toman su texto de las claves Fluent +/// `date_format_short`/`_medium`/`_long` del idioma efectivo. `Iso` es un patrón ISO 8601 fijo, +/// igual en todos los idiomas (no pasa por Fluent porque nunca debería divergir entre idiomas). +/// `Custom` acepta un patrón `strftime` explícito para el resto de casos. +/// +/// Para una fecha y hora completas, combina esta fecha con [`TimeFormat`] mediante +/// [`Contextual::format_datetime()`](crate::core::component::Contextual::format_datetime). +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// # fn show(cx: &Context, date: NaiveDate) { +/// let short = cx.format_date(date, DateFormat::Short); +/// let long = cx.format_date(date, DateFormat::Long); // p. ej. "15 de junio de 2026" +/// let iso = cx.format_date(date, DateFormat::Iso); +/// let custom = cx.format_date(date, DateFormat::Custom("%Y-%m-%d")); +/// # } +/// ``` +#[derive(Clone, Copy, Debug)] +pub enum DateFormat<'a> { + /// Fecha corta. + Short, + /// Fecha con año completo. + Medium, + /// Fecha larga (formato extenso propio del idioma), con el nombre del mes. + Long, + /// Fecha en formato ISO 8601; igual en todos los idiomas. + Iso, + /// Patrón `strftime` explícito. + Custom(&'a str), +} + +impl DateFormat<'_> { + // Formatea `date` para `language`. + pub(crate) fn apply(&self, date: NaiveDate, language: &impl LangId) -> String { + match self { + DateFormat::Short => Lc::l("date_format_short") + .with_arg("day", format!("{:02}", date.day())) + .with_arg("month", format!("{:02}", date.month())) + .with_arg("year_short", format!("{:02}", date.year().rem_euclid(100))) + .lookup(language) + .unwrap_or_else(|| date.format(FALLBACK_DATE_SHORT).to_string()), + DateFormat::Medium => Lc::l("date_format_medium") + .with_arg("day", format!("{:02}", date.day())) + .with_arg("month", format!("{:02}", date.month())) + .with_arg("year", date.year().to_string()) + .lookup(language) + .unwrap_or_else(|| date.format(FALLBACK_DATE_MEDIUM).to_string()), + DateFormat::Long => Lc::l("date_format_long") + .with_arg("day", date.day().to_string()) + .with_arg("month", month_name(date.month(), language)) + .with_arg("year", date.year().to_string()) + .lookup(language) + .unwrap_or_else(|| date.format(FALLBACK_DATE_LONG).to_string()), + DateFormat::Iso => date.format(ISO_DATE).to_string(), + DateFormat::Custom(pattern) => date.format(pattern).to_string(), + } + } +} + +// **< TimeFormat >********************************************************************************* + +// Patrones `strftime` de respaldo, sólo si faltase la clave Fluent correspondiente. +const FALLBACK_TIME_SHORT: &str = "%H:%M"; +const FALLBACK_TIME_LONG: &str = "%H:%M:%S"; + +/// Tipo de formato a aplicar al mostrar una hora del día ([`NaiveTime`](chrono::NaiveTime)). +/// +/// Mismo mecanismo que [`DateFormat`], en este caso usa `Short` y `Long` para tomar su texto de las +/// claves Fluent `time_format_short`/`_long` del idioma efectivo, aplicando plantillas con los +/// argumentos `{ $hour }`, `{ $minute }` (más `{ $second }` en `Long`). `Custom` acepta un patrón +/// `strftime` explícito, útil para aplicar, por ejemplo, un formato de 12 horas con AM/PM +/// independientemente del idioma efectivo (`"%I:%M %p"`). +/// +/// Para una fecha y hora completas, combina este `TimeFormat` con un [`DateFormat`] mediante +/// [`Contextual::format_datetime()`], que permite elegir un formato distinto para cada parte (p. +/// ej. fecha larga con hora corta) en vez de un único formato combinado fijo. +/// +/// [`Contextual::format_datetime()`]: crate::core::component::Contextual::format_datetime +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// # fn show(cx: &Context, dt: DateTime) { +/// let short = cx.format_time(dt, TimeFormat::Short); +/// let long = cx.format_time(dt, TimeFormat::Long); +/// let custom = cx.format_time(dt, TimeFormat::Custom("%H:%M:%S")); +/// +/// // Formato de 12 horas con AM/PM (p. ej. "02:30 PM"). +/// let twelve_hour = cx.format_time(dt, TimeFormat::Custom("%I:%M %p")); +/// # } +/// ``` +#[derive(Clone, Copy, Debug)] +pub enum TimeFormat<'a> { + /// Hora y minutos. + Short, + /// Hora, minutos y segundos. + Long, + /// Patrón `strftime` explícito. + Custom(&'a str), +} + +impl TimeFormat<'_> { + // Formatea `time` para `language`. + pub(crate) fn apply(&self, time: NaiveTime, language: &impl LangId) -> String { + match self { + TimeFormat::Short => Lc::l("time_format_short") + .with_arg("hour", format!("{:02}", time.hour())) + .with_arg("minute", format!("{:02}", time.minute())) + .lookup(language) + .unwrap_or_else(|| time.format(FALLBACK_TIME_SHORT).to_string()), + TimeFormat::Long => Lc::l("time_format_long") + .with_arg("hour", format!("{:02}", time.hour())) + .with_arg("minute", format!("{:02}", time.minute())) + .with_arg("second", format!("{:02}", time.second())) + .lookup(language) + .unwrap_or_else(|| time.format(FALLBACK_TIME_LONG).to_string()), + TimeFormat::Custom(pattern) => time.format(pattern).to_string(), + } + } +} diff --git a/src/datetime/precision.rs b/src/datetime/precision.rs new file mode 100644 index 00000000..39cd3766 --- /dev/null +++ b/src/datetime/precision.rs @@ -0,0 +1,89 @@ +use super::format::month_name; +use crate::locale::{LangId, Lc}; + +use chrono::{Datelike, NaiveDate}; + +/// Precisión a aplicar al mostrar una fecha de inicio o fin. +/// +/// Por ejemplo, "desde junio" o "hasta junio de 2026", usada por [`Contextual::format_since()`] y +/// [`Contextual::format_until()`]. +/// +/// A diferencia de [`DateFormat`], que siempre muestra día, mes y año (sólo cambia el estilo), +/// aquí lo que cambia es la propia precisión revelada, dejando `Short` para sólo el mes, `Medium` +/// para mes y año, y `Long` para la fecha completa. El texto de cada nivel es una plantilla +/// Fluent completa por idioma (traducciones `since_short`/`since_medium`/`since_long` y +/// `until_short`/`until_medium`/`until_long` en `src/locale/{lang}/datetime.ftl`). En español, por +/// ejemplo, el artículo "el" sólo aparece delante de un día concreto ("desde el 3 de junio de +/// 2026", pero "desde junio de 2026" no lleva "el"), así que forma parte de la plantilla de `Long`, +/// no de un prefijo genérico compartido. +/// +/// No tiene variante `Custom` porque no hay un patrón `strftime` equivalente para "sólo el mes" o +/// "mes y año" con el nombre del mes traducido (mismo motivo que en [`RelativeFormat`]). +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// # fn show(cx: &Context, start: NaiveDate, end: NaiveDate) { +/// let since = cx.format_since(start, DatePrecision::Medium); // p. ej. "desde junio de 2026" +/// let until = cx.format_until(end, DatePrecision::Long); // p. ej. "hasta el 3 de junio de 2026" +/// # } +/// ``` +/// +/// [`Contextual::format_since()`]: crate::core::component::Contextual::format_since +/// [`Contextual::format_until()`]: crate::core::component::Contextual::format_until +/// [`DateFormat`]: super::DateFormat +/// [`RelativeFormat`]: super::RelativeFormat +#[derive(Clone, Copy, Debug)] +pub enum DatePrecision { + /// Sólo el mes. + Short, + /// Mes y año. + Medium, + /// Día, mes y año. + Long, +} + +impl DatePrecision { + // Formatea `date` para `language` contra el trío de claves `since_short`/`_medium`/`_long`. + pub(crate) fn apply_since(&self, date: NaiveDate, language: &impl LangId) -> String { + self.apply( + date, + ["since_short", "since_medium", "since_long"], + language, + ) + } + + // Formatea `date` para `language` contra el trío de claves `until_short`/`_medium`/`_long`. + pub(crate) fn apply_until(&self, date: NaiveDate, language: &impl LangId) -> String { + self.apply( + date, + ["until_short", "until_medium", "until_long"], + language, + ) + } + + // `apply_since()`/`apply_until()` comparten exactamente los mismos argumentos por nivel de + // precisión; sólo cambia el trío de claves Fluent que se resuelve. + fn apply(&self, date: NaiveDate, keys: [&'static str; 3], language: &impl LangId) -> String { + let [short, medium, long] = keys; + let month = || month_name(date.month(), language); + match self { + DatePrecision::Short => Lc::l(short) + .with_arg("month", month()) + .lookup(language) + .unwrap_or_else(month), + DatePrecision::Medium => Lc::l(medium) + .with_arg("month", month()) + .with_arg("year", date.year().to_string()) + .lookup(language) + .unwrap_or_else(|| format!("{} {}", month(), date.year())), + DatePrecision::Long => Lc::l(long) + .with_arg("day", date.day().to_string()) + .with_arg("month", month()) + .with_arg("year", date.year().to_string()) + .lookup(language) + .unwrap_or_else(|| format!("{} {} {}", date.day(), month(), date.year())), + } + } +} diff --git a/src/datetime/relative.rs b/src/datetime/relative.rs new file mode 100644 index 00000000..44d1fb61 --- /dev/null +++ b/src/datetime/relative.rs @@ -0,0 +1,230 @@ +use crate::locale::{LangId, Lc}; + +use chrono::{Months, NaiveDate}; + +/// Tipo de formato a aplicar al mostrar una fecha **en relación al momento actual**. +/// +/// Por ejemplo, "hace 3 años" o "dentro de 5 días", en vez de la fecha absoluta. +/// +/// El desglose es un cálculo exacto de calendario en años, meses y días (no una duración +/// redondeada tipo "hace unos 3 años"): `Short` muestra el componente más significativo, `Medium` +/// hasta dos y `Long` hasta tres. Los componentes en cero se omiten antes de aplicar el límite, no +/// después: con una diferencia de "2 años, 0 meses y 3 días", tanto `Medium` como `Long` muestran +/// "2 años y 3 días" (sin un componente de meses que mostrar, `Long` no tiene un tercero disponible +/// y coincide con `Medium`). +/// +/// La unidad mínima es el día: la comparación se hace entre fechas civiles (la de `dt`, convertida +/// a la zona horaria efectiva, y la de "ahora"), no entre instantes. Un valor de hace unos minutos o +/// dentro de unos minutos, si cae en la misma fecha civil que "ahora", se muestra como "hoy" +/// (clave Fluent `relative_today`), sin necesidad de ningún umbral de minutos u horas. +/// +/// El texto completo ("hace 3 años y 2 meses" / "3 years and 2 months ago") se compone enteramente +/// a partir de claves Fluent del idioma efectivo (`src/locale/{lang}/datetime.ftl`): el orden y la +/// posición de "hace"/"dentro de" (prefijo en español, "ago" como sufijo en inglés), la unión de +/// varios componentes y el plural de cada unidad son propiedades del idioma, no de este tipo. +/// +/// # Ejemplo +/// +/// ```rust,no_run +/// # use pagetop::prelude::*; +/// # fn show(cx: &Context, dt: DateTime) { +/// let short = cx.format_relative(dt, RelativeFormat::Short); // p. ej. "hace 3 años" +/// let long = cx.format_relative(dt, RelativeFormat::Long); // p. ej. "hace 3 años, 2 meses y 10 días" +/// # } +/// ``` +#[derive(Clone, Copy, Debug)] +pub enum RelativeFormat { + /// El componente más significativo (años, meses o días). + Short, + /// Hasta dos componentes. + Medium, + /// Hasta tres componentes (años, meses y días). + Long, +} + +impl RelativeFormat { + // Compara `target` con `today` (ambas fechas civiles ya en la zona horaria efectiva) y compone + // el texto relativo para `language`. + pub(crate) fn apply( + &self, + target: NaiveDate, + today: NaiveDate, + language: &impl LangId, + ) -> String { + if target == today { + return Lc::l("relative_today") + .lookup(language) + .unwrap_or_else(|| "today".to_owned()); + } + + let is_future = target > today; + let (early, late) = if is_future { + (today, target) + } else { + (target, today) + }; + let (years, months, days) = breakdown(early, late); + + let mut parts = Vec::with_capacity(3); + if years > 0 { + parts.push(unit("relative_years", years, language)); + } + if months > 0 { + parts.push(unit("relative_months", months, language)); + } + if days > 0 { + parts.push(unit("relative_days", days, language)); + } + let depth = match self { + RelativeFormat::Short => 1, + RelativeFormat::Medium => 2, + RelativeFormat::Long => 3, + }; + parts.truncate(depth); + + let value = join(&parts, language); + let key = if is_future { + "relative_future" + } else { + "relative_past" + }; + Lc::l(key) + .with_arg("value", value.clone()) + .lookup(language) + .unwrap_or(value) + } +} + +// **< HELPERS >************************************************************************************ + +// Descompone el intervalo `[early, late]` (`early <= late`, garantizado por el llamador) en años, +// meses y días completos de calendario, no en una duración aproximada. +// +// Se apoya en `NaiveDate::checked_add_months()`. Cuando el día de partida no existe en el mes de +// destino (p. ej. 31 de enero + 1 mes, y febrero no llega a 31), ajusta al último día válido de ese +// mes (28 de febrero, o 29 si es bisiesto). +fn breakdown(early: NaiveDate, late: NaiveDate) -> (u32, u32, u32) { + let mut months = 0u32; + let mut cursor = early; + while let Some(next) = early.checked_add_months(Months::new(months + 1)) { + if next > late { + break; + } + months += 1; + cursor = next; + } + let days = (late - cursor).num_days() as u32; + (months / 12, months % 12, days) +} + +// Texto de un componente ("3 años", "1 mes"...), con selección de plural real vía `with_number()`. +fn unit(key: &'static str, n: u32, language: &impl LangId) -> String { + Lc::l(key) + .with_number("n", n) + .lookup(language) + .unwrap_or_else(|| n.to_string()) +} + +// Une de 1 a 3 componentes ya traducidos en una frase natural del idioma ("3 años, 2 meses y 10 +// días"). El punto de unión (con o sin coma, con o sin conjunción final) es una propiedad del +// idioma, igual que `datetime_join`. +fn join(parts: &[String], language: &impl LangId) -> String { + match parts { + [] => String::new(), + [a] => a.clone(), + [a, b] => Lc::l("relative_join_two") + .with_arg("a", a.clone()) + .with_arg("b", b.clone()) + .lookup(language) + .unwrap_or_else(|| format!("{a} {b}")), + [a, b, c, ..] => Lc::l("relative_join_three") + .with_arg("a", a.clone()) + .with_arg("b", b.clone()) + .with_arg("c", c.clone()) + .lookup(language) + .unwrap_or_else(|| format!("{a} {b} {c}")), + } +} + +// `breakdown()` y `apply()` dependen de fechas exactas de calendario (bisiestos, fin de mes, +// componentes en cero intercalados...), no de la hora actual. Se prueban aquí, con fechas fijas, en +// vez de en `tests/datetime.rs` (que sólo ve la API pública `Contextual::format_relative()`, +// siempre relativa a `Utc::now()` real y no serviría para fijar estos casos concretos). +#[cfg(test)] +mod tests { + use super::*; + use crate::locale::Locale; + + fn date(year: i32, month: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, day).unwrap() + } + + #[test] + fn breakdown_splits_years_months_days() { + assert_eq!(breakdown(date(2020, 3, 5), date(2023, 5, 15)), (3, 2, 10)); + } + + #[test] + fn breakdown_skips_to_days_when_months_are_zero() { + assert_eq!(breakdown(date(2022, 6, 12), date(2024, 6, 15)), (2, 0, 3)); + } + + #[test] + fn breakdown_clamps_month_end_across_a_leap_year() { + // 31 ene 2024 (bisiesto) + 1 mes = 29 feb (checked_add_months hace el *clamping*); de ahí + // a 1 mar queda 1 día más: 1 mes y 1 día, no "1 mes y -1 día" ni "2 meses". + assert_eq!(breakdown(date(2024, 1, 31), date(2024, 3, 1)), (0, 1, 1)); + } + + #[test] + fn apply_same_civil_date_is_today() { + let today = date(2026, 6, 15); + let en = Locale::resolve("en-US"); + assert_eq!(RelativeFormat::Short.apply(today, today, &en), "today"); + } + + #[test] + fn apply_truncates_after_filtering_zero_components() { + let today = date(2026, 6, 15); + let target = date(2024, 6, 12); // 2 años, 0 meses, 3 días. + let en = Locale::resolve("en-US"); + + assert_eq!( + RelativeFormat::Short.apply(target, today, &en), + "2 years ago" + ); + assert_eq!( + RelativeFormat::Medium.apply(target, today, &en), + "2 years and 3 days ago" + ); + // Sin un tercer componente disponible (meses = 0), `Long` coincide con `Medium`. + assert_eq!( + RelativeFormat::Long.apply(target, today, &en), + "2 years and 3 days ago" + ); + } + + #[test] + fn apply_shows_three_components_in_spanish() { + let today = date(2026, 6, 15); + let target = date(2023, 4, 5); // 3 años, 2 meses, 10 días. + let es = Locale::resolve("es-ES"); + + assert_eq!( + RelativeFormat::Long.apply(target, today, &es), + "hace 3 años, 2 meses y 10 días" + ); + } + + #[test] + fn apply_handles_future_dates_and_singular_units() { + let today = date(2026, 6, 15); + let target = date(2026, 6, 16); // dentro de 1 día. + let es = Locale::resolve("es-ES"); + + assert_eq!( + RelativeFormat::Short.apply(target, today, &es), + "dentro de 1 día" + ); + } +} diff --git a/src/global.rs b/src/global.rs index 3d4dd8ef..e8f7286e 100644 --- a/src/global.rs +++ b/src/global.rs @@ -23,6 +23,7 @@ include_config!(SETTINGS: Settings => [ "app.name" => "PageTop App", "app.theme" => "Basic", "app.lang_negotiation" => "Full", + "app.timezone" => "UTC", "app.startup_banner" => "Slant", // [dev] @@ -76,6 +77,16 @@ pub struct App { /// Define las fuentes que intervienen en la negociación del idioma para el renderizado de los /// documentos y la generación de URLs. Ver [`LangNegotiation`] para los modos disponibles. pub lang_negotiation: LangNegotiation, + /// Zona horaria predeterminada de la aplicación (p. ej. *"UTC"* o *"Europe/Madrid"*). + /// + /// Se usa como zona horaria efectiva para las peticiones de usuarios anónimos o sin zona + /// horaria propia. Ver [`Timezone`] y [`CurrentUser::timezone()`]. + /// + /// Si es `None` o no contiene un valor válido, se aplica `UTC`. + /// + /// [`Timezone`]: crate::datetime::Timezone + /// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone + pub timezone: Option, /// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o /// *"Starwars"*. pub startup_banner: StartupBanner, diff --git a/src/locale/definition.rs b/src/locale/definition.rs index 89108d5a..e9014239 100644 --- a/src/locale/definition.rs +++ b/src/locale/definition.rs @@ -156,7 +156,7 @@ impl Locale { if let Some(langid) = *CONFIG_LANGID { trace::debug!("Default language \"{langid}\" (from config: \"{raw}\")"); } else { - trace::debug!( + trace::warn!( "Default language \"{}\" (fallback, invalid config: \"{raw}\")", *FALLBACK_LANGID ); diff --git a/src/locale/en-US/datetime.ftl b/src/locale/en-US/datetime.ftl new file mode 100644 index 00000000..4e7710a8 --- /dev/null +++ b/src/locale/en-US/datetime.ftl @@ -0,0 +1,50 @@ +# Date formats. +date_format_short = { $month }/{ $day }/{ $year_short } +date_format_medium = { $month }/{ $day }/{ $year } +date_format_long = { $month } { $day }, { $year } + +# Time formats. +time_format_short = { $hour }:{ $minute } +time_format_long = { $hour }:{ $minute }:{ $second } +datetime_join = { $date }, { $time } + +# Relative dates. +relative_today = today +relative_years = { $n -> + [one] { $n } year + *[other] { $n } years +} +relative_months = { $n -> + [one] { $n } month + *[other] { $n } months +} +relative_days = { $n -> + [one] { $n } day + *[other] { $n } days +} +relative_join_two = { $a } and { $b } +relative_join_three = { $a }, { $b }, and { $c } +relative_past = { $value } ago +relative_future = in { $value } + +# Start/end date precision. +since_short = since { $month } +since_medium = since { $month } { $year } +since_long = since { $month } { $day }, { $year } +until_short = until { $month } +until_medium = until { $month } { $year } +until_long = until { $month } { $day }, { $year } + +# Month names. +month_01 = January +month_02 = February +month_03 = March +month_04 = April +month_05 = May +month_06 = June +month_07 = July +month_08 = August +month_09 = September +month_10 = October +month_11 = November +month_12 = December diff --git a/src/locale/es-ES/datetime.ftl b/src/locale/es-ES/datetime.ftl new file mode 100644 index 00000000..76015715 --- /dev/null +++ b/src/locale/es-ES/datetime.ftl @@ -0,0 +1,50 @@ +# Date formats. +date_format_short = { $day }/{ $month }/{ $year_short } +date_format_medium = { $day }/{ $month }/{ $year } +date_format_long = { $day } de { $month } de { $year } + +# Time formats. +time_format_short = { $hour }:{ $minute } +time_format_long = { $hour }:{ $minute }:{ $second } +datetime_join = { $date }, { $time } + +# Relative dates. +relative_today = hoy +relative_years = { $n -> + [one] { $n } año + *[other] { $n } años +} +relative_months = { $n -> + [one] { $n } mes + *[other] { $n } meses +} +relative_days = { $n -> + [one] { $n } día + *[other] { $n } días +} +relative_join_two = { $a } y { $b } +relative_join_three = { $a }, { $b } y { $c } +relative_past = hace { $value } +relative_future = dentro de { $value } + +# Start/end date precision. +since_short = desde { $month } +since_medium = desde { $month } de { $year } +since_long = desde el { $day } de { $month } de { $year } +until_short = hasta { $month } +until_medium = hasta { $month } de { $year } +until_long = hasta el { $day } de { $month } de { $year } + +# Month names. +month_01 = enero +month_02 = febrero +month_03 = marzo +month_04 = abril +month_05 = mayo +month_06 = junio +month_07 = julio +month_08 = agosto +month_09 = septiembre +month_10 = octubre +month_11 = noviembre +month_12 = diciembre diff --git a/src/response/page.rs b/src/response/page.rs index 8685e13b..126226ee 100644 --- a/src/response/page.rs +++ b/src/response/page.rs @@ -24,6 +24,7 @@ use crate::base::component::layout; use crate::core::component::{AssetsOp, ChildOp, ComponentRender}; use crate::core::component::{Context, ContextError, Contextual}; use crate::core::theme::{CoreRegions, RegionName, RegionRef, TemplateRef, ThemeRef}; +use crate::datetime::Tz; use crate::html::{Assets, Favicon, JavaScript, ResponsiveStyles, StyleSheet}; use crate::html::{DOCTYPE, Markup, html}; use crate::html::{Props, PropsOp}; @@ -280,6 +281,11 @@ impl Contextual for Page { self } + fn with_timezone(mut self, tz: Tz) -> Self { + self.context.alter_timezone(tz); + self + } + fn with_template(mut self, template: TemplateRef) -> Self { self.context.alter_template(template); self @@ -326,6 +332,10 @@ impl Contextual for Page { self.context.current_user() } + fn timezone(&self) -> Tz { + self.context.timezone() + } + fn template(&self) -> TemplateRef { self.context.template() } diff --git a/tests/auth.rs b/tests/auth.rs index 485cb68c..f07849fb 100644 --- a/tests/auth.rs +++ b/tests/auth.rs @@ -13,14 +13,27 @@ async fn anonymous_reports_itself_correctly() { #[pagetop::test] async fn authenticated_reports_itself_correctly() { + let madrid: Tz = "Europe/Madrid".parse().unwrap(); let user = CurrentUser::Authenticated { id: 42, display_name: "Alice".to_owned(), + timezone: Some(madrid), }; assert!(!user.is_anonymous()); assert!(user.is_authenticated()); assert_eq!(user.id(), Some(42)); assert_eq!(user.display_name(), Some("Alice")); + assert_eq!(user.timezone(), madrid); +} + +#[pagetop::test] +async fn authenticated_falls_back_to_default_timezone_when_none() { + let user = CurrentUser::Authenticated { + id: 42, + display_name: "Alice".to_owned(), + timezone: None, + }; + assert_eq!(user.timezone(), Timezone::default_tz()); } // **< Context::current_user() >******************************************************************** @@ -37,6 +50,7 @@ async fn current_user_propagates_from_request_extensions() { .with_extension(CurrentUser::Authenticated { id: 7, display_name: "Bob".to_owned(), + timezone: None, }) .to_http_request(); let cx = Context::new(req); @@ -60,6 +74,7 @@ async fn request_extension_returns_injected_value() { .with_extension(CurrentUser::Authenticated { id: 1, display_name: "Carol".to_owned(), + timezone: None, }) .to_http_request(); @@ -79,6 +94,7 @@ async fn page_new_propagates_current_user_from_request_extensions() { .with_extension(CurrentUser::Authenticated { id: 5, display_name: "Dave".to_owned(), + timezone: None, }) .to_http_request(); let page = Page::new(req); diff --git a/tests/datetime.rs b/tests/datetime.rs new file mode 100644 index 00000000..3078cec9 --- /dev/null +++ b/tests/datetime.rs @@ -0,0 +1,325 @@ +use pagetop::prelude::*; + +async fn setup() { + Application::new().await; +} + +// **< Context::timezone() >************************************************************************ + +#[pagetop::test] +async fn resolve_uses_anonymous_fallback_to_default() { + setup().await; + + let cx = Context::default(); + assert_eq!(cx.timezone(), Timezone::default_tz()); +} + +// `CurrentUser::timezone()` resolves the user's own timezone when it's `Some`; this only checks +// that the value, when present, reaches `Context` intact. The `None` case (no personal timezone, +// falls back to the application's) is already covered by `tests/auth.rs`. +#[pagetop::test] +async fn resolve_uses_the_timezone_already_resolved_by_the_authenticated_user() { + setup().await; + + let madrid: Tz = "Europe/Madrid".parse().unwrap(); + let req = web::test::TestRequest::get() + .with_extension(CurrentUser::Authenticated { + id: 1, + display_name: "Alice".to_owned(), + timezone: Some(madrid), + }) + .to_http_request(); + let cx = Context::new(req); + assert_eq!(cx.timezone(), madrid); +} + +// **< Context::with_timezone() >******************************************************************* + +#[pagetop::test] +async fn with_timezone_forces_the_effective_timezone() { + setup().await; + + let new_york: Tz = "America/New_York".parse().unwrap(); + let cx = Context::default().with_timezone(new_york); + assert_eq!(cx.timezone(), new_york); +} + +// **< Context::format_datetime() >***************************************************************** + +// Without a request, the effective language is the fallback ("en-US"): month/day order +// (American convention), defined in `src/locale/en-US/datetime.ftl`. `format_datetime()` +// combines an independent `DateFormat` and `TimeFormat` with the `datetime_join` separator. +#[pagetop::test] +async fn format_datetime_uses_en_us_month_first_order_by_default() { + setup().await; + + // 2026-06-15T10:30:00Z -> 12:30 in Europe/Madrid (CEST, UTC+2). + let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 0).unwrap(); + let cx = Context::default().with_timezone("Europe/Madrid".parse().unwrap()); + + assert_eq!( + cx.format_datetime(dt, DateFormat::Short, TimeFormat::Short), + "06/15/26, 12:30" + ); + assert_eq!( + cx.format_datetime(dt, DateFormat::Medium, TimeFormat::Short), + "06/15/2026, 12:30" + ); + assert_eq!( + cx.format_datetime(dt, DateFormat::Long, TimeFormat::Long), + "June 15, 2026, 12:30:00" + ); + // Different formats for date and time: long date with short time. + assert_eq!( + cx.format_datetime(dt, DateFormat::Long, TimeFormat::Short), + "June 15, 2026, 12:30" + ); +} + +// `format_iso_datetime()` doesn't depend on the language (it doesn't go through Fluent) but does +// convert to the active timezone, with its offset: same result under any language. +#[pagetop::test] +async fn format_iso_datetime_is_the_same_in_every_language() { + setup().await; + + let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 0).unwrap(); + let cx = Context::default().with_timezone("Europe/Madrid".parse().unwrap()); + + assert_eq!(cx.format_iso_datetime(dt), "2026-06-15T12:30:00+02:00"); + assert_eq!( + cx.with_langid(&Locale::resolve("es-ES")) + .format_iso_datetime(dt), + "2026-06-15T12:30:00+02:00" + ); +} + +// The day/month/year order depends on the effective language, not a global constant: in es-ES +// the pattern is day/month (Spanish convention), defined in `src/locale/es-ES/datetime.ftl`. +#[pagetop::test] +async fn format_datetime_uses_es_es_day_first_order() { + setup().await; + + let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 0).unwrap(); + let cx = Context::default() + .with_timezone("Europe/Madrid".parse().unwrap()) + .with_langid(&Locale::resolve("es-ES")); + + assert_eq!( + cx.format_datetime(dt, DateFormat::Short, TimeFormat::Short), + "15/06/26, 12:30" + ); + assert_eq!( + cx.format_datetime(dt, DateFormat::Medium, TimeFormat::Short), + "15/06/2026, 12:30" + ); + assert_eq!( + cx.format_datetime(dt, DateFormat::Long, TimeFormat::Long), + "15 de junio de 2026, 12:30:00" + ); +} + +// **< Context::format_time() >********************************************************************* + +// `format_time()` only shows the time, already converted to the active timezone. +#[pagetop::test] +async fn format_time_converts_to_the_effective_timezone() { + setup().await; + + let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 45).unwrap(); + let cx = Context::default().with_timezone("Europe/Madrid".parse().unwrap()); + + assert_eq!(cx.format_time(dt, TimeFormat::Short), "12:30"); + assert_eq!(cx.format_time(dt, TimeFormat::Long), "12:30:45"); + assert_eq!( + cx.format_time(dt, TimeFormat::Custom("%H:%M:%S")), + "12:30:45" + ); +} + +// **< Context::format_date() >********************************************************************* + +// `format_date()` doesn't convert timezone (it doesn't apply to a date without a time); the +// result is the same regardless of the context's effective timezone. Under the fallback +// language ("en-US"), month/day order. +#[pagetop::test] +async fn format_date_does_not_depend_on_timezone() { + setup().await; + + let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap(); + let cx = Context::default().with_timezone("Pacific/Auckland".parse().unwrap()); + + assert_eq!(cx.format_date(date, DateFormat::Short), "06/15/26"); + assert_eq!(cx.format_date(date, DateFormat::Medium), "06/15/2026"); + assert_eq!(cx.format_date(date, DateFormat::Long), "June 15, 2026"); + assert_eq!( + cx.format_date(date, DateFormat::Custom("%d-%m-%Y")), + "15-06-2026" + ); +} + +// Same day/month/year order as `format_datetime`, dependent on the effective language. +#[pagetop::test] +async fn format_date_uses_es_es_day_first_order() { + setup().await; + + let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap(); + let cx = Context::default().with_langid(&Locale::resolve("es-ES")); + + assert_eq!(cx.format_date(date, DateFormat::Short), "15/06/26"); + assert_eq!(cx.format_date(date, DateFormat::Medium), "15/06/2026"); + assert_eq!( + cx.format_date(date, DateFormat::Long), + "15 de junio de 2026" + ); +} + +// `Iso` doesn't depend on the language (it doesn't go through Fluent): same result under any +// language. +#[pagetop::test] +async fn format_date_iso_is_the_same_in_every_language() { + setup().await; + + let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap(); + let cx = Context::default(); + + assert_eq!(cx.format_date(date, DateFormat::Iso), "2026-06-15"); + assert_eq!( + cx.with_langid(&Locale::resolve("es-ES")) + .format_date(date, DateFormat::Iso), + "2026-06-15" + ); +} + +// **< Context::format_relative() >***************************************************************** +// +// The exact calendar breakdown (leap years, zero components...) is tested with fixed dates in +// `src/datetime/relative.rs` (`RelativeFormat::apply()` is `pub(crate)`, not visible from here). +// These tests verify the connection with the public API: timezone conversion, resolution via a +// real `Utc::now()`, and day offsets -- always < 28, so the result doesn't depend on which real +// month they run in (a month offset could cross a different month-end depending on the real day +// of execution). + +// A value from a few minutes ago or a few minutes from now falls on the same civil date as "now": +// "today", regardless of the language. +#[pagetop::test] +async fn format_relative_collapses_to_today_within_the_same_civil_date() { + setup().await; + + let cx = Context::default(); + let just_before = Utc::now() - Duration::minutes(3); + let just_after = Utc::now() + Duration::minutes(3); + + assert_eq!( + cx.format_relative(just_before, RelativeFormat::Short), + "today" + ); + assert_eq!( + cx.with_langid(&Locale::resolve("es-ES")) + .format_relative(just_after, RelativeFormat::Short), + "hoy" + ); +} + +// A small day offset (below any month) always gives a single days component, both in the past +// and in the future. +#[pagetop::test] +async fn format_relative_uses_day_only_offsets() { + setup().await; + + let cx = Context::default(); + let three_days_ago = Utc::now() - Duration::days(3); + let in_five_days = Utc::now() + Duration::days(5); + + assert_eq!( + cx.format_relative(three_days_ago, RelativeFormat::Short), + "3 days ago" + ); + assert_eq!( + cx.with_langid(&Locale::resolve("es-ES")) + .format_relative(in_five_days, RelativeFormat::Short), + "dentro de 5 días" + ); +} + +// Fluent's plural selector distinguishes singular (1) from plural (everything else), via +// `Lc::with_number()`. +#[pagetop::test] +async fn format_relative_pluralizes_the_unit() { + setup().await; + + let cx = Context::default(); + let yesterday = Utc::now() - Duration::days(1); + let three_days_ago = Utc::now() - Duration::days(3); + + assert_eq!( + cx.format_relative(yesterday, RelativeFormat::Short), + "1 day ago" + ); + assert_eq!( + cx.format_relative(three_days_ago, RelativeFormat::Short), + "3 days ago" + ); +} + +// **< Context::format_since() / format_until() >*************************************************** + +// The translated month and, in `Long`, the article "el" before the day (Spanish only) are part +// of each level's own template, not a shared generic wrapper. +#[pagetop::test] +async fn format_since_reveals_increasing_precision() { + setup().await; + + let date = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap(); + let en = Context::default(); + let es = Context::default().with_langid(&Locale::resolve("es-ES")); + + assert_eq!(en.format_since(date, DatePrecision::Short), "since June"); + assert_eq!( + en.format_since(date, DatePrecision::Medium), + "since June 2026" + ); + assert_eq!( + en.format_since(date, DatePrecision::Long), + "since June 3, 2026" + ); + + assert_eq!(es.format_since(date, DatePrecision::Short), "desde junio"); + assert_eq!( + es.format_since(date, DatePrecision::Medium), + "desde junio de 2026" + ); + assert_eq!( + es.format_since(date, DatePrecision::Long), + "desde el 3 de junio de 2026" + ); +} + +// Same mechanism as `format_since()`, symmetric for an end date. +#[pagetop::test] +async fn format_until_is_symmetric_to_format_since() { + setup().await; + + let date = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap(); + let en = Context::default(); + let es = Context::default().with_langid(&Locale::resolve("es-ES")); + + assert_eq!(en.format_until(date, DatePrecision::Short), "until June"); + assert_eq!( + en.format_until(date, DatePrecision::Medium), + "until June 2026" + ); + assert_eq!( + en.format_until(date, DatePrecision::Long), + "until June 3, 2026" + ); + + assert_eq!(es.format_until(date, DatePrecision::Short), "hasta junio"); + assert_eq!( + es.format_until(date, DatePrecision::Medium), + "hasta junio de 2026" + ); + assert_eq!( + es.format_until(date, DatePrecision::Long), + "hasta el 3 de junio de 2026" + ); +}