✨ (datetime): Añade zona horaria y formato de fechas
Añade formatos `DateFormat`, `TimeFormat`, `RelativeFormat` y `DatePrecision`, con estilos traducidos vía Fluent (orden día/mes/año, nombre del mes, plurales). La zona horaria efectiva se resuelve por petición, la propia del usuario autenticado si la tiene, si no la configurada para la aplicación (`app.timezone`), o UTC en su defecto- y se expone en `Contextual` junto a format_date(), format_time(), format_datetime(), format_iso_datetime(), format_relative(), format_since() y format_until().
This commit is contained in:
parent
3df73f0551
commit
90e4f85bd4
21 changed files with 1683 additions and 21 deletions
37
Cargo.lock
generated
37
Cargo.lock
generated
|
|
@ -348,6 +348,16 @@ dependencies = [
|
|||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.1"
|
||||
|
|
@ -1029,7 +1039,7 @@ dependencies = [
|
|||
"indexmap",
|
||||
"lasso",
|
||||
"once_cell",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"rand",
|
||||
]
|
||||
|
||||
|
|
@ -1787,6 +1797,7 @@ dependencies = [
|
|||
"async-trait",
|
||||
"axum",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"colored",
|
||||
"config",
|
||||
"figlet-rs",
|
||||
|
|
@ -2026,7 +2037,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_macros",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||
dependencies = [
|
||||
"phf_shared 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2035,7 +2055,7 @@ version = "0.11.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"rand",
|
||||
]
|
||||
|
||||
|
|
@ -2046,7 +2066,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
|
|
@ -2061,6 +2081,15 @@ dependencies = [
|
|||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ axum = { version = "0.8" }
|
|||
base64ct = { version = "1.8", features = ["alloc"] }
|
||||
change-detection = "1.2"
|
||||
chrono = "0.4"
|
||||
chrono-tz = "0.10"
|
||||
colored = "3.1"
|
||||
config = { version = "0.15", default-features = false, features = ["toml"] }
|
||||
figlet-rs = "1.0"
|
||||
|
|
@ -111,6 +112,7 @@ testing = []
|
|||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
chrono-tz.workspace = true
|
||||
colored.workspace = true
|
||||
config.workspace = true
|
||||
figlet-rs.workspace = true
|
||||
|
|
|
|||
270
examples/intro-datetime.rs
Normal file
270
examples/intro-datetime.rs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
include_locales!(LOC from "examples/locale");
|
||||
|
||||
struct IntroDatetime;
|
||||
|
||||
#[async_trait]
|
||||
impl Extension for IntroDatetime {
|
||||
fn configure_router(&self, router: Router) -> Router {
|
||||
router.route("/", web::get(intro_datetime))
|
||||
}
|
||||
}
|
||||
|
||||
async fn intro_datetime(request: HttpRequest) -> Result<Markup, ErrorPage> {
|
||||
Page::new(request)
|
||||
.with_child(
|
||||
Intro::custom()
|
||||
.with_title(Lc::n("PageTop"))
|
||||
.with_slogan(Lc::t("datetime_slogan", &LOC))
|
||||
.with_child(world_block())
|
||||
.with_child(formats_block())
|
||||
.with_child(relative_block())
|
||||
.with_child(since_until_block()),
|
||||
)
|
||||
.render()
|
||||
.await
|
||||
}
|
||||
|
||||
// Zonas IANA de la demostración, con la clave de su etiqueta traducible.
|
||||
const ZONES: [(&str, &str); 4] = [
|
||||
("datetime_zone_utc", "UTC"),
|
||||
("datetime_zone_madrid", "Europe/Madrid"),
|
||||
("datetime_zone_mexico", "America/Mexico_City"),
|
||||
("datetime_zone_tokyo", "Asia/Tokyo"),
|
||||
];
|
||||
|
||||
// El mismo instante (`Utc::now()`) mostrado en cada zona de `ZONES`: fecha y hora combinadas
|
||||
// (`DateFormat::Medium` + `TimeFormat::Short`) y su equivalente ISO 8601, con el offset propio de
|
||||
// cada zona.
|
||||
fn world_block() -> Block {
|
||||
Block::new()
|
||||
.with_title(Lc::t("datetime_block_world", &LOC))
|
||||
.with_child(Html::with(|cx| {
|
||||
let now = Utc::now();
|
||||
let rows: Vec<(Lc, String, String)> = ZONES
|
||||
.into_iter()
|
||||
.map(|(label_key, zone)| {
|
||||
let tz: Tz = zone.parse().expect("valid IANA timezone");
|
||||
let zone_cx = Context::default().with_langid(cx).with_timezone(tz);
|
||||
(
|
||||
Lc::t(label_key, &LOC),
|
||||
zone_cx.format_datetime(now, DateFormat::Medium, TimeFormat::Short),
|
||||
zone_cx.format_iso_datetime(now),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
html! {
|
||||
ul {
|
||||
@for (label, combined, iso) in &rows {
|
||||
li {
|
||||
strong { (label.using(cx)) ": " } (combined)
|
||||
" (ISO " code { (iso) } ")"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// Combinaciones de `DateFormat`/`TimeFormat` sobre la zona horaria efectiva del visitante. Para
|
||||
// añadir un nuevo formato basta con ampliar el array correspondiente.
|
||||
fn formats_block() -> Block {
|
||||
Block::new()
|
||||
.with_title(Lc::t("datetime_block_formats", &LOC))
|
||||
.with_child(Html::with(|cx| {
|
||||
let now = Utc::now();
|
||||
let today = now.with_timezone(&cx.timezone()).date_naive();
|
||||
|
||||
let dates: Vec<(&str, String)> = [
|
||||
("Short", DateFormat::Short),
|
||||
("Medium", DateFormat::Medium),
|
||||
("Long", DateFormat::Long),
|
||||
("Iso", DateFormat::Iso),
|
||||
("Custom(\"%d.%m.%Y\")", DateFormat::Custom("%d.%m.%Y")),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(label, format)| (label, cx.format_date(today, format)))
|
||||
.collect();
|
||||
|
||||
let times: Vec<(&str, String)> = [
|
||||
("Short", TimeFormat::Short),
|
||||
("Long", TimeFormat::Long),
|
||||
("Custom(\"%I:%M %p\")", TimeFormat::Custom("%I:%M %p")),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(label, format)| (label, cx.format_time(now, format)))
|
||||
.collect();
|
||||
|
||||
let combos: Vec<(&str, String)> = [
|
||||
("Short + Short", DateFormat::Short, TimeFormat::Short),
|
||||
("Medium + Short", DateFormat::Medium, TimeFormat::Short),
|
||||
("Long + Long", DateFormat::Long, TimeFormat::Long),
|
||||
("Long + Short", DateFormat::Long, TimeFormat::Short),
|
||||
("Iso + Long", DateFormat::Iso, TimeFormat::Long),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(label, date, time)| (label, cx.format_datetime(now, date, time)))
|
||||
.collect();
|
||||
|
||||
html! {
|
||||
h3 { (Lc::t("datetime_formats_date", &LOC).using(cx)) }
|
||||
ul {
|
||||
@for (label, value) in &dates {
|
||||
li { code { (label) } ": " (value) }
|
||||
}
|
||||
}
|
||||
h3 { (Lc::t("datetime_formats_time", &LOC).using(cx)) }
|
||||
ul {
|
||||
@for (label, value) in × {
|
||||
li { code { (label) } ": " (value) }
|
||||
}
|
||||
}
|
||||
h3 { (Lc::t("datetime_formats_combos", &LOC).using(cx)) }
|
||||
ul {
|
||||
@for (label, value) in &combos {
|
||||
li { code { (label) } ": " (value) }
|
||||
}
|
||||
}
|
||||
p {
|
||||
(Lc::t("datetime_formats_iso", &LOC).using(cx))
|
||||
" " code { (cx.format_iso_datetime(now)) }
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// Casos ilustrativos de `RelativeFormat` sobre la zona horaria efectiva del visitante: hoy, un
|
||||
// desplazamiento simple de días, uno con el componente de meses en cero, uno con los tres
|
||||
// componentes y uno en el futuro -- siempre respecto al día actual. Añadir un caso nuevo es sólo
|
||||
// ampliar `samples`. La primera columna muestra la fecha seleccionada (no una etiqueta fija),
|
||||
// formateada con `DateFormat::Medium` en el idioma efectivo, para que se vea a qué fecha concreta
|
||||
// corresponde cada resultado relativo.
|
||||
fn relative_block() -> Block {
|
||||
Block::new()
|
||||
.with_title(Lc::t("datetime_block_relative", &LOC))
|
||||
.with_child(Html::with(|cx| {
|
||||
let today = Utc::now().date_naive();
|
||||
|
||||
// Desplazamientos de meses/años construidos con `checked_sub_months()` (mismo mecanismo
|
||||
// que usa `RelativeFormat` por dentro), para que el resultado coincida exactamente con
|
||||
// el criterio de cada caso.
|
||||
let two_years_and_3_days_ago = today
|
||||
.checked_sub_months(Months::new(24))
|
||||
.expect("valid date")
|
||||
- Duration::days(3);
|
||||
let three_years_2_months_10_days_ago = today
|
||||
.checked_sub_months(Months::new(38))
|
||||
.expect("valid date")
|
||||
- Duration::days(10);
|
||||
|
||||
let samples = [
|
||||
today,
|
||||
today - Duration::days(3),
|
||||
two_years_and_3_days_ago,
|
||||
three_years_2_months_10_days_ago,
|
||||
today + Duration::days(5),
|
||||
];
|
||||
|
||||
let rows: Vec<(String, String, String, String)> = samples
|
||||
.into_iter()
|
||||
.map(|date| {
|
||||
let dt = at_noon(date);
|
||||
(
|
||||
cx.format_date(date, DateFormat::Medium),
|
||||
cx.format_relative(dt, RelativeFormat::Short),
|
||||
cx.format_relative(dt, RelativeFormat::Medium),
|
||||
cx.format_relative(dt, RelativeFormat::Long),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
html! {
|
||||
table style="width: 100%; border: 1px solid black;" {
|
||||
thead {
|
||||
tr {
|
||||
th { (Lc::t("datetime_relative_col_date", &LOC).using(cx)) }
|
||||
th { "Short" }
|
||||
th { "Medium" }
|
||||
th { "Long" }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
@for (date, short, medium, long) in &rows {
|
||||
tr {
|
||||
td { (date) }
|
||||
td { (short) }
|
||||
td { (medium) }
|
||||
td { (long) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// Niveles de `DatePrecision` para `format_since()`/`format_until()`. Añadir un nivel nuevo es sólo
|
||||
// ampliar este array.
|
||||
const PRECISIONS: [(&str, DatePrecision); 3] = [
|
||||
("Short", DatePrecision::Short),
|
||||
("Medium", DatePrecision::Medium),
|
||||
("Long", DatePrecision::Long),
|
||||
];
|
||||
|
||||
// `format_since()`/`format_until()` sobre una única fecha de ejemplo: a diferencia de `DateFormat`,
|
||||
// lo que cambia entre niveles no es el estilo, sino la propia precisión revelada (sólo el mes, mes
|
||||
// y año, o fecha completa).
|
||||
fn since_until_block() -> Block {
|
||||
Block::new()
|
||||
.with_title(Lc::t("datetime_block_since_until", &LOC))
|
||||
.with_child(Html::with(|cx| {
|
||||
let date = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap();
|
||||
|
||||
let rows: Vec<(&str, String, String)> = PRECISIONS
|
||||
.into_iter()
|
||||
.map(|(label, precision)| {
|
||||
(
|
||||
label,
|
||||
cx.format_since(date, precision),
|
||||
cx.format_until(date, precision),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
html! {
|
||||
table style="width: 100%; border: 1px solid black;" {
|
||||
thead {
|
||||
tr {
|
||||
th { (Lc::t("datetime_since_until_col_precision", &LOC).using(cx)) }
|
||||
th { (Lc::t("datetime_since_until_col_since", &LOC).using(cx)) }
|
||||
th { (Lc::t("datetime_since_until_col_until", &LOC).using(cx)) }
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
@for (label, since, until) in &rows {
|
||||
tr {
|
||||
td { (*label) }
|
||||
td { (since) }
|
||||
td { (until) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
// Mediodía, para evitar que el redondeo horario de la conversión de zona horaria empuje la fecha
|
||||
// civil resultante al día anterior o siguiente en zonas con un offset amplio.
|
||||
fn at_noon(date: NaiveDate) -> DateTime<Utc> {
|
||||
date.and_hms_opt(12, 0, 0)
|
||||
.expect("noon is always a valid time")
|
||||
.and_utc()
|
||||
}
|
||||
|
||||
#[pagetop::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
Application::prepare(&IntroDatetime).await.run().await
|
||||
}
|
||||
21
examples/locale/en-US/intro-datetime.ftl
Normal file
21
examples/locale/en-US/intro-datetime.ftl
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
datetime_slogan = Timezones and date/time formats
|
||||
|
||||
datetime_block_world = The same instant, around the world
|
||||
datetime_zone_utc = UTC
|
||||
datetime_zone_madrid = Madrid (Europe/Madrid)
|
||||
datetime_zone_mexico = Mexico City (America/Mexico_City)
|
||||
datetime_zone_tokyo = Tokyo (Asia/Tokyo)
|
||||
|
||||
datetime_block_formats = DateFormat / TimeFormat combinations
|
||||
datetime_formats_date = DateFormat (date only)
|
||||
datetime_formats_time = TimeFormat (time only)
|
||||
datetime_formats_combos = format_datetime() combinations (date + time)
|
||||
datetime_formats_iso = format_iso_datetime():
|
||||
|
||||
datetime_block_relative = RelativeFormat
|
||||
datetime_relative_col_date = Date
|
||||
|
||||
datetime_block_since_until = DatePrecision (format_since / format_until)
|
||||
datetime_since_until_col_precision = Precision
|
||||
datetime_since_until_col_since = since
|
||||
datetime_since_until_col_until = until
|
||||
21
examples/locale/es-ES/intro-datetime.ftl
Normal file
21
examples/locale/es-ES/intro-datetime.ftl
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
datetime_slogan = Zonas horarias y formatos de fecha y hora
|
||||
|
||||
datetime_block_world = El mismo instante, alrededor del mundo
|
||||
datetime_zone_utc = UTC
|
||||
datetime_zone_madrid = Madrid (Europe/Madrid)
|
||||
datetime_zone_mexico = Ciudad de México (America/Mexico_City)
|
||||
datetime_zone_tokyo = Tokio (Asia/Tokyo)
|
||||
|
||||
datetime_block_formats = Combinaciones de DateFormat / TimeFormat
|
||||
datetime_formats_date = DateFormat (sólo fecha)
|
||||
datetime_formats_time = TimeFormat (sólo hora)
|
||||
datetime_formats_combos = Combinaciones de format_datetime() (fecha + hora)
|
||||
datetime_formats_iso = format_iso_datetime():
|
||||
|
||||
datetime_block_relative = RelativeFormat
|
||||
datetime_relative_col_date = Fecha
|
||||
|
||||
datetime_block_since_until = DatePrecision (format_since / format_until)
|
||||
datetime_since_until_col_precision = Precisión
|
||||
datetime_since_until_col_since = desde
|
||||
datetime_since_until_col_until = hasta
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
mod figfont;
|
||||
|
||||
use crate::core::{extension, extension::ExtensionRef};
|
||||
use crate::datetime::Timezone;
|
||||
use crate::locale::Locale;
|
||||
use crate::response::{render_error_pages, response_for_panic, route_not_found};
|
||||
use crate::web::Router;
|
||||
|
|
@ -63,6 +64,9 @@ impl Application {
|
|||
// Inicializa el idioma predeterminado.
|
||||
Locale::init();
|
||||
|
||||
// Inicializa la zona horaria predeterminada.
|
||||
Timezone::init();
|
||||
|
||||
// Registra las extensiones de la aplicación.
|
||||
extension::all::register_extensions(root_extension);
|
||||
|
||||
|
|
|
|||
23
src/auth.rs
23
src/auth.rs
|
|
@ -17,6 +17,7 @@
|
|||
//! [`Context`]: crate::core::component::Context
|
||||
|
||||
use crate::core::action::{ActionDispatcher, try_dispatch_actions};
|
||||
use crate::datetime::{Timezone, Tz};
|
||||
use crate::locale::Lc;
|
||||
use crate::response::ErrorPage;
|
||||
use crate::web::HttpRequest;
|
||||
|
|
@ -49,6 +50,10 @@ pub enum CurrentUser {
|
|||
id: i32,
|
||||
/// Nombre visible del usuario.
|
||||
display_name: String,
|
||||
/// Zona horaria del usuario, si tiene una configurada y es válida. En otro caso valdrá
|
||||
/// `None` y [`timezone()`](Self::timezone) devolverá la zona horaria predeterminada de la
|
||||
/// aplicación.
|
||||
timezone: Option<Tz>,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +83,23 @@ impl CurrentUser {
|
|||
CurrentUser::Authenticated { display_name, .. } => Some(display_name),
|
||||
}
|
||||
}
|
||||
|
||||
/// Devuelve la zona horaria efectiva del usuario.
|
||||
///
|
||||
/// Un usuario autenticado devuelve la suya si tiene una configurada y es válida; en cualquier
|
||||
/// otro caso (incluido el usuario anónimo), 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()`](crate::core::component::Contextual::timezone).
|
||||
pub fn timezone(&self) -> Tz {
|
||||
match self {
|
||||
CurrentUser::Anonymous => Timezone::default_tz(),
|
||||
CurrentUser::Authenticated { timezone, .. } => {
|
||||
timezone.unwrap_or_else(Timezone::default_tz)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **< Permission >*********************************************************************************
|
||||
|
|
@ -283,7 +305,6 @@ pub fn has_permission(request: &HttpRequest, perm: PermissionRef) -> bool {
|
|||
/// ```
|
||||
// `ErrorPage` incluye `Option<HttpRequest>` en cada variante y es el tipo de error ya establecido
|
||||
// para toda la respuesta HTTP; boxearlo aquí sólo para esta función no compensa.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn require_permission(request: &HttpRequest, perm: PermissionRef) -> Result<(), ErrorPage> {
|
||||
if has_permission(request, perm) {
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ use crate::core::component::{ChildOp, Component, MessageLevel, StatusMessage};
|
|||
use crate::core::theme::all::DEFAULT_THEME;
|
||||
use crate::core::theme::{ChildrenInRegions, CoreRegions, CoreTemplates};
|
||||
use crate::core::theme::{RegionRef, TemplateRef, ThemeRef};
|
||||
use crate::datetime::Tz;
|
||||
use crate::html::{Assets, Favicon, JavaScript, Preload, ResponsiveStyles, StyleSheet};
|
||||
use crate::html::{Markup, Props, PropsOp, RoutePath, html};
|
||||
use crate::locale::Lc;
|
||||
|
|
@ -30,7 +31,7 @@ pub use contextual::Contextual;
|
|||
/// Se crea una sola vez por petición usando [`Context::new()`] (típicamente a través de
|
||||
/// [`Page::new()`](crate::response::Page::new) o [`Page::admin()`](crate::response::Page::admin)),
|
||||
/// y es la única vía por la que un componente, una acción o el tema activo conocen: la petición
|
||||
/// HTTP de origen, el idioma negociado, el usuario autenticado
|
||||
/// HTTP de origen, el idioma negociado y la zona horaria efectiva, el usuario autenticado
|
||||
/// ([`current_user()`](Contextual::current_user)), la plantilla y el tema en uso, y los recursos
|
||||
/// (favicon, hojas de estilo, scripts) acumulados hasta ese momento. Otros datos que los
|
||||
/// componentes necesiten durante el renderizado pueden ser parámetros dinámicos tipados con
|
||||
|
|
@ -97,6 +98,7 @@ pub struct Context {
|
|||
request : Option<HttpRequest>, // Petición HTTP de origen.
|
||||
locale : RequestLocale, // Idioma asociado a la petición.
|
||||
current_user: CurrentUser, // Identidad del usuario actual.
|
||||
timezone : Tz, // Zona horaria efectiva del documento.
|
||||
template : TemplateRef, // Plantilla usada para renderizar.
|
||||
theme : ThemeRef, // Referencia al tema usado para renderizar.
|
||||
favicon : Option<Favicon>, // Favicon, si se ha definido.
|
||||
|
|
@ -126,10 +128,12 @@ impl Context {
|
|||
fn base(request: Option<HttpRequest>, template: TemplateRef) -> Self {
|
||||
let locale = RequestLocale::from_request(request.as_ref());
|
||||
let current_user = Self::resolve_current_user(request.as_ref());
|
||||
let timezone = current_user.timezone();
|
||||
Context {
|
||||
request,
|
||||
locale,
|
||||
current_user,
|
||||
timezone,
|
||||
template,
|
||||
theme : *DEFAULT_THEME,
|
||||
favicon : None,
|
||||
|
|
@ -340,10 +344,11 @@ impl Contextual for Context {
|
|||
|
||||
fn with_request(mut self, request: Option<HttpRequest>) -> Self {
|
||||
self.request = request;
|
||||
// Recalcula el *locale* y el usuario actual según la nueva petición y la política de
|
||||
// negociación configurada.
|
||||
// Recalcula el *locale*, el usuario actual y la zona horaria según la nueva petición y la
|
||||
// política de negociación configurada.
|
||||
self.locale = RequestLocale::from_request(self.request.as_ref());
|
||||
self.current_user = Self::resolve_current_user(self.request.as_ref());
|
||||
self.timezone = self.current_user.timezone();
|
||||
self
|
||||
}
|
||||
|
||||
|
|
@ -352,6 +357,11 @@ impl Contextual for Context {
|
|||
self
|
||||
}
|
||||
|
||||
fn with_timezone(mut self, tz: Tz) -> Self {
|
||||
self.timezone = tz;
|
||||
self
|
||||
}
|
||||
|
||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||
self.template = template;
|
||||
self
|
||||
|
|
@ -438,6 +448,10 @@ impl Contextual for Context {
|
|||
&self.current_user
|
||||
}
|
||||
|
||||
fn timezone(&self) -> Tz {
|
||||
self.timezone
|
||||
}
|
||||
|
||||
fn template(&self) -> TemplateRef {
|
||||
self.template
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,20 +4,31 @@ use crate::auth::CurrentUser;
|
|||
use crate::builder_impl;
|
||||
use crate::core::component::ChildOp;
|
||||
use crate::core::theme::{RegionRef, TemplateRef, ThemeRef};
|
||||
use crate::datetime::{DateFormat, DatePrecision, RelativeFormat, TimeFormat};
|
||||
use crate::datetime::{DateTime, NaiveDate, Tz, Utc};
|
||||
use crate::html::{Assets, Favicon, JavaScript, Props, PropsOp, ResponsiveStyles, StyleSheet};
|
||||
use crate::locale::LangId;
|
||||
use crate::locale::{LangId, Lc};
|
||||
use crate::web::HttpRequest;
|
||||
|
||||
// RFC 3339 con el offset de la zona horaria activa (no UTC fijo): igual en todos los idiomas, no
|
||||
// es una clave Fluent. Usado sólo por `format_iso_datetime()`.
|
||||
const ISO_DATETIME: &str = "%Y-%m-%dT%H:%M:%S%:z";
|
||||
|
||||
/// Interfaz para gestionar el **contexto de renderizado** de un documento HTML.
|
||||
///
|
||||
/// `Contextual` extiende [`LangId`] para establecer el idioma del documento y añade métodos para:
|
||||
///
|
||||
/// - Almacenar la **petición HTTP** de origen.
|
||||
/// - Conocer la **identidad del usuario actual** ([`current_user()`](Self::current_user)) y la
|
||||
/// **zona horaria efectiva** del documento ([`timezone()`](Self::timezone)).
|
||||
/// - Seleccionar la **plantilla** y el **tema** de renderizado.
|
||||
/// - Administrar **recursos** del documento como el icono [`Favicon`], las hojas de estilo
|
||||
/// [`StyleSheet`] o los scripts [`JavaScript`], directamente o mediante una operación
|
||||
/// [`AssetsOp`].
|
||||
/// - Leer y mantener **parámetros dinámicos tipados** de contexto.
|
||||
/// - Formatear **fechas y horas** ([`format_date()`](Self::format_date),
|
||||
/// [`format_time()`](Self::format_time), [`format_datetime()`](Self::format_datetime) y demás)
|
||||
/// para la zona horaria e idioma del documento.
|
||||
///
|
||||
/// Lo implementan, típicamente, estructuras que manejan el contexto de renderizado, como
|
||||
/// [`Context`](crate::core::component::Context) o [`Page`](crate::response::Page).
|
||||
|
|
@ -44,15 +55,26 @@ pub trait Contextual: LangId {
|
|||
/// Establece el idioma del documento.
|
||||
fn with_langid(self, language: &impl LangId) -> Self;
|
||||
|
||||
/// Fuerza la zona horaria que se utilizará para mostrar fechas y horas en el documento.
|
||||
///
|
||||
/// Sustituye la zona horaria aplicada (por el usuario actual o por configuración de la
|
||||
/// aplicación) por otra explícita. Ver [`CurrentUser::timezone()`].
|
||||
///
|
||||
/// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone
|
||||
fn with_timezone(self, tz: Tz) -> Self;
|
||||
|
||||
/// Almacena la petición HTTP de origen en el contexto.
|
||||
///
|
||||
/// También recalcula el idioma ([`RequestLocale::from_request()`]) y
|
||||
/// [`current_user()`](Self::current_user) a partir de la petición indicada, descartando
|
||||
/// cualquier idioma forzado antes con [`with_langid()`](Self::with_langid) o el usuario ya
|
||||
/// resuelto. Si necesitas forzar el idioma o el usuario, llama a `with_request()` primero en
|
||||
/// Al asociar la petición, recalcula el idioma ([`RequestLocale::from_request()`]), establece
|
||||
/// el usuario actual ([`current_user()`]) y, a partir de éste, asigna la zona horaria efectiva
|
||||
/// ([`timezone()`]), descartando en el proceso cualquier idioma o zona horaria anteriores.
|
||||
///
|
||||
/// Si sabes que vas a forzar el idioma o la zona horaria, llama a `with_request()` primero en
|
||||
/// la cadena de construcción, nunca después.
|
||||
///
|
||||
/// [`RequestLocale::from_request()`]: crate::locale::RequestLocale::from_request
|
||||
/// [`current_user()`]: Self::current_user
|
||||
/// [`timezone()`]: Self::timezone
|
||||
fn with_request(self, request: Option<HttpRequest>) -> Self;
|
||||
|
||||
/// Especifica la plantilla para renderizar el documento.
|
||||
|
|
@ -119,6 +141,19 @@ pub trait Contextual: LangId {
|
|||
/// ```
|
||||
fn current_user(&self) -> &CurrentUser;
|
||||
|
||||
/// Devuelve la zona horaria efectiva para mostrar fechas y horas en el documento.
|
||||
///
|
||||
/// Se resuelve una sola vez, al construir el `Context` o al llamar a [`with_request()`], y
|
||||
/// queda guardado para consultar. Llamarlo dentro de un bucle que renderiza miles de filas (p.
|
||||
/// ej. `format_date()`/`format_time()`/`format_datetime()` en cada celda de una tabla) no
|
||||
/// repite esa resolución.
|
||||
///
|
||||
/// Ver [`CurrentUser::timezone()`] para el orden de resolución.
|
||||
///
|
||||
/// [`with_request()`]: Self::with_request
|
||||
/// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone
|
||||
fn timezone(&self) -> Tz;
|
||||
|
||||
/// Devuelve la plantilla configurada para renderizar el documento.
|
||||
fn template(&self) -> TemplateRef;
|
||||
|
||||
|
|
@ -181,6 +216,187 @@ pub trait Contextual: LangId {
|
|||
|
||||
// **< Contextual HELPERS >*********************************************************************
|
||||
|
||||
/// Formatea una fecha y hora completas en la zona horaria efectiva del documento
|
||||
/// ([`timezone()`](Self::timezone)), combinando un [`DateFormat`] y un [`TimeFormat`]
|
||||
/// independientes (posiblemente distintos entre sí, como una fecha larga con la hora corta),
|
||||
/// con el separador de la clave Fluent `datetime_join` del idioma efectivo.
|
||||
///
|
||||
/// `dt` se graba siempre en UTC. Aquí la conversión a la zona horaria de visualización se hace
|
||||
/// una sola vez, antes de aplicar `date`/`time` por separado (evita convertir la misma fecha y
|
||||
/// hora dos veces).
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, dt: DateTime<Utc>) {
|
||||
/// let text = cx.format_datetime(dt, DateFormat::Medium, TimeFormat::Short);
|
||||
/// # }
|
||||
/// ```
|
||||
fn format_datetime(
|
||||
&self,
|
||||
dt: DateTime<Utc>,
|
||||
date: DateFormat<'_>,
|
||||
time: TimeFormat<'_>,
|
||||
) -> String
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let local = dt.with_timezone(&self.timezone());
|
||||
let date = date.apply(local.date_naive(), self);
|
||||
let time = time.apply(local.time(), self);
|
||||
Lc::l("datetime_join")
|
||||
.with_arg("date", date.clone())
|
||||
.with_arg("time", time.clone())
|
||||
.lookup(self)
|
||||
.unwrap_or_else(|| format!("{date} {time}"))
|
||||
}
|
||||
|
||||
/// Formatea una fecha sin hora ([`NaiveDate`]).
|
||||
///
|
||||
/// A diferencia de [`format_datetime()`] y [`format_time()`], que reciben [`DateTime<Utc>`] y
|
||||
/// convierten a la zona horaria efectiva, aquí no hay ninguna conversión que aplicar. El
|
||||
/// argumento `date` ya es la fecha civil a mostrar. No es una inconsistencia de tipos entre
|
||||
/// métodos de la misma familia. Un `DateTime<Utc>` es un instante grabado en UTC ("qué día es"
|
||||
/// depende de la zona horaria de quien mira); un `NaiveDate` es una fecha civil sin hora ni
|
||||
/// zona horaria asociada (una fecha de nacimiento, "socio desde junio de 2020", etc.), es el
|
||||
/// mismo dato en cualquier sitio desde el que se mire. Forzar aquí un `DateTime<Utc>` obligaría
|
||||
/// a inventar una hora falsa para encajar en el tipo, y expondría el dato al error que se
|
||||
/// quiere evitar. Si se convierte a un huso horario negativo, esa hora inventada puede hacer
|
||||
/// caer la fecha en el día anterior, por lo que dos usuarios en zonas distintas verían un día
|
||||
/// distinto para lo que en el dominio es un único hecho fijo.
|
||||
///
|
||||
/// Usa [`DateFormat`], cuyos patrones por defecto no incluyen componentes de hora.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, date: NaiveDate) {
|
||||
/// let text = cx.format_date(date, DateFormat::Medium);
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// Si el dato de origen es un instante (`DateTime<Utc>`) y sólo hace falta mostrar su fecha,
|
||||
/// conviértelo explícitamente a la zona horaria efectiva antes de llamar:
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, dt: DateTime<Utc>) {
|
||||
/// // `dt`, p. ej. `created_at`: un instante real, con hora, no una fecha civil fabricada.
|
||||
/// let local_date = dt.with_timezone(&cx.timezone()).date_naive();
|
||||
/// let text = cx.format_date(local_date, DateFormat::Medium);
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// [`format_datetime()`]: Self::format_datetime
|
||||
/// [`format_time()`]: Self::format_time
|
||||
/// [`DateTime<Utc>`]: chrono::DateTime
|
||||
fn format_date(&self, date: NaiveDate, format: DateFormat<'_>) -> String
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
format.apply(date, self)
|
||||
}
|
||||
|
||||
/// Formatea sólo la hora del día de `dt`, convertida a la zona horaria efectiva
|
||||
/// ([`timezone()`](Self::timezone)). Usa [`TimeFormat`].
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, dt: DateTime<Utc>) {
|
||||
/// let text = cx.format_time(dt, TimeFormat::Short);
|
||||
/// # }
|
||||
/// ```
|
||||
fn format_time(&self, dt: DateTime<Utc>, format: TimeFormat<'_>) -> String
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
format.apply(dt.with_timezone(&self.timezone()).time(), self)
|
||||
}
|
||||
|
||||
/// Formatea `dt` como ISO 8601 (RFC 3339), con el offset de la zona horaria efectiva
|
||||
/// ([`timezone()`](Self::timezone)); igual para todos los idiomas.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, dt: DateTime<Utc>) {
|
||||
/// let text = cx.format_iso_datetime(dt);
|
||||
/// # }
|
||||
/// ```
|
||||
fn format_iso_datetime(&self, dt: DateTime<Utc>) -> String
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
dt.with_timezone(&self.timezone())
|
||||
.format(ISO_DATETIME)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Formatea `dt` en relación al momento actual ("hace 3 años", "dentro de 5 días"), en la zona
|
||||
/// horaria efectiva del documento ([`timezone()`](Self::timezone)). Usa [`RelativeFormat`].
|
||||
///
|
||||
/// La comparación es entre fechas civiles (la de `dt` y la de "ahora", ambas convertidas a la
|
||||
/// zona horaria efectiva), no entre instantes. Un valor de hace unos minutos o dentro de unos
|
||||
/// minutos se muestra como "hoy" si cae en la misma fecha civil que "ahora".
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, dt: DateTime<Utc>) {
|
||||
/// let text = cx.format_relative(dt, RelativeFormat::Medium);
|
||||
/// # }
|
||||
/// ```
|
||||
fn format_relative(&self, dt: DateTime<Utc>, format: RelativeFormat) -> String
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let today = Utc::now().with_timezone(&self.timezone()).date_naive();
|
||||
let target = dt.with_timezone(&self.timezone()).date_naive();
|
||||
format.apply(target, today, self)
|
||||
}
|
||||
|
||||
/// Formatea `date` como fecha de **inicio**, con la precisión indicada ("desde junio", "desde
|
||||
/// junio de 2026", "desde el 3 de junio de 2026"). Usa [`DatePrecision`].
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, date: NaiveDate) {
|
||||
/// let text = cx.format_since(date, DatePrecision::Medium);
|
||||
/// # }
|
||||
/// ```
|
||||
fn format_since(&self, date: NaiveDate, precision: DatePrecision) -> String
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
precision.apply_since(date, self)
|
||||
}
|
||||
|
||||
/// Formatea `date` como fecha de **fin**, con la precisión indicada ("hasta junio", "hasta
|
||||
/// junio de 2026", "hasta el 3 de junio de 2026"). Usa [`DatePrecision`].
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, date: NaiveDate) {
|
||||
/// let text = cx.format_until(date, DatePrecision::Medium);
|
||||
/// # }
|
||||
/// ```
|
||||
fn format_until(&self, date: NaiveDate, precision: DatePrecision) -> String
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
precision.apply_until(date, self)
|
||||
}
|
||||
|
||||
/// Elimina un parámetro del contexto. Devuelve `true` si la clave existía y se eliminó.
|
||||
///
|
||||
/// # Ejemplo
|
||||
|
|
|
|||
|
|
@ -1,9 +1,70 @@
|
|||
//! Soporte a fechas y horas según estándar [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)
|
||||
//! (basado en [chrono](https://docs.rs/chrono)).
|
||||
//! Soporte a fechas y horas según estándar [ISO 8601].
|
||||
//!
|
||||
//! Basado en [chrono] y zonas horarias de [chrono-tz] con formatos de visualización según idioma.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! [`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
|
||||
//! (`src/locale/{lang}/datetime.ftl`), con el mismo mecanismo que cualquier otro texto traducido de
|
||||
//! PageTop.
|
||||
//!
|
||||
//! [`RelativeFormat`] muestra una fecha en relación al momento actual ("hace 3 años", "dentro de 5
|
||||
//! días") en vez de como fecha absoluta, con el mismo mecanismo de claves Fluent, vía
|
||||
//! [`Contextual::format_relative()`].
|
||||
//!
|
||||
//! [`DatePrecision`] muestra una fecha de inicio o fin con precisión reducida ("desde junio",
|
||||
//! "hasta junio de 2026"), vía [`Contextual::format_since()`] y [`Contextual::format_until()`].
|
||||
//!
|
||||
//! [ISO 8601]: https://en.wikipedia.org/wiki/ISO_8601
|
||||
//! [chrono]: https://docs.rs/chrono
|
||||
//! [chrono-tz]: https://docs.rs/chrono-tz
|
||||
//! [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone
|
||||
//! [`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
|
||||
//! [`Contextual::format_until()`]: crate::core::component::Contextual::format_until
|
||||
|
||||
pub use chrono::prelude::*;
|
||||
// Reexportado para que el resto del código y las extensiones se refieran siempre a `datetime::X`,
|
||||
// nunca a `chrono` directamente (mismo criterio que `Tz`/`TZ_VARIANTS` más abajo). No es todo el
|
||||
// `chrono::prelude` porque se excluyen `Local` (incompatible con el invariante "siempre UTC" de
|
||||
// este módulo), `Month` (su nombre de mes es inglés fijo, contradice el mecanismo Fluent de este
|
||||
// módulo) y `SubsecRound`/`SecondsFormat`/`Offset` (sin ningún consumidor, ni directo ni
|
||||
// indirecto). `Weekday` y `FixedOffset` se mantienen pese a no tener tampoco uso propio: `Weekday`
|
||||
// es el tipo que ya devuelve `Datelike::weekday()` (en uso interno); `FixedOffset` es el tipo que
|
||||
// devolvería cualquier futuro *parsing* de fecha ISO (`DateTime::parse_from_rfc3339()`).
|
||||
pub use chrono::DateTime;
|
||||
pub use chrono::TimeZone;
|
||||
pub use chrono::{Datelike, Timelike, Weekday};
|
||||
pub use chrono::{FixedOffset, Utc};
|
||||
pub use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
|
||||
|
||||
// `Duration` no forma parte de `chrono::prelude`, pero es de uso tan habitual junto al resto de
|
||||
// tipos de este módulo (sumar/restar intervalos a un `NaiveDateTime`, calcular expiraciones...) que
|
||||
// se reexporta igualmente.
|
||||
pub use chrono::Duration;
|
||||
// `Duration` y `Months` no forman parte de `chrono::prelude`, pero son de uso habitual junto al
|
||||
// resto de tipos de este módulo (sumar/restar intervalos a un `NaiveDateTime`, calcular
|
||||
// expiraciones, desglosar una diferencia de calendario en años/meses, etc.) que se reexportan
|
||||
// igualmente.
|
||||
pub use chrono::{Duration, Months};
|
||||
|
||||
// Reexportado para que el resto del código y las extensiones se refieran siempre a `datetime::Tz`/
|
||||
// `datetime::TZ_VARIANTS`, sin depender directamente de `chrono_tz` (mismo criterio que
|
||||
// `locale::LanguageIdentifier` sobre `unic_langid`).
|
||||
pub use chrono_tz::{TZ_VARIANTS, Tz};
|
||||
|
||||
mod definition;
|
||||
pub use definition::Timezone;
|
||||
|
||||
mod format;
|
||||
pub use format::{DateFormat, TimeFormat};
|
||||
|
||||
mod relative;
|
||||
pub use relative::RelativeFormat;
|
||||
|
||||
mod precision;
|
||||
pub use precision::DatePrecision;
|
||||
|
|
|
|||
72
src/datetime/definition.rs
Normal file
72
src/datetime/definition.rs
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
use crate::{global, trace, util};
|
||||
|
||||
use super::Tz;
|
||||
|
||||
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(|| {
|
||||
global::SETTINGS
|
||||
.app
|
||||
.timezone
|
||||
.as_deref()
|
||||
.and_then(util::non_blank)
|
||||
.and_then(|raw| raw.parse().ok())
|
||||
});
|
||||
|
||||
// Zona horaria de respaldo, garantizada incluso sin configuración válida.
|
||||
const FALLBACK_TZ: Tz = Tz::UTC;
|
||||
|
||||
/// Zona horaria configurada para la aplicación.
|
||||
///
|
||||
/// 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`).
|
||||
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.
|
||||
pub(crate) fn init() {
|
||||
match global::SETTINGS
|
||||
.app
|
||||
.timezone
|
||||
.as_deref()
|
||||
.and_then(util::non_blank)
|
||||
{
|
||||
Some(raw) => {
|
||||
if let Some(tz) = *CONFIG_TZ {
|
||||
trace::debug!("Default timezone \"{tz}\" (from config: \"{raw}\")");
|
||||
} else {
|
||||
trace::warn!(
|
||||
"Default timezone \"{FALLBACK_TZ}\" (fallback, invalid config: \"{raw}\")"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => trace::debug!("Default timezone \"{FALLBACK_TZ}\" (fallback, no config)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Devuelve la zona horaria configurada explícitamente, si es válida.
|
||||
///
|
||||
/// Si no se ha configurado una zona horaria por defecto o el valor no es válido, devuelve
|
||||
/// `None`.
|
||||
pub fn try_tz() -> Option<Tz> {
|
||||
*CONFIG_TZ
|
||||
}
|
||||
|
||||
/// Devuelve siempre la zona horaria de respaldo (`UTC`).
|
||||
///
|
||||
/// Es la zona horaria garantizada incluso cuando no haya configuración de la aplicación o
|
||||
/// cuando el valor configurado no sea válido.
|
||||
pub fn fallback_tz() -> Tz {
|
||||
FALLBACK_TZ
|
||||
}
|
||||
|
||||
/// Devuelve la zona horaria configurada o, en su defecto, la de respaldo (`UTC`).
|
||||
pub fn default_tz() -> Tz {
|
||||
Self::try_tz().unwrap_or(FALLBACK_TZ)
|
||||
}
|
||||
}
|
||||
150
src/datetime/format.rs
Normal file
150
src/datetime/format.rs
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
use crate::locale::{LangId, Lc};
|
||||
|
||||
use chrono::{Datelike, NaiveDate, NaiveTime, Timelike};
|
||||
|
||||
// Nombre del mes (1-12) traducido contra `LOCALES_PAGETOP` para `language` (claves
|
||||
// `month_01`..`month_12`). `chrono` sólo traduce `%B`/`%A` con la *feature* `unstable-locales`,
|
||||
// redundante con Fluent y descartada para todo el proyecto; el nombre se resuelve aquí y se pasa
|
||||
// como argumento `{ $month }` a `date_format_long` y, desde `precision.rs`, a `since_*`/`until_*`.
|
||||
pub(crate) fn month_name(month: u32, language: &impl LangId) -> String {
|
||||
Lc::l(format!("month_{month:02}"))
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| month.to_string())
|
||||
}
|
||||
|
||||
// **< DateFormat >*********************************************************************************
|
||||
|
||||
// Patrones `strftime` de respaldo, sólo si faltase la clave Fluent correspondiente (no debería
|
||||
// ocurrir porque el idioma de respaldo `en-US` siempre las define).
|
||||
const FALLBACK_DATE_SHORT: &str = "%d/%m/%y";
|
||||
const FALLBACK_DATE_MEDIUM: &str = "%d/%m/%Y";
|
||||
const FALLBACK_DATE_LONG: &str = "%d/%m/%Y";
|
||||
|
||||
// Fecha en formato ISO 8601; igual en todos los idiomas (no es una clave Fluent).
|
||||
const ISO_DATE: &str = "%Y-%m-%d";
|
||||
|
||||
/// Tipo de formato a aplicar al mostrar una fecha **sin hora** ([`NaiveDate`](chrono::NaiveDate)).
|
||||
///
|
||||
/// `Short`, `Medium` y `Long` toman su texto de las claves Fluent
|
||||
/// `date_format_short`/`_medium`/`_long` del idioma efectivo. `Iso` es un patrón ISO 8601 fijo,
|
||||
/// igual en todos los idiomas (no pasa por Fluent porque nunca debería divergir entre idiomas).
|
||||
/// `Custom` acepta un patrón `strftime` explícito para el resto de casos.
|
||||
///
|
||||
/// Para una fecha y hora completas, combina esta fecha con [`TimeFormat`] mediante
|
||||
/// [`Contextual::format_datetime()`](crate::core::component::Contextual::format_datetime).
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, date: NaiveDate) {
|
||||
/// let short = cx.format_date(date, DateFormat::Short);
|
||||
/// let long = cx.format_date(date, DateFormat::Long); // p. ej. "15 de junio de 2026"
|
||||
/// let iso = cx.format_date(date, DateFormat::Iso);
|
||||
/// let custom = cx.format_date(date, DateFormat::Custom("%Y-%m-%d"));
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum DateFormat<'a> {
|
||||
/// Fecha corta.
|
||||
Short,
|
||||
/// Fecha con año completo.
|
||||
Medium,
|
||||
/// Fecha larga (formato extenso propio del idioma), con el nombre del mes.
|
||||
Long,
|
||||
/// Fecha en formato ISO 8601; igual en todos los idiomas.
|
||||
Iso,
|
||||
/// Patrón `strftime` explícito.
|
||||
Custom(&'a str),
|
||||
}
|
||||
|
||||
impl DateFormat<'_> {
|
||||
// Formatea `date` para `language`.
|
||||
pub(crate) fn apply(&self, date: NaiveDate, language: &impl LangId) -> String {
|
||||
match self {
|
||||
DateFormat::Short => Lc::l("date_format_short")
|
||||
.with_arg("day", format!("{:02}", date.day()))
|
||||
.with_arg("month", format!("{:02}", date.month()))
|
||||
.with_arg("year_short", format!("{:02}", date.year().rem_euclid(100)))
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| date.format(FALLBACK_DATE_SHORT).to_string()),
|
||||
DateFormat::Medium => Lc::l("date_format_medium")
|
||||
.with_arg("day", format!("{:02}", date.day()))
|
||||
.with_arg("month", format!("{:02}", date.month()))
|
||||
.with_arg("year", date.year().to_string())
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| date.format(FALLBACK_DATE_MEDIUM).to_string()),
|
||||
DateFormat::Long => Lc::l("date_format_long")
|
||||
.with_arg("day", date.day().to_string())
|
||||
.with_arg("month", month_name(date.month(), language))
|
||||
.with_arg("year", date.year().to_string())
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| date.format(FALLBACK_DATE_LONG).to_string()),
|
||||
DateFormat::Iso => date.format(ISO_DATE).to_string(),
|
||||
DateFormat::Custom(pattern) => date.format(pattern).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **< TimeFormat >*********************************************************************************
|
||||
|
||||
// Patrones `strftime` de respaldo, sólo si faltase la clave Fluent correspondiente.
|
||||
const FALLBACK_TIME_SHORT: &str = "%H:%M";
|
||||
const FALLBACK_TIME_LONG: &str = "%H:%M:%S";
|
||||
|
||||
/// Tipo de formato a aplicar al mostrar una hora del día ([`NaiveTime`](chrono::NaiveTime)).
|
||||
///
|
||||
/// Mismo mecanismo que [`DateFormat`], en este caso usa `Short` y `Long` para tomar su texto de las
|
||||
/// claves Fluent `time_format_short`/`_long` del idioma efectivo, aplicando plantillas con los
|
||||
/// argumentos `{ $hour }`, `{ $minute }` (más `{ $second }` en `Long`). `Custom` acepta un patrón
|
||||
/// `strftime` explícito, útil para aplicar, por ejemplo, un formato de 12 horas con AM/PM
|
||||
/// independientemente del idioma efectivo (`"%I:%M %p"`).
|
||||
///
|
||||
/// Para una fecha y hora completas, combina este `TimeFormat` con un [`DateFormat`] mediante
|
||||
/// [`Contextual::format_datetime()`], que permite elegir un formato distinto para cada parte (p.
|
||||
/// ej. fecha larga con hora corta) en vez de un único formato combinado fijo.
|
||||
///
|
||||
/// [`Contextual::format_datetime()`]: crate::core::component::Contextual::format_datetime
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, dt: DateTime<Utc>) {
|
||||
/// let short = cx.format_time(dt, TimeFormat::Short);
|
||||
/// let long = cx.format_time(dt, TimeFormat::Long);
|
||||
/// let custom = cx.format_time(dt, TimeFormat::Custom("%H:%M:%S"));
|
||||
///
|
||||
/// // Formato de 12 horas con AM/PM (p. ej. "02:30 PM").
|
||||
/// let twelve_hour = cx.format_time(dt, TimeFormat::Custom("%I:%M %p"));
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum TimeFormat<'a> {
|
||||
/// Hora y minutos.
|
||||
Short,
|
||||
/// Hora, minutos y segundos.
|
||||
Long,
|
||||
/// Patrón `strftime` explícito.
|
||||
Custom(&'a str),
|
||||
}
|
||||
|
||||
impl TimeFormat<'_> {
|
||||
// Formatea `time` para `language`.
|
||||
pub(crate) fn apply(&self, time: NaiveTime, language: &impl LangId) -> String {
|
||||
match self {
|
||||
TimeFormat::Short => Lc::l("time_format_short")
|
||||
.with_arg("hour", format!("{:02}", time.hour()))
|
||||
.with_arg("minute", format!("{:02}", time.minute()))
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| time.format(FALLBACK_TIME_SHORT).to_string()),
|
||||
TimeFormat::Long => Lc::l("time_format_long")
|
||||
.with_arg("hour", format!("{:02}", time.hour()))
|
||||
.with_arg("minute", format!("{:02}", time.minute()))
|
||||
.with_arg("second", format!("{:02}", time.second()))
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| time.format(FALLBACK_TIME_LONG).to_string()),
|
||||
TimeFormat::Custom(pattern) => time.format(pattern).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/datetime/precision.rs
Normal file
89
src/datetime/precision.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use super::format::month_name;
|
||||
use crate::locale::{LangId, Lc};
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
|
||||
/// Precisión a aplicar al mostrar una fecha de inicio o fin.
|
||||
///
|
||||
/// Por ejemplo, "desde junio" o "hasta junio de 2026", usada por [`Contextual::format_since()`] y
|
||||
/// [`Contextual::format_until()`].
|
||||
///
|
||||
/// A diferencia de [`DateFormat`], que siempre muestra día, mes y año (sólo cambia el estilo),
|
||||
/// aquí lo que cambia es la propia precisión revelada, dejando `Short` para sólo el mes, `Medium`
|
||||
/// para mes y año, y `Long` para la fecha completa. El texto de cada nivel es una plantilla
|
||||
/// Fluent completa por idioma (traducciones `since_short`/`since_medium`/`since_long` y
|
||||
/// `until_short`/`until_medium`/`until_long` en `src/locale/{lang}/datetime.ftl`). En español, por
|
||||
/// ejemplo, el artículo "el" sólo aparece delante de un día concreto ("desde el 3 de junio de
|
||||
/// 2026", pero "desde junio de 2026" no lleva "el"), así que forma parte de la plantilla de `Long`,
|
||||
/// no de un prefijo genérico compartido.
|
||||
///
|
||||
/// No tiene variante `Custom` porque no hay un patrón `strftime` equivalente para "sólo el mes" o
|
||||
/// "mes y año" con el nombre del mes traducido (mismo motivo que en [`RelativeFormat`]).
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, start: NaiveDate, end: NaiveDate) {
|
||||
/// let since = cx.format_since(start, DatePrecision::Medium); // p. ej. "desde junio de 2026"
|
||||
/// let until = cx.format_until(end, DatePrecision::Long); // p. ej. "hasta el 3 de junio de 2026"
|
||||
/// # }
|
||||
/// ```
|
||||
///
|
||||
/// [`Contextual::format_since()`]: crate::core::component::Contextual::format_since
|
||||
/// [`Contextual::format_until()`]: crate::core::component::Contextual::format_until
|
||||
/// [`DateFormat`]: super::DateFormat
|
||||
/// [`RelativeFormat`]: super::RelativeFormat
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum DatePrecision {
|
||||
/// Sólo el mes.
|
||||
Short,
|
||||
/// Mes y año.
|
||||
Medium,
|
||||
/// Día, mes y año.
|
||||
Long,
|
||||
}
|
||||
|
||||
impl DatePrecision {
|
||||
// Formatea `date` para `language` contra el trío de claves `since_short`/`_medium`/`_long`.
|
||||
pub(crate) fn apply_since(&self, date: NaiveDate, language: &impl LangId) -> String {
|
||||
self.apply(
|
||||
date,
|
||||
["since_short", "since_medium", "since_long"],
|
||||
language,
|
||||
)
|
||||
}
|
||||
|
||||
// Formatea `date` para `language` contra el trío de claves `until_short`/`_medium`/`_long`.
|
||||
pub(crate) fn apply_until(&self, date: NaiveDate, language: &impl LangId) -> String {
|
||||
self.apply(
|
||||
date,
|
||||
["until_short", "until_medium", "until_long"],
|
||||
language,
|
||||
)
|
||||
}
|
||||
|
||||
// `apply_since()`/`apply_until()` comparten exactamente los mismos argumentos por nivel de
|
||||
// precisión; sólo cambia el trío de claves Fluent que se resuelve.
|
||||
fn apply(&self, date: NaiveDate, keys: [&'static str; 3], language: &impl LangId) -> String {
|
||||
let [short, medium, long] = keys;
|
||||
let month = || month_name(date.month(), language);
|
||||
match self {
|
||||
DatePrecision::Short => Lc::l(short)
|
||||
.with_arg("month", month())
|
||||
.lookup(language)
|
||||
.unwrap_or_else(month),
|
||||
DatePrecision::Medium => Lc::l(medium)
|
||||
.with_arg("month", month())
|
||||
.with_arg("year", date.year().to_string())
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| format!("{} {}", month(), date.year())),
|
||||
DatePrecision::Long => Lc::l(long)
|
||||
.with_arg("day", date.day().to_string())
|
||||
.with_arg("month", month())
|
||||
.with_arg("year", date.year().to_string())
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| format!("{} {} {}", date.day(), month(), date.year())),
|
||||
}
|
||||
}
|
||||
}
|
||||
230
src/datetime/relative.rs
Normal file
230
src/datetime/relative.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
use crate::locale::{LangId, Lc};
|
||||
|
||||
use chrono::{Months, NaiveDate};
|
||||
|
||||
/// Tipo de formato a aplicar al mostrar una fecha **en relación al momento actual**.
|
||||
///
|
||||
/// Por ejemplo, "hace 3 años" o "dentro de 5 días", en vez de la fecha absoluta.
|
||||
///
|
||||
/// El desglose es un cálculo exacto de calendario en años, meses y días (no una duración
|
||||
/// redondeada tipo "hace unos 3 años"): `Short` muestra el componente más significativo, `Medium`
|
||||
/// hasta dos y `Long` hasta tres. Los componentes en cero se omiten antes de aplicar el límite, no
|
||||
/// después: con una diferencia de "2 años, 0 meses y 3 días", tanto `Medium` como `Long` muestran
|
||||
/// "2 años y 3 días" (sin un componente de meses que mostrar, `Long` no tiene un tercero disponible
|
||||
/// y coincide con `Medium`).
|
||||
///
|
||||
/// La unidad mínima es el día: la comparación se hace entre fechas civiles (la de `dt`, convertida
|
||||
/// a la zona horaria efectiva, y la de "ahora"), no entre instantes. Un valor de hace unos minutos o
|
||||
/// dentro de unos minutos, si cae en la misma fecha civil que "ahora", se muestra como "hoy"
|
||||
/// (clave Fluent `relative_today`), sin necesidad de ningún umbral de minutos u horas.
|
||||
///
|
||||
/// El texto completo ("hace 3 años y 2 meses" / "3 years and 2 months ago") se compone enteramente
|
||||
/// a partir de claves Fluent del idioma efectivo (`src/locale/{lang}/datetime.ftl`): el orden y la
|
||||
/// posición de "hace"/"dentro de" (prefijo en español, "ago" como sufijo en inglés), la unión de
|
||||
/// varios componentes y el plural de cada unidad son propiedades del idioma, no de este tipo.
|
||||
///
|
||||
/// # Ejemplo
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use pagetop::prelude::*;
|
||||
/// # fn show(cx: &Context, dt: DateTime<Utc>) {
|
||||
/// let short = cx.format_relative(dt, RelativeFormat::Short); // p. ej. "hace 3 años"
|
||||
/// let long = cx.format_relative(dt, RelativeFormat::Long); // p. ej. "hace 3 años, 2 meses y 10 días"
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum RelativeFormat {
|
||||
/// El componente más significativo (años, meses o días).
|
||||
Short,
|
||||
/// Hasta dos componentes.
|
||||
Medium,
|
||||
/// Hasta tres componentes (años, meses y días).
|
||||
Long,
|
||||
}
|
||||
|
||||
impl RelativeFormat {
|
||||
// Compara `target` con `today` (ambas fechas civiles ya en la zona horaria efectiva) y compone
|
||||
// el texto relativo para `language`.
|
||||
pub(crate) fn apply(
|
||||
&self,
|
||||
target: NaiveDate,
|
||||
today: NaiveDate,
|
||||
language: &impl LangId,
|
||||
) -> String {
|
||||
if target == today {
|
||||
return Lc::l("relative_today")
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| "today".to_owned());
|
||||
}
|
||||
|
||||
let is_future = target > today;
|
||||
let (early, late) = if is_future {
|
||||
(today, target)
|
||||
} else {
|
||||
(target, today)
|
||||
};
|
||||
let (years, months, days) = breakdown(early, late);
|
||||
|
||||
let mut parts = Vec::with_capacity(3);
|
||||
if years > 0 {
|
||||
parts.push(unit("relative_years", years, language));
|
||||
}
|
||||
if months > 0 {
|
||||
parts.push(unit("relative_months", months, language));
|
||||
}
|
||||
if days > 0 {
|
||||
parts.push(unit("relative_days", days, language));
|
||||
}
|
||||
let depth = match self {
|
||||
RelativeFormat::Short => 1,
|
||||
RelativeFormat::Medium => 2,
|
||||
RelativeFormat::Long => 3,
|
||||
};
|
||||
parts.truncate(depth);
|
||||
|
||||
let value = join(&parts, language);
|
||||
let key = if is_future {
|
||||
"relative_future"
|
||||
} else {
|
||||
"relative_past"
|
||||
};
|
||||
Lc::l(key)
|
||||
.with_arg("value", value.clone())
|
||||
.lookup(language)
|
||||
.unwrap_or(value)
|
||||
}
|
||||
}
|
||||
|
||||
// **< HELPERS >************************************************************************************
|
||||
|
||||
// Descompone el intervalo `[early, late]` (`early <= late`, garantizado por el llamador) en años,
|
||||
// meses y días completos de calendario, no en una duración aproximada.
|
||||
//
|
||||
// Se apoya en `NaiveDate::checked_add_months()`. Cuando el día de partida no existe en el mes de
|
||||
// destino (p. ej. 31 de enero + 1 mes, y febrero no llega a 31), ajusta al último día válido de ese
|
||||
// mes (28 de febrero, o 29 si es bisiesto).
|
||||
fn breakdown(early: NaiveDate, late: NaiveDate) -> (u32, u32, u32) {
|
||||
let mut months = 0u32;
|
||||
let mut cursor = early;
|
||||
while let Some(next) = early.checked_add_months(Months::new(months + 1)) {
|
||||
if next > late {
|
||||
break;
|
||||
}
|
||||
months += 1;
|
||||
cursor = next;
|
||||
}
|
||||
let days = (late - cursor).num_days() as u32;
|
||||
(months / 12, months % 12, days)
|
||||
}
|
||||
|
||||
// Texto de un componente ("3 años", "1 mes"...), con selección de plural real vía `with_number()`.
|
||||
fn unit(key: &'static str, n: u32, language: &impl LangId) -> String {
|
||||
Lc::l(key)
|
||||
.with_number("n", n)
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| n.to_string())
|
||||
}
|
||||
|
||||
// Une de 1 a 3 componentes ya traducidos en una frase natural del idioma ("3 años, 2 meses y 10
|
||||
// días"). El punto de unión (con o sin coma, con o sin conjunción final) es una propiedad del
|
||||
// idioma, igual que `datetime_join`.
|
||||
fn join(parts: &[String], language: &impl LangId) -> String {
|
||||
match parts {
|
||||
[] => String::new(),
|
||||
[a] => a.clone(),
|
||||
[a, b] => Lc::l("relative_join_two")
|
||||
.with_arg("a", a.clone())
|
||||
.with_arg("b", b.clone())
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| format!("{a} {b}")),
|
||||
[a, b, c, ..] => Lc::l("relative_join_three")
|
||||
.with_arg("a", a.clone())
|
||||
.with_arg("b", b.clone())
|
||||
.with_arg("c", c.clone())
|
||||
.lookup(language)
|
||||
.unwrap_or_else(|| format!("{a} {b} {c}")),
|
||||
}
|
||||
}
|
||||
|
||||
// `breakdown()` y `apply()` dependen de fechas exactas de calendario (bisiestos, fin de mes,
|
||||
// componentes en cero intercalados...), no de la hora actual. Se prueban aquí, con fechas fijas, en
|
||||
// vez de en `tests/datetime.rs` (que sólo ve la API pública `Contextual::format_relative()`,
|
||||
// siempre relativa a `Utc::now()` real y no serviría para fijar estos casos concretos).
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::locale::Locale;
|
||||
|
||||
fn date(year: i32, month: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(year, month, day).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakdown_splits_years_months_days() {
|
||||
assert_eq!(breakdown(date(2020, 3, 5), date(2023, 5, 15)), (3, 2, 10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn breakdown_skips_to_days_when_months_are_zero() {
|
||||
assert_eq!(breakdown(date(2022, 6, 12), date(2024, 6, 15)), (2, 0, 3));
|
||||
}
|
||||
|
||||
#[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".
|
||||
assert_eq!(breakdown(date(2024, 1, 31), date(2024, 3, 1)), (0, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_same_civil_date_is_today() {
|
||||
let today = date(2026, 6, 15);
|
||||
let en = Locale::resolve("en-US");
|
||||
assert_eq!(RelativeFormat::Short.apply(today, today, &en), "today");
|
||||
}
|
||||
|
||||
#[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 en = Locale::resolve("en-US");
|
||||
|
||||
assert_eq!(
|
||||
RelativeFormat::Short.apply(target, today, &en),
|
||||
"2 years ago"
|
||||
);
|
||||
assert_eq!(
|
||||
RelativeFormat::Medium.apply(target, today, &en),
|
||||
"2 years and 3 days ago"
|
||||
);
|
||||
// Sin un tercer componente disponible (meses = 0), `Long` coincide con `Medium`.
|
||||
assert_eq!(
|
||||
RelativeFormat::Long.apply(target, today, &en),
|
||||
"2 years and 3 days ago"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 es = Locale::resolve("es-ES");
|
||||
|
||||
assert_eq!(
|
||||
RelativeFormat::Long.apply(target, today, &es),
|
||||
"hace 3 años, 2 meses y 10 días"
|
||||
);
|
||||
}
|
||||
|
||||
#[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 es = Locale::resolve("es-ES");
|
||||
|
||||
assert_eq!(
|
||||
RelativeFormat::Short.apply(target, today, &es),
|
||||
"dentro de 1 día"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ include_config!(SETTINGS: Settings => [
|
|||
"app.name" => "PageTop App",
|
||||
"app.theme" => "Basic",
|
||||
"app.lang_negotiation" => "Full",
|
||||
"app.timezone" => "UTC",
|
||||
"app.startup_banner" => "Slant",
|
||||
|
||||
// [dev]
|
||||
|
|
@ -76,6 +77,16 @@ pub struct App {
|
|||
/// Define las fuentes que intervienen en la negociación del idioma para el renderizado de los
|
||||
/// documentos y la generación de URLs. Ver [`LangNegotiation`] para los modos disponibles.
|
||||
pub lang_negotiation: LangNegotiation,
|
||||
/// 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()`].
|
||||
///
|
||||
/// Si es `None` o no contiene un valor válido, se aplica `UTC`.
|
||||
///
|
||||
/// [`Timezone`]: crate::datetime::Timezone
|
||||
/// [`CurrentUser::timezone()`]: crate::auth::CurrentUser::timezone
|
||||
pub timezone: Option<String>,
|
||||
/// Banner ASCII mostrado al inicio: *"Off"* (desactivado), *"Slant"*, *"Small"*, *"Speed"* o
|
||||
/// *"Starwars"*.
|
||||
pub startup_banner: StartupBanner,
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ impl Locale {
|
|||
if let Some(langid) = *CONFIG_LANGID {
|
||||
trace::debug!("Default language \"{langid}\" (from config: \"{raw}\")");
|
||||
} else {
|
||||
trace::debug!(
|
||||
trace::warn!(
|
||||
"Default language \"{}\" (fallback, invalid config: \"{raw}\")",
|
||||
*FALLBACK_LANGID
|
||||
);
|
||||
|
|
|
|||
50
src/locale/en-US/datetime.ftl
Normal file
50
src/locale/en-US/datetime.ftl
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Date formats.
|
||||
date_format_short = { $month }/{ $day }/{ $year_short }
|
||||
date_format_medium = { $month }/{ $day }/{ $year }
|
||||
date_format_long = { $month } { $day }, { $year }
|
||||
|
||||
# Time formats.
|
||||
time_format_short = { $hour }:{ $minute }
|
||||
time_format_long = { $hour }:{ $minute }:{ $second }
|
||||
datetime_join = { $date }, { $time }
|
||||
|
||||
# Relative dates.
|
||||
relative_today = today
|
||||
relative_years = { $n ->
|
||||
[one] { $n } year
|
||||
*[other] { $n } years
|
||||
}
|
||||
relative_months = { $n ->
|
||||
[one] { $n } month
|
||||
*[other] { $n } months
|
||||
}
|
||||
relative_days = { $n ->
|
||||
[one] { $n } day
|
||||
*[other] { $n } days
|
||||
}
|
||||
relative_join_two = { $a } and { $b }
|
||||
relative_join_three = { $a }, { $b }, and { $c }
|
||||
relative_past = { $value } ago
|
||||
relative_future = in { $value }
|
||||
|
||||
# Start/end date precision.
|
||||
since_short = since { $month }
|
||||
since_medium = since { $month } { $year }
|
||||
since_long = since { $month } { $day }, { $year }
|
||||
until_short = until { $month }
|
||||
until_medium = until { $month } { $year }
|
||||
until_long = until { $month } { $day }, { $year }
|
||||
|
||||
# Month names.
|
||||
month_01 = January
|
||||
month_02 = February
|
||||
month_03 = March
|
||||
month_04 = April
|
||||
month_05 = May
|
||||
month_06 = June
|
||||
month_07 = July
|
||||
month_08 = August
|
||||
month_09 = September
|
||||
month_10 = October
|
||||
month_11 = November
|
||||
month_12 = December
|
||||
50
src/locale/es-ES/datetime.ftl
Normal file
50
src/locale/es-ES/datetime.ftl
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# Date formats.
|
||||
date_format_short = { $day }/{ $month }/{ $year_short }
|
||||
date_format_medium = { $day }/{ $month }/{ $year }
|
||||
date_format_long = { $day } de { $month } de { $year }
|
||||
|
||||
# Time formats.
|
||||
time_format_short = { $hour }:{ $minute }
|
||||
time_format_long = { $hour }:{ $minute }:{ $second }
|
||||
datetime_join = { $date }, { $time }
|
||||
|
||||
# Relative dates.
|
||||
relative_today = hoy
|
||||
relative_years = { $n ->
|
||||
[one] { $n } año
|
||||
*[other] { $n } años
|
||||
}
|
||||
relative_months = { $n ->
|
||||
[one] { $n } mes
|
||||
*[other] { $n } meses
|
||||
}
|
||||
relative_days = { $n ->
|
||||
[one] { $n } día
|
||||
*[other] { $n } días
|
||||
}
|
||||
relative_join_two = { $a } y { $b }
|
||||
relative_join_three = { $a }, { $b } y { $c }
|
||||
relative_past = hace { $value }
|
||||
relative_future = dentro de { $value }
|
||||
|
||||
# Start/end date precision.
|
||||
since_short = desde { $month }
|
||||
since_medium = desde { $month } de { $year }
|
||||
since_long = desde el { $day } de { $month } de { $year }
|
||||
until_short = hasta { $month }
|
||||
until_medium = hasta { $month } de { $year }
|
||||
until_long = hasta el { $day } de { $month } de { $year }
|
||||
|
||||
# Month names.
|
||||
month_01 = enero
|
||||
month_02 = febrero
|
||||
month_03 = marzo
|
||||
month_04 = abril
|
||||
month_05 = mayo
|
||||
month_06 = junio
|
||||
month_07 = julio
|
||||
month_08 = agosto
|
||||
month_09 = septiembre
|
||||
month_10 = octubre
|
||||
month_11 = noviembre
|
||||
month_12 = diciembre
|
||||
|
|
@ -24,6 +24,7 @@ use crate::base::component::layout;
|
|||
use crate::core::component::{AssetsOp, ChildOp, ComponentRender};
|
||||
use crate::core::component::{Context, ContextError, Contextual};
|
||||
use crate::core::theme::{CoreRegions, RegionName, RegionRef, TemplateRef, ThemeRef};
|
||||
use crate::datetime::Tz;
|
||||
use crate::html::{Assets, Favicon, JavaScript, ResponsiveStyles, StyleSheet};
|
||||
use crate::html::{DOCTYPE, Markup, html};
|
||||
use crate::html::{Props, PropsOp};
|
||||
|
|
@ -280,6 +281,11 @@ impl Contextual for Page {
|
|||
self
|
||||
}
|
||||
|
||||
fn with_timezone(mut self, tz: Tz) -> Self {
|
||||
self.context.alter_timezone(tz);
|
||||
self
|
||||
}
|
||||
|
||||
fn with_template(mut self, template: TemplateRef) -> Self {
|
||||
self.context.alter_template(template);
|
||||
self
|
||||
|
|
@ -326,6 +332,10 @@ impl Contextual for Page {
|
|||
self.context.current_user()
|
||||
}
|
||||
|
||||
fn timezone(&self) -> Tz {
|
||||
self.context.timezone()
|
||||
}
|
||||
|
||||
fn template(&self) -> TemplateRef {
|
||||
self.context.template()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,14 +13,27 @@ async fn anonymous_reports_itself_correctly() {
|
|||
|
||||
#[pagetop::test]
|
||||
async fn authenticated_reports_itself_correctly() {
|
||||
let madrid: Tz = "Europe/Madrid".parse().unwrap();
|
||||
let user = CurrentUser::Authenticated {
|
||||
id: 42,
|
||||
display_name: "Alice".to_owned(),
|
||||
timezone: Some(madrid),
|
||||
};
|
||||
assert!(!user.is_anonymous());
|
||||
assert!(user.is_authenticated());
|
||||
assert_eq!(user.id(), Some(42));
|
||||
assert_eq!(user.display_name(), Some("Alice"));
|
||||
assert_eq!(user.timezone(), madrid);
|
||||
}
|
||||
|
||||
#[pagetop::test]
|
||||
async fn authenticated_falls_back_to_default_timezone_when_none() {
|
||||
let user = CurrentUser::Authenticated {
|
||||
id: 42,
|
||||
display_name: "Alice".to_owned(),
|
||||
timezone: None,
|
||||
};
|
||||
assert_eq!(user.timezone(), Timezone::default_tz());
|
||||
}
|
||||
|
||||
// **< Context::current_user() >********************************************************************
|
||||
|
|
@ -37,6 +50,7 @@ async fn current_user_propagates_from_request_extensions() {
|
|||
.with_extension(CurrentUser::Authenticated {
|
||||
id: 7,
|
||||
display_name: "Bob".to_owned(),
|
||||
timezone: None,
|
||||
})
|
||||
.to_http_request();
|
||||
let cx = Context::new(req);
|
||||
|
|
@ -60,6 +74,7 @@ async fn request_extension_returns_injected_value() {
|
|||
.with_extension(CurrentUser::Authenticated {
|
||||
id: 1,
|
||||
display_name: "Carol".to_owned(),
|
||||
timezone: None,
|
||||
})
|
||||
.to_http_request();
|
||||
|
||||
|
|
@ -79,6 +94,7 @@ async fn page_new_propagates_current_user_from_request_extensions() {
|
|||
.with_extension(CurrentUser::Authenticated {
|
||||
id: 5,
|
||||
display_name: "Dave".to_owned(),
|
||||
timezone: None,
|
||||
})
|
||||
.to_http_request();
|
||||
let page = Page::new(req);
|
||||
|
|
|
|||
325
tests/datetime.rs
Normal file
325
tests/datetime.rs
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
use pagetop::prelude::*;
|
||||
|
||||
async fn setup() {
|
||||
Application::new().await;
|
||||
}
|
||||
|
||||
// **< Context::timezone() >************************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn resolve_uses_anonymous_fallback_to_default() {
|
||||
setup().await;
|
||||
|
||||
let cx = Context::default();
|
||||
assert_eq!(cx.timezone(), Timezone::default_tz());
|
||||
}
|
||||
|
||||
// `CurrentUser::timezone()` resolves the user's own timezone when it's `Some`; this only checks
|
||||
// that the value, when present, reaches `Context` intact. The `None` case (no personal timezone,
|
||||
// falls back to the application's) is already covered by `tests/auth.rs`.
|
||||
#[pagetop::test]
|
||||
async fn resolve_uses_the_timezone_already_resolved_by_the_authenticated_user() {
|
||||
setup().await;
|
||||
|
||||
let madrid: Tz = "Europe/Madrid".parse().unwrap();
|
||||
let req = web::test::TestRequest::get()
|
||||
.with_extension(CurrentUser::Authenticated {
|
||||
id: 1,
|
||||
display_name: "Alice".to_owned(),
|
||||
timezone: Some(madrid),
|
||||
})
|
||||
.to_http_request();
|
||||
let cx = Context::new(req);
|
||||
assert_eq!(cx.timezone(), madrid);
|
||||
}
|
||||
|
||||
// **< Context::with_timezone() >*******************************************************************
|
||||
|
||||
#[pagetop::test]
|
||||
async fn with_timezone_forces_the_effective_timezone() {
|
||||
setup().await;
|
||||
|
||||
let new_york: Tz = "America/New_York".parse().unwrap();
|
||||
let cx = Context::default().with_timezone(new_york);
|
||||
assert_eq!(cx.timezone(), new_york);
|
||||
}
|
||||
|
||||
// **< Context::format_datetime() >*****************************************************************
|
||||
|
||||
// Without a request, the effective language is the fallback ("en-US"): month/day order
|
||||
// (American convention), defined in `src/locale/en-US/datetime.ftl`. `format_datetime()`
|
||||
// combines an independent `DateFormat` and `TimeFormat` with the `datetime_join` separator.
|
||||
#[pagetop::test]
|
||||
async fn format_datetime_uses_en_us_month_first_order_by_default() {
|
||||
setup().await;
|
||||
|
||||
// 2026-06-15T10:30:00Z -> 12:30 in Europe/Madrid (CEST, UTC+2).
|
||||
let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 0).unwrap();
|
||||
let cx = Context::default().with_timezone("Europe/Madrid".parse().unwrap());
|
||||
|
||||
assert_eq!(
|
||||
cx.format_datetime(dt, DateFormat::Short, TimeFormat::Short),
|
||||
"06/15/26, 12:30"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.format_datetime(dt, DateFormat::Medium, TimeFormat::Short),
|
||||
"06/15/2026, 12:30"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.format_datetime(dt, DateFormat::Long, TimeFormat::Long),
|
||||
"June 15, 2026, 12:30:00"
|
||||
);
|
||||
// Different formats for date and time: long date with short time.
|
||||
assert_eq!(
|
||||
cx.format_datetime(dt, DateFormat::Long, TimeFormat::Short),
|
||||
"June 15, 2026, 12:30"
|
||||
);
|
||||
}
|
||||
|
||||
// `format_iso_datetime()` doesn't depend on the language (it doesn't go through Fluent) but does
|
||||
// convert to the active timezone, with its offset: same result under any language.
|
||||
#[pagetop::test]
|
||||
async fn format_iso_datetime_is_the_same_in_every_language() {
|
||||
setup().await;
|
||||
|
||||
let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 0).unwrap();
|
||||
let cx = Context::default().with_timezone("Europe/Madrid".parse().unwrap());
|
||||
|
||||
assert_eq!(cx.format_iso_datetime(dt), "2026-06-15T12:30:00+02:00");
|
||||
assert_eq!(
|
||||
cx.with_langid(&Locale::resolve("es-ES"))
|
||||
.format_iso_datetime(dt),
|
||||
"2026-06-15T12:30:00+02:00"
|
||||
);
|
||||
}
|
||||
|
||||
// The day/month/year order depends on the effective language, not a global constant: in es-ES
|
||||
// the pattern is day/month (Spanish convention), defined in `src/locale/es-ES/datetime.ftl`.
|
||||
#[pagetop::test]
|
||||
async fn format_datetime_uses_es_es_day_first_order() {
|
||||
setup().await;
|
||||
|
||||
let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 0).unwrap();
|
||||
let cx = Context::default()
|
||||
.with_timezone("Europe/Madrid".parse().unwrap())
|
||||
.with_langid(&Locale::resolve("es-ES"));
|
||||
|
||||
assert_eq!(
|
||||
cx.format_datetime(dt, DateFormat::Short, TimeFormat::Short),
|
||||
"15/06/26, 12:30"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.format_datetime(dt, DateFormat::Medium, TimeFormat::Short),
|
||||
"15/06/2026, 12:30"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.format_datetime(dt, DateFormat::Long, TimeFormat::Long),
|
||||
"15 de junio de 2026, 12:30:00"
|
||||
);
|
||||
}
|
||||
|
||||
// **< Context::format_time() >*********************************************************************
|
||||
|
||||
// `format_time()` only shows the time, already converted to the active timezone.
|
||||
#[pagetop::test]
|
||||
async fn format_time_converts_to_the_effective_timezone() {
|
||||
setup().await;
|
||||
|
||||
let dt = Utc.with_ymd_and_hms(2026, 6, 15, 10, 30, 45).unwrap();
|
||||
let cx = Context::default().with_timezone("Europe/Madrid".parse().unwrap());
|
||||
|
||||
assert_eq!(cx.format_time(dt, TimeFormat::Short), "12:30");
|
||||
assert_eq!(cx.format_time(dt, TimeFormat::Long), "12:30:45");
|
||||
assert_eq!(
|
||||
cx.format_time(dt, TimeFormat::Custom("%H:%M:%S")),
|
||||
"12:30:45"
|
||||
);
|
||||
}
|
||||
|
||||
// **< Context::format_date() >*********************************************************************
|
||||
|
||||
// `format_date()` doesn't convert timezone (it doesn't apply to a date without a time); the
|
||||
// result is the same regardless of the context's effective timezone. Under the fallback
|
||||
// language ("en-US"), month/day order.
|
||||
#[pagetop::test]
|
||||
async fn format_date_does_not_depend_on_timezone() {
|
||||
setup().await;
|
||||
|
||||
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
|
||||
let cx = Context::default().with_timezone("Pacific/Auckland".parse().unwrap());
|
||||
|
||||
assert_eq!(cx.format_date(date, DateFormat::Short), "06/15/26");
|
||||
assert_eq!(cx.format_date(date, DateFormat::Medium), "06/15/2026");
|
||||
assert_eq!(cx.format_date(date, DateFormat::Long), "June 15, 2026");
|
||||
assert_eq!(
|
||||
cx.format_date(date, DateFormat::Custom("%d-%m-%Y")),
|
||||
"15-06-2026"
|
||||
);
|
||||
}
|
||||
|
||||
// Same day/month/year order as `format_datetime`, dependent on the effective language.
|
||||
#[pagetop::test]
|
||||
async fn format_date_uses_es_es_day_first_order() {
|
||||
setup().await;
|
||||
|
||||
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
|
||||
let cx = Context::default().with_langid(&Locale::resolve("es-ES"));
|
||||
|
||||
assert_eq!(cx.format_date(date, DateFormat::Short), "15/06/26");
|
||||
assert_eq!(cx.format_date(date, DateFormat::Medium), "15/06/2026");
|
||||
assert_eq!(
|
||||
cx.format_date(date, DateFormat::Long),
|
||||
"15 de junio de 2026"
|
||||
);
|
||||
}
|
||||
|
||||
// `Iso` doesn't depend on the language (it doesn't go through Fluent): same result under any
|
||||
// language.
|
||||
#[pagetop::test]
|
||||
async fn format_date_iso_is_the_same_in_every_language() {
|
||||
setup().await;
|
||||
|
||||
let date = NaiveDate::from_ymd_opt(2026, 6, 15).unwrap();
|
||||
let cx = Context::default();
|
||||
|
||||
assert_eq!(cx.format_date(date, DateFormat::Iso), "2026-06-15");
|
||||
assert_eq!(
|
||||
cx.with_langid(&Locale::resolve("es-ES"))
|
||||
.format_date(date, DateFormat::Iso),
|
||||
"2026-06-15"
|
||||
);
|
||||
}
|
||||
|
||||
// **< Context::format_relative() >*****************************************************************
|
||||
//
|
||||
// The exact calendar breakdown (leap years, zero components...) is tested with fixed dates in
|
||||
// `src/datetime/relative.rs` (`RelativeFormat::apply()` is `pub(crate)`, not visible from here).
|
||||
// These tests verify the connection with the public API: timezone conversion, resolution via a
|
||||
// real `Utc::now()`, and day offsets -- always < 28, so the result doesn't depend on which real
|
||||
// month they run in (a month offset could cross a different month-end depending on the real day
|
||||
// of execution).
|
||||
|
||||
// A value from a few minutes ago or a few minutes from now falls on the same civil date as "now":
|
||||
// "today", regardless of the language.
|
||||
#[pagetop::test]
|
||||
async fn format_relative_collapses_to_today_within_the_same_civil_date() {
|
||||
setup().await;
|
||||
|
||||
let cx = Context::default();
|
||||
let just_before = Utc::now() - Duration::minutes(3);
|
||||
let just_after = Utc::now() + Duration::minutes(3);
|
||||
|
||||
assert_eq!(
|
||||
cx.format_relative(just_before, RelativeFormat::Short),
|
||||
"today"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.with_langid(&Locale::resolve("es-ES"))
|
||||
.format_relative(just_after, RelativeFormat::Short),
|
||||
"hoy"
|
||||
);
|
||||
}
|
||||
|
||||
// A small day offset (below any month) always gives a single days component, both in the past
|
||||
// and in the future.
|
||||
#[pagetop::test]
|
||||
async fn format_relative_uses_day_only_offsets() {
|
||||
setup().await;
|
||||
|
||||
let cx = Context::default();
|
||||
let three_days_ago = Utc::now() - Duration::days(3);
|
||||
let in_five_days = Utc::now() + Duration::days(5);
|
||||
|
||||
assert_eq!(
|
||||
cx.format_relative(three_days_ago, RelativeFormat::Short),
|
||||
"3 days ago"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.with_langid(&Locale::resolve("es-ES"))
|
||||
.format_relative(in_five_days, RelativeFormat::Short),
|
||||
"dentro de 5 días"
|
||||
);
|
||||
}
|
||||
|
||||
// Fluent's plural selector distinguishes singular (1) from plural (everything else), via
|
||||
// `Lc::with_number()`.
|
||||
#[pagetop::test]
|
||||
async fn format_relative_pluralizes_the_unit() {
|
||||
setup().await;
|
||||
|
||||
let cx = Context::default();
|
||||
let yesterday = Utc::now() - Duration::days(1);
|
||||
let three_days_ago = Utc::now() - Duration::days(3);
|
||||
|
||||
assert_eq!(
|
||||
cx.format_relative(yesterday, RelativeFormat::Short),
|
||||
"1 day ago"
|
||||
);
|
||||
assert_eq!(
|
||||
cx.format_relative(three_days_ago, RelativeFormat::Short),
|
||||
"3 days ago"
|
||||
);
|
||||
}
|
||||
|
||||
// **< Context::format_since() / format_until() >***************************************************
|
||||
|
||||
// The translated month and, in `Long`, the article "el" before the day (Spanish only) are part
|
||||
// of each level's own template, not a shared generic wrapper.
|
||||
#[pagetop::test]
|
||||
async fn format_since_reveals_increasing_precision() {
|
||||
setup().await;
|
||||
|
||||
let date = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap();
|
||||
let en = Context::default();
|
||||
let es = Context::default().with_langid(&Locale::resolve("es-ES"));
|
||||
|
||||
assert_eq!(en.format_since(date, DatePrecision::Short), "since June");
|
||||
assert_eq!(
|
||||
en.format_since(date, DatePrecision::Medium),
|
||||
"since June 2026"
|
||||
);
|
||||
assert_eq!(
|
||||
en.format_since(date, DatePrecision::Long),
|
||||
"since June 3, 2026"
|
||||
);
|
||||
|
||||
assert_eq!(es.format_since(date, DatePrecision::Short), "desde junio");
|
||||
assert_eq!(
|
||||
es.format_since(date, DatePrecision::Medium),
|
||||
"desde junio de 2026"
|
||||
);
|
||||
assert_eq!(
|
||||
es.format_since(date, DatePrecision::Long),
|
||||
"desde el 3 de junio de 2026"
|
||||
);
|
||||
}
|
||||
|
||||
// Same mechanism as `format_since()`, symmetric for an end date.
|
||||
#[pagetop::test]
|
||||
async fn format_until_is_symmetric_to_format_since() {
|
||||
setup().await;
|
||||
|
||||
let date = NaiveDate::from_ymd_opt(2026, 6, 3).unwrap();
|
||||
let en = Context::default();
|
||||
let es = Context::default().with_langid(&Locale::resolve("es-ES"));
|
||||
|
||||
assert_eq!(en.format_until(date, DatePrecision::Short), "until June");
|
||||
assert_eq!(
|
||||
en.format_until(date, DatePrecision::Medium),
|
||||
"until June 2026"
|
||||
);
|
||||
assert_eq!(
|
||||
en.format_until(date, DatePrecision::Long),
|
||||
"until June 3, 2026"
|
||||
);
|
||||
|
||||
assert_eq!(es.format_until(date, DatePrecision::Short), "hasta junio");
|
||||
assert_eq!(
|
||||
es.format_until(date, DatePrecision::Medium),
|
||||
"hasta junio de 2026"
|
||||
);
|
||||
assert_eq!(
|
||||
es.format_until(date, DatePrecision::Long),
|
||||
"hasta el 3 de junio de 2026"
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue