From 8f8b5b4601029a1037088ef65c1fb602360252a6 Mon Sep 17 00:00:00 2001 From: Priec Date: Thu, 13 Aug 2026 10:20:04 +0200 Subject: [PATCH] ecb for dashboard - WRONG NEEDS CORRECTION ASAP --- client | 2 +- common/proto/ecb.proto | 11 ++++++++ common/src/proto/descriptor.bin | Bin 171962 -> 172926 bytes common/src/proto/komp_ac.ecb.rs | 16 +++++++++++ server | 2 +- web/src/pages/admin/ecb/loader.rs | 2 ++ web/src/pages/admin/ecb/state.rs | 8 +++++- web/src/pages/admin/ecb/ui.rs | 20 ++++++++++++++ web/static/app.css | 11 ++++++++ web/templates/pages/admin/ecb/status.html | 31 ++++++++++++++++++++++ 10 files changed, 100 insertions(+), 3 deletions(-) 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 921214d28fa74fbbcbf6681d3d5f6c1e7929d0e1..2d5576ba722bd24c7719729ed5bae3f38d81ed2d 100644 GIT binary patch delta 4901 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 EKgRXH>i_@% delta 3887 zcmYM1+iz9J6~@n+GiRT3wlVe@V=m@uE-?-`n6^<#TvP>4ee81i5nii(0`@(?Bb3w`NJZx3x%^|#K-){j z+H=-t{`cqfvzhdhzrSbx{oBcZzIVykKa|rIdE@-z$%zZUmi*HDJ(K@^|D}%l@T+On zRZ69&liMa%)@1wSFQ$en>C369l$?FN^z;0~nU}NIE6dWh)K*HS5G0llc-!AD)o+gmo$6GQ4GGln+MWa zX&w``FaOJfhlBfC`{+&g)mH^~Su1+irk8xZb>;o0r|0*QpPB2*|N3xj)BcvxV%%Tv z4o2Ei-oJEN`jt0wl>_;eKYhLFfJs6{h57^4c+3j*2bT4u7qq3;Ro*JSlV5psA$zN~ zzIjQjEj<`oiU4>pY>fhSFtii}>R@Q8)$1P$Ek$5E)Us(y4~3SphV4*jsWr6puvwaB z+ENt0!!4gO57)bz_h1d*;l)eBDG^8b6y8?mOB@Mb!W!-Dk?DHNC;cxsJDbnP5BK zx?FtqOB1XloM`wR6tIwS{*j8x$;T>Gv;zN zASYXp8jzDMNKIT;KI#1Lfl??Z*ZNQIJSlsc55w$8Z5QHC1LvB7?I zAVYoq;V7vqXJ%t4Oo3!AND3rt%i@JnD4C0$PN8HQ`Y`IpYjgH(VN|p?-%6Wp$~4M~ zO70X^MWx#iCDY?=*{JtW(gJ6P#q@>61=eM%BWeMt;R3Y))UdeTSPg1e*6WN?RJ*gM zgo)+1ZRb$PNNR_4N@!77g>*_9Yodg7O4ea!qxttgpUXxILI;G=;xHW$Mx`+r4$}c)RKERUxK^jLr-hO258laH zknt>?kWLq*PDrPv5qr=H>9q9gNVodCYB)P4W=lXdKp`Wl0cxy3H9(C?qdyjd8k5cX zJJ(w|XUBziHg=H7>lRUorS9E_qi2=g{bFlU5;`=h3V#k2X}=6*bIws0`+>{(%Mc?a_j z$>7@3bQovP<$J%_`ocLe)lkBqv*)Dd-)==G9dc23dR{+fMdkT?_KOPx=Zj4ka`wE` z7X<=S4!LA$`m?5k&RopL=C==AEH+`h*^5$Z4-U=Ki_+OhUu`;Q-sQah^rzb{$BtGE zbIx9l-v$ZbvNYBO0Fw^+TwnSc4NY9~fs`KR`P1#Md>}QQhSnz9(5u3D*G{0|A^ok9 z@UBYD_X!E_s`lw8O)HDn@-1J!l3gnt+SzMT^OsIENswIHZ$@XY3)9wbMwF=ZpD{7o z;_Je^4XaqM3-i`nQGXQD%P*F(BE22WA_citb4p2M%A}BQeo-?88A)xBvdQ=hpcvGoka3|J)T9t{9!03!j?*bIgo?_D z(oDHs97;3gwuIqCF{Il%oIHt4xf6qq_otzPjwlV4I|Zs8)SZ}g6oa}GlP=Lrxf?SD z1qm5Rnkjb+l4i7y>Xt>-j-m<0%GQLMmllxL%5x!Rg>wQ_>oBl)R zYNBy6oqze&zq09KcTJJ$;vNpdbPP!x5T;{DCZ@~TnHZ8N?tzT==z=s;kh&nv#E_&I z(o8EP6SK(K*%*>2P{@c{1ZuWGEdn(gLy}@pvoRzS)9vho;u&^BLPkL)r9+GCw|+nNp*pX7JG`QZirpjkOT^3k)YkDkdlbN>g+dx7@= 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. #}