Skip to main content

kasl/libs/
report_template.rs

1//! Declarative, JSON-based design templates for the hourly daily report.
2//!
3//! The visual style of the hourly report (fonts, border/fill colors, column
4//! widths, row heights and which sections are shown) used to be hardcoded in
5//! [`crate::libs::export`]. This module externalizes that style into a
6//! serializable [`ReportTemplate`] so it can be customized without recompiling.
7//!
8//! ## Resolution
9//!
10//! Templates are looked up by name (`config.report.template`, default
11//! `siserver`) in `<data>/report_templates/<name>.json`. When the file is
12//! missing or fails to parse, the built-in [`ReportTemplate::siserver`] default
13//! is used, guaranteeing the report always renders. On first use the default
14//! `siserver.json` is materialized to disk so users have a concrete example to
15//! copy and edit.
16//!
17//! ## Colors
18//!
19//! Colors are stored as `#RRGGBB` hex strings and converted to
20//! [`rust_xlsxwriter::Color`] via [`ReportTemplate::parse_color`].
21
22use crate::libs::data_storage::DataStorage;
23use anyhow::Result;
24use rust_xlsxwriter::Color;
25use serde::{Deserialize, Serialize};
26use std::fs;
27use std::path::PathBuf;
28
29/// Font specification for a single report element.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FontSpec {
32    /// Font family name (e.g. "Verdana").
33    pub name: String,
34    /// Font size in points.
35    pub size: f64,
36    /// Whether the font is bold.
37    #[serde(default)]
38    pub bold: bool,
39}
40
41impl FontSpec {
42    /// Convenience constructor.
43    fn new(name: &str, size: f64, bold: bool) -> Self {
44        Self {
45            name: name.to_string(),
46            size,
47            bold,
48        }
49    }
50}
51
52/// The set of fonts used across the report's distinct elements.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TemplateFonts {
55    /// Font for the report title cell.
56    pub title: FontSpec,
57    /// Font for the month name cell.
58    pub month: FontSpec,
59    /// Font for the date cell.
60    pub date: FontSpec,
61    /// Font for table header cells.
62    pub header: FontSpec,
63    /// Font for the start/end time cells and totals.
64    pub time: FontSpec,
65}
66
67/// A complete, serializable description of the hourly report's visual design.
68///
69/// All measurements mirror the values previously hardcoded for the SiServer
70/// layout so that [`ReportTemplate::siserver`] reproduces the original look
71/// byte-for-byte.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct ReportTemplate {
74    /// Fonts for the various report elements.
75    pub fonts: TemplateFonts,
76    /// Cell border color as `#RRGGBB`.
77    pub border_color: String,
78    /// Header/total fill color as `#RRGGBB`.
79    pub header_fill: String,
80    /// Widths of the five data columns (B..F).
81    pub col_widths: [f64; 5],
82    /// Height of each hourly data row.
83    pub data_row_height: f64,
84    /// Height of the title row.
85    pub title_row_height: f64,
86    /// Whether to render the "hours" column.
87    pub show_hours_column: bool,
88    /// Whether to render the "result" column.
89    pub show_result_column: bool,
90    /// Whether to render the comment label and comment box.
91    pub show_comment: bool,
92    /// Number of rows the comment box spans.
93    pub comment_rows: u32,
94}
95
96impl ReportTemplate {
97    /// Returns the built-in `siserver` template reproducing the original design.
98    pub fn siserver() -> Self {
99        Self {
100            fonts: TemplateFonts {
101                title: FontSpec::new("Verdana", 14.0, true),
102                month: FontSpec::new("Verdana", 14.0, false),
103                date: FontSpec::new("Verdana", 10.0, true),
104                header: FontSpec::new("Verdana", 10.0, true),
105                time: FontSpec::new("Verdana", 10.0, true),
106            },
107            border_color: "#333333".to_string(),
108            header_fill: "#C0C0C0".to_string(),
109            col_widths: [13.55, 13.55, 96.55, 14.89, 62.55],
110            data_row_height: 126.0,
111            title_row_height: 17.4,
112            show_hours_column: true,
113            show_result_column: true,
114            show_comment: true,
115            comment_rows: 11,
116        }
117    }
118
119    /// Loads the named template, falling back to the built-in [`Self::siserver`].
120    ///
121    /// The lookup path is `<data>/report_templates/<name>.json`. Missing files
122    /// or parse errors are non-fatal: the built-in default is returned instead.
123    /// Regardless of the requested name, the default `siserver.json` is
124    /// materialized on disk (if absent) as an editable example.
125    pub fn load(name: &str) -> Self {
126        // Best-effort: never fail report generation because of template I/O.
127        let _ = Self::ensure_default_on_disk();
128
129        match Self::templates_dir() {
130            Ok(dir) => {
131                let path = dir.join(format!("{}.json", name));
132                match fs::read_to_string(&path) {
133                    Ok(contents) => serde_json::from_str::<ReportTemplate>(&contents).unwrap_or_default(),
134                    Err(_) => Self::siserver(),
135                }
136            }
137            Err(_) => Self::siserver(),
138        }
139    }
140
141    /// Writes the built-in `siserver.json` template to disk when it is missing.
142    ///
143    /// This gives users a ready-to-copy reference for authoring custom
144    /// templates. Existing files are never overwritten.
145    pub fn ensure_default_on_disk() -> Result<()> {
146        let dir = Self::templates_dir()?;
147        let path = dir.join("siserver.json");
148        if !path.exists() {
149            let json = serde_json::to_string_pretty(&Self::siserver())?;
150            fs::write(&path, json)?;
151        }
152        Ok(())
153    }
154
155    /// Resolves (and creates) the `report_templates` directory under app data.
156    fn templates_dir() -> Result<PathBuf> {
157        let dir = DataStorage::new().get_path("report_templates")?;
158        if !dir.exists() {
159            fs::create_dir_all(&dir)?;
160        }
161        Ok(dir)
162    }
163
164    /// Parses a `#RRGGBB` (or `RRGGBB`) hex string into an xlsx [`Color`].
165    ///
166    /// Invalid strings fall back to the provided default color so a malformed
167    /// template value never aborts rendering.
168    pub fn parse_color(hex: &str, default: u32) -> Color {
169        let trimmed = hex.trim().trim_start_matches('#');
170        match u32::from_str_radix(trimmed, 16) {
171            Ok(value) if trimmed.len() == 6 => Color::RGB(value),
172            _ => Color::RGB(default),
173        }
174    }
175
176    /// Border color as an xlsx [`Color`], defaulting to `#333333`.
177    pub fn border(&self) -> Color {
178        Self::parse_color(&self.border_color, 0x333333)
179    }
180
181    /// Header/total fill color as an xlsx [`Color`], defaulting to `#C0C0C0`.
182    pub fn fill(&self) -> Color {
183        Self::parse_color(&self.header_fill, 0xC0C0C0)
184    }
185}
186
187impl Default for ReportTemplate {
188    fn default() -> Self {
189        Self::siserver()
190    }
191}