Compare commits

..

3 commits

Author SHA1 Message Date
08d673b89d 📝 (config): Añade referencia de opciones 2026-10-07 01:29:24 +02:00
03473b53e1 💬 Muestra cada idioma en su propia lengua 2026-10-07 01:23:55 +02:00
346714ab29 ✨ (pagetop): Mejora la elección de zona horaria
- Nuevas opciones `app.timezone_regions`, `app.timezone_order` y
  `app.timezone_per_user` para limitar y ordenar las regiones ofrecidas
  y decidir si cada usuario puede tener su propia zona horaria.
- Añade `TzRegion` y `TimezoneOrder`; `Timezone::supported_by_region()`
  devuelve ahora `TzRegion` y excluye los alias de la base IANA que
  repiten otra zona (tzdata 2025b).
- Añade `Timezone::is_supported_in()` para validar con otras regiones.
- `SelectTimezone` admite `with_regions()`, `with_order()` y
  `with_utc_offset()`, muestra nombres legibles y conserva la zona
  actual aunque ya no se ofrezca.
- `app.timezone` se resuelve sin distinguir mayúsculas.
- pagetop-user no ofrece idioma ni zona horaria si no se aplican y
  conserva lo guardado; acepta la zona actual aunque ya no se ofrezca.
2026-10-07 01:20:47 +02:00
22 changed files with 1448 additions and 159 deletions

149
config/reference.toml Normal file
View file

@ -0,0 +1,149 @@
# ==================================================================================================
# Referencia de opciones de configuración de PageTop y sus extensiones.
#
# Este fichero NO se carga: sólo documenta todas las opciones disponibles con su valor por defecto.
# Para usarlas, copia las que necesites en alguno de los ficheros de la cascada de configuración,
# que se aplican en este orden (cada uno sobrescribe al anterior):
#
# 1. config/common.toml -> ajustes comunes a todos los entornos.
# 2. config/{rm}.toml -> ajustes del modo de ejecución (p. ej. default.toml).
# 3. config/local.{rm}.toml -> ajustes locales del modo de ejecución (no se versiona).
# 4. config/local.toml -> ajustes locales para cualquier entorno (no se versiona).
#
# Donde {rm} es el valor de la variable de entorno PAGETOP_RUN_MODE ("default" si no se define).
# El directorio config/ puede cambiarse con la variable de entorno CONFIG_DIR. No hay más
# variables de entorno: el resto de opciones sólo se leen de estos ficheros.
#
# Las opciones comentadas no tienen valor por defecto; se muestran con un valor de ejemplo.
# ==================================================================================================
# **< PageTop (núcleo) >****************************************************************************
[app]
name = "PageTop App" # Nombre de la aplicación.
theme = "Basic" # Tema predeterminado: "Basic" o el de una extensión enlazada
# por la aplicación ("Aliner", "Bootsier"...).
#language = "es-ES" # Idioma predeterminado (BCP 47: "es-ES", "en-US"...). Sin
# valor, o si no es válido, el idioma se negocia con otras
# fuentes (cabecera Accept-Language, idioma de respaldo).
lang_negotiation = "Full" # Estrategia para resolver el idioma de cada petición:
# "Full" -> ?lang de la URL, idioma del usuario,
# 'language', Accept-Language y respaldo.
# "NoQuery" -> igual que "Full" pero sin ?lang.
# "ConfigOnly" -> sólo 'language' o el idioma de respaldo
# (no se ofrece elegir idioma al usuario).
timezone = "UTC" # Zona horaria del sitio (IANA: "UTC", "Europe/Madrid"...);
# da igual escribirla en mayúsculas o minúsculas.
# Se aplica a anónimos y a usuarios sin zona horaria propia,
# y a todos si 'timezone_per_user' es false.
timezone_regions = "" # Regiones de zonas horarias que se ofrecen, separadas por
# comas (p. ej. "Europe, America"): "Africa", "America",
# "Antarctica", "Arctic", "Asia", "Atlantic", "Australia",
# "Europe", "Indian" y "Pacific". "Etc" (Etc/UTC) se ofrece
# siempre. Vacío -> todas. Los nombres desconocidos se ignoran
# con un aviso al arrancar. No cambia las zonas ya asignadas a
# los usuarios (las conservan), sólo las nuevas elecciones.
# Es el valor por defecto del selector de zona horaria; la
# aplicación puede cambiarlo en un formulario concreto.
timezone_order = "Nearest" # Orden de esas regiones al elegir zona horaria:
# "Nearest" -> la del sitio primero, si tiene región, y
# luego por cercanía de su desfase horario.
# "Alphabetical" -> por el nombre de la región traducido al
# idioma de la página.
# "Listed" -> en el orden de 'timezone_regions' (sin
# regiones, como "Alphabetical").
# Es el valor por defecto del selector de zona horaria; la
# aplicación puede cambiarlo en un formulario concreto.
timezone_per_user = true # Si cada usuario puede tener su propia zona horaria (true) o
# se usa siempre 'timezone' (false). Con false no se ofrece
# elegirla y la guardada se conserva por si se reactiva.
startup_banner = "Slant" # Banner ASCII al arrancar: "Off", "Slant", "Small", "Speed"
# o "Starwars".
[dev]
pagetop_static_dir = "" # Directorio static/ de PageTop para servir sus archivos
# estáticos desde disco (editables sin recompilar). Ruta
# absoluta o relativa al proyecto o al binario. Vacío -> se
# sirven los incluidos en el binario.
bootsier_static_dir = "" # Igual que el anterior, para los archivos estáticos de
# pagetop-bootsier.
[log]
enabled = true # Activa (true) o desactiva (false) las trazas.
tracing = "Info" # Filtro de trazas: "Error", "Warn", "Info", "Debug" o
# "Trace", o combinaciones separadas por comas por módulo,
# p. ej. "Info,pagetop=Debug,sqlx=Warn".
rolling = "Stdout" # Salida de las trazas:
# "Stdout" -> terminal, sin archivos.
# "Daily", "Hourly", "Minutely" -> archivos con rotación.
# "Endless" -> un único archivo, sin rotación.
path = "log" # Directorio de los archivos de traza (si rolling != Stdout).
prefix = "tracing.log" # Prefijo de los archivos de traza (si rolling != Stdout).
format = "Full" # Formato: "Full", "Compact", "Pretty" o "Json".
[server]
bind_address = "localhost" # Dirección de enlace del servidor web.
bind_port = 8080 # Puerto de escucha del servidor web.
# **< pagetop-seaorm >******************************************************************************
[database]
db_type = "" # Motor: "mysql" (o "mariadb"), "postgres" (o "postgresql")
# o "sqlite". Obligatorio: sin él la aplicación no arranca.
db_name = "" # Nombre (mysql/postgres) o referencia (sqlite) de la BD.
db_user = "" # Usuario de conexión (mysql/postgres).
db_pass = "" # Contraseña de conexión (mysql/postgres).
db_host = "localhost" # Servidor de la base de datos (mysql/postgres).
#db_port = 5432 # Puerto (mysql/postgres). Sin valor -> 3306 para MySQL y
# 5432 para PostgreSQL.
max_pool_size = 5 # Número máximo de conexiones simultáneas.
# **< pagetop-user >********************************************************************************
[user]
allow_registration = true # Permite que los visitantes creen su propia cuenta.
require_email_verification = false # Las cuentas nuevas quedan pendientes ("Pending") en lugar
# de activas hasta verificar el correo.
session_cookie_name = "pgt_session" # Nombre de la cookie de sesión.
session_ttl_secs = 1209600 # Duración de la sesión en segundos si se marca "recordarme"
# al acceder (14 días).
session_idle_ttl_secs = 7200 # Duración de la sesión en segundos sin "recordarme" (2
# horas). Se cuenta desde el acceso, no desde la última
# actividad.
secure_cookie = false # Marca la cookie como Secure (sólo HTTPS). Activar en
# producción.
max_failed_logins = 5 # Intentos fallidos de acceso antes de bloquear la cuenta.
failed_login_window_secs = 900 # Ventana de recuento de intentos fallidos (15 minutos).
# Reservada: todavía no tiene efecto.
locked_for_secs = 600 # Duración del bloqueo de la cuenta en segundos (10 minutos).
login_strict = false # Dificulta que el navegador recuerde o autorrellene las
# credenciales en el formulario de acceso.
[user.password] # Contraseñas, cifradas con Argon2id.
argon2_m_cost = 19456 # Coste de memoria en KiB. Si cambia, las contraseñas se
# vuelven a cifrar en el siguiente acceso de cada usuario.
argon2_t_cost = 2 # Coste de tiempo (número de iteraciones).
argon2_p_cost = 1 # Grado de paralelismo (número de hilos).
min_length = 8 # Longitud mínima de las contraseñas.
[user.seed] # Primer administrador, creado si no existe ningún usuario.
admin_username = "admin" # Nombre de usuario.
admin_email = "admin@example.com" # Correo electrónico.
#admin_password = "cambiar" # Contraseña. Sin valor -> se genera una aleatoria y se
# muestra una sola vez por la salida estándar al crearlo.
[user.admin]
list_page_size = 20 # Filas por página en los listados de administración
# (usuarios, roles...).
# **< pagetop-bootsier >****************************************************************************
[bootsier]
max_width = "1440px" # Ancho máximo de la página ("100%", "90rem", "1440px"...).

View file

