✨ (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

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