✨ (datetime): Añade zona horaria y formato de fechas

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().
This commit is contained in:
Manuel Cillero 2026-10-01 21:53:57 +02:00
parent 3df73f0551
commit 90e4f85bd4
21 changed files with 1683 additions and 21 deletions

37
Cargo.lock generated
View file

@ -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"

View file

@ -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

270
examples/intro-datetime.rs Normal file
View file

@ -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<Markup, ErrorPage> {
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 &times {
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<Utc> {
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
}

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -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<Tz>,
},
}
@ -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<HttpRequest>` 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(())

View file

@ -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<HttpRequest>, // 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>, // Favicon, si se ha definido.
@ -126,10 +128,12 @@ impl Context {
fn base(request: Option<HttpRequest>, 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<HttpRequest>) -> 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
}

View file

@ -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<HttpRequest>) -> 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<Utc>) {
/// let text = cx.format_datetime(dt, DateFormat::Medium, TimeFormat::Short);
/// # }
/// ```
fn format_datetime(
&self,
dt: DateTime<Utc>,
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<Utc>`] 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<Utc>` 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<Utc>` 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<Utc>`) 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<Utc>) {
/// // `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<Utc>`]: 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<Utc>) {
/// let text = cx.format_time(dt, TimeFormat::Short);
/// # }
/// ```
fn format_time(&self, dt: DateTime<Utc>, 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<Utc>) {
/// let text = cx.format_iso_datetime(dt);
/// # }
/// ```
fn format_iso_datetime(&self, dt: DateTime<Utc>) -> 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<Utc>) {
/// let text = cx.format_relative(dt, RelativeFormat::Medium);
/// # }
/// ```
fn format_relative(&self, dt: DateTime<Utc>, 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

View file

@ -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;

View file

@ -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<Option<Tz>> = 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<Tz> {
*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)
}
}

150
src/datetime/format.rs Normal file
View file

@ -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<Utc>) {
/// 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(),
}
}
}

89
src/datetime/precision.rs Normal file
View file

@ -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())),
}
}
}

230
src/datetime/relative.rs Normal file
View file

@ -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<Utc>) {
/// 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"
);
}
}

View file

@ -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<String>,
/// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o
/// *"Starwars"*.
pub startup_banner: StartupBanner,

View file

@ -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
);

View file

@ -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

View file

@ -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

View file

@ -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()
}

View file

@ -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);

325
tests/datetime.rs Normal file
View file

@ -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"
);
}