New Index page with more interesting recent insights #26

Merged
alban merged 5 commits from daily-transactions into master 3 years ago

74
Cargo.lock generated

@ -392,6 +392,17 @@ version = "1.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b645a089122eccb6111b4f81cbc1a49f5900ac4666bb93ac027feaecf15607bf" 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]] [[package]]
name = "binascii" name = "binascii"
version = "0.1.4" version = "0.1.4"
@ -564,14 +575,11 @@ version = "3.2.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71655c45cb9845d3270c9d6df84ebe72b4dad3c2ba3f7023ad47c144e4e473a5" checksum = "71655c45cb9845d3270c9d6df84ebe72b4dad3c2ba3f7023ad47c144e4e473a5"
dependencies = [ dependencies = [
"atty",
"bitflags", "bitflags",
"clap_derive", "clap_derive",
"clap_lex", "clap_lex",
"indexmap", "indexmap",
"once_cell", "once_cell",
"strsim",
"termcolor",
"textwrap", "textwrap",
] ]
@ -713,21 +721,6 @@ dependencies = [
"libc", "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]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.3.2" version = "1.3.2"
@ -2011,6 +2004,7 @@ name = "migration"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"async-std", "async-std",
"chrono",
"sea-orm-migration", "sea-orm-migration",
] ]
@ -3299,15 +3293,15 @@ dependencies = [
[[package]] [[package]]
name = "sea-orm" name = "sea-orm"
version = "0.10.7" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88694d01b528a94f90ad87f8d2f546d060d070eee180315c67d158cb69476034" checksum = "e7a0e3ec90718d849c73b167df7a476672b64c7ee5f3c582179069e63b2451e1"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"async-trait", "async-trait",
"bigdecimal",
"chrono", "chrono",
"futures", "futures",
"futures-util",
"log 0.4.17", "log 0.4.17",
"ouroboros", "ouroboros",
"rust_decimal", "rust_decimal",
@ -3327,9 +3321,9 @@ dependencies = [
[[package]] [[package]]
name = "sea-orm-cli" name = "sea-orm-cli"
version = "0.10.7" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ebe1f820fe8949cf6a57272ba9ebd0be766e47c9b85c04b3cabea40ab9459b3" checksum = "992bc003ed84e736daa19d1b562bd80fa2de09d7bca70cb1745adec3f3b54064"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
@ -3343,9 +3337,9 @@ dependencies = [
[[package]] [[package]]
name = "sea-orm-macros" name = "sea-orm-macros"
version = "0.10.7" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7216195de9c6b2474fd0efab486173dccd0eff21f28cc54aa4c0205d52fb3af0" checksum = "5d89f7d4d2533c178e08a9e1990619c391e9ca7b402851d02a605938b15e03d9"
dependencies = [ dependencies = [
"bae", "bae",
"heck 0.3.3", "heck 0.3.3",
@ -3356,13 +3350,14 @@ dependencies = [
[[package]] [[package]]
name = "sea-orm-migration" name = "sea-orm-migration"
version = "0.10.7" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ed3cdfa669e4c385922f902b9a58e0c2128782a4d0fe79c6c34f3b927565e5b" checksum = "355b1e2763e73d36de6f4539b04fc5d01b232e5c97785e0d08c4becbc2accad3"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"clap", "clap",
"dotenvy", "dotenvy",
"futures",
"sea-orm", "sea-orm",
"sea-orm-cli", "sea-orm-cli",
"sea-schema", "sea-schema",
@ -3392,10 +3387,11 @@ dependencies = [
[[package]] [[package]]
name = "sea-query" name = "sea-query"
version = "0.27.2" version = "0.28.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4f0fc4d8e44e1d51c739a68d336252a18bc59553778075d5e32649be6ec92ed" checksum = "d2fbe015dbdaa7d8829d71c1e14fb6289e928ac256b93dfda543c85cd89d6f03"
dependencies = [ dependencies = [
"bigdecimal",
"chrono", "chrono",
"rust_decimal", "rust_decimal",
"sea-query-derive", "sea-query-derive",
@ -3406,10 +3402,11 @@ dependencies = [
[[package]] [[package]]
name = "sea-query-binder" name = "sea-query-binder"
version = "0.2.2" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c2585b89c985cfacfe0ec9fc9e7bb055b776c1a2581c4e3c6185af2b8bf8865" checksum = "03548c63aec07afd4fd190923e0160d2f2fc92def27470b54154cf232da6203b"
dependencies = [ dependencies = [
"bigdecimal",
"chrono", "chrono",
"rust_decimal", "rust_decimal",
"sea-query", "sea-query",
@ -3421,11 +3418,11 @@ dependencies = [
[[package]] [[package]]
name = "sea-query-derive" name = "sea-query-derive"
version = "0.2.0" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34cdc022b4f606353fe5dc85b09713a04e433323b70163e81513b141c6ae6eb5" checksum = "63f62030c60f3a691f5fe251713b4e220b306e50a71e1d6f9cce1f24bb781978"
dependencies = [ dependencies = [
"heck 0.3.3", "heck 0.4.1",
"proc-macro2 1.0.51", "proc-macro2 1.0.51",
"quote 1.0.23", "quote 1.0.23",
"syn 1.0.108", "syn 1.0.108",
@ -3434,9 +3431,9 @@ dependencies = [
[[package]] [[package]]
name = "sea-schema" name = "sea-schema"
version = "0.10.3" version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d5fda574d980e9352b6c7abd6fc75697436fe0078cac2b548559b52643ad3b" checksum = "eeb2940bb5a10bc6cd05b450ce6cd3993e27fddd7eface2becb97fc5af3a040e"
dependencies = [ dependencies = [
"futures", "futures",
"sea-query", "sea-query",
@ -3599,7 +3596,6 @@ dependencies = [
"rocket_contrib", "rocket_contrib",
"sea-orm", "sea-orm",
"sea-orm-rocket", "sea-orm-rocket",
"sea-query",
"serde", "serde",
"serde_json", "serde_json",
"slug", "slug",
@ -3756,11 +3752,11 @@ checksum = "dcbc16ddba161afc99e14d1713a453747a2b07fc097d2009f4c300ec99286105"
dependencies = [ dependencies = [
"ahash 0.7.6", "ahash 0.7.6",
"atoi", "atoi",
"bigdecimal",
"bitflags", "bitflags",
"byteorder", "byteorder",
"bytes", "bytes",
"chrono", "chrono",
"crc",
"crossbeam-queue", "crossbeam-queue",
"digest", "digest",
"dotenvy", "dotenvy",
@ -3816,7 +3812,6 @@ dependencies = [
"proc-macro2 1.0.51", "proc-macro2 1.0.51",
"quote 1.0.23", "quote 1.0.23",
"serde_json", "serde_json",
"sha2",
"sqlx-core", "sqlx-core",
"sqlx-rt", "sqlx-rt",
"syn 1.0.108", "syn 1.0.108",
@ -4607,7 +4602,6 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79" checksum = "1674845326ee10d37ca60470760d4288a6f80f304007d92e5c53bab78c9cfd79"
dependencies = [ dependencies = [
"getrandom",
"serde", "serde",
] ]

@ -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( pub async fn get_transactions(
company_slug: Option<String>, company_slug: Option<String>,
@ -7,17 +10,18 @@ pub async fn get_transactions(
) -> Result<PaginatedResponse<TransactionCompany>, ()> { ) -> Result<PaginatedResponse<TransactionCompany>, ()> {
use crate::env::Config; use crate::env::Config;
// TODO: Remove build-time environment variable
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,
);
#[cfg(client)] #[cfg(client)]
{ let res = reqwasm::http::Request::get(route)
// 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,
))
.send() .send()
.await .await
.map_err(|_| ())? .map_err(|_| ())?
@ -25,24 +29,50 @@ pub async fn get_transactions(
.await .await
.map_err(|_| ())?; .map_err(|_| ())?;
return Ok(res);
}
#[cfg(engine)] #[cfg(engine)]
{ let res = reqwest::get(route)
let res = reqwest::get(&format!(
"{}transaction{}?page={}&size={}",
Config::new().api_url,
company_slug.unwrap_or_default(),
page,
size,
))
.await .await
.map_err(|_| ())? .map_err(|_| ())?
.json::<PaginatedResponse<TransactionCompany>>() .json::<PaginatedResponse<TransactionCompany>>()
.await .await
.map_err(|_| ())?; .map_err(|_| ())?;
return Ok(res); return Ok(res);
} }
pub async fn get_aggregated_transactions(
hours: Option<String>,
page: i64,
size: i64,
) -> Result<PaginatedResponse<TransactionsAggregated>, ()> {
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::<PaginatedResponse<TransactionsAggregated>>()
.await
.map_err(|_| ())?;
#[cfg(engine)]
let res = reqwest::get(route)
.await
.map_err(|_| ())?
.json::<PaginatedResponse<TransactionsAggregated>>()
.await
.map_err(|_| ())?;
return Ok(res);
} }

@ -3,7 +3,7 @@ use sycamore::prelude::*;
use crate::components::base_table::TableContent; use crate::components::base_table::TableContent;
use super::transaction::TransactionCompany; use super::transaction::{TransactionCompany, TransactionsAggregated};
pub trait IntoTableData<G> pub trait IntoTableData<G>
where where
@ -43,32 +43,22 @@ where
.into_iter() .into_iter()
.map(|t| { .map(|t| {
let mut res = vec![]; let mut res = vec![];
let t1 = t.clone();
res.push(view! {cx, 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", 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, (t.date_published.to_owned()) });
res.push(view! {cx, (t1.date_published.to_owned()) }); res.push(view! {cx, (t.date_executed.to_owned()) });
let t1 = t.clone(); res.push(view! {cx, (t.person.to_owned()) });
res.push(view! {cx, (t1.date_executed.to_owned()) }); res.push(view! {cx, (t.nature.to_owned()) });
let t1 = t.clone(); res.push(view! {cx, (t.isin.to_owned().unwrap_or_else(|| "-".to_string())) });
res.push(view! {cx, (t1.person.to_owned()) }); res.push(view! {cx, (t.instrument.to_owned()) });
let t1 = t.clone(); res.push(view! {cx, (t.exchange.to_owned()) });
res.push(view! {cx, (t1.nature.to_owned()) }); res.push(view! {cx, (t.volume.to_owned()) });
let t1 = t.clone(); res.push(view! {cx, (t.unit_price.to_owned()) });
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.volume as f32 * t.unit_price).to_string()) }); res.push(view! {cx, ((t.volume as f32 * t.unit_price).to_string()) });
res res
@ -81,3 +71,38 @@ where
} }
} }
} }
impl<G> IntoTableData<G> for PaginatedResponse<TransactionsAggregated>
where
G: GenericNode,
{
fn into_table_data<'a>(self, cx: Scope<'a>) -> TableContent<G> {
let headers_view = vec![
view! {cx, "Company" },
view! {cx, "Transactions" },
];
let data_view: Vec<Vec<View<G>>> = 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,
}
}
}

@ -1,4 +1,4 @@
use chrono::NaiveDate; use chrono::{NaiveDate, NaiveDateTime};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::company::Company; use super::company::Company;
@ -17,6 +17,7 @@ pub struct Transaction {
pub instrument: String, pub instrument: String,
pub volume: i32, pub volume: i32,
pub unit_price: f32, pub unit_price: f32,
pub created_at_utc: NaiveDateTime,
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -32,5 +33,14 @@ pub struct TransactionCompany {
pub instrument: String, pub instrument: String,
pub volume: i32, pub volume: i32,
pub unit_price: f32, pub unit_price: f32,
pub created_at_utc: NaiveDateTime,
pub company: Company, pub company: Company,
} }
#[derive(Deserialize, Clone)]
pub struct TransactionsAggregated {
pub id: i32,
pub name: String,
pub slug: String,
pub transaction_count: i32,
}

@ -16,6 +16,7 @@ where
{ {
pub headers_view: &'a Signal<Vec<View<G>>>, pub headers_view: &'a Signal<Vec<View<G>>>,
pub data_view: &'a Signal<Vec<Vec<View<G>>>>, pub data_view: &'a Signal<Vec<Vec<View<G>>>>,
pub table_class: &'a String,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -61,7 +62,7 @@ where
}); });
view! { cx, 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 { thead {
tr (class="border-b-2 border-slate-500 text-center") { tr (class="border-b-2 border-slate-500 text-center") {
Keyed( Keyed(

@ -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<G> {
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)
}
}
}
}

@ -2,5 +2,6 @@ pub mod base_async_select;
pub mod base_button; pub mod base_button;
pub mod base_table; pub mod base_table;
pub mod loading; pub mod loading;
pub mod main_content_container;
pub mod paginated_data_table; pub mod paginated_data_table;
pub mod the_header; pub mod the_header;

@ -13,17 +13,19 @@ use crate::{
}; };
#[derive(Prop)] #[derive(Prop)]
pub struct PaginatedTableStateRx<M, F, C> pub struct PaginatedTableStateRx<'a, M, F, C>
where where
M: 'static, M: 'static,
C: Fn(Option<String>, i64, i64) -> F, C: Fn(Option<String>, i64, i64) -> F,
F: std::future::Future<Output = Result<PaginatedResponse<M>, ()>>, F: std::future::Future<Output = Result<PaginatedResponse<M>, ()>>,
{ {
pub record_label: String,
pub route: C, pub route: C,
pub filter: Option<String>, pub filter: Option<String>,
pub table_class: &'a String,
} }
impl<M, F, C> PaginatedTableStateRx<M, F, C> impl<'a, M, F, C> PaginatedTableStateRx<'a, M, F, C>
where where
M: 'static, M: 'static,
C: Fn(Option<String>, i64, i64) -> F, C: Fn(Option<String>, i64, i64) -> F,
@ -34,10 +36,11 @@ 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<String>
#[component] #[component]
pub fn PaginatedTable<'a, G, M, F, C>( pub fn PaginatedTable<'a, G, M, F, C>(
cx: Scope<'a>, cx: Scope<'a>,
props: PaginatedTableStateRx<M, F, C>, props: PaginatedTableStateRx<'a, M, F, C>,
) -> View<G> ) -> View<G>
where where
G: Html, G: Html,
@ -51,6 +54,7 @@ where
let table_prop: TableContentRx<G> = TableContentRx { let table_prop: TableContentRx<G> = TableContentRx {
headers_view: create_signal(cx, vec![]), headers_view: create_signal(cx, vec![]),
data_view: create_signal(cx, vec![vec![]]), data_view: create_signal(cx, vec![vec![]]),
table_class: props.table_class,
}; };
let page = create_signal(cx, 0); let page = create_signal(cx, 0);
let n_page = create_signal(cx, 1); let n_page = create_signal(cx, 1);
@ -74,6 +78,10 @@ where
let page_size_string = create_signal(cx, "20".to_string()); 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); let props_sig = create_signal(cx, props);
create_effect(cx, move || { create_effect(cx, move || {
let page = *page.get(); let page = *page.get();
@ -93,32 +101,41 @@ where
view! { cx, view! { cx,
(if paginated_data.get().is_some() { (if paginated_data.get().is_some() {
view! {cx, if *n_rows.get() == 0 {
p (class="text-right") { (format!("{} transactions", n_rows.get())) } view!{cx,
div (class="flex flex-row justify-between") { div (class="bg-slate-200 dark:bg-slate-800 text-center rounded-md") {
select (bind:value=page_size_string, (format!("No {}", props_sig.get().record_label))
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") {
"<<"
} }
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, table_class=table_prop.table_class)
BaseTable(headers_view=table_prop.headers_view, data_view=table_prop.data_view) }
} }
} else { } else {
view! {cx, view! {cx,

@ -1,18 +1,48 @@
use perseus::prelude::*;
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::global_state::AppStateRx;
#[component] #[component]
pub fn the_header<G: Html>(cx: Scope) -> View<G> { pub fn TheHeader<'a, G: Html>(cx: Scope<'a>) -> View<G> {
// 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<Option<&AppStateRx>> = create_signal(cx, None);
#[cfg(client)]
global_state_sig.set(Some(
Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(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, view! { cx,
"Don't use until global state is recheable from a component" header (class="shadow-md h-11 p-2 align-middle w-full bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") {
// header (class="shadow-md h-10 sm:p-2 w-full mb-20 bg-gray-100 dark:bg-slate-500/30 backdrop-blur-lg") { div (class="flex") {
// nav () { div (class="flex-none mr-12") {
// div (class="fixed flex justify-between") { a (href="/", class="hover:underline") {
// div (class="font-mono mr-10") { "Fast Insiders" } "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" } }
// } 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" }
}
}
}
} }
} }

@ -12,6 +12,7 @@ pub mod templates;
pub fn main<G: Html>() -> PerseusApp<G> { pub fn main<G: Html>() -> PerseusApp<G> {
PerseusApp::new() PerseusApp::new()
.template(crate::templates::index::get_template()) .template(crate::templates::index::get_template())
.template(crate::templates::transactions::get_template())
.global_state_creator(crate::global_state::get_global_state_creator()) .global_state_creator(crate::global_state::get_global_state_creator())
.error_views(crate::error_pages::get_error_views()) .error_views(crate::error_pages::get_error_views())
.index_view(|cx| { .index_view(|cx| {

@ -1,134 +1,67 @@
use perseus::prelude::*; use perseus::prelude::*;
use serde::{Deserialize, Serialize};
use sycamore::prelude::*; use sycamore::prelude::*;
use crate::{ use crate::{
api::routes::transaction::get_transactions, api::{
api::types::{company::Company, transaction::TransactionCompany}, routes::transaction::get_aggregated_transactions,
types::transaction::TransactionsAggregated,
},
components::{ components::{
base_async_select::{AsyncSelectRx, BaseAsyncSelect}, main_content_container::MainContentContainer,
base_button::{BaseButton, BaseButtonStateRx},
paginated_data_table::{PaginatedTable, PaginatedTableStateRx}, paginated_data_table::{PaginatedTable, PaginatedTableStateRx},
the_header::TheHeader,
}, },
global_state::AppStateRx, global_state::AppStateRx,
}; };
#[derive(Serialize, Deserialize, Clone, ReactiveState)] fn index_page<G: Html>(cx: Scope) -> View<G> {
#[rx(alias = "IndexPageStateRx")]
pub struct IndexPageState {
pub company_slug: String,
}
#[auto_scope]
fn index_page<G: Html>(cx: Scope, state: &IndexPageStateRx) -> View<G> {
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(cx); let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(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 table_classes = create_ref(cx, "w-full".to_string());
let paginated_table_state: PaginatedTableStateRx<TransactionCompany, _, _> = let table_transactions_24hours: PaginatedTableStateRx<TransactionsAggregated, _, _> =
PaginatedTableStateRx { PaginatedTableStateRx {
route: get_transactions, record_label: "companies".to_owned(),
filter: if (*state.company_slug.get()).is_empty() { route: get_aggregated_transactions,
None filter: Some("72".to_string()),
} else { table_class: table_classes,
Some((*state.company_slug.get()).clone())
},
}; };
let async_select_prop: AsyncSelectRx<Company> = AsyncSelectRx { let table_transactions_month: PaginatedTableStateRx<TransactionsAggregated, _, _> =
remote_list: create_signal(cx, format!("{}company/", "http://localhost:8000/v1/")), PaginatedTableStateRx {
selected_item: create_signal(cx, None), record_label: "companies".to_owned(),
}; route: get_aggregated_transactions,
filter: Some((24 * 30).to_string()),
let search_button = BaseButtonStateRx { table_class: table_classes,
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, 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") { 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") { TheHeader()
div (class="flex flex-row justify-between") { MainContentContainer(useless_prop=1) {
div (class="mr-10 align-middle") { div(class="flex flex-wrap gap-4 justify-around") {
a (href="/", class="hover:underline") { div (class="flex-grow") {
"Fast Insiders" h1 (class="mb-1 text-center") {
} "Latest insider activity (72h)"
}
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"
} }
PaginatedTable(table_transactions_24hours)
} }
BaseButton(filter_expand) div (class="flex-grow") {
div {} // Without this useless div, the code doesn't run in the browser h1 (class="mb-1 text-center") {
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 {}", "Most activity in the past 30 days"
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(table_transactions_month)
} }
PaginatedTable(paginated_table_state)
} }
} }
}
} }
} }
}
} }
pub fn get_template<G: Html>() -> Template<G> { pub fn get_template<G: Html>() -> Template<G> {
Template::build("index") Template::build("index").head(head).view(index_page).build()
.head(head)
.build_state_fn(get_build_state)
.build_paths_fn(get_build_paths)
.incremental_generation()
.view_with_state(index_page)
.build()
} }
#[engine_only_fn] #[engine_only_fn]
@ -137,23 +70,3 @@ fn head(cx: Scope) -> View<SsrNode> {
title { "Fast Insiders" } title { "Fast Insiders" }
} }
} }
#[engine_only_fn]
async fn get_build_state(
StateGeneratorInfo { path, .. }: StateGeneratorInfo<()>,
) -> Result<IndexPageState, BlamedError<std::io::Error>> {
let company_slug: String = path
.split("transactions")
.nth(1)
.unwrap_or(&("/".to_owned() + &path))
.to_string();
Ok(IndexPageState { company_slug })
}
#[engine_only_fn]
async fn get_build_paths() -> BuildPaths {
BuildPaths {
paths: vec!["".to_string()],
extra: ().into(),
}
}

@ -1 +1,2 @@
pub mod index; pub mod index;
pub mod transactions;

@ -0,0 +1,137 @@
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<String>,
}
#[auto_scope]
fn transactions_page<G: Html>(cx: Scope, state: &TransactionsPageStateRx) -> View<G> {
let global_state = Reactor::<G>::from_cx(cx).get_global_state::<AppStateRx>(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<TransactionCompany, _, _> =
PaginatedTableStateRx {
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<Company> = 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<G: Html>() -> Template<G> {
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<SsrNode> {
view! {cx,
title { "Fast Insiders" }
}
}
#[engine_only_fn]
async fn get_build_state(
StateGeneratorInfo { path, .. }: StateGeneratorInfo<()>,
) -> Result<TransactionsPageState, BlamedError<std::io::Error>> {
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(),
}
}

@ -525,10 +525,6 @@ video {
position: static; position: static;
} }
.fixed {
position: fixed;
}
.absolute { .absolute {
position: absolute; position: absolute;
} }
@ -549,14 +545,14 @@ video {
margin: 0.5rem; margin: 0.5rem;
} }
.m-1 {
margin: 0.25rem;
}
.m-10 { .m-10 {
margin: 2.5rem; margin: 2.5rem;
} }
.m-1 {
margin: 0.25rem;
}
.my-2 { .my-2 {
margin-top: 0.5rem; margin-top: 0.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
@ -572,12 +568,12 @@ video {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
.mb-20 { .mr-12 {
margin-bottom: 5rem; margin-right: 3rem;
} }
.mr-10 { .mb-1 {
margin-right: 2.5rem; margin-bottom: 0.25rem;
} }
.flex { .flex {
@ -588,12 +584,16 @@ video {
display: table; display: table;
} }
.h-10 { .grid {
height: 2.5rem; display: grid;
} }
.h-12 { .contents {
height: 3rem; display: contents;
}
.h-11 {
height: 2.75rem;
} }
.h-40 { .h-40 {
@ -616,6 +616,40 @@ video {
width: 80%; 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;
}
.table-auto { .table-auto {
table-layout: auto; table-layout: auto;
} }
@ -628,6 +662,22 @@ video {
cursor: pointer; 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-row {
flex-direction: row; flex-direction: row;
} }
@ -636,6 +686,10 @@ video {
flex-direction: column; flex-direction: column;
} }
.flex-wrap {
flex-wrap: wrap;
}
.items-center { .items-center {
align-items: center; align-items: center;
} }
@ -652,6 +706,14 @@ video {
justify-content: space-between; justify-content: space-between;
} }
.justify-around {
justify-content: space-around;
}
.gap-4 {
gap: 1rem;
}
.rounded-md { .rounded-md {
border-radius: 0.375rem; border-radius: 0.375rem;
} }
@ -706,29 +768,20 @@ video {
background-color: rgb(226 232 240 / var(--tw-bg-opacity)); background-color: rgb(226 232 240 / var(--tw-bg-opacity));
} }
.bg-gray-100 { .bg-slate-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity));
}
.bg-pink-200 {
--tw-bg-opacity: 1; --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; --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 { .p-2 {
padding: 0.5rem; padding: 0.5rem;
} }
.p-1 {
padding: 0.25rem;
}
.p-3 { .p-3 {
padding: 0.75rem; padding: 0.75rem;
} }
@ -759,10 +812,6 @@ video {
vertical-align: middle; vertical-align: middle;
} }
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.font-sans { .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"; 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 +893,13 @@ video {
background-color: rgb(30 41 59 / var(--tw-bg-opacity)); background-color: rgb(30 41 59 / var(--tw-bg-opacity));
} }
.dark .dark\:bg-slate-500\/30 { .dark .dark\:bg-slate-600 {
background-color: rgb(100 116 139 / 0.3); --tw-bg-opacity: 1;
background-color: rgb(71 85 105 / var(--tw-bg-opacity));
} }
.dark .dark\:bg-pink-600 { .dark .dark\:bg-slate-500\/30 {
--tw-bg-opacity: 1; background-color: rgb(100 116 139 / 0.3);
background-color: rgb(219 39 119 / var(--tw-bg-opacity));
} }
.dark .dark\:bg-slate-700 { .dark .dark\:bg-slate-700 {
@ -858,11 +907,6 @@ video {
background-color: rgb(51 65 85 / var(--tw-bg-opacity)); 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 { .dark .dark\:text-slate-100 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(241 245 249 / var(--tw-text-opacity)); color: rgb(241 245 249 / var(--tw-text-opacity));
@ -882,9 +926,3 @@ video {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(79 70 229 / var(--tw-text-opacity)); color: rgb(79 70 229 / var(--tw-text-opacity));
} }
@media (min-width: 640px) {
.sm\:p-2 {
padding: 0.5rem;
}
}

@ -3,6 +3,7 @@ use perseus::wait_for_checkpoint;
// This is is an E2E test with the following steps: // This is is an E2E test with the following steps:
// - Go to the index page // - Go to the index page
// - Navigate to all transactions
// - Try all page sizes and verify that they are respected // - Try all page sizes and verify that they are respected
// - Try to go to page 2 // - Try to go to page 2
// - Use the async select component to find any company // - 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?; let title = c.find(Locator::Css("title")).await?.html(false).await?;
assert!(title.contains("Fast Insiders")); 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 // Verify that the default table size is 20
let page_size_select = c.find(Locator::Css("#size-select")).await?; let page_size_select = c.find(Locator::Css("#size-select")).await?;
let default_page_size = page_size_select 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?; search_button.click().await?;
let page = c.current_url().await?; let page = c.current_url().await?;
assert!( assert!(
page.as_ref().starts_with("http://localhost:8080/"), page.as_ref()
.starts_with("http://localhost:8080/transactions"),
"Unexpected target url reached: {}", "Unexpected target url reached: {}",
page 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? { for a in c.find_all(Locator::Css("table a")).await? {
let a_text = a.text().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); 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?; let transactions_link = c.find(Locator::Css("a h1")).await?;
root_link.click().await?; transactions_link.click().await?;
let first_company_in_table = c.find(Locator::Css("table a")).await?; let first_company_in_table = c.find(Locator::Css("table a")).await?;
let company_name = first_company_in_table.text().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_eq!(company_name, a_text, "The clicked company is different from the target page list of companies, respectively {} and {}", company_name, a_text);
} }
assert!( assert!(
page.as_ref().starts_with("http://localhost:8080/"), page.as_ref()
.starts_with("http://localhost:8080/transactions"),
"Unexpected target url reached: {}", "Unexpected target url reached: {}",
page page
); );

@ -18,7 +18,14 @@ serve:
cd client && \ cd client && \
perseus serve -w perseus serve -w
client-deploy: check-client:
cd client && \
perseus check -w
check-server:
cargo check
deploy-client:
cd client && \ cd client && \
perseus deploy perseus deploy

@ -15,7 +15,7 @@ envy = { workspace = true }
tokio = { version = "^1.20.1", features = ["full"] } tokio = { version = "^1.20.1", features = ["full"] }
reqwest = { version = "0.11", features = ["json", "rustls-tls"] } reqwest = { version = "0.11", features = ["json", "rustls-tls"] }
rocket = { version = "0.5.0-rc.2", features = ["json"] } 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", "runtime-tokio-rustls",
"macros", "macros",
"sqlx-mysql", "sqlx-mysql",
@ -30,5 +30,4 @@ rocket_contrib = "0.4.11"
async-trait = "0.1.61" async-trait = "0.1.61"
sea-orm-rocket = "0.5.2" sea-orm-rocket = "0.5.2"
thiserror = "1.0.38" thiserror = "1.0.38"
sea-query = "^0.27.1"
slug = "0.1.4" slug = "0.1.4"

@ -10,10 +10,8 @@ path = "src/lib.rs"
[dependencies] [dependencies]
async-std = { version = "^1", features = ["attributes", "tokio1"] } async-std = { version = "^1", features = ["attributes", "tokio1"] }
chrono.workspace = true
[dependencies.sea-orm-migration] [dependencies.sea-orm-migration]
version = "^0.10.0" version = "0.11.0"
features = [ features = ["sqlx-mysql", "runtime-tokio-rustls"]
"sqlx-mysql",
"runtime-tokio-rustls",
]

@ -1,8 +1,10 @@
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_115856_create_company_table;
mod m20230112_160440_create_transaction_table; mod m20230112_160440_create_transaction_table;
mod m20230119_112539_create_transactions_in_process_table; mod m20230119_112539_create_transactions_in_process_table;
mod m20230303_132528_transactions_created_at;
pub struct Migrator; pub struct Migrator;
@ -13,6 +15,7 @@ impl MigratorTrait for Migrator {
Box::new(m20230112_115856_create_company_table::Migration), Box::new(m20230112_115856_create_company_table::Migration),
Box::new(m20230112_160440_create_transaction_table::Migration), Box::new(m20230112_160440_create_transaction_table::Migration),
Box::new(m20230119_112539_create_transactions_in_process_table::Migration), Box::new(m20230119_112539_create_transactions_in_process_table::Migration),
Box::new(m20230303_132528_transactions_created_at::Migration),
] ]
} }
} }

@ -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::CreatedAtUtc)
.date_time()
.not_null()
.default(Expr::current_timestamp()),
)
.to_owned(),
)
.await?;
let query = Query::update()
.table(Transaction::Table)
.value(
Transaction::CreatedAtUtc,
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::CreatedAtUtc)
.to_owned(),
)
.await
}
}
/// Learn more at https://docs.rs/sea-query#iden
#[derive(Iden)]
enum Transaction {
Table,
DatePublished,
CreatedAtUtc,
}

@ -1,7 +1,7 @@
use sea_orm::{error::DbErr, FromQueryResult}; use sea_orm::{
use sea_orm::{prelude::*, Order, QueryOrder}; error::DbErr, prelude::*, sea_query::SimpleExpr, FromQueryResult, Order, QueryOrder,
};
use sea_orm_rocket::Connection; use sea_orm_rocket::Connection;
use sea_query::expr::SimpleExpr;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::Db; use super::Db;
@ -58,7 +58,7 @@ pub async fn paginate_also_related<E, R, T, K, C>(
size: Option<u64>, size: Option<u64>,
column: Option<C>, column: Option<C>,
order: Option<Order>, order: Option<Order>,
filter: Option<SimpleExpr>, filters: Option<Vec<SimpleExpr>>,
) -> Result<PaginatedResponse<(T, Option<K>)>, DbErr> ) -> Result<PaginatedResponse<(T, Option<K>)>, DbErr>
where where
E: EntityTrait + Related<R>, E: EntityTrait + Related<R>,
@ -79,8 +79,10 @@ where
selector = E::find().find_also_related::<R>(R::default()); selector = E::find().find_also_related::<R>(R::default());
} }
if let Some(fil) = filter { if let Some(fils) = filters {
selector = selector.filter(fil); for fil in fils {
selector = selector.filter(fil);
}
} }
let pages = selector.into_model().paginate(db, s); let pages = selector.into_model().paginate(db, s);

@ -53,8 +53,8 @@ async fn start_rocket() -> Result<(), sea_orm_rocket::rocket::Error> {
routes![ routes![
route::company::get_all, route::company::get_all,
route::company::get_by_isin, route::company::get_by_isin,
route::transaction::get_by_company_id, route::transaction::get_transactions,
route::transaction::get_all, route::transaction::get_aggregated_transactions,
route::in_process_transaction::get_all, route::in_process_transaction::get_all,
route::in_process_transaction::retry_failed_transaction, route::in_process_transaction::retry_failed_transaction,
route::in_process_transaction::retry_all route::in_process_transaction::retry_all

@ -21,6 +21,7 @@ pub struct Model {
pub instrument: String, pub instrument: String,
pub volume: i32, pub volume: i32,
pub unit_price: f32, pub unit_price: f32,
pub created_at_utc: DateTime,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

@ -1,7 +1,13 @@
use chrono::NaiveDate; use chrono::{Duration, NaiveDate, NaiveDateTime, Utc};
use rocket::http::Status; use rocket::http::Status;
use rocket::response::status::Custom; 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::rocket::serde::json::Json;
use sea_orm_rocket::Connection; use sea_orm_rocket::Connection;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -9,12 +15,45 @@ use serde::{Deserialize, Serialize};
use crate::db::paginate::{paginate_also_related, PaginatedResponse}; use crate::db::paginate::{paginate_also_related, PaginatedResponse};
use crate::{db::Db, model}; use crate::{db::Db, model};
#[get("/transaction?<page>&<size>")] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub async fn get_all( 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<String>,
pub instrument: String,
pub volume: i32,
pub unit_price: f32,
pub created_at_utc: NaiveDateTime,
pub company: Option<model::company::Model>,
}
#[get("/transaction?<company_slug>&<page>&<size>&<hours>")]
pub async fn get_transactions(
conn: Connection<'_, Db>, conn: Connection<'_, Db>,
company_slug: Option<String>,
hours: Option<i64>,
page: Option<u64>, page: Option<u64>,
size: Option<u64>, size: Option<u64>,
) -> Result<Json<PaginatedResponse<TransactionCompany>>, Custom<String>> { ) -> Result<Json<PaginatedResponse<TransactionCompany>>, Custom<String>> {
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::< let res = paginate_also_related::<
model::transaction::Entity, model::transaction::Entity,
model::company::Entity, model::company::Entity,
@ -27,7 +66,7 @@ pub async fn get_all(
size, size,
Some(model::transaction::Column::DatePublished), Some(model::transaction::Column::DatePublished),
Some(Order::Desc), Some(Order::Desc),
None, Some(filters),
) )
.await .await
.map_err(|e| { .map_err(|e| {
@ -52,6 +91,7 @@ pub async fn get_all(
instrument: t.0.instrument.to_owned(), instrument: t.0.instrument.to_owned(),
volume: t.0.volume, volume: t.0.volume,
unit_price: t.0.unit_price, unit_price: t.0.unit_price,
created_at_utc: t.0.created_at_utc,
company: t.1.to_owned(), company: t.1.to_owned(),
}) })
.collect(); .collect();
@ -65,76 +105,74 @@ pub async fn get_all(
Ok(Json(res)) Ok(Json(res))
} }
#[get("/transaction/<company_slug>?<page>&<size>")] #[get("/transaction/aggregated?<page>&<size>&<hours>")]
pub async fn get_by_company_id( pub async fn get_aggregated_transactions(
conn: Connection<'_, Db>, conn: Connection<'_, Db>,
company_slug: String, hours: Option<i64>,
page: Option<u64>, page: Option<u64>,
size: Option<u64>, size: Option<u64>,
) -> Result<Json<PaginatedResponse<TransactionCompany>>, Custom<String>> { ) -> Result<Json<PaginatedResponse<TransactionsAggregated>>, Custom<String>> {
let filter = model::company::Column::Slug.eq(company_slug); let db = conn.into_inner();
let res = paginate_also_related::< let s = size.unwrap_or(20).min(50);
model::transaction::Entity, let mut query = model::company::Entity::find()
model::company::Entity, .select_only()
model::transaction::Model, .join(
model::company::Model, JoinType::InnerJoin,
model::transaction::Column, model::company::Relation::Transaction.def(),
>( )
conn, .column(model::company::Column::Id)
page, .column(model::company::Column::Name)
size, .column(model::company::Column::Slug)
Some(model::transaction::Column::DatePublished), .column_as(model::transaction::Column::Id.count(), "transaction_count");
Some(Order::Desc),
Some(filter), if let Some(h) = hours {
) query = query.filter(
.await model::transaction::Column::CreatedAtUtc.gte(
.map_err(|e| { 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::<TransactionsAggregated>()
.paginate(db, s);
let ItemsAndPagesNumber {
number_of_items: count,
number_of_pages: num_pages,
} = pages.num_items_and_pages().await.map_err(|e| {
Custom( Custom(
Status::InternalServerError, Status::InternalServerError,
format!("Database error: {}", e), format!("Database error: {}", e),
) )
})?; })?;
let list = res let p = page.unwrap_or(0).min(num_pages);
.list
.iter() let list = pages.fetch_page(p).await.map_err(|e| {
.map(|t| TransactionCompany { Custom(
id: t.0.id, Status::InternalServerError,
foreign_id: t.0.foreign_id.to_owned(), format!("Database error: {}", e),
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,
company: t.1.to_owned(),
})
.collect();
let res = PaginatedResponse { let res = PaginatedResponse {
count: res.count, count,
num_pages: res.num_pages, num_pages,
list, list,
}; };
Ok(Json(res)) Ok(Json(res))
} }
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Serialize, FromQueryResult, Debug)]
pub struct TransactionCompany { pub struct TransactionsAggregated {
pub id: i32, id: i32,
pub foreign_id: String, name: String,
pub date_published: NaiveDate, slug: String,
pub date_executed: NaiveDate, transaction_count: i32,
pub person: String,
pub exchange: String,
pub nature: String,
pub isin: Option<String>,
pub instrument: String,
pub volume: i32,
pub unit_price: f32,
pub company: Option<model::company::Model>,
} }

Loading…
Cancel
Save