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}