From 6a52f3c2d1ff5af1cae1cd1f16b23a7f9df7f19e Mon Sep 17 00:00:00 2001 From: Miroito Date: Fri, 3 Mar 2023 15:08:34 +0100 Subject: [PATCH 1/5] add created_at column to transaction table --- Cargo.lock | 1 + client/src/api/types/transaction.rs | 4 +- server/migration/Cargo.toml | 6 +-- server/migration/src/lib.rs | 2 + ...20230303_132528_transactions_created_at.rs | 52 +++++++++++++++++++ server/src/model/transaction.rs | 1 + server/src/route/transaction.rs | 5 +- 7 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 server/migration/src/m20230303_132528_transactions_created_at.rs diff --git a/Cargo.lock b/Cargo.lock index 09a39e0..d0fbe3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2011,6 +2011,7 @@ name = "migration" version = "0.1.0" dependencies = [ "async-std", + "chrono", "sea-orm-migration", ] diff --git a/client/src/api/types/transaction.rs b/client/src/api/types/transaction.rs index a681b45..9a65061 100644 --- a/client/src/api/types/transaction.rs +++ b/client/src/api/types/transaction.rs @@ -1,4 +1,4 @@ -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveDateTime}; use serde::{Deserialize, Serialize}; use super::company::Company; @@ -17,6 +17,7 @@ pub struct Transaction { pub instrument: String, pub volume: i32, pub unit_price: f32, + pub created_at: NaiveDateTime, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -32,5 +33,6 @@ pub struct TransactionCompany { pub instrument: String, pub volume: i32, pub unit_price: f32, + pub created_at: NaiveDateTime, pub company: Company, } diff --git a/server/migration/Cargo.toml b/server/migration/Cargo.toml index 6d3180b..d3387e8 100644 --- a/server/migration/Cargo.toml +++ b/server/migration/Cargo.toml @@ -10,10 +10,8 @@ path = "src/lib.rs" [dependencies] async-std = { version = "^1", features = ["attributes", "tokio1"] } +chrono.workspace = true [dependencies.sea-orm-migration] version = "^0.10.0" -features = [ -"sqlx-mysql", -"runtime-tokio-rustls", -] +features = ["sqlx-mysql", "runtime-tokio-rustls"] diff --git a/server/migration/src/lib.rs b/server/migration/src/lib.rs index 86bb294..a98dfce 100644 --- a/server/migration/src/lib.rs +++ b/server/migration/src/lib.rs @@ -3,6 +3,7 @@ pub use sea_orm_migration::prelude::*; mod m20230112_115856_create_company_table; mod m20230112_160440_create_transaction_table; mod m20230119_112539_create_transactions_in_process_table; +mod m20230303_132528_transactions_created_at; pub struct Migrator; @@ -13,6 +14,7 @@ impl MigratorTrait for Migrator { Box::new(m20230112_115856_create_company_table::Migration), Box::new(m20230112_160440_create_transaction_table::Migration), Box::new(m20230119_112539_create_transactions_in_process_table::Migration), + Box::new(m20230303_132528_transactions_created_at::Migration), ] } } diff --git a/server/migration/src/m20230303_132528_transactions_created_at.rs b/server/migration/src/m20230303_132528_transactions_created_at.rs new file mode 100644 index 0000000..ec3c20c --- /dev/null +++ b/server/migration/src/m20230303_132528_transactions_created_at.rs @@ -0,0 +1,52 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Transaction::Table) + .add_column( + ColumnDef::new(Transaction::CreatedAt) + .date_time() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + let query = Query::update() + .table(Transaction::Table) + .value( + Transaction::CreatedAt, + Expr::col(Transaction::DatePublished), + ) + .to_owned(); + + manager.exec_stmt(query).await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Transaction::Table) + .drop_column(Transaction::CreatedAt) + .to_owned(), + ) + .await + } +} + +/// Learn more at https://docs.rs/sea-query#iden +#[derive(Iden)] +enum Transaction { + Table, + DatePublished, + CreatedAt, +} diff --git a/server/src/model/transaction.rs b/server/src/model/transaction.rs index 55ea84c..1759ab8 100644 --- a/server/src/model/transaction.rs +++ b/server/src/model/transaction.rs @@ -21,6 +21,7 @@ pub struct Model { pub instrument: String, pub volume: i32, pub unit_price: f32, + pub created_at: DateTime, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/server/src/route/transaction.rs b/server/src/route/transaction.rs index 6863229..67206fe 100644 --- a/server/src/route/transaction.rs +++ b/server/src/route/transaction.rs @@ -1,4 +1,4 @@ -use chrono::NaiveDate; +use chrono::{NaiveDate, NaiveDateTime}; use rocket::http::Status; use rocket::response::status::Custom; use sea_orm::{ColumnTrait, Order}; @@ -52,6 +52,7 @@ pub async fn get_all( instrument: t.0.instrument.to_owned(), volume: t.0.volume, unit_price: t.0.unit_price, + created_at: t.0.created_at, company: t.1.to_owned(), }) .collect(); @@ -110,6 +111,7 @@ pub async fn get_by_company_id( instrument: t.0.instrument.to_owned(), volume: t.0.volume, unit_price: t.0.unit_price, + created_at: t.0.created_at, company: t.1.to_owned(), }) .collect(); @@ -136,5 +138,6 @@ pub struct TransactionCompany { pub instrument: String, pub volume: i32, pub unit_price: f32, + pub created_at: NaiveDateTime, pub company: Option, } -- 2.36.3 From 3976e8ff9b0d3039b9105bdcb715908968fad488 Mon Sep 17 00:00:00 2001 From: Miroito Date: Fri, 3 Mar 2023 16:09:58 +0100 Subject: [PATCH 2/5] Clean up transaction routes --- client/src/api/routes/transaction.rs | 36 ++---- client/src/api/types/transaction.rs | 4 +- client/src/templates/index.rs | 76 ++++++------ ...20230303_132528_transactions_created_at.rs | 8 +- server/src/db/paginate.rs | 8 +- server/src/lib.rs | 3 +- server/src/model/transaction.rs | 2 +- server/src/route/transaction.rs | 115 ++++++------------ 8 files changed, 96 insertions(+), 156 deletions(-) diff --git a/client/src/api/routes/transaction.rs b/client/src/api/routes/transaction.rs index c3fadb8..60ec76e 100644 --- a/client/src/api/routes/transaction.rs +++ b/client/src/api/routes/transaction.rs @@ -7,17 +7,18 @@ pub async fn get_transactions( ) -> Result, ()> { use crate::env::Config; + let api_url = Config::new().api_url; + let route = &format!( + "{}transaction?{}&page={}&size={}", + api_url, + company_slug.map_or("".to_string(), |c| format!("company_slug={}", c)), + page, + size, + ); + + // TODO: Remove build-time environment variable #[cfg(client)] - { - // TODO: Remove build-time environment variable - let api_url = Config::new().api_url; - let res = reqwasm::http::Request::get(&format!( - "{}transaction{}?page={}&size={}", - api_url, - company_slug.unwrap_or_default(), - page, - size, - )) + let res = reqwasm::http::Request::get(route) .send() .await .map_err(|_| ())? @@ -25,24 +26,13 @@ pub async fn get_transactions( .await .map_err(|_| ())?; - return Ok(res); - } - #[cfg(engine)] - { - let res = reqwest::get(&format!( - "{}transaction{}?page={}&size={}", - Config::new().api_url, - company_slug.unwrap_or_default(), - page, - size, - )) + let res = reqwest::get(route) .await .map_err(|_| ())? .json::>() .await .map_err(|_| ())?; - return Ok(res); - } + return Ok(res); } diff --git a/client/src/api/types/transaction.rs b/client/src/api/types/transaction.rs index 9a65061..266f46f 100644 --- a/client/src/api/types/transaction.rs +++ b/client/src/api/types/transaction.rs @@ -17,7 +17,7 @@ pub struct Transaction { pub instrument: String, pub volume: i32, pub unit_price: f32, - pub created_at: NaiveDateTime, + pub created_at_utc: NaiveDateTime, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -33,6 +33,6 @@ pub struct TransactionCompany { pub instrument: String, pub volume: i32, pub unit_price: f32, - pub created_at: NaiveDateTime, + pub created_at_utc: NaiveDateTime, pub company: Company, } diff --git a/client/src/templates/index.rs b/client/src/templates/index.rs index 7885bbb..5568c58 100644 --- a/client/src/templates/index.rs +++ b/client/src/templates/index.rs @@ -16,7 +16,7 @@ use crate::{ #[derive(Serialize, Deserialize, Clone, ReactiveState)] #[rx(alias = "IndexPageStateRx")] pub struct IndexPageState { - pub company_slug: String, + pub company_slug: Option, } #[auto_scope] @@ -45,11 +45,7 @@ fn index_page(cx: Scope, state: &IndexPageStateRx) -> View { let paginated_table_state: PaginatedTableStateRx = PaginatedTableStateRx { route: get_transactions, - filter: if (*state.company_slug.get()).is_empty() { - None - } else { - Some((*state.company_slug.get()).clone()) - }, + filter: (*state.company_slug.get()).clone(), }; let async_select_prop: AsyncSelectRx = AsyncSelectRx { @@ -76,49 +72,49 @@ fn index_page(cx: Scope, state: &IndexPageStateRx) -> View { }); view! {cx, - main (class=if *dark_mode_3.get() { "dark" } else { "" }) { - div (class="bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-100 font-sans") { - header (class="shadow-md h-12 p-2 align-middle w-full bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") { - div (class="flex flex-row justify-between") { - div (class="mr-10 align-middle") { - a (href="/", class="hover:underline") { - "Fast Insiders" - } - } - div (class="align-middle") { - button (on:click=toggle_dark_mode, class="mx-1 py-1 px-2 bg-slate-200 dark:bg-slate-800 rounded-full") - { "Toggle dark mode" } + main (class=if *dark_mode_3.get() { "dark" } else { "" }) { + div (class="bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-100 font-sans") { + header (class="shadow-md h-12 p-2 align-middle w-full bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") { + div (class="flex flex-row justify-between") { + div (class="mr-10 align-middle") { + a (href="/", class="hover:underline") { + "Fast Insiders" } } + div (class="align-middle") { + button (on:click=toggle_dark_mode, class="mx-1 py-1 px-2 bg-slate-200 dark:bg-slate-800 rounded-full") + { "Toggle dark mode" } + } } - div (id="main", class="flex flex-col items-center justify-center ") { - div (class="w-4/5 m-10 p-3 bg-slate-100 dark:bg-slate-600 rounded-lg items-center justify-center") { - a (class="hover:underline", href="/") { - h1 ( - class="text-center text-lg" - ) { - "Insider Transactions published by the AMF" - } + } + div (id="main", class="flex flex-col items-center justify-center ") { + div (class="w-4/5 m-10 p-3 bg-slate-100 dark:bg-slate-600 rounded-lg items-center justify-center") { + a (class="hover:underline", href="/") { + h1 ( + class="text-center text-lg" + ) { + "Insider Transactions published by the AMF" } - BaseButton(filter_expand) + } + BaseButton(filter_expand) div {} // Without this useless div, the code doesn't run in the browser - div (id="filters", class=format!("p-2 border rounded-lg border-slate-200 dark:border-slate-800 bg-slate-200 dark:bg-slate-700 transition-all ease-in {}", - if *expand.get() { "h-40 visible" } else { "h-0 collapse" }, - ) - ) - { - div (class="w-80") { - p () {"Search for a company:"} - BaseAsyncSelect(async_select_prop) + div (id="filters", class=format!("p-2 border rounded-lg border-slate-200 dark:border-slate-800 bg-slate-200 dark:bg-slate-700 transition-all ease-in {}", + if *expand.get() { "h-40 visible" } else { "h-0 collapse" }, + ) + ) + { + div (class="w-80") { + p () {"Search for a company:"} + BaseAsyncSelect(async_select_prop) BaseButton(search_button) - } } - PaginatedTable(paginated_table_state) } + PaginatedTable(paginated_table_state) } } } } + } } pub fn get_template() -> Template { @@ -142,11 +138,7 @@ fn head(cx: Scope) -> View { async fn get_build_state( StateGeneratorInfo { path, .. }: StateGeneratorInfo<()>, ) -> Result> { - let company_slug: String = path - .split("transactions") - .nth(1) - .unwrap_or(&("/".to_owned() + &path)) - .to_string(); + let company_slug = if path.is_empty() { None } else { Some(path) }; Ok(IndexPageState { company_slug }) } diff --git a/server/migration/src/m20230303_132528_transactions_created_at.rs b/server/migration/src/m20230303_132528_transactions_created_at.rs index ec3c20c..b346b3c 100644 --- a/server/migration/src/m20230303_132528_transactions_created_at.rs +++ b/server/migration/src/m20230303_132528_transactions_created_at.rs @@ -11,7 +11,7 @@ impl MigrationTrait for Migration { Table::alter() .table(Transaction::Table) .add_column( - ColumnDef::new(Transaction::CreatedAt) + ColumnDef::new(Transaction::CreatedAtUtc) .date_time() .not_null() .default(Expr::current_timestamp()), @@ -23,7 +23,7 @@ impl MigrationTrait for Migration { let query = Query::update() .table(Transaction::Table) .value( - Transaction::CreatedAt, + Transaction::CreatedAtUtc, Expr::col(Transaction::DatePublished), ) .to_owned(); @@ -36,7 +36,7 @@ impl MigrationTrait for Migration { .alter_table( Table::alter() .table(Transaction::Table) - .drop_column(Transaction::CreatedAt) + .drop_column(Transaction::CreatedAtUtc) .to_owned(), ) .await @@ -48,5 +48,5 @@ impl MigrationTrait for Migration { enum Transaction { Table, DatePublished, - CreatedAt, + CreatedAtUtc, } diff --git a/server/src/db/paginate.rs b/server/src/db/paginate.rs index a056650..3943347 100644 --- a/server/src/db/paginate.rs +++ b/server/src/db/paginate.rs @@ -58,7 +58,7 @@ pub async fn paginate_also_related( size: Option, column: Option, order: Option, - filter: Option, + filters: Option>, ) -> Result)>, DbErr> where E: EntityTrait + Related, @@ -79,8 +79,10 @@ where selector = E::find().find_also_related::(R::default()); } - if let Some(fil) = filter { - selector = selector.filter(fil); + if let Some(fils) = filters { + for fil in fils { + selector = selector.filter(fil); + } } let pages = selector.into_model().paginate(db, s); diff --git a/server/src/lib.rs b/server/src/lib.rs index 085ae5b..72f6b52 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -53,8 +53,7 @@ async fn start_rocket() -> Result<(), sea_orm_rocket::rocket::Error> { routes![ route::company::get_all, route::company::get_by_isin, - route::transaction::get_by_company_id, - route::transaction::get_all, + route::transaction::get_transactions, route::in_process_transaction::get_all, route::in_process_transaction::retry_failed_transaction, route::in_process_transaction::retry_all diff --git a/server/src/model/transaction.rs b/server/src/model/transaction.rs index 1759ab8..87d060d 100644 --- a/server/src/model/transaction.rs +++ b/server/src/model/transaction.rs @@ -21,7 +21,7 @@ pub struct Model { pub instrument: String, pub volume: i32, pub unit_price: f32, - pub created_at: DateTime, + pub created_at_utc: DateTime, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/server/src/route/transaction.rs b/server/src/route/transaction.rs index 67206fe..8bb86a9 100644 --- a/server/src/route/transaction.rs +++ b/server/src/route/transaction.rs @@ -1,4 +1,4 @@ -use chrono::{NaiveDate, NaiveDateTime}; +use chrono::{Duration, NaiveDate, NaiveDateTime, Utc}; use rocket::http::Status; use rocket::response::status::Custom; use sea_orm::{ColumnTrait, Order}; @@ -9,71 +9,45 @@ use serde::{Deserialize, Serialize}; use crate::db::paginate::{paginate_also_related, PaginatedResponse}; use crate::{db::Db, model}; -#[get("/transaction?&")] -pub async fn get_all( - conn: Connection<'_, Db>, - page: Option, - size: Option, -) -> Result>, Custom> { - let res = paginate_also_related::< - model::transaction::Entity, - model::company::Entity, - model::transaction::Model, - model::company::Model, - model::transaction::Column, - >( - conn, - page, - size, - Some(model::transaction::Column::DatePublished), - Some(Order::Desc), - None, - ) - .await - .map_err(|e| { - Custom( - Status::InternalServerError, - format!("Database error: {}", e), - ) - })?; - - let list = res - .list - .iter() - .map(|t| TransactionCompany { - id: t.0.id, - foreign_id: t.0.foreign_id.to_owned(), - date_published: t.0.date_published, - date_executed: t.0.date_executed, - person: t.0.person.to_owned(), - exchange: t.0.exchange.to_owned(), - nature: t.0.nature.to_owned(), - isin: t.0.isin.clone(), - instrument: t.0.instrument.to_owned(), - volume: t.0.volume, - unit_price: t.0.unit_price, - created_at: t.0.created_at, - company: t.1.to_owned(), - }) - .collect(); - - let res = PaginatedResponse { - count: res.count, - num_pages: res.num_pages, - list, - }; - - Ok(Json(res)) +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct TransactionCompany { + pub id: i32, + pub foreign_id: String, + pub date_published: NaiveDate, + pub date_executed: NaiveDate, + pub person: String, + pub exchange: String, + pub nature: String, + pub isin: Option, + pub instrument: String, + pub volume: i32, + pub unit_price: f32, + pub created_at_utc: NaiveDateTime, + pub company: Option, } -#[get("/transaction/?&")] -pub async fn get_by_company_id( +#[get("/transaction?&&&")] +pub async fn get_transactions( conn: Connection<'_, Db>, - company_slug: String, + company_slug: Option, + hours: Option, page: Option, size: Option, ) -> Result>, Custom> { - let filter = model::company::Column::Slug.eq(company_slug); + let mut filters = vec![]; + if let Some(c) = company_slug { + filters.push(model::company::Column::Slug.eq(c)) + } + if let Some(h) = hours { + filters.push( + model::transaction::Column::CreatedAtUtc.gte( + Utc::now() + .naive_utc() + .checked_sub_signed(Duration::hours(h)), + ), + ) + } + let res = paginate_also_related::< model::transaction::Entity, model::company::Entity, @@ -86,7 +60,7 @@ pub async fn get_by_company_id( size, Some(model::transaction::Column::DatePublished), Some(Order::Desc), - Some(filter), + Some(filters), ) .await .map_err(|e| { @@ -111,7 +85,7 @@ pub async fn get_by_company_id( instrument: t.0.instrument.to_owned(), volume: t.0.volume, unit_price: t.0.unit_price, - created_at: t.0.created_at, + created_at_utc: t.0.created_at_utc, company: t.1.to_owned(), }) .collect(); @@ -124,20 +98,3 @@ pub async fn get_by_company_id( Ok(Json(res)) } - -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -pub struct TransactionCompany { - pub id: i32, - pub foreign_id: String, - pub date_published: NaiveDate, - pub date_executed: NaiveDate, - pub person: String, - pub exchange: String, - pub nature: String, - pub isin: Option, - pub instrument: String, - pub volume: i32, - pub unit_price: f32, - pub created_at: NaiveDateTime, - pub company: Option, -} -- 2.36.3 From 34ea39bfe0d7726c4403e55b7a617697134d63d8 Mon Sep 17 00:00:00 2001 From: Miroito Date: Sat, 4 Mar 2023 15:03:18 +0100 Subject: [PATCH 3/5] proper index page --- Cargo.lock | 73 +++++---- client/src/api/routes/transaction.rs | 44 +++++- client/src/api/types/paginated_response.rs | 69 ++++++--- client/src/api/types/transaction.rs | 8 + .../src/components/main_content_container.rs | 21 +++ client/src/components/mod.rs | 1 + client/src/components/paginated_data_table.rs | 59 +++++--- client/src/components/the_header.rs | 54 +++++-- client/src/main.rs | 1 + client/src/templates/index.rs | 139 +++--------------- client/src/templates/mod.rs | 1 + client/src/templates/transactions.rs | 136 +++++++++++++++++ client/static/tailwind.css | 90 ++++++------ client/tests/index.rs | 21 ++- makefile | 9 +- server/Cargo.toml | 3 +- server/migration/Cargo.toml | 2 +- server/migration/src/lib.rs | 3 +- server/src/db/paginate.rs | 6 +- server/src/lib.rs | 1 + server/src/route/transaction.rs | 80 +++++++++- 21 files changed, 544 insertions(+), 277 deletions(-) create mode 100644 client/src/components/main_content_container.rs create mode 100644 client/src/templates/transactions.rs diff --git a/Cargo.lock b/Cargo.lock index d0fbe3c..2d94eee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -392,6 +392,17 @@ version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf" +[[package]] +name = "bigdecimal" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aaf33151a6429fe9211d1b276eafdf70cdff28b071e76c0b0e1503221ea3744" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "binascii" version = "0.1.4" @@ -564,14 +575,11 @@ version = "3.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71655c45cb9845d3270c9d6df84ebe72b4dad3c2ba3f7023ad47c144e4e473a5" dependencies = [ - "atty", "bitflags", "clap_derive", "clap_lex", "indexmap", "once_cell", - "strsim", - "termcolor", "textwrap", ] @@ -713,21 +721,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86ec7a15cbe22e59248fc7eadb1907dab5ba09372595da4d73dd805ed4417dfe" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cace84e55f07e7301bae1c519df89cdad8cc3cd868413d3fdbdeca9ff3db484" - [[package]] name = "crc32fast" version = "1.3.2" @@ -3300,15 +3293,15 @@ dependencies = [ [[package]] name = "sea-orm" -version = "0.10.7" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88694d01b528a94f90ad87f8d2f546d060d070eee180315c67d158cb69476034" +checksum = "e7a0e3ec90718d849c73b167df7a476672b64c7ee5f3c582179069e63b2451e1" dependencies = [ "async-stream", "async-trait", + "bigdecimal", "chrono", "futures", - "futures-util", "log 0.4.17", "ouroboros", "rust_decimal", @@ -3328,9 +3321,9 @@ dependencies = [ [[package]] name = "sea-orm-cli" -version = "0.10.7" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ebe1f820fe8949cf6a57272ba9ebd0be766e47c9b85c04b3cabea40ab9459b3" +checksum = "992bc003ed84e736daa19d1b562bd80fa2de09d7bca70cb1745adec3f3b54064" dependencies = [ "chrono", "clap", @@ -3344,9 +3337,9 @@ dependencies = [ [[package]] name = "sea-orm-macros" -version = "0.10.7" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216195de9c6b2474fd0efab486173dccd0eff21f28cc54aa4c0205d52fb3af0" +checksum = "5d89f7d4d2533c178e08a9e1990619c391e9ca7b402851d02a605938b15e03d9" dependencies = [ "bae", "heck 0.3.3", @@ -3357,13 +3350,14 @@ dependencies = [ [[package]] name = "sea-orm-migration" -version = "0.10.7" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ed3cdfa669e4c385922f902b9a58e0c2128782a4d0fe79c6c34f3b927565e5b" +checksum = "355b1e2763e73d36de6f4539b04fc5d01b232e5c97785e0d08c4becbc2accad3" dependencies = [ "async-trait", "clap", "dotenvy", + "futures", "sea-orm", "sea-orm-cli", "sea-schema", @@ -3393,10 +3387,11 @@ dependencies = [ [[package]] name = "sea-query" -version = "0.27.2" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0fc4d8e44e1d51c739a68d336252a18bc59553778075d5e32649be6ec92ed" +checksum = "d2fbe015dbdaa7d8829d71c1e14fb6289e928ac256b93dfda543c85cd89d6f03" dependencies = [ + "bigdecimal", "chrono", "rust_decimal", "sea-query-derive", @@ -3407,10 +3402,11 @@ dependencies = [ [[package]] name = "sea-query-binder" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c2585b89c985cfacfe0ec9fc9e7bb055b776c1a2581c4e3c6185af2b8bf8865" +checksum = "03548c63aec07afd4fd190923e0160d2f2fc92def27470b54154cf232da6203b" dependencies = [ + "bigdecimal", "chrono", "rust_decimal", "sea-query", @@ -3422,11 +3418,11 @@ dependencies = [ [[package]] name = "sea-query-derive" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34cdc022b4f606353fe5dc85b09713a04e433323b70163e81513b141c6ae6eb5" +checksum = "63f62030c60f3a691f5fe251713b4e220b306e50a71e1d6f9cce1f24bb781978" dependencies = [ - "heck 0.3.3", + "heck 0.4.1", "proc-macro2 1.0.51", "quote 1.0.23", "syn 1.0.108", @@ -3435,9 +3431,9 @@ dependencies = [ [[package]] name = "sea-schema" -version = "0.10.3" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d5fda574d980e9352b6c7abd6fc75697436fe0078cac2b548559b52643ad3b" +checksum = "eeb2940bb5a10bc6cd05b450ce6cd3993e27fddd7eface2becb97fc5af3a040e" dependencies = [ "futures", "sea-query", @@ -3600,7 +3596,6 @@ dependencies = [ "rocket_contrib", "sea-orm", "sea-orm-rocket", - "sea-query", "serde", "serde_json", "slug", @@ -3757,11 +3752,11 @@ checksum = "dcbc16ddba161afc99e14d1713a453747a2b07fc097d2009f4c300ec99286105" dependencies = [ "ahash 0.7.6", "atoi", + "bigdecimal", "bitflags", "byteorder", "bytes", "chrono", - "crc", "crossbeam-queue", "digest", "dotenvy", @@ -3817,7 +3812,6 @@ dependencies = [ "proc-macro2 1.0.51", "quote 1.0.23", "serde_json", - "sha2", "sqlx-core", "sqlx-rt", "syn 1.0.108", @@ -4608,7 +4602,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79" dependencies = [ - "getrandom", "serde", ] diff --git a/client/src/api/routes/transaction.rs b/client/src/api/routes/transaction.rs index 60ec76e..da3c34c 100644 --- a/client/src/api/routes/transaction.rs +++ b/client/src/api/routes/transaction.rs @@ -1,4 +1,7 @@ -use crate::api::types::{paginated_response::PaginatedResponse, transaction::TransactionCompany}; +use crate::api::types::{ + paginated_response::PaginatedResponse, + transaction::{TransactionCompany, TransactionsAggregated}, +}; pub async fn get_transactions( company_slug: Option, @@ -7,6 +10,7 @@ pub async fn get_transactions( ) -> Result, ()> { use crate::env::Config; + // TODO: Remove build-time environment variable let api_url = Config::new().api_url; let route = &format!( "{}transaction?{}&page={}&size={}", @@ -16,7 +20,6 @@ pub async fn get_transactions( size, ); - // TODO: Remove build-time environment variable #[cfg(client)] let res = reqwasm::http::Request::get(route) .send() @@ -36,3 +39,40 @@ pub async fn get_transactions( return Ok(res); } + +pub async fn get_aggregated_transactions( + hours: Option, + page: i64, + size: i64, +) -> Result, ()> { + use crate::env::Config; + + // TODO: Remove build-time environment variable + let api_url = Config::new().api_url; + let route = &format!( + "{}transaction/aggregated?{}&page={}&size={}", + api_url, + hours.map_or("".to_string(), |c| format!("hours={}", c)), + page, + size, + ); + + #[cfg(client)] + let res = reqwasm::http::Request::get(route) + .send() + .await + .map_err(|_| ())? + .json::>() + .await + .map_err(|_| ())?; + + #[cfg(engine)] + let res = reqwest::get(route) + .await + .map_err(|_| ())? + .json::>() + .await + .map_err(|_| ())?; + + return Ok(res); +} diff --git a/client/src/api/types/paginated_response.rs b/client/src/api/types/paginated_response.rs index 4b843a2..c241934 100644 --- a/client/src/api/types/paginated_response.rs +++ b/client/src/api/types/paginated_response.rs @@ -3,7 +3,7 @@ use sycamore::prelude::*; use crate::components::base_table::TableContent; -use super::transaction::TransactionCompany; +use super::transaction::{TransactionCompany, TransactionsAggregated}; pub trait IntoTableData where @@ -43,32 +43,22 @@ where .into_iter() .map(|t| { let mut res = vec![]; - let t1 = t.clone(); res.push(view! {cx, - a (href=format!("{}", t1.company.slug), + a (href=format!("transactions/{}", t.company.slug), class="text-indigo-800 dark:text-indigo-300 hover:text-indigo-500 dark:hover:text-indigo-600 hover:underline", ) { - (t1.company.name.to_owned()) + (t.company.name.to_owned()) } }); - let t1 = t.clone(); - res.push(view! {cx, (t1.date_published.to_owned()) }); - let t1 = t.clone(); - res.push(view! {cx, (t1.date_executed.to_owned()) }); - let t1 = t.clone(); - res.push(view! {cx, (t1.person.to_owned()) }); - let t1 = t.clone(); - res.push(view! {cx, (t1.nature.to_owned()) }); - let t1 = t.clone(); - res.push(view! {cx, (t1.isin.to_owned().unwrap_or_else(|| "-".to_string())) }); - let t1 = t.clone(); - res.push(view! {cx, (t1.instrument.to_owned()) }); - let t1 = t.clone(); - res.push(view! {cx, (t1.exchange.to_owned()) }); - let t1 = t.clone(); - res.push(view! {cx, (t1.volume.to_owned()) }); - let t1 = t.clone(); - res.push(view! {cx, (t1.unit_price.to_owned()) }); + res.push(view! {cx, (t.date_published.to_owned()) }); + res.push(view! {cx, (t.date_executed.to_owned()) }); + res.push(view! {cx, (t.person.to_owned()) }); + res.push(view! {cx, (t.nature.to_owned()) }); + res.push(view! {cx, (t.isin.to_owned().unwrap_or_else(|| "-".to_string())) }); + res.push(view! {cx, (t.instrument.to_owned()) }); + res.push(view! {cx, (t.exchange.to_owned()) }); + res.push(view! {cx, (t.volume.to_owned()) }); + res.push(view! {cx, (t.unit_price.to_owned()) }); res.push(view! {cx, ((t.volume as f32 * t.unit_price).to_string()) }); res @@ -81,3 +71,38 @@ where } } } + +impl IntoTableData for PaginatedResponse +where + G: GenericNode, +{ + fn into_table_data<'a>(self, cx: Scope<'a>) -> TableContent { + let headers_view = vec![ + view! {cx, "Company" }, + view! {cx, "Transactions" }, + ]; + + let data_view: Vec>> = self + .list + .into_iter() + .map(|t| { + let mut res = vec![]; + res.push(view! {cx, + a (href=format!("transactions/{}", t.slug), + class="text-indigo-800 dark:text-indigo-300 hover:text-indigo-500 dark:hover:text-indigo-600 hover:underline", + ) { + (t.name.to_owned()) + } + }); + res.push(view! {cx, (t.transaction_count) }); + + res + }) + .collect(); + + TableContent { + headers_view, + data_view, + } + } +} diff --git a/client/src/api/types/transaction.rs b/client/src/api/types/transaction.rs index 266f46f..ccb1124 100644 --- a/client/src/api/types/transaction.rs +++ b/client/src/api/types/transaction.rs @@ -36,3 +36,11 @@ pub struct TransactionCompany { pub created_at_utc: NaiveDateTime, pub company: Company, } + +#[derive(Deserialize, Clone)] +pub struct TransactionsAggregated { + pub id: i32, + pub name: String, + pub slug: String, + pub transaction_count: i32, +} diff --git a/client/src/components/main_content_container.rs b/client/src/components/main_content_container.rs new file mode 100644 index 0000000..abc529e --- /dev/null +++ b/client/src/components/main_content_container.rs @@ -0,0 +1,21 @@ +use sycamore::prelude::*; + +#[derive(Prop)] +pub struct MainProps<'a, G: Html> { + children: Children<'a, G>, + useless_prop: u8, // Without another prop, the view doesn't render with children +} + +/// This component is used to contain the main contents of pages +#[component] +pub fn MainContentContainer<'a, G: Html>(cx: Scope<'a>, props: MainProps<'a, G>) -> View { + let children = props.children.call(cx); + + view! {cx, + div (id="main", class="flex flex-col items-center justify-center ") { + div (class="w-4/5 m-10 p-3 bg-slate-100 dark:bg-slate-600 rounded-lg items-center justify-center") { + (children) + } + } + } +} diff --git a/client/src/components/mod.rs b/client/src/components/mod.rs index 9905cc0..9b358c1 100644 --- a/client/src/components/mod.rs +++ b/client/src/components/mod.rs @@ -2,5 +2,6 @@ pub mod base_async_select; pub mod base_button; pub mod base_table; pub mod loading; +pub mod main_content_container; pub mod paginated_data_table; pub mod the_header; diff --git a/client/src/components/paginated_data_table.rs b/client/src/components/paginated_data_table.rs index f900175..7a39c51 100644 --- a/client/src/components/paginated_data_table.rs +++ b/client/src/components/paginated_data_table.rs @@ -19,6 +19,7 @@ where C: Fn(Option, i64, i64) -> F, F: std::future::Future, ()>>, { + pub record_label: String, pub route: C, pub filter: Option, } @@ -34,6 +35,7 @@ where } } +/// This is a generic component that will display a paginated table given a function of the generic signature C and a filter represented by an Option #[component] pub fn PaginatedTable<'a, G, M, F, C>( cx: Scope<'a>, @@ -74,6 +76,10 @@ where let page_size_string = create_signal(cx, "20".to_string()); + create_effect(cx, move || { + page_size_string.track(); + page.set(0); + }); let props_sig = create_signal(cx, props); create_effect(cx, move || { let page = *page.get(); @@ -93,32 +99,41 @@ where view! { cx, (if paginated_data.get().is_some() { - view! {cx, - p (class="text-right") { (format!("{} transactions", n_rows.get())) } - div (class="flex flex-row justify-between") { - select (bind:value=page_size_string, - class="p-2 justify-end text-slate-700 dark:text-slate-100 bg-slate-200 dark:bg-slate-800 rounded-md", - id="size-select", - ) { - option (value="10", selected=(*page_size_string.get()).eq("10")) { "10" } - option (value="20", selected=(*page_size_string.get()).eq("20")) { "20" } - option (value="30", selected=(*page_size_string.get()).eq("30")) { "30" } - option (value="40", selected=(*page_size_string.get()).eq("40")) { "40" } - option (value="50", selected=(*page_size_string.get()).eq("50")) { "50" } - } - div (id="page_buttons", class="flex flex-row p-2 bg-slate-200 dark:bg-slate-800 rounded-md") { - button (on:click=page_down,class="m-1 hover:font-bold") { - "<<" + if *n_rows.get() == 0 { + view!{cx, + div (class="bg-slate-200 dark:bg-slate-800 text-center rounded-md") { + (format!("No {}", props_sig.get().record_label)) } - div (class="m-1 align-middle text-center") { - (format!("{}/{}",*page.get() + 1, *n_page.get()) ) + } + } + else { + view! {cx, + p (class="text-right") { (format!("{} {}", n_rows.get(), props_sig.get().record_label)) } + div (class="flex flex-row justify-between") { + select (bind:value=page_size_string, + class="p-2 justify-end text-slate-700 dark:text-slate-100 bg-slate-200 dark:bg-slate-800 rounded-md", + id="size-select", + ) { + option (value="10", selected=(*page_size_string.get()).eq("10")) { "10" } + option (value="20", selected=(*page_size_string.get()).eq("20")) { "20" } + option (value="30", selected=(*page_size_string.get()).eq("30")) { "30" } + option (value="40", selected=(*page_size_string.get()).eq("40")) { "40" } + option (value="50", selected=(*page_size_string.get()).eq("50")) { "50" } } - button (on:click=page_up, class="m-1 hover:font-bold") { - ">>" + div (id="page_buttons", class="flex flex-row p-2 bg-slate-200 dark:bg-slate-800 rounded-md") { + button (on:click=page_down,class="m-1 hover:font-bold") { + "<<" + } + div (class="m-1 align-middle text-center") { + (format!("{}/{}",*page.get() + 1, *n_page.get()) ) + } + button (on:click=page_up, class="m-1 hover:font-bold") { + ">>" + } } } - } - BaseTable(headers_view=table_prop.headers_view, data_view=table_prop.data_view) + BaseTable(headers_view=table_prop.headers_view, data_view=table_prop.data_view) + } } } else { view! {cx, diff --git a/client/src/components/the_header.rs b/client/src/components/the_header.rs index d7738f7..a3ec62a 100644 --- a/client/src/components/the_header.rs +++ b/client/src/components/the_header.rs @@ -1,18 +1,48 @@ +use perseus::prelude::*; use sycamore::prelude::*; +use crate::global_state::AppStateRx; + #[component] -pub fn the_header(cx: Scope) -> View { +pub fn TheHeader<'a, G: Html>(cx: Scope<'a>) -> View { + // This is ugly and is only caused by the get_global_state function panicking when running on the server at build time + let global_state_sig: &Signal> = create_signal(cx, None); + + #[cfg(client)] + global_state_sig.set(Some( + Reactor::::from_cx(cx).get_global_state::(cx), + )); + + let dark_mode = create_signal(cx, true); + create_effect(cx, move || { + if let Some(gstate) = (*global_state_sig.get()).clone() { + dark_mode.set(*gstate.dark_mode.get()); + } + }); + let toggle_dark_mode = move |_| { + if let Some(gstate) = *global_state_sig.get() { + gstate.dark_mode.set(!*dark_mode.get()); + } + }; + view! { cx, - "Don't use until global state is recheable from a component" - // header (class="shadow-md h-10 sm:p-2 w-full mb-20 bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") { - // nav () { - // div (class="fixed flex justify-between") { - // div (class="font-mono mr-10") { "Fast Insiders" } - // div (class="flex justify-end") { - // button (on:click=toggle_dark_mode, class="mx-1 p-1 bg-pink-200 dark:bg-pink-600 rounded-full") { "toggle dark mode" } - // } - // } - // } - // } + header (class="shadow-md h-11 p-2 align-middle w-full bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") { + div (class="flex") { + div (class="flex-none mr-12") { + a (href="/", class="hover:underline") { + "Fast Insiders" + } + } + div (class="grow text-left") { + a (id="header-all-transactions", href="/transactions", class="hover:underline") { + "All transactions" + } + } + div (class="flex-none") { + button (on:click=toggle_dark_mode, class="mx-1 py-1 px-2 bg-slate-200 dark:bg-slate-800 rounded-full") + { "Toggle dark mode" } + } + } + } } } diff --git a/client/src/main.rs b/client/src/main.rs index 9c5b1e4..9424ffd 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -12,6 +12,7 @@ pub mod templates; pub fn main() -> PerseusApp { PerseusApp::new() .template(crate::templates::index::get_template()) + .template(crate::templates::transactions::get_template()) .global_state_creator(crate::global_state::get_global_state_creator()) .error_views(crate::error_pages::get_error_views()) .index_view(|cx| { diff --git a/client/src/templates/index.rs b/client/src/templates/index.rs index 5568c58..e240114 100644 --- a/client/src/templates/index.rs +++ b/client/src/templates/index.rs @@ -3,128 +3,51 @@ use serde::{Deserialize, Serialize}; use sycamore::prelude::*; use crate::{ - api::routes::transaction::get_transactions, - api::types::{company::Company, transaction::TransactionCompany}, + api::{ + routes::transaction::get_aggregated_transactions, + types::transaction::TransactionsAggregated, + }, components::{ - base_async_select::{AsyncSelectRx, BaseAsyncSelect}, - base_button::{BaseButton, BaseButtonStateRx}, + main_content_container::MainContentContainer, paginated_data_table::{PaginatedTable, PaginatedTableStateRx}, + the_header::TheHeader, }, global_state::AppStateRx, }; -#[derive(Serialize, Deserialize, Clone, ReactiveState)] -#[rx(alias = "IndexPageStateRx")] -pub struct IndexPageState { - pub company_slug: Option, -} - -#[auto_scope] -fn index_page(cx: Scope, state: &IndexPageStateRx) -> View { +fn index_page(cx: Scope) -> View { let global_state = Reactor::::from_cx(cx).get_global_state::(cx); - let dark_mode = &global_state.dark_mode; - let dark_mode_2 = dark_mode.clone(); - let dark_mode_3 = dark_mode.clone(); - - let expand = create_signal(cx, false); - let filter_expand = BaseButtonStateRx { - label: create_signal(cx, "Filters".to_string()), - disabled: create_signal(cx, false), - clicked: create_signal(cx, false), - }; - - create_effect(cx, move || { - if *filter_expand.clicked.get() { - filter_expand.clicked.set(false); - expand.set(!*expand.get()); - } - }); - let toggle_dark_mode = move |_| dark_mode_2.set(!*dark_mode.get()); - - let paginated_table_state: PaginatedTableStateRx = + let table_transactions_24hours: PaginatedTableStateRx = PaginatedTableStateRx { - route: get_transactions, - filter: (*state.company_slug.get()).clone(), + record_label: "companies".to_owned(), + route: get_aggregated_transactions, + filter: Some("24".to_owned()), }; - let async_select_prop: AsyncSelectRx = AsyncSelectRx { - remote_list: create_signal(cx, format!("{}company/", "http://localhost:8000/v1/")), - selected_item: create_signal(cx, None), - }; - - let search_button = BaseButtonStateRx { - label: create_signal(cx, "Search".to_string()), - disabled: create_memo(cx, move || async_select_prop.selected_item.get().is_none()), - clicked: create_signal(cx, false), - }; - - create_effect(cx, || { - if *search_button.clicked.get() { - search_button.clicked.set(false); - navigate(&format!( - "/{}", - (*async_select_prop.selected_item.get()) - .clone() - .map_or("".to_string(), |c| c.slug) - )); - } - }); - view! {cx, - main (class=if *dark_mode_3.get() { "dark" } else { "" }) { + main (class=if *global_state.dark_mode.get() { "dark" } else { "" }) { div (class="bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-100 font-sans") { - header (class="shadow-md h-12 p-2 align-middle w-full bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") { - div (class="flex flex-row justify-between") { - div (class="mr-10 align-middle") { - a (href="/", class="hover:underline") { - "Fast Insiders" - } - } - div (class="align-middle") { - button (on:click=toggle_dark_mode, class="mx-1 py-1 px-2 bg-slate-200 dark:bg-slate-800 rounded-full") - { "Toggle dark mode" } - } - } - } - div (id="main", class="flex flex-col items-center justify-center ") { - div (class="w-4/5 m-10 p-3 bg-slate-100 dark:bg-slate-600 rounded-lg items-center justify-center") { - a (class="hover:underline", href="/") { - h1 ( - class="text-center text-lg" - ) { - "Insider Transactions published by the AMF" + TheHeader() + MainContentContainer(useless_prop=1) { + div(class="flex flex-wrap gap-4 justify-around") { + div () { + h1 (class="mb-1 text-center") { + "Companies with transactions published" + br () {} + "in the last 24 hours" + } + PaginatedTable(table_transactions_24hours) } } - BaseButton(filter_expand) - div {} // Without this useless div, the code doesn't run in the browser - div (id="filters", class=format!("p-2 border rounded-lg border-slate-200 dark:border-slate-800 bg-slate-200 dark:bg-slate-700 transition-all ease-in {}", - if *expand.get() { "h-40 visible" } else { "h-0 collapse" }, - ) - ) - { - div (class="w-80") { - p () {"Search for a company:"} - BaseAsyncSelect(async_select_prop) - BaseButton(search_button) - } - } - PaginatedTable(paginated_table_state) } - } } } } } pub fn get_template() -> Template { - Template::build("index") - .head(head) - .build_state_fn(get_build_state) - .build_paths_fn(get_build_paths) - .incremental_generation() - .view_with_state(index_page) - .build() + Template::build("index").head(head).view(index_page).build() } #[engine_only_fn] @@ -133,19 +56,3 @@ fn head(cx: Scope) -> View { title { "Fast Insiders" } } } - -#[engine_only_fn] -async fn get_build_state( - StateGeneratorInfo { path, .. }: StateGeneratorInfo<()>, -) -> Result> { - let company_slug = if path.is_empty() { None } else { Some(path) }; - Ok(IndexPageState { company_slug }) -} - -#[engine_only_fn] -async fn get_build_paths() -> BuildPaths { - BuildPaths { - paths: vec!["".to_string()], - extra: ().into(), - } -} diff --git a/client/src/templates/mod.rs b/client/src/templates/mod.rs index 33edc95..69bb5f6 100644 --- a/client/src/templates/mod.rs +++ b/client/src/templates/mod.rs @@ -1 +1,2 @@ pub mod index; +pub mod transactions; diff --git a/client/src/templates/transactions.rs b/client/src/templates/transactions.rs new file mode 100644 index 0000000..ad92690 --- /dev/null +++ b/client/src/templates/transactions.rs @@ -0,0 +1,136 @@ +use perseus::prelude::*; +use serde::{Deserialize, Serialize}; +use sycamore::prelude::*; + +use crate::{ + api::routes::transaction::get_transactions, + api::types::{company::Company, transaction::TransactionCompany}, + components::{ + base_async_select::{AsyncSelectRx, BaseAsyncSelect}, + base_button::{BaseButton, BaseButtonStateRx}, + main_content_container::MainContentContainer, + paginated_data_table::{PaginatedTable, PaginatedTableStateRx}, + the_header::TheHeader, + }, + global_state::AppStateRx, +}; + +#[derive(Serialize, Deserialize, Clone, ReactiveState)] +#[rx(alias = "TransactionsPageStateRx")] +pub struct TransactionsPageState { + pub company_slug: Option, +} + +#[auto_scope] +fn transactions_page(cx: Scope, state: &TransactionsPageStateRx) -> View { + let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + let dark_mode = &global_state.dark_mode; + + let expand = create_signal(cx, false); + let filter_expand = BaseButtonStateRx { + label: create_signal(cx, "Filters".to_string()), + disabled: create_signal(cx, false), + clicked: create_signal(cx, false), + }; + + create_effect(cx, move || { + if *filter_expand.clicked.get() { + filter_expand.clicked.set(false); + expand.set(!*expand.get()); + } + }); + + let paginated_table_state: PaginatedTableStateRx = + PaginatedTableStateRx { + record_label: "transactions".to_owned(), + route: get_transactions, + filter: (*state.company_slug.get()).clone(), + }; + + let async_select_prop: AsyncSelectRx = AsyncSelectRx { + remote_list: create_signal(cx, format!("{}company/", "http://localhost:8000/v1/")), + selected_item: create_signal(cx, None), + }; + + let search_button = BaseButtonStateRx { + label: create_signal(cx, "Search".to_string()), + disabled: create_memo(cx, move || async_select_prop.selected_item.get().is_none()), + clicked: create_signal(cx, false), + }; + + create_effect(cx, || { + if *search_button.clicked.get() { + search_button.clicked.set(false); + navigate(&format!( + "/transactions/{}", + (*async_select_prop.selected_item.get()) + .clone() + .map_or("".to_string(), |c| c.slug) + )); + } + }); + + view! {cx, + main (class=if *dark_mode.get() { "dark" } else { "" }) { + div (class="bg-slate-200 dark:bg-slate-700 text-slate-700 dark:text-slate-100 font-sans") { + TheHeader() + MainContentContainer(useless_prop=1) { + a (class="hover:underline", href="/transactions") { + h1 ( + class="text-center text-lg" + ) { + "Insider Transactions published by the AMF" + } + } + BaseButton(filter_expand) + div (id="filters", + class=format!("p-2 border rounded-lg border-slate-200 dark:border-slate-800 bg-slate-200 dark:bg-slate-700 transition-all ease-in {}", + if *expand.get() { "h-40 visible" } else { "h-0 collapse" }, + ) + ) + { + div (class="w-80") { + p () {"Search for a company:"} + BaseAsyncSelect(async_select_prop) + BaseButton(search_button) + } + } + PaginatedTable(paginated_table_state) + } + } + } + } +} + +pub fn get_template() -> Template { + Template::build("transactions") + .head(head) + .build_state_fn(get_build_state) + .build_paths_fn(get_build_paths) + .incremental_generation() + .view_with_state(transactions_page) + .build() +} + +#[engine_only_fn] +fn head(cx: Scope) -> View { + view! {cx, + title { "Fast Insiders" } + } +} + +#[engine_only_fn] +async fn get_build_state( + StateGeneratorInfo { path, .. }: StateGeneratorInfo<()>, +) -> Result> { + let company_slug = if path.is_empty() { None } else { Some(path) }; + Ok(TransactionsPageState { company_slug }) +} + +#[engine_only_fn] +async fn get_build_paths() -> BuildPaths { + BuildPaths { + paths: vec!["".to_string()], + extra: ().into(), + } +} diff --git a/client/static/tailwind.css b/client/static/tailwind.css index c1ee8d9..12fc681 100644 --- a/client/static/tailwind.css +++ b/client/static/tailwind.css @@ -525,10 +525,6 @@ video { position: static; } -.fixed { - position: fixed; -} - .absolute { position: absolute; } @@ -549,14 +545,14 @@ video { margin: 0.5rem; } -.m-1 { - margin: 0.25rem; -} - .m-10 { margin: 2.5rem; } +.m-1 { + margin: 0.25rem; +} + .my-2 { margin-top: 0.5rem; margin-bottom: 0.5rem; @@ -572,12 +568,12 @@ video { margin-right: 0.25rem; } -.mb-20 { - margin-bottom: 5rem; +.mr-12 { + margin-right: 3rem; } -.mr-10 { - margin-right: 2.5rem; +.mb-1 { + margin-bottom: 0.25rem; } .flex { @@ -588,12 +584,12 @@ video { display: table; } -.h-10 { - height: 2.5rem; +.contents { + display: contents; } -.h-12 { - height: 3rem; +.h-11 { + height: 2.75rem; } .h-40 { @@ -616,6 +612,14 @@ video { width: 80%; } +.flex-none { + flex: none; +} + +.grow { + flex-grow: 1; +} + .table-auto { table-layout: auto; } @@ -636,6 +640,10 @@ video { flex-direction: column; } +.flex-wrap { + flex-wrap: wrap; +} + .items-center { align-items: center; } @@ -652,6 +660,14 @@ video { justify-content: space-between; } +.justify-around { + justify-content: space-around; +} + +.gap-4 { + gap: 1rem; +} + .rounded-md { border-radius: 0.375rem; } @@ -706,29 +722,20 @@ video { background-color: rgb(226 232 240 / var(--tw-bg-opacity)); } -.bg-gray-100 { - --tw-bg-opacity: 1; - background-color: rgb(243 244 246 / var(--tw-bg-opacity)); -} - -.bg-pink-200 { +.bg-slate-100 { --tw-bg-opacity: 1; - background-color: rgb(251 207 232 / var(--tw-bg-opacity)); + background-color: rgb(241 245 249 / var(--tw-bg-opacity)); } -.bg-slate-100 { +.bg-gray-100 { --tw-bg-opacity: 1; - background-color: rgb(241 245 249 / var(--tw-bg-opacity)); + background-color: rgb(243 244 246 / var(--tw-bg-opacity)); } .p-2 { padding: 0.5rem; } -.p-1 { - padding: 0.25rem; -} - .p-3 { padding: 0.75rem; } @@ -759,10 +766,6 @@ video { vertical-align: middle; } -.font-mono { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; -} - .font-sans { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; } @@ -844,13 +847,13 @@ video { background-color: rgb(30 41 59 / var(--tw-bg-opacity)); } -.dark .dark\:bg-slate-500\/30 { - background-color: rgb(100 116 139 / 0.3); +.dark .dark\:bg-slate-600 { + --tw-bg-opacity: 1; + background-color: rgb(71 85 105 / var(--tw-bg-opacity)); } -.dark .dark\:bg-pink-600 { - --tw-bg-opacity: 1; - background-color: rgb(219 39 119 / var(--tw-bg-opacity)); +.dark .dark\:bg-slate-500\/30 { + background-color: rgb(100 116 139 / 0.3); } .dark .dark\:bg-slate-700 { @@ -858,11 +861,6 @@ video { background-color: rgb(51 65 85 / var(--tw-bg-opacity)); } -.dark .dark\:bg-slate-600 { - --tw-bg-opacity: 1; - background-color: rgb(71 85 105 / var(--tw-bg-opacity)); -} - .dark .dark\:text-slate-100 { --tw-text-opacity: 1; color: rgb(241 245 249 / var(--tw-text-opacity)); @@ -882,9 +880,3 @@ video { --tw-text-opacity: 1; color: rgb(79 70 229 / var(--tw-text-opacity)); } - -@media (min-width: 640px) { - .sm\:p-2 { - padding: 0.5rem; - } -} diff --git a/client/tests/index.rs b/client/tests/index.rs index 9cc54dc..a722b4f 100644 --- a/client/tests/index.rs +++ b/client/tests/index.rs @@ -3,6 +3,7 @@ use perseus::wait_for_checkpoint; // This is is an E2E test with the following steps: // - Go to the index page +// - Navigate to all transactions // - Try all page sizes and verify that they are respected // - Try to go to page 2 // - Use the async select component to find any company @@ -27,6 +28,14 @@ async fn index(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { let title = c.find(Locator::Css("title")).await?.html(false).await?; assert!(title.contains("Fast Insiders")); + let all_transactions_link = c.find(Locator::Css("#header-all-transactions")).await?; + all_transactions_link.click().await?; + let url = c.current_url().await?; + assert!(url + .as_ref() + .starts_with("http://localhost:8080/transactions")); + wait_for_checkpoint!("page_interactive", 1, c); + // Verify that the default table size is 20 let page_size_select = c.find(Locator::Css("#size-select")).await?; let default_page_size = page_size_select @@ -94,18 +103,19 @@ async fn index(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { search_button.click().await?; let page = c.current_url().await?; assert!( - page.as_ref().starts_with("http://localhost:8080/"), + page.as_ref() + .starts_with("http://localhost:8080/transactions"), "Unexpected target url reached: {}", page ); - wait_for_checkpoint!("page_interactive", 1, c); + wait_for_checkpoint!("page_interactive", 2, c); for a in c.find_all(Locator::Css("table a")).await? { let a_text = a.text().await?; assert_eq!(clicked_company, a_text, "The clicked company is different from the target page list of companies, respectively {} and {}", clicked_company, a_text); } - let root_link = c.find(Locator::Css("a")).await?; - root_link.click().await?; + let transactions_link = c.find(Locator::Css("a h1")).await?; + transactions_link.click().await?; let first_company_in_table = c.find(Locator::Css("table a")).await?; let company_name = first_company_in_table.text().await?; @@ -115,7 +125,8 @@ async fn index(c: &mut Client) -> Result<(), fantoccini::error::CmdError> { assert_eq!(company_name, a_text, "The clicked company is different from the target page list of companies, respectively {} and {}", company_name, a_text); } assert!( - page.as_ref().starts_with("http://localhost:8080/"), + page.as_ref() + .starts_with("http://localhost:8080/transactions"), "Unexpected target url reached: {}", page ); diff --git a/makefile b/makefile index b60ea05..4dcf42d 100644 --- a/makefile +++ b/makefile @@ -18,7 +18,14 @@ serve: cd client && \ perseus serve -w -client-deploy: +check-client: + cd client && \ + perseus check -w + +check-server: + cargo check + +deploy-client: cd client && \ perseus deploy diff --git a/server/Cargo.toml b/server/Cargo.toml index c4c9d88..561ad87 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -15,7 +15,7 @@ envy = { workspace = true } tokio = { version = "^1.20.1", features = ["full"] } reqwest = { version = "0.11", features = ["json", "rustls-tls"] } rocket = { version = "0.5.0-rc.2", features = ["json"] } -sea-orm = { version = "0.10.7", features = [ +sea-orm = { version = "0.11.0", features = [ "runtime-tokio-rustls", "macros", "sqlx-mysql", @@ -30,5 +30,4 @@ rocket_contrib = "0.4.11" async-trait = "0.1.61" sea-orm-rocket = "0.5.2" thiserror = "1.0.38" -sea-query = "^0.27.1" slug = "0.1.4" diff --git a/server/migration/Cargo.toml b/server/migration/Cargo.toml index d3387e8..e766bf2 100644 --- a/server/migration/Cargo.toml +++ b/server/migration/Cargo.toml @@ -13,5 +13,5 @@ async-std = { version = "^1", features = ["attributes", "tokio1"] } chrono.workspace = true [dependencies.sea-orm-migration] -version = "^0.10.0" +version = "0.11.0" features = ["sqlx-mysql", "runtime-tokio-rustls"] diff --git a/server/migration/src/lib.rs b/server/migration/src/lib.rs index a98dfce..053257a 100644 --- a/server/migration/src/lib.rs +++ b/server/migration/src/lib.rs @@ -1,4 +1,5 @@ -pub use sea_orm_migration::prelude::*; +use sea_orm_migration::prelude::*; +pub use sea_orm_migration::MigratorTrait; mod m20230112_115856_create_company_table; mod m20230112_160440_create_transaction_table; diff --git a/server/src/db/paginate.rs b/server/src/db/paginate.rs index 3943347..dcd03e5 100644 --- a/server/src/db/paginate.rs +++ b/server/src/db/paginate.rs @@ -1,7 +1,7 @@ -use sea_orm::{error::DbErr, FromQueryResult}; -use sea_orm::{prelude::*, Order, QueryOrder}; +use sea_orm::{ + error::DbErr, prelude::*, sea_query::SimpleExpr, FromQueryResult, Order, QueryOrder, +}; use sea_orm_rocket::Connection; -use sea_query::expr::SimpleExpr; use serde::{Deserialize, Serialize}; use super::Db; diff --git a/server/src/lib.rs b/server/src/lib.rs index 72f6b52..efba629 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -54,6 +54,7 @@ async fn start_rocket() -> Result<(), sea_orm_rocket::rocket::Error> { route::company::get_all, route::company::get_by_isin, route::transaction::get_transactions, + route::transaction::get_aggregated_transactions, route::in_process_transaction::get_all, route::in_process_transaction::retry_failed_transaction, route::in_process_transaction::retry_all diff --git a/server/src/route/transaction.rs b/server/src/route/transaction.rs index 8bb86a9..9f5dbb8 100644 --- a/server/src/route/transaction.rs +++ b/server/src/route/transaction.rs @@ -1,7 +1,13 @@ use chrono::{Duration, NaiveDate, NaiveDateTime, Utc}; use rocket::http::Status; use rocket::response::status::Custom; -use sea_orm::{ColumnTrait, Order}; +use sea_orm::prelude::*; +use sea_orm::FromQueryResult; +use sea_orm::ItemsAndPagesNumber; +use sea_orm::JoinType; +use sea_orm::Order; +use sea_orm::QueryOrder; +use sea_orm::QuerySelect; use sea_orm_rocket::rocket::serde::json::Json; use sea_orm_rocket::Connection; use serde::{Deserialize, Serialize}; @@ -98,3 +104,75 @@ pub async fn get_transactions( Ok(Json(res)) } + +#[get("/transaction/aggregated?&&")] +pub async fn get_aggregated_transactions( + conn: Connection<'_, Db>, + hours: Option, + page: Option, + size: Option, +) -> Result>, Custom> { + let db = conn.into_inner(); + let s = size.unwrap_or(20).min(50); + let mut query = model::company::Entity::find() + .select_only() + .join( + JoinType::InnerJoin, + model::company::Relation::Transaction.def(), + ) + .column(model::company::Column::Id) + .column(model::company::Column::Name) + .column(model::company::Column::Slug) + .column_as(model::transaction::Column::Id.count(), "transaction_count"); + + if let Some(h) = hours { + query = query.filter( + model::transaction::Column::CreatedAtUtc.gte( + Utc::now() + .naive_utc() + .checked_sub_signed(Duration::hours(h)), + ), + ) + } + + let pages = query + .group_by(model::company::Column::Name) + .order_by(model::transaction::Column::Id.count(), Order::Desc) + .into_model::() + .paginate(db, s); + + let ItemsAndPagesNumber { + number_of_items: count, + number_of_pages: num_pages, + } = pages.num_items_and_pages().await.map_err(|e| { + Custom( + Status::InternalServerError, + format!("Database error: {}", e), + ) + })?; + + let p = page.unwrap_or(0).min(num_pages); + + let list = pages.fetch_page(p).await.map_err(|e| { + Custom( + Status::InternalServerError, + format!("Database error: {}", e), + ) + })?; + + let res = PaginatedResponse { + count, + num_pages, + list, + }; + + Ok(Json(res)) +} + +#[derive(Serialize, FromQueryResult, Debug)] +pub struct TransactionsAggregated { + id: i32, + name: String, + slug: String, + transaction_count: i32, +} -- 2.36.3 From 84822cbac1e51a49c2717ec2784220c79b0d7a9f Mon Sep 17 00:00:00 2001 From: Miroito Date: Sun, 5 Mar 2023 14:33:42 +0100 Subject: [PATCH 4/5] Better page flow --- client/src/components/base_table.rs | 3 +- client/src/components/paginated_data_table.rs | 10 ++-- client/src/templates/index.rs | 26 ++++++++--- client/src/templates/transactions.rs | 1 + client/static/tailwind.css | 46 +++++++++++++++++++ 5 files changed, 75 insertions(+), 11 deletions(-) diff --git a/client/src/components/base_table.rs b/client/src/components/base_table.rs index fd16834..0eba683 100644 --- a/client/src/components/base_table.rs +++ b/client/src/components/base_table.rs @@ -16,6 +16,7 @@ where { pub headers_view: &'a Signal>>, pub data_view: &'a Signal>>>, + pub table_class: &'a String, } #[derive(Debug, Clone)] @@ -61,7 +62,7 @@ where }); view! { cx, - table (class="table-auto bg-slate-200 text-left dark:bg-slate-800 rounded-lg mx-auto my-2") { + table (class=format!("{} table-auto bg-slate-200 text-left dark:bg-slate-800 rounded-lg mx-auto my-2", props.table_class)) { thead { tr (class="border-b-2 border-slate-500 text-center") { Keyed( diff --git a/client/src/components/paginated_data_table.rs b/client/src/components/paginated_data_table.rs index 7a39c51..21252a1 100644 --- a/client/src/components/paginated_data_table.rs +++ b/client/src/components/paginated_data_table.rs @@ -13,7 +13,7 @@ use crate::{ }; #[derive(Prop)] -pub struct PaginatedTableStateRx +pub struct PaginatedTableStateRx<'a, M, F, C> where M: 'static, C: Fn(Option, i64, i64) -> F, @@ -22,9 +22,10 @@ where pub record_label: String, pub route: C, pub filter: Option, + pub table_class: &'a String, } -impl PaginatedTableStateRx +impl<'a, M, F, C> PaginatedTableStateRx<'a, M, F, C> where M: 'static, C: Fn(Option, i64, i64) -> F, @@ -39,7 +40,7 @@ where #[component] pub fn PaginatedTable<'a, G, M, F, C>( cx: Scope<'a>, - props: PaginatedTableStateRx, + props: PaginatedTableStateRx<'a, M, F, C>, ) -> View where G: Html, @@ -53,6 +54,7 @@ where let table_prop: TableContentRx = TableContentRx { headers_view: create_signal(cx, vec![]), data_view: create_signal(cx, vec![vec![]]), + table_class: props.table_class, }; let page = create_signal(cx, 0); let n_page = create_signal(cx, 1); @@ -132,7 +134,7 @@ where } } } - BaseTable(headers_view=table_prop.headers_view, data_view=table_prop.data_view) + BaseTable(headers_view=table_prop.headers_view, data_view=table_prop.data_view, table_class=table_prop.table_class) } } } else { diff --git a/client/src/templates/index.rs b/client/src/templates/index.rs index e240114..9655b54 100644 --- a/client/src/templates/index.rs +++ b/client/src/templates/index.rs @@ -1,5 +1,4 @@ use perseus::prelude::*; -use serde::{Deserialize, Serialize}; use sycamore::prelude::*; use crate::{ @@ -18,11 +17,22 @@ use crate::{ fn index_page(cx: Scope) -> View { let global_state = Reactor::::from_cx(cx).get_global_state::(cx); + let table_classes = create_ref(cx, "w-full".to_string()); + let table_transactions_24hours: PaginatedTableStateRx = PaginatedTableStateRx { record_label: "companies".to_owned(), route: get_aggregated_transactions, - filter: Some("24".to_owned()), + filter: Some("72".to_string()), + table_class: table_classes, + }; + + let table_transactions_month: PaginatedTableStateRx = + PaginatedTableStateRx { + record_label: "companies".to_owned(), + route: get_aggregated_transactions, + filter: Some((24 * 30).to_string()), + table_class: table_classes, }; view! {cx, @@ -31,14 +41,18 @@ fn index_page(cx: Scope) -> View { TheHeader() MainContentContainer(useless_prop=1) { div(class="flex flex-wrap gap-4 justify-around") { - div () { + div (class="flex-grow") { h1 (class="mb-1 text-center") { - "Companies with transactions published" - br () {} - "in the last 24 hours" + "Latest insider activity (72h)" } PaginatedTable(table_transactions_24hours) } + div (class="flex-grow") { + h1 (class="mb-1 text-center") { + "Most activity in the past month" + } + PaginatedTable(table_transactions_month) + } } } } diff --git a/client/src/templates/transactions.rs b/client/src/templates/transactions.rs index ad92690..10445fd 100644 --- a/client/src/templates/transactions.rs +++ b/client/src/templates/transactions.rs @@ -45,6 +45,7 @@ fn transactions_page(cx: Scope, state: &TransactionsPageStateRx) -> Vie record_label: "transactions".to_owned(), route: get_transactions, filter: (*state.company_slug.get()).clone(), + table_class: create_ref(cx, "".to_string()), }; let async_select_prop: AsyncSelectRx = AsyncSelectRx { diff --git a/client/static/tailwind.css b/client/static/tailwind.css index 12fc681..db599bf 100644 --- a/client/static/tailwind.css +++ b/client/static/tailwind.css @@ -584,6 +584,10 @@ video { display: table; } +.grid { + display: grid; +} + .contents { display: contents; } @@ -612,10 +616,36 @@ video { width: 80%; } +.w-32 { + width: 8rem; +} + +.w-60 { + width: 15rem; +} + +.w-max { + width: -moz-max-content; + width: max-content; +} + +.w-fit { + width: -moz-fit-content; + width: fit-content; +} + +.w-5\/12 { + width: 41.666667%; +} + .flex-none { flex: none; } +.flex-grow { + flex-grow: 1; +} + .grow { flex-grow: 1; } @@ -632,6 +662,22 @@ video { cursor: pointer; } +.auto-cols-max { + grid-auto-columns: max-content; +} + +.grid-flow-row { + grid-auto-flow: row; +} + +.grid-flow-col { + grid-auto-flow: column; +} + +.auto-rows-max { + grid-auto-rows: max-content; +} + .flex-row { flex-direction: row; } -- 2.36.3 From ddd9e36288428e703cfcb81624ce09b6478304e0 Mon Sep 17 00:00:00 2001 From: Miroito Date: Mon, 6 Mar 2023 09:39:27 +0100 Subject: [PATCH 5/5] switch to mroe truthful wording --- client/src/templates/index.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/templates/index.rs b/client/src/templates/index.rs index 9655b54..e88d11e 100644 --- a/client/src/templates/index.rs +++ b/client/src/templates/index.rs @@ -49,7 +49,7 @@ fn index_page(cx: Scope) -> View { } div (class="flex-grow") { h1 (class="mb-1 text-center") { - "Most activity in the past month" + "Most activity in the past 30 days" } PaginatedTable(table_transactions_month) } -- 2.36.3