Implement uniformity handling in dashboard insights and AI components. Introduced numberOrZero function to ensure proper reporting of uniformity and average weight, treating days without weighing as zero instead of unavailable. Updated AiInsightCard and WeightMonitoringPageRevamped to reflect these changes, ensuring consistent data representation across components. Adjusted context management to align with new uniformity logic.
CI / lint-and-test (push) Canceled after 0s
CI / lint-and-test (push) Canceled after 0s
This commit is contained in:
1 parent
427cac54a1
commit
059087be1d
5 files changed
+120
-78
No files matched your search
@@ -1028,6 +1028,13 @@ const rescopeScalarsToPeriod = (pack, reportType = 'daily', reportPeriod = null)
|
|||||||
|
|
||||||
const round2 = (value) => Math.round(value * 100) / 100;
|
const round2 = (value) => Math.round(value * 100) / 100;
|
||||||
|
|
||||||
|
// Uniformity is reported as a real 0 when the day has no weighing, so it
|
||||||
|
// must not go through the null-ing helpers the other scalars use.
|
||||||
|
const numberOrZero = (value) => {
|
||||||
|
const n = typeof value?.value === 'number' ? value.value : value;
|
||||||
|
return typeof n === 'number' && Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
|
||||||
// `cleanPromptValue` strips nulls out of the prompt entirely, so a null scalar
|
// `cleanPromptValue` strips nulls out of the prompt entirely, so a null scalar
|
||||||
// reaches the model as a MISSING key — indistinguishable from a field that was
|
// reaches the model as a MISSING key — indistinguishable from a field that was
|
||||||
// never part of the schema, and easy to fill in from imagination. An explicit
|
// never part of the schema, and easy to fill in from imagination. An explicit
|
||||||
@@ -1097,7 +1104,9 @@ const rescopeScalarsToPeriod = (pack, reportType = 'daily', reportPeriod = null)
|
|||||||
// `deviation` plus a separate `satuan` map, the model quoted "-4 gram" for
|
// `deviation` plus a separate `satuan` map, the model quoted "-4 gram" for
|
||||||
// a value that was -4 PERCENT. A key it cannot read without reading the
|
// a value that was -4 PERCENT. A key it cannot read without reading the
|
||||||
// unit removes the choice.
|
// unit removes the choice.
|
||||||
bobotRataRata_gram: orUnavailable(averageWeight),
|
// Bobot mengikuti aturan yang sama dengan uniformity: hari tanpa
|
||||||
|
// penimbangan dilaporkan 0, bukan "tidak tersedia".
|
||||||
|
bobotRataRata_gram: numberOrZero(averageWeight),
|
||||||
targetBobot_gram: orUnavailable(targetWeight),
|
targetBobot_gram: orUnavailable(targetWeight),
|
||||||
deviasiBobot_gram: orUnavailable(deviationGram),
|
deviasiBobot_gram: orUnavailable(deviationGram),
|
||||||
deviasiBobot_persen: orUnavailable(deviationPersen),
|
deviasiBobot_persen: orUnavailable(deviationPersen),
|
||||||
@@ -1108,8 +1117,12 @@ const rescopeScalarsToPeriod = (pack, reportType = 'daily', reportPeriod = null)
|
|||||||
// No day-indexed source exists for these, so they cannot be re-scoped.
|
// No day-indexed source exists for these, so they cannot be re-scoped.
|
||||||
status: UNAVAILABLE,
|
status: UNAVAILABLE,
|
||||||
dailyGainStatus: UNAVAILABLE,
|
dailyGainStatus: UNAVAILABLE,
|
||||||
uniformity: UNAVAILABLE,
|
// Uniformity is the exception: it has no per-day column either, but the
|
||||||
uniformityStatus: UNAVAILABLE,
|
// supervisor wants the latest API figure carried through rather than
|
||||||
|
// blanked, because that is the number users see on the Berat Ayam page.
|
||||||
|
// A day with no weighing (day 48) reports 0, not "tidak tersedia".
|
||||||
|
uniformity: numberOrZero(weight.current?.uniformity),
|
||||||
|
uniformityStatus: weight.current?.uniformityStatus ?? UNAVAILABLE,
|
||||||
fcrStatus: UNAVAILABLE,
|
fcrStatus: UNAVAILABLE,
|
||||||
fcrTrend: UNAVAILABLE,
|
fcrTrend: UNAVAILABLE,
|
||||||
catatan:
|
catatan:
|
||||||
@@ -1155,7 +1168,9 @@ const rescopeScalarsToPeriod = (pack, reportType = 'daily', reportPeriod = null)
|
|||||||
periode: periodLabel,
|
periode: periodLabel,
|
||||||
// `value` is the only field the model may quote as "the EEF".
|
// `value` is the only field the model may quote as "the EEF".
|
||||||
nilaiEef_indeksTanpaSatuan: orUnavailable(eefValue),
|
nilaiEef_indeksTanpaSatuan: orUnavailable(eefValue),
|
||||||
bobotRataRata_gram: orUnavailable(averageWeight),
|
// Bobot mengikuti aturan yang sama dengan uniformity: hari tanpa
|
||||||
|
// penimbangan dilaporkan 0, bukan "tidak tersedia".
|
||||||
|
bobotRataRata_gram: numberOrZero(averageWeight),
|
||||||
fcr_rasio: orUnavailable(fcr),
|
fcr_rasio: orUnavailable(fcr),
|
||||||
persenHidup_persen: orUnavailable(survivalPersen),
|
persenHidup_persen: orUnavailable(survivalPersen),
|
||||||
umur_hari: orUnavailable(eefDay),
|
umur_hari: orUnavailable(eefDay),
|
||||||
@@ -1420,7 +1435,13 @@ const generateLocalDashboardInsight = (context, reportType = 'daily', reportPeri
|
|||||||
? cp707.getBwStandardByDay(dayAge)
|
? cp707.getBwStandardByDay(dayAge)
|
||||||
: (positiveOrNull(wm.targetWeight) ??
|
: (positiveOrNull(wm.targetWeight) ??
|
||||||
latestFromHistory(context?.weight?.dailyWeightData, 'target'));
|
latestFromHistory(context?.weight?.dailyWeightData, 'target'));
|
||||||
const wmUniformity = positiveOrNull(wm.uniformity);
|
// 0 is a real reading here (a day with no weighing), not a missing value, so
|
||||||
|
// this deliberately does NOT use `positiveOrNull` like the figures above.
|
||||||
|
const wmUniformity = (() => {
|
||||||
|
const raw = wm.uniformity;
|
||||||
|
const n = isNum(raw?.value) ? raw.value : isNum(raw) ? raw : null;
|
||||||
|
return n === null ? 0 : n;
|
||||||
|
})();
|
||||||
|
|
||||||
// An unmeasured bird cannot be graded "ok".
|
// An unmeasured bird cannot be graded "ok".
|
||||||
let statusBerat = wmAvg === null ? 'unknown' : 'ok';
|
let statusBerat = wmAvg === null ? 'unknown' : 'ok';
|
||||||
@@ -1442,9 +1463,7 @@ const generateLocalDashboardInsight = (context, reportType = 'daily', reportPeri
|
|||||||
status: statusBerat,
|
status: statusBerat,
|
||||||
bullets: [
|
bullets: [
|
||||||
`Bobot aktual: ${bwDisplayAvg}g. Target standar CP 707 hari ke-${dayAge || '?'}: ${bwDisplayTarget}g.`,
|
`Bobot aktual: ${bwDisplayAvg}g. Target standar CP 707 hari ke-${dayAge || '?'}: ${bwDisplayTarget}g.`,
|
||||||
wmUniformity !== null
|
`Keseragaman (uniformity): ${wmUniformity}% ayam berada dalam ±5% dari bobot rata-rata (band yang dipakai di lapangan, bukan ±10% rumus buku).`,
|
||||||
? `Keseragaman (uniformity): ${wmUniformity}% ayam berada dalam ±10% dari bobot rata-rata. Standar CP 707: >80%.`
|
|
||||||
: 'Data uniformity tidak tersedia.',
|
|
||||||
],
|
],
|
||||||
evidence:
|
evidence:
|
||||||
statusBerat !== 'ok'
|
statusBerat !== 'ok'
|
||||||
|
|||||||
@@ -1500,6 +1500,24 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
|||||||
|
|
||||||
const rawEef = kpis?.eef?.value;
|
const rawEef = kpis?.eef?.value;
|
||||||
const eefVal = rawEef ? (typeof rawEef === 'number' ? rawEef.toFixed(2) : rawEef) : 'N/A';
|
const eefVal = rawEef ? (typeof rawEef === 'number' ? rawEef.toFixed(2) : rawEef) : 'N/A';
|
||||||
|
// EEF naik jadi angka utama di samping FCR: keduanya adalah indeks performa
|
||||||
|
// siklus, dan supervisor membacanya berpasangan. Status dan trend-nya sudah
|
||||||
|
// dihitung di `kpis.eef`, jadi tidak ada angka baru yang diciptakan di sini.
|
||||||
|
const hasEef = eefVal !== 'N/A';
|
||||||
|
const eefStatus: InsightStatus = hasEef ? asInsightStatus(kpis?.eef?.status) : 'unknown';
|
||||||
|
// Ambang mengikuti `getEefStatus` di AppContext (>=350 ok, >=300 warning).
|
||||||
|
const eefDelta = !hasEef
|
||||||
|
? null
|
||||||
|
: eefStatus === 'ok'
|
||||||
|
? '✓ ≥350, sangat baik'
|
||||||
|
: eefStatus === 'warning'
|
||||||
|
? '300–350, di bawah target 350'
|
||||||
|
: '<300, perlu perhatian';
|
||||||
|
const eefSpark = Array.isArray(kpis?.eef?.trend)
|
||||||
|
? (kpis.eef.trend as unknown[]).filter(
|
||||||
|
(v): v is number => typeof v === 'number' && Number.isFinite(v)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
// ── Headline figures ──────────────────────────────────────────────────
|
// ── Headline figures ──────────────────────────────────────────────────
|
||||||
// Everything below is derived from values already computed above; nothing
|
// Everything below is derived from values already computed above; nothing
|
||||||
@@ -1623,14 +1641,15 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
|||||||
? kpis.karungDituang.value.toLocaleString('id-ID')
|
? kpis.karungDituang.value.toLocaleString('id-ID')
|
||||||
: 'N/A';
|
: 'N/A';
|
||||||
|
|
||||||
// Rounded for display: the CP 707 figure is computed, so it arrives at full
|
// Rounded for display: the figure arrives at full float precision from the
|
||||||
// float precision and printed as "51.391368269062724%".
|
// API and would otherwise print as "51.391368269062724%".
|
||||||
|
// Hari tanpa penimbangan keluar 0%, bukan "N/A" -- 0 adalah pembacaan yang
|
||||||
|
// sah di sini, dan angka ini harus sama dengan yang dipegang user.
|
||||||
const uniformityVal =
|
const uniformityVal =
|
||||||
typeof weightStats?.uniformity?.value === 'number' &&
|
typeof weightStats?.uniformity?.value === 'number' &&
|
||||||
Number.isFinite(weightStats.uniformity.value) &&
|
Number.isFinite(weightStats.uniformity.value)
|
||||||
weightStats.uniformity.unit !== 'N/A'
|
|
||||||
? `${weightStats.uniformity.value.toFixed(1)}%`
|
? `${weightStats.uniformity.value.toFixed(1)}%`
|
||||||
: 'N/A';
|
: '0.0%';
|
||||||
const { summary, detailed } = structuredInsight;
|
const { summary, detailed } = structuredInsight;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1638,22 +1657,32 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
|||||||
<div className="rounded-2xl border border-purple-100 bg-white/90 p-5 shadow-sm">
|
<div className="rounded-2xl border border-purple-100 bg-white/90 p-5 shadow-sm">
|
||||||
{/* Hero + tiles: the card leads with the metric it is really about,
|
{/* Hero + tiles: the card leads with the metric it is really about,
|
||||||
and the remaining figures stay available but visibly secondary. */}
|
and the remaining figures stay available but visibly secondary. */}
|
||||||
<HeroFigure
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
label="FCR Aktual"
|
<HeroFigure
|
||||||
// 3 desimal, sama seperti kartu KPI FCR. `toFixed(2)` membulatkan
|
label="FCR Aktual"
|
||||||
// 1,704 menjadi "1.70" — digit terakhirnya hilang, padahal FCR
|
// 3 desimal, sama seperti kartu KPI FCR. `toFixed(2)` membulatkan
|
||||||
// dibaca sampai ribuan dan selisih 0,004 itu berarti.
|
// 1,704 menjadi "1.70" — digit terakhirnya hilang, padahal FCR
|
||||||
value={hasFcr ? formatTruncatedRatio(fcrNumber, 3) : 'N/A'}
|
// dibaca sampai ribuan dan selisih 0,004 itu berarti.
|
||||||
context={
|
value={hasFcr ? formatTruncatedRatio(fcrNumber, 3) : 'N/A'}
|
||||||
standard?.fcr && cycleDay
|
context={
|
||||||
? `Standar CP 707 hari ke-${cycleDay}: ${formatTruncatedRatio(standard.fcr, 3)}`
|
standard?.fcr && cycleDay
|
||||||
: null
|
? `Standar CP 707 hari ke-${cycleDay}: ${formatTruncatedRatio(standard.fcr, 3)}`
|
||||||
}
|
: null
|
||||||
status={fcrStatus}
|
}
|
||||||
deltaLabel={fcrDelta}
|
status={fcrStatus}
|
||||||
/>
|
deltaLabel={fcrDelta}
|
||||||
|
/>
|
||||||
|
<HeroFigure
|
||||||
|
label="EEF"
|
||||||
|
value={eefVal}
|
||||||
|
context={hasEef ? 'Target ≥350' : 'belum tersedia'}
|
||||||
|
status={eefStatus}
|
||||||
|
deltaLabel={eefDelta}
|
||||||
|
sparkline={eefSpark}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
<div className="mt-3 grid grid-cols-2 gap-3 lg:grid-cols-3">
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Populasi Hidup"
|
label="Populasi Hidup"
|
||||||
value={population}
|
value={population}
|
||||||
@@ -1682,12 +1711,6 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
|||||||
deltaLabel={weightGapPct === null ? null : `${weightGapPct.toFixed(0)}% vs target`}
|
deltaLabel={weightGapPct === null ? null : `${weightGapPct.toFixed(0)}% vs target`}
|
||||||
sparkline={weightSpark}
|
sparkline={weightSpark}
|
||||||
/>
|
/>
|
||||||
<StatTile
|
|
||||||
label="EEF"
|
|
||||||
value={eefVal}
|
|
||||||
status="unknown"
|
|
||||||
hint={eefVal === 'N/A' ? 'belum tersedia' : null}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-1 gap-x-6 md:grid-cols-2">
|
<div className="mt-4 grid grid-cols-1 gap-x-6 md:grid-cols-2">
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ const WeightMonitoringPageRevamped: React.FC = () => {
|
|||||||
bobotRataRata_gram: {
|
bobotRataRata_gram: {
|
||||||
field: 'bobot_gram',
|
field: 'bobot_gram',
|
||||||
agg: 'mean',
|
agg: 'mean',
|
||||||
|
// A day with no weighing reports 0 g rather than a blank.
|
||||||
|
emptyAs: 0,
|
||||||
replaces: 'averageWeight',
|
replaces: 'averageWeight',
|
||||||
},
|
},
|
||||||
targetBobot_gram: {
|
targetBobot_gram: {
|
||||||
@@ -147,8 +149,12 @@ const WeightMonitoringPageRevamped: React.FC = () => {
|
|||||||
},
|
},
|
||||||
// Unitless originals the derived keys replace.
|
// Unitless originals the derived keys replace.
|
||||||
drop: ['averageWeight', 'targetWeight', 'adg'],
|
drop: ['averageWeight', 'targetWeight', 'adg'],
|
||||||
// Uniformity has no per-day column, so it cannot be re-scoped.
|
// Uniformity has no per-day column, but the supervisor wants the
|
||||||
unavailable: ['uniformity', 'uniformityStatus', 'weightStatus', 'adgStatus'],
|
// latest API figure shown rather than "tidak tersedia" — the
|
||||||
|
// number users compare against is the one on this page, and a
|
||||||
|
// blank there read as a broken card. It stays out of `derive`
|
||||||
|
// because there is nothing per-day to average.
|
||||||
|
unavailable: ['weightStatus', 'adgStatus'],
|
||||||
}}
|
}}
|
||||||
contextData={{
|
contextData={{
|
||||||
currentDay: cycleData.currentDay,
|
currentDay: cycleData.currentDay,
|
||||||
|
|||||||
+27
-41
@@ -81,7 +81,7 @@ import {
|
|||||||
processDeviceWeightData,
|
processDeviceWeightData,
|
||||||
} from '../utils/scaleDataProcessor.ts';
|
} from '../utils/scaleDataProcessor.ts';
|
||||||
|
|
||||||
import { calculateCp707Uniformity, getCp707UniformityStatus } from '../utils/weightUtils.ts';
|
import { getCp707UniformityStatus } from '../utils/weightUtils.ts';
|
||||||
|
|
||||||
import { cobbStandardPerformance } from '../mockData/cobbStandard.ts';
|
import { cobbStandardPerformance } from '../mockData/cobbStandard.ts';
|
||||||
import {
|
import {
|
||||||
@@ -944,12 +944,13 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
|
|||||||
max: apiData.statistik.max,
|
max: apiData.statistik.max,
|
||||||
avg: apiData.statistik.avg,
|
avg: apiData.statistik.avg,
|
||||||
standardDeviation: apiData.statistik.Standard_deviasi,
|
standardDeviation: apiData.statistik.Standard_deviasi,
|
||||||
// Dihitung ulang menurut CP 707 (+/-10%); field `uniformity` dari API
|
// Dipakai apa adanya dari API. Band yang dipakai user adalah +/-5%,
|
||||||
// memakai band +/-5% sehingga tidak sebanding dengan ambang 80%.
|
// bukan +/-10% seperti rumus di buku CP 707, dan +/-5% itu persis
|
||||||
uniformity: calculateCp707Uniformity(
|
// yang sudah dihitung API di field `uniformity` -- menghitung ulang
|
||||||
apiData.statistik.avg,
|
// sendiri hanya membuat angka di AI Insight berbeda dari angka yang
|
||||||
apiData.statistik.Standard_deviasi
|
// dipegang user. Kosong diperlakukan sebagai 0, bukan "tidak
|
||||||
),
|
// tersedia": hari tanpa penimbangan (mis. hari ke-48) harus keluar 0.
|
||||||
|
uniformity: apiData.statistik.uniformity ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
staticDataStore.setWeightDistribution(chartData);
|
staticDataStore.setWeightDistribution(chartData);
|
||||||
@@ -1016,11 +1017,8 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
|
|||||||
statsDate.setHours(0, 0, 0, 0);
|
statsDate.setHours(0, 0, 0, 0);
|
||||||
const previousStatsDate = new Date(statsDate);
|
const previousStatsDate = new Date(statsDate);
|
||||||
previousStatsDate.setDate(previousStatsDate.getDate() - 1);
|
previousStatsDate.setDate(previousStatsDate.getDate() - 1);
|
||||||
const dayBeforePreviousStatsDate = new Date(previousStatsDate);
|
|
||||||
dayBeforePreviousStatsDate.setDate(dayBeforePreviousStatsDate.getDate() - 1);
|
|
||||||
const statsDateStr = formatDateForDb(statsDate);
|
const statsDateStr = formatDateForDb(statsDate);
|
||||||
const previousStatsDateStr = formatDateForDb(previousStatsDate);
|
const previousStatsDateStr = formatDateForDb(previousStatsDate);
|
||||||
const dayBeforePreviousStatsDateStr = formatDateForDb(dayBeforePreviousStatsDate);
|
|
||||||
const displayDate = formatEffectiveDateLabel(statsDate);
|
const displayDate = formatEffectiveDateLabel(statsDate);
|
||||||
|
|
||||||
// Fetch scale predictions range for entire cycle
|
// Fetch scale predictions range for entire cycle
|
||||||
@@ -1030,7 +1028,7 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
|
|||||||
'LIVE'
|
'LIVE'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch effective day and prior distributions to keep stats aligned with D-1 logic
|
// Hari efektif untuk statistik, hari sebelumnya semata-mata untuk ADG.
|
||||||
const currentDistribution = await apiClient.getWeightDistributionRange(
|
const currentDistribution = await apiClient.getWeightDistributionRange(
|
||||||
statsDateStr,
|
statsDateStr,
|
||||||
statsDateStr,
|
statsDateStr,
|
||||||
@@ -1043,12 +1041,6 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
|
|||||||
'LIVE',
|
'LIVE',
|
||||||
cycleStartStr
|
cycleStartStr
|
||||||
);
|
);
|
||||||
const dayBeforePreviousDistribution = await apiClient.getWeightDistributionRange(
|
|
||||||
dayBeforePreviousStatsDateStr,
|
|
||||||
dayBeforePreviousStatsDateStr,
|
|
||||||
'LIVE',
|
|
||||||
cycleStartStr
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rangeResponse.success && currentDistribution.success) {
|
if (rangeResponse.success && currentDistribution.success) {
|
||||||
// Process daily weight data
|
// Process daily weight data
|
||||||
@@ -1063,26 +1055,22 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
|
|||||||
const dailyGain = calculateDailyWeightGain(dailyWeight);
|
const dailyGain = calculateDailyWeightGain(dailyWeight);
|
||||||
staticDataStore.setDailyWeightGainData(dailyGain);
|
staticDataStore.setDailyWeightGainData(dailyGain);
|
||||||
|
|
||||||
// Use the effective date first, then fallback to the previous day if needed
|
// Hanya hari efektif yang dipakai -- TIDAK ada fallback mundur ke hari
|
||||||
let selectedDistData = Array.isArray(currentDistribution.data)
|
// sebelumnya. Fallback itu membuat hari tanpa penimbangan menampilkan
|
||||||
|
// angka hari lain (mis. uniformity 22.9% milik 10 Agt muncul saat 11-12
|
||||||
|
// Agt kosong), sehingga kartu terbaca sebagai kondisi hari ini padahal
|
||||||
|
// bukan. Hari tanpa penimbangan harus jujur keluar 0 / "Data Tidak
|
||||||
|
// Tersedia".
|
||||||
|
const selectedDistData = Array.isArray(currentDistribution.data)
|
||||||
? currentDistribution.data[0]
|
? currentDistribution.data[0]
|
||||||
: currentDistribution.data;
|
: currentDistribution.data;
|
||||||
let selectedDateLabel = displayDate;
|
const selectedDateLabel = displayDate;
|
||||||
let comparisonDistData = Array.isArray(previousDistribution.data)
|
// Hari sebelumnya tetap diambil, tapi hanya sebagai pembanding ADG.
|
||||||
|
const comparisonDistData = Array.isArray(previousDistribution.data)
|
||||||
? previousDistribution.data[0]
|
? previousDistribution.data[0]
|
||||||
: previousDistribution.data;
|
: previousDistribution.data;
|
||||||
|
|
||||||
if (!selectedDistData || selectedDistData.total_chicken_count === 0) {
|
// Tidak ada penimbangan pada hari efektif
|
||||||
if (previousDistribution.success && (comparisonDistData?.total_chicken_count ?? 0) > 0) {
|
|
||||||
selectedDistData = comparisonDistData;
|
|
||||||
selectedDateLabel = formatEffectiveDateLabel(previousStatsDate);
|
|
||||||
comparisonDistData = Array.isArray(dayBeforePreviousDistribution.data)
|
|
||||||
? dayBeforePreviousDistribution.data[0]
|
|
||||||
: dayBeforePreviousDistribution.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If still no data available (both today and yesterday empty)
|
|
||||||
if (!selectedDistData || selectedDistData.total_chicken_count === 0) {
|
if (!selectedDistData || selectedDistData.total_chicken_count === 0) {
|
||||||
// Set empty/null stats to indicate no data
|
// Set empty/null stats to indicate no data
|
||||||
staticDataStore.setInitialWeight({
|
staticDataStore.setInitialWeight({
|
||||||
@@ -1114,13 +1102,11 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
|
|||||||
dailyGain = avgWeight - priorAvg;
|
dailyGain = avgWeight - priorAvg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keseragaman dihitung ulang menurut CP 707 (+/-10% dari rata-rata).
|
// Keseragaman diambil langsung dari API: band yang dipakai user
|
||||||
// Field `uniformity` bawaan API memakai band +/-5%, sehingga nilainya
|
// adalah +/-5%, yang memang sudah dipakai field `uniformity` API.
|
||||||
// (median 70 hari = 39.8) tidak sebanding dengan ambang CP 707 >80%.
|
// Hari tanpa data keluar 0, bukan null, supaya kartu dan AI Insight
|
||||||
const uniformity = calculateCp707Uniformity(
|
// menampilkan angka yang sama dengan yang dipegang user.
|
||||||
selectedDistData.statistik.avg,
|
const uniformity = selectedDistData.statistik.uniformity ?? 0;
|
||||||
selectedDistData.statistik.Standard_deviasi
|
|
||||||
);
|
|
||||||
|
|
||||||
// Status helper functions
|
// Status helper functions
|
||||||
const getAdgStatus = (adg: number): 'ok' | 'warning' | 'critical' => {
|
const getAdgStatus = (adg: number): 'ok' | 'warning' | 'critical' => {
|
||||||
@@ -1143,8 +1129,8 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
|
|||||||
status: getAdgStatus(dailyGain),
|
status: getAdgStatus(dailyGain),
|
||||||
},
|
},
|
||||||
uniformity: {
|
uniformity: {
|
||||||
value: uniformity ?? 0, // Keep full precision, will format in display
|
value: uniformity, // Keep full precision, will format in display
|
||||||
unit: uniformity === null ? 'N/A' : '%',
|
unit: '%',
|
||||||
status: getCp707UniformityStatus(uniformity),
|
status: getCp707UniformityStatus(uniformity),
|
||||||
},
|
},
|
||||||
dataDate: selectedDateLabel, // Add the date label
|
dataDate: selectedDateLabel, // Add the date label
|
||||||
|
|||||||
@@ -149,6 +149,14 @@ export type ScalarScopeSpec = {
|
|||||||
*/
|
*/
|
||||||
agg: 'mean' | 'sum' | 'last' | 'lastValid';
|
agg: 'mean' | 'sum' | 'last' | 'lastValid';
|
||||||
replaces?: string;
|
replaces?: string;
|
||||||
|
/**
|
||||||
|
* What to write when the window holds no usable reading. The default is
|
||||||
|
* `UNAVAILABLE`, which is right for figures a missing day says nothing
|
||||||
|
* about. Set `0` for the ones the supervisor wants reported as a plain
|
||||||
|
* zero instead — a day with no weighing (day 48) has to render "0 g",
|
||||||
|
* not a blank, because a blank read as a broken card.
|
||||||
|
*/
|
||||||
|
emptyAs?: 0;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
drop?: string[];
|
drop?: string[];
|
||||||
@@ -210,7 +218,7 @@ export const scopeScalarsToTimeframe = (
|
|||||||
|
|
||||||
for (const [outKey, rule] of Object.entries(spec.derive)) {
|
for (const [outKey, rule] of Object.entries(spec.derive)) {
|
||||||
const value = aggregate(rows, rule.field, rule.agg);
|
const value = aggregate(rows, rule.field, rule.agg);
|
||||||
out[outKey] = value === null ? UNAVAILABLE : value;
|
out[outKey] = value === null ? (rule.emptyAs ?? UNAVAILABLE) : value;
|
||||||
}
|
}
|
||||||
for (const key of spec.unavailable ?? []) out[key] = UNAVAILABLE;
|
for (const key of spec.unavailable ?? []) out[key] = UNAVAILABLE;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user