diff --git a/components/AiInsightCard.tsx b/components/AiInsightCard.tsx index 5968a40..1d5d5fb 100644 --- a/components/AiInsightCard.tsx +++ b/components/AiInsightCard.tsx @@ -583,8 +583,11 @@ export const AiInsightCard: React.FC = ({ ? { firstDay: 1, lastDay: gradeDay } : null; + const hasInsight = + Boolean(insightText.trim()) || structuredInsight != null || endCycleInsight != null; + const renderVisualBlock = () => { - if (!scopedMetrics || page !== 'dashboard') return null; + if (!hasInsight || !scopedMetrics || page !== 'dashboard') return null; const fcrVal = scopedMetrics.fcr; const eefVal = scopedMetrics.eef; diff --git a/components/EefDashboardPage.tsx b/components/EefDashboardPage.tsx index 5834093..42c838d 100644 --- a/components/EefDashboardPage.tsx +++ b/components/EefDashboardPage.tsx @@ -330,6 +330,11 @@ const EefDashboardPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) agg: 'lastValid', replaces: 'bw_panen_gram', }, + umurPanenTerakhir_hari: { + field: 'umur_panen_hari', + agg: 'lastValid', + replaces: 'umur_panen_rata_hari', + }, }, }} /> diff --git a/components/IotPanelPage.tsx b/components/IotPanelPage.tsx index 0f7fe53..060df46 100644 --- a/components/IotPanelPage.tsx +++ b/components/IotPanelPage.tsx @@ -53,7 +53,11 @@ type DaySummary = { minExp: number; maxExp: number; humidity: number; + minHumidity: number; + maxHumidity: number; windSpeed: number; + minWind: number; + maxWind: number; waterTotal: number; }; @@ -198,7 +202,11 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { minExp: min('experience_temperature'), maxExp: max('experience_temperature'), humidity: last.humidity, + minHumidity: min('humidity'), + maxHumidity: max('humidity'), windSpeed: last.wind_speed, + minWind: min('wind_speed'), + maxWind: max('wind_speed'), waterTotal: last.water_total, }; }, [dayRows]); @@ -248,6 +256,12 @@ const IotPanelPage: React.FC<{ onSettings: () => void }> = ({ onSettings }) => { setpoint_C: getTempStandardByDay(dayAge), suhu_min_C: summary?.minTemp ?? null, suhu_max_C: summary?.maxTemp ?? null, + experience_suhu_min_C: summary?.minExp ?? null, + experience_suhu_max_C: summary?.maxExp ?? null, + kelembapan_min_persen: summary?.minHumidity ?? null, + kelembapan_max_persen: summary?.maxHumidity ?? null, + kecepatan_angin_min_mPerDetik: summary?.minWind ?? null, + kecepatan_angin_max_mPerDetik: summary?.maxWind ?? null, waktu_data: selectedDate || null, }); }, [selectedKandang, selectedCycle, summary, dayAge, selectedDate]); diff --git a/components/shared/PageAiInsight.tsx b/components/shared/PageAiInsight.tsx index e7a23a9..432ad46 100644 --- a/components/shared/PageAiInsight.tsx +++ b/components/shared/PageAiInsight.tsx @@ -74,6 +74,20 @@ const renderValue = (val: unknown, suffix: string = ''): string => { return `${val} ${suffix}`.trim(); }; +/** Format a day's min–max range for IoT panel insight metrics (no rounding). */ +const renderDayRange = (minVal: unknown, maxVal: unknown, suffix: string): string => { + const lo = typeof minVal === 'number' && Number.isFinite(minVal) ? minVal : null; + const hi = typeof maxVal === 'number' && Number.isFinite(maxVal) ? maxVal : null; + // Keep full precision — do not toFixed / cap fraction digits. + const fmt = (n: number) => + n.toLocaleString('id-ID', { maximumFractionDigits: 20, useGrouping: false }); + if (lo === null && hi === null) return 'N/A'; + if (lo === null) return `${fmt(hi!)} ${suffix}`.trim(); + if (hi === null) return `${fmt(lo)} ${suffix}`.trim(); + if (lo === hi) return `${fmt(lo)} ${suffix}`.trim(); + return `${fmt(lo)} – ${fmt(hi)} ${suffix}`.trim(); +}; + /** Match FeedSaldoCard: masuk IOT + saldo awal. */ const feedMasukTotal = (ctx: Record): number | null => { const masuk = @@ -330,24 +344,40 @@ export const PageAiInsight: React.FC = ({ } if (topic === 'iot_panel') { - const actual = n(displayContext.suhu_rata_rata_C); + const minTemp = n(displayContext.suhu_min_C); + const maxTemp = n(displayContext.suhu_max_C); + const lastTemp = n(displayContext.suhu_rata_rata_C); const setpoint = n(displayContext.setpoint_C); - if (actual === null) return null; - const diff = setpoint === null ? null : actual - setpoint; + if (minTemp === null && maxTemp === null && lastTemp === null) return null; + const lo = minTemp ?? lastTemp!; + const hi = maxTemp ?? lastTemp!; + // Grade against the farther extreme from setpoint so a bad stretch that day surfaces. + const worstDiff = + setpoint === null + ? null + : Math.max(Math.abs(lo - setpoint), Math.abs(hi - setpoint)); + const signedWorst = + setpoint === null + ? null + : Math.abs(lo - setpoint) >= Math.abs(hi - setpoint) + ? lo - setpoint + : hi - setpoint; return { label: 'Suhu Rata-rata Kandang', - value: actual.toLocaleString('id-ID'), + value: renderDayRange(lo, hi, ''), unit: '°C', context: setpoint === null ? null : `Setpoint: ${setpoint.toLocaleString('id-ID')} °C`, - status: (diff === null + status: (worstDiff === null ? 'unknown' - : Math.abs(diff) <= 1 + : worstDiff <= 1 ? 'ok' - : Math.abs(diff) <= 2.5 + : worstDiff <= 2.5 ? 'warning' : 'critical') as InsightStatus, deltaLabel: - diff === null ? null : `${diff > 0 ? '+' : ''}${diff.toFixed(1)} °C vs setpoint`, + signedWorst === null + ? null + : `${signedWorst > 0 ? '+' : ''}${signedWorst} °C vs setpoint`, }; } @@ -811,14 +841,6 @@ export const PageAiInsight: React.FC = ({ {topic === 'eef' && ( <> -
- EEF Aktual - - {typeof displayContext.eef_terakhir === 'number' - ? formatRatio(displayContext.eef_terakhir, 3) - : renderValue(displayContext.eef_terakhir)} - -
FCR Aktual @@ -830,7 +852,31 @@ export const PageAiInsight: React.FC = ({
Persentase Hidup - {renderValue(displayContext.persen_hidup, '%')} + {typeof displayContext.persen_hidup === 'number' + ? `${displayContext.persen_hidup.toLocaleString('id-ID', { + maximumFractionDigits: 2, + })} %` + : renderValue(displayContext.persen_hidup, '%')} + +
+
+ BW Rata-rata Panen + + {typeof displayContext.bw_panen_gram === 'number' + ? `${displayContext.bw_panen_gram.toLocaleString('id-ID', { + maximumFractionDigits: 2, + })} g` + : renderValue(displayContext.bw_panen_gram, 'g')} + +
+
+ Umur Rata-rata Panen + + {typeof displayContext.umur_panen_rata_hari === 'number' + ? `${displayContext.umur_panen_rata_hari.toLocaleString('id-ID', { + maximumFractionDigits: 2, + })} hari` + : renderValue(displayContext.umur_panen_rata_hari, 'hari')}
@@ -838,16 +884,40 @@ export const PageAiInsight: React.FC = ({ {topic === 'iot_panel' && ( <> +
+ Experience Suhu + + {renderDayRange( + displayContext.experience_suhu_min_C ?? + displayContext.experience_suhu_C, + displayContext.experience_suhu_max_C ?? + displayContext.experience_suhu_C, + '°C' + )} + +
Kelembapan - {renderValue(displayContext.kelembapan_persen, '%')} + {renderDayRange( + displayContext.kelembapan_min_persen ?? + displayContext.kelembapan_persen, + displayContext.kelembapan_max_persen ?? + displayContext.kelembapan_persen, + '%' + )}
Kecepatan Angin - {renderValue(displayContext.kecepatan_angin_mPerDetik, 'm/s')} + {renderDayRange( + displayContext.kecepatan_angin_min_mPerDetik ?? + displayContext.kecepatan_angin_mPerDetik, + displayContext.kecepatan_angin_max_mPerDetik ?? + displayContext.kecepatan_angin_mPerDetik, + 'm/s' + )}
diff --git a/utils/insightContextBuilders.ts b/utils/insightContextBuilders.ts index 4f8b8d7..f320775 100644 --- a/utils/insightContextBuilders.ts +++ b/utils/insightContextBuilders.ts @@ -4,6 +4,10 @@ import { getTargetWeightForAge } from './weight.ts'; const num = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; +/** Round to at most 2 decimal places (avoids float noise like 89.50399999999999). */ +const round2 = (value: number | null): number | null => + value === null ? null : Math.round(value * 100) / 100; + /** Positive finite number only — 0 / negative treated as missing sensor data. */ const positiveNum = (value: unknown): number | null => { const n = num(value); @@ -162,7 +166,7 @@ export function buildEefInsightContext(args: BuildEefInsightContextArgs): Insigh hari, eef: num(row.eef), fcr: num(row.fcr), - persen_hidup: num(row.chickenLifePercentage) ?? num(row.persen_hidup), + persen_hidup: round2(num(row.chickenLifePercentage) ?? num(row.persen_hidup)), bw_panen: num(row.averageHarvestBw) ?? num(row.bw_panen_gram), umur_panen_hari: num(row.averageHarvestDay), }; @@ -180,7 +184,7 @@ export function buildEefInsightContext(args: BuildEefInsightContextArgs): Insigh totalDays: args.totalDays ?? null, eef_terakhir: args.eef_terakhir ?? latest?.eef ?? null, fcr_terakhir: args.fcr_terakhir ?? latest?.fcr ?? null, - persen_hidup: args.persen_hidup ?? latest?.persen_hidup ?? null, + persen_hidup: round2(args.persen_hidup ?? latest?.persen_hidup ?? null), bw_panen_gram: args.bw_panen_gram ?? latest?.bw_panen ?? null, umur_panen_rata_hari: args.umur_panen_rata_hari ?? latest?.umur_panen_hari ?? null, tren_eef_harian: tren, @@ -273,6 +277,12 @@ export type BuildIotInsightContextArgs = { setpoint_C?: number | null; suhu_min_C?: number | null; suhu_max_C?: number | null; + experience_suhu_min_C?: number | null; + experience_suhu_max_C?: number | null; + kelembapan_min_persen?: number | null; + kelembapan_max_persen?: number | null; + kecepatan_angin_min_mPerDetik?: number | null; + kecepatan_angin_max_mPerDetik?: number | null; waktu_data?: string | null; }; @@ -293,6 +303,12 @@ export function buildIotInsightContext(args: BuildIotInsightContextArgs): Insigh setpoint_C: args.setpoint_C ?? null, suhu_min_C: args.suhu_min_C ?? null, suhu_max_C: args.suhu_max_C ?? null, + experience_suhu_min_C: args.experience_suhu_min_C ?? null, + experience_suhu_max_C: args.experience_suhu_max_C ?? null, + kelembapan_min_persen: args.kelembapan_min_persen ?? null, + kelembapan_max_persen: args.kelembapan_max_persen ?? null, + kecepatan_angin_min_mPerDetik: args.kecepatan_angin_min_mPerDetik ?? null, + kecepatan_angin_max_mPerDetik: args.kecepatan_angin_max_mPerDetik ?? null, waktu_data: args.waktu_data ?? null, }; }