From 90e4f85bd4acabf787805e974fddc8bbf6a366e4 Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Thu, 1 Oct 2026 21:53:57 +0200 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20(datetime):=20A=C3=B1ade=20zona?= =?UTF-8?q?=20horaria=20y=20formato=20de=20fechas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Añade formatos `DateFormat`, `TimeFormat`, `RelativeFormat` y `DatePrecision`, con estilos traducidos vía Fluent (orden día/mes/año, nombre del mes, plurales). La zona horaria efectiva se resuelve por petición, la propia del usuario autenticado si la tiene, si no la configurada para la aplicación (`app.timezone`), o UTC en su defecto- y se expone en `Contextual` junto a format_date(), format_time(), format_datetime(), format_iso_datetime(), format_relative(), format_since() y format_until(). --- Cargo.lock | 37 ++- Cargo.toml | 2 + examples/intro-datetime.rs | 270 +++++++++++++++++++ examples/locale/en-US/intro-datetime.ftl | 21 ++ examples/locale/es-ES/intro-datetime.ftl | 21 ++ src/app.rs | 4 + src/auth.rs | 23 +- src/core/component/context.rs | 20 +- src/core/component/context/contextual.rs | 226 +++++++++++++++- src/datetime.rs | 75 +++++- src/datetime/definition.rs | 72 +++++ src/datetime/format.rs | 150 +++++++++++ src/datetime/precision.rs | 89 +++++++ src/datetime/relative.rs | 230 ++++++++++++++++ src/global.rs | 11 + src/locale/definition.rs | 2 +- src/locale/en-US/datetime.ftl | 50 ++++ src/locale/es-ES/datetime.ftl | 50 ++++ src/response/page.rs | 10 + tests/auth.rs | 16 ++ tests/datetime.rs | 325 +++++++++++++++++++++++ 21 files changed, 1683 insertions(+), 21 deletions(-) create mode 100644 examples/intro-datetime.rs create mode 100644 examples/locale/en-US/intro-datetime.ftl create mode 100644 examples/locale/es-ES/intro-datetime.ftl create mode 100644 src/datetime/definition.rs create mode 100644 src/datetime/format.rs create mode 100644 src/datetime/precision.rs create mode 100644 src/datetime/relative.rs create mode 100644 src/locale/en-US/datetime.ftl create mode 100644 src/locale/es-ES/datetime.ftl create mode 100644 tests/datetime.rs 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" + ); +} From 4cd75af8d2531e63fda33fab37f0785931a0219c Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Fri, 2 Oct 2026 06:27:36 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20(seaorm):=20Crea=20timestamp?= =?UTF-8?q?()=20con=20zona=20horaria?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Las entidades guardan los instantes como DateTimeUtc, que PostgreSQL sólo lee desde TIMESTAMPTZ. timestamp() y timestamp_null() pasan a crear columnas con zona horaria; en MySQL y SQLite no cambia nada en la práctica. --- extensions/pagetop-seaorm/src/migration.rs | 2 +- extensions/pagetop-seaorm/src/migration/schema.rs | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/extensions/pagetop-seaorm/src/migration.rs b/extensions/pagetop-seaorm/src/migration.rs index 0c974f06..412426aa 100644 --- a/extensions/pagetop-seaorm/src/migration.rs +++ b/extensions/pagetop-seaorm/src/migration.rs @@ -103,7 +103,7 @@ //! | `string_uniq(col)` | `VARCHAR NOT NULL UNIQUE` | //! | `integer(col)` | `INTEGER NOT NULL` | //! | `boolean(col)` | `BOOLEAN NOT NULL` | -//! | `timestamp(col)` | `TIMESTAMP NOT NULL` | +//! | `timestamp(col)` | `TIMESTAMP WITH TIME ZONE NOT NULL` | //! | `uuid(col)` | `UUID NOT NULL` | //! //! Estas son sólo las funciones más habituales. El módulo [`schema`] define la lista completa, con diff --git a/extensions/pagetop-seaorm/src/migration/schema.rs b/extensions/pagetop-seaorm/src/migration/schema.rs index 88ce8b13..1d645c5d 100644 --- a/extensions/pagetop-seaorm/src/migration/schema.rs +++ b/extensions/pagetop-seaorm/src/migration/schema.rs @@ -329,12 +329,16 @@ pub fn interval_uniq( interval(col, fields, precision).unique_key().take() } +// Unlike the original helpers, `timestamp()` and `timestamp_null()` create time zone aware +// columns. Entities store instants as `DateTimeUtc`, which PostgreSQL can only decode from +// `TIMESTAMPTZ`; a plain `TIMESTAMP` would also store `CURRENT_TIMESTAMP` in server local time. +// Keep this deviation when syncing with upstream. pub fn timestamp(col: T) -> ColumnDef { - ColumnDef::new(col).timestamp().not_null().take() + timestamp_with_time_zone(col) } pub fn timestamp_null(col: T) -> ColumnDef { - ColumnDef::new(col).timestamp().null().take() + timestamp_with_time_zone_null(col) } pub fn timestamp_uniq(col: T) -> ColumnDef { From 04502ef81e2c9be2b3a36c09e0a78e3960a2069e Mon Sep 17 00:00:00 2001 From: Manuel Cillero Date: Fri, 2 Oct 2026 22:31:58 +0200 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20(user):=20A=C3=B1ade=20zona=20h?= =?UTF-8?q?oraria=20propia=20del=20usuario?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sustituye el campo de texto libre por un selector de zonas horarias IANA canónicas agrupadas por región, con la opción de usar la del sitio (que muestra la zona efectiva). --- .../pagetop-admin/src/entity/setting.rs | 3 +- extensions/pagetop-admin/src/settings.rs | 8 +-- extensions/pagetop-user/src/auth.rs | 8 +-- .../src/component/admin/user_form.rs | 69 ++++++++++++++++--- extensions/pagetop-user/src/demo.rs | 4 +- extensions/pagetop-user/src/entity/role.rs | 5 +- .../src/entity/role_permission.rs | 3 +- extensions/pagetop-user/src/entity/session.rs | 7 +- extensions/pagetop-user/src/entity/user.rs | 13 ++-- .../pagetop-user/src/entity/user_token.rs | 9 ++- extensions/pagetop-user/src/error.rs | 3 + extensions/pagetop-user/src/handlers/admin.rs | 1 + extensions/pagetop-user/src/handlers/auth.rs | 8 +-- .../pagetop-user/src/locale/en-US/common.ftl | 24 ++++--- .../pagetop-user/src/locale/es-ES/common.ftl | 24 ++++--- .../pagetop-user/src/service/role_admin.rs | 6 +- .../pagetop-user/src/service/user_admin.rs | 44 +++++++----- extensions/pagetop-user/src/session.rs | 9 ++- extensions/pagetop-user/src/token.rs | 4 +- src/auth.rs | 2 +- 20 files changed, 162 insertions(+), 92 deletions(-) diff --git a/extensions/pagetop-admin/src/entity/setting.rs b/extensions/pagetop-admin/src/entity/setting.rs index 95b6c4c6..8907f0fd 100644 --- a/extensions/pagetop-admin/src/entity/setting.rs +++ b/extensions/pagetop-admin/src/entity/setting.rs @@ -1,4 +1,3 @@ -use pagetop::prelude::*; use pagetop_seaorm::db::*; /// Entidad SeaORM para la tabla `settings`. @@ -9,7 +8,7 @@ pub struct Model { pub key: String, pub scope: String, pub value: String, - pub updated_at: NaiveDateTime, + pub updated_at: DateTimeUtc, pub updated_by: Option, } diff --git a/extensions/pagetop-admin/src/settings.rs b/extensions/pagetop-admin/src/settings.rs index 60f73637..d145709d 100644 --- a/extensions/pagetop-admin/src/settings.rs +++ b/extensions/pagetop-admin/src/settings.rs @@ -11,7 +11,7 @@ use serde::{Serialize, de::DeserializeOwned}; use crate::entity::setting::{ActiveModel, Column, Entity}; use crate::error::AdminError; -// **< API pública >********************************************************************************* +// **< API pública >******************************************************************************** /// Lee un valor persistido, devolviendo el `Default` del tipo si no existe. pub async fn get(key: &str) -> T { @@ -40,7 +40,7 @@ pub async fn list_scope(scope: &str) -> Vec<(String, String)> { list_scope_async(scope).await.unwrap_or_default() } -// **< Implementación asíncrona >******************************************************************** +// **< Implementación asíncrona >******************************************************************* async fn get_async(key: &str) -> Result { let model = Entity::find_by_id(key) @@ -57,7 +57,7 @@ async fn set_async( user_id: Option, ) -> Result<(), AdminError> { let value_json = serde_json::to_string(value)?; - let now = Utc::now().naive_utc(); + let now = Utc::now(); let existing = Entity::find_by_id(key).one(dbconn()).await?; if existing.is_some() { @@ -95,7 +95,7 @@ async fn list_scope_async(scope: &str) -> Result, AdminErr Ok(rows.into_iter().map(|m| (m.key, m.value)).collect()) } -// **< Tipos de esquema >**************************************************************************** +// **< Tipos de esquema >*************************************************************************** /// Tipo de campo de configuración para un [`SettingsSchema`]. #[derive(Clone, Debug)] diff --git a/extensions/pagetop-user/src/auth.rs b/extensions/pagetop-user/src/auth.rs index 67d21aa6..6d737bda 100644 --- a/extensions/pagetop-user/src/auth.rs +++ b/extensions/pagetop-user/src/auth.rs @@ -24,7 +24,7 @@ pub async fn login( plain_password: &str, remember: bool, ) -> Result { - let now = Utc::now().naive_utc(); + let now = Utc::now(); // Buscar usuario por username o email. let user_model = user::Entity::find() @@ -119,7 +119,7 @@ pub async fn register( } let hash = password::hash_password(plain_password)?; - let now = Utc::now().naive_utc(); + let now = Utc::now(); let status = if SETTINGS.require_email_verification { UserStatus::Pending } else { @@ -175,7 +175,7 @@ pub async fn assign_role(user_id: i32, role_id: i32) -> Result<(), AuthError> { async fn register_failed_login( user_model: &user::Model, - now: NaiveDateTime, + now: DateTime, ) -> Result<(), AuthError> { let new_count = user_model.failed_login_count + 1; let lock_at = if new_count >= SETTINGS.max_failed_logins { @@ -236,7 +236,7 @@ async fn do_seed() { } }; - let now = Utc::now().naive_utc(); + let now = Utc::now(); let new_admin = user::ActiveModel { id: ActiveValue::NotSet, username: Set(cfg.admin_username.clone()), diff --git a/extensions/pagetop-user/src/component/admin/user_form.rs b/extensions/pagetop-user/src/component/admin/user_form.rs index 621f5d9e..42ccd0e2 100644 --- a/extensions/pagetop-user/src/component/admin/user_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_form.rs @@ -1,5 +1,8 @@ //! Formulario de alta/edición de usuario. +use std::collections::BTreeMap; +use std::sync::LazyLock; + use pagetop::prelude::*; use crate::ADMIN_USERS_PATH; @@ -10,6 +13,32 @@ use crate::component::{PasswordConfirm, error_banner}; use super::{USER_ADMIN_FORM_ID, roles_fieldset}; +// Regiones de la base IANA que sólo contienen alias heredados (fichero `backward`), todos con una +// zona canónica equivalente en otra región (p. ej. `US/Eastern` es `America/New_York`). +const LEGACY_REGIONS: [&str; 5] = ["Brazil", "Canada", "Chile", "Mexico", "US"]; + +// Zonas horarias IANA canónicas agrupadas por región (lo anterior a la primera `/`), ordenadas por +// región y nombre. Se descartan los alias heredados: los nombres sin región (`GB`, `Japan`, +// `EST5EDT`...), los de `LEGACY_REGIONS` y los de `Etc` salvo `Etc/UTC`, cuyo grupo va al final. +static TZ_BY_REGION: LazyLock)>> = LazyLock::new(|| { + let mut regions: BTreeMap<&'static str, Vec<&'static str>> = BTreeMap::new(); + for tz in TZ_VARIANTS.iter() { + let name = tz.name(); + let Some((region, _)) = name.split_once('/') else { + continue; + }; + if LEGACY_REGIONS.contains(®ion) || (region == "Etc" && name != "Etc/UTC") { + continue; + } + regions.entry(region).or_default().push(name); + } + for names in regions.values_mut() { + names.sort_unstable(); + } + let etc = regions.remove_entry("Etc"); + regions.into_iter().chain(etc).collect() +}); + #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub(crate) enum UserFormMode { #[default] @@ -81,17 +110,13 @@ impl Component for UserForm { .with_value(self.language()) .with_label(Lc::t("field-language", &LOCALES_USER)), ) - .with_child( - form::input::Field::text() - .with_name("timezone") - .with_value(self.timezone()) - .with_label(Lc::t("field-timezone", &LOCALES_USER)), - ); + .with_child(timezone_field(self.timezone())); if *self.mode() == UserFormMode::New { - form = form - .with_child(PasswordConfirm::new()) - .with_child(roles_fieldset(self.roles())); + form = form.with_child(PasswordConfirm::new()); + if !self.roles().is_empty() { + form = form.with_child(roles_fieldset(self.roles())); + } if *self.allow_admin_field() { form = form.with_child( @@ -181,3 +206,29 @@ impl UserForm { self } } + +// `