@ -3,6 +3,7 @@
use pagetop::prelude::*;
use crate::LOCALES_USER;
use crate::config::{user_language_applies, user_timezone_applies};
use crate::user_path;
use crate::{ADMIN_USERS_PATH, PROFILE_EDIT_PATH};
@ -86,25 +87,33 @@ impl Component for UserForm {
.with_name("display_name")
.with_value(self.display_name())
.with_label(Lc::t("field-display-name", &LOCALES_USER)),
)
.with_child(
);
// Sólo se ofrecen si se aplican (ver `config::user_language_applies()` y
// `config::user_timezone_applies()`).
if user_language_applies() {
form = form.with_child(
form::SelectLanguage::new()
.with_name("language")
.with_label(Lc::t("field-language", &LOCALES_USER))
.with_selected(self.language()),
)
.with_child(
);
}
if user_timezone_applies() {
form = form.with_child(
form::SelectTimezone::new()
.with_name("timezone")
.with_label(Lc::t("field-timezone", &LOCALES_USER))
.with_utc_offset(true)
.with_selected(self.timezone()),
)
.with_child(
form::SelectTheme::new()
.with_name("theme")
.with_label(Lc::t("field-theme", &LOCALES_USER))
.with_selected(self.theme()),
);
}
form = form.with_child(
form::SelectTheme::new()
.with_name("theme")
.with_label(Lc::t("field-theme", &LOCALES_USER))
.with_selected(self.theme()),
);
if *self.mode() == UserFormMode::New {
form = form.with_child(PasswordConfirm::new());

View file

@ -134,3 +134,17 @@ impl Default for AdminConfig {
AdminConfig { list_page_size: 20 }
}
}
// **< Preferencias del usuario >*******************************************************************
// Si se aplica el idioma propio del usuario: no con `lang_negotiation = "ConfigOnly"`, que usa
// siempre el de la configuración. Si no se aplica, no se ofrece elegirlo ni se borra el guardado.
pub(crate) fn user_language_applies() -> bool {
global::SETTINGS.app.lang_negotiation != global::LangNegotiation::ConfigOnly
}
// Si se aplica la zona horaria propia del usuario (`app.timezone_per_user`). Si no se aplica, no se
// ofrece elegirla ni se borra la guardada.
pub(crate) fn user_timezone_applies() -> bool {
global::SETTINGS.app.timezone_per_user
}

View file

