✨ (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.
This commit is contained in:
Manuel Cillero 2026-10-07 01:19:01 +02:00
parent f819181872
commit 346714ab29
19 changed files with 1257 additions and 131 deletions

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]);
}
}