Compare commits

..

No commits in common. "c06efbd0d1c6fe916fac137ab79b98ba210dbda3" and "3df73f055192b96bb835d7fab3d6cc286dbca19e" have entirely different histories.

53 changed files with 147 additions and 1884 deletions

37
Cargo.lock generated
View file

@ -348,16 +348,6 @@ 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"
@ -1039,7 +1029,7 @@ dependencies = [
"indexmap",
"lasso",
"once_cell",
"phf 0.11.3",
"phf",
"rand",
]
@ -1797,7 +1787,6 @@ dependencies = [
"async-trait",
"axum",
"chrono",
"chrono-tz",
"colored",
"config",
"figlet-rs",
@ -2037,16 +2026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros",
"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",
"phf_shared",
]
[[package]]
@ -2055,7 +2035,7 @@ version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"phf_shared",
"rand",
]
@ -2066,7 +2046,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator",
"phf_shared 0.11.3",
"phf_shared",
"proc-macro2",
"quote",
"syn",
@ -2081,15 +2061,6 @@ 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,7 +29,6 @@ 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"
@ -112,7 +111,6 @@ 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

View file

@ -1,270 +0,0 @@
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

@ -1,21 +0,0 @@
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

@ -1,21 +0,0 @@
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,3 +1,4 @@
use pagetop::prelude::*;
use pagetop_seaorm::db::*;
/// Entidad SeaORM para la tabla `settings`.
@ -8,7 +9,7 @@ pub struct Model {
pub key: String,
pub scope: String,
pub value: String,
pub updated_at: DateTimeUtc,
pub updated_at: NaiveDateTime,
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();
let now = Utc::now().naive_utc();
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 WITH TIME ZONE NOT NULL` |
//! | `timestamp(col)` | `TIMESTAMP 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,16 +329,12 @@ 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 {
timestamp_with_time_zone(col)
ColumnDef::new(col).timestamp().not_null().take()
}
pub fn timestamp_null<T: IntoIden>(col: T) -> ColumnDef {
timestamp_with_time_zone_null(col)
ColumnDef::new(col).timestamp().null().take()
}
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();
let now = Utc::now().naive_utc();
// 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();
let now = Utc::now().naive_utc();
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: DateTime<Utc>,
now: NaiveDateTime,
) -> 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();
let now = Utc::now().naive_utc();
let new_admin = user::ActiveModel {
id: ActiveValue::NotSet,
username: Set(cfg.admin_username.clone()),

View file

@ -22,16 +22,15 @@ 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);
@ -39,12 +38,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 >************************************************************************************
// **< helpers compartidos >*************************************************************************
// `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`.
// `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`.
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,8 +1,5 @@
//! Formulario de alta/edición de usuario.
use std::collections::BTreeMap;
use std::sync::LazyLock;
use pagetop::prelude::*;
use crate::ADMIN_USERS_PATH;
@ -13,32 +10,6 @@ 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]
@ -110,13 +81,17 @@ impl Component for UserForm {
.with_value(self.language())
.with_label(Lc::t("field-language", &LOCALES_USER)),
)
.with_child(timezone_field(self.timezone()));
.with_child(
form::input::Field::text()
.with_name("timezone")
.with_value(self.timezone())
.with_label(Lc::t("field-timezone", &LOCALES_USER)),
);
if *self.mode() == UserFormMode::New {
form = form.with_child(PasswordConfirm::new());
if !self.roles().is_empty() {
form = form.with_child(roles_fieldset(self.roles()));
}
form = form
.with_child(PasswordConfirm::new())
.with_child(roles_fieldset(self.roles()));
if *self.allow_admin_field() {
form = form.with_child(
@ -206,29 +181,3 @@ 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();
let now = Utc::now().naive_utc();
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();
let now = Utc::now().naive_utc();
for n in 1..=USER_COUNT {
let new_user = user::ActiveModel {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -37,9 +37,6 @@ 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,7 +28,6 @@ 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();
let now = Utc::now().naive_utc();
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();
let now = Utc::now().naive_utc();
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();
let now = Utc::now().naive_utc();
user::ActiveModel {
id: Set(user_id),
email_verified_at: Set(Some(now)),
@ -352,7 +352,7 @@ pub async fn verify_email_get(
}
}
// **< HELPERS >************************************************************************************
// **< helpers privados >***************************************************************************
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,7 +55,6 @@ 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.
@ -105,18 +104,17 @@ col-users-count = Users
# **< Admin: field labels >**
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
field-weight = Weight
field-roles = Roles
field-is-admin = Administrator (unrestricted access)
field-search-users = Search by username, email or name...
field-username-admin = Username
field-display-name = Display name
field-language = Language
field-timezone = Timezone
field-machine-name = Machine name
field-label = Label
field-description = Description
field-weight = Weight
field-roles = Roles
field-is-admin = Administrator (unrestricted access)
field-search-users = Search by username, email or name...
help-machine-name-immutable =
Lowercase letters, digits and underscores only. Cannot be changed after creation.

View file

@ -55,7 +55,6 @@ 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.
@ -105,18 +104,17 @@ col-users-count = Usuarios
# **< Administración: etiquetas de campos >**
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
field-weight = Peso
field-roles = Roles
field-is-admin = Administrador (acceso irrestricto)
field-search-users = Buscar por usuario, email o nombre...
field-username-admin = Usuario
field-display-name = Nombre visible
field-language = Idioma
field-timezone = Zona horaria
field-machine-name = Nombre técnico
field-label = Etiqueta
field-description = Descripción
field-weight = Peso
field-roles = Roles
field-is-admin = Administrador (acceso irrestricto)
field-search-users = Buscar por usuario, email o nombre...
help-machine-name-immutable =
Sólo minúsculas, dígitos y guiones bajos. No se puede cambiar tras crearlo.

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();
let now = Utc::now().naive_utc();
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();
let now = Utc::now().naive_utc();
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();
let now = Utc::now().naive_utc();
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,12 +173,11 @@ 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();
let now = Utc::now().naive_utc();
let new_user = user::ActiveModel {
id: ActiveValue::NotSet,
@ -188,7 +187,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(timezone.map(str::to_owned)),
timezone: Set(data.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),
@ -208,7 +207,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,
@ -219,18 +218,17 @@ 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();
let now = Utc::now().naive_utc();
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(timezone.map(str::to_owned)),
timezone: Set(data.timezone.map(str::to_owned)),
updated_at: Set(now),
..Default::default()
}
@ -239,7 +237,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.
///
@ -278,7 +276,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.
@ -298,7 +296,7 @@ pub(crate) async fn set_user_status(
}
}
let now = Utc::now();
let now = Utc::now().naive_utc();
user::ActiveModel {
id: Set(user_id),
status: Set(new_status.as_i16()),
@ -317,7 +315,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`
@ -338,7 +336,7 @@ pub(crate) async fn set_user_admin(
return Err(AuthError::CannotModifyOwnAdminFlag);
}
let now = Utc::now();
let now = Utc::now().naive_utc();
user::ActiveModel {
id: Set(user_id),
is_admin: Set(is_admin),
@ -350,7 +348,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.
@ -362,7 +360,7 @@ pub(crate) async fn admin_reset_password(
password::validate_strength(new_password)?;
let hash = password::hash_password(new_password)?;
let now = Utc::now();
let now = Utc::now().naive_utc();
user::ActiveModel {
id: Set(user_id),
password_hash: Set(hash),
@ -378,17 +376,7 @@ pub(crate) async fn admin_reset_password(
Ok(())
}
// **< 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)
}
// **< helpers privados >****************************************************************************
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();
let now = Utc::now().naive_utc();
// Buscar sesión activa y no expirada.
let Ok(Some(sess)) = session::Entity::find_by_id(sid).one(dbconn()).await else {
@ -178,14 +178,9 @@ 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))
@ -196,7 +191,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();
let now = Utc::now().naive_utc();
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();
let now = Utc::now().naive_utc();
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();
let now = Utc::now().naive_utc();
let row = user_token::Entity::find()
.filter(user_token::Column::TokenHash.eq(&hash))

View file

@ -3,7 +3,6 @@
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;
@ -64,9 +63,6 @@ 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,7 +17,6 @@
//! [`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;
@ -44,16 +43,12 @@ use crate::{CowStr, Weight};
pub enum CurrentUser {
/// Usuario no autenticado.
Anonymous,
/// Usuario autenticado con su identificador, nombre visible y zona horaria propia.
/// Usuario autenticado con su identificador y nombre visible.
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>,
},
}
@ -83,23 +78,6 @@ 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 >*********************************************************************************
@ -305,6 +283,7 @@ 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,7 +4,6 @@ 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;
@ -31,7 +30,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 y la zona horaria efectiva, el usuario autenticado
/// HTTP de origen, el idioma negociado, 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
@ -98,7 +97,6 @@ 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.
@ -128,12 +126,10 @@ 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,
@ -344,11 +340,10 @@ impl Contextual for Context {
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
self.request = request;
// Recalcula el *locale*, el usuario actual y la zona horaria según la nueva petición y la
// política de negociación configurada.
// Recalcula el *locale* y el usuario actual 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
}
@ -357,11 +352,6 @@ 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
@ -448,10 +438,6 @@ impl Contextual for Context {
&self.current_user
}
fn timezone(&self) -> Tz {
self.timezone
}
fn template(&self) -> TemplateRef {
self.template
}

View file

@ -4,31 +4,20 @@ 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, Lc};
use crate::locale::LangId;
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).
@ -55,26 +44,15 @@ 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.
///
/// 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
/// 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
/// 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.
@ -141,19 +119,6 @@ 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;
@ -216,187 +181,6 @@ 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,70 +1,9 @@
//! 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
//! 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)).
// 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};
pub use chrono::prelude::*;
// `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;
// `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;

View file

@ -1,72 +0,0 @@
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)
}
}

View file

@ -1,150 +0,0 @@
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(),
}
}
}

View file

@ -1,89 +0,0 @@
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())),
}
}
}

View file

@ -1,230 +0,0 @@
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,7 +23,6 @@ include_config!(SETTINGS: Settings => [
"app.name" => "PageTop App",
"app.theme" => "Basic",
"app.lang_negotiation" => "Full",
"app.timezone" => "UTC",
"app.startup_banner" => "Slant",
// [dev]
@ -77,16 +76,6 @@ 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::warn!(
trace::debug!(
"Default language \"{}\" (fallback, invalid config: \"{raw}\")",
*FALLBACK_LANGID
);

View file

@ -1,50 +0,0 @@
# 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

@ -1,50 +0,0 @@
# 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,7 +24,6 @@ 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};
@ -281,11 +280,6 @@ 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
@ -332,10 +326,6 @@ 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,27 +13,14 @@ 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() >********************************************************************
@ -50,7 +37,6 @@ 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);
@ -74,7 +60,6 @@ async fn request_extension_returns_injected_value() {
.with_extension(CurrentUser::Authenticated {
id: 1,
display_name: "Carol".to_owned(),
timezone: None,
})
.to_http_request();
@ -94,7 +79,6 @@ 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);

View file

@ -1,325 +0,0 @@
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() {