1use ratatui::prelude::*;
7
8use aranet_core::settings::{DeviceSettings, RadonUnit, TemperatureUnit};
9use aranet_types::HistoryRecord;
10
11#[inline]
13fn celsius_to_fahrenheit(celsius: f32) -> f32 {
14 celsius * 9.0 / 5.0 + 32.0
15}
16
17#[inline]
19fn bq_to_pci(bq: u32) -> f32 {
20 bq as f32 * 0.027
21}
22
23#[must_use]
27pub fn format_temp_for_device(celsius: f32, settings: Option<&DeviceSettings>) -> String {
28 let use_fahrenheit = settings
29 .map(|s| s.temperature_unit == TemperatureUnit::Fahrenheit)
30 .unwrap_or(false);
31
32 if use_fahrenheit {
33 format!("{:.1}°F", celsius_to_fahrenheit(celsius))
34 } else {
35 format!("{:.1}°C", celsius)
36 }
37}
38
39#[must_use]
43pub fn format_radon_for_device(bq: u32, settings: Option<&DeviceSettings>) -> String {
44 let use_pci = settings
45 .map(|s| s.radon_unit == RadonUnit::PciL)
46 .unwrap_or(false);
47
48 if use_pci {
49 format!("{:.2} pCi/L", bq_to_pci(bq))
50 } else {
51 format!("{} Bq/m³", bq)
52 }
53}
54
55#[must_use]
57pub fn radon_unit_for_device(settings: Option<&DeviceSettings>) -> &'static str {
58 let use_pci = settings
59 .map(|s| s.radon_unit == RadonUnit::PciL)
60 .unwrap_or(false);
61
62 if use_pci { "pCi/L" } else { "Bq/m³" }
63}
64
65#[must_use]
69pub fn convert_radon_for_device(bq: u32, settings: Option<&DeviceSettings>) -> f32 {
70 let use_pci = settings
71 .map(|s| s.radon_unit == RadonUnit::PciL)
72 .unwrap_or(false);
73
74 if use_pci { bq_to_pci(bq) } else { bq as f32 }
75}
76
77#[must_use]
91pub fn sparkline_data(
92 history: &[HistoryRecord],
93 device_type: Option<aranet_types::DeviceType>,
94) -> Vec<u64> {
95 use aranet_types::DeviceType;
96
97 match device_type {
98 Some(DeviceType::AranetRadon) => {
99 history
101 .iter()
102 .filter_map(|record| record.radon)
103 .map(u64::from)
104 .collect()
105 }
106 Some(DeviceType::AranetRadiation) => {
107 history
109 .iter()
110 .filter_map(|record| record.radiation_rate)
111 .map(|r| r as u64)
112 .collect()
113 }
114 _ => {
115 history
117 .iter()
118 .filter(|record| record.co2 > 0)
119 .map(|record| u64::from(record.co2))
120 .collect()
121 }
122 }
123}
124
125#[must_use]
140pub fn resample_sparkline_data(data: &[u64], target_width: usize) -> Vec<u64> {
141 if data.is_empty() || target_width == 0 {
142 return Vec::new();
143 }
144
145 if data.len() == target_width {
146 return data.to_vec();
147 }
148
149 let mut result = Vec::with_capacity(target_width);
150
151 if data.len() < target_width {
152 for i in 0..target_width {
155 let src_idx = i * (data.len() - 1) / (target_width - 1).max(1);
156 result.push(data[src_idx.min(data.len() - 1)]);
157 }
158 } else {
159 let bucket_size = data.len() as f64 / target_width as f64;
161 for i in 0..target_width {
162 let start = (i as f64 * bucket_size) as usize;
163 let end = ((i + 1) as f64 * bucket_size) as usize;
164 let end = end.min(data.len());
165
166 if start < end {
167 let sum: u64 = data[start..end].iter().sum();
168 let avg = sum / (end - start) as u64;
169 result.push(avg);
170 } else if start < data.len() {
171 result.push(data[start]);
172 }
173 }
174 }
175
176 result
177}
178
179pub fn trend_indicator(current: i32, previous: i32, threshold: i32) -> (&'static str, Color) {
182 let diff = current - previous;
183 if diff > threshold {
184 ("↑", Color::Red) } else if diff < -threshold {
186 ("↓", Color::Green) } else {
188 ("→", Color::DarkGray) }
190}
191
192pub fn co2_trend(current: u16, previous: Option<u16>) -> Option<(&'static str, Color)> {
194 previous.map(|prev| trend_indicator(current as i32, prev as i32, 20))
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
206 fn test_celsius_to_fahrenheit_freezing() {
207 let result = celsius_to_fahrenheit(0.0);
208 assert!((result - 32.0).abs() < 0.01);
209 }
210
211 #[test]
212 fn test_celsius_to_fahrenheit_boiling() {
213 let result = celsius_to_fahrenheit(100.0);
214 assert!((result - 212.0).abs() < 0.01);
215 }
216
217 #[test]
218 fn test_celsius_to_fahrenheit_negative() {
219 let result = celsius_to_fahrenheit(-40.0);
221 assert!((result - (-40.0)).abs() < 0.01);
222 }
223
224 #[test]
229 fn test_bq_to_pci_zero() {
230 let result = bq_to_pci(0);
231 assert!((result - 0.0).abs() < 0.001);
232 }
233
234 #[test]
235 fn test_bq_to_pci_100() {
236 let result = bq_to_pci(100);
238 assert!((result - 2.7).abs() < 0.01);
239 }
240
241 #[test]
246 fn test_format_temp_no_settings_defaults_celsius() {
247 let result = format_temp_for_device(20.5, None);
248 assert_eq!(result, "20.5°C");
249 }
250
251 #[test]
252 fn test_format_temp_celsius_setting() {
253 let settings = DeviceSettings {
254 temperature_unit: TemperatureUnit::Celsius,
255 ..Default::default()
256 };
257 let result = format_temp_for_device(20.5, Some(&settings));
258 assert_eq!(result, "20.5°C");
259 }
260
261 #[test]
262 fn test_format_temp_fahrenheit_setting() {
263 let settings = DeviceSettings {
264 temperature_unit: TemperatureUnit::Fahrenheit,
265 ..Default::default()
266 };
267 let result = format_temp_for_device(20.0, Some(&settings));
268 assert_eq!(result, "68.0°F");
270 }
271
272 #[test]
277 fn test_format_radon_no_settings_defaults_bq() {
278 let result = format_radon_for_device(150, None);
279 assert_eq!(result, "150 Bq/m³");
280 }
281
282 #[test]
283 fn test_format_radon_bq_setting() {
284 let settings = DeviceSettings {
285 radon_unit: RadonUnit::BqM3,
286 ..Default::default()
287 };
288 let result = format_radon_for_device(150, Some(&settings));
289 assert_eq!(result, "150 Bq/m³");
290 }
291
292 #[test]
293 fn test_format_radon_pci_setting() {
294 let settings = DeviceSettings {
295 radon_unit: RadonUnit::PciL,
296 ..Default::default()
297 };
298 let result = format_radon_for_device(100, Some(&settings));
299 assert_eq!(result, "2.70 pCi/L");
301 }
302
303 #[test]
308 fn test_resample_empty_data() {
309 let result = resample_sparkline_data(&[], 10);
310 assert!(result.is_empty());
311 }
312
313 #[test]
314 fn test_resample_zero_width() {
315 let result = resample_sparkline_data(&[1, 2, 3], 0);
316 assert!(result.is_empty());
317 }
318
319 #[test]
320 fn test_resample_same_size() {
321 let data = vec![1, 2, 3, 4, 5];
322 let result = resample_sparkline_data(&data, 5);
323 assert_eq!(result, data);
324 }
325
326 #[test]
327 fn test_resample_upsample() {
328 let data = vec![100, 200];
329 let result = resample_sparkline_data(&data, 4);
330 assert_eq!(result.len(), 4);
331 }
332
333 #[test]
334 fn test_resample_downsample() {
335 let data = vec![100, 100, 200, 200];
336 let result = resample_sparkline_data(&data, 2);
337 assert_eq!(result.len(), 2);
338 assert_eq!(result[0], 100);
340 assert_eq!(result[1], 200);
341 }
342
343 #[test]
348 fn test_trend_indicator_rising() {
349 let (arrow, color) = trend_indicator(500, 400, 20);
350 assert_eq!(arrow, "↑");
351 assert_eq!(color, Color::Red);
352 }
353
354 #[test]
355 fn test_trend_indicator_falling() {
356 let (arrow, color) = trend_indicator(400, 500, 20);
357 assert_eq!(arrow, "↓");
358 assert_eq!(color, Color::Green);
359 }
360
361 #[test]
362 fn test_trend_indicator_stable() {
363 let (arrow, color) = trend_indicator(500, 505, 20);
364 assert_eq!(arrow, "→");
365 assert_eq!(color, Color::DarkGray);
366 }
367
368 #[test]
373 fn test_co2_trend_no_previous() {
374 let result = co2_trend(800, None);
375 assert!(result.is_none());
376 }
377
378 #[test]
379 fn test_co2_trend_rising() {
380 let result = co2_trend(850, Some(800));
381 assert!(result.is_some());
382 let (arrow, color) = result.unwrap();
383 assert_eq!(arrow, "↑");
384 assert_eq!(color, Color::Red);
385 }
386
387 #[test]
388 fn test_co2_trend_falling() {
389 let result = co2_trend(750, Some(800));
390 assert!(result.is_some());
391 let (arrow, color) = result.unwrap();
392 assert_eq!(arrow, "↓");
393 assert_eq!(color, Color::Green);
394 }
395
396 #[test]
397 fn test_co2_trend_stable() {
398 let result = co2_trend(805, Some(800));
399 assert!(result.is_some());
400 let (arrow, _) = result.unwrap();
401 assert_eq!(arrow, "→");
402 }
403
404 #[test]
409 fn test_radon_unit_no_settings() {
410 let result = radon_unit_for_device(None);
411 assert_eq!(result, "Bq/m³");
412 }
413
414 #[test]
415 fn test_radon_unit_bq_setting() {
416 let settings = DeviceSettings {
417 radon_unit: RadonUnit::BqM3,
418 ..Default::default()
419 };
420 let result = radon_unit_for_device(Some(&settings));
421 assert_eq!(result, "Bq/m³");
422 }
423
424 #[test]
425 fn test_radon_unit_pci_setting() {
426 let settings = DeviceSettings {
427 radon_unit: RadonUnit::PciL,
428 ..Default::default()
429 };
430 let result = radon_unit_for_device(Some(&settings));
431 assert_eq!(result, "pCi/L");
432 }
433
434 #[test]
439 fn test_convert_radon_no_settings() {
440 let result = convert_radon_for_device(100, None);
441 assert_eq!(result, 100.0);
442 }
443
444 #[test]
445 fn test_convert_radon_bq_setting() {
446 let settings = DeviceSettings {
447 radon_unit: RadonUnit::BqM3,
448 ..Default::default()
449 };
450 let result = convert_radon_for_device(100, Some(&settings));
451 assert_eq!(result, 100.0);
452 }
453
454 #[test]
455 fn test_convert_radon_pci_setting() {
456 let settings = DeviceSettings {
457 radon_unit: RadonUnit::PciL,
458 ..Default::default()
459 };
460 let result = convert_radon_for_device(100, Some(&settings));
461 assert!((result - 2.7).abs() < 0.01);
463 }
464
465 #[test]
470 fn test_sparkline_data_empty() {
471 let result = sparkline_data(&[], None);
472 assert!(result.is_empty());
473 }
474
475 #[test]
476 fn test_sparkline_data_aranet4() {
477 use aranet_types::DeviceType;
478 use time::OffsetDateTime;
479
480 let history = vec![
481 HistoryRecord {
482 timestamp: OffsetDateTime::now_utc(),
483 co2: 800,
484 temperature: 22.5,
485 humidity: 45,
486 pressure: 1013.0,
487 radon: None,
488 radiation_rate: None,
489 radiation_total: None,
490 },
491 HistoryRecord {
492 timestamp: OffsetDateTime::now_utc(),
493 co2: 850,
494 temperature: 22.5,
495 humidity: 45,
496 pressure: 1013.0,
497 radon: None,
498 radiation_rate: None,
499 radiation_total: None,
500 },
501 ];
502
503 let result = sparkline_data(&history, Some(DeviceType::Aranet4));
504 assert_eq!(result, vec![800, 850]);
505 }
506
507 #[test]
508 fn test_sparkline_data_radon() {
509 use aranet_types::DeviceType;
510 use time::OffsetDateTime;
511
512 let history = vec![
513 HistoryRecord {
514 timestamp: OffsetDateTime::now_utc(),
515 co2: 0,
516 temperature: 22.5,
517 humidity: 45,
518 pressure: 1013.0,
519 radon: Some(100),
520 radiation_rate: None,
521 radiation_total: None,
522 },
523 HistoryRecord {
524 timestamp: OffsetDateTime::now_utc(),
525 co2: 0,
526 temperature: 22.5,
527 humidity: 45,
528 pressure: 1013.0,
529 radon: Some(150),
530 radiation_rate: None,
531 radiation_total: None,
532 },
533 ];
534
535 let result = sparkline_data(&history, Some(DeviceType::AranetRadon));
536 assert_eq!(result, vec![100, 150]);
537 }
538
539 #[test]
540 fn test_sparkline_data_filters_zero_co2() {
541 use time::OffsetDateTime;
542
543 let history = vec![
544 HistoryRecord {
545 timestamp: OffsetDateTime::now_utc(),
546 co2: 0, temperature: 22.5,
548 humidity: 45,
549 pressure: 1013.0,
550 radon: None,
551 radiation_rate: None,
552 radiation_total: None,
553 },
554 HistoryRecord {
555 timestamp: OffsetDateTime::now_utc(),
556 co2: 800,
557 temperature: 22.5,
558 humidity: 45,
559 pressure: 1013.0,
560 radon: None,
561 radiation_rate: None,
562 radiation_total: None,
563 },
564 ];
565
566 let result = sparkline_data(&history, None);
567 assert_eq!(result, vec![800]); }
569}