ai insight rework #1
This commit is contained in:
1 parent
0d960c8153
commit
9d76f54d33
13 files changed
+730
-299
No files matched your search
+215
-221
@@ -7,12 +7,14 @@
|
||||
* the DB row is the source of truth.
|
||||
* Distinct from `AiInsightCard` (Dashboard report modes).
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
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 { formatRatio } from '../../utils/format.ts';
|
||||
@@ -28,9 +30,31 @@ import {
|
||||
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<InsightApiResponse | null> {
|
||||
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[];
|
||||
};
|
||||
@@ -144,6 +168,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
const [result, setResult] = useState<PageInsightResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState<InsightJobStage | null>(null);
|
||||
const supportsTimeframe = Boolean(scalarScope);
|
||||
const timeframe: 'harian' | 'mingguan' = 'harian';
|
||||
|
||||
@@ -157,18 +182,15 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const setSelectedDay = useCallback(
|
||||
(next: number | null) => {
|
||||
setSelectedDayState(next);
|
||||
try {
|
||||
if (next === null) sessionStorage.removeItem(selectedDayKey);
|
||||
else sessionStorage.setItem(selectedDayKey, String(next));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[selectedDayKey]
|
||||
);
|
||||
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 =
|
||||
@@ -308,7 +330,9 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
label: 'FCR Aktual',
|
||||
value: formatRatio(actual, 3),
|
||||
unit: undefined,
|
||||
context: std ? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}` : null,
|
||||
context: std
|
||||
? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatRatio(std, 3)}`
|
||||
: null,
|
||||
status: (std === null
|
||||
? 'unknown'
|
||||
: actual <= std
|
||||
@@ -353,9 +377,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
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));
|
||||
setpoint === null ? null : Math.max(Math.abs(lo - setpoint), Math.abs(hi - setpoint));
|
||||
const signedWorst =
|
||||
setpoint === null
|
||||
? null
|
||||
@@ -412,9 +434,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
value: Math.round(actual).toLocaleString('id-ID'),
|
||||
unit: 'ekor',
|
||||
context:
|
||||
awal === null
|
||||
? null
|
||||
: `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`,
|
||||
awal === null ? null : `Populasi awal: ${Math.round(awal).toLocaleString('id-ID')} ekor`,
|
||||
status: (mortPct === null
|
||||
? 'unknown'
|
||||
: mortPct < 5
|
||||
@@ -434,221 +454,192 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
|
||||
const meta = TOPIC_META[topic] ?? TOPIC_META.dashboard;
|
||||
|
||||
const generate = useCallback(
|
||||
async (force = false) => {
|
||||
const storageKey = `page-insight-v1-${scopeKey}`;
|
||||
const requestId = ++requestIdRef.current;
|
||||
const isCurrent = () => requestIdRef.current === requestId;
|
||||
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;
|
||||
}
|
||||
// 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);
|
||||
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<PageInsightResult | null> => {
|
||||
try {
|
||||
if (selectedKandangId == null && !(contextData as Record<string, unknown>).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 fetchPromise = (async (): Promise<PageInsightResult | null> => {
|
||||
try {
|
||||
if (selectedKandangId == null && !(contextData as Record<string, unknown>).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<string, unknown>;
|
||||
const kandangId = requireKandangId({
|
||||
...rawCtx,
|
||||
kandangId: rawCtx.kandangId ?? selectedKandangId,
|
||||
});
|
||||
const rawCtx = contextData as Record<string, unknown>;
|
||||
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 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<string, unknown>,
|
||||
timeframe,
|
||||
scalarScope,
|
||||
effectiveDay
|
||||
)
|
||||
: slicedContextData;
|
||||
const slicedContextData = scopeContextToTimeframe(contextData, timeframe, effectiveDay);
|
||||
const scopedContextData = scalarScope
|
||||
? scopeScalarsToTimeframe(
|
||||
slicedContextData as Record<string, unknown>,
|
||||
timeframe,
|
||||
scalarScope,
|
||||
effectiveDay
|
||||
)
|
||||
: slicedContextData;
|
||||
|
||||
const dataPeriode = (scopedContextData as Record<string, unknown>)?.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 dataPeriode = (scopedContextData as Record<string, unknown>)?.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 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,
|
||||
};
|
||||
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<ReturnType<typeof api.insights.generate>> | null;
|
||||
let apiResult = null as Awaited<ReturnType<typeof api.insights.generate>> | 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 {
|
||||
const generateCall = api.insights.generate({
|
||||
if (!force) {
|
||||
try {
|
||||
apiResult = await api.insights.cached({
|
||||
cycle_id: selectedCycle.id,
|
||||
kandang_id: kandangId,
|
||||
topic,
|
||||
report_type: 'page',
|
||||
report_period: reportPeriod,
|
||||
context: contextPayload,
|
||||
force_refresh: true,
|
||||
});
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
'TIMEOUT_LIMIT: generate insight melebihi batas waktu. Coba lagi nanti.'
|
||||
)
|
||||
),
|
||||
CLIENT_TIMEOUT_MS
|
||||
)
|
||||
);
|
||||
apiResult = await Promise.race([generateCall, timeoutPromise]);
|
||||
if (!apiResult?.insight_text && !apiResult?.summary) apiResult = null;
|
||||
} catch {
|
||||
apiResult = null;
|
||||
}
|
||||
|
||||
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 ?? '';
|
||||
const parsed =
|
||||
apiResult.summary || apiResult.insight
|
||||
? {
|
||||
summary: apiResult.summary || '',
|
||||
insight: apiResult.insight || '',
|
||||
}
|
||||
: 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));
|
||||
// Manual-generate only: never POST generate on mount / soft refresh.
|
||||
if (!apiResult) {
|
||||
if (!isCurrent()) return null;
|
||||
setResult(null);
|
||||
} else {
|
||||
setResult(null);
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
setLoading(false);
|
||||
inFlightRef.current = false;
|
||||
lastCacheKeyRef.current = scopeKey;
|
||||
return null;
|
||||
}
|
||||
_insightInFlight.delete(storageKey);
|
||||
} 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 (!force) {
|
||||
_insightInFlight.set(storageKey, fetchPromise);
|
||||
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 ?? '';
|
||||
const parsed =
|
||||
apiResult.summary || apiResult.insight
|
||||
? {
|
||||
summary: apiResult.summary || '',
|
||||
insight: apiResult.insight || '',
|
||||
}
|
||||
: 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);
|
||||
}
|
||||
await fetchPromise;
|
||||
},
|
||||
[
|
||||
topic,
|
||||
contextData,
|
||||
selectedCycle,
|
||||
selectedKandang,
|
||||
selectedKandangId,
|
||||
currentDay,
|
||||
timeframe,
|
||||
effectiveDay,
|
||||
scalarScope,
|
||||
supportsTimeframe,
|
||||
scopeKey,
|
||||
]
|
||||
);
|
||||
})();
|
||||
|
||||
if (!force) {
|
||||
_insightInFlight.set(storageKey, fetchPromise);
|
||||
}
|
||||
await fetchPromise;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
desiredKeyRef.current = scopeKey;
|
||||
@@ -679,7 +670,11 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
<p className="text-xs text-gray-400 truncate">
|
||||
{selectedKandang?.kandang_name ?? 'Kandang'}
|
||||
{selectedSite?.site_name ? ` · ${selectedSite.site_name}` : ''}
|
||||
{effectiveDay != null ? ` · Hari ke-${effectiveDay}` : currentDay != null ? ` · Hari ke-${currentDay}` : ''}
|
||||
{effectiveDay != null
|
||||
? ` · Hari ke-${effectiveDay}`
|
||||
: currentDay != null
|
||||
? ` · Hari ke-${currentDay}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -725,6 +720,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
{loading && !result && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-4 border-gray-100 border-t-red-600 animate-spin" />
|
||||
<InsightProgressSteps stage={stage} />
|
||||
<p className="text-sm text-gray-500 text-center max-w-sm">
|
||||
Menganalisis data dengan model AI… proses ini bisa memakan waktu beberapa menit.
|
||||
</p>
|
||||
@@ -870,7 +866,9 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-1 border-b border-gray-50">
|
||||
<span className="text-sm text-gray-600 font-medium">Umur Rata-rata Panen</span>
|
||||
<span className="text-sm text-gray-600 font-medium">
|
||||
Umur Rata-rata Panen
|
||||
</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{typeof displayContext.umur_panen_rata_hari === 'number'
|
||||
? `${displayContext.umur_panen_rata_hari.toLocaleString('id-ID', {
|
||||
@@ -888,10 +886,8 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
<span className="text-sm text-gray-600 font-medium">Experience Suhu</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderDayRange(
|
||||
displayContext.experience_suhu_min_C ??
|
||||
displayContext.experience_suhu_C,
|
||||
displayContext.experience_suhu_max_C ??
|
||||
displayContext.experience_suhu_C,
|
||||
displayContext.experience_suhu_min_C ?? displayContext.experience_suhu_C,
|
||||
displayContext.experience_suhu_max_C ?? displayContext.experience_suhu_C,
|
||||
'°C'
|
||||
)}
|
||||
</span>
|
||||
@@ -900,10 +896,8 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
|
||||
<span className="text-sm text-gray-600 font-medium">Kelembapan</span>
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{renderDayRange(
|
||||
displayContext.kelembapan_min_persen ??
|
||||
displayContext.kelembapan_persen,
|
||||
displayContext.kelembapan_max_persen ??
|
||||
displayContext.kelembapan_persen,
|
||||
displayContext.kelembapan_min_persen ?? displayContext.kelembapan_persen,
|
||||
displayContext.kelembapan_max_persen ?? displayContext.kelembapan_persen,
|
||||
'%'
|
||||
)}
|
||||
</span>
|
||||
|
||||
Reference in new issue
Block a user