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