AMSHistoryModal.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. import { useState, useEffect } from 'react';
  2. import { useQuery } from '@tanstack/react-query';
  3. import { X, Droplets, Thermometer, TrendingUp, TrendingDown, Minus } from 'lucide-react';
  4. import {
  5. LineChart,
  6. Line,
  7. XAxis,
  8. YAxis,
  9. CartesianGrid,
  10. Tooltip,
  11. ResponsiveContainer,
  12. Legend,
  13. ReferenceLine,
  14. } from 'recharts';
  15. import { api, type AMSHistoryResponse } from '../api/client';
  16. import { parseUTCDate, applyTimeFormat, type TimeFormat } from '../utils/date';
  17. import { useTranslation } from 'react-i18next';
  18. import { useUiSettings } from '../hooks/useUiSettings';
  19. interface AMSHistoryModalProps {
  20. isOpen: boolean;
  21. onClose: () => void;
  22. printerId: number;
  23. printerName: string;
  24. amsId: number;
  25. amsLabel: string;
  26. initialMode?: 'humidity' | 'temperature';
  27. thresholds?: {
  28. humidityGood: number;
  29. humidityFair: number;
  30. tempGood: number;
  31. tempFair: number;
  32. };
  33. }
  34. type TimeRange = '6h' | '24h' | '48h' | '7d';
  35. const TIME_RANGES: { value: TimeRange; label: string; hours: number }[] = [
  36. { value: '6h', label: '6h', hours: 6 },
  37. { value: '24h', label: '24h', hours: 24 },
  38. { value: '48h', label: '48h', hours: 48 },
  39. { value: '7d', label: '7d', hours: 168 },
  40. ];
  41. export function AMSHistoryModal({
  42. isOpen,
  43. onClose,
  44. printerId,
  45. printerName,
  46. amsId,
  47. amsLabel,
  48. initialMode = 'humidity',
  49. thresholds,
  50. }: AMSHistoryModalProps) {
  51. const { t } = useTranslation();
  52. const [timeRange, setTimeRange] = useState<TimeRange>('24h');
  53. const [mode, setMode] = useState<'humidity' | 'temperature'>(initialMode);
  54. const { data: settings } = useUiSettings();
  55. const timeFormat: TimeFormat = settings?.time_format || 'system';
  56. // Close on Escape key
  57. useEffect(() => {
  58. if (!isOpen) return;
  59. const handleKeyDown = (e: KeyboardEvent) => {
  60. if (e.key === 'Escape') onClose();
  61. };
  62. window.addEventListener('keydown', handleKeyDown);
  63. return () => window.removeEventListener('keydown', handleKeyDown);
  64. }, [isOpen, onClose]);
  65. const hours = TIME_RANGES.find(r => r.value === timeRange)?.hours || 24;
  66. const { data, isLoading, error } = useQuery<AMSHistoryResponse>({
  67. queryKey: ['ams-history', printerId, amsId, hours],
  68. queryFn: () => api.getAMSHistory(printerId, amsId, hours),
  69. enabled: isOpen,
  70. refetchInterval: 60000, // Refresh every minute
  71. });
  72. if (!isOpen) return null;
  73. // Format data for chart
  74. const rawPoints = data?.data.map(point => {
  75. const date = parseUTCDate(point.recorded_at) || new Date();
  76. return {
  77. time: date.getTime(),
  78. humidity: point.humidity,
  79. temperature: point.temperature,
  80. };
  81. }) || [];
  82. // Pad edges so the line extends across the full time window
  83. const domainStart = Date.now() - hours * 60 * 60 * 1000;
  84. const domainEnd = Date.now();
  85. const chartData = [...rawPoints];
  86. if (chartData.length > 0) {
  87. const first = chartData[0];
  88. if (first.time > domainStart) {
  89. chartData.unshift({ ...first, time: domainStart });
  90. }
  91. const last = chartData[chartData.length - 1];
  92. if (last.time < domainEnd) {
  93. chartData.push({ ...last, time: domainEnd });
  94. }
  95. }
  96. // Get thresholds
  97. const humidityGood = thresholds?.humidityGood || 40;
  98. const humidityFair = thresholds?.humidityFair || 60;
  99. const tempGood = thresholds?.tempGood || 30;
  100. const tempFair = thresholds?.tempFair || 35;
  101. // Current values (last data point)
  102. const lastPoint = chartData[chartData.length - 1];
  103. const currentHumidity = lastPoint?.humidity;
  104. const currentTemp = lastPoint?.temperature;
  105. // Trend calculation (compare first and last 20% of data)
  106. const getTrend = (values: (number | null)[]) => {
  107. const filtered = values.filter((v): v is number => v != null);
  108. if (filtered.length < 4) return 'stable';
  109. const firstQuarter = filtered.slice(0, Math.floor(filtered.length / 4));
  110. const lastQuarter = filtered.slice(-Math.floor(filtered.length / 4));
  111. const firstAvg = firstQuarter.reduce((a, b) => a + b, 0) / firstQuarter.length;
  112. const lastAvg = lastQuarter.reduce((a, b) => a + b, 0) / lastQuarter.length;
  113. const diff = lastAvg - firstAvg;
  114. if (Math.abs(diff) < 2) return 'stable';
  115. return diff > 0 ? 'up' : 'down';
  116. };
  117. const humidityTrend = getTrend(chartData.map(d => d.humidity));
  118. const tempTrend = getTrend(chartData.map(d => d.temperature));
  119. const TrendIcon = ({ trend }: { trend: string }) => {
  120. if (trend === 'up') return <TrendingUp className="w-4 h-4 text-red-600 dark:text-red-400" />;
  121. if (trend === 'down') return <TrendingDown className="w-4 h-4 text-green-600 dark:text-green-400" />;
  122. return <Minus className="w-4 h-4 text-gray-400 dark:text-bambu-gray" />;
  123. };
  124. // Get status color for current value
  125. const getHumidityColor = (value: number | undefined | null) => {
  126. if (value == null) return '#9ca3af';
  127. if (value <= humidityGood) return '#22a352';
  128. if (value <= humidityFair) return '#d4a017';
  129. return '#c62828';
  130. };
  131. const getTempColor = (value: number | undefined | null) => {
  132. if (value == null) return '#9ca3af';
  133. if (value <= tempGood) return '#22a352';
  134. if (value <= tempFair) return '#d4a017';
  135. return '#c62828';
  136. };
  137. // Theme-aware styles via CSS variables so the modal follows the active
  138. // background variant (neutral / warm / cool / oled / slate / forest), not
  139. // just light vs dark. See src/index.css for the variable definitions.
  140. const modalBg = 'var(--bg-secondary)';
  141. const cardBg = 'var(--bg-primary)';
  142. const borderColor = 'var(--border-color)';
  143. const textPrimary = 'var(--text-primary)';
  144. const textSecondary = 'var(--text-secondary)';
  145. const axisColor = 'var(--text-muted)';
  146. return (
  147. <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
  148. <div
  149. className="rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-xl"
  150. style={{ backgroundColor: modalBg }}
  151. onClick={e => e.stopPropagation()}
  152. >
  153. {/* Header */}
  154. <div
  155. className="flex items-center justify-between px-6 py-4 border-b"
  156. style={{ borderColor }}
  157. >
  158. <div>
  159. <h2 className="text-lg font-semibold" style={{ color: textPrimary }}>
  160. {amsLabel} {t('common.history', 'History')}
  161. </h2>
  162. <p className="text-sm" style={{ color: textSecondary }}>{printerName}</p>
  163. </div>
  164. <button
  165. onClick={onClose}
  166. className="p-2 rounded-lg transition-colors"
  167. style={{ color: textSecondary }}
  168. >
  169. <X className="w-5 h-5" />
  170. </button>
  171. </div>
  172. {/* Content */}
  173. <div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]">
  174. {/* Time Range & Mode Selector */}
  175. <div className="flex items-center justify-between max-[550px]:flex-col max-[550px]:items-start max-[550px]:gap-3">
  176. <div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
  177. <button
  178. onClick={() => setMode('humidity')}
  179. className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
  180. mode === 'humidity' ? 'bg-blue-600 text-white' : ''
  181. }`}
  182. style={mode !== 'humidity' ? { color: textSecondary } : undefined}
  183. >
  184. <Droplets className="w-4 h-4" />
  185. {t('common.humidity', 'Humidity')}
  186. </button>
  187. <button
  188. onClick={() => setMode('temperature')}
  189. className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${
  190. mode === 'temperature' ? 'bg-orange-600 text-white' : ''
  191. }`}
  192. style={mode !== 'temperature' ? { color: textSecondary } : undefined}
  193. >
  194. <Thermometer className="w-4 h-4" />
  195. {t('common.temperature', 'Temperature')}
  196. </button>
  197. </div>
  198. <div className="inline-flex gap-1 rounded-lg p-1 max-w-full flex-wrap w-fit" style={{ backgroundColor: cardBg }}>
  199. {TIME_RANGES.map(range => (
  200. <button
  201. key={range.value}
  202. onClick={() => setTimeRange(range.value)}
  203. className={`px-3 py-1 text-sm rounded-md transition-colors ${
  204. timeRange === range.value ? 'bg-bambu-green text-white' : ''
  205. }`}
  206. style={timeRange !== range.value ? { color: textSecondary } : undefined}
  207. >
  208. {range.label}
  209. </button>
  210. ))}
  211. </div>
  212. </div>
  213. {/* Stats Cards */}
  214. <div className="grid grid-cols-4 gap-4 max-[550px]:grid-cols-2">
  215. {mode === 'humidity' ? (
  216. <>
  217. <div className="rounded-lg p-4 max-[550px]:order-2" style={{ backgroundColor: cardBg }}>
  218. <p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
  219. <div className="flex items-center gap-2">
  220. <p className="text-2xl font-bold" style={{ color: getHumidityColor(currentHumidity) }}>
  221. {currentHumidity != null ? `${currentHumidity}%` : '—'}
  222. </p>
  223. <TrendIcon trend={humidityTrend} />
  224. </div>
  225. </div>
  226. <div className="rounded-lg p-4 max-[550px]:order-4" style={{ backgroundColor: cardBg }}>
  227. <p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
  228. <p className="text-2xl font-bold" style={{ color: textPrimary }}>
  229. {data?.avg_humidity != null ? `${data.avg_humidity}%` : '—'}
  230. </p>
  231. </div>
  232. <div className="rounded-lg p-4 max-[550px]:order-1" style={{ backgroundColor: cardBg }}>
  233. <p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
  234. <p className="text-2xl font-bold text-green-500">
  235. {data?.min_humidity != null ? `${data.min_humidity}%` : '—'}
  236. </p>
  237. </div>
  238. <div className="rounded-lg p-4 max-[550px]:order-3" style={{ backgroundColor: cardBg }}>
  239. <p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
  240. <p className="text-2xl font-bold text-red-500">
  241. {data?.max_humidity != null ? `${data.max_humidity}%` : '—'}
  242. </p>
  243. </div>
  244. </>
  245. ) : (
  246. <>
  247. <div className="rounded-lg p-4 max-[550px]:order-2" style={{ backgroundColor: cardBg }}>
  248. <p className="text-xs" style={{ color: textSecondary }}>{t('common.current', 'Current')}</p>
  249. <div className="flex items-center gap-2">
  250. <p className="text-2xl font-bold" style={{ color: getTempColor(currentTemp) }}>
  251. {currentTemp != null ? `${currentTemp}°C` : '—'}
  252. </p>
  253. <TrendIcon trend={tempTrend} />
  254. </div>
  255. </div>
  256. <div className="rounded-lg p-4 max-[550px]:order-4" style={{ backgroundColor: cardBg }}>
  257. <p className="text-xs" style={{ color: textSecondary }}>{t('common.average', 'Average')}</p>
  258. <p className="text-2xl font-bold" style={{ color: textPrimary }}>
  259. {data?.avg_temperature != null ? `${data.avg_temperature}°C` : '—'}
  260. </p>
  261. </div>
  262. <div className="rounded-lg p-4 max-[550px]:order-1" style={{ backgroundColor: cardBg }}>
  263. <p className="text-xs" style={{ color: textSecondary }}>{t('common.min', 'Min')}</p>
  264. <p className="text-2xl font-bold text-blue-500">
  265. {data?.min_temperature != null ? `${data.min_temperature}°C` : '—'}
  266. </p>
  267. </div>
  268. <div className="rounded-lg p-4 max-[550px]:order-3" style={{ backgroundColor: cardBg }}>
  269. <p className="text-xs" style={{ color: textSecondary }}>{t('common.max', 'Max')}</p>
  270. <p className="text-2xl font-bold text-red-500">
  271. {data?.max_temperature != null ? `${data.max_temperature}°C` : '—'}
  272. </p>
  273. </div>
  274. </>
  275. )}
  276. </div>
  277. {/* Chart */}
  278. <div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}>
  279. {isLoading ? (
  280. <div className="h-[300px] flex items-center justify-center" style={{ color: textSecondary }}>
  281. {t('common.loading', 'Loading...')}
  282. </div>
  283. ) : error ? (
  284. <div className="h-[300px] flex items-center justify-center text-red-500">
  285. {t('common.error', 'Error loading data')}
  286. </div>
  287. ) : chartData.length === 0 ? (
  288. <div className="h-[300px] flex items-center justify-center" style={{ color: textSecondary }}>
  289. {t('common.noData', 'No data available for this time range')}
  290. </div>
  291. ) : (
  292. <ResponsiveContainer width="100%" height={300}>
  293. <LineChart data={chartData}>
  294. <CartesianGrid strokeDasharray="3 3" stroke={borderColor} />
  295. <XAxis
  296. dataKey="time"
  297. type="number"
  298. domain={[Date.now() - hours * 60 * 60 * 1000, Date.now()]}
  299. tickFormatter={(ts) => {
  300. const date = new Date(ts);
  301. if (hours > 24) {
  302. return date.toLocaleDateString([], { day: 'numeric', month: 'short' });
  303. }
  304. return date.toLocaleTimeString([], applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat));
  305. }}
  306. stroke={axisColor}
  307. tick={{ fontSize: 12 }}
  308. />
  309. <YAxis
  310. stroke={axisColor}
  311. tick={{ fontSize: 12 }}
  312. domain={mode === 'humidity' ? [0, 100] : ['auto', 'auto']}
  313. tickFormatter={(value) => mode === 'humidity' ? `${value}%` : `${value}°C`}
  314. />
  315. <Tooltip
  316. contentStyle={{
  317. backgroundColor: modalBg,
  318. border: `1px solid ${borderColor}`,
  319. borderRadius: '8px',
  320. color: textPrimary,
  321. }}
  322. labelFormatter={(ts) => new Date(ts).toLocaleString(undefined, applyTimeFormat({
  323. year: 'numeric',
  324. month: 'short',
  325. day: 'numeric',
  326. hour: '2-digit',
  327. minute: '2-digit',
  328. }, timeFormat))}
  329. formatter={(value) => [
  330. mode === 'humidity' ? `${value ?? 0}%` : `${value ?? 0}°C`,
  331. mode === 'humidity' ? 'Humidity' : 'Temperature'
  332. ]}
  333. />
  334. <Legend />
  335. {/* Threshold lines */}
  336. {mode === 'humidity' ? (
  337. <>
  338. <ReferenceLine y={humidityGood} stroke="#22a352" strokeDasharray="5 5" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} />
  339. <ReferenceLine y={humidityFair} stroke="#d4a017" strokeDasharray="5 5" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} />
  340. </>
  341. ) : (
  342. <>
  343. <ReferenceLine y={tempGood} stroke="#22a352" strokeDasharray="5 5" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} />
  344. <ReferenceLine y={tempFair} stroke="#d4a017" strokeDasharray="5 5" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} />
  345. </>
  346. )}
  347. <Line
  348. type="monotone"
  349. dataKey={mode}
  350. name={mode === 'humidity' ? 'Humidity' : 'Temperature'}
  351. stroke={mode === 'humidity' ? '#3b82f6' : '#f97316'}
  352. strokeWidth={2}
  353. dot={false}
  354. activeDot={{ r: 4 }}
  355. connectNulls={true}
  356. />
  357. </LineChart>
  358. </ResponsiveContainer>
  359. )}
  360. </div>
  361. {/* Info */}
  362. <div className="text-xs text-center" style={{ color: textSecondary }}>
  363. {t('amsHistory.recordingInfo', 'Data is recorded every 5 minutes while the printer is connected')}
  364. </div>
  365. </div>
  366. </div>
  367. </div>
  368. );
  369. }