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}