Simple reports view
This commit is contained in:
parent
f1d075865c
commit
8947c409b7
34
jirs-client/js/css/reports.css
Normal file
34
jirs-client/js/css/reports.css
Normal file
@ -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);
|
||||||
|
}
|
@ -31,3 +31,4 @@
|
|||||||
@import "./css/register.css";
|
@import "./css/register.css";
|
||||||
@import "./css/users.css";
|
@import "./css/users.css";
|
||||||
@import "./css/invite.css";
|
@import "./css/invite.css";
|
||||||
|
@import "./css/reports.css";
|
||||||
|
@ -55,6 +55,12 @@ pub enum InvitationPageChange {
|
|||||||
SubmitForm,
|
SubmitForm,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum ReportsPageChange {
|
||||||
|
DayHovered(Option<chrono::NaiveDate>),
|
||||||
|
DaySelected(Option<chrono::NaiveDate>),
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub enum PageChanged {
|
pub enum PageChanged {
|
||||||
Users(UsersPageChange),
|
Users(UsersPageChange),
|
||||||
@ -62,6 +68,7 @@ pub enum PageChanged {
|
|||||||
Profile(ProfilePageChange),
|
Profile(ProfilePageChange),
|
||||||
Board(BoardPageChange),
|
Board(BoardPageChange),
|
||||||
Invitation(InvitationPageChange),
|
Invitation(InvitationPageChange),
|
||||||
|
Reports(ReportsPageChange),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
use std::collections::hash_map::HashMap;
|
use std::collections::hash_map::HashMap;
|
||||||
|
|
||||||
|
use chrono::{prelude::*, NaiveDate};
|
||||||
use seed::browser::web_socket::WebSocket;
|
use seed::browser::web_socket::WebSocket;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -431,7 +432,28 @@ impl ProfilePage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct ReportsPage {}
|
pub struct ReportsPage {
|
||||||
|
pub selected_day: Option<chrono::NaiveDate>,
|
||||||
|
pub hovered_day: Option<chrono::NaiveDate>,
|
||||||
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum PageContent {
|
pub enum PageContent {
|
||||||
|
@ -2,6 +2,7 @@ use seed::prelude::*;
|
|||||||
|
|
||||||
use jirs_data::WsMsg;
|
use jirs_data::WsMsg;
|
||||||
|
|
||||||
|
use crate::changes::{PageChanged, ReportsPageChange};
|
||||||
use crate::model::{Model, Page, PageContent, ReportsPage};
|
use crate::model::{Model, Page, PageContent, ReportsPage};
|
||||||
use crate::ws::enqueue_ws_msg;
|
use crate::ws::enqueue_ws_msg;
|
||||||
use crate::{Msg, WebSocketChanged};
|
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,
|
PageContent::Reports(page) => page,
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
@ -28,12 +29,18 @@ pub fn update(msg: Msg, model: &mut crate::model::Model, orders: &mut impl Order
|
|||||||
| Msg::ChangePage(Page::Reports) => {
|
| Msg::ChangePage(Page::Reports) => {
|
||||||
init_load(model, orders);
|
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) {
|
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<Msg>) {
|
fn init_load(model: &mut Model, orders: &mut impl Orders<Msg>) {
|
||||||
|
@ -5,91 +5,166 @@ use seed::{prelude::*, *};
|
|||||||
|
|
||||||
use jirs_data::Issue;
|
use jirs_data::Issue;
|
||||||
|
|
||||||
use crate::model::Model;
|
use crate::model::{Model, PageContent, ReportsPage};
|
||||||
use crate::shared::inner_layout;
|
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_DRAWABLE_HEIGHT: u32 = 300;
|
||||||
const SVG_HEIGHT: u32 = SVG_DRAWABLE_HEIGHT + 30;
|
const SVG_HEIGHT: u32 = SVG_DRAWABLE_HEIGHT + 30;
|
||||||
const SVG_WIDTH: u32 = 1060;
|
const SVG_WIDTH: u32 = 1060;
|
||||||
const SVG_BAR_WIDTH: u32 = 25;
|
|
||||||
const SVG_BAR_MARGIN: u32 = 10;
|
const SVG_BAR_MARGIN: u32 = 10;
|
||||||
|
|
||||||
pub fn view(model: &Model) -> Node<Msg> {
|
pub fn view(model: &Model) -> Node<Msg> {
|
||||||
let body = section![
|
let page = match &model.page_content {
|
||||||
h1![class!["header"], "Reports"],
|
PageContent::Reports(page) => page,
|
||||||
div![this_month_graph(model)],
|
_ => 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])
|
inner_layout(model, "reports", vec![body])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn this_month_graph(model: &Model) -> Node<Msg> {
|
fn this_month_graph(page: &Box<ReportsPage>, this_month_updated: &Vec<&Issue>) -> Node<Msg> {
|
||||||
let first_day = chrono::Utc::today().with_day(1).unwrap().naive_local();
|
let mut dominant = 0;
|
||||||
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();
|
|
||||||
|
|
||||||
let mut issues: HashMap<u32, Vec<&Issue>> = HashMap::new();
|
let mut issues: HashMap<u32, Vec<&Issue>> = HashMap::new();
|
||||||
|
|
||||||
let list: Vec<Node<Msg>> = this_month_updated
|
for issue in this_month_updated {
|
||||||
.into_iter()
|
|
||||||
.map(|issue| {
|
|
||||||
let date = issue.updated_at.date();
|
let date = issue.updated_at.date();
|
||||||
issues.entry(date.day0()).or_default().push(issue);
|
let v = issues.entry(date.day0()).or_default();
|
||||||
let day = issue.updated_at.date().format("%Y-%m-%d").to_string();
|
v.push(issue);
|
||||||
li![span![issue.title.as_str()], span![day.as_str()]]
|
if dominant < v.len() {
|
||||||
})
|
dominant = v.len();
|
||||||
.collect();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let legend_margin_width = (dominant as f64).log10() * SVG_MARGIN_X as f64;
|
||||||
let mut columns: Vec<Node<Msg>> = vec![];
|
let mut columns: Vec<Node<Msg>> = vec![];
|
||||||
let x_origin: Node<Msg> = 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 piece_height = SVG_DRAWABLE_HEIGHT as f64 / dominant as f64;
|
||||||
let x = (SVG_BAR_WIDTH * day) + (SVG_BAR_MARGIN * day) + (SVG_MARGIN_X * 2);
|
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<Node<Msg>> = 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 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![
|
let on_hover: EventHandler<Msg> = mouse_ev(Ev::MouseEnter, move |_| {
|
||||||
|
Some(Msg::PageChanged(PageChanged::Reports(
|
||||||
|
ReportsPageChange::DayHovered(Some(day.clone())),
|
||||||
|
)))
|
||||||
|
});
|
||||||
|
let on_blur: EventHandler<Msg> = 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::X => x,
|
||||||
At::Y => SVG_DRAWABLE_HEIGHT - height, // reverse draw origin
|
At::Y => SVG_DRAWABLE_HEIGHT as f64 - height, // reverse draw origin
|
||||||
At::Width => SVG_BAR_WIDTH,
|
At::Width => piece_width,
|
||||||
At::Height => height,
|
At::Height => height,
|
||||||
At::Style => "fill: rgb(255,0,0);",
|
At::Style => "fill: rgb(255, 0, 0);",
|
||||||
At::Title => format!("Number of issues: {}", num_issues),
|
At::Title => format!("Number of issues: {}", num_issues),
|
||||||
]]);
|
]
|
||||||
|
]);
|
||||||
columns.push(seed::text![
|
columns.push(seed::text![
|
||||||
attrs![
|
attrs![
|
||||||
At::X => x,
|
At::X => x,
|
||||||
At::Y => SVG_HEIGHT,
|
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(),
|
day.format("%d/%m").to_string(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
div![
|
div![
|
||||||
|
class!["graph"],
|
||||||
|
h5![class!["graphHeader"], "Last updated"],
|
||||||
svg![
|
svg![
|
||||||
attrs![At::Height => SVG_HEIGHT, At::Width => SVG_WIDTH],
|
attrs![At::Height => SVG_HEIGHT, At::Width => SVG_WIDTH],
|
||||||
x_origin,
|
|
||||||
columns,
|
columns,
|
||||||
],
|
],
|
||||||
ul![list],
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn issue_list(page: &Box<ReportsPage>, this_month_updated: &Vec<&Issue>) -> Node<Msg> {
|
||||||
|
let mut children: Vec<Node<Msg>> = 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<ReportsPage>) -> Vec<&'a Issue> {
|
||||||
|
model
|
||||||
|
.issues
|
||||||
|
.iter()
|
||||||
|
.filter(|issue| {
|
||||||
|
issue.updated_at.date() >= page.first_day && issue.updated_at.date() <= page.last_day
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user