@ -9,6 +9,7 @@ use crate::account::{Account, UserStatus};
use crate::auth;
use crate::component::admin::{UserForm, UserFormMode, status_key};
use crate::component::{ChangePasswordForm, language_name, multiline_text, theme_name};
use crate::config::{user_language_applies, user_timezone_applies};
use crate::entity::{role, user};
use crate::error::AuthError;
use crate::handlers::admin::map_auth_error;
@ -119,17 +120,23 @@ async fn profile_details(user: &user::Model, status: UserStatus, cx: &mut Contex
.with_cell(multiline_text(
user.about.clone().unwrap_or_else(|| "-".into()),
)),
)
.with_row(
);
// Sólo se muestran si se aplican, igual que en el formulario de edición.
if user_language_applies() {
table = table.with_row(
table::Row::new()
.with_cell(Lc::t("field-language", &LOCALES_USER))
.with_cell(language_name(user.language.as_deref())),
)
.with_row(
);
}
if user_timezone_applies() {
table = table.with_row(
table::Row::new()
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
.with_cell(user.timezone.as_deref().unwrap_or("-")),
)
);
}
table = table
.with_row(
table::Row::new()
.with_cell(Lc::t("field-theme", &LOCALES_USER))

View file

@ -14,7 +14,7 @@ use crate::component::admin::{
AdminPasswordForm, USER_ADMIN_FORM_ID, UserForm, UserFormMode, UserTable, status_key,
};
use crate::component::{language_name, multiline_text, theme_name};
use crate::config::SETTINGS;
use crate::config::{SETTINGS, user_language_applies, user_timezone_applies};
use crate::entity::{role, user};
use crate::error::AuthError;
use crate::handlers::admin::{back_link, frame, map_auth_error};
@ -589,17 +589,23 @@ async fn user_view_details(user: &user::Model, status: UserStatus, cx: &mut Cont
.with_cell(multiline_text(
user.about.clone().unwrap_or_else(|| "-".into()),
)),
)
.with_row(
);
// Sólo se muestran si se aplican, igual que en el formulario de edición.
if user_language_applies() {
table = table.with_row(
table::Row::new()
.with_cell(Lc::t("field-language", &LOCALES_USER))
.with_cell(language_name(user.language.as_deref())),
)
.with_row(
);
}
if user_timezone_applies() {
table = table.with_row(
table::Row::new()
.with_cell(Lc::t("field-timezone", &LOCALES_USER))
.with_cell(user.timezone.as_deref().unwrap_or("-")),
)
);
}
table = table
.with_row(
table::Row::new()
.with_cell(Lc::t("field-theme", &LOCALES_USER))

View file

@ -10,6 +10,7 @@ use pagetop_seaorm::db::{
};
use crate::account::UserStatus;
use crate::config::{user_language_applies, user_timezone_applies};
use crate::entity::{role, user, user_role};
use crate::error::AuthError;
use crate::password;
@ -174,8 +175,18 @@ 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 language = validate_language(data.language)?;
let timezone = validate_timezone(data.timezone)?;
// El idioma y la zona horaria que no se aplican no se ofrecen en el formulario: se ignora lo
// que pudiera llegar y el usuario se crea sin ellos.
let language = if user_language_applies() {
validate_language(data.language)?
} else {
None
};
let timezone = if user_timezone_applies() {
validate_timezone(data.timezone)?
} else {
None
};
let theme = validate_theme(data.theme)?;
ensure_username_available(data.username, None).await?;
ensure_email_available(data.email, None).await?;
@ -230,8 +241,24 @@ pub(crate) struct UserUpdateData<'a> {
}
pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Result<(), AuthError> {
let language = validate_language(data.language)?;
let timezone = validate_timezone(data.timezone)?;
// El idioma y la zona horaria que no se aplican tampoco se ofrecen en el formulario, así que no
// llegan: se conserva lo guardado por si se vuelven a aplicar.
let language = if user_language_applies() {
Set(validate_language(data.language)?.map(str::to_owned))
} else {
ActiveValue::NotSet
};
let timezone = if user_timezone_applies() {
let timezone = match validate_timezone(data.timezone) {
Err(AuthError::InvalidTimezone) => {
keep_current_timezone(user_id, data.timezone).await?
}
result => result?,
};
Set(timezone.map(str::to_owned))
} else {
ActiveValue::NotSet
};
let theme = validate_theme(data.theme)?;
// El navegador envía los saltos de línea de un `<textarea>` como `\r\n`, pero `maxlength` puede
// contarlos como un único carácter: se normalizan antes de medir para no rechazar un texto que
@ -254,8 +281,8 @@ pub(crate) async fn update_user(user_id: i32, data: UserUpdateData<'_>) -> Resul
email: Set(data.email.to_owned()),
display_name: Set(data.display_name.map(str::to_owned)),
about: Set(about),
language: Set(language.map(str::to_owned)),
timezone: Set(timezone.map(str::to_owned)),
language,
timezone,
theme: Set(theme.map(str::to_owned)),
updated_at: Set(now),
..Default::default()
@ -448,6 +475,22 @@ fn validate_timezone(timezone: Option<&str>) -> Result<Option<&str>, AuthError>
Ok(timezone)
}
// Acepta una zona que ya no se ofrece si es la que el usuario tenía guardada: el selector la sigue
// mostrando para que volver a guardar el formulario sin tocarla no la descarte. Sólo se consulta la
// base de datos cuando la zona recibida no se ofrece.
async fn keep_current_timezone(
user_id: i32,
timezone: Option<&str>,
) -> Result<Option<&str>, AuthError> {
let timezone = timezone.and_then(util::non_blank);
let current = find_user(user_id).await?.timezone;
if timezone.is_some() && timezone == current.as_deref() {
Ok(timezone)
} else {
Err(AuthError::InvalidTimezone)
}
}
async fn ensure_username_available(
username: &str,
exclude_id: Option<i32>,

View file

@ -64,7 +64,7 @@ impl Application {
// Inicializa el idioma predeterminado.
Locale::init();
// Inicializa la zona horaria predeterminada.
// Inicializa la zona horaria predeterminada y las regiones para elegir.
Timezone::init();
// Registra las extensiones de la aplicación.

View file

@ -22,7 +22,7 @@ use crate::datetime::{Timezone, Tz};
use crate::locale::{LanguageIdentifier, Lc, Locale};
use crate::response::ErrorPage;
use crate::web::HttpRequest;
use crate::{AutoDefault, CowStr, Getters, Weight, builder_impl};
use crate::{AutoDefault, CowStr, Getters, Weight, builder_impl, global};
use std::ops::ControlFlow;
@ -149,16 +149,21 @@ impl CurrentUser {
/// Devuelve la zona horaria efectiva del usuario.
///
/// Devuelve la suya si tiene una; en otro caso, devuelve [`Timezone::default_tz()`].
/// Devuelve su zona horaria si tiene una y [`global::SETTINGS.app.timezone_per_user`] lo
/// permite; en otro caso, 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()`].
///
/// [`global::SETTINGS.app.timezone_per_user`]: crate::global::App::timezone_per_user
/// [`Context`]: crate::core::component::Context
/// [`Contextual::timezone()`]: crate::core::component::Contextual::timezone
pub fn timezone(&self) -> Tz {
self.timezone.unwrap_or_else(Timezone::default_tz)
match self.timezone {
Some(tz) if global::SETTINGS.app.timezone_per_user => tz,
_ => Timezone::default_tz(),
}
}
}

View file

@ -3,8 +3,10 @@ use crate::prelude::*;
/// Componente para **elegir un idioma** de la lista de idiomas soportados por PageTop.
///
/// Ofrece un elemento por cada idioma de [`Locale::supported_languages()`], con su identificador
/// como valor (p. ej. `"es-ES"`) y su nombre traducido como etiqueta, ordenados por ese nombre en
/// el idioma de la página. Se renderiza como cualquier [`form::select::Field`].
/// como valor (p. ej. `"es-ES"`) y su nombre escrito en ese mismo idioma como etiqueta (p. ej.
/// *"Español (España)"* o *"English (United States)"*), sea cual sea el idioma de la página; así
/// cualquiera reconoce el suyo aunque no entienda el de la página. Se ordenan por ese nombre, sin
/// distinguir mayúsculas ni acentos. Se renderiza como cualquier [`form::select::Field`].
///
/// La primera opción, con valor vacío, depende de si el campo es obligatorio:
///
@ -46,8 +48,16 @@ impl Component for SelectLanguage {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let mut field = self.field().clone();
let mut languages = Locale::supported_languages();
languages.sort_by_cached_key(|(_, name)| name.collation_key(&*cx));
let mut languages: Vec<_> = Locale::supported_languages()
.into_iter()
.map(|(langid, name)| {
let own = name
.lookup(&Locale::Resolved(langid))
.unwrap_or_else(|| langid.to_string());
(langid, own)
})
.collect();
languages.sort_by_cached_key(|(_, name)| Lc::n(name.clone()).collation_key(&*cx));
let selected = Locale::resolve(self.selected()).as_option();
let known = selected.is_some();
@ -56,8 +66,7 @@ impl Component for SelectLanguage {
let default_name = languages
.iter()
.find(|(langid, _)| *langid == default_langid)
.and_then(|(_, name)| name.lookup(cx))
.unwrap_or_else(|| default_langid.to_string());
.map_or_else(|| default_langid.to_string(), |(_, name)| name.clone());
let label = Lc::l("select_language_site_default").with_arg("language", default_name);
field.alter_item(form::select::Item::new("", label).with_selected(!known));
} else if !known {
@ -66,7 +75,7 @@ impl Component for SelectLanguage {
}
for (langid, name) in languages {
let item = form::select::Item::new(langid.to_string(), name);
let item = form::select::Item::new(langid.to_string(), Lc::n(name));
field.alter_item(item.with_selected(selected == Some(langid)));
}
Ok(field.render(cx).await)

View file

@ -1,21 +1,45 @@
use crate::prelude::*;
use chrono::Offset;
/// Componente para crear una **lista de selección de zonas horarias** IANA.
///
/// Ofrece las zonas horarias de [`Timezone::supported_by_region()`] agrupadas por región, con el
/// nombre de la región traducido (*"Europa"*, *"América"*...) y el nombre IANA completo como valor
/// y como etiqueta (p. ej. `"Europe/Madrid"`). No admite opciones libres. Se renderiza como
/// cualquier [`form::select::Field`].
/// Ofrece zonas horarias agrupadas por región, con el nombre de la región traducido (*"Europa"*,
/// *"América"*, etc.). Cada opción tiene como valor el nombre IANA completo (p. ej.
/// `"America/New_York"`) y como etiqueta ese mismo nombre con espacios en lugar de guiones bajos
/// (p. ej. `"America/New York"`). No admite opciones libres. Se renderiza como cualquier
/// [`form::select::Field`]. Con [`with_utc_offset(true)`] cada etiqueta añade además el desfase
/// actual respecto a UTC (p. ej. `"America/New York (UTC-05:00)"`) y las zonas de cada región se
/// ordenan por ese desfase y, a igual desfase, por nombre.
///
/// Por defecto ofrece las regiones y el orden de la configuración, los mismos que
/// [`Timezone::supported_by_region()`]; [`with_regions()`] y [`with_order()`] permiten cambiarlos
/// para este componente. Cuando las regiones se ordenan por nombre (con
/// [`TimezoneOrder::Alphabetical`], o [`TimezoneOrder::Listed`] sin regiones), se ordenan por su
/// nombre traducido al idioma de la página, sin distinguir mayúsculas ni acentos, y *"Otras"*
/// (`Etc/UTC`) sigue al final.
///
/// La primera opción, con valor vacío, depende de si el campo es obligatorio:
///
/// - Si no lo es (por defecto), siempre se incluye y propone usar la zona horaria del sitio.
/// Quedarse sin zona propia es válido y significa usar la predeterminada de la aplicación. Se
/// selecciona cuando el valor elegido no corresponde a ninguna zona de la lista.
/// - Si lo es ([`with_required(true)`](Self::with_required)), sólo se incluye cuando el valor
/// seleccionado no corresponde a ninguna zona de la lista, y pide elegir una; así el navegador no
/// deja enviar el formulario sin elegir una zona horaria. El valor recibido debe validarse
/// igualmente en el servidor.
/// selecciona cuando el valor elegido no es ninguna zona horaria válida.
/// - Si lo es ([`with_required(true)`]), sólo se incluye cuando el valor seleccionado no es ninguna
/// zona horaria válida, y pide elegir una; así el navegador no deja enviar el formulario sin
/// elegir una zona horaria.
///
/// Si el valor seleccionado es una zona horaria válida que no se ofrece (p. ej. de una región que
/// se ha dejado de ofrecer después de elegirla), se muestra igualmente, seleccionada, en un grupo
/// *"Zona horaria actual"* antes de las regiones; así volver a guardar el formulario sin tocarla no
/// la descarta.
///
/// El valor recibido debe validarse igualmente en el servidor: con
/// [`Timezone::supported_by_region()`] si se usan las regiones de la configuración, o con
/// [`Timezone::is_supported_in()`] si se han cambiado; y aceptando además, sin cambios, el valor
/// que ya estuviera guardado aunque no se ofrezca.
///
/// Si [`global::SETTINGS.app.timezone_per_user`] es *false*, la zona horaria propia del usuario no
/// se aplica y no conviene mostrar este campo.
///
/// # Ejemplo
///
@ -26,6 +50,14 @@ use crate::prelude::*;
/// .with_label(Lc::n("Time zone"))
/// .with_selected("Europe/Madrid");
/// ```
///
/// [`TimezoneOrder::Alphabetical`]: crate::global::TimezoneOrder::Alphabetical
/// [`TimezoneOrder::Listed`]: crate::global::TimezoneOrder::Listed
/// [`global::SETTINGS.app.timezone_per_user`]: crate::global::App::timezone_per_user
/// [`with_utc_offset(true)`]: Self::with_utc_offset
/// [`with_required(true)`]: Self::with_required
/// [`with_regions()`]: Self::with_regions
/// [`with_order()`]: Self::with_order
#[derive(AutoDefault, Clone, Debug, Getters)]
pub struct SelectTimezone {
/// Devuelve la lista de selección interna con la configuración común (nombre, etiqueta, ayuda,
@ -33,6 +65,16 @@ pub struct SelectTimezone {
field: form::select::Field,
/// Devuelve el nombre IANA de la zona horaria seleccionada.
selected: String,
/// Devuelve si las etiquetas muestran el desfase actual respecto a UTC.
utc_offset: bool,
/// Devuelve las regiones que se ofrecen, junto a `Etc`, que se ofrece siempre; por defecto, las
/// de [`global::SETTINGS.app.timezone_regions`](crate::global::App::timezone_regions).
#[default(_code = "Timezone::configured_regions()")]
regions: Vec<TzRegion>,
/// Devuelve el orden de las regiones; por defecto, el de
/// [`global::SETTINGS.app.timezone_order`](crate::global::App::timezone_order).
#[default(_code = "global::SETTINGS.app.timezone_order")]
order: global::TimezoneOrder,
}
#[async_trait]
@ -47,37 +89,61 @@ impl Component for SelectTimezone {
async fn prepare(&self, cx: &mut Context) -> Result<Markup, ComponentError> {
let mut field = self.field().clone();
let regions = Timezone::supported_by_region();
let known = regions
let regions = Timezone::regions_with(*self.order(), self.regions());
let offered = regions
.iter()
.any(|(_, names)| names.contains(&self.selected()));
// Una zona válida que no se ofrece (p. ej. de una región retirada después) se muestra igual
// para que guardar el formulario sin tocarla no la descarte.
let current = (!offered)
.then(|| self.selected().parse::<Tz>().ok())
.flatten();
let known = offered || current.is_some();
// El desfase se calcula en cada renderizado porque cambia con el horario de verano.
let now = Utc::now();
let offset = |name: &str| {
self.utc_offset()
.then(|| name.parse::<Tz>().ok())
.flatten()
.map(|tz| now.with_timezone(&tz).offset().fix().local_minus_utc())
};
if !field.required() {
let site = Timezone::default_tz().name();
let label = Lc::l("select_timezone_site_default")
.with_arg("timezone", Timezone::default_tz().name());
.with_arg("timezone", zone_label(zone_name(site), offset(site)));
field.alter_item(form::select::Item::new("", label).with_selected(!known));
} else if !known {
let label = Lc::l("select_timezone_placeholder");
field.alter_item(form::select::Item::new("", label).with_selected(true));
}
for (region, names) in regions {
let mut group = form::select::Group::new(match *region {
"Africa" => Lc::l("timezone_region_africa"),
"America" => Lc::l("timezone_region_america"),
"Antarctica" => Lc::l("timezone_region_antarctica"),
"Arctic" => Lc::l("timezone_region_arctic"),
"Asia" => Lc::l("timezone_region_asia"),
"Atlantic" => Lc::l("timezone_region_atlantic"),
"Australia" => Lc::l("timezone_region_australia"),
"Etc" => Lc::l("timezone_region_etc"),
"Europe" => Lc::l("timezone_region_europe"),
"Indian" => Lc::l("timezone_region_indian"),
"Pacific" => Lc::l("timezone_region_pacific"),
_ => Lc::n(*region),
});
for name in names {
let selected = *name == self.selected();
if let Some(tz) = current {
let name = tz.name();
let mut group = form::select::Group::new(Lc::l("select_timezone_current"));
group.alter_item(
form::select::Item::new(name, Lc::n(zone_label(zone_name(name), offset(name))))
.with_selected(true),
);
field.alter_group(group);
}
let mut groups: Vec<_> = regions
.iter()
.map(|(region, names)| (*region, region.label(), names))
.collect();
if Timezone::regions_by_name(*self.order(), self.regions()) {
sort_by_label(&mut groups, cx);
}
for (_, label, names) in groups {
let mut group = form::select::Group::new(label);
let mut zones: Vec<_> = names.iter().map(|name| (offset(name), *name)).collect();
// Ordenación estable: a igual desfase, se mantienen ordenadas por nombre.
if *self.utc_offset() {
zones.sort_by_key(|(offset, _)| *offset);
}
for (offset, name) in zones {
let selected = name == self.selected();
group.alter_item(
form::select::Item::new(*name, Lc::n(*name)).with_selected(selected),
form::select::Item::new(name, Lc::n(zone_label(zone_name(name), offset)))
.with_selected(selected),
);
}
field.alter_group(group);
@ -86,6 +152,35 @@ impl Component for SelectTimezone {
}
}
// Ordena las regiones por su nombre traducido al idioma dado, sin distinguir mayúsculas ni acentos
// (`Lc::collation_key()`); `Etc` sigue al final, como en `supported_by_region()`.
fn sort_by_label<T>(groups: &mut [(TzRegion, Lc, T)], language: &impl LangId) {
groups.sort_by_cached_key(|(region, label, _)| {
(*region == TzRegion::Etc, label.collation_key(language))
});
}
// Nombre legible de una zona horaria usando espacios en lugar de guiones bajos (p. ej.
// `"America/New York"` para `"America/New_York"`).
fn zone_name(name: &'static str) -> CowStr {
if name.contains('_') {
name.replace('_', " ").into()
} else {
name.into()
}
}
// Etiqueta de una zona horaria: su nombre legible y, si se indica, su desfase en segundos respecto
// a UTC (p. ej. `"Europe/Madrid (UTC+02:00)"`).
fn zone_label(name: CowStr, offset: Option<i32>) -> CowStr {
let Some(offset) = offset else {
return name;
};
let sign = if offset < 0 { '-' } else { '+' };
let minutes = offset.abs() / 60;
format!("{name} (UTC{sign}{:02}:{:02})", minutes / 60, minutes % 60).into()
}
#[builder_impl]
impl SelectTimezone {
// **< SelectTimezone BUILDER >*****************************************************************
@ -133,10 +228,76 @@ impl SelectTimezone {
self
}
/// Establece si las etiquetas muestran el desfase actual respecto a UTC, incluida la de la
/// opción de usar la zona horaria del sitio (p. ej. `"Europe/Madrid (UTC+02:00)"`), y ordenan
/// las zonas de cada región por ese desfase y, a igual desfase, por nombre. Por defecto sólo se
/// muestra el nombre de la zona y las zonas se ordenan por nombre.
pub fn with_utc_offset(mut self, utc_offset: bool) -> Self {
self.utc_offset = utc_offset;
self
}
/// Establece las regiones que se ofrecen (p. ej. `[TzRegion::Europe, TzRegion::America]`).
/// [`TzRegion::Etc`] (`Etc/UTC`) se ofrece siempre, se indique o no; una lista vacía ofrece
/// todas. Valida después el valor recibido con [`Timezone::is_supported_in()`] y la misma
/// lista.
pub fn with_regions(mut self, regions: impl IntoIterator<Item = TzRegion>) -> Self {
self.regions = regions.into_iter().collect();
self
}
/// Establece el orden de las regiones (ver [`TimezoneOrder`](crate::global::TimezoneOrder)).
pub fn with_order(mut self, order: global::TimezoneOrder) -> Self {
self.order = order;
self
}
/// Establece el nombre IANA de la zona horaria seleccionada (p. ej. `"Europe/Madrid"`). Vacía,
/// o una que no esté en la lista, selecciona la primera opción (ver [`SelectTimezone`]).
/// o un nombre que no sea una zona horaria válida, selecciona la primera opción (ver
/// [`SelectTimezone`]).
pub fn with_selected(mut self, selected: impl Into<String>) -> Self {
self.selected = selected.into();
self
}
}
#[cfg(test)]
mod tests {
use super::sort_by_label;
use crate::datetime::TzRegion::{self, *};
use crate::locale::{Lc, Locale};
fn sorted(groups: &[(TzRegion, Lc)], language: &str) -> Vec<TzRegion> {
let mut groups: Vec<_> = groups
.iter()
.map(|(region, label)| (*region, label.clone(), ()))
.collect();
sort_by_label(&mut groups, &Locale::resolve(language));
groups.into_iter().map(|(region, _, _)| region).collect()
}
#[test]
fn regions_are_sorted_ignoring_accents_with_etc_last() {
let groups: Vec<_> = [Etc, Pacific, Asia, Arctic, Antarctica, Africa]
.into_iter()
.map(|region| (region, region.label()))
.collect();
assert_eq!(
sorted(&groups, "es-ES"),
[Africa, Antarctica, Arctic, Asia, Pacific, Etc]
);
}
// With the current translations the translated order matches the IANA names; these labels
// change it to check that regions are sorted by their translation.
#[test]
fn regions_are_sorted_by_translated_label_not_by_iana_name() {
let groups = [
(Atlantic, Lc::n("Océano Atlántico")),
(Etc, Lc::n("Alguna otra")),
(Europe, Lc::n("Europa")),
(Pacific, Lc::n("Pacífico")),
];
assert_eq!(sorted(&groups, "es-ES"), [Europe, Atlantic, Pacific, Etc]);
}
}

View file

@ -4,15 +4,22 @@
//!
//! 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.
//! válida y [`timezone_per_user`] lo permite; 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.
//!
//! Las reglas de cada zona horaria (desfase respecto a UTC, horario de verano) son las de la base
//! de datos de zonas horarias de la IANA que incluye [chrono-tz] al compilar. Como los países
//! cambian sus reglas, una aplicación compilada sigue usando las reglas anteriores hasta que se
//! recompila con una versión más reciente de `chrono-tz`; conviene hacerlo periódicamente. La traza
//! de arranque indica la versión de la base de datos en uso (p. ej. `2025b`).
//!
//! [`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
//! 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.
//!
@ -27,6 +34,7 @@
//! [chrono]: https://docs.rs/chrono
//! [chrono-tz]: https://docs.rs/chrono-tz
//! [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone
//! [`timezone_per_user`]: crate::global::App::timezone_per_user
//! [`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
@ -60,6 +68,9 @@ pub use chrono_tz::{TZ_VARIANTS, Tz};
mod definition;
pub use definition::Timezone;
mod region;
pub use region::TzRegion;
mod format;
pub use format::{DateFormat, TimeFormat};

View file

@ -1,72 +1,271 @@
use crate::{global, trace, util};
use crate::global::{self, TimezoneOrder};
use crate::{trace, util};
use super::{TZ_VARIANTS, Tz};
use super::{DateTime, TZ_VARIANTS, TimeZone, Tz, TzRegion};
use chrono::Offset;
use std::collections::BTreeMap;
use std::f64::consts::TAU;
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(|| {
// Valor de `app.timezone` sin espacios al principio ni al final, si no está vacío.
fn configured_raw() -> Option<&'static str> {
global::SETTINGS
.app
.timezone
.as_deref()
.and_then(util::non_blank)
.and_then(|raw| raw.parse().ok())
}
// Zona horaria configurada para la aplicación, si es válida. El nombre se busca sin distinguir
// mayúsculas y minúsculas, como las regiones de `app.timezone_regions`.
static CONFIG_TZ: LazyLock<Option<Tz>> = LazyLock::new(|| {
configured_raw().and_then(|raw| {
TZ_VARIANTS
.iter()
.copied()
.find(|tz| tz.name().eq_ignore_ascii_case(raw))
})
});
// Zona horaria de respaldo, garantizada incluso sin configuración válida.
const FALLBACK_TZ: Tz = Tz::UTC;
// 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 agrupadas por región (lo anterior a la primera `/`), ordenadas por región y
// nombre. Se descartan los nombres sin región (alias heredados como `GB`, `Japan` o `EST5EDT`) y
// las regiones de `LEGACY_REGIONS`. De `Etc` sólo se conserva `Etc/UTC`, cuyo grupo va al final:
// el resto son zonas de desfase fijo (`Etc/GMT+1`...) que no representan ningún lugar. Siguen
// apareciendo los alias heredados que viven dentro de una región normal (p. ej. `Asia/Calcutta`
// junto a `Asia/Kolkata`): `chrono-tz` no distingue zonas canónicas de enlaces y filtrarlos
// exigiría mantener a mano una lista de casi 180 nombres.
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()
// Regiones válidas de `app.timezone_regions`; los nombres desconocidos se ignoran (ver `init()`).
static CONFIG_REGIONS: LazyLock<Vec<TzRegion>> = LazyLock::new(|| {
global::SETTINGS
.app
.timezone_regions
.split(',')
.filter_map(TzRegion::from_name)
.collect()
});
/// Zona horaria configurada para la aplicación.
// Zonas horarias que se ofrecen para elegir según la configuración (ver `regions_by()`).
static TZ_BY_REGION: LazyLock<Vec<(TzRegion, &'static [&'static str])>> =
LazyLock::new(|| Timezone::regions_with(global::SETTINGS.app.timezone_order, &CONFIG_REGIONS));
// Alias de la base IANA que no se ofrecen porque repiten otra zona que sí se ofrece: nombres
// antiguos (`Australia/NSW`), grafías alternativas (`Asia/Calcutta` es `Asia/Kolkata`) y lugares
// que duplican a otro sin representar a ningún país (`America/Montreal` es `America/Toronto`).
//
// Son los enlaces de cuatro secciones del fichero `backward` de tzdata 2025b (el que incluye
// `chrono-tz`): "Pre-1993 naming conventions", "Two-part names that were renamed...", "Non-zone.tab
// locations..." y "Alternate names for the same location"; salvo `Asia/Istanbul` y
// `Europe/Nicosia`, que la IANA mantiene para encontrar Turquía y Chipre en los dos continentes. Se
// conservan los de la sección "Pre-2013 practice" (`Europe/Oslo`, `Africa/Accra`...); son zonas
// fusionadas con otra porque coinciden desde 1970, pero cada una es la referencia de su país.
//
// Revisar al actualizar `chrono-tz`. Si quedara desfasada, reaparecería algún duplicado o se
// ocultaría una zona que hubiera dejado de ser alias; el test avisa si alguno deja de existir.
const ALIAS_ZONES: &[&str] = &[
"Africa/Asmera",
"Africa/Timbuktu",
"America/Argentina/ComodRivadavia",
"America/Atka",
"America/Buenos_Aires",
"America/Catamarca",
"America/Coral_Harbour",
"America/Cordoba",
"America/Ensenada",
"America/Fort_Wayne",
"America/Godthab",
"America/Indianapolis",
"America/Jujuy",
"America/Knox_IN",
"America/Louisville",
"America/Mendoza",
"America/Montreal",
"America/Nipigon",
"America/Pangnirtung",
"America/Porto_Acre",
"America/Rainy_River",
"America/Rosario",
"America/Santa_Isabel",
"America/Shiprock",
"America/Thunder_Bay",
"America/Virgin",
"America/Yellowknife",
"Antarctica/South_Pole",
"Asia/Ashkhabad",
"Asia/Calcutta",
"Asia/Choibalsan",
"Asia/Chongqing",
"Asia/Chungking",
"Asia/Dacca",
"Asia/Harbin",
"Asia/Kashgar",
"Asia/Katmandu",
"Asia/Macao",
"Asia/Rangoon",
"Asia/Saigon",
"Asia/Tel_Aviv",
"Asia/Thimbu",
"Asia/Ujung_Pandang",
"Asia/Ulan_Bator",
"Atlantic/Faeroe",
"Atlantic/Jan_Mayen",
"Australia/ACT",
"Australia/Canberra",
"Australia/Currie",
"Australia/LHI",
"Australia/NSW",
"Australia/North",
"Australia/Queensland",
"Australia/South",
"Australia/Tasmania",
"Australia/Victoria",
"Australia/West",
"Australia/Yancowinna",
"Europe/Belfast",
"Europe/Kiev",
"Europe/Tiraspol",
"Europe/Uzhgorod",
"Europe/Zaporozhye",
"Pacific/Enderbury",
"Pacific/Johnston",
"Pacific/Ponape",
"Pacific/Samoa",
"Pacific/Truk",
"Pacific/Yap",
];
// Región de zonas horarias con sus zonas ordenadas por nombre y el ángulo de su desfase medio en un
// reloj de 24 horas (ver `angle()`).
struct Region {
region: TzRegion,
zones: Vec<&'static str>,
center: f64,
}
// Todas las regiones que se pueden ofrecer, ordenadas por nombre. Sólo se conservan las zonas de
// alguna región de `TzRegion`, lo que descarta los nombres sin región (alias heredados como `GB`,
// `Japan` o `EST5EDT`) y las regiones formadas sólo por alias heredados (`US/Eastern` es
// `America/New_York`). De `Etc` sólo se conserva `Etc/UTC`: el resto son alias de UTC
// (`Etc/Zulu`...) o zonas de desfase fijo (`Etc/GMT+1`...) que no representan ningún lugar.
// También se descartan los alias de `ALIAS_ZONES`: `chrono-tz` no distingue zonas canónicas de
// enlaces.
//
// Sin coordenadas en `chrono-tz`, la posición de cada región es el desfase medio de sus zonas,
// tratado como un ángulo (media circular) porque `Pacific` abarca de -11 a +14 horas y su centro
// real está junto a la línea de cambio de fecha, no a medio camino entre ambos extremos.
static ALL_REGIONS: LazyLock<Vec<Region>> = LazyLock::new(|| {
// Por región: nombres de sus zonas y suma de senos y cosenos de sus desfases.
let mut regions: BTreeMap<TzRegion, (Vec<&'static str>, f64, f64)> = BTreeMap::new();
for tz in TZ_VARIANTS.iter() {
let name = tz.name();
let Some(region) = name
.split_once('/')
.and_then(|(region, _)| TzRegion::from_name(region))
else {
continue;
};
if (region == TzRegion::Etc && name != "Etc/UTC")
|| ALIAS_ZONES.binary_search(&name).is_ok()
{
continue;
}
let (zones, sin, cos) = regions.entry(region).or_default();
let a = angle(tz);
zones.push(name);
*sin += a.sin();
*cos += a.cos();
}
regions
.into_iter()
.map(|(region, (mut zones, sin, cos))| {
zones.sort_unstable();
Region {
region,
zones,
center: sin.atan2(cos),
}
})
.collect()
});
// Desfase de una zona horaria respecto a UTC como ángulo de un reloj de 24 horas. Se toma en un
// instante fijo (15/01/2026) para que el orden no dependa de la fecha; el horario de verano movería
// los desfases normalmente una hora.
fn angle(tz: &Tz) -> f64 {
let at = DateTime::from_timestamp(1_768_435_200, 0)
.unwrap_or_default()
.naive_utc();
let seconds = tz.offset_from_utc_datetime(&at).fix().local_minus_utc();
f64::from(seconds) / 86_400.0 * TAU
}
// Si la región se ofrece con la lista dada: con una lista vacía se ofrecen todas; si no, sólo las
// indicadas y `Etc`, que se mantiene siempre porque `Etc/UTC` equivale a `UTC`, la zona horaria de
// respaldo.
fn is_offered(region: TzRegion, list: &[TzRegion]) -> bool {
list.is_empty() || region == TzRegion::Etc || list.contains(&region)
}
// Regiones que se ofrecen con la lista dada (ver `is_offered()`), ordenadas según `order`. `Etc` va
// siempre al final. `site` es la referencia de `Nearest`, que mide la cercanía con media y
// distancia circulares.
fn regions_by(order: TimezoneOrder, list: &[TzRegion], site: Tz) -> Vec<&'static Region> {
let site_region = site
.name()
.split_once('/')
.and_then(|(region, _)| TzRegion::from_name(region));
let site_angle = angle(&site);
let mut sorted: Vec<_> = ALL_REGIONS
.iter()
.filter(|r| is_offered(r.region, list))
.map(|r| {
// Grupo (0 primero, 2 al final) y clave de orden dentro del grupo; empates por nombre.
let (rank, key) = match order {
_ if r.region == TzRegion::Etc => (2, 0.0),
TimezoneOrder::Nearest if Some(r.region) == site_region => (0, 0.0),
TimezoneOrder::Nearest => {
let gap = (r.center - site_angle).rem_euclid(TAU);
(1, gap.min(TAU - gap))
}
TimezoneOrder::Alphabetical => (1, 0.0),
TimezoneOrder::Listed => {
let position = list.iter().position(|listed| *listed == r.region);
(1, position.map_or(0.0, |p| p as f64))
}
};
(rank, key, r)
})
.collect();
sorted.sort_by(|a, b| {
a.0.cmp(&b.0)
.then(a.1.total_cmp(&b.1))
.then(a.2.region.as_str().cmp(b.2.region.as_str()))
});
sorted.into_iter().map(|(_, _, r)| r).collect()
}
/// Zona horaria configurada para la aplicación y zonas horarias para elegir.
///
/// 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`).
/// Resuelve [`global::SETTINGS.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`). Ofrece
/// además las zonas horarias que se pueden elegir ([`supported_by_region()`],
/// [`is_supported_in()`]).
///
/// [`global::SETTINGS.app.timezone`]: crate::global::App::timezone
/// [`supported_by_region()`]: Self::supported_by_region
/// [`is_supported_in()`]: Self::is_supported_in
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.
// Inicializa la zona horaria por defecto y las regiones para elegir.
//
// 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, y para
// avisar de las regiones de `app.timezone_regions` que no existan. De paso, calcula al arrancar
// las zonas horarias que se ofrecen, en vez de esperar a la primera petición que las necesite.
pub(crate) fn init() {
match global::SETTINGS
.app
.timezone
.as_deref()
.and_then(util::non_blank)
{
trace::debug!(
"Timezone database: IANA tzdata {}",
chrono_tz::IANA_TZDB_VERSION
);
match configured_raw() {
Some(raw) => {
if let Some(tz) = *CONFIG_TZ {
trace::debug!("Default timezone \"{tz}\" (from config: \"{raw}\")");
@ -76,8 +275,23 @@ impl Timezone {
);
}
}
_ => trace::debug!("Default timezone \"{FALLBACK_TZ}\" (fallback, no config)"),
None => trace::debug!("Default timezone \"{FALLBACK_TZ}\" (fallback, no config)"),
}
for name in global::SETTINGS.app.timezone_regions.split(',') {
if !name.trim().is_empty() && TzRegion::from_name(name).is_none() {
trace::warn!("Ignored unknown timezone region \"{}\"", name.trim());
}
}
let regions: Vec<_> = Self::supported_by_region()
.iter()
.map(|(region, _)| region.as_str())
.collect();
let order = global::SETTINGS.app.timezone_order;
trace::debug!(
"Timezone regions offered ({order:?}): {}",
regions.join(", ")
);
}
/// Devuelve la zona horaria configurada explícitamente, si es válida.
@ -103,13 +317,25 @@ impl Timezone {
/// Devuelve las zonas horarias IANA que se ofrecen para elegir, agrupadas por región.
///
/// Cada grupo es la región (lo anterior a la primera `/`, p. ej. `"Europe"`) con los nombres
/// completos de sus zonas (p. ej. `"Europe/Madrid"`), ordenados por región y nombre; el grupo
/// `"Etc"`, sólo con `"Etc/UTC"`, va al final. Se excluyen los nombres sin región (`"UTC"`,
/// `"Japan"`...), las regiones formadas sólo por alias heredados (`"US"`, `"Canada"`...) y las
/// zonas de desfase fijo (`"Etc/GMT+1"`...). Es la lista que ofrece
/// [`form::SelectTimezone`](crate::base::component::form::SelectTimezone), útil también para
/// validar el valor recibido.
/// Cada grupo es la región ([`TzRegion`]) con los nombres completos de sus zonas (p. ej.
/// `"Europe/Madrid"`) ordenados por nombre. Las regiones siguen el orden de
/// [`global::SETTINGS.app.timezone_order`]: por defecto, primero la región de la zona horaria
/// del sitio, si la tiene, y luego el resto, de la más cercana a la más lejana; con
/// *"Alphabetical"*, o *"Listed"* sin regiones, se ordenan por su nombre IANA, y quien las
/// muestre traducidas debe reordenarlas por el nombre traducido, como hace
/// [`form::SelectTimezone`]. [`TzRegion::Etc`], sólo con `"Etc/UTC"`, va siempre al final.
///
/// Se excluyen los nombres sin región (`"UTC"`, `"Japan"`...), las regiones formadas sólo por
/// alias heredados (`"US"`, `"Canada"`...), los alias que sólo repiten otra zona con otro
/// nombre (`"Asia/Calcutta"` es `"Asia/Kolkata"`), aunque se conservan las zonas de referencia
/// de cada país fusionadas con otra (`"Europe/Oslo"`), y las zonas de desfase fijo
/// (`"Etc/GMT+1"`...). Se limita a las regiones indicadas en
/// [`global::SETTINGS.app.timezone_regions`], si las hay, más [`TzRegion::Etc`], que se ofrece
/// siempre.
///
/// Son las zonas horarias que ofrece por defecto [`form::SelectTimezone`], útiles también para
/// validar el valor recibido, aceptando además, sin cambios, el que ya estuviera guardado
/// aunque ya no se ofrezca.
///
/// # Ejemplo
///
@ -124,7 +350,205 @@ impl Timezone {
/// assert!(is_supported("Europe/Madrid"));
/// assert!(!is_supported("US/Eastern"));
/// ```
pub fn supported_by_region() -> &'static [(&'static str, Vec<&'static str>)] {
///
/// [`global::SETTINGS.app.timezone_order`]: crate::global::App::timezone_order
/// [`global::SETTINGS.app.timezone_regions`]: crate::global::App::timezone_regions
/// [`form::SelectTimezone`]: crate::base::component::form::SelectTimezone
pub fn supported_by_region() -> &'static [(TzRegion, &'static [&'static str])] {
&TZ_BY_REGION
}
/// Devuelve si `name` es una de las zonas horarias que se ofrecen para elegir con las regiones
/// indicadas: las de esas regiones más `Etc/UTC`, que se ofrece siempre, o las de todas si la
/// lista está vacía. `name` debe ser el nombre IANA exacto, sin espacios y con las mayúsculas
/// correctas.
///
/// Sirve para validar el valor recibido de un [`form::SelectTimezone`] al que se le hayan
/// cambiado las regiones con `with_regions()`; con las de la configuración basta
/// [`supported_by_region()`]. En ambos casos debe aceptarse también, sin cambios, el valor que
/// ya estuviera guardado aunque ya no se ofrezca.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// let europe = [TzRegion::Europe];
/// assert!(Timezone::is_supported_in("Europe/Madrid", &europe));
/// assert!(Timezone::is_supported_in("Etc/UTC", &europe));
/// assert!(!Timezone::is_supported_in("Asia/Tokyo", &europe));
/// assert!(Timezone::is_supported_in("Asia/Tokyo", &[]));
/// ```
///
/// [`form::SelectTimezone`]: crate::base::component::form::SelectTimezone
/// [`supported_by_region()`]: Self::supported_by_region
pub fn is_supported_in(name: impl AsRef<str>, regions: &[TzRegion]) -> bool {
let name = name.as_ref();
ALL_REGIONS
.iter()
.any(|r| is_offered(r.region, regions) && r.zones.binary_search(&name).is_ok())
}
// Regiones de `app.timezone_regions`, valor por defecto de `SelectTimezone::with_regions()`.
pub(crate) fn configured_regions() -> Vec<TzRegion> {
CONFIG_REGIONS.clone()
}
// Regiones que se ofrecen con el orden y la lista dados; `supported_by_region()` es el
// resultado con los de la configuración.
pub(crate) fn regions_with(
order: TimezoneOrder,
regions: &[TzRegion],
) -> Vec<(TzRegion, &'static [&'static str])> {
regions_by(order, regions, Self::default_tz())
.into_iter()
.map(|r| (r.region, r.zones.as_slice()))
.collect()
}
// Si las regiones se ordenan por nombre: con `Alphabetical`, o con `Listed` sin lista. Se
// ordenan por su nombre IANA, pero quien las muestre traducidas debe reordenarlas por el nombre
// traducido.
pub(crate) fn regions_by_name(order: TimezoneOrder, regions: &[TzRegion]) -> bool {
match order {
TimezoneOrder::Nearest => false,
TimezoneOrder::Alphabetical => true,
TimezoneOrder::Listed => regions.is_empty(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use TimezoneOrder::{Alphabetical, Listed, Nearest};
use TzRegion::*;
fn tz(name: &str) -> Tz {
name.parse().unwrap()
}
fn order(by: TimezoneOrder, list: &[TzRegion], site: &str) -> Vec<TzRegion> {
regions_by(by, list, tz(site))
.into_iter()
.map(|r| r.region)
.collect()
}
fn position(regions: &[TzRegion], region: TzRegion) -> usize {
regions.iter().position(|r| *r == region).unwrap()
}
#[test]
fn offers_every_region_with_sorted_zones_and_only_etc_utc() {
let regions = regions_by(Alphabetical, &[], tz("UTC"));
let names: Vec<_> = regions.iter().map(|r| r.region).collect();
assert_eq!(
names,
[
Africa, America, Antarctica, Arctic, Asia, Atlantic, Australia, Europe, Indian,
Pacific, Etc
]
);
assert_eq!(
regions.last().map(|r| r.zones.as_slice()),
Some(&["Etc/UTC"][..])
);
for r in &regions {
assert!(
r.zones.is_sorted(),
"zones of {:?} are not sorted by name",
r.region
);
}
}
#[test]
fn filters_regions_and_keeps_etc_even_when_not_listed() {
assert_eq!(
order(Alphabetical, &[Europe, Asia], "UTC"),
[Asia, Europe, Etc]
);
assert_eq!(order(Alphabetical, &[Etc], "UTC"), [Etc]);
assert_eq!(order(Alphabetical, &[], "UTC").len(), 11);
}
#[test]
fn listed_follows_the_order_of_the_list() {
assert_eq!(
order(Listed, &[Pacific, Europe, Asia], "UTC"),
[Pacific, Europe, Asia, Etc]
);
}
#[test]
fn listed_keeps_etc_last_even_when_listed_first() {
assert_eq!(order(Listed, &[Etc, Europe], "UTC"), [Europe, Etc]);
}
#[test]
fn listed_without_list_is_alphabetical() {
assert_eq!(order(Listed, &[], "UTC"), order(Alphabetical, &[], "UTC"));
}
#[test]
fn nearest_puts_the_site_region_first_and_etc_last() {
let regions = order(Nearest, &[], "Europe/Madrid");
assert_eq!(regions.first(), Some(&Europe));
assert_eq!(regions.last(), Some(&Etc));
assert!(position(&regions, Africa) < position(&regions, Asia));
assert!(position(&regions, Asia) < position(&regions, Australia));
}
// `Pacific` spans from -11 to +14 hours: a linear mean would put its center around +5.7, far
// from `America`; the circular mean places it next to the date line (-11.5).
#[test]
fn nearest_averages_offsets_around_the_clock() {
assert_eq!(
order(Nearest, &[Europe, Pacific], "America/Mexico_City"),
[Pacific, Europe, Etc]
);
}
// From Auckland (+13), `America` (-4.9) is about 6 hours away across the date line; a linear
// distance would put it almost 18 hours away, farther than `Europe` (+1.5).
#[test]
fn nearest_measures_distances_around_the_clock() {
assert_eq!(
order(Nearest, &[Europe, America], "Pacific/Auckland"),
[America, Europe, Etc]
);
}
#[test]
fn nearest_without_site_region_sorts_every_region_by_distance() {
assert_eq!(
order(Nearest, &[Australia, Africa], "Europe/Madrid"),
[Africa, Australia, Etc]
);
// From UTC, `Pacific` (-11.5) is the farthest region, ahead of `Australia` (+10.1).
let regions = order(Nearest, &[], "UTC");
assert_eq!(regions[regions.len() - 2..], [Pacific, Etc]);
}
#[test]
fn alias_zones_exist_are_sorted_and_are_not_offered() {
assert!(ALIAS_ZONES.is_sorted(), "ALIAS_ZONES must be sorted");
for alias in ALIAS_ZONES {
assert!(
alias.parse::<Tz>().is_ok(),
"{alias} is not a chrono-tz zone"
);
assert!(!Timezone::is_supported_in(alias, &[]), "{alias} is offered");
}
// Canonical zones and zones merged since 2013 are still offered.
for zone in ["Asia/Kolkata", "Europe/Kyiv", "Europe/Oslo", "Africa/Accra"] {
assert!(
Timezone::is_supported_in(zone, &[]),
"{zone} is not offered"
);
}
assert!(Timezone::is_supported_in("Asia/Istanbul", &[]));
assert!(Timezone::is_supported_in("Europe/Nicosia", &[]));
}
}

105
src/datetime/region.rs Normal file
View file

@ -0,0 +1,105 @@
use crate::locale::Lc;
/// Regiones de la base de datos de zonas horarias de la IANA.
///
/// Cada región es el nombre que antecede la primera `/` del identificador de una zona horaria (p.
/// ej. `Europe` en `"Europe/Madrid"`). [`Timezone::supported_by_region()`] agrupa por ellas las
/// zonas horarias que se ofrecen para elegir, y [`form::SelectTimezone`] permite limitarlas.
///
/// `TzRegion::Etc` sólo contiene `Etc/UTC`, equivalente a `UTC`, la zona horaria de respaldo, y se
/// ofrece siempre.
///
/// # Ejemplo
///
/// ```rust
/// # use pagetop::prelude::*;
/// assert_eq!(TzRegion::Europe.as_str(), "Europe");
/// assert_eq!(TzRegion::from_name("europe"), Some(TzRegion::Europe));
/// assert_eq!(TzRegion::from_name("US"), None);
/// ```
///
/// [`Timezone::supported_by_region()`]: super::Timezone::supported_by_region
/// [`form::SelectTimezone`]: crate::base::component::form::SelectTimezone
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum TzRegion {
/// África.
Africa,
/// América.
America,
/// Antártida.
Antarctica,
/// Ártico.
Arctic,
/// Asia.
Asia,
/// Océano Atlántico.
Atlantic,
/// Australia.
Australia,
/// Otras: sólo `Etc/UTC`.
Etc,
/// Europa.
Europe,
/// Océano Índico.
Indian,
/// Océano Pacífico.
Pacific,
}
impl TzRegion {
/// Devuelve el nombre IANA de la región (p. ej. `"Europe"`).
pub const fn as_str(&self) -> &'static str {
match self {
Self::Africa => "Africa",
Self::America => "America",
Self::Antarctica => "Antarctica",
Self::Arctic => "Arctic",
Self::Asia => "Asia",
Self::Atlantic => "Atlantic",
Self::Australia => "Australia",
Self::Etc => "Etc",
Self::Europe => "Europe",
Self::Indian => "Indian",
Self::Pacific => "Pacific",
}
}
/// Devuelve la región con el nombre IANA dado, sin distinguir mayúsculas ni tener en cuenta los
/// espacios del principio y del final; o `None` si no es ninguna de las regiones disponibles
/// (las que sólo agrupan alias heredados, como `"US"` o `"Canada"`, no lo son).
pub fn from_name(name: &str) -> Option<Self> {
let name = name.trim();
[
Self::Africa,
Self::America,
Self::Antarctica,
Self::Arctic,
Self::Asia,
Self::Atlantic,
Self::Australia,
Self::Etc,
Self::Europe,
Self::Indian,
Self::Pacific,
]
.into_iter()
.find(|region| region.as_str().eq_ignore_ascii_case(name))
}
/// Devuelve el nombre traducido de la región (p. ej. *"Europa"* en español).
pub fn label(&self) -> Lc {
Lc::l(match self {
Self::Africa => "timezone_region_africa",
Self::America => "timezone_region_america",
Self::Antarctica => "timezone_region_antarctica",
Self::Arctic => "timezone_region_arctic",
Self::Asia => "timezone_region_asia",
Self::Atlantic => "timezone_region_atlantic",
Self::Australia => "timezone_region_australia",
Self::Etc => "timezone_region_etc",
Self::Europe => "timezone_region_europe",
Self::Indian => "timezone_region_indian",
Self::Pacific => "timezone_region_pacific",
})
}
}

View file

@ -171,8 +171,8 @@ mod tests {
#[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".
// Jan 31, 2024 (leap year) + 1 month = Feb 29 (checked_add_months clamps it); from there to
// Mar 1 there is 1 more day: 1 month and 1 day, not "1 month and -1 day" nor "2 months".
assert_eq!(breakdown(date(2024, 1, 31), date(2024, 3, 1)), (0, 1, 1));
}
@ -186,7 +186,7 @@ mod tests {
#[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 target = date(2024, 6, 12); // 2 years, 0 months, 3 days.
let en = Locale::resolve("en-US");
assert_eq!(
@ -197,7 +197,7 @@ mod tests {
RelativeFormat::Medium.apply(target, today, &en),
"2 years and 3 days ago"
);
// Sin un tercer componente disponible (meses = 0), `Long` coincide con `Medium`.
// With no third component available (months = 0), `Long` matches `Medium`.
assert_eq!(
RelativeFormat::Long.apply(target, today, &en),
"2 years and 3 days ago"
@ -207,7 +207,7 @@ mod tests {
#[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 target = date(2023, 4, 5); // 3 years, 2 months, 10 days.
let es = Locale::resolve("es-ES");
assert_eq!(
@ -219,7 +219,7 @@ mod tests {
#[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 target = date(2026, 6, 16); // in 1 day.
let es = Locale::resolve("es-ES");
assert_eq!(

View file

@ -10,6 +10,9 @@ pub use lang_negotiation::LangNegotiation;
mod startup_banner;
pub use startup_banner::StartupBanner;
mod timezone_order;
pub use timezone_order::TimezoneOrder;
mod log_rolling;
pub use log_rolling::LogRolling;
@ -24,6 +27,9 @@ include_config!(SETTINGS: Settings => [
"app.theme" => "Basic",
"app.lang_negotiation" => "Full",
"app.timezone" => "UTC",
"app.timezone_regions" => "",
"app.timezone_order" => "Nearest",
"app.timezone_per_user" => true,
"app.startup_banner" => "Slant",
// [dev]
@ -80,13 +86,59 @@ pub struct App {
/// 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()`].
/// horaria propia, y para todos si [`timezone_per_user`] es *false*. Ver [`Timezone`] y
/// [`CurrentUser::timezone()`].
///
/// Si es `None` o no contiene un valor válido, se aplica `UTC`.
///
/// [`timezone_per_user`]: Self::timezone_per_user
/// [`Timezone`]: crate::datetime::Timezone
/// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone
pub timezone: Option<String>,
/// Regiones de zonas horarias que se ofrecen para elegir, separadas por comas (p. ej.
/// *"Europe, America"*); da igual escribirlas en mayúsculas o minúsculas.
///
/// Limita las zonas horarias de [`Timezone::supported_by_region()`] a las de esas regiones (ver
/// [`TzRegion`]): *"Africa"*, *"America"*, *"Antarctica"*, *"Arctic"*, *"Asia"*, *"Atlantic"*,
/// *"Australia"*, *"Europe"*, *"Indian"* y *"Pacific"*. Los nombres desconocidos se ignoran con
/// un aviso al arrancar. El grupo *"Etc"*, sólo con `Etc/UTC`, se ofrece siempre, se indique o
/// no; por eso *"Etc"* como único valor deja `Etc/UTC` como la única zona horaria disponible.
/// Si está vacío, o ninguna de las indicadas es válida, se ofrecen todas.
///
/// Es el valor por defecto de [`form::SelectTimezone`], que puede cambiarse para un componente
/// concreto con `with_regions()`.
///
/// Restringir las regiones no cambia las zonas horarias que los usuarios ya tengan asignadas:
/// se les siguen aplicando ([`CurrentUser::timezone()`]) aunque queden fuera, y
/// [`form::SelectTimezone`] las sigue mostrando como zona horaria actual para que volver a
/// guardar el formulario no las descarte. Sólo dejan de ofrecerse para nuevas elecciones; quien
/// valide el valor recibido contra [`Timezone::supported_by_region()`] debe aceptar también,
/// sin cambios, el que ya estuviera guardado.
///
/// [`Timezone::supported_by_region()`]: crate::datetime::Timezone::supported_by_region
/// [`TzRegion`]: crate::datetime::TzRegion
/// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone
/// [`form::SelectTimezone`]: crate::base::component::form::SelectTimezone
pub timezone_regions: String,
/// Orden de las regiones de zonas horarias que se ofrecen para elegir: *"Nearest"*,
/// *"Alphabetical"* o *"Listed"*.
///
/// Ver [`TimezoneOrder`] para los criterios disponibles. Es el valor por defecto de
/// [`form::SelectTimezone`], que puede cambiarse para un componente concreto con
/// `with_order()`.
///
/// [`form::SelectTimezone`]: crate::base::component::form::SelectTimezone
pub timezone_order: TimezoneOrder,
/// Si cada usuario puede tener su propia zona horaria (*true*) o se usa siempre la de la
/// aplicación (*false*).
///
/// Con *false*, [`CurrentUser::timezone()`] devuelve siempre la zona horaria de la aplicación
/// ([`timezone`](Self::timezone)), aunque el usuario tenga otra guardada, y las extensiones que
/// gestionan usuarios no deben ofrecer elegirla; la zona guardada se conserva por si se vuelve
/// a activar. Es el equivalente de [`LangNegotiation::ConfigOnly`] para el idioma.
///
/// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone
pub timezone_per_user: bool,
/// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o
/// *"Starwars"*.
pub startup_banner: StartupBanner,

View file

@ -30,6 +30,9 @@ pub enum LangNegotiation {
/// Usa sólo la configuración o, en su defecto, el idioma de respaldo; ignora el idioma del
/// usuario, la cabecera `Accept-Language` y el parámetro de la URL. Este modo proporciona un
/// comportamiento estable con idioma fijo.
///
/// Las extensiones que gestionan usuarios no deben ofrecer elegir idioma en este modo; el
/// idioma que el usuario tenga guardado se conserva por si se cambia de modo.
ConfigOnly,
}

View file

@ -0,0 +1,57 @@
use crate::AutoDefault;
use serde::{Deserialize, Deserializer};
/// Criterios para ordenar las regiones de zonas horarias que se ofrecen para elegir.
///
/// Se obtiene de [`global::SETTINGS.app.timezone_order`](crate::global::App::timezone_order) y
/// determina el orden de los grupos de [`Timezone::supported_by_region()`]. Es también el orden
/// por defecto de [`form::SelectTimezone`], que puede cambiarse con `with_order()`. En todos los
/// casos el grupo *"Etc"* (`Etc/UTC`) va siempre al final.
///
/// [`Timezone::supported_by_region()`]: crate::datetime::Timezone::supported_by_region
/// [`form::SelectTimezone`]: crate::base::component::form::SelectTimezone
#[derive(AutoDefault, Clone, Copy, Debug, Eq, PartialEq)]
pub enum TimezoneOrder {
/// Primero la región de la zona horaria del sitio y luego el resto, de la más cercana a la más
/// lejana según su desfase horario. Es el comportamiento por defecto.
#[default]
Nearest,
/// Por nombre de la región. [`form::SelectTimezone`] las ordena por su nombre traducido al
/// idioma de la página, sin distinguir mayúsculas ni acentos;
/// `Timezone::supported_by_region()`, que no depende del idioma, por su nombre IANA.
///
/// [`form::SelectTimezone`]: crate::base::component::form::SelectTimezone
Alphabetical,
/// En el orden en que se indican las regiones, en
/// [`global::SETTINGS.app.timezone_regions`](crate::global::App::timezone_regions) o con
/// `SelectTimezone::with_regions()`; si no se indica ninguna región válida, por nombre, como
/// `TimezoneOrder::Alphabetical`.
Listed,
}
impl<'de> Deserialize<'de> for TimezoneOrder {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
let result = match raw.trim().to_ascii_lowercase().as_str() {
"nearest" => Self::Nearest,
"alphabetical" => Self::Alphabetical,
"listed" => Self::Listed,
_ => {
let default = Self::default();
println!(
concat!(
"\nInvalid value \"{}\" for [app].timezone_order. ",
"Using \"{:?}\". Check settings.",
),
raw, default,
);
default
}
};
Ok(result)
}
}

View file

@ -19,6 +19,7 @@ select_theme_placeholder = Choose a theme...
select_timezone_site_default = Use the site time zone: { $timezone }
select_timezone_placeholder = Choose a time zone...
select_timezone_current = Current time zone
timezone_region_africa = Africa
timezone_region_america = America

View file

@ -19,6 +19,7 @@ select_theme_placeholder = Elige un tema...
select_timezone_site_default = Usar la zona horaria del sitio: { $timezone }
select_timezone_placeholder = Elige una zona horaria...
select_timezone_current = Zona horaria actual
timezone_region_africa = África
timezone_region_america = América
@ -28,7 +29,7 @@ timezone_region_asia = Asia
timezone_region_atlantic = Atlántico
timezone_region_australia = Australia
timezone_region_europe = Europa
timezone_region_indian = Océano Índico
timezone_region_indian = Índico
timezone_region_pacific = Pacífico
timezone_region_etc = Otras

View file

@ -75,27 +75,32 @@ async fn unknown_selected_value_falls_back_to_the_empty_option() {
assert!(html.contains(r#"<option value="" selected>Choose a language...</option>"#));
}
// Languages are sorted by their own name, so the order is the same for every page language.
#[pagetop::test]
async fn languages_are_sorted_by_their_translated_name() {
async fn languages_are_sorted_by_their_own_name() {
let mut field = form::SelectLanguage::new();
let mut cx = Context::default().with_langid(&Locale::resolve("es-ES"));
let html = field.render(&mut cx).await.into_string();
let spanish = html
.find(">Español (España)</option>")
.expect("Spanish name");
let english = html
.find(">Inglés (Estados Unidos)</option>")
.expect("English name");
assert!(spanish < english);
let mut cx = Context::default().with_langid(&Locale::resolve("en-US"));
let html = field.render(&mut cx).await.into_string();
let spanish = html
.find(">Spanish (Spain)</option>")
.expect("Spanish name");
let english = html
.find(">English (United States)</option>")
.expect("English name");
assert!(english < spanish);
for page in ["en-US", "es-ES"] {
let mut cx = Context::default().with_langid(&Locale::resolve(page));
let html = field.render(&mut cx).await.into_string();
let english = html
.find(">English (United States)</option>")
.expect("English name");
let spanish = html
.find(">Español (España)</option>")
.expect("Spanish name");
assert!(english < spanish);
}
}
// Each language is labelled in its own language, whatever the language of the page.
#[pagetop::test]
async fn languages_are_labelled_in_their_own_language() {
for page in ["en-US", "es-ES"] {
let mut field = form::SelectLanguage::new();
let mut cx = Context::default().with_langid(&Locale::resolve(page));
let html = field.render(&mut cx).await.into_string();
assert!(html.contains(r#"<option value="es-ES">Español (España)</option>"#));
assert!(html.contains(r#"<option value="en-US">English (United States)</option>"#));
}
}

View file

@ -61,3 +61,175 @@ async fn required_field_asks_to_choose_only_when_nothing_valid_is_selected() {
let html = chosen.render(&mut Context::default()).await.into_string();
assert!(!html.contains(r#"value="""#));
}
// Zones without daylight saving time keep the expected labels stable all year round.
#[pagetop::test]
async fn utc_offset_is_shown_only_when_enabled() {
let mut plain = form::SelectTimezone::new();
let html = plain.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<option value="Asia/Kolkata">Asia/Kolkata</option>"#));
assert!(!html.contains("(UTC"));
let mut with_offset = form::SelectTimezone::new().with_utc_offset(true);
let html = with_offset
.render(&mut Context::default())
.await
.into_string();
assert!(html.contains(r#"<option value="Asia/Kolkata">Asia/Kolkata (UTC+05:30)</option>"#));
assert!(html.contains(r#"<option value="America/Bogota">America/Bogota (UTC-05:00)</option>"#));
assert!(html.contains(r#"<option value="Etc/UTC">Etc/UTC (UTC+00:00)</option>"#));
}
// Zones of a region are sorted by name, or by offset and then by name when offsets are shown:
// Kathmandu (+05:45) comes after Kolkata (+05:30), and Seoul precedes Tokyo (both +09:00).
#[pagetop::test]
async fn zones_are_sorted_by_offset_and_then_by_name_when_offsets_are_shown() {
let positions = |html: &str| -> Vec<usize> {
[
"Asia/Dubai",
"Asia/Kathmandu",
"Asia/Kolkata",
"Asia/Seoul",
"Asia/Tokyo",
]
.iter()
.map(|zone| html.find(&format!(r#"value="{zone}""#)).unwrap())
.collect()
};
let mut plain = form::SelectTimezone::new();
let html = plain.render(&mut Context::default()).await.into_string();
let [dubai, kathmandu, kolkata, seoul, tokyo] = positions(&html)[..] else {
unreachable!()
};
assert!(dubai < kathmandu && kathmandu < kolkata && kolkata < seoul && seoul < tokyo);
let mut with_offset = form::SelectTimezone::new().with_utc_offset(true);
let html = with_offset
.render(&mut Context::default())
.await
.into_string();
let [dubai, kathmandu, kolkata, seoul, tokyo] = positions(&html)[..] else {
unreachable!()
};
assert!(dubai < kolkata && kolkata < kathmandu && kathmandu < seoul && seoul < tokyo);
}
fn group_labels(html: &str) -> Vec<&str> {
html.split(r#"<optgroup label=""#)
.skip(1)
.map(|group| group.split('"').next().unwrap())
.collect()
}
#[pagetop::test]
async fn defaults_come_from_the_global_settings() {
let field = form::SelectTimezone::new();
assert_eq!(*field.order(), global::SETTINGS.app.timezone_order);
let regions: Vec<TzRegion> = global::SETTINGS
.app
.timezone_regions
.split(',')
.filter_map(TzRegion::from_name)
.collect();
assert_eq!(field.regions(), &regions);
}
#[pagetop::test]
async fn with_regions_limits_the_offered_regions_and_keeps_etc() {
let mut field = form::SelectTimezone::new().with_regions([TzRegion::Europe]);
let html = field.render(&mut Context::default()).await.into_string();
assert_eq!(group_labels(&html), ["Europe", "Other"]);
assert!(html.contains(r#"value="Europe/Madrid""#));
assert!(!html.contains(r#"value="America/Bogota""#));
}
#[pagetop::test]
async fn with_order_listed_follows_the_given_regions() {
let mut field = form::SelectTimezone::new()
.with_order(global::TimezoneOrder::Listed)
.with_regions([TzRegion::Pacific, TzRegion::Europe, TzRegion::Asia]);
let html = field.render(&mut Context::default()).await.into_string();
assert_eq!(group_labels(&html), ["Pacific", "Europe", "Asia", "Other"]);
}
#[pagetop::test]
async fn with_order_alphabetical_sorts_by_translated_name() {
let mut field = form::SelectTimezone::new()
.with_order(global::TimezoneOrder::Alphabetical)
.with_regions([
TzRegion::Pacific,
TzRegion::Arctic,
TzRegion::Indian,
TzRegion::Europe,
TzRegion::Asia,
]);
let mut cx = Context::default().with_langid(&Locale::resolve("es-ES"));
let html = field.render(&mut cx).await.into_string();
assert_eq!(
group_labels(&html),
["Ártico", "Asia", "Europa", "Índico", "Pacífico", "Otras"]
);
}
// A valid zone that is no longer offered stays selectable, so saving the form keeps it.
#[pagetop::test]
async fn a_valid_but_unoffered_selected_zone_is_kept_in_its_own_group() {
let mut field = form::SelectTimezone::new()
.with_regions([TzRegion::Europe])
.with_selected("America/Bogota");
let html = field.render(&mut Context::default()).await.into_string();
assert_eq!(
group_labels(&html),
["Current time zone", "Europe", "Other"]
);
assert!(html.contains(r#"<option value="America/Bogota" selected>America/Bogota</option>"#));
assert!(!html.contains(r#"<option value="" selected>"#));
let mut legacy = form::SelectTimezone::new().with_selected("US/Eastern");
let html = legacy.render(&mut Context::default()).await.into_string();
assert_eq!(group_labels(&html).first(), Some(&"Current time zone"));
assert!(html.contains(r#"<option value="US/Eastern" selected>US/Eastern</option>"#));
let mut required = form::SelectTimezone::new()
.with_required(true)
.with_regions([TzRegion::Europe])
.with_selected("America/Bogota");
let html = required.render(&mut Context::default()).await.into_string();
assert!(!html.contains(r#"value="""#));
}
#[pagetop::test]
async fn an_invalid_selected_zone_is_not_offered() {
let mut field = form::SelectTimezone::new().with_selected("Mars/Olympus");
let html = field.render(&mut Context::default()).await.into_string();
assert!(!html.contains("Current time zone"));
assert!(!html.contains("Mars/Olympus"));
assert!(html.contains(r#"<option value="" selected>"#));
}
// Labels replace underscores with spaces; values keep the IANA name.
#[pagetop::test]
async fn labels_are_readable_while_values_keep_the_iana_name() {
let mut field = form::SelectTimezone::new();
let html = field.render(&mut Context::default()).await.into_string();
assert!(html.contains(r#"<option value="America/New_York">America/New York</option>"#));
assert!(html.contains(
r#"<option value="America/Argentina/Buenos_Aires">America/Argentina/Buenos Aires</option>"#
));
assert!(html.contains(r#"<option value="Etc/UTC">Etc/UTC</option>"#));
let mut current = form::SelectTimezone::new()
.with_regions([TzRegion::Europe])
.with_selected("America/New_York");
let html = current.render(&mut Context::default()).await.into_string();
assert!(
html.contains(r#"<option value="America/New_York" selected>America/New York</option>"#)
);
}

View file

@ -319,3 +319,58 @@ async fn format_until_is_symmetric_to_format_since() {
"hasta el 3 de junio de 2026"
);
}
// **< TzRegion::from_name() >**********************************************************************
#[pagetop::test]
async fn region_names_are_parsed_ignoring_case_and_blanks() {
assert_eq!(TzRegion::from_name(" europe "), Some(TzRegion::Europe));
assert_eq!(TzRegion::from_name("PACIFIC"), Some(TzRegion::Pacific));
assert_eq!(TzRegion::from_name("US"), None);
assert_eq!(TzRegion::from_name("Mars"), None);
assert_eq!(TzRegion::from_name(""), None);
}
// **< Timezone::supported_by_region() >************************************************************
#[pagetop::test]
async fn supported_regions_put_the_site_first_and_etc_last() {
setup().await;
let regions = Timezone::supported_by_region();
let site = Timezone::default_tz();
if global::SETTINGS.app.timezone_order == global::TimezoneOrder::Nearest
&& let Some(site_region) = site
.name()
.split_once('/')
.and_then(|(region, _)| TzRegion::from_name(region))
&& site_region != TzRegion::Etc
{
assert_eq!(
regions.first().map(|(region, _)| *region),
Some(site_region)
);
}
assert_eq!(regions.last(), Some(&(TzRegion::Etc, &["Etc/UTC"][..])));
for (region, names) in regions {
assert!(
names.is_sorted(),
"zones of {region:?} are not sorted by name"
);
}
}
// **< Timezone::is_supported_in() >****************************************************************
#[pagetop::test]
async fn is_supported_in_applies_the_same_rules_as_the_offered_list() {
let europe = [TzRegion::Europe];
assert!(Timezone::is_supported_in("Europe/Madrid", &europe));
assert!(Timezone::is_supported_in("Etc/UTC", &europe));
assert!(!Timezone::is_supported_in("Asia/Tokyo", &europe));
assert!(Timezone::is_supported_in("Asia/Tokyo", &[]));
assert!(!Timezone::is_supported_in("US/Eastern", &[]));
assert!(!Timezone::is_supported_in("Etc/GMT+1", &[]));
assert!(!Timezone::is_supported_in("Europe/Atlantis", &[]));
assert!(!Timezone::is_supported_in("UTC", &[]));
}