/** * Per-page / per-topic AI Insight shell (FCR, weight, counting, IoT, …). * * Builds/scopes context, then loads the insight from the DB on mount * (`GET /ai-insights/cached/`). LLM generate runs only when the user clicks * Generate (force=true). No client memory/sessionStorage for insight text — * the DB row is the source of truth. * Distinct from `AiInsightCard` (Dashboard report modes). */ import React, { useState, useEffect, useRef } from 'react'; import { useFarm } from '../../context/FarmContext.tsx'; import { api, errorMessage } from '../../services/apiClient.ts'; import type { InsightApiResponse, InsightCitation, InsightContext, InsightJobStage, InsightTopic, } from '../../types/api.ts'; import { formatNumber, formatRatio } from '../../utils/format.ts'; import { getCobbStandardForDay } from '../../utils/insightHelpers.ts'; import { parseAiResult, type AiResult } from '../../utils/insightParse.ts'; import { scopeContextToTimeframe, scopeScalarsToTimeframe, describeTimeframe, UNAVAILABLE, type ScalarScopeSpec, } from '../../utils/insightScope.ts'; import InsightTrendChart, { type InsightSeries } from './InsightTrendChart.tsx'; import InsightConditionChart, { type ConditionBar } from './InsightConditionChart.tsx'; import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx'; import InsightProgressSteps from './InsightProgressSteps.tsx'; const CLIENT_TIMEOUT_MS = 1_200_000; // 20 minutes — NUC LLM can take several minutes /** Phase 4: poll async generate job until done / error / timeout / cancel. */ async function pollInsightJob( jobId: string, isCurrent: () => boolean, setStage: (s: InsightJobStage | null) => void ): Promise { const deadline = Date.now() + CLIENT_TIMEOUT_MS; for (;;) { if (!isCurrent()) return null; const job = await api.insights.jobStatus(jobId); if (!isCurrent()) return null; setStage(job.stage); if (job.status === 'done') return job.result; if (job.status === 'error') throw new Error(job.error || 'Gagal menghasilkan AI Insight.'); if (Date.now() > deadline) { throw new Error('TIMEOUT_LIMIT: generate insight melebihi batas waktu. Coba lagi nanti.'); } await new Promise((resolve) => setTimeout(resolve, 1000)); } } type PageInsightResult = AiResult & { citations?: InsightCitation[]; }; /** Dedupes concurrent soft DB loads for the same scope (not a content cache). */ const _insightInFlight = new Map>(); export type { InsightTopic }; export interface PageAiInsightProps { topic: InsightTopic; /** Curated context — must include kandangId (never null "all kandang"). */ contextData: InsightContext | Record; cacheKey: string; scalarScope?: ScalarScopeSpec; chartSpec?: | { type: 'trend'; historyKey: string; series: InsightSeries[]; unit?: string; title?: string; gap?: { actualField: string; targetField: string }; } | { type: 'condition'; bars: ConditionBar[]; title?: string }; } const TOPIC_META: Record = { hitung_ayam: { title: 'AI Insight — Hitung Ayam', icon: 'fa-kiwi-bird' }, berat_ayam: { title: 'AI Insight — Berat Ayam', icon: 'fa-weight-scale' }, fcr: { title: 'AI Insight — FCR', icon: 'fa-wheat-awn' }, eef: { title: 'AI Insight — EEF', icon: 'fa-chart-line' }, iot_panel: { title: 'AI Insight — Panel IoT', icon: 'fa-temperature-half' }, hitung_karung: { title: 'AI Insight — Hitung Karung', icon: 'fa-boxes-stacked' }, dashboard: { title: 'AI Insight — Dashboard', icon: 'fa-brain' }, }; const renderValue = (val: unknown, suffix: string = ''): string => { if (val === undefined || val === null || val === '') return 'N/A'; if (val === UNAVAILABLE) return UNAVAILABLE; 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 = typeof ctx.total_karung_masuk_iot === 'number' && Number.isFinite(ctx.total_karung_masuk_iot) ? ctx.total_karung_masuk_iot : null; const saldo = typeof ctx.saldo_awal_karung === 'number' && Number.isFinite(ctx.saldo_awal_karung) ? ctx.saldo_awal_karung : null; if (masuk === null && saldo === null) return null; return (masuk ?? 0) + (saldo ?? 0); }; const requireKandangId = (ctx: Record): number => { const raw = ctx.kandangId ?? ctx.kandang_id; const id = typeof raw === 'number' ? raw : Number(raw); if (!Number.isFinite(id) || id <= 0) { throw new Error('kandangId wajib — AI Insight tidak boleh digenerate untuk semua kandang'); } return id; }; const CitationsBlock: React.FC<{ citations?: InsightCitation[] }> = ({ citations }) => { if (!citations || citations.length === 0) return null; return (

Sumber referensi

    {citations.map((c, i) => (
  • {c.chapter || c.title || c.source || `Referensi ${i + 1}`} {c.excerpt ? — {c.excerpt} : null}
  • ))}
); }; export const PageAiInsight: React.FC = ({ topic, contextData, cacheKey, scalarScope, chartSpec, }) => { const { selectedCycle, selectedKandang, selectedSite, selectedKandangId } = useFarm(); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [stage, setStage] = useState(null); const supportsTimeframe = Boolean(scalarScope); const timeframe: 'harian' | 'mingguan' = 'harian'; const selectedDayKey = `insight-day-${topic}`; const [selectedDay, setSelectedDayState] = useState(() => { try { const raw = sessionStorage.getItem(selectedDayKey); const parsed = raw === null ? NaN : parseInt(raw, 10); return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } catch { return null; } }); const setSelectedDay = (next: number | null) => { setSelectedDayState(next); try { if (next === null) sessionStorage.removeItem(selectedDayKey); else sessionStorage.setItem(selectedDayKey, String(next)); } catch { /* ignore */ } }; const availableDays = React.useMemo(() => { const key = scalarScope?.historyKey ?? (chartSpec?.type === 'trend' ? chartSpec.historyKey : null); if (!key) return []; const rows = (contextData as Record)[key]; if (!Array.isArray(rows)) return []; const days = new Set(); for (const row of rows) { if (typeof row !== 'object' || row === null) continue; const r = row as Record; for (const field of ['hari', 'day']) { const value = r[field]; if (typeof value === 'number' && Number.isFinite(value)) days.add(value); } } return [...days].sort((a, b) => a - b); }, [contextData, scalarScope, chartSpec]); const contextDay: number | null = React.useMemo(() => { const raw = contextData as Record | null | undefined; if (!raw) return null; for (const key of ['hari_ke', 'hari', 'day', 'hari_terakhir']) { const val = raw[key]; if (typeof val === 'number' && Number.isFinite(val)) return val; } return null; }, [contextData]); const lastAvailableDay: number | null = availableDays.length > 0 ? availableDays[availableDays.length - 1]! : null; // Dropdown shows last available day when nothing is selected — generate/cache // must use that same day, not cycle.current_day (calendar can be ahead of data). const activeDay: number | null = timeframe === 'harian' && selectedDay !== null && availableDays.includes(selectedDay) ? selectedDay : null; const effectiveDay: number | null = timeframe === 'harian' ? (activeDay ?? lastAvailableDay ?? contextDay) : null; const scopeKey = `${cacheKey}-${timeframe}${effectiveDay === null ? '' : `-h${effectiveDay}`}`; const inFlightRef = useRef(false); const lastCacheKeyRef = useRef(null); const desiredKeyRef = useRef(''); const requestIdRef = useRef(0); const generateRef = useRef<(force?: boolean) => Promise>(async () => {}); const chartHighlight = React.useMemo(() => { if (!chartSpec || chartSpec.type !== 'trend') return null; const rows = (contextData as Record)[chartSpec.historyKey]; if (!Array.isArray(rows)) return null; const days = rows .map((row) => { const r = row as Record; for (const key of ['hari', 'day']) { const value = r[key]; if (typeof value === 'number' && Number.isFinite(value)) return value; } return null; }) .filter((day): day is number => day !== null); if (days.length === 0) return null; if (timeframe === 'harian' && effectiveDay !== null) { return { firstDay: 0, lastDay: effectiveDay }; } const lastDay = Math.max(...days); const firstDay = timeframe === 'harian' ? lastDay : Math.max(Math.min(...days), lastDay - 6); return { firstDay, lastDay }; }, [chartSpec, contextData, timeframe, effectiveDay]); const displayContext = React.useMemo(() => { const raw = contextData as Record; if (!scalarScope) return raw; const sliced = scopeContextToTimeframe(raw, timeframe, effectiveDay); const scoped = scopeScalarsToTimeframe( sliced as Record, timeframe, scalarScope, effectiveDay ); const out: Record = { ...raw }; for (const [outKey, rule] of Object.entries(scalarScope.derive)) { if (rule.replaces) out[rule.replaces] = scoped[outKey]; } for (const key of scalarScope.unavailable ?? []) out[key] = UNAVAILABLE; out.periode = scoped.periode; return out; }, [contextData, timeframe, effectiveDay, scalarScope]); const periodeLabel = typeof displayContext.periode === 'string' ? displayContext.periode : null; const trendChartRows = React.useMemo(() => { if (!chartSpec || chartSpec.type !== 'trend') return []; const rows = (contextData as Record)[chartSpec.historyKey]; if (!Array.isArray(rows)) return []; if (timeframe !== 'harian' || effectiveDay === null) { return rows as Array>; } return (rows as Array>).filter((row) => { for (const field of ['hari', 'day']) { const value = row[field]; if (typeof value === 'number' && Number.isFinite(value)) return value <= effectiveDay; } return true; }); }, [chartSpec, contextData, timeframe, effectiveDay]); const currentDay = selectedCycle?.current_day ?? null; const heroSpec = React.useMemo(() => { const n = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) ? value : null; const standardDay = effectiveDay ?? currentDay ?? null; const standard = standardDay ? getCobbStandardForDay(standardDay) : null; const dayLabel = standardDay ? `hari ke-${standardDay}` : null; if (topic === 'berat_ayam') { const actual = n(displayContext.averageWeight); const target = n(displayContext.targetWeight) ?? standard?.weight ?? null; if (actual === null) return null; const dev = target ? ((actual - target) / target) * 100 : null; return { label: 'Rata-rata Bobot Aktual', value: Math.round(actual).toLocaleString('id-ID'), unit: 'g', context: target ? `Target CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${Math.round(target).toLocaleString('id-ID')} g` : null, status: (dev === null ? 'unknown' : dev >= -5 ? 'ok' : dev >= -15 ? 'warning' : 'critical') as InsightStatus, deltaLabel: dev === null ? null : `${dev.toFixed(0)}% vs target`, }; } if (topic === 'fcr') { const actual = n(displayContext.fcr_terakhir); if (actual === null) return null; const std = standard?.fcr ?? null; return { label: 'FCR Aktual', value: formatRatio(actual, 3), unit: undefined, context: std ? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}` : null, status: (std === null ? 'unknown' : actual <= std ? 'ok' : actual <= std * 1.1 ? 'warning' : 'critical') as InsightStatus, deltaLabel: std === null ? null : `${actual > std ? '+' : ''}${formatRatio(actual - std, 3)} vs standar`, }; } if (topic === 'eef') { const actual = n(displayContext.eef_terakhir); if (actual === null) return null; const atHarvest = (currentDay ?? 0) >= 35; return { label: 'EEF', value: formatRatio(actual, 3), unit: undefined, context: atHarvest ? 'Standar umur panen: >300' : 'Belum umur panen — wajar masih rendah', status: (!atHarvest ? 'unknown' : actual >= 300 ? 'ok' : actual >= 250 ? 'warning' : 'critical') as InsightStatus, deltaLabel: atHarvest ? (actual >= 300 ? 'di atas standar' : 'di bawah standar') : null, }; } if (topic === 'iot_panel') { 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 (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: renderDayRange(lo, hi, ''), unit: '°C', context: setpoint === null ? null : `Setpoint: ${formatNumber(setpoint, 2, 0)} °C`, status: (worstDiff === null ? 'unknown' : worstDiff <= 1 ? 'ok' : worstDiff <= 2.5 ? 'warning' : 'critical') as InsightStatus, deltaLabel: signedWorst === null ? null : `${signedWorst > 0 ? '+' : ''}${formatNumber(signedWorst, 2, 0)} °C vs setpoint`, }; } if (topic === 'hitung_karung') { const actual = n(displayContext.total_karung_dituang_iot); if (actual === null) return null; const manual = n(displayContext.total_karung_dituang_manual); const perDay = n(displayContext.rata_karung_per_hari); const masukTotal = feedMasukTotal(displayContext); const parts = [ masukTotal === null ? null : `Masuk: ${masukTotal.toLocaleString('id-ID')} karung`, manual === null ? null : `Manual dituang: ${manual.toLocaleString('id-ID')} karung`, perDay === null ? null : `${perDay.toLocaleString('id-ID')} karung/hari`, ].filter(Boolean); return { label: 'Pakan Dituang (IOT)', value: actual.toLocaleString('id-ID'), unit: 'karung', context: parts.length > 0 ? parts.join(' · ') : null, status: 'unknown' as InsightStatus, deltaLabel: null, }; } if (topic === 'hitung_ayam') { const actual = n(displayContext.populasi_kini_ekor); if (actual === null) return null; const awal = n(displayContext.populasi_awal_ekor); const mortPct = n(displayContext.mortalitas_persen); return { label: 'Populasi Kini', value: Math.round(actual).toLocaleString('id-ID'), unit: 'ekor', context: awal === null ? null : `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`, status: (mortPct === null ? 'unknown' : mortPct < 5 ? 'ok' : mortPct <= 7 ? 'warning' : 'critical') as InsightStatus, deltaLabel: mortPct === null ? null : `Mortalitas ${mortPct.toLocaleString('id-ID', { maximumFractionDigits: 2 })}%`, }; } return null; }, [topic, displayContext, currentDay, effectiveDay]); const meta = TOPIC_META[topic] ?? TOPIC_META.dashboard; const generate = async (force = false) => { const storageKey = `page-insight-v1-${scopeKey}`; const requestId = ++requestIdRef.current; const isCurrent = () => requestIdRef.current === requestId; // Soft load: share one in-flight DB GET for the same scope (no client content cache). if (!force && _insightInFlight.has(storageKey)) { const shared = await _insightInFlight.get(storageKey); if (!isCurrent()) return; setResult(shared ?? null); setLoading(false); lastCacheKeyRef.current = scopeKey; return; } if (inFlightRef.current && !force) return; inFlightRef.current = true; // Only show the LLM loading UI when the user clicks Generate. if (force) { setLoading(true); setResult(null); } setError(null); const fetchPromise = (async (): Promise => { try { if (selectedKandangId == null && !(contextData as Record).kandangId) { throw new Error( 'Pilih kandang terlebih dahulu. AI Insight digenerate per kandang, bukan semua kandang.' ); } if (!selectedCycle?.id) { throw new Error('Data siklus tidak tersedia. Pastikan ada siklus aktif.'); } const rawCtx = contextData as Record; const kandangId = requireKandangId({ ...rawCtx, kandangId: rawCtx.kandangId ?? selectedKandangId, }); const rawCtxDay = effectiveDay ?? rawCtx.hari_ke ?? rawCtx.hari_terakhir ?? rawCtx.currentDay ?? currentDay; const maxDays = (rawCtx.totalDays as number | undefined) ?? selectedCycle.total_days; const currentDayVal = typeof rawCtxDay === 'number' && maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay; const slicedContextData = scopeContextToTimeframe(contextData, timeframe, effectiveDay); const scopedContextData = scalarScope ? scopeScalarsToTimeframe( slicedContextData as Record, timeframe, scalarScope, effectiveDay ) : slicedContextData; const dataPeriode = (scopedContextData as Record)?.periode; // Prefer explicit day-0..N wording when a day is selected (matches dashboard reports). const timeframeLabel = effectiveDay !== null ? describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay) : !supportsTimeframe ? 'Kondisi terkini (pembacaan sesaat, bukan rentang waktu)' : typeof dataPeriode === 'string' && dataPeriode.trim().length > 0 ? dataPeriode : describeTimeframe(timeframe, currentDayVal as number | null, effectiveDay); const reportPeriod = timeframe === 'harian' ? effectiveDay !== null ? `Hari ${effectiveDay}` : currentDayVal ? `Hari ${currentDayVal}` : null : null; const contextPayload: InsightContext = { ...(scopedContextData as InsightContext), kandangId, kandangName: (rawCtx.kandangName as string | undefined) ?? selectedKandang?.kandang_name ?? undefined, cycleId: selectedCycle.id, hari_ke: typeof effectiveDay === 'number' ? effectiveDay : typeof currentDayVal === 'number' ? currentDayVal : null, totalDays: maxDays ?? null, periode: timeframeLabel, }; let apiResult = null as Awaited> | null; if (!force) { try { apiResult = await api.insights.cached({ cycle_id: selectedCycle.id, kandang_id: kandangId, topic, report_type: 'page', report_period: reportPeriod, }); if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null; } catch { apiResult = null; } // Manual-generate only: never POST generate on mount / soft refresh. if (!apiResult) { if (!isCurrent()) return null; setResult(null); setLoading(false); lastCacheKeyRef.current = scopeKey; return null; } } else { setStage(null); const job = await api.insights.generateAsync({ cycle_id: selectedCycle.id, kandang_id: kandangId, topic, report_type: 'page', report_period: reportPeriod, context: contextPayload, force_refresh: true, }); apiResult = await pollInsightJob(job.job_id, isCurrent, setStage); } if (!isCurrent()) return null; if (!apiResult?.insight_text && !apiResult?.summary && !apiResult?.insight) { throw new Error(apiResult?.message || 'Gagal menghasilkan AI Insight.'); } const text = apiResult.insight_text ?? ''; let parsed: { summary: string; insight: string } | null = null; if (apiResult.summary || apiResult.insight) { parsed = { summary: apiResult.summary || '', insight: apiResult.insight || apiResult.insight_text || '', }; } else { parsed = parseAiResult(text); } if (!parsed || (!parsed.summary && !parsed.insight)) { throw new Error('Respons AI tidak dapat diproses.'); } const wrapped: PageInsightResult = { summary: parsed.summary, insight: parsed.insight, citations: apiResult.citations, }; if (!isCurrent()) return wrapped; setResult(wrapped); lastCacheKeyRef.current = scopeKey; return wrapped; } catch (err) { if (!isCurrent()) return null; console.error(`[PageAiInsight:${topic}] request failed:`, err); // Soft DB misses/errors should not look like a failed generate. if (force) { setError(errorMessage(err)); setResult(null); } else { setResult(null); } return null; } finally { if (isCurrent()) { setLoading(false); inFlightRef.current = false; } _insightInFlight.delete(storageKey); } })(); if (!force) { _insightInFlight.set(storageKey, fetchPromise); } await fetchPromise; }; useEffect(() => { desiredKeyRef.current = scopeKey; generateRef.current = generate; }); // Day / scope change: clear UI and soft-load from DB (no generate). useEffect(() => { if (lastCacheKeyRef.current === scopeKey) return; requestIdRef.current += 1; setResult(null); setError(null); setLoading(false); inFlightRef.current = false; const timer = setTimeout(() => generateRef.current(false), 50); return () => clearTimeout(timer); }, [scopeKey]); return (
{meta.title}

{selectedKandang?.kandang_name ?? 'Kandang'} {selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''} {effectiveDay != null ? ` · Hari ke-${effectiveDay}` : currentDay != null ? ` · Hari ke-${currentDay}` : ''}

{supportsTimeframe && timeframe === 'harian' && availableDays.length > 1 && ( )}
{selectedKandangId == null && (

Pilih satu kandang untuk menghasilkan AI Insight. Generate untuk semua kandang tidak diizinkan.

)} {loading && !result && (

Menganalisis data dengan model AI… proses ini bisa memakan waktu beberapa menit.

)} {error && !result && (

Gagal memuat insight

{error}

)} {!loading && !error && !result && selectedKandangId != null && (

Belum ada AI Insight untuk periode ini.

Klik tombol refresh untuk generate insight.

)} {result && (
{heroSpec && ( )}
{periodeLabel && (

Angka di bawah dihitung dari periode{' '} {periodeLabel}

)}
{topic === 'fcr' && ( <>
Konsumsi Pakan Kumulatif {renderValue(displayContext.pakan_karung_terakhir, 'Karung')}
Rata-rata Bobot {renderValue(displayContext.bobot_iot_gram_terakhir, 'g')}
Populasi Hidup {renderValue(displayContext.ayam_hidup_ekor, 'Ekor')}
Total Bobot Panen {renderValue(displayContext.beratPanen_kg, 'kg')}
)} {topic === 'berat_ayam' && ( <>
Target Bobot {renderValue(displayContext.targetWeight, 'g')}
Uniformity {renderValue(displayContext.uniformity, '%')}
Average Daily Gain (ADG) {renderValue(displayContext.adg, 'g/hari')}
)} {topic === 'eef' && ( <>
FCR Aktual {typeof displayContext.fcr_terakhir === 'number' ? formatRatio(displayContext.fcr_terakhir, 3) : renderValue(displayContext.fcr_terakhir)}
Persentase 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')}
)} {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 {renderDayRange( displayContext.kelembapan_min_persen ?? displayContext.kelembapan_persen, displayContext.kelembapan_max_persen ?? displayContext.kelembapan_persen, '%' )}
Kecepatan Angin {renderDayRange( displayContext.kecepatan_angin_min_mPerDetik ?? displayContext.kecepatan_angin_mPerDetik, displayContext.kecepatan_angin_max_mPerDetik ?? displayContext.kecepatan_angin_mPerDetik, 'm/s' )}
)} {topic === 'hitung_karung' && ( <>
Total Karung Masuk {(typeof displayContext.saldo_awal_karung === 'number' || typeof displayContext.total_karung_masuk_iot === 'number') && ( {[ typeof displayContext.saldo_awal_karung === 'number' ? `Saldo Awal: ${displayContext.saldo_awal_karung.toLocaleString('id-ID')}` : null, typeof displayContext.total_karung_masuk_iot === 'number' ? `Masuk IoT: ${displayContext.total_karung_masuk_iot.toLocaleString('id-ID')}` : null, ] .filter(Boolean) .join(' + ')} )}
{renderValue(feedMasukTotal(displayContext), 'Karung')}
Total Karung Pakan Dituang {renderValue(displayContext.total_karung_dituang_iot, 'Karung')}
)} {topic === 'hitung_ayam' && ( <>
Populasi Awal {renderValue(displayContext.populasi_awal_ekor, 'Ekor')}
Populasi Kini {renderValue(displayContext.populasi_kini_ekor, 'Ekor')}
Mortalitas Kumulatif {renderValue(displayContext.mortalitas_kumulatif_ekor, 'Ekor')}
Mortalitas {typeof displayContext.mortalitas_persen === 'number' ? `${displayContext.mortalitas_persen.toLocaleString('id-ID', { maximumFractionDigits: 2, })} %` : renderValue(displayContext.mortalitas_persen, '%')}
Panen Kumulatif {renderValue(displayContext.panen_kumulatif_ekor, 'Ekor')}
Berat Panen {renderValue(displayContext.berat_panen_kumulatif_kg, 'kg')}
)}
{chartSpec && (
{chartSpec.type === 'trend' ? ( ) : ( )}
)}

Kesimpulan

{result.summary}

AI Insight

{result.insight}

)}
); }; export default PageAiInsight;