diff --git a/jirs-client/js/css/reports.css b/jirs-client/js/css/reports.css new file mode 100644 index 00000000..53e53975 --- /dev/null +++ b/jirs-client/js/css/reports.css @@ -0,0 +1,34 @@ +#reports { +} + +#reports > .top > .graph { + margin-top: 15px; +} + +#reports > .top > .graph > .graphHeader { + margin-bottom: 15px; +} + +#reports > .top > .issueList { + display: block; +} + +#reports > .top > .issueList > .issue { + display: flex; + justify-content: space-between; + justify-items: flex-start; +} + +#reports > .top > .issueList > .issue > * { + width: 25%; +} + +#reports > .top > .issueList > .issue.selected { + color: var(--primary); + font-family: var(--font-bold); +} + +#reports > .top > .issueList > .issue.nonSelected { + color: var(--textLight); + font-family: var(--font-regular); +} diff --git a/jirs-client/js/styles.css b/jirs-client/js/styles.css index f87e6274..2a967cb1 100644 --- a/jirs-client/js/styles.css +++ b/jirs-client/js/styles.css @@ -31,3 +31,4 @@ @import "./css/register.css"; @import "./css/users.css"; @import "./css/invite.css"; +@import "./css/reports.css"; diff --git a/jirs-client/src/changes.rs b/jirs-client/src/changes.rs index 45866731..126327d9 100644 --- a/jirs-client/src/changes.rs +++ b/jirs-client/src/changes.rs @@ -55,6 +55,12 @@ pub enum InvitationPageChange { SubmitForm, } +#[derive(Clone, Debug, PartialEq)] +pub enum ReportsPageChange { + DayHovered(Option), + DaySelected(Option), +} + #[derive(Clone, Debug, PartialEq)] pub enum PageChanged { Users(UsersPageChange), @@ -62,6 +68,7 @@ pub enum PageChanged { Profile(ProfilePageChange), Board(BoardPageChange), Invitation(InvitationPageChange), + Reports(ReportsPageChange), } #[derive(Debug)] diff --git a/jirs-client/src/model.rs b/jirs-client/src/model.rs index d908ab27..6b687327 100644 --- a/jirs-client/src/model.rs +++ b/jirs-client/src/model.rs @@ -1,5 +1,6 @@ use std::collections::hash_map::HashMap; +use chrono::{prelude::*, NaiveDate}; use seed::browser::web_socket::WebSocket; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -431,7 +432,28 @@ impl ProfilePage { } #[derive(Debug)] -pub struct ReportsPage {} +pub struct ReportsPage { + pub selected_day: Option, + pub hovered_day: Option, + pub first_day: NaiveDate, + pub last_day: NaiveDate, +} + +impl ReportsPage { + pub fn new() -> Self { + let first_day = chrono::Utc::today().with_day(1).unwrap().naive_local(); + let last_day = (first_day + chrono::Duration::days(32)) + .with_day(1) + .unwrap() + - chrono::Duration::days(1); + Self { + first_day, + last_day, + selected_day: None, + hovered_day: None, + } + } +} #[derive(Debug)] pub enum PageContent { diff --git a/jirs-client/src/reports/update.rs b/jirs-client/src/reports/update.rs index 4af1f23c..ed10ae58 100644 --- a/jirs-client/src/reports/update.rs +++ b/jirs-client/src/reports/update.rs @@ -2,6 +2,7 @@ use seed::prelude::*; use jirs_data::WsMsg; +use crate::changes::{PageChanged, ReportsPageChange}; use crate::model::{Model, Page, PageContent, ReportsPage}; use crate::ws::enqueue_ws_msg; use crate::{Msg, WebSocketChanged}; @@ -14,7 +15,7 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order _ => (), } - let _page = match &mut model.page_content { + let page = match &mut model.page_content { PageContent::Reports(page) => page, _ => return, }; @@ -28,12 +29,18 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order | Msg::ChangePage(Page::Reports) => { init_load(model, orders); } + Msg::PageChanged(PageChanged::Reports(ReportsPageChange::DayHovered(v))) => { + page.hovered_day = v; + } + Msg::PageChanged(PageChanged::Reports(ReportsPageChange::DaySelected(v))) => { + page.selected_day = v; + } _ => {} } } fn build_page_content(model: &mut Model) { - model.page_content = PageContent::Reports(Box::new(ReportsPage {})) + model.page_content = PageContent::Reports(Box::new(ReportsPage::new())) } fn init_load(model: &mut Model, orders: &mut impl Orders) { diff --git a/jirs-client/src/reports/view.rs b/jirs-client/src/reports/view.rs index 6eda0ca6..501c9820 100644 --- a/jirs-client/src/reports/view.rs +++ b/jirs-client/src/reports/view.rs @@ -5,91 +5,166 @@ use seed::{prelude::*, *}; use jirs_data::Issue; -use crate::model::Model; +use crate::model::{Model, PageContent, ReportsPage}; use crate::shared::inner_layout; -use crate::Msg; +use crate::{Msg, PageChanged, ReportsPageChange}; -const SVG_MARGIN_X: u32 = 5; +const SVG_MARGIN_X: u32 = 10; const SVG_DRAWABLE_HEIGHT: u32 = 300; const SVG_HEIGHT: u32 = SVG_DRAWABLE_HEIGHT + 30; const SVG_WIDTH: u32 = 1060; -const SVG_BAR_WIDTH: u32 = 25; const SVG_BAR_MARGIN: u32 = 10; pub fn view(model: &Model) -> Node { - let body = section![ - h1![class!["header"], "Reports"], - div![this_month_graph(model)], - ]; + let page = match &model.page_content { + PageContent::Reports(page) => page, + _ => return empty![], + }; + + let this_month_updated = this_month_updated(model, page); + let graph = this_month_graph(page, &this_month_updated); + let list = issue_list(page, &this_month_updated); + + let body = section![class!["top"], h1![class!["header"], "Reports"], graph, list]; inner_layout(model, "reports", vec![body]) } -fn this_month_graph(model: &Model) -> Node { - let first_day = chrono::Utc::today().with_day(1).unwrap().naive_local(); - let last_day = (first_day + chrono::Duration::days(32)) - .with_day(1) - .unwrap() - - chrono::Duration::days(1); - - let this_month_updated: Vec<&Issue> = model - .issues - .iter() - .filter(|issue| issue.updated_at.date() >= first_day) - .collect(); - +fn this_month_graph(page: &Box, this_month_updated: &Vec<&Issue>) -> Node { + let mut dominant = 0; let mut issues: HashMap> = HashMap::new(); - let list: Vec> = this_month_updated - .into_iter() - .map(|issue| { - let date = issue.updated_at.date(); - issues.entry(date.day0()).or_default().push(issue); - let day = issue.updated_at.date().format("%Y-%m-%d").to_string(); - li![span![issue.title.as_str()], span![day.as_str()]] - }) - .collect(); + for issue in this_month_updated { + let date = issue.updated_at.date(); + let v = issues.entry(date.day0()).or_default(); + v.push(issue); + if dominant < v.len() { + dominant = v.len(); + } + } + let legend_margin_width = (dominant as f64).log10() * SVG_MARGIN_X as f64; let mut columns: Vec> = vec![]; - let x_origin: Node = seed::rect![attrs![ - At::X => SVG_MARGIN_X, - At::Y => SVG_HEIGHT - SVG_MARGIN_X - 20, - At::Width => SVG_WIDTH - (SVG_MARGIN_X * 2), - At::Height => 2, - At::Style => "fill: var(--textDark);" - ]]; - for day in (first_day.day0())..(last_day.day0()) { - let x = (SVG_BAR_WIDTH * day) + (SVG_BAR_MARGIN * day) + (SVG_MARGIN_X * 2); + let piece_height = SVG_DRAWABLE_HEIGHT as f64 / dominant as f64; + let piece_width = (SVG_WIDTH as f64 - (legend_margin_width + SVG_MARGIN_X as f64)) + / page.last_day.day() as f64; + + let resolution = 10; + let mut legend_parts: Vec> = vec![]; + for y in 0..(resolution + 1) { + let current = dominant as f64 * (y as f64 / resolution as f64); + + legend_parts.push(seed::text![ + attrs![ + At::X => 0, + At::Y => SVG_DRAWABLE_HEIGHT as f64 - (current as f64 * piece_height) + 12f64, + At::Style => "fill: var(--textLight); font-family: var(--font-regular); font-size: 10px;", + ], + format!("{:.1}", current), + ]); + legend_parts.push(seed::rect![attrs![ + At::X => legend_margin_width + SVG_MARGIN_X as f64, + At::Y => SVG_DRAWABLE_HEIGHT as f64 - (current as f64 * piece_height), + At::Width => SVG_WIDTH as f64 - (legend_margin_width + SVG_MARGIN_X as f64), + At::Height => 1, + At::Style => "fill: var(--textLight);", + ],]); + } + columns.push(seed::g![legend_parts]); + + for day in (page.first_day.day0())..(page.last_day.day()) { let num_issues = issues.get(&day).map(|v| v.len()).unwrap_or_default() as u32; - let height = num_issues * SVG_BAR_WIDTH; + if num_issues == 0 { + continue; + } + let x = (piece_width * day as f64) + + (SVG_BAR_MARGIN * day) as f64 + + (legend_margin_width + SVG_MARGIN_X as f64); + let height = num_issues as f64 * piece_height; - let day = first_day.with_day0(day).unwrap(); + let day = page.first_day.with_day0(day).unwrap(); - columns.push(seed::rect![attrs![ - At::X => x, - At::Y => SVG_DRAWABLE_HEIGHT - height, // reverse draw origin - At::Width => SVG_BAR_WIDTH, - At::Height => height, - At::Style => "fill: rgb(255,0,0);", - At::Title => format!("Number of issues: {}", num_issues), - ]]); + let on_hover: EventHandler = mouse_ev(Ev::MouseEnter, move |_| { + Some(Msg::PageChanged(PageChanged::Reports( + ReportsPageChange::DayHovered(Some(day.clone())), + ))) + }); + let on_blur: EventHandler = mouse_ev(Ev::MouseLeave, move |_| { + Some(Msg::PageChanged(PageChanged::Reports( + ReportsPageChange::DayHovered(None), + ))) + }); + + columns.push(seed::rect![ + on_hover, + on_blur, + attrs![ + At::X => x, + At::Y => SVG_DRAWABLE_HEIGHT as f64 - height, // reverse draw origin + At::Width => piece_width, + At::Height => height, + At::Style => "fill: rgb(255, 0, 0);", + At::Title => format!("Number of issues: {}", num_issues), + ] + ]); columns.push(seed::text![ attrs![ At::X => x, At::Y => SVG_HEIGHT, - At::Style => "fill: var(--textDark); font-family: var(--font-regular); font-size: 10px;", + At::Style => "fill: var(--textLight); font-family: var(--font-regular); font-size: 10px;", ], day.format("%d/%m").to_string(), ]); } div![ + class!["graph"], + h5![class!["graphHeader"], "Last updated"], svg![ attrs![At::Height => SVG_HEIGHT, At::Width => SVG_WIDTH], - x_origin, columns, ], - ul![list], ] } + +fn issue_list(page: &Box, this_month_updated: &Vec<&Issue>) -> Node { + let mut children: Vec> = vec![]; + for issue in this_month_updated.into_iter() { + let date = issue.updated_at.date(); + let day = date.format("%Y-%m-%d").to_string(); + let active_class = match (page.hovered_day.as_ref(), page.selected_day.as_ref()) { + (Some(d), _) if *d == date => "selected", + (_, Some(d)) if *d == date => "selected", + (Some(_), _) | (_, Some(_)) => "nonSelected", + _ => "", + }; + let Issue { + title, + issue_type, + priority, + description: _, + issue_status_id: _, + .. + } = issue; + children.push(li![ + class!["issue"], + class![active_class], + span![title.as_str()], + span![format!("{}", issue_type)], + span![format!("{}", priority)], + span![day.as_str()] + ]); + } + div![class!["issueList"], children] +} + +fn this_month_updated<'a>(model: &'a Model, page: &Box) -> Vec<&'a Issue> { + model + .issues + .iter() + .filter(|issue| { + issue.updated_at.date() >= page.first_day && issue.updated_at.date() <= page.last_day + }) + .collect() +}