Compare commits

...

4 commits

Author SHA1 Message Date
c06efbd0d1 🎨 Ajustes menores de separadores de sección 2026-10-02 22:59:26 +02:00
04502ef81e ✨ (user): Añade zona horaria propia del usuario
Sustituye el campo de texto libre por un selector de zonas horarias IANA
canónicas agrupadas por región, con la opción de usar la del sitio (que
muestra la zona efectiva).
2026-10-02 22:31:58 +02:00
4cd75af8d2 🐛 (seaorm): Crea timestamp() con zona horaria
Las entidades guardan los instantes como DateTimeUtc, que PostgreSQL
sólo lee desde TIMESTAMPTZ. timestamp() y timestamp_null() pasan a crear
columnas con zona horaria; en MySQL y SQLite no cambia nada en la
práctica.
2026-10-02 06:27:36 +02:00
90e4f85bd4 ✨ (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().
2026-10-01 21:53:57 +02:00
53 changed files with 1884 additions and 147 deletions

37
Cargo.lock generated
View file

@ -348,6 +348,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "chrono-tz"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
dependencies = [
"chrono",
"phf 0.12.1",
]
[[package]]
name = "clap"
version = "4.6.1"
@ -1029,7 +1039,7 @@ dependencies = [
"indexmap",
"lasso",
"once_cell",
"phf",
"phf 0.11.3",
"rand",
]
@ -1787,6 +1797,7 @@ dependencies = [
"async-trait",
"axum",
"chrono",
"chrono-tz",
"colored",
"config",
"figlet-rs",
@ -2026,7 +2037,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"phf_shared",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
dependencies = [
"phf_shared 0.12.1",
]
[[package]]
@ -2035,7 +2055,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"phf_shared 0.11.3",
"rand",
]
@ -2046,7 +2066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn",
@ -2061,6 +2081,15 @@ dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"

View file

@ -29,6 +29,7 @@ axum = { version = "0.8" }
base64ct = { version = "1.8", features = ["alloc"] }
change-detection = "1.2"
chrono = "0.4"
chrono-tz = "0.10"
colored = "3.1"
config = { version = "0.15", default-features = false, features = ["toml"] }
figlet-rs = "1.0"
@ -111,6 +112,7 @@ testing = []
async-trait.workspace = true
axum.workspace = true
chrono.workspace = true
chrono-tz.workspace = true
colored.workspace = true
config.workspace = true
figlet-rs.workspace = true

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

@ -0,0 +1,270 @@
use pagetop::prelude::*;
include_locales!(LOC from "examples/locale");
struct IntroDatetime;
#[async_trait]
impl Extension for IntroDatetime {
fn configure_router(&self, router: Router) -> Router {
router.route("/", web::get(intro_datetime))
}
}
async fn intro_datetime(request: HttpRequest) -> Result<Markup, ErrorPage> {
Page::new(request)
.with_child(
Intro::custom()
.with_title(Lc::n("PageTop"))
.with_slogan(Lc::t("datetime_slogan", &LOC))
.with_child(world_block())
.with_child(formats_block())
.with_child(relative_block())
.with_child(since_until_block()),
)
.render()
.await
}
// Zonas IANA de la demostración, con la clave de su etiqueta traducible.
const ZONES: [(&str, &str); 4] = [
("datetime_zone_utc", "UTC"),
("datetime_zone_madrid", "Europe/Madrid"),
("datetime_zone_mexico", "America/Mexico_City"),
("datetime_zone_tokyo", "Asia/Tokyo"),
];
// El mismo instante (`Utc::now()`) mostrado en cada zona de `ZONES`: fecha y hora combinadas
// (`DateFormat::Medium` + `TimeFormat::Short`) y su equivalente ISO 8601, con el offset propio de
// cada zona.
fn world_block() -> Block {
Block::new()
.with_title(Lc::t("datetime_block_world", &LOC))
.with_child(Html::with(|cx| {
let now = Utc::now();
let rows: Vec<(Lc, String, String)> = ZONES
.into_iter()
.map(|(label_key, zone)| {
let tz: Tz = zone.parse().expect("valid IANA timezone");
let zone_cx = Context::default().with_langid(cx).with_timezone(tz);
(
Lc::t(label_key, &LOC),
zone_cx.format_datetime(now, DateFormat::Medium, TimeFormat::Short),
zone_cx.format_iso_datetime(now),
)
})
.collect();
html! {
ul {
@for (label, combined, iso) in &rows {
li {
strong { (label.using(cx)) ": " } (combined)
" (ISO " code { (iso) } ")"
}
}
}
}
}))
}
// Combinaciones de `DateFormat`/`TimeFormat` sobre la zona horaria efectiva del visitante. Para
// añadir un nuevo formato basta con ampliar el array correspondiente.
fn formats_block() -> Block {
Block::new()
.with_title(Lc::t("datetime_block_formats", &LOC))
.with_child(Html::with(|cx| {
let now = Utc::now();
let today = now.with_timezone(&cx.timezone()).date_naive();
let dates: Vec<(&str, String)> = [
("Short", DateFormat::Short),
("Medium", DateFormat::Medium),
("Long", DateFormat::Long),
("Iso", DateFormat::Iso),
("Custom(\"%d.%m.%Y\")", DateFormat::Custom("%d.%m.%Y")),
]
.into_iter()
.map(|(label, format)| (label, cx.format_date(today, format)))
.collect();
let times: Vec<(&str, String)> = [
("Short", TimeFormat::Short),
("Long", TimeFormat::Long),
("Custom(\"%I:%M %p\")", TimeFormat::Custom("%I:%M %p")),
]
.into_iter()
.map(|(label, format)| (label, cx.format_time(now, format)))
.collect();
let combos: Vec<(&str, String)> = [
("Short + Short", DateFormat::Short, TimeFormat::Short),
("Medium + Short", DateFormat::Medium, TimeFormat::Short),
("Long + Long", DateFormat::Long, TimeFormat::Long),
("Long + Short", DateFormat::Long, TimeFormat::Short),
("Iso + Long", DateFormat::Iso, TimeFormat::Long),
]
.into_iter()
.map(|(label, date, time)| (label, cx.format_datetime(now, date, time)))
.collect();
html! {
h3 { (Lc::t("datetime_formats_date", &LOC).using(cx)) }
ul {
@for (label, value) in &dates {
li { code { (label) } ": " (value) }
}
}
h3 { (Lc::t("datetime_formats_time", &LOC).using(cx)) }
ul {
@for (label, value) in &times {
li { code { (label) } ": " (value) }
}
}
h3 { (Lc::t("datetime_formats_combos", &LOC).using(cx)) }
ul {
@for (label, value) in &combos {
li { code { (label) } ": " (value) }
}
}
p {
(Lc::t("datetime_formats_iso", &LOC).using(cx))
" " code { (cx.format_iso_datetime(now)) }
}
}
}))
}
// Casos ilustrativos de `RelativeFormat` sobre la zona horaria efectiva del visitante: hoy, un
// desplazamiento simple de días, uno con el componente de meses en cero, uno con los tres
// componentes y uno en el futuro -- siempre respecto al día actual. Añadir un caso nuevo es sólo
// ampliar `samples`. La primera columna muestra la fecha seleccionada (no una etiqueta fija),
// formateada con `DateFormat::Medium` en el idioma efectivo, para que se vea a qué fecha concreta
// corresponde cada resultado relativo.
fn relative_block() -> Block {
Block::new()
.with_title(Lc::t("datetime_block_relative", &LOC))
.with_child(Html::with(|cx| {
let today = Utc::now().date_naive();
// Desplazamientos de meses/años construidos con `checked_sub_months()` (mismo mecanismo
// que usa `RelativeFormat` por dentro), para que el resultado coincida exactamente con
// el criterio de cada caso.
let two_years_and_3_days_ago = today
.checked_sub_months(Months::new(24))
.expect("valid date")
- Duration::days(3);
let three_years_2_months_10_days_ago = today
.checked_sub_months(Months::new(38))
.expect("valid date")
- Duration::days(10);
let samples = [
today,
today - Duration::days(3),
two_years_and_3_days_ago,
three_years_2_months_10_days_ago,
today + Duration::days(5),
];
let rows: Vec<(String, String, String, String)> = samples
.into_iter()
.map(|date| {
let dt = at_noon(date);
(
cx.format_date(date, DateFormat::Medium),
cx.format_relative(dt, RelativeFormat::Short),
cx.format_relative(dt, RelativeFormat::Medium),
cx.format_relative(dt, RelativeFormat::Long),
)
})
.collect();
html! {
table style="width: 100%; border: 1px solid black;" {
thead {
tr {
th { (Lc::t("datetime_relative_col_date", &LOC).using(cx)) }
th { "Short" }
th { "Medium" }
th { "Long" }
}
}
tbody {
@for (date, short, medium, long) in &rows {
tr {
td { (date) }
td { (short) }
td { (medium) }
td { (long) }
}
}
}
}
}
}))
}
// Niveles de `DatePrecision` para `format_since()`/`format_until()`. Añadir un nivel nuevo es sólo
// ampliar este array.
const PRECISIONS: [(&str, DatePrecision); 3] = [
("Short", DatePrecision::Short),
("Medium", DatePrecision::Medium),
("Long", DatePrecision::Long),
];
// `format_since()`/`format_until()` sobre una única fecha de ejemplo: a diferencia de `DateFormat`,
// lo que cambia entre niveles no es el estilo, sino la propia precisión revelada (sólo el mes, mes
// y año, o fecha completa).
fn since_until_block() -> Block {
Block::new()
.with_title(Lc::t("datetime_block_since_until", &LOC))
.with_child(Html::with(|cx| {
let date = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap();
let rows: Vec<(&str, String, String)> = PRECISIONS
.into_iter()
.map(|(label, precision)| {
(
label,
cx.format_since(date, precision),
cx.format_until(date, precision),
)
})
.collect();
html! {
table style="width: 100%; border: 1px solid black;" {
thead {
tr {
th { (Lc::t("datetime_since_until_col_precision", &LOC).using(cx)) }
th { (Lc::t("datetime_since_until_col_since", &LOC).using(cx)) }
th { (Lc::t("datetime_since_until_col_until", &LOC).using(cx)) }
}
}
tbody {
@for (label, since, until) in &rows {
tr {
td { (*label) }
td { (since) }
td { (until) }
}
}
}
}
}
}))
}
// Mediodía, para evitar que el redondeo horario de la conversión de zona horaria empuje la fecha
// civil resultante al día anterior o siguiente en zonas con un offset amplio.
fn at_noon(date: NaiveDate) -> DateTime<Utc> {
date.and_hms_opt(12, 0, 0)
.expect("noon is always a valid time")
.and_utc()
}
#[pagetop::main]
async fn main() -> std::io::Result<()> {
Application::prepare(&IntroDatetime).await.run().await
}

View file

@ -0,0 +1,21 @@
datetime_slogan = Timezones and date/time formats
datetime_block_world = The same instant, around the world
datetime_zone_utc = UTC
datetime_zone_madrid = Madrid (Europe/Madrid)
datetime_zone_mexico = Mexico City (America/Mexico_City)
datetime_zone_tokyo = Tokyo (Asia/Tokyo)
datetime_block_formats = DateFormat / TimeFormat combinations
datetime_formats_date = DateFormat (date only)
datetime_formats_time = TimeFormat (time only)
datetime_formats_combos = format_datetime() combinations (date + time)
datetime_formats_iso = format_iso_datetime():
datetime_block_relative = RelativeFormat
datetime_relative_col_date = Date
datetime_block_since_until = DatePrecision (format_since / format_until)
datetime_since_until_col_precision = Precision
datetime_since_until_col_since = since
datetime_since_until_col_until = until

View file

@ -0,0 +1,21 @@
datetime_slogan = Zonas horarias y formatos de fecha y hora
datetime_block_world = El mismo instante, alrededor del mundo
datetime_zone_utc = UTC
datetime_zone_madrid = Madrid (Europe/Madrid)
datetime_zone_mexico = Ciudad de México (America/Mexico_City)
datetime_zone_tokyo = Tokio (Asia/Tokyo)
datetime_block_formats = Combinaciones de DateFormat / TimeFormat
datetime_formats_date = DateFormat (sólo fecha)
datetime_formats_time = TimeFormat (sólo hora)
datetime_formats_combos = Combinaciones de format_datetime() (fecha + hora)
datetime_formats_iso = format_iso_datetime():
datetime_block_relative = RelativeFormat
datetime_relative_col_date = Fecha
datetime_block_since_until = DatePrecision (format_since / format_until)
datetime_since_until_col_precision = Precisión
datetime_since_until_col_since = desde
datetime_since_until_col_until = hasta

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
/// Entidad SeaORM para la tabla `settings`.
@ -9,7 +8,7 @@ pub struct Model {
pub key: String,
pub scope: String,
pub value: String,
pub updated_at: NaiveDateTime,
pub updated_at: DateTimeUtc,
pub updated_by: Option<i32>,
}

View file

@ -14,7 +14,7 @@ use crate::action::{
};
use crate::settings::SettingsSchema;
// **< AdminPermission >*****************************************************************************
// **< AdminPermission >****************************************************************************
/// Permisos propios de `pagetop-admin`.
#[derive(Clone, Copy, Debug)]
@ -43,7 +43,7 @@ impl Permission for AdminPermission {
}
}
// **< Tipos del registro >**************************************************************************
// **< Tipos del registro >*************************************************************************
/// Sección del panel de administración (agrupación en el sidebar).
#[derive(Clone)]
@ -143,7 +143,7 @@ pub struct AdminAction {
pub weight: i32,
}
// **< AdminRegistry >*******************************************************************************
// **< AdminRegistry >******************************************************************************
/// Registro global del panel de administración, construido una sola vez en `initialize()`.
#[derive(Getters)]

View file

@ -11,7 +11,7 @@ use serde::{Serialize, de::DeserializeOwned};
use crate::entity::setting::{ActiveModel, Column, Entity};
use crate::error::AdminError;
// **< API pública >*********************************************************************************
// **< API pública >********************************************************************************
/// Lee un valor persistido, devolviendo el `Default` del tipo si no existe.
pub async fn get<T: DeserializeOwned + Default>(key: &str) -> T {
@ -40,7 +40,7 @@ pub async fn list_scope(scope: &str) -> Vec<(String, String)> {
list_scope_async(scope).await.unwrap_or_default()
}
// **< Implementación asíncrona >********************************************************************
// **< Implementación asíncrona >*******************************************************************
async fn get_async<T: DeserializeOwned>(key: &str) -> Result<T, AdminError> {
let model = Entity::find_by_id(key)
@ -57,7 +57,7 @@ async fn set_async<T: Serialize>(
user_id: Option<i32>,
) -> Result<(), AdminError> {
let value_json = serde_json::to_string(value)?;
let now = Utc::now().naive_utc();
let now = Utc::now();
let existing = Entity::find_by_id(key).one(dbconn()).await?;
if existing.is_some() {
@ -95,7 +95,7 @@ async fn list_scope_async(scope: &str) -> Result<Vec<(String, String)>, AdminErr
Ok(rows.into_iter().map(|m| (m.key, m.value)).collect())
}
// **< Tipos de esquema >****************************************************************************
// **< Tipos de esquema >***************************************************************************
/// Tipo de campo de configuración para un [`SettingsSchema`].
#[derive(Clone, Debug)]

View file

@ -42,7 +42,7 @@ async fn htmx_script_is_served_at_the_expected_static_path() {
assert!(body.contains("htmx"));
}
// **< Automatic script injection (BeforeRenderBody) >***********************************************
// **< Automatic script injection (BeforeRenderBody) >**********************************************
#[pagetop::test]
async fn rendered_pages_automatically_include_the_pinned_htmx_script_tag() {

View file

@ -103,7 +103,7 @@
//! | `string_uniq(col)` | `VARCHAR NOT NULL UNIQUE` |
//! | `integer(col)` | `INTEGER NOT NULL` |
//! | `boolean(col)` | `BOOLEAN NOT NULL` |
//! | `timestamp(col)` | `TIMESTAMP NOT NULL` |
//! | `timestamp(col)` | `TIMESTAMP WITH TIME ZONE NOT NULL` |
//! | `uuid(col)` | `UUID NOT NULL` |
//!
//! Estas son sólo las funciones más habituales. El módulo [`schema`] define la lista completa, con

View file

@ -329,12 +329,16 @@ pub fn interval_uniq<T: IntoIden>(
interval(col, fields, precision).unique_key().take()
}
// Unlike the original helpers, `timestamp()` and `timestamp_null()` create time zone aware
// columns. Entities store instants as `DateTimeUtc`, which PostgreSQL can only decode from
// `TIMESTAMPTZ`; a plain `TIMESTAMP` would also store `CURRENT_TIMESTAMP` in server local time.
// Keep this deviation when syncing with upstream.
pub fn timestamp<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).timestamp().not_null().take()
timestamp_with_time_zone(col)
}
pub fn timestamp_null<T: IntoIden>(col: T) -> ColumnDef {
ColumnDef::new(col).timestamp().null().take()
timestamp_with_time_zone_null(col)
}
pub fn timestamp_uniq<T: IntoIden>(col: T) -> ColumnDef {

View file

@ -24,7 +24,7 @@ pub async fn login(
plain_password: &str,
remember: bool,
) -> Result<String, AuthError> {
let now = Utc::now().naive_utc();
let now = Utc::now();
// Buscar usuario por username o email.
let user_model = user::Entity::find()
@ -119,7 +119,7 @@ pub async fn register(
}
let hash = password::hash_password(plain_password)?;
let now = Utc::now().naive_utc();
let now = Utc::now();
let status = if SETTINGS.require_email_verification {
UserStatus::Pending
} else {
@ -175,7 +175,7 @@ pub async fn assign_role(user_id: i32, role_id: i32) -> Result<(), AuthError> {
async fn register_failed_login(
user_model: &user::Model,
now: NaiveDateTime,
now: DateTime<Utc>,
) -> Result<(), AuthError> {
let new_count = user_model.failed_login_count + 1;
let lock_at = if new_count >= SETTINGS.max_failed_logins {
@ -236,7 +236,7 @@ async fn do_seed() {
}
};
let now = Utc::now().naive_utc();
let now = Utc::now();
let new_admin = user::ActiveModel {
id: ActiveValue::NotSet,
username: Set(cfg.admin_username.clone()),

View file

@ -22,15 +22,16 @@ use pagetop::prelude::*;
use crate::LOCALES_USER;
// **< constantes compartidas >**********************************************************************
// **< constantes compartidas >*********************************************************************
/// Identificador del `<form>` de [`UserForm`] en modo [`UserFormMode::Edit`]. En ese modo, el botón
/// "Guardar" no se renderiza dentro del formulario (ver `UserForm::prepare()`): lo añade la pantalla
/// de edición (`handlers::admin::users::edit_actions()`) junto al resto de acciones, referenciando
/// este id mediante el atributo `form` para seguir enviando el formulario aunque esté fuera de él.
/// "Guardar" no se renderiza dentro del formulario (ver `UserForm::prepare()`). Lo añade la
/// pantalla de edición (`handlers::admin::users::edit_actions()`) junto al resto de acciones,
/// referenciando este `id` mediante el atributo `form` para seguir enviando el formulario aunque
/// esté fuera de él.
pub(crate) const USER_ADMIN_FORM_ID: &str = "user-admin-form";
// **< tipos compartidos >***************************************************************************
// **< tipos compartidos >**************************************************************************
/// Un permiso dentro de un grupo del catálogo: `(clave, etiqueta, concedido)`.
pub(crate) type PermissionItem = (CowStr, Lc, bool);
@ -38,12 +39,12 @@ pub(crate) type PermissionItem = (CowStr, Lc, bool);
/// Catálogo de permisos agrupado: `(título del grupo, permisos del grupo)`.
pub(crate) type PermissionGroups = Vec<(Lc, Vec<PermissionItem>)>;
// **< helpers compartidos >*************************************************************************
// **< HELPERS >************************************************************************************
// `Fieldset` con las casillas para asignar roles (usado en el alta de usuario y en la pantalla
// dedicada de asignación de roles). El rol "authenticated" no se lista como casilla ni se envía:
// todo usuario autenticado lo tiene concedido por definición (ver `session::load_user_from_session`),
// sin necesidad de una fila en `user_role`.
// `Fieldset` con las casillas para asignar roles (usado en el alta de usuario y en la pantalla de
// asignación de roles). El rol "authenticated" no se lista como casilla ni se envía: todo usuario
// autenticado lo tiene concedido por definición (ver `session::load_user_from_session`), sin
// necesidad de una fila en `user_role`.
pub(crate) fn roles_fieldset(roles: &[(i32, String, bool)]) -> form::Fieldset {
let mut field = form::check::Field::new().with_name("role_ids");
for (role_id, label, checked) in roles {

View file

@ -1,5 +1,8 @@
//! Formulario de alta/edición de usuario.
use std::collections::BTreeMap;
use std::sync::LazyLock;
use pagetop::prelude::*;
use crate::ADMIN_USERS_PATH;
@ -10,6 +13,32 @@ use crate::component::{PasswordConfirm, error_banner};
use super::{USER_ADMIN_FORM_ID, roles_fieldset};
// Regiones de la base IANA que sólo contienen alias heredados (fichero `backward`), todos con una
// zona canónica equivalente en otra región (p. ej. `US/Eastern` es `America/New_York`).
const LEGACY_REGIONS: [&str; 5] = ["Brazil", "Canada", "Chile", "Mexico", "US"];
// Zonas horarias IANA canónicas agrupadas por región (lo anterior a la primera `/`), ordenadas por
// región y nombre. Se descartan los alias heredados: los nombres sin región (`GB`, `Japan`,
// `EST5EDT`...), los de `LEGACY_REGIONS` y los de `Etc` salvo `Etc/UTC`, cuyo grupo va al final.
static TZ_BY_REGION: LazyLock<Vec<(&'static str, Vec<&'static str>)>> = LazyLock::new(|| {
let mut regions: BTreeMap<&'static str, Vec<&'static str>> = BTreeMap::new();
for tz in TZ_VARIANTS.iter() {
let name = tz.name();
let Some((region, _)) = name.split_once('/') else {
continue;
};
if LEGACY_REGIONS.contains(&region) || (region == "Etc" && name != "Etc/UTC") {
continue;
}
regions.entry(region).or_default().push(name);
}
for names in regions.values_mut() {
names.sort_unstable();
}
let etc = regions.remove_entry("Etc");
regions.into_iter().chain(etc).collect()
});
#[derive(AutoDefault, Clone, Copy, Debug, PartialEq)]
pub(crate) enum UserFormMode {
#[default]
@ -81,17 +110,13 @@ impl Component for UserForm {
.with_value(self.language())
.with_label(Lc::t("field-language", &LOCALES_USER)),
)
.with_child(
form::input::Field::text()
.with_name("timezone")
.with_value(self.timezone())
.with_label(Lc::t("field-timezone", &LOCALES_USER)),
);
.with_child(timezone_field(self.timezone()));
if *self.mode() == UserFormMode::New {
form = form
.with_child(PasswordConfirm::new())
.with_child(roles_fieldset(self.roles()));
form = form.with_child(PasswordConfirm::new());
if !self.roles().is_empty() {
form = form.with_child(roles_fieldset(self.roles()));
}
if *self.allow_admin_field() {
form = form.with_child(
@ -181,3 +206,29 @@ impl UserForm {
self
}
}
// `<select>` de zona horaria: primero la opción de usar la predeterminada de la aplicación y luego
// un `<optgroup>` por región. La etiqueta de cada opción es el nombre IANA completo.
fn timezone_field(selected: &str) -> form::select::Field {
let mut field = form::select::Field::new()
.with_name("timezone")
.with_label(Lc::t("field-timezone", &LOCALES_USER))
.with_item(
form::select::Item::new(
"",
Lc::t("field-timezone-site-default", &LOCALES_USER)
.with_arg("tz", Timezone::default_tz().name()),
)
.with_selected(selected.is_empty()),
);
for (region, names) in TZ_BY_REGION.iter() {
let mut group = form::select::Group::new(Lc::n(*region));
for name in names {
group = group.with_item(
form::select::Item::new(*name, Lc::n(*name)).with_selected(*name == selected),
);
}
field = field.with_group(group);
}
field
}

View file

@ -50,7 +50,7 @@ impl Component for PasswordConfirm {
#[builder_impl]
impl PasswordConfirm {
// **< PasswordConfirm BUILDER >********************************************************************
// **< PasswordConfirm BUILDER >****************************************************************
/// Establece la etiqueta del campo de contraseña (por defecto, "field-password").
pub(crate) fn with_password_label(mut self, label: Lc) -> Self {

View file

@ -54,7 +54,7 @@ pub(crate) async fn seed_demo_data() {
}
async fn create_demo_roles() -> Result<Vec<i32>, AuthError> {
let now = Utc::now().naive_utc();
let now = Utc::now();
let mut role_ids = Vec::with_capacity(ROLE_COUNT);
for n in 1..=ROLE_COUNT {
let new_role = role::ActiveModel {
@ -78,7 +78,7 @@ async fn create_demo_roles() -> Result<Vec<i32>, AuthError> {
async fn create_demo_users(role_ids: &[i32]) -> Result<(), AuthError> {
let hash = password::hash_password(DEMO_PASSWORD)?;
let now = Utc::now().naive_utc();
let now = Utc::now();
for n in 1..=USER_COUNT {
let new_user = user::ActiveModel {

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
@ -12,8 +11,8 @@ pub struct Model {
pub description: Option<String>,
pub weight: i32,
pub locked: bool,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
@ -8,7 +7,7 @@ pub struct Model {
pub role_id: i32,
#[sea_orm(primary_key, auto_increment = false)]
pub permission_key: String,
pub granted_at: NaiveDateTime,
pub granted_at: DateTimeUtc,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
@ -8,9 +7,9 @@ pub struct Model {
pub sid: String,
pub user_id: i32,
pub data: String,
pub last_activity_at: Option<NaiveDateTime>,
pub expires_at: NaiveDateTime,
pub created_at: NaiveDateTime,
pub last_activity_at: Option<DateTimeUtc>,
pub expires_at: DateTimeUtc,
pub created_at: DateTimeUtc,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
@ -10,20 +9,20 @@ pub struct Model {
pub username: String,
#[sea_orm(unique)]
pub email: String,
pub email_verified_at: Option<NaiveDateTime>,
pub email_verified_at: Option<DateTimeUtc>,
pub password_hash: String,
pub status: i16,
pub language: Option<String>,
pub timezone: Option<String>,
pub display_name: Option<String>,
pub last_login_at: Option<NaiveDateTime>,
pub last_access_at: Option<NaiveDateTime>,
pub last_login_at: Option<DateTimeUtc>,
pub last_access_at: Option<DateTimeUtc>,
pub failed_login_count: i32,
pub locked_until: Option<NaiveDateTime>,
pub locked_until: Option<DateTimeUtc>,
/// Acceso irrestricto al sistema, sin pasar por roles ni permisos.
pub is_admin: bool,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]

View file

@ -1,4 +1,3 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
#[derive(Clone, Debug, DeriveEntityModel, PartialEq)]
@ -10,10 +9,10 @@ pub struct Model {
pub kind: String,
#[sea_orm(unique)]
pub token_hash: String,
pub expires_at: NaiveDateTime,
pub consumed_at: Option<NaiveDateTime>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub expires_at: DateTimeUtc,
pub consumed_at: Option<DateTimeUtc>,
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
}
#[derive(Clone, Copy, Debug, DeriveRelation, EnumIter)]

View file

@ -37,6 +37,9 @@ pub enum AuthError {
#[error("password must be at least {0} characters")]
PasswordTooShort(usize),
#[error("invalid IANA timezone identifier")]
InvalidTimezone,
#[error("user not found")]
UserNotFound,

View file

@ -28,6 +28,7 @@ pub(crate) fn map_auth_error(err: &AuthError) -> Lc {
Lc::t("error-password-too-short", &LOCALES_USER).with_arg("n", n.to_string())
}
AuthError::PasswordMismatch => Lc::t("error-password-mismatch", &LOCALES_USER),
AuthError::InvalidTimezone => Lc::t("error-invalid-timezone", &LOCALES_USER),
AuthError::UsernameTaken => Lc::t("error-username-taken", &LOCALES_USER),
AuthError::EmailTaken => Lc::t("error-email-taken", &LOCALES_USER),
AuthError::UserNotFound => Lc::t("error-user-not-found", &LOCALES_USER),

View file

@ -257,7 +257,7 @@ pub(crate) async fn new_post(
}
}
// **< edit_get / edit_post >************************************************************************
// **< edit_get / edit_post >***********************************************************************
async fn render_user_edit(
request: HttpRequest,
@ -611,7 +611,7 @@ async fn user_view_roles(roles: &[role::Model], cx: &mut Context) -> Block {
}))
}
// **< roles_get / roles_post >**********************************************************************
// **< roles_get / roles_post >*********************************************************************
/// GET /admin/user/users/{id}/roles - Formulario de asignación de roles de un usuario.
pub(crate) async fn roles_get(
@ -801,7 +801,7 @@ pub(crate) async fn admin_post(
}
}
// **< password_get / password_post >****************************************************************
// **< password_get / password_post >***************************************************************
/// GET /admin/user/users/{id}/password - Formulario de restablecimiento de contraseña por un
/// administrador.

View file

@ -247,7 +247,7 @@ pub async fn password_reset_confirm_get(
use crate::token::hash_token;
use pagetop_seaorm::db::{ColumnTrait, EntityTrait, QueryFilter, dbconn};
let hash = hash_token(&token);
let now = Utc::now().naive_utc();
let now = Utc::now();
user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(&hash))
.filter(user_token::Column::UserId.eq(uid))
@ -291,7 +291,7 @@ pub async fn password_reset_confirm_post(
let user_id = consume_token(&token, TokenKind::PasswordReset).await?;
password::validate_strength(&form.password)?;
let hash = password::hash_password(&form.password)?;
let now = Utc::now().naive_utc();
let now = Utc::now();
user::ActiveModel {
id: Set(user_id),
password_hash: Set(hash),
@ -333,7 +333,7 @@ pub async fn verify_email_get(
use crate::entity::user;
use pagetop_seaorm::db::{ActiveModelTrait, Set, dbconn};
let user_id = consume_token(&token, TokenKind::EmailVerification).await?;
let now = Utc::now().naive_utc();
let now = Utc::now();
user::ActiveModel {
id: Set(user_id),
email_verified_at: Set(Some(now)),
@ -352,7 +352,7 @@ pub async fn verify_email_get(
}
}
// **< helpers privados >***************************************************************************
// **< HELPERS >************************************************************************************
fn redirect_with_cookie(to: impl Into<RoutePath>, cookie: &str) -> Response {
(

View file

@ -178,7 +178,7 @@ fn declare_admin_pages(bag: &mut PageBag) {
});
}
// **< Roles de sistema (fijos) >********************************************************************
// **< Roles de sistema (fijos) >*******************************************************************
// Sembrados con id fijo en `migration/m20260629_000002_create_roles.rs` y bloqueados (`locked`);
// no se borran ni cambian de id.

View file

@ -55,6 +55,7 @@ error-account-pending = Please verify your email address before signing in.
error-account-locked = Too many failed attempts. Please try again later.
error-password-mismatch = Passwords do not match.
error-password-too-short = Password must be at least { $n } characters.
error-invalid-timezone = Invalid timezone.
error-username-taken = This username is already taken.
error-email-taken = This email address is already registered.
error-token-invalid = This link is invalid or has expired.
@ -108,6 +109,7 @@ field-username-admin = Username
field-display-name = Display name
field-language = Language
field-timezone = Timezone
field-timezone-site-default = Use site timezone ({ $tz })
field-machine-name = Machine name
field-label = Label
field-description = Description

View file

@ -55,6 +55,7 @@ error-account-pending = Verifica tu dirección de email antes de iniciar ses
error-account-locked = Demasiados intentos fallidos. Inténtalo de nuevo más tarde.
error-password-mismatch = Las contraseñas no coinciden.
error-password-too-short = La contraseña debe tener al menos { $n } caracteres.
error-invalid-timezone = Zona horaria no válida.
error-username-taken = Este nombre de usuario ya está en uso.
error-email-taken = Esta dirección de email ya está registrada.
error-token-invalid = Este enlace no es válido o ha caducado.
@ -108,6 +109,7 @@ field-username-admin = Usuario
field-display-name = Nombre visible
field-language = Idioma
field-timezone = Zona horaria
field-timezone-site-default = Usar la zona horaria del sitio ({ $tz })
field-machine-name = Nombre técnico
field-label = Etiqueta
field-description = Descripción

View file

@ -12,7 +12,7 @@ use crate::entity::{role, role_permission, user_role};
use crate::error::AuthError;
use crate::permission;
// **< listado >**************************************************************************************
// **< listado >************************************************************************************
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) enum RoleSortField {
@ -129,7 +129,7 @@ async fn role_items(roles: Vec<role::Model>) -> Result<Vec<RoleListItem>, AuthEr
.collect())
}
// **< find_role / role_permission_keys >***************************************************************
// **< find_role / role_permission_keys >***********************************************************
pub(crate) async fn find_role(role_id: i32) -> Result<role::Model, AuthError> {
role::Entity::find_by_id(role_id)
@ -146,7 +146,7 @@ pub(crate) async fn role_permission_keys(role_id: i32) -> Result<Vec<String>, Au
Ok(rows.into_iter().map(|r| r.permission_key).collect())
}
// **< create_role >*********************************************************************************
// **< create_role >********************************************************************************
pub(crate) struct NewRoleData<'a> {
pub machine_name: &'a str,
@ -178,7 +178,7 @@ pub(crate) async fn create_role(data: NewRoleData<'_>) -> Result<i32, AuthError>
return Err(AuthError::RoleMachineNameTaken);
}
let now = Utc::now().naive_utc();
let now = Utc::now();
let new_role = role::ActiveModel {
id: ActiveValue::NotSet,
machine_name: Set(data.machine_name.to_owned()),
@ -193,7 +193,7 @@ pub(crate) async fn create_role(data: NewRoleData<'_>) -> Result<i32, AuthError>
Ok(result.last_insert_id)
}
// **< update_role >*********************************************************************************
// **< update_role >********************************************************************************
pub(crate) struct RoleUpdateData<'a> {
pub label: &'a str,
@ -207,7 +207,7 @@ pub(crate) async fn update_role(role_id: i32, data: RoleUpdateData<'_>) -> Resul
return Err(AuthError::RoleLocked);
}
let now = Utc::now().naive_utc();
let now = Utc::now();
role::ActiveModel {
id: Set(role_id),
label: Set(data.label.to_owned()),
@ -221,7 +221,7 @@ pub(crate) async fn update_role(role_id: i32, data: RoleUpdateData<'_>) -> Resul
Ok(())
}
// **< delete_role >*********************************************************************************
// **< delete_role >********************************************************************************
pub(crate) async fn delete_role(role_id: i32) -> Result<(), AuthError> {
let role = find_role(role_id).await?;
@ -241,7 +241,7 @@ pub(crate) async fn delete_role(role_id: i32) -> Result<(), AuthError> {
Ok(())
}
// **< set_role_permissions >************************************************************************
// **< set_role_permissions >***********************************************************************
/// Reemplaza por completo el conjunto de permisos concedidos a un rol. Permitido aunque el rol
/// esté bloqueado (`locked`): los roles de sistema también necesitan permisos gestionables.
@ -258,7 +258,7 @@ pub(crate) async fn set_role_permissions(
}
}
let now = Utc::now().naive_utc();
let now = Utc::now();
let keys = permission_keys.to_vec();
dbconn()
.transaction::<_, _, AuthError>(|txn| {

View file

@ -15,7 +15,7 @@ use crate::error::AuthError;
use crate::password;
use crate::session;
// **< listado >**************************************************************************************
// **< listado >************************************************************************************
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) enum UserSortField {
@ -126,7 +126,7 @@ async fn user_items(users: Vec<user::Model>) -> Result<Vec<UserListItem>, AuthEr
.collect())
}
// **< find_user / user_role_ids >**********************************************************************
// **< find_user / user_role_ids >******************************************************************
pub(crate) async fn find_user(user_id: i32) -> Result<user::Model, AuthError> {
user::Entity::find_by_id(user_id)
@ -152,7 +152,7 @@ pub(crate) async fn user_roles(user_id: i32) -> Result<Vec<role::Model>, AuthErr
.await?)
}
// **< create_user >*********************************************************************************
// **< create_user >********************************************************************************
pub(crate) struct NewUserData<'a> {
pub username: &'a str,
@ -173,11 +173,12 @@ pub(crate) struct NewUserData<'a> {
pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError> {
password::validate_strength(data.password)?;
password::passwords_match(data.password, data.confirm_password)?;
let timezone = validate_timezone(data.timezone)?;
ensure_username_available(data.username, None).await?;
ensure_email_available(data.email, None).await?;
let hash = password::hash_password(data.password)?;
let now = Utc::now().naive_utc();
let now = Utc::now();
let new_user = user::ActiveModel {
id: ActiveValue::NotSet,
@ -187,7 +188,7 @@ pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError>
password_hash: Set(hash),
status: Set(UserStatus::Active.as_i16()),
language: Set(data.language.map(str::to_owned)),
timezone: Set(data.timezone.map(str::to_owned)),
timezone: Set(timezone.map(str::to_owned)),
display_name: Set(data.display_name.map(str::to_owned)),
last_login_at: Set(None),
last_access_at: Set(None),
@ -207,7 +208,7 @@ pub(crate) async fn create_user(data: NewUserData<'_>) -> Result<i32, AuthError>
Ok(user_id)
}
// **< update_user >*********************************************************************************
// **< update_user >********************************************************************************
pub(crate) struct UserUpdateData<'a> {
pub username: &'a str,
@ -218,17 +219,18 @@ pub(crate) struct UserUpdateData<'a> {
}
pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Result<(), AuthError> {
let timezone = validate_timezone(data.timezone)?;
ensure_username_available(data.username, Some(user_id)).await?;
ensure_email_available(data.email, Some(user_id)).await?;
let now = Utc::now().naive_utc();
let now = Utc::now();
user::ActiveModel {
id: Set(user_id),
username: Set(data.username.to_owned()),
email: Set(data.email.to_owned()),
display_name: Set(data.display_name.map(str::to_owned)),
language: Set(data.language.map(str::to_owned)),
timezone: Set(data.timezone.map(str::to_owned)),
timezone: Set(timezone.map(str::to_owned)),
updated_at: Set(now),
..Default::default()
}
@ -237,7 +239,7 @@ pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Resul
Ok(())
}
// **< set_user_roles >******************************************************************************
// **< set_user_roles >*****************************************************************************
/// Reemplaza por completo el conjunto de roles asignados a un usuario.
///
@ -276,7 +278,7 @@ pub(crate) async fn set_user_roles(user_id: i32, role_ids: &[i32]) -> Result<(),
.map_err(flatten_txn_err)
}
// **< set_user_status >*****************************************************************************
// **< set_user_status >****************************************************************************
/// Cambia el estado de la cuenta. Rechaza que un usuario se bloquee a sí mismo o bloquee al último
/// administrador. Al bloquear, invalida todas las sesiones activas del usuario.
@ -296,7 +298,7 @@ pub(crate) async fn set_user_status(
}
}
let now = Utc::now().naive_utc();
let now = Utc::now();
user::ActiveModel {
id: Set(user_id),
status: Set(new_status.as_i16()),
@ -315,7 +317,7 @@ pub(crate) async fn set_user_status(
Ok(())
}
// **< set_user_admin >******************************************************************************
// **< set_user_admin >*****************************************************************************
/// Concede o revoca el acceso irrestricto (`is_admin`). No es un permiso del catálogo: sólo un
/// administrador puede concederlo o revocarlo (el handler comprueba `account.is_admin`
@ -336,7 +338,7 @@ pub(crate) async fn set_user_admin(
return Err(AuthError::CannotModifyOwnAdminFlag);
}
let now = Utc::now().naive_utc();
let now = Utc::now();
user::ActiveModel {
id: Set(user_id),
is_admin: Set(is_admin),
@ -348,7 +350,7 @@ pub(crate) async fn set_user_admin(
Ok(())
}
// **< admin_reset_password >************************************************************************
// **< admin_reset_password >***********************************************************************
/// Restablece la contraseña de un usuario como acción administrativa e invalida sus sesiones
/// activas.
@ -360,7 +362,7 @@ pub(crate) async fn admin_reset_password(
password::validate_strength(new_password)?;
let hash = password::hash_password(new_password)?;
let now = Utc::now().naive_utc();
let now = Utc::now();
user::ActiveModel {
id: Set(user_id),
password_hash: Set(hash),
@ -376,7 +378,17 @@ pub(crate) async fn admin_reset_password(
Ok(())
}
// **< helpers privados >****************************************************************************
// **< HELPERS >************************************************************************************
// Devuelve la zona sin espacios, tal como debe guardarse. Una zona ausente o en blanco es válida y
// devuelve `None`: equivale a usar la predeterminada de la aplicación.
fn validate_timezone(timezone: Option<&str>) -> Result<Option<&str>, AuthError> {
let timezone = timezone.and_then(util::non_blank);
if let Some(tz) = timezone {
tz.parse::<Tz>().map_err(|_| AuthError::InvalidTimezone)?;
}
Ok(timezone)
}
async fn ensure_username_available(
username: &str,

View file

@ -90,7 +90,7 @@ pub async fn resolve_session(headers: &web::http::HeaderMap) -> (CurrentUser, Op
///
/// Devuelve `(CurrentUser::Anonymous, None)` si la sesión no existe o ha expirado.
pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>) {
let now = Utc::now().naive_utc();
let now = Utc::now();
// Buscar sesión activa y no expirada.
let Ok(Some(sess)) = session::Entity::find_by_id(sid).one(dbconn()).await else {
@ -178,9 +178,14 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
permissions,
is_admin,
};
let timezone = user_model
.timezone
.as_deref()
.and_then(|tz| tz.parse().ok());
let current_user = CurrentUser::Authenticated {
id: account.id,
display_name: visible_name,
timezone,
};
(current_user, Some(account))
@ -191,7 +196,7 @@ pub async fn load_user_from_session(sid: &str) -> (CurrentUser, Option<Account>)
/// Crea una nueva sesión en base de datos y devuelve el session ID.
pub async fn create_session(user_id: i32, remember: bool) -> Result<String, DbErr> {
let sid = generate_sid();
let now = Utc::now().naive_utc();
let now = Utc::now();
let ttl = Duration::seconds(SETTINGS.session_ttl_secs);
let idle = Duration::seconds(SETTINGS.session_idle_ttl_secs);
let expires_at = if remember { now + ttl } else { now + idle };

View file

@ -79,7 +79,7 @@ pub async fn create_token(user_id: i32, kind: TokenKind) -> Result<String, AuthE
.await?;
let token = generate_token();
let now = Utc::now().naive_utc();
let now = Utc::now();
let expires_at = now + Duration::seconds(kind.ttl_secs());
let new_token = user_token::ActiveModel {
@ -102,7 +102,7 @@ pub async fn create_token(user_id: i32, kind: TokenKind) -> Result<String, AuthE
/// y lo marca como consumido. Devuelve el `user_id` asociado.
pub async fn consume_token(token: &str, kind: TokenKind) -> Result<i32, AuthError> {
let hash = hash_token(token);
let now = Utc::now().naive_utc();
let now = Utc::now();
let row = user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(&hash))

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;
@ -43,12 +44,16 @@ use crate::{CowStr, Weight};
pub enum CurrentUser {
/// Usuario no autenticado.
Anonymous,
/// Usuario autenticado con su identificador y nombre visible.
/// Usuario autenticado con su identificador, nombre visible y zona horaria propia.
Authenticated {
/// Identificador único del usuario en el sistema.
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

@ -123,7 +123,7 @@ impl Component for Number {
#[builder_impl]
impl Number {
// **< Number BUILDER >************************************************************************
// **< Number BUILDER >*************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {

View file

@ -341,7 +341,7 @@ impl Component for Pager {
#[builder_impl]
impl Pager {
// **< Pager BUILDER >*************************************************************************
// **< Pager BUILDER >**************************************************************************
/// Establece el identificador único del componente; igual a `with_prop(PropsOp::set_id(id))`.
pub fn with_id(mut self, id: impl Into<CowStr>) -> Self {
@ -440,7 +440,7 @@ impl Pager {
self
}
// **< Pager HELPERS >*************************************************************************
// **< Pager HELPERS >**************************************************************************
/// Número total de páginas según [`total_items()`](Self::total_items) y
/// [`items_per_page()`](Self::items_per_page). Nunca es cero, aunque `total_items` sea cero:

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

@ -43,7 +43,7 @@ impl ItemPlacement {
}
}
// **< ItemJustify >*********************************************************************************
// **< ItemJustify >********************************************************************************
/// Alineación individual en el eje de columnas de un [`GridItem`](super::GridItem).
///

View file

@ -156,7 +156,7 @@ impl Locale {
if let Some(langid) = *CONFIG_LANGID {
trace::debug!("Default language \"{langid}\" (from config: \"{raw}\")");
} else {
trace::debug!(
trace::warn!(
"Default language \"{}\" (fallback, invalid config: \"{raw}\")",
*FALLBACK_LANGID
);

View file

@ -0,0 +1,50 @@
# Date formats.
date_format_short = { $month }/{ $day }/{ $year_short }
date_format_medium = { $month }/{ $day }/{ $year }
date_format_long = { $month } { $day }, { $year }
# Time formats.
time_format_short = { $hour }:{ $minute }
time_format_long = { $hour }:{ $minute }:{ $second }
datetime_join = { $date }, { $time }
# Relative dates.
relative_today = today
relative_years = { $n ->
[one] { $n } year
*[other] { $n } years
}
relative_months = { $n ->
[one] { $n } month
*[other] { $n } months
}
relative_days = { $n ->
[one] { $n } day
*[other] { $n } days
}
relative_join_two = { $a } and { $b }
relative_join_three = { $a }, { $b }, and { $c }
relative_past = { $value } ago
relative_future = in { $value }
# Start/end date precision.
since_short = since { $month }
since_medium = since { $month } { $year }
since_long = since { $month } { $day }, { $year }
until_short = until { $month }
until_medium = until { $month } { $year }
until_long = until { $month } { $day }, { $year }
# Month names.
month_01 = January
month_02 = February
month_03 = March
month_04 = April
month_05 = May
month_06 = June
month_07 = July
month_08 = August
month_09 = September
month_10 = October
month_11 = November
month_12 = December

View file

@ -0,0 +1,50 @@
# Date formats.
date_format_short = { $day }/{ $month }/{ $year_short }
date_format_medium = { $day }/{ $month }/{ $year }
date_format_long = { $day } de { $month } de { $year }
# Time formats.
time_format_short = { $hour }:{ $minute }
time_format_long = { $hour }:{ $minute }:{ $second }
datetime_join = { $date }, { $time }
# Relative dates.
relative_today = hoy
relative_years = { $n ->
[one] { $n } año
*[other] { $n } años
}
relative_months = { $n ->
[one] { $n } mes
*[other] { $n } meses
}
relative_days = { $n ->
[one] { $n } día
*[other] { $n } días
}
relative_join_two = { $a } y { $b }
relative_join_three = { $a }, { $b } y { $c }
relative_past = hace { $value }
relative_future = dentro de { $value }
# Start/end date precision.
since_short = desde { $month }
since_medium = desde { $month } de { $year }
since_long = desde el { $day } de { $month } de { $year }
until_short = hasta { $month }
until_medium = hasta { $month } de { $year }
until_long = hasta el { $day } de { $month } de { $year }
# Month names.
month_01 = enero
month_02 = febrero
month_03 = marzo
month_04 = abril
month_05 = mayo
month_06 = junio
month_07 = julio
month_08 = agosto
month_09 = septiembre
month_10 = octubre
month_11 = noviembre
month_12 = diciembre

View file

@ -24,6 +24,7 @@ use crate::base::component::layout;
use crate::core::component::{AssetsOp, ChildOp, ComponentRender};
use crate::core::component::{Context, ContextError, Contextual};
use crate::core::theme::{CoreRegions, RegionName, RegionRef, TemplateRef, ThemeRef};
use crate::datetime::Tz;
use crate::html::{Assets, Favicon, JavaScript, ResponsiveStyles, StyleSheet};
use crate::html::{DOCTYPE, Markup, html};
use crate::html::{Props, PropsOp};
@ -280,6 +281,11 @@ impl Contextual for Page {
self
}
fn with_timezone(mut self, tz: Tz) -> Self {
self.context.alter_timezone(tz);
self
}
fn with_template(mut self, template: TemplateRef) -> Self {
self.context.alter_template(template);
self
@ -326,6 +332,10 @@ impl Contextual for Page {
self.context.current_user()
}
fn timezone(&self) -> Tz {
self.context.timezone()
}
fn template(&self) -> TemplateRef {
self.context.template()
}

View file

@ -13,14 +13,27 @@ async fn anonymous_reports_itself_correctly() {
#[pagetop::test]
async fn authenticated_reports_itself_correctly() {
let madrid: Tz = "Europe/Madrid".parse().unwrap();
let user = CurrentUser::Authenticated {
id: 42,
display_name: "Alice".to_owned(),
timezone: Some(madrid),
};
assert!(!user.is_anonymous());
assert!(user.is_authenticated());
assert_eq!(user.id(), Some(42));
assert_eq!(user.display_name(), Some("Alice"));
assert_eq!(user.timezone(), madrid);
}
#[pagetop::test]
async fn authenticated_falls_back_to_default_timezone_when_none() {
let user = CurrentUser::Authenticated {
id: 42,
display_name: "Alice".to_owned(),
timezone: None,
};
assert_eq!(user.timezone(), Timezone::default_tz());
}
// **< Context::current_user() >********************************************************************
@ -37,6 +50,7 @@ async fn current_user_propagates_from_request_extensions() {
.with_extension(CurrentUser::Authenticated {
id: 7,
display_name: "Bob".to_owned(),
timezone: None,
})
.to_http_request();
let cx = Context::new(req);
@ -60,6 +74,7 @@ async fn request_extension_returns_injected_value() {
.with_extension(CurrentUser::Authenticated {
id: 1,
display_name: "Carol".to_owned(),
timezone: None,
})
.to_http_request();
@ -79,6 +94,7 @@ async fn page_new_propagates_current_user_from_request_extensions() {
.with_extension(CurrentUser::Authenticated {
id: 5,
display_name: "Dave".to_owned(),
timezone: None,
})
.to_http_request();
let page = Page::new(req);

325
tests/datetime.rs Normal file
View file

@ -0,0 +1,325 @@
use pagetop::prelude::*;
async fn setup() {
Application::new().await;
}
// **< Context::timezone() >************************************************************************
#[pagetop::test]
async fn resolve_uses_anonymous_fallback_to_default() {
setup().await;
let cx = Context::default();
assert_eq!(cx.timezone(), Timezone::default_tz());
}
// `CurrentUser::timezone()` resolves the user's own timezone when it's `Some`; this only checks
// that the value, when present, reaches `Context` intact. The `None` case (no personal timezone,
// falls back to the application's) is already covered by `tests/auth.rs`.
#[pagetop::test]
async fn resolve_uses_the_timezone_already_resolved_by_the_authenticated_user() {
setup().await;
let madrid: Tz = "Europe/Madrid".parse().unwrap();
let req = web::test::TestRequest::get()
.with_extension(CurrentUser::Authenticated {
id: 1,
display_name: "Alice".to_owned(),
timezone: Some(madrid),
})
.to_http_request();
let cx = Context::new(req);
assert_eq!(cx.timezone(), madrid);
}
// **< Context::with_timezone() >*******************************************************************
#[pagetop::test]
async fn with_timezone_forces_the_effective_timezone() {
setup().await;
let new_york: Tz = "America/New_York".parse().unwrap();
let cx = Context::default().with_timezone(new_york);
assert_eq!(cx.timezone(), new_york);
}
// **< Context::format_datetime() >*****************************************************************
// Without a request, the effective language is the fallback ("en-US"): month/day order
// (American convention), defined in `src/locale/en-US/datetime.ftl`. `format_datetime()`
// combines an independent `DateFormat` and `TimeFormat` with the `datetime_join` separator.
#[pagetop::test]
async fn format_datetime_uses_en_us_month_first_order_by_default() {
setup().await;
// 2026-06-15T10:30:00Z -> 12:30 in Europe/Madrid (CEST, UTC+2).
let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 0).unwrap();
let cx = Context::default().with_timezone("Europe/Madrid".parse().unwrap());
assert_eq!(
cx.format_datetime(dt, DateFormat::Short, TimeFormat::Short),
"06/15/26, 12:30"
);
assert_eq!(
cx.format_datetime(dt, DateFormat::Medium, TimeFormat::Short),
"06/15/2026, 12:30"
);
assert_eq!(
cx.format_datetime(dt, DateFormat::Long, TimeFormat::Long),
"June 15, 2026, 12:30:00"
);
// Different formats for date and time: long date with short time.
assert_eq!(
cx.format_datetime(dt, DateFormat::Long, TimeFormat::Short),
"June 15, 2026, 12:30"
);
}
// `format_iso_datetime()` doesn't depend on the language (it doesn't go through Fluent) but does
// convert to the active timezone, with its offset: same result under any language.
#[pagetop::test]
async fn format_iso_datetime_is_the_same_in_every_language() {
setup().await;
let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 0).unwrap();
let cx = Context::default().with_timezone("Europe/Madrid".parse().unwrap());
assert_eq!(cx.format_iso_datetime(dt), "2026-06-15T12:30:00+02:00");
assert_eq!(
cx.with_langid(&Locale::resolve("es-ES"))
.format_iso_datetime(dt),
"2026-06-15T12:30:00+02:00"
);
}
// The day/month/year order depends on the effective language, not a global constant: in es-ES
// the pattern is day/month (Spanish convention), defined in `src/locale/es-ES/datetime.ftl`.
#[pagetop::test]
async fn format_datetime_uses_es_es_day_first_order() {
setup().await;
let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 0).unwrap();
let cx = Context::default()
.with_timezone("Europe/Madrid".parse().unwrap())
.with_langid(&Locale::resolve("es-ES"));
assert_eq!(
cx.format_datetime(dt, DateFormat::Short, TimeFormat::Short),
"15/06/26, 12:30"
);
assert_eq!(
cx.format_datetime(dt, DateFormat::Medium, TimeFormat::Short),
"15/06/2026, 12:30"
);
assert_eq!(
cx.format_datetime(dt, DateFormat::Long, TimeFormat::Long),
"15 de junio de 2026, 12:30:00"
);
}
// **< Context::format_time() >*********************************************************************
// `format_time()` only shows the time, already converted to the active timezone.
#[pagetop::test]
async fn format_time_converts_to_the_effective_timezone() {
setup().await;
let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 45).unwrap();
let cx = Context::default().with_timezone("Europe/Madrid".parse().unwrap());
assert_eq!(cx.format_time(dt, TimeFormat::Short), "12:30");
assert_eq!(cx.format_time(dt, TimeFormat::Long), "12:30:45");
assert_eq!(
cx.format_time(dt, TimeFormat::Custom("%H:%M:%S")),
"12:30:45"
);
}
// **< Context::format_date() >*********************************************************************
// `format_date()` doesn't convert timezone (it doesn't apply to a date without a time); the
// result is the same regardless of the context's effective timezone. Under the fallback
// language ("en-US"), month/day order.
#[pagetop::test]
async fn format_date_does_not_depend_on_timezone() {
setup().await;
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
let cx = Context::default().with_timezone("Pacific/Auckland".parse().unwrap());
assert_eq!(cx.format_date(date, DateFormat::Short), "06/15/26");
assert_eq!(cx.format_date(date, DateFormat::Medium), "06/15/2026");
assert_eq!(cx.format_date(date, DateFormat::Long), "June 15, 2026");
assert_eq!(
cx.format_date(date, DateFormat::Custom("%d-%m-%Y")),
"15-06-2026"
);
}
// Same day/month/year order as `format_datetime`, dependent on the effective language.
#[pagetop::test]
async fn format_date_uses_es_es_day_first_order() {
setup().await;
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
let cx = Context::default().with_langid(&Locale::resolve("es-ES"));
assert_eq!(cx.format_date(date, DateFormat::Short), "15/06/26");
assert_eq!(cx.format_date(date, DateFormat::Medium), "15/06/2026");
assert_eq!(
cx.format_date(date, DateFormat::Long),
"15 de junio de 2026"
);
}
// `Iso` doesn't depend on the language (it doesn't go through Fluent): same result under any
// language.
#[pagetop::test]
async fn format_date_iso_is_the_same_in_every_language() {
setup().await;
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
let cx = Context::default();
assert_eq!(cx.format_date(date, DateFormat::Iso), "2026-06-15");
assert_eq!(
cx.with_langid(&Locale::resolve("es-ES"))
.format_date(date, DateFormat::Iso),
"2026-06-15"
);
}
// **< Context::format_relative() >*****************************************************************
//
// The exact calendar breakdown (leap years, zero components...) is tested with fixed dates in
// `src/datetime/relative.rs` (`RelativeFormat::apply()` is `pub(crate)`, not visible from here).
// These tests verify the connection with the public API: timezone conversion, resolution via a
// real `Utc::now()`, and day offsets -- always < 28, so the result doesn't depend on which real
// month they run in (a month offset could cross a different month-end depending on the real day
// of execution).
// A value from a few minutes ago or a few minutes from now falls on the same civil date as "now":
// "today", regardless of the language.
#[pagetop::test]
async fn format_relative_collapses_to_today_within_the_same_civil_date() {
setup().await;
let cx = Context::default();
let just_before = Utc::now() - Duration::minutes(3);
let just_after = Utc::now() + Duration::minutes(3);
assert_eq!(
cx.format_relative(just_before, RelativeFormat::Short),
"today"
);
assert_eq!(
cx.with_langid(&Locale::resolve("es-ES"))
.format_relative(just_after, RelativeFormat::Short),
"hoy"
);
}
// A small day offset (below any month) always gives a single days component, both in the past
// and in the future.
#[pagetop::test]
async fn format_relative_uses_day_only_offsets() {
setup().await;
let cx = Context::default();
let three_days_ago = Utc::now() - Duration::days(3);
let in_five_days = Utc::now() + Duration::days(5);
assert_eq!(
cx.format_relative(three_days_ago, RelativeFormat::Short),
"3 days ago"
);
assert_eq!(
cx.with_langid(&Locale::resolve("es-ES"))
.format_relative(in_five_days, RelativeFormat::Short),
"dentro de 5 días"
);
}
// Fluent's plural selector distinguishes singular (1) from plural (everything else), via
// `Lc::with_number()`.
#[pagetop::test]
async fn format_relative_pluralizes_the_unit() {
setup().await;
let cx = Context::default();
let yesterday = Utc::now() - Duration::days(1);
let three_days_ago = Utc::now() - Duration::days(3);
assert_eq!(
cx.format_relative(yesterday, RelativeFormat::Short),
"1 day ago"
);
assert_eq!(
cx.format_relative(three_days_ago, RelativeFormat::Short),
"3 days ago"
);
}
// **< Context::format_since() / format_until() >***************************************************
// The translated month and, in `Long`, the article "el" before the day (Spanish only) are part
// of each level's own template, not a shared generic wrapper.
#[pagetop::test]
async fn format_since_reveals_increasing_precision() {
setup().await;
let date = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap();
let en = Context::default();
let es = Context::default().with_langid(&Locale::resolve("es-ES"));
assert_eq!(en.format_since(date, DatePrecision::Short), "since June");
assert_eq!(
en.format_since(date, DatePrecision::Medium),
"since June 2026"
);
assert_eq!(
en.format_since(date, DatePrecision::Long),
"since June 3, 2026"
);
assert_eq!(es.format_since(date, DatePrecision::Short), "desde junio");
assert_eq!(
es.format_since(date, DatePrecision::Medium),
"desde junio de 2026"
);
assert_eq!(
es.format_since(date, DatePrecision::Long),
"desde el 3 de junio de 2026"
);
}
// Same mechanism as `format_since()`, symmetric for an end date.
#[pagetop::test]
async fn format_until_is_symmetric_to_format_since() {
setup().await;
let date = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap();
let en = Context::default();
let es = Context::default().with_langid(&Locale::resolve("es-ES"));
assert_eq!(en.format_until(date, DatePrecision::Short), "until June");
assert_eq!(
en.format_until(date, DatePrecision::Medium),
"until June 2026"
);
assert_eq!(
en.format_until(date, DatePrecision::Long),
"until June 3, 2026"
);
assert_eq!(es.format_until(date, DatePrecision::Short), "hasta junio");
assert_eq!(
es.format_until(date, DatePrecision::Medium),
"hasta junio de 2026"
);
assert_eq!(
es.format_until(date, DatePrecision::Long),
"hasta el 3 de junio de 2026"
);
}

View file

@ -74,7 +74,7 @@ async fn classes_prepend_ignores_empty_input() {
assert_classes(&p, Some("a b"));
}
// **< PropsOp::replace_classes >********************************************************************
// **< PropsOp::replace_classes >*******************************************************************
#[pagetop::test]
async fn classes_replace_removes_targets_and_inserts_new_at_min_position() {

View file

@ -310,7 +310,7 @@ async fn get_styles_matches_classes_after_normalization() {
);
}
// **< ResponsiveStyles::render >********************************************************************
// **< ResponsiveStyles::render >*******************************************************************
#[pagetop::test]
async fn render_is_empty_when_nothing_stored() {