✨ (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

@ -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", &[]));
}