diff --git a/Cargo.lock b/Cargo.lock index 72ae1baf..9326263b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -348,6 +348,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + [[package]] name = "clap" version = "4.6.1" @@ -1029,7 +1039,7 @@ dependencies = [ "indexmap", "lasso", "once_cell", - "phf", + "phf 0.11.3", "rand", ] @@ -1787,6 +1797,7 @@ dependencies = [ "async-trait", "axum", "chrono", + "chrono-tz", "colored", "config", "figlet-rs", @@ -2026,7 +2037,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", ] [[package]] @@ -2035,7 +2055,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand", ] @@ -2046,7 +2066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn", @@ -2061,6 +2081,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" diff --git a/Cargo.toml b/Cargo.toml index 21e081ca..25dacfb3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ axum = { version = "0.8" } base64ct = { version = "1.8", features = ["alloc"] } change-detection = "1.2" chrono = "0.4" +chrono-tz = "0.10" colored = "3.1" config = { version = "0.15", default-features = false, features = ["toml"] } figlet-rs = "1.0" @@ -111,6 +112,7 @@ testing = [] async-trait.workspace = true axum.workspace = true chrono.workspace = true +chrono-tz.workspace = true colored.workspace = true config.workspace = true figlet-rs.workspace = true diff --git a/examples/intro-datetime.rs b/examples/intro-datetime.rs new file mode 100644 index 00000000..c15afb0b --- /dev/null +++ b/examples/intro-datetime.rs @@ -0,0 +1,270 @@ +use pagetop::prelude::*; + +include_locales!(LOC from "examples/locale"); + +struct IntroDatetime; + +#[async_trait] +impl Extension for IntroDatetime { + fn configure_router(&self, router: Router) -> Router { + router.route("/", web::get(intro_datetime)) + } +} + +async fn intro_datetime(request: HttpRequest) -> Result { + 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 × { + 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 { + 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 +} diff --git a/examples/locale/en-US/intro-datetime.ftl b/examples/locale/en-US/intro-datetime.ftl new file mode 100644 index 00000000..5b0fd436 --- /dev/null +++ b/examples/locale/en-US/intro-datetime.ftl @@ -0,0 +1,21 @@ +datetime_slogan = Timezones and date/time formats + +datetime_block_world = The same instant, around the world +datetime_zone_utc = UTC +datetime_zone_madrid = Madrid (Europe/Madrid) +datetime_zone_mexico = Mexico City (America/Mexico_City) +datetime_zone_tokyo = Tokyo (Asia/Tokyo) + +datetime_block_formats = DateFormat / TimeFormat combinations +datetime_formats_date = DateFormat (date only) +datetime_formats_time = TimeFormat (time only) +datetime_formats_combos = format_datetime() combinations (date + time) +datetime_formats_iso = format_iso_datetime(): + +datetime_block_relative = RelativeFormat +datetime_relative_col_date = Date + +datetime_block_since_until = DatePrecision (format_since / format_until) +datetime_since_until_col_precision = Precision +datetime_since_until_col_since = since +datetime_since_until_col_until = until diff --git a/examples/locale/es-ES/intro-datetime.ftl b/examples/locale/es-ES/intro-datetime.ftl new file mode 100644 index 00000000..6800682e --- /dev/null +++ b/examples/locale/es-ES/intro-datetime.ftl @@ -0,0 +1,21 @@ +datetime_slogan = Zonas horarias y formatos de fecha y hora + +datetime_block_world = El mismo instante, alrededor del mundo +datetime_zone_utc = UTC +datetime_zone_madrid = Madrid (Europe/Madrid) +datetime_zone_mexico = Ciudad de México (America/Mexico_City) +datetime_zone_tokyo = Tokio (Asia/Tokyo) + +datetime_block_formats = Combinaciones de DateFormat / TimeFormat +datetime_formats_date = DateFormat (sólo fecha) +datetime_formats_time = TimeFormat (sólo hora) +datetime_formats_combos = Combinaciones de format_datetime() (fecha + hora) +datetime_formats_iso = format_iso_datetime(): + +datetime_block_relative = RelativeFormat +datetime_relative_col_date = Fecha + +datetime_block_since_until = DatePrecision (format_since / format_until) +datetime_since_until_col_precision = Precisión +datetime_since_until_col_since = desde +datetime_since_until_col_until = hasta diff --git a/extensions/pagetop-admin/src/entity/setting.rs b/extensions/pagetop-admin/src/entity/setting.rs index 95b6c4c6..8907f0fd 100644 --- a/extensions/pagetop-admin/src/entity/setting.rs +++ b/extensions/pagetop-admin/src/entity/setting.rs @@ -1,4 +1,3 @@ -use pagetop::prelude::*; use pagetop_seaorm::db::*; /// Entidad SeaORM para la tabla `settings`. @@ -9,7 +8,7 @@ pub struct Model { pub key: String, pub scope: String, pub value: String, - pub updated_at: NaiveDateTime, + pub updated_at: DateTimeUtc, pub updated_by: Option, } diff --git a/extensions/pagetop-admin/src/registry.rs b/extensions/pagetop-admin/src/registry.rs index 3adcfd50..4ebbbd4b 100644 --- a/extensions/pagetop-admin/src/registry.rs +++ b/extensions/pagetop-admin/src/registry.rs @@ -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)] diff --git a/extensions/pagetop-admin/src/settings.rs b/extensions/pagetop-admin/src/settings.rs index 60f73637..d145709d 100644 --- a/extensions/pagetop-admin/src/settings.rs +++ b/extensions/pagetop-admin/src/settings.rs @@ -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(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(key: &str) -> Result { let model = Entity::find_by_id(key) @@ -57,7 +57,7 @@ async fn set_async( user_id: Option, ) -> Result<(), AdminError> { let value_json = serde_json::to_string(value)?; - let now = Utc::now().naive_utc(); + let now = Utc::now(); let existing = Entity::find_by_id(key).one(dbconn()).await?; if existing.is_some() { @@ -95,7 +95,7 @@ async fn list_scope_async(scope: &str) -> Result, 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)] diff --git a/extensions/pagetop-htmx/tests/extension.rs b/extensions/pagetop-htmx/tests/extension.rs index c0452ada..36c21704 100644 --- a/extensions/pagetop-htmx/tests/extension.rs +++ b/extensions/pagetop-htmx/tests/extension.rs @@ -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() { diff --git a/extensions/pagetop-seaorm/src/migration.rs b/extensions/pagetop-seaorm/src/migration.rs index 0c974f06..412426aa 100644 --- a/extensions/pagetop-seaorm/src/migration.rs +++ b/extensions/pagetop-seaorm/src/migration.rs @@ -103,7 +103,7 @@ //! | `string_uniq(col)` | `VARCHAR NOT NULL UNIQUE` | //! | `integer(col)` | `INTEGER NOT NULL` | //! | `boolean(col)` | `BOOLEAN NOT NULL` | -//! | `timestamp(col)` | `TIMESTAMP NOT NULL` | +//! | `timestamp(col)` | `TIMESTAMP WITH TIME ZONE NOT NULL` | //! | `uuid(col)` | `UUID NOT NULL` | //! //! Estas son sólo las funciones más habituales. El módulo [`schema`] define la lista completa, con diff --git a/extensions/pagetop-seaorm/src/migration/schema.rs b/extensions/pagetop-seaorm/src/migration/schema.rs index 88ce8b13..1d645c5d 100644 --- a/extensions/pagetop-seaorm/src/migration/schema.rs +++ b/extensions/pagetop-seaorm/src/migration/schema.rs @@ -329,12 +329,16 @@ pub fn interval_uniq( 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(col: T) -> ColumnDef { - ColumnDef::new(col).timestamp().not_null().take() + timestamp_with_time_zone(col) } pub fn timestamp_null(col: T) -> ColumnDef { - ColumnDef::new(col).timestamp().null().take() + timestamp_with_time_zone_null(col) } pub fn timestamp_uniq(col: T) -> ColumnDef { diff --git a/extensions/pagetop-user/src/auth.rs b/extensions/pagetop-user/src/auth.rs index 67d21aa6..6d737bda 100644 --- a/extensions/pagetop-user/src/auth.rs +++ b/extensions/pagetop-user/src/auth.rs @@ -24,7 +24,7 @@ pub async fn login( plain_password: &str, remember: bool, ) -> Result { - let now = Utc::now().naive_utc(); + let now = Utc::now(); // Buscar usuario por username o email. let user_model = user::Entity::find() @@ -119,7 +119,7 @@ pub async fn register( } let hash = password::hash_password(plain_password)?; - let now = Utc::now().naive_utc(); + let now = Utc::now(); let status = if SETTINGS.require_email_verification { UserStatus::Pending } else { @@ -175,7 +175,7 @@ pub async fn assign_role(user_id: i32, role_id: i32) -> Result<(), AuthError> { async fn register_failed_login( user_model: &user::Model, - now: NaiveDateTime, + now: DateTime, ) -> Result<(), AuthError> { let new_count = user_model.failed_login_count + 1; let lock_at = if new_count >= SETTINGS.max_failed_logins { @@ -236,7 +236,7 @@ async fn do_seed() { } }; - let now = Utc::now().naive_utc(); + let now = Utc::now(); let new_admin = user::ActiveModel { id: ActiveValue::NotSet, username: Set(cfg.admin_username.clone()), diff --git a/extensions/pagetop-user/src/component/admin.rs b/extensions/pagetop-user/src/component/admin.rs index 296b3370..01835906 100644 --- a/extensions/pagetop-user/src/component/admin.rs +++ b/extensions/pagetop-user/src/component/admin.rs @@ -22,15 +22,16 @@ use pagetop::prelude::*; use crate::LOCALES_USER; -// **< constantes compartidas >********************************************************************** +// **< constantes compartidas >********************************************************************* /// Identificador del `
` de [`UserForm`] en modo [`UserFormMode::Edit`]. En ese modo, el botón -/// "Guardar" no se renderiza dentro del formulario (ver `UserForm::prepare()`): lo añade la pantalla -/// de edición (`handlers::admin::users::edit_actions()`) junto al resto de acciones, referenciando -/// este id mediante el atributo `form` para seguir enviando el formulario aunque esté fuera de él. +/// "Guardar" no se renderiza dentro del formulario (ver `UserForm::prepare()`). Lo añade la +/// pantalla de edición (`handlers::admin::users::edit_actions()`) junto al resto de acciones, +/// referenciando este `id` mediante el atributo `form` para seguir enviando el formulario aunque +/// esté fuera de él. pub(crate) const USER_ADMIN_FORM_ID: &str = "user-admin-form"; -// **< tipos compartidos >*************************************************************************** +// **< tipos compartidos >************************************************************************** /// Un permiso dentro de un grupo del catálogo: `(clave, etiqueta, concedido)`. pub(crate) type PermissionItem = (CowStr, Lc, bool); @@ -38,12 +39,12 @@ pub(crate) type PermissionItem = (CowStr, Lc, bool); /// Catálogo de permisos agrupado: `(título del grupo, permisos del grupo)`. pub(crate) type PermissionGroups = Vec<(Lc, Vec)>; -// **< helpers compartidos >************************************************************************* +// **< HELPERS >************************************************************************************ -// `Fieldset` con las casillas para asignar roles (usado en el alta de usuario y en la pantalla -// dedicada de asignación de roles). El rol "authenticated" no se lista como casilla ni se envía: -// todo usuario autenticado lo tiene concedido por definición (ver `session::load_user_from_session`), -// sin necesidad de una fila en `user_role`. +// `Fieldset` con las casillas para asignar roles (usado en el alta de usuario y en la pantalla de +// asignación de roles). El rol "authenticated" no se lista como casilla ni se envía: todo usuario +// autenticado lo tiene concedido por definición (ver `session::load_user_from_session`), sin +// necesidad de una fila en `user_role`. pub(crate) fn roles_fieldset(roles: &[(i32, String, bool)]) -> form::Fieldset { let mut field = form::check::Field::new().with_name("role_ids"); for (role_id, label, checked) in roles { diff --git a/extensions/pagetop-user/src/component/admin/user_form.rs b/extensions/pagetop-user/src/component/admin/user_form.rs index 621f5d9e..42ccd0e2 100644 --- a/extensions/pagetop-user/src/component/admin/user_form.rs +++ b/extensions/pagetop-user/src/component/admin/user_form.rs @@ -1,5 +1,8 @@ //! Formulario de alta/edición de usuario. +use std::collections::BTreeMap; +use std::sync::LazyLock; + use pagetop::prelude::*; use crate::ADMIN_USERS_PATH; @@ -10,6 +13,32 @@ use crate::component::{PasswordConfirm, error_banner}; use super::{USER_ADMIN_FORM_ID, roles_fieldset}; +// Regiones de la base IANA que sólo contienen alias heredados (fichero `backward`), todos con una +// zona canónica equivalente en otra región (p. ej. `US/Eastern` es `America/New_York`). +const LEGACY_REGIONS: [&str; 5] = ["Brazil", "Canada", "Chile", "Mexico", "US"]; + +// Zonas horarias IANA canónicas agrupadas por región (lo anterior a la primera `/`), ordenadas por +// región y nombre. Se descartan los alias heredados: los nombres sin región (`GB`, `Japan`, +// `EST5EDT`...), los de `LEGACY_REGIONS` y los de `Etc` salvo `Etc/UTC`, cuyo grupo va al final. +static TZ_BY_REGION: LazyLock)>> = 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(®ion) || (region == "Etc" && name != "Etc/UTC") { + continue; + } + regions.entry(region).or_default().push(name); + } + for names in regions.values_mut() { + names.sort_unstable(); + } + let etc = regions.remove_entry("Etc"); + regions.into_iter().chain(etc).collect() +}); + #[derive(AutoDefault, Clone, Copy, Debug, PartialEq)] pub(crate) enum UserFormMode { #[default] @@ -81,17 +110,13 @@ impl Component for UserForm { .with_value(self.language()) .with_label(Lc::t("field-language", &LOCALES_USER)), ) - .with_child( - form::input::Field::text() - .with_name("timezone") - .with_value(self.timezone()) - .with_label(Lc::t("field-timezone", &LOCALES_USER)), - ); + .with_child(timezone_field(self.timezone())); if *self.mode() == UserFormMode::New { - form = form - .with_child(PasswordConfirm::new()) - .with_child(roles_fieldset(self.roles())); + form = form.with_child(PasswordConfirm::new()); + if !self.roles().is_empty() { + form = form.with_child(roles_fieldset(self.roles())); + } if *self.allow_admin_field() { form = form.with_child( @@ -181,3 +206,29 @@ impl UserForm { self } } + +// `