Skip to main content

ravenrs_app_report/
analytics.rs

1//! Panels people put together themselves and keep as records: tiles of numbers and charts over the
2//! entities of the application, each read as the person can read it.
3
4use std::collections::BTreeMap;
5use std::rc::Rc;
6
7use ravenrs_app::{
8    Action, ActionCategory, Criteria, EntityInfo, EnumOption, FieldInfo, FieldKind, Module, Object, Query,
9    Selection, Value, ViewController,
10};
11use ravenrs_app_ui::{Session, chart_view, summary_text};
12use ravenrs_layout::Align;
13use ravenrs_reactive::State;
14use ravenrs_style::{TextStyle, Weight};
15use ravenrs_widgets::{
16    AnyView, BuildCx, Button, ButtonSize, ButtonStyle, HStack, NodeId, ScrollView, Text, Tile, TileLayout,
17    TileSpot, VStack, View, ViewExt,
18};
19
20use crate::stored::{ReportWords, problem};
21
22/// The entity of the panels people keep.
23pub const ANALYTICS_DASHBOARD: &str = "AnalyticsDashboard";
24/// The entity of their tiles.
25pub const ANALYTICS_TILE: &str = "AnalyticsTile";
26const OPEN: &str = "OpenPanel";
27const TILE_COLUMNS: u16 = 4;
28const NUMBER_COLUMNS: u16 = 2;
29const TILE_ROWS: u16 = 2;
30const NUMBER_ROWS: u16 = 1;
31const CHART_HEIGHT: f32 = 240.0;
32
33/// The entities of panels, which storage has to know. Their tiles are edited on the panel's own form,
34/// so the designer is the screens every record gets.
35pub fn analytics_entities() -> Vec<EntityInfo> {
36    let kinds = vec![
37        EnumOption::new("number", "Number"),
38        EnumOption::new("bar", "Bars"),
39        EnumOption::new("line", "Lines"),
40        EnumOption::new("area", "Areas"),
41        EnumOption::new("pie", "Pie"),
42        EnumOption::new("doughnut", "Doughnut"),
43    ];
44    vec![
45        EntityInfo::new(ANALYTICS_DASHBOARD)
46            .caption("Panels")
47            .navigation("Reports")
48            .icon("layout-dashboard")
49            .field(FieldInfo::new("id", FieldKind::Guid).key())
50            .field(FieldInfo::new("name", FieldKind::Text).default_property())
51            .field(
52                FieldInfo::new("tiles", FieldKind::Collection(ANALYTICS_TILE.to_owned()))
53                    .back_reference("dashboard_id")
54                    .aggregated(),
55            )
56            .rule(ravenrs_app::Rule::required("name")),
57        EntityInfo::new(ANALYTICS_TILE)
58            .caption("Tiles")
59            .field(FieldInfo::new("id", FieldKind::Guid).key())
60            .field(
61                FieldInfo::new("dashboard_id", FieldKind::Reference(ANALYTICS_DASHBOARD.to_owned()))
62                    .caption("Panel"),
63            )
64            .field(FieldInfo::new("title", FieldKind::Text).default_property())
65            .field(FieldInfo::new("position", FieldKind::Integer).default_value(0.0))
66            .field(FieldInfo::new("target_entity", FieldKind::Text).caption("Entity"))
67            .field(FieldInfo::new("kind", FieldKind::Enum(kinds)))
68            .field(FieldInfo::new("argument", FieldKind::Text).caption("Along the axis"))
69            .field(FieldInfo::new("series", FieldKind::Text).caption("Values, as field:Sum"))
70            .field(FieldInfo::new("interval", FieldKind::Text).caption("Dates by Day, Month or Year"))
71            .field(FieldInfo::new("filter", FieldKind::Text))
72            .rule(ravenrs_app::Rule::required("title"))
73            .rule(ravenrs_app::Rule::required("target_entity")),
74    ]
75}
76
77fn text(record: &Object, name: &str) -> String {
78    match record.get(name) {
79        Value::Text(text) => text,
80        _ => String::new(),
81    }
82}
83
84/// What a tile shows, read now, as the person can read it.
85fn tile_content(session: &Session, tile: &Object) -> AnyView {
86    let title = text(tile, "title");
87    let target = text(tile, "target_entity");
88    let Some(entity) = session.entity(&target) else {
89        return problem(&title, format!("{} {target}", session.words().list_editor_needs));
90    };
91    let mut query = Query::all();
92    let filter = text(tile, "filter");
93    if !filter.trim().is_empty() {
94        match Criteria::parse(&filter) {
95            Ok(criteria) => query = query.filter(criteria),
96            Err(error) => return problem(&title, error.to_string()),
97        }
98    }
99    let objects = match session.frame().space().borrow().query(&target, &query) {
100        Ok(objects) => objects,
101        Err(error) => return problem(&title, error.to_string()),
102    };
103    let (kind, series) = (text(tile, "kind"), text(tile, "series"));
104    if kind == "number" {
105        return match summary_text(session, &entity, &objects, &series) {
106            Ok(value) => Box::new(Text::new(value).style(TextStyle::Metric)),
107            Err(why) => problem(&title, why),
108        };
109    }
110    let mut settings = BTreeMap::new();
111    let chart_kind = match kind.as_str() {
112        "line" => "Line",
113        "area" => "Area",
114        "pie" => "Pie",
115        "doughnut" => "Doughnut",
116        _ => "Bar",
117    };
118    settings.insert("Kind".to_owned(), chart_kind.to_owned());
119    for (from, to) in [("argument", "Argument"), ("series", "Series"), ("interval", "Interval")] {
120        let value = text(tile, from);
121        if !value.trim().is_empty() {
122            settings.insert(to.to_owned(), value);
123        }
124    }
125    chart_view(session, &entity, Rc::new(objects), settings, CHART_HEIGHT)
126        .unwrap_or_else(|why| problem(&title, why))
127}
128
129/// A panel on the screen.
130#[derive(Clone)]
131struct PanelView {
132    session: Session,
133    panel: Value,
134    close: String,
135    title: String,
136    spots: State<Vec<TileSpot>>,
137    tiles: Rc<Vec<Object>>,
138}
139
140impl PanelView {
141    fn new(session: Session, panel: Value, title: String, close: String) -> Self {
142        let mut tiles: Vec<Object> = session
143            .frame()
144            .space()
145            .borrow()
146            .query(ANALYTICS_TILE, &Query::all())
147            .unwrap_or_default()
148            .into_iter()
149            .filter(|tile| tile.get("dashboard_id") == panel)
150            .collect();
151        tiles.sort_by_key(|tile| match tile.get("position") {
152            Value::Number(position) => position as i64,
153            _ => 0,
154        });
155        let spots = tiles
156            .iter()
157            .map(|tile| {
158                let (columns, rows) = if text(tile, "kind") == "number" {
159                    (NUMBER_COLUMNS, NUMBER_ROWS)
160                } else {
161                    (TILE_COLUMNS, TILE_ROWS)
162                };
163                TileSpot::new(ravenrs_app::key_text(&tile.get("id")).unwrap_or_default(), columns, rows)
164            })
165            .collect();
166        Self { session, panel, close, title, spots: State::new(spots), tiles: Rc::new(tiles) }
167    }
168}
169
170impl View for PanelView {
171    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
172        // The records can change under it.
173        self.session.revision();
174        let mut tiles = TileLayout::new(self.spots.binding());
175        for tile in self.tiles.iter() {
176            let key = ravenrs_app::key_text(&tile.get("id")).unwrap_or_default();
177            tiles = tiles.tile(Tile::new(key, text(tile, "title"), tile_content(&self.session, tile)));
178        }
179        let closing = self.session.clone();
180        let close = Button::new(self.close.clone())
181            .style(ButtonStyle::Secondary)
182            .size(ButtonSize::Small)
183            .on_click(move || closing.close_full());
184        let _ = &self.panel;
185        let top = HStack::new((
186            Text::new(self.title.clone()).style(TextStyle::Title).font_weight(Weight::SEMIBOLD).grow(1.0),
187            close,
188        ))
189        .align(Align::Center);
190        let content = VStack::new((top, tiles)).spacing(16.0).align(Align::Stretch).padding(16.0);
191        cx.child(&ScrollView::new((content,)).grow(1.0))
192    }
193}
194
195struct Panels {
196    words: ReportWords,
197}
198
199impl ViewController for Panels {
200    fn id(&self) -> &str {
201        "Panels"
202    }
203
204    fn actions(&self) -> Vec<Action> {
205        let close = self.words.close.clone();
206        vec![
207            Action::simple(OPEN, self.words.open_panel.clone())
208                .category(ActionCategory::View)
209                .target_entity(ANALYTICS_DASHBOARD)
210                .selection(Selection::RequireSingle)
211                .icon("layout-dashboard")
212                .on_execute(move |cx| {
213                    let session = Session::of(cx).ok_or("this action needs the screens")?;
214                    let record = cx.selected.first().ok_or("this action needs a panel")?;
215                    let shown = PanelView::new(
216                        session.clone(),
217                        record.get("id"),
218                        text(record, "name"),
219                        close.clone(),
220                    );
221                    session.show_full(move || Box::new(shown.clone()) as AnyView);
222                    Ok(())
223                }),
224        ]
225    }
226}
227
228/// Panels people put together and keep. Storage has to know [`analytics_entities`].
229#[derive(Debug, Default)]
230pub struct AnalyticsModule {
231    words: ReportWords,
232}
233
234impl AnalyticsModule {
235    /// With these words.
236    pub fn new(words: ReportWords) -> Self {
237        Self { words }
238    }
239}
240
241impl Module for AnalyticsModule {
242    fn name(&self) -> &str {
243        "Analytics"
244    }
245
246    fn entities(&self) -> Vec<EntityInfo> {
247        analytics_entities()
248    }
249
250    fn view_controllers(&self) -> Vec<Box<dyn ViewController>> {
251        vec![Box::new(Panels { words: self.words.clone() })]
252    }
253}