✨ (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:
parent
3df73f0551
commit
90e4f85bd4
21 changed files with 1683 additions and 21 deletions
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
23
src/auth.rs
23
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<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(())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
72
src/datetime/definition.rs
Normal file
72
src/datetime/definition.rs
Normal 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
150
src/datetime/format.rs
Normal 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
89
src/datetime/precision.rs
Normal 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
230
src/datetime/relative.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
50
src/locale/en-US/datetime.ftl
Normal file
50
src/locale/en-US/datetime.ftl
Normal 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
|
||||
50
src/locale/es-ES/datetime.ftl
Normal file
50
src/locale/es-ES/datetime.ftl
Normal 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
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue