diff --git a/client b/client index d8cbdbfc..c48deeb2 160000 --- a/client +++ b/client @@ -1 +1 @@ -Subproject commit d8cbdbfcf180bd769265c82b458658691271a1b4 +Subproject commit c48deeb2caee4b4e19fbf1b50a2f654b7c8c68e4 diff --git a/common/proto/ecb.proto b/common/proto/ecb.proto index 717f4262..f2f905cf 100644 --- a/common/proto/ecb.proto +++ b/common/proto/ecb.proto @@ -80,6 +80,17 @@ message GetEcbPipelineStatusResponse { // Distinct currencies observed on verified_through_date, so a coverage gap // for one currency is visible even when the date itself is covered. repeated string covered_currencies = 8; + + // Current coverage restated as the question a reader actually has: what may + // be posted right now. Both are the latest basis date whose derived + // publication date is still covered, under the two rules + // required_publication_date applies -- transactions look strictly before the + // basis date, statements and decisive dates look at it directly, so the + // transaction bound is the later of the two. Derived here rather than by the + // caller so it cannot disagree with the calendar the conversion path uses. + // Absent when nothing has ever been verified and nothing can be posted. + optional string transactions_postable_through = 9; + optional string statements_postable_through = 10; } // Conversion basis rule used to select an ECB publication. diff --git a/common/src/proto/descriptor.bin b/common/src/proto/descriptor.bin index 921214d2..2d5576ba 100644 Binary files a/common/src/proto/descriptor.bin and b/common/src/proto/descriptor.bin differ diff --git a/common/src/proto/komp_ac.ecb.rs b/common/src/proto/komp_ac.ecb.rs index e8e8d4a4..03cd6f6a 100644 --- a/common/src/proto/komp_ac.ecb.rs +++ b/common/src/proto/komp_ac.ecb.rs @@ -69,6 +69,22 @@ pub struct GetEcbPipelineStatusResponse { /// for one currency is visible even when the date itself is covered. #[prost(string, repeated, tag = "8")] pub covered_currencies: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Current coverage restated as the question a reader actually has: what may + /// be posted right now. Both are the latest basis date whose derived + /// publication date is still covered, under the two rules + /// required_publication_date applies -- transactions look strictly before the + /// basis date, statements and decisive dates look at it directly, so the + /// transaction bound is the later of the two. Derived here rather than by the + /// caller so it cannot disagree with the calendar the conversion path uses. + /// Absent when nothing has ever been verified and nothing can be posted. + #[prost(string, optional, tag = "9")] + pub transactions_postable_through: ::core::option::Option< + ::prost::alloc::string::String, + >, + #[prost(string, optional, tag = "10")] + pub statements_postable_through: ::core::option::Option< + ::prost::alloc::string::String, + >, } /// Exact inputs for a conversion preview. Decimal values are strings so the /// client never loses precision through binary floating point. diff --git a/server b/server index 59acd7d8..09b145f9 160000 --- a/server +++ b/server @@ -1 +1 @@ -Subproject commit 59acd7d8d389b57c4af69dd0428e66edfd8e9ac8 +Subproject commit 09b145f9e37e84e349012852aff2581a0145c23e diff --git a/web/src/pages/admin/ecb/loader.rs b/web/src/pages/admin/ecb/loader.rs index 14ade665..74e27b17 100644 --- a/web/src/pages/admin/ecb/loader.rs +++ b/web/src/pages/admin/ecb/loader.rs @@ -69,6 +69,8 @@ pub(crate) async fn load_ecb_page( import_running: status.import_running, next_import_at: status.next_import_at, covered_currencies: status.covered_currencies, + transactions_postable_through: status.transactions_postable_through, + statements_postable_through: status.statements_postable_through, batches: status .batches .into_iter() diff --git a/web/src/pages/admin/ecb/state.rs b/web/src/pages/admin/ecb/state.rs index 205a446e..382e2b6d 100644 --- a/web/src/pages/admin/ecb/state.rs +++ b/web/src/pages/admin/ecb/state.rs @@ -37,8 +37,12 @@ fn coarse(duration: SignedDuration) -> String { format!("{hours}h {minutes}m") } else if minutes > 0 { format!("{minutes}m {}s", seconds % 60) - } else { + } else if seconds > 0 { format!("{seconds}s") + } else { + // A run that finished inside the same second is fast, not instant, and + // "0s" reads like a missing measurement. + "<1s".to_string() } } @@ -133,6 +137,8 @@ pub(crate) struct EcbPageState { pub next_import_at: String, pub batches: Vec, pub covered_currencies: Vec, + pub transactions_postable_through: Option, + pub statements_postable_through: Option, } impl EcbPageState { diff --git a/web/src/pages/admin/ecb/ui.rs b/web/src/pages/admin/ecb/ui.rs index a2592cec..c9ff43ff 100644 --- a/web/src/pages/admin/ecb/ui.rs +++ b/web/src/pages/admin/ecb/ui.rs @@ -70,6 +70,8 @@ mod tests { next_import_at: "2026-08-13T15:00:00Z".to_string(), batches: vec![batch(9, "succeeded")], covered_currencies: vec!["CZK".to_string(), "USD".to_string()], + transactions_postable_through: Some("2026-08-13".to_string()), + statements_postable_through: Some("2026-08-12".to_string()), } } @@ -131,6 +133,20 @@ mod tests { assert!(html.contains("2 failed"), "{html}"); } + /// Coverage is a publication date; the reader's question is which dates + /// they may post. The card has to answer the second one outright, and the + /// two bounds differ because the two rules differ. + #[test] + fn the_card_says_which_dates_can_be_posted() { + let html = render_page(&healthy_page()); + + assert!(html.contains("What can be posted right now"), "{html}"); + assert!(html.contains("Ordinary transactions")); + assert!(html.contains("2026-08-13"), "{html}"); + assert!(html.contains("Statements & decisive dates")); + assert!(html.contains("2026-08-12"), "{html}"); + } + /// A pipeline that has never succeeded is a different problem from one /// that has fallen behind, and reads differently. #[test] @@ -140,12 +156,16 @@ mod tests { page.verified_through_date = None; page.covered_currencies.clear(); page.batches.clear(); + page.transactions_postable_through = None; + page.statements_postable_through = None; let html = render_page(&page); assert!(!html.contains("Template error"), "{html}"); assert!(html.contains("No rates have ever been imported")); assert!(html.contains("every conversion in a foreign currency is refused")); + // And the postable block says so too rather than showing an empty date. + assert!(html.contains("Nothing."), "{html}"); } /// While a batch runs the card watches itself, and the fragment it swaps diff --git a/web/static/app.css b/web/static/app.css index 2555ac10..6ff70a4b 100644 --- a/web/static/app.css +++ b/web/static/app.css @@ -232,6 +232,17 @@ .status-card.healthy { border-left-color: #21643a; } .status-card.unhealthy { border-left-color: #a12b2b; } .status-card.unhealthy h2 { color: #a12b2b; } + /* The postable limits. This is what the reader came for -- coverage dates + are the mechanism behind it -- so it sits directly under the headline and + is the only thing on the card set at reading size. */ + .postable { margin-top: 16px; padding: 14px; border: 1px solid #dfe4ea; border-radius: 8px; background: white; } + .postable-lead { margin: 0 0 10px; color: #7c8796; font-size: 11px; letter-spacing: .04em; text-transform: uppercase; } + .postable-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; margin: 0 0 10px; } + .postable-grid dt { color: #33415c; font-size: 13px; font-weight: 600; } + .postable-grid dd { margin: 3px 0 0; color: #17202a; font-size: 14px; } + .postable-grid dd strong { font-size: 16px; font-variant-numeric: tabular-nums; } + .postable-grid dd.hint { font-size: 12px; } + /* Coverage reads left to right as one sentence -- reached, gap, owed -- so the two dates are compared rather than looked up one at a time. */ .coverage { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f7f9fc; } diff --git a/web/templates/pages/admin/ecb/status.html b/web/templates/pages/admin/ecb/status.html index 0fef0960..52e8a9fe 100644 --- a/web/templates/pages/admin/ecb/status.html +++ b/web/templates/pages/admin/ecb/status.html @@ -16,6 +16,37 @@

{{ page.explanation() }}

+ {# The question a reader came with. Coverage is a publication date, and + turning it into "which transaction dates will post" means applying the + rule the backend applies -- so the backend applies it and sends both + bounds, rather than the page inviting the reader to do it in their head. #} +
+

What can be posted right now

+ {% if let Some(through) = page.transactions_postable_through %} +
+
+
Ordinary transactions
+
dated up to and including {{ through }}
+
Converted at the rate published the business day before the transaction.
+
+
+
Statements & decisive dates
+
dated up to and including {{ page.statements_postable_through.as_deref().unwrap_or("—") }}
+
Converted at the rate published on that day, or the last business day before it.
+
+
+

+ A foreign-currency amount dated after its limit is refused outright until the + importer catches up — it is not converted at a stale rate. +

+ {% else %} +

+ Nothing. No publication has ever been verified, so every + foreign-currency amount is refused whatever its date. +

+ {% endif %} +
+ {# Coverage first and on its own line: the two dates and the gap between them are the whole answer, and burying them in a four-column grid made them read as trivia. #}