✨ (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:
parent
f819181872
commit
346714ab29
19 changed files with 1257 additions and 131 deletions
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
11
src/auth.rs
11
src/auth.rs
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(®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()
|
||||
// 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(®ion)
|
||||
}
|
||||
|
||||
// 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 ®ions {
|
||||
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(®ions, Africa) < position(®ions, Asia));
|
||||
assert!(position(®ions, Asia) < position(®ions, 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
105
src/datetime/region.rs
Normal 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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
57
src/global/timezone_order.rs
Normal file
57
src/global/timezone_order.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(), ®ions);
|
||||
}
|
||||
|
||||
#[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>"#)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", &[]));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue