Add daily report day picker and cumulative insights for mortality data. Updated AiInsightCard and related components to support daily and weekly reporting, ensuring accurate data representation. Introduced new utility functions for water consumption standards and enhanced context management for insights.
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-08-12 13:50:30 +07:00
1 parent 172ea3a148
commit 427cac54a1
18 files changed
+1055 -205

No files matched your search

Binary file not shown.
+7
View File
@@ -311,6 +311,13 @@ router.post('/proxy', async (req, res) => {
// Same anti-fabrication contract the dashboard path already enforces.
enrichedSystemPrompt = `${enrichedSystemPrompt}
ATURAN MORTALITAS (WAJIB DIPATUHI — TIDAK BOLEH DILANGGAR):
- Standar CP 707: mortalitas kumulatif NORMAL adalah < 5%.
- Mortalitas >= 5% dan <= 7% = TINGGI (warning) — WAJIB disebut "TINGGI", bukan "rendah" atau "normal".
- Mortalitas > 7% = SANGAT TINGGI (critical) — WAJIB disebut "SANGAT TINGGI".
- Mortalitas < 5% = rendah/normal.
- DILARANG KERAS menyebut mortalitas sebagai "rendah" atau "normal" jika nilainya >= 5%.
ATURAN ANGKA (WAJIB DIPATUHI):
- Setiap angka yang Anda tulis HARUS ada di [Data Halaman (JSON)] atau di blok
STANDAR CP 707. DILARANG menghitung sendiri, memperkirakan, atau menurunkan
@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest';
const { pruneContextPackForLocalLLM } = require('../dashboardInsightService.js');
/**
* The daily report gained a day picker ("Hari 1".."Hari 30"), mirroring the
* weekly one. Before it, a daily report always described whichever day was
* newest in the data, so picking a past day was impossible.
*
* These cover the slicing that makes the picker actually change the answer —
* without it the dropdown moves but every day returns the same brief.
*/
const pack = {
weight: {
dailyWeightData: [
{ day: 1, actual: 45 },
{ day: 5, actual: 160 },
{ day: 12, actual: 480 },
{ day: 30, actual: 1620 },
],
// A snapshot list: no time dimension, so it must survive every mode intact.
devices: [{ name: 'B1' }, { name: 'B2' }],
},
};
const daysIn = (pruned) => pruned.weight.dailyWeightData.map((row) => row.day);
describe('pruneContextPackForLocalLLM — jendela kumulatif hari 0..N', () => {
it('menyertakan seluruh hari sampai hari yang diminta', () => {
expect(daysIn(pruneContextPackForLocalLLM(pack, 'daily', 'Hari 5'))).toEqual([1, 5]);
expect(daysIn(pruneContextPackForLocalLLM(pack, 'daily', 'Hari 12'))).toEqual([1, 5, 12]);
});
it('tidak menyertakan hari setelah hari yang diminta', () => {
expect(daysIn(pruneContextPackForLocalLLM(pack, 'daily', 'Hari 5'))).not.toContain(12);
expect(daysIn(pruneContextPackForLocalLLM(pack, 'daily', 'Hari 5'))).not.toContain(30);
});
it('hari tanpa data sendiri tetap memuat hari-hari sebelumnya', () => {
expect(daysIn(pruneContextPackForLocalLLM(pack, 'daily', 'Hari 7'))).toEqual([1, 5]);
});
it('tetap memakai hari terakhir saja bila tidak ada hari yang diminta', () => {
// Perilaku lama: dipakai oleh baris cache yang ditulis sebelum ada dropdown.
expect(daysIn(pruneContextPackForLocalLLM(pack, 'daily', null))).toEqual([30]);
});
it('tidak memotong daftar snapshot yang tidak berdimensi waktu', () => {
const pruned = pruneContextPackForLocalLLM(pack, 'daily', 'Hari 5');
expect(pruned.weight.devices).toHaveLength(2);
});
it('tidak mengubah perilaku laporan mingguan', () => {
expect(daysIn(pruneContextPackForLocalLLM(pack, 'weekly', 'Minggu 1'))).toEqual([1, 5]);
expect(daysIn(pruneContextPackForLocalLLM(pack, 'weekly', 'Minggu 2'))).toEqual([12]);
});
it('membedakan "Hari 3" dari "Minggu 3" — angka yang sama, satuan berbeda', () => {
// Minggu 3 = hari 15-21 saja; Hari 3 = kumulatif hari 0 s/d 3.
expect(daysIn(pruneContextPackForLocalLLM(pack, 'daily', 'Hari 3'))).toEqual([1]);
expect(daysIn(pruneContextPackForLocalLLM(pack, 'weekly', 'Minggu 3'))).toEqual([]);
});
});
const { stampDailyPeriodOnHeadline } = require('../dashboardInsightService.js');
/**
* The model was observed returning a correctly scoped day-28 brief whose text
* never said "28", so two different days printed identical headlines. This
* makes the label independent of whether the model obeys the prompt.
*/
describe('stampDailyPeriodOnHeadline', () => {
const withHeadline = (headline) => ({ summary: { headline, bullets: [] } });
const headlineOf = (parsed) => parsed.summary.headline;
it('menambahkan label hari saat model tidak menyebutnya', () => {
const out = stampDailyPeriodOnHeadline(
withHeadline('Mortalitas tinggi dan bobot di bawah target.'),
'daily',
'Hari 28'
);
expect(headlineOf(out)).toBe(
'Laporan Harian (Hari ke-28): Mortalitas tinggi dan bobot di bawah target.'
);
});
it('tidak menggandakan label bila model sudah menyebut harinya', () => {
const already = 'Laporan Harian (Hari ke-28): Kondisi terpantau.';
expect(headlineOf(stampDailyPeriodOnHeadline(withHeadline(already), 'daily', 'Hari 28'))).toBe(
already
);
});
it('tidak menganggap "hari ke-3" sebagai penyebutan hari 30', () => {
// Tanpa batas \D, regex hari 3 akan cocok dengan "hari ke-30" dan sebaliknya.
const out = stampDailyPeriodOnHeadline(
withHeadline('Laporan Harian (Hari ke-3): Kondisi terpantau.'),
'daily',
'Hari 30'
);
expect(headlineOf(out)).toBe(
'Laporan Harian (Hari ke-30): Laporan Harian (Hari ke-3): Kondisi terpantau.'
);
});
it('tidak menyentuh laporan mingguan dan akhir siklus', () => {
const h = withHeadline('Ringkasan minggu ini.');
expect(headlineOf(stampDailyPeriodOnHeadline(h, 'weekly', 'Minggu 4'))).toBe(
'Ringkasan minggu ini.'
);
expect(headlineOf(stampDailyPeriodOnHeadline(h, 'end_cycle', null))).toBe(
'Ringkasan minggu ini.'
);
});
it('tidak mengubah apa pun bila tidak ada hari yang diminta', () => {
const h = withHeadline('Ringkasan.');
expect(headlineOf(stampDailyPeriodOnHeadline(h, 'daily', null))).toBe('Ringkasan.');
});
it('aman terhadap bentuk respons yang tidak lengkap', () => {
expect(stampDailyPeriodOnHeadline(null, 'daily', 'Hari 5')).toBeNull();
expect(stampDailyPeriodOnHeadline({}, 'daily', 'Hari 5')).toEqual({});
const empty = withHeadline('');
expect(headlineOf(stampDailyPeriodOnHeadline(empty, 'daily', 'Hari 5'))).toBe('');
});
});
+174 -28
View File
@@ -11,7 +11,7 @@ const { llmDispatcher, llmFetch } = require('./llmDispatcher.js');
// data-availability guards. Insights cached under v5 were generated before those
// fixes — they still carry the fabricated figures and the false "ok" statuses,
// so the bump is what actually retires them from the dashboard.
const INSIGHT_VERSION = 'v6';
const INSIGHT_VERSION = 'v7'; // bumped: added explicit mortality grade labels to prevent LLM from calling ≥5% "rendah"
const dashboardInsightRequests = new Map();
// num_predict is a ceiling, not a target — a well-formed JSON answer stops on
@@ -385,7 +385,22 @@ const buildDashboardInsightPrompt = (
) => {
const bookRef = dayAge ? cp707.buildBookReferenceContext(dayAge) : '';
let taskInstruction = `Hasilkan LAPORAN HARIAN untuk hari ke-${dayAge || '?'} saja. Data yang diberikan HANYA berisi hari tersebut — analisis kondisi hari ini, jangan merangkum tren mingguan atau seluruh siklus.`;
const requestedDay = reportType === 'daily' ? parseDayNumber(reportPeriod) : null;
const dailyDay = requestedDay ?? dayAge ?? null;
let taskInstruction = `Hasilkan LAPORAN HARIAN dengan hari ke-${dailyDay ?? '?'} sebagai hari terakhir periode.
Field yang bernilai null atau "tidak tersedia" memang TIDAK ada untuk periode ini. Nyatakan "data tidak tersedia" untuk field itu dan JANGAN mengarang angkanya.`;
if (requestedDay !== null) {
// The window runs day 0..N, so the model must not present a 31-day
// cumulative figure as one day's achievement. And without the explicit
// headline rule it produced a correct day-28 brief that never said "28"
// anywhere, making two different days print identical reports.
taskInstruction += `
Data yang diberikan mencakup hari ke-0 SAMPAI hari ke-${requestedDay} (kumulatif sejak awal siklus), bukan hari ke-${requestedDay} saja.
Angka kumulatif seperti mortalitas dan total panen adalah akumulasi SELURUH periode itu — jangan menyebutnya sebagai capaian satu hari.
Boleh membahas tren dari hari ke-0 sampai hari ke-${requestedDay}, tetapi JANGAN membahas hari setelah hari ke-${requestedDay}.
WAJIB: "headline" HARUS diawali dengan "Laporan Harian (Hari ke-${requestedDay}):" persis seperti itu, lalu diikuti ringkasan kondisinya.
Bandingkan angka kondisi akhir dengan standar CP 707 untuk hari ke-${requestedDay}, bukan hari lain, dan jangan menyebut angkanya sebagai kondisi "saat ini" atau "terkini".`;
}
if (reportType === 'weekly') {
taskInstruction = `Hasilkan LAPORAN MINGGUAN untuk ${reportPeriod || 'minggu ini'} saja. Data yang diberikan HANYA berisi hari-hari dalam ${reportPeriod || 'minggu ini'} — analisis rata-rata dan tren di dalam minggu tersebut dibandingkan standar CP 707. Jangan membahas minggu lain.
Angka ringkas seperti averageWeight, dailyGain, dan fcr adalah RATA-RATA minggu tersebut, bukan kondisi hari ini — sebut sebagai "rata-rata ${reportPeriod || 'minggu ini'}", jangan sebut "saat ini" atau "terkini".
@@ -413,6 +428,14 @@ PANDUAN PENTING:
- Jangan gunakan standar atau referensi dari luar buku CP 707.
- Risiko yang disebutkan HARUS bersumber dari deviasi data aktual vs standar CP 707.
ATURAN MORTALITAS (WAJIB DIPATUHI — TIDAK BOLEH DILANGGAR):
- Standar CP 707: mortalitas kumulatif NORMAL adalah < 5%.
- Mortalitas >= 5% dan <= 7% = TINGGI (warning) — WAJIB disebut "TINGGI", bukan "rendah" atau "normal".
- Mortalitas > 7% = SANGAT TINGGI (critical) — WAJIB disebut "SANGAT TINGGI".
- Mortalitas < 5% = rendah/normal.
- DILARANG KERAS menyebut mortalitas sebagai "rendah" atau "normal" jika nilainya >= 5%.
- Field "statusMortalitas" di data konteks sudah berisi grade yang benar — GUNAKAN grade itu secara konsisten di seluruh teks laporan.
ATURAN ANGKA (WAJIB DIPATUHI):
- Setiap angka yang Anda tulis HARUS ada di data konteks. DILARANG menghitung sendiri,
memperkirakan, atau menyalin angka dari field lain.
@@ -519,7 +542,7 @@ const toNumber = (value) => {
return Number.isFinite(n) ? n : null;
};
const buildChickenCountingSummary = async (cycleId, kandangId) => {
const buildChickenCountingSummary = async (cycleId, kandangId, periodLastDay = null) => {
const cycle = await Cycle.getById(cycleId);
if (!cycle || !cycle.startDate) {
return null;
@@ -548,7 +571,15 @@ const buildChickenCountingSummary = async (cycleId, kandangId) => {
)[0]
: null;
const mortalityRecords = await Mortality.getByCycle(cycleId, selectedKandangId);
const allMortalityRecords = await Mortality.getByCycle(cycleId, selectedKandangId);
// Cumulative up to the period's closing day, not up to today. Records with no
// day are kept only for a whole-cycle report, where nothing is being excluded.
const mortalityRecords =
periodLastDay === null
? allMortalityRecords
: allMortalityRecords.filter(
(record) => typeof record.day === 'number' && record.day <= periodLastDay
);
const totalMortality = mortalityRecords.reduce(
(sum, record) => sum + (record.mortalityCount || 0) + (record.afkir || 0),
0
@@ -564,9 +595,13 @@ const buildChickenCountingSummary = async (cycleId, kandangId) => {
// data, next to 5248 recorded deaths. null forces callers to say "unknown".
const mortalityRate =
initialPopulation > 0 ? Number(((totalMortality / initialPopulation) * 100).toFixed(2)) : null;
const currentPopulation = latestChickenCount
? latestChickenCount.totalCount
: Math.max(0, initialPopulation - totalMortality - totalPanen);
// `latestChickenCount` is always TODAY's count, so it must not stand in for a
// past period's population — it would contradict the period-scoped mortality
// right beside it. Derive it from the scoped totals instead.
const currentPopulation =
latestChickenCount && periodLastDay === null
? latestChickenCount.totalCount
: Math.max(0, initialPopulation - totalMortality - totalPanen);
return {
source: {
@@ -605,8 +640,26 @@ const buildChickenCountingSummary = async (cycleId, kandangId) => {
};
};
const enrichContextPack = async (contextPack, cycleId, kandangId) => {
const chickenCountingSummary = await buildChickenCountingSummary(cycleId, kandangId);
/**
* Last cycle day the report covers, or null for "the whole cycle".
*
* The DB-derived figures below are cumulative, so they have to stop at the
* period's closing day. Without this a "Hari 28" report quoted the day-48
* mortality rate (9.91%) beside a KPI tile correctly showing 3.54% for day 28,
* and the model had no way to tell which was right.
*/
const periodLastDayOf = (reportType, reportPeriod) => {
if (reportType === 'daily') return parseDayNumber(reportPeriod);
if (reportType === 'weekly') return parseWeekNumber(reportPeriod) * 7;
return null;
};
const enrichContextPack = async (contextPack, cycleId, kandangId, periodLastDay = null) => {
const chickenCountingSummary = await buildChickenCountingSummary(
cycleId,
kandangId,
periodLastDay
);
if (!chickenCountingSummary) {
return contextPack;
}
@@ -628,6 +681,21 @@ const parseWeekNumber = (reportPeriod) => {
return Number.isFinite(week) && week > 0 ? week : 1;
};
/**
* Day number requested by a 'daily' report, e.g. "Hari 12" -> 12.
*
* Returns null when no day was requested, which keeps the old behaviour
* (report on the latest day present in the data) for callers that still send
* no period — the dashboard before this dropdown existed, and any cached row
* written back then.
*/
const parseDayNumber = (reportPeriod) => {
const match = reportPeriod && String(reportPeriod).match(/\d+/);
if (!match) return null;
const day = parseInt(match[0], 10);
return Number.isFinite(day) && day > 0 ? day : null;
};
/**
* A day-indexed history (weight per day, FCR per day, ...) as opposed to a
* snapshot list (devices, cameras, coops) that has no time dimension at all.
@@ -734,12 +802,20 @@ const pruneContextPackForLocalLLM = (obj, reportType = 'daily', reportPeriod = n
}
if (reportType === 'daily') {
const requestedDay = parseDayNumber(reportPeriod);
const days = obj.map(dayOfItem).filter((day) => day != null);
if (days.length > 0) {
const latestDay = Math.max(...days);
return recurse(obj.filter((item) => dayOfItem(item) === latestDay));
// Picking a day means "the cycle so far, up to that day" — day 0 through
// day N — so trends and cumulative figures have the history they need.
if (requestedDay != null) {
return recurse(obj.filter((item) => (dayOfItem(item) ?? Infinity) <= requestedDay));
}
return recurse(obj.filter((item) => dayOfItem(item) === Math.max(...days)));
}
// Date-only history: the array position is the only ordering we have.
if (requestedDay != null) {
return recurse(obj.slice(0, requestedDay + 1));
}
// Date-only history: the last row is the most recent one.
return recurse(obj.slice(-1));
}
@@ -946,7 +1022,9 @@ const rescopeScalarsToPeriod = (pack, reportType = 'daily', reportPeriod = null)
const periodLabel =
reportType === 'weekly'
? `${reportPeriod || 'Minggu ini'} (hari ke-${range?.firstDay ?? '?'} s/d ke-${range?.lastDay ?? '?'})`
: `Harian (hari ke-${range?.lastDay ?? '?'})`;
: range && range.firstDay !== range.lastDay
? `Harian (hari ke-${range.firstDay} s/d ke-${range.lastDay}, kumulatif)`
: `Harian (hari ke-${range?.lastDay ?? '?'})`;
const round2 = (value) => Math.round(value * 100) / 100;
@@ -1105,6 +1183,37 @@ const rescopeScalarsToPeriod = (pack, reportType = 'daily', reportPeriod = null)
return scoped;
};
/**
* Guarantee a daily report names the day it describes.
*
* The prompt asks for it, but a model that ignores the instruction produced a
* correctly scoped day-28 brief whose text said "28" nowhere — so day 28 and
* day 30 printed indistinguishable headlines and the picker looked broken.
* Prefixing here makes the label independent of model compliance.
*
* Left alone when the headline already names that day, so an obedient model is
* not given a doubled prefix.
*/
const stampDailyPeriodOnHeadline = (parsed, reportType, reportPeriod) => {
if (reportType !== 'daily') return parsed;
const day = parseDayNumber(reportPeriod);
if (day === null) return parsed;
if (!parsed || typeof parsed !== 'object' || !parsed.summary) return parsed;
const headline = typeof parsed.summary.headline === 'string' ? parsed.summary.headline.trim() : '';
if (!headline) return parsed;
// `\D` on both sides so "Hari ke-3" is not treated as naming day 30.
if (new RegExp(`hari\\s*ke-?\\s*${day}(\\D|$)`, 'i').test(headline)) return parsed;
return {
...parsed,
summary: {
...parsed.summary,
headline: `Laporan Harian (Hari ke-${day}): ${headline}`,
},
};
};
const generateLocalDashboardInsight = (context, reportType = 'daily', reportPeriod = null) => {
// Ambil umur ayam untuk referensi standar CP 707
const cycleDay =
@@ -1129,12 +1238,18 @@ const generateLocalDashboardInsight = (context, reportType = 'daily', reportPeri
const weekFirstDay = weekNumber === null ? null : (weekNumber - 1) * 7 + 1;
const weekLastDay =
weekNumber === null ? null : cycleDay ? Math.min(weekNumber * 7, cycleDay) : weekNumber * 7;
const dayAge = weekLastDay ?? cycleDay;
// A daily report for a past day must be graded at THAT day's CP 707 standard
// for the same reason a weekly one is: comparing day-5 figures against the
// day-30 standard invents a deficit that is not there.
const requestedDailyDay = reportType === 'daily' ? parseDayNumber(reportPeriod) : null;
const dayAge = weekLastDay ?? requestedDailyDay ?? cycleDay;
/** How the period should be named in headlines. */
const periodeText =
reportType === 'weekly'
? `${reportPeriod || 'Minggu ini'}, hari ke-${weekFirstDay} s/d ke-${weekLastDay}`
: null;
: requestedDailyDay !== null
? `hari ke-${requestedDailyDay}`
: null;
// ─── 1. Hitung Ayam (Mortalitas) ─────────────────────────────────────────
const isNum = (value) => typeof value === 'number' && Number.isFinite(value);
@@ -1219,10 +1334,23 @@ const generateLocalDashboardInsight = (context, reportType = 'daily', reportPeri
else if (ccRate >= 5) statusAyam = 'warning';
}
// Compute a human-readable mortality grade that is injected into the context
// so the LLM can read it directly instead of inferring it from prose.
const mortalityGradeLabel =
ccRate === null
? 'tidak diketahui'
: ccRate > 7
? 'SANGAT TINGGI (critical, melampaui batas maksimum CP 707 >7%)'
: ccRate >= 5
? 'TINGGI (warning, melampaui standar normal CP 707 <5%)'
: 'NORMAL (di bawah standar CP 707 <5%)';
const hitung_ayam = {
status: statusAyam,
// Explicit grade label — prevents the model from labelling 9.91% as "rendah".
statusMortalitas: mortalityGradeLabel,
bullets: [
`Mortalitas kumulatif: ${ccRateText}. Standar CP 707 pada hari ke-${dayAge || '?'}: ${stdMortalityCum ? stdMortalityCum + '%' : '<5%'}.`,
`Mortalitas kumulatif: ${ccRateText} — STATUS: ${mortalityGradeLabel}. Standar CP 707 pada hari ke-${dayAge || '?'}: ${stdMortalityCum ? stdMortalityCum + '%' : '<5%'}.`,
`Populasi awal: ${ekorText(ccInitial)}, saat ini: ${ekorText(ccCurrent)}. Total deplesi: ${ekorText(ccMortality)}.`,
// Without this the fallback reported a flock shrinking from 25000 to 2
// and attributed all of it to deplesi, because harvest was never named.
@@ -1240,7 +1368,7 @@ const generateLocalDashboardInsight = (context, reportType = 'daily', reportPeri
mortalityAnalysis?.message ||
(ccRate === null
? 'Persentase deplesi tidak dapat dihitung: populasi awal tidak tersedia.'
: `Deplesi ${ccRate.toFixed(2)}% melampaui standar CP 707 (<5%).`),
: `Deplesi ${ccRate.toFixed(2)}% melampaui standar CP 707 (<5%). Status mortalitas: ${mortalityGradeLabel}.`),
]
: [],
actions:
@@ -1315,7 +1443,7 @@ const generateLocalDashboardInsight = (context, reportType = 'daily', reportPeri
bullets: [
`Bobot aktual: ${bwDisplayAvg}g. Target standar CP 707 hari ke-${dayAge || '?'}: ${bwDisplayTarget}g.`,
wmUniformity !== null
? `Keseragaman (uniformity): ${wmUniformity}%. Standar CP 707: >80%.`
? `Keseragaman (uniformity): ${wmUniformity}% ayam berada dalam ±10% dari bobot rata-rata. Standar CP 707: >80%.`
: 'Data uniformity tidak tersedia.',
],
evidence:
@@ -1662,11 +1790,12 @@ const generateDashboardInsight = async ({
reportType = 'daily',
reportPeriod: rawReportPeriod = null,
}) => {
// Only a weekly report is scoped by a week label. The dashboard keeps its
// week dropdown mounted and sends its value regardless of type, so without
// this a daily report would be cached under whichever week was last picked
// and regenerate needlessly when that week changed.
const reportPeriod = reportType === 'weekly' ? rawReportPeriod : null;
// Daily and weekly reports are each scoped by their own label ("Hari 12",
// "Minggu 3") and must be cached per label, or every day would overwrite the
// previous one's row. An end-cycle report covers the whole cycle and has no
// label of its own, so it is pinned to null — otherwise it would be cached
// under whichever day or week the dropdown happened to be showing.
const reportPeriod = reportType === 'end_cycle' ? null : rawReportPeriod;
const cacheKey = buildCacheKey(cycleId, kandangId, version, reportType, reportPeriod);
if (!forceRefresh) {
@@ -1693,7 +1822,22 @@ const generateDashboardInsight = async ({
}
}
const enrichedContextPack = await enrichContextPack(contextPack, cycleId, kandangId);
const enrichedContextPack = await enrichContextPack(
contextPack,
cycleId,
kandangId,
periodLastDayOf(reportType, reportPeriod)
);
/**
* The age the report is ABOUT, which is not today's age once a past
* period is picked. Every CP 707 lookup keys off this, so a "Hari 5"
* report must not be graded against the day-30 standard.
*/
const packAgeDay =
enrichedContextPack?.cycle?.currentAgeDay || enrichedContextPack?.ageDay || null;
const scopedDayAge =
reportType === 'daily' ? (parseDayNumber(reportPeriod) ?? packAgeDay) : packAgeDay;
const rawLength = JSON.stringify(enrichedContextPack).length;
const prunedContextPack = rescopeScalarsToPeriod(
pruneContextPackForLocalLLM(enrichedContextPack, reportType, reportPeriod),
@@ -1713,8 +1857,7 @@ const generateDashboardInsight = async ({
// Jika konteks terlalu pendek, tambahkan standar CP 707 agar model bisa generate output
let finalContextText = contextText;
if (contextText.length < 400) {
const dayAge =
enrichedContextPack?.cycle?.currentAgeDay || enrichedContextPack?.ageDay || null;
const dayAge = scopedDayAge;
const padding = dayAge
? cp707.buildBookReferenceContext(dayAge)
: '\n[Data operasional kandang tidak tersedia lengkap. Gunakan standar umum CP 707.]';
@@ -1758,8 +1901,7 @@ const generateDashboardInsight = async ({
);
}
const dayAge =
enrichedContextPack?.cycle?.currentAgeDay || enrichedContextPack?.ageDay || null;
const dayAge = scopedDayAge;
const isOllama = lmStudioBaseUrl.includes('11434') || lmStudioBaseUrl.includes('llm');
const endpoint = isOllama ? '/api/chat' : '/v1/chat/completions';
@@ -1944,6 +2086,7 @@ const generateDashboardInsight = async ({
// Judged on what was actually sent — this marks a topic "tidak dikirim
// untuk periode ini", which is only true of the scoped pack.
parsed = enforceDataAvailability(parsed, prunedContextPack);
parsed = stampDailyPeriodOnHeadline(parsed, reportType, reportPeriod);
// Check if newly generated content is identical to existing content in database
const existing = await AiInsight.getByCycleAndKandang(
@@ -2018,6 +2161,9 @@ module.exports = {
// Siklus actually differ, so it needs to be verifiable on its own.
pruneContextPackForLocalLLM,
rescopeScalarsToPeriod,
// Exported for tests: this is what guarantees a daily report names its day
// even when the model ignores the instruction to do so.
stampDailyPeriodOnHeadline,
// Exported for tests: the healing defaults decide whether an unassessed topic
// shows up green, so they need to be verifiable directly.
parseDashboardInsightJson,
+85 -29
View File
@@ -44,6 +44,7 @@ import type { FeedSackLogEntry } from '../types/counting.ts';
import { marked } from 'marked';
import { HeroFigure, MutedStatRow, StatTile } from './shared/InsightStatTiles.tsx';
import { exportCardToPdf } from '../utils/pdfExport.ts';
import { formatTruncatedRatio } from '../utils/formatters.ts';
import PdfReportHeader, {
buildPdfFilename,
describeDataDates,
@@ -750,6 +751,18 @@ const parseWeekNumber = (period: string | null) => {
return Number.isFinite(week) && week > 0 ? week : 1;
};
/**
* Day number requested by a 'daily' report, e.g. "Hari 12" -> 12.
* Null means no day was picked, which keeps the old behaviour of reporting on
* the latest day present in the data.
*/
const parseDayNumber = (period: string | null) => {
const match = period && String(period).match(/\d+/);
if (!match) return null;
const day = parseInt(match[0] ?? '', 10);
return Number.isFinite(day) && day > 0 ? day : null;
};
/**
* Narrow per-day rows to the timeframe the user picked in the report dropdown.
*
@@ -774,6 +787,22 @@ const sliceMortalityByPeriod = (
const latestDay = days.length > 0 ? Math.max(...days) : null;
if (reportType === 'daily') {
// Picking a day means "the cycle so far, up to that day" — day 0 through
// day N — so the flows below are running totals, matching the cumulative
// mortality rate the card leads with.
const requestedDay = parseDayNumber(reportPeriod);
if (requestedDay !== null) {
const window = rows.filter((row) => (dayOf(row) ?? Infinity) <= requestedDay);
const windowDays = window.map(dayOf).filter((day): day is number => day !== null);
return {
window,
lastDay: requestedDay,
label:
windowDays.length === 0
? `hari ke-${requestedDay}`
: `hari ke-${Math.min(...windowDays)} s/d ke-${requestedDay}`,
};
}
return {
window: latestDay === null ? [] : rows.filter((row) => dayOf(row) === latestDay),
lastDay: latestDay,
@@ -809,7 +838,8 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
const [error, setError] = useState<string | null>(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [reportType, setReportType] = useState<'daily' | 'weekly' | 'end_cycle'>('daily');
const [reportPeriod, setReportPeriod] = useState<string>('Minggu 1');
// `dayPeriod` starts null, meaning "the newest day we have".
const [dayPeriod, setDayPeriod] = useState<string | null>(null);
// Per-day mortality/harvest rows for the KPI grid. Loaded independently of
// insight generation so the figures are right even when the insight itself
// comes from cache.
@@ -819,10 +849,23 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
const cycleId = context.cycleData?.id;
// Calendar span the report actually covers, per scope: today for a daily
// report, the requested week for a weekly one, day 1 to now for end-cycle.
// Both the printed header and the filename read from here so they cannot
// disagree.
/** Day the cycle has reached — the newest day any report can cover. */
const currentDayVal = context.kpis?.umur?.value || context.cycleData?.currentDay || 0;
/**
* The label sent to the backend and printed on the PDF. Daily falls back to
* the current day so the card behaves exactly as before until the user picks
* a different one.
*/
const reportPeriod =
reportType === 'daily'
? (dayPeriod ?? (currentDayVal > 0 ? `Hari ${currentDayVal}` : null))
: null;
// Calendar span the report actually covers, per scope: the chosen day for a
// daily report, the requested week for a weekly one, day 1 to now for
// end-cycle. Both the printed header and the filename read from here so they
// cannot disagree.
const { dashboardDataDateLabel, dashboardIsoDates } = (() => {
const startDate = context.cycleData?.startDate;
const currentDay = context.cycleData?.currentDay;
@@ -839,6 +882,10 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
fromDay = (weekNo - 1) * 7 + 1;
toDay = weekNo * 7;
}
} else if (reportType === 'daily') {
// Here the number IS a day, so it is used as-is rather than scaled by 7.
fromDay = parseDayNumber(reportPeriod) ?? currentDay;
toDay = null;
}
return {
@@ -1473,7 +1520,9 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
: 'critical';
const fcrDelta =
hasFcr && standard?.fcr
? `${fcrNumber > standard.fcr ? '+' : ''}${(fcrNumber - standard.fcr).toFixed(2)} vs standar`
? // 3 desimal agar sebanding dengan nilai FCR di atasnya; dibulatkan ke 2
// desimal, selisih 0,004 tampil sebagai 0,00 alias "tidak ada selisih".
`${fcrNumber > standard.fcr ? '+' : ''}${formatTruncatedRatio(fcrNumber - standard.fcr, 3)} vs standar`
: null;
const weightRows = (context.weightHistory ?? []) as Array<{
@@ -1574,9 +1623,14 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
? kpis.karungDituang.value.toLocaleString('id-ID')
: 'N/A';
const uniformityVal = weightStats?.uniformity?.value
? `${weightStats.uniformity.value}%`
: 'N/A';
// Rounded for display: the CP 707 figure is computed, so it arrives at full
// float precision and printed as "51.391368269062724%".
const uniformityVal =
typeof weightStats?.uniformity?.value === 'number' &&
Number.isFinite(weightStats.uniformity.value) &&
weightStats.uniformity.unit !== 'N/A'
? `${weightStats.uniformity.value.toFixed(1)}%`
: 'N/A';
const { summary, detailed } = structuredInsight;
return (
@@ -1586,10 +1640,13 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
and the remaining figures stay available but visibly secondary. */}
<HeroFigure
label="FCR Aktual"
value={hasFcr ? fcrNumber.toFixed(2) : 'N/A'}
// 3 desimal, sama seperti kartu KPI FCR. `toFixed(2)` membulatkan
// 1,704 menjadi "1.70" — digit terakhirnya hilang, padahal FCR
// dibaca sampai ribuan dan selisih 0,004 itu berarti.
value={hasFcr ? formatTruncatedRatio(fcrNumber, 3) : 'N/A'}
context={
standard?.fcr && cycleDay
? `Standar CP 707 hari ke-${cycleDay}: ${standard.fcr.toFixed(2)}`
? `Standar CP 707 hari ke-${cycleDay}: ${formatTruncatedRatio(standard.fcr, 3)}`
: null
}
status={fcrStatus}
@@ -1607,7 +1664,15 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
label="Mortalitas Kumulatif"
value={mortalityPct === null ? '-' : `${mortalityPct.toFixed(2)}%`}
status={mortalityStatus}
deltaLabel={mortalityPct === null ? null : 'standar <5%'}
deltaLabel={
mortalityPct === null
? null
: mortalityStatus === 'ok'
? '✓ normal <5%'
: mortalityStatus === 'warning'
? '↑ melebihi standar <5%'
: '↑ tinggi, standar <5%'
}
/>
<StatTile
label="Bobot Rata-rata"
@@ -2001,32 +2066,23 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
className="text-sm border-gray-300 rounded-lg shadow-sm focus:border-purple-500 focus:ring-purple-500"
>
<option value="daily">Harian</option>
<option value="weekly">Mingguan</option>
{((context.kpis?.umur?.value || context.cycleData?.currentDay || 0) >= 35 ||
context.cycleData?.status === 'Completed') && (
<option value="end_cycle">Akhir Siklus</option>
)}
</select>
{reportType === 'weekly' && (
{/* Day picker: the cycle only ever reaches `currentDayVal`, so
later days are not offered. */}
{reportType === 'daily' && currentDayVal > 0 && (
<select
value={reportPeriod}
onChange={(e) => setReportPeriod(e.target.value)}
value={reportPeriod ?? `Hari ${currentDayVal}`}
onChange={(e) => setDayPeriod(e.target.value)}
className="text-sm border-gray-300 rounded-lg shadow-sm focus:border-purple-500 focus:ring-purple-500"
>
{Array.from(
{
length: Math.max(
1,
Math.ceil(
(context.kpis?.umur?.value || context.cycleData?.currentDay || 0) / 7
)
),
},
(_, i) => i + 1
).map((w) => (
<option key={w} value={`Minggu ${w}`}>
Minggu {w}
{Array.from({ length: currentDayVal }, (_, i) => i + 1).map((d) => (
<option key={d} value={`Hari ${d}`}>
Hari {d}
</option>
))}
</select>
+23 -9
View File
@@ -16,8 +16,8 @@ import IotPanelDashboard from './iotPanel/IotPanelDashboard.tsx';
import { PageAiInsight } from './shared/PageAiInsight.tsx';
import {
getTempStandardByDay,
getWaterStandardByDay,
HUMIDITY_RANGE_PCT,
AIR_QUALITY,
parseReading,
} from '../utils/cp707EnvStandards.ts';
@@ -224,11 +224,26 @@ const IotPanelPage: React.FC = () => {
note: `Rentang ideal buku ${HUMIDITY_RANGE_PCT.min}–${HUMIDITY_RANGE_PCT.max}%; batang biru menandai batas atas.`,
},
{
label: 'CO₂',
actual: parseReading(computed.co2),
standard: AIR_QUALITY.co2Ideal_ppm,
unit: 'ppm',
note: `Ideal <${AIR_QUALITY.co2Ideal_ppm} ppm.`,
label: 'Experience Suhu',
actual: parseReading(computed.experienceTemp),
standard: getTempStandardByDay(computed.dayAgeValue),
unit: '°C',
note:
getTempStandardByDay(computed.dayAgeValue) === null
? 'Umur tidak diketahui, standar suhu tidak dapat ditentukan.'
: 'Suhu yang dirasakan ayam memperhitungkan kecepatan angin dan kelembapan.',
},
{
label: 'Konsumsi Air',
actual: parseReading(computed.water),
standard: getWaterStandardByDay(computed.dayAgeValue)?.max ?? null,
unit: 'L',
note: (() => {
const std = getWaterStandardByDay(computed.dayAgeValue);
return std
? `Standar CP 707 hari ke-${computed.dayAgeValue}: ${std.min}–${std.max} L / 1.000 ekor / hari (suhu 21°C).`
: 'Standar tidak tersedia untuk umur ini.';
})(),
},
],
}}
@@ -241,11 +256,10 @@ const IotPanelPage: React.FC = () => {
// rendered "24,2 °C °C" in the summary and handed the model a
// comma-decimal value it had to parse for itself.
suhu_rata_rata_C: parseReading(computed.avgTemp),
setpoint_C: parseReading(computed.setPoint),
suhu_luar_C: parseReading(computed.outsideTemp),
experience_suhu_C: parseReading(computed.experienceTemp),
kelembapan_persen: parseReading(computed.humidity),
co2_ppm: parseReading(computed.co2),
kecepatan_angin_mPerDetik: parseReading(computed.wind),
konsumsi_air_L: parseReading(computed.water),
lux: parseReading(computed.lux),
suhu1_C: parseReading(computed.temp1),
suhu2_C: parseReading(computed.temp2),
+116 -27
View File
@@ -42,7 +42,11 @@ interface MortalityDayRow {
* mean the deaths of that week, not the cycle total. The window is matched on
* each row's own `day` so a day with no record drops out instead of sliding it.
*/
const sliceRowsByTimeframe = (rows: MortalityDayRow[], timeframe: 'harian' | 'mingguan') => {
const sliceRowsByTimeframe = (
rows: MortalityDayRow[],
timeframe: 'harian' | 'mingguan',
selectedDay: number | null = null
) => {
const dayOf = (row: MortalityDayRow) =>
typeof row.day === 'number' && Number.isFinite(row.day) ? row.day : null;
const days = rows.map(dayOf).filter((day): day is number => day !== null);
@@ -50,6 +54,20 @@ const sliceRowsByTimeframe = (rows: MortalityDayRow[], timeframe: 'harian' | 'mi
const lastDay = Math.max(...days);
if (timeframe === 'harian') {
// Picking a day means "the cycle so far, up to that day" — day 0 through
// day N. Deaths and culls are then the running total, which is what the
// cumulative mortality headline above the card already reports.
if (selectedDay !== null) {
const window = rows.filter((row) => (dayOf(row) ?? Infinity) <= selectedDay);
const windowDays = window.map(dayOf).filter((d): d is number => d !== null);
return {
window,
label:
windowDays.length === 0
? `hari ke-${selectedDay}`
: `hari ke-${Math.min(...windowDays)} s/d ke-${selectedDay}`,
};
}
return {
window: rows.filter((row) => dayOf(row) === lastDay),
label: `hari ke-${lastDay}`,
@@ -178,9 +196,14 @@ const buildPrompt = (
timeframe === 'harian' ? 'Populasi hari ini' : 'Trend populasi (7 hari terakhir)';
const trendStr = recentPops.length > 0 ? recentPops.join(' → ') : 'belum ada data trend';
// `periodRangeLabel` already names the sliced window ("hari ke-20"), so it is
// the authority here. Hardcoding `currentDay` made a report for a past day
// announce itself as "HARI INI", which the model then repeated as fact.
const periodLabel =
timeframe === 'harian'
? `HARI INI SAJA (hari ke-${currentDay})`
? periodRangeLabel.includes(`ke-${currentDay}`)
? `HARI INI SAJA (${periodRangeLabel})`
: `${periodRangeLabel.toUpperCase()} SAJA — BUKAN hari ini (hari ini hari ke-${currentDay})`
: `7 HARI TERAKHIR (hari ke-${Math.max(1, currentDay - 6)} sampai hari ke-${currentDay})`;
const kandangStr =
@@ -301,18 +324,46 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
};
}, [activeCycleId, props.kandangId]);
const departures = summariseDepartures(mortalityRows, props.totalInitialPopulation);
const [result, setResult] = useState<AiResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [timeframe, setTimeframe] = useState<'harian' | 'mingguan'>('harian');
/**
* The weekly view was removed: the day picker covers the same ground and more,
* since picking a day already reports the cycle cumulatively up to it. Kept as
* a typed constant so the slicing helper and the PDF labels keep their shape.
*/
const timeframe: 'harian' | 'mingguan' = 'harian';
// Which cycle day the daily view describes; null = the newest day in the data,
// which is how this card behaved before the picker existed.
const [selectedDay, setSelectedDay] = useState<number | null>(null);
/**
* Days the mortality history actually contains. Offering a day with no record
* would slice the window to nothing and report "no data" for a day the user
* had no reason to think was empty.
*/
const availableDays = React.useMemo(() => {
const days = new Set<number>();
for (const row of mortalityRows) {
if (typeof row.day === 'number' && Number.isFinite(row.day)) days.add(row.day);
}
return [...days].sort((a, b) => a - b);
}, [mortalityRows]);
const activeDay =
timeframe === 'harian' && selectedDay !== null && availableDays.includes(selectedDay)
? selectedDay
: null;
const inFlightRef = useRef(false);
const hasGeneratedRef = useRef(false);
const lastCacheKeyRef = useRef<string | null>(null);
const pdfRef = useRef<HTMLDivElement>(null);
const cacheKey = `cc-insight-${props.cycleId}-${props.currentDay}-${timeframe}`;
// The day belongs in the key: without it day 5 and day 12 share a cache entry
// and switching days shows the previous day's report.
const cacheKey = `cc-insight-${props.cycleId}-${props.currentDay}-${timeframe}${
activeDay === null ? '' : `-h${activeDay}`
}`;
// The scope the user is currently asking for, so a request dropped because
// another was in flight can be re-run once that one finishes.
@@ -322,9 +373,29 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
// Flow figures for the selected period. `departures` stays cycle-wide because
// the saldo is a stock: it must count every departure up to now, not just the
// ones inside the window.
/**
* Cycle-to-date figures, capped at the day being reported on.
*
* Mortality rate and saldo are stocks, so they count every departure since
* day 1 — but "since day 1" must end at the day the report describes. Left
* uncapped, a "Hari 20" card headlined the day-48 cumulative rate (9,91%)
* beside its own day-20 rows, which is the contradiction the day picker was
* meant to remove.
*/
const cumulativeRows = React.useMemo(
() =>
activeDay === null
? mortalityRows
: mortalityRows.filter(
(row) => typeof row.day === 'number' && Number.isFinite(row.day) && row.day <= activeDay
),
[mortalityRows, activeDay]
);
const departures = summariseDepartures(cumulativeRows, props.totalInitialPopulation);
const period = React.useMemo(
() => sliceRowsByTimeframe(mortalityRows, timeframe),
[mortalityRows, timeframe]
() => sliceRowsByTimeframe(mortalityRows, timeframe, activeDay),
[mortalityRows, timeframe, activeDay]
);
const periodDepartures = summariseDepartures(period.window, props.totalInitialPopulation);
@@ -353,21 +424,31 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
// day the insight text actually discusses.
const chartRows = React.useMemo(() => {
const points = props.populationTrend ?? [];
return points.map((point, index) => ({
const all = points.map((point, index) => ({
hari: props.currentDay - (points.length - 1 - index),
populasi: point.population,
}));
}, [props.populationTrend, props.currentDay]);
// Cut the line at the chosen day: plotting past it would show days the
// figures below deliberately exclude.
return activeDay === null ? all : all.filter((row) => row.hari <= activeDay);
}, [props.populationTrend, props.currentDay, activeDay]);
// The insight text covers one day (harian) or the trailing week (mingguan);
// shade exactly that span so the reader sees which part the words describe.
const chartHighlight = React.useMemo(() => {
if (chartRows.length === 0) return null;
const days = chartRows.map((row) => row.hari);
const lastDay = Math.max(...days);
const firstDay = timeframe === 'harian' ? lastDay : Math.max(Math.min(...days), lastDay - 6);
// The band covers the whole analysed window, which for a picked day runs
// from the start of the cycle to that day.
const lastDay = timeframe === 'harian' && activeDay !== null ? activeDay : Math.max(...days);
const firstDay =
timeframe === 'harian'
? activeDay !== null
? Math.min(...days)
: lastDay
: Math.max(Math.min(...days), lastDay - 6);
return { firstDay, lastDay };
}, [chartRows, timeframe]);
}, [chartRows, timeframe, activeDay]);
const downloadPdf = async () => {
if (!result || !pdfRef.current) return;
@@ -380,7 +461,13 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
reportName: 'Hitung-Ayam',
area: appContext.selectedArea || 'Sukawarna',
kandangLabel: describeKandangScope(appContext.kandangs, props.kandangId ?? null),
periodSlug: timeframe === 'harian' ? 'Harian' : 'Mingguan',
// One file per day instead of every day overwriting "…-Harian.pdf".
periodSlug:
timeframe === 'harian'
? activeDay === null
? 'Harian'
: `Harian-Hari-${activeDay}`
: 'Mingguan',
isoDates: isoDataDates(cycleData?.startDate, fromDay, toDay),
});
await exportCardToPdf(pdfRef.current, filename);
@@ -553,20 +640,22 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
</div>
</div>
<div className="flex items-center gap-2" data-html2canvas-ignore="true">
<div className="flex bg-purple-50 rounded-lg p-0.5 border border-purple-100 mr-1">
<button
onClick={() => setTimeframe('harian')}
className={`px-2 py-1 text-[11px] font-semibold rounded-md transition-all ${timeframe === 'harian' ? 'bg-white shadow-sm text-purple-700' : 'text-purple-500 hover:text-purple-600'}`}
{/* DAY PICKER — same role as the dashboard's: the daily view describes
one cycle day, and without this it could only ever be the newest. */}
{timeframe === 'harian' && availableDays.length > 1 && (
<select
value={activeDay ?? availableDays[availableDays.length - 1]}
onChange={(e) => setSelectedDay(parseInt(e.target.value, 10))}
className="text-[11px] font-semibold border border-purple-100 bg-white text-purple-700 rounded-lg px-2 py-1 shadow-sm focus:border-purple-400 focus:ring-purple-400"
title="Pilih hari siklus"
>
Harian
</button>
<button
onClick={() => setTimeframe('mingguan')}
className={`px-2 py-1 text-[11px] font-semibold rounded-md transition-all ${timeframe === 'mingguan' ? 'bg-white shadow-sm text-purple-700' : 'text-purple-500 hover:text-purple-600'}`}
>
Mingguan
</button>
</div>
{availableDays.map((d) => (
<option key={d} value={d}>
Hari {d}
</option>
))}
</select>
)}
{result && (
<button
onClick={downloadPdf}
+2 -2
View File
@@ -16,9 +16,9 @@ export type InsightStatus = 'ok' | 'warning' | 'critical' | 'unknown';
* with its color, and the text tone (not the fill) is what clears contrast.
*/
const STATUS_CHIP: Record<InsightStatus, { cls: string; glyph: string }> = {
ok: { cls: 'bg-green-50 text-green-800 border-green-200', glyph: '▲' },
ok: { cls: 'bg-green-50 text-green-800 border-green-200', glyph: '✓' },
warning: { cls: 'bg-amber-50 text-amber-800 border-amber-200', glyph: '!' },
critical: { cls: 'bg-red-50 text-red-800 border-red-200', glyph: '▼' },
critical: { cls: 'bg-red-50 text-red-800 border-red-200', glyph: '⚠' },
unknown: { cls: 'bg-gray-50 text-gray-500 border-gray-200', glyph: '–' },
};
+187 -85
View File
@@ -10,6 +10,7 @@ import PdfReportHeader, {
isoDataDates,
} from './PdfReportHeader.tsx';
import { exportCardToPdf } from '../../utils/pdfExport.ts';
import { formatTruncatedRatio } from '../../utils/formatters.ts';
import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx';
import { getCobbStandardForDay } from '../../utils/insightHelpers.ts';
import { apiClient } from '../../services/apiService.ts';
@@ -181,32 +182,88 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
const [result, setResult] = useState<AiResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Persisted per topic: the FCR and EEF pages render this card behind
// `{!isLoading && ...}`, so every data refresh unmounts it. Plain state came
// back as "harian" and silently discarded the user's choice, which looked
// like the weekly insight being identical to the daily one.
// Only pages that declare how their scalars follow a day-indexed history can
// produce a genuinely different answer per period.
// produce a genuinely different answer per day, so only those get the picker.
const supportsTimeframe = Boolean(scalarScope);
const timeframeKey = `insight-timeframe-${topic}`;
const [timeframe, setTimeframeState] = useState<'harian' | 'mingguan'>(() => {
/**
* The weekly view was removed: the day picker covers the same ground and more,
* since picking a day already reports the cycle cumulatively up to it. Kept as
* a typed constant rather than inlined so the scoping helpers, the cache key
* and the PDF labels keep their existing shape.
*/
const timeframe: 'harian' | 'mingguan' = 'harian';
// Which cycle day the daily view describes. null means "the newest day in the
// data", which is how this card behaved before the picker existed.
// Persisted alongside the timeframe for the same reason: the FCR and EEF pages
// unmount this card on every data refresh.
const selectedDayKey = `insight-day-${topic}`;
const [selectedDay, setSelectedDayState] = useState<number | null>(() => {
try {
return sessionStorage.getItem(timeframeKey) === 'mingguan' ? 'mingguan' : 'harian';
const raw = sessionStorage.getItem(selectedDayKey);
const parsed = raw === null ? NaN : parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
} catch {
return 'harian';
return null;
}
});
const setTimeframe = useCallback(
(next: 'harian' | 'mingguan') => {
setTimeframeState(next);
const setSelectedDay = useCallback(
(next: number | null) => {
setSelectedDayState(next);
try {
sessionStorage.setItem(timeframeKey, next);
if (next === null) sessionStorage.removeItem(selectedDayKey);
else sessionStorage.setItem(selectedDayKey, String(next));
} catch {
/* ignore */
}
},
[timeframeKey]
[selectedDayKey]
);
/**
* Days this page can actually answer for.
*
* Built from the history the scalars are derived from, NOT from the cycle
* length: the FCR and EEF pages carry `tren_7_hari_terakhir`, so a 1..48 list
* would offer 41 days whose window is empty and whose report reads "periode
* tidak tersedia". Offering only what exists keeps every option meaningful.
*/
const availableDays = React.useMemo(() => {
const key =
scalarScope?.historyKey ?? (chartSpec?.type === 'trend' ? chartSpec.historyKey : null);
if (!key) return [];
const rows = (contextData as Record<string, unknown>)[key];
if (!Array.isArray(rows)) return [];
const days = new Set<number>();
for (const row of rows) {
if (typeof row !== 'object' || row === null) continue;
const r = row as Record<string, unknown>;
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]);
/**
* Only a day the data actually contains may scope the report. A day persisted
* from a page with a longer history would otherwise slice this one to an empty
* window and the card would read "periode tidak tersedia".
*/
const activeDay =
timeframe === 'harian' && selectedDay !== null && availableDays.includes(selectedDay)
? selectedDay
: null;
/**
* Identity of the scope currently on screen: topic + timeframe + day.
*
* Everything that decides "is the text on screen the one the user asked for"
* compares against this. Omitting the day would make day 5 and day 12 share a
* cache entry, so switching days would show the previous day's report.
*/
const scopeKey = `${cacheKey}-${timeframe}${activeDay === null ? '' : `-h${activeDay}`}`;
const inFlightRef = useRef(false);
const lastCacheKeyRef = useRef<string | null>(null);
const pdfRef = useRef<HTMLDivElement>(null);
@@ -236,10 +293,16 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
})
.filter((day): day is number => day !== null);
if (days.length === 0) return null;
// The band must cover exactly the window analysed. Daily with a day picked
// is cumulative (day 0 -> N), so shading only day N would contradict both
// the figures and the trimmed chart.
if (timeframe === 'harian' && activeDay !== null) {
return { firstDay: Math.min(...days), lastDay: activeDay };
}
const lastDay = Math.max(...days);
const firstDay = timeframe === 'harian' ? lastDay : Math.max(Math.min(...days), lastDay - 6);
return { firstDay, lastDay };
}, [chartSpec, contextData, timeframe]);
}, [chartSpec, contextData, timeframe, activeDay]);
/**
* The figures shown in the summary grid, scoped exactly like the prompt.
@@ -251,7 +314,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
const displayContext = React.useMemo(() => {
const raw = contextData as Record<string, unknown>;
if (!scalarScope) return raw;
const sliced = scopeContextToTimeframe(raw, timeframe);
const sliced = scopeContextToTimeframe(raw, timeframe, activeDay);
const scoped = scopeScalarsToTimeframe(
sliced as Record<string, unknown>,
timeframe,
@@ -266,10 +329,34 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
for (const key of scalarScope.unavailable ?? []) out[key] = UNAVAILABLE;
out.periode = scoped.periode;
return out;
}, [contextData, timeframe, scalarScope]);
}, [contextData, timeframe, activeDay, scalarScope]);
const periodeLabel = typeof displayContext.periode === 'string' ? displayContext.periode : null;
/**
* Rows the trend chart draws.
*
* Picking a day means "the cycle up to that day", so the chart is cut there
* too — plotting the full history would show days the figures below exclude.
* Every other mode keeps the whole history, where the shaded band is what
* marks the analysed part.
*/
const trendChartRows = React.useMemo(() => {
if (!chartSpec || chartSpec.type !== 'trend') return [];
const rows = (contextData as Record<string, unknown>)[chartSpec.historyKey];
if (!Array.isArray(rows)) return [];
if (timeframe !== 'harian' || activeDay === null) {
return rows as Array<Record<string, unknown>>;
}
return (rows as Array<Record<string, unknown>>).filter((row) => {
for (const field of ['hari', 'day']) {
const value = row[field];
if (typeof value === 'number' && Number.isFinite(value)) return value <= activeDay;
}
return true;
});
}, [chartSpec, contextData, timeframe, activeDay]);
/**
* The one figure this card is about.
*
@@ -281,8 +368,13 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
const heroSpec = React.useMemo(() => {
const num = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
const standard = cycleData?.currentDay ? getCobbStandardForDay(cycleData.currentDay) : null;
const dayLabel = cycleData?.currentDay ? `hari ke-${cycleData.currentDay}` : null;
// Graded at the day the report is ABOUT, not at today's. A day-44 FCR
// compared against the day-48 standard invents a deficit that is not there,
// and the card printed "standar CP 707 ... pada hari ke-48" beside a day-44
// figure.
const standardDay = activeDay ?? cycleData?.currentDay ?? null;
const standard = standardDay ? getCobbStandardForDay(standardDay) : null;
const dayLabel = standardDay ? `hari ke-${standardDay}` : null;
if (topic === 'berat_ayam') {
const actual = num(displayContext.averageWeight);
@@ -314,9 +406,12 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
// Lower FCR is better, so the comparison runs the other way.
return {
label: 'FCR Aktual',
value: actual.toFixed(2),
// 3 desimal: membulatkan ke 2 menghapus digit terakhir FCR (1,704 -> 1,70).
value: formatTruncatedRatio(actual, 3),
unit: undefined,
context: std ? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${std.toFixed(2)}` : null,
context: std
? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${formatTruncatedRatio(std, 3)}`
: null,
status: (std === null
? 'unknown'
: actual <= std
@@ -395,7 +490,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
}
return null;
}, [topic, displayContext, cycleData?.currentDay]);
}, [topic, displayContext, cycleData?.currentDay, activeDay]);
// Indexing a Record yields `T | undefined`, so a lookup fallback is undefined
// too. Naming the defaults directly gives both a value the type system can
@@ -406,7 +501,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
// eslint-disable-next-line react-hooks/preserve-manual-memoization
const generate = useCallback(
async (force = false) => {
const storageKey = `page-insight-v5-${cacheKey}-${timeframe}`;
const storageKey = `page-insight-v5-${scopeKey}`;
// ── On force refresh, evict all caches first ──────────────────────────────
if (force) {
@@ -424,9 +519,9 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
if (memCached) {
setResult(memCached);
// Must carry the timeframe: the effect guard compares against
// `${cacheKey}-${timeframe}`, so a bare cacheKey never matched and
// scopeKey, so a bare cacheKey never matched and
// this path re-triggered generation on every render.
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
lastCacheKeyRef.current = scopeKey;
return;
}
}
@@ -437,7 +532,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
const memCached = _insightMemoryCache.get(storageKey);
if (memCached) {
setResult(memCached);
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
lastCacheKeyRef.current = scopeKey;
}
return;
}
@@ -451,7 +546,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
if (parsed) {
_insightMemoryCache.set(storageKey, parsed); // promote to memory
setResult(parsed);
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
lastCacheKeyRef.current = scopeKey;
return;
}
sessionStorage.removeItem(storageKey);
@@ -477,7 +572,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
// Trim the data itself to the selected window. Previously the full cycle
// was sent in both modes and only this label changed, so "Harian" and
// "Mingguan" came back nearly identical.
const slicedContextData = scopeContextToTimeframe(contextData, timeframe);
const slicedContextData = scopeContextToTimeframe(contextData, timeframe, activeDay);
// Scalars must follow the same window as the history they summarise.
const scopedContextData = scalarScope
? scopeScalarsToTimeframe(
@@ -497,7 +592,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({
? 'Kondisi terkini (pembacaan sesaat, bukan rentang waktu)'
: typeof dataPeriode === 'string' && dataPeriode.trim().length > 0
? dataPeriode
: describeTimeframe(timeframe, currentDayVal);
: describeTimeframe(timeframe, currentDayVal, activeDay);
// Surface the gap rather than hide it: data lagging the cycle is itself a
// finding, and leaving it implicit is what let the mismatch through.
@@ -538,7 +633,7 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
// cycle only — not by topic and not by timeframe — so reading it here
// handed every page the same text and made the Harian/Mingguan toggle
// look broken. The memory + sessionStorage caches above are keyed by
// `${cacheKey}-${timeframe}`, which is the scope this component needs.
// scopeKey, which is the scope this component needs.
const system_prompt =
'Anda adalah analis peternakan ayam broiler berpengalaman. Kembalikan HANYA objek JSON valid dengan format: {"kesimpulan": "kesimpulan analisis", "insight": "rekomendasi/tindakan praktis"}. Dilarang memberikan teks penjelasan di luar objek JSON.';
@@ -576,7 +671,7 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
_saveToCache(storageKey, parsed);
setResult(parsed);
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
lastCacheKeyRef.current = scopeKey;
} catch (err) {
console.error(`[PageAiInsight:${topic}] LLM request failed/timed out:`, err);
const errMsg = err instanceof Error ? err.message : 'Gagal menghasilkan AI Insight.';
@@ -599,13 +694,13 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
// eslint-disable-next-line react-hooks/preserve-manual-memoization
// eslint-disable-next-line react-hooks/exhaustive-deps
},
[topic, cacheKey, contextData, cycleData?.id, result, timeframe, scalarScope]
[topic, cacheKey, contextData, cycleData?.id, result, timeframe, activeDay, scalarScope]
);
// Refreshed after commit, not during render: writing a ref while rendering is
// unsafe under concurrent rendering, and both are only ever read from async
// callbacks that run well after this effect.
useEffect(() => {
desiredKeyRef.current = `${cacheKey}-${timeframe}`;
desiredKeyRef.current = scopeKey;
generateRef.current = generate;
});
@@ -617,10 +712,10 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
// 800ms timer and rescheduling it, and on a page that re-renders faster than
// that the insight was never requested at all.
useEffect(() => {
if (lastCacheKeyRef.current === `${cacheKey}-${timeframe}`) return;
if (lastCacheKeyRef.current === scopeKey) return;
const timer = setTimeout(() => generateRef.current(false), 800);
return () => clearTimeout(timer);
}, [cacheKey, timeframe]);
}, [cacheKey, timeframe, activeDay]);
const downloadPdf = async () => {
if (!result || !pdfRef.current) return;
@@ -637,12 +732,20 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
maxDays && rawCtxDay !== undefined && rawCtxDay > maxDays ? maxDays : rawCtxDay;
// Same identity as the printed header.
const fromDay =
timeframe === 'harian' ? currentDayVal : Math.max(1, (currentDayVal || 1) - 6);
timeframe === 'harian'
? (activeDay ?? currentDayVal)
: Math.max(1, (currentDayVal || 1) - 6);
const filename = buildPdfFilename({
reportName: topic,
area: appCtx.selectedArea || 'Sukawarna',
kandangLabel: describeKandangScope(appCtx.kandangs, appCtx.selectedKandangId),
periodSlug: timeframe === 'harian' ? 'Harian' : 'Mingguan',
// One file per day instead of every day overwriting "…-Harian.pdf".
periodSlug:
timeframe === 'harian'
? activeDay === null
? 'Harian'
: `Harian-Hari-${activeDay}`
: 'Mingguan',
isoDates: isoDataDates(
cycleData?.startDate,
fromDay,
@@ -665,12 +768,15 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
title={`AI Insight — ${topic.replace(/_/g, ' ')}`}
area={appCtx.selectedArea || 'Sukawarna'}
kandangLabel={describeKandangScope(appCtx.kandangs, appCtx.selectedKandangId)}
periodLabel={describeTimeframePeriod(timeframe)}
periodLabel={describeTimeframePeriod(
timeframe,
timeframe === 'harian' && activeDay !== null ? `Hari ${activeDay}` : null
)}
cycleId={cycleData?.id}
dataDateLabel={describeDataDates(
cycleData?.startDate,
timeframe === 'harian'
? cycleData?.currentDay
? (activeDay ?? cycleData?.currentDay)
: Math.max(1, (cycleData?.currentDay || 1) - 6),
timeframe === 'harian' ? null : cycleData?.currentDay
)}
@@ -693,25 +799,21 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
</div>
</div>
<div className="flex items-center gap-2" data-html2canvas-ignore="true">
{/* TIMEFRAME TOGGLE — only where the period can actually change the
payload. The IoT panel is a live snapshot with no day-indexed
history, so both settings produced a byte-identical prompt and the
toggle offered a choice it could not honour. */}
{supportsTimeframe && (
<div className="flex bg-slate-100 rounded-lg p-0.5 border border-slate-200 mr-1">
<button
onClick={() => setTimeframe('harian')}
className={`px-2 py-1 text-[11px] font-semibold rounded-md transition-all ${timeframe === 'harian' ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Harian
</button>
<button
onClick={() => setTimeframe('mingguan')}
className={`px-2 py-1 text-[11px] font-semibold rounded-md transition-all ${timeframe === 'mingguan' ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
>
Mingguan
</button>
</div>
{/* DAY PICKER — same role as the dashboard's: the daily view describes
one cycle day, and without this it could only ever be the newest. */}
{supportsTimeframe && timeframe === 'harian' && availableDays.length > 1 && (
<select
value={activeDay ?? availableDays[availableDays.length - 1]}
onChange={(e) => setSelectedDay(parseInt(e.target.value, 10))}
className="text-[11px] font-semibold border border-slate-200 bg-white text-slate-700 rounded-lg px-2 py-1 shadow-sm focus:border-slate-400 focus:ring-slate-400"
title="Pilih hari siklus"
>
{availableDays.map((d) => (
<option key={d} value={d}>
Hari {d}
</option>
))}
</select>
)}
{result && (
<button
@@ -842,6 +944,12 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
{renderValue(displayContext.ayam_hidup_ekor, 'Ekor')}
</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">Total Bobot Panen</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.beratPanen_kg, 'kg')}
</span>
</div>
</>
)}
@@ -867,25 +975,19 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
{renderValue(displayContext.bw_panen_gram, 'g')}
</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 font-bold text-gray-900">
{renderValue(displayContext.bobotHarian_gram, 'hari')}
</span>
</div>
</>
)}
{topic === 'iot_panel' && (
<>
<div className="flex justify-between items-center py-1 border-b border-gray-50">
<span className="text-sm text-gray-600 font-medium">
Target Suhu (Setpoint)
</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.setpoint_C, '°C')}
</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">Suhu Luar</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.suhu_luar_C, '°C')}
</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">Kelembapan</span>
<span className="text-sm font-bold text-gray-900">
@@ -898,6 +1000,18 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
{renderValue(displayContext.kecepatan_angin_mPerDetik, 'm/s')}
</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">Experience Suhu</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.experience_suhu_C, '°C')}
</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">Konsumsi Air</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.konsumsi_air_L, 'L')}
</span>
</div>
</>
)}
@@ -921,14 +1035,6 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
<span className="ml-1 text-xs font-normal text-gray-400">Manual</span>
</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">
Rata-rata Konsumsi Harian
</span>
<span className="text-sm font-bold text-gray-900">
{renderValue(displayContext.rata_karung_per_hari, 'Karung/Hari')}
</span>
</div>
</>
)}
</div>
@@ -938,11 +1044,7 @@ Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`;
<div className="mt-6 pt-4 border-t border-purple-100" data-pdf-block>
{chartSpec.type === 'trend' ? (
<InsightTrendChart
rows={
(contextData as Record<string, unknown>)[chartSpec.historyKey] as Array<
Record<string, unknown>
>
}
rows={trendChartRows}
series={chartSpec.series}
unit={chartSpec.unit}
title={chartSpec.title}
+5 -2
View File
@@ -21,7 +21,9 @@ export const describeReportPeriod = (
return reportPeriod ? `Laporan Mingguan — ${reportPeriod}` : 'Laporan Mingguan';
}
if (reportType === 'end_cycle') return 'Laporan Akhir Siklus';
return 'Laporan Harian';
// A daily report can now name a past day, so the header must say which one —
// otherwise two reports for different days print identical titles.
return reportPeriod ? `Laporan Harian — ${reportPeriod}` : 'Laporan Harian';
};
/** Same, for the cards that use the harian/mingguan toggle instead. */
@@ -168,7 +170,8 @@ export const reportPeriodSlug = (
): string => {
if (reportType === 'weekly') return reportPeriod ? `Mingguan-${reportPeriod}` : 'Mingguan';
if (reportType === 'end_cycle') return 'Akhir-Siklus';
return 'Harian';
// Keeps one file per day instead of every day overwriting "…-Harian.pdf".
return reportPeriod ? `Harian-${reportPeriod}` : 'Harian';
};
const formatDate = (value?: Date | string | null): string => {
+18 -12
View File
@@ -81,6 +81,8 @@ import {
processDeviceWeightData,
} from '../utils/scaleDataProcessor.ts';
import { calculateCp707Uniformity, getCp707UniformityStatus } from '../utils/weightUtils.ts';
import { cobbStandardPerformance } from '../mockData/cobbStandard.ts';
import {
shouldUsePreviousDay,
@@ -942,7 +944,12 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
max: apiData.statistik.max,
avg: apiData.statistik.avg,
standardDeviation: apiData.statistik.Standard_deviasi,
uniformity: apiData.statistik.uniformity,
// Dihitung ulang menurut CP 707 (+/-10%); field `uniformity` dari API
// memakai band +/-5% sehingga tidak sebanding dengan ambang 80%.
uniformity: calculateCp707Uniformity(
apiData.statistik.avg,
apiData.statistik.Standard_deviasi
),
};
staticDataStore.setWeightDistribution(chartData);
@@ -1107,8 +1114,13 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
dailyGain = avgWeight - priorAvg;
}
// Get uniformity from distribusi bobot API
const uniformity = selectedDistData.statistik.uniformity || 0;
// Keseragaman dihitung ulang menurut CP 707 (+/-10% dari rata-rata).
// Field `uniformity` bawaan API memakai band +/-5%, sehingga nilainya
// (median 70 hari = 39.8) tidak sebanding dengan ambang CP 707 >80%.
const uniformity = calculateCp707Uniformity(
selectedDistData.statistik.avg,
selectedDistData.statistik.Standard_deviasi
);
// Status helper functions
const getAdgStatus = (adg: number): 'ok' | 'warning' | 'critical' => {
@@ -1117,12 +1129,6 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
return 'critical';
};
const getUniformityStatus = (uniformity: number): 'ok' | 'warning' | 'critical' => {
if (uniformity >= 85) return 'ok';
if (uniformity >= 75) return 'warning';
return 'critical';
};
// Set stats from distribusi bobot API
// Keep precision: avgWeight with 2 decimals, uniformity with 1 decimal (no rounding yet for display)
const stats = {
@@ -1137,9 +1143,9 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
status: getAdgStatus(dailyGain),
},
uniformity: {
value: uniformity, // Keep full precision, will format in display
unit: '%',
status: getUniformityStatus(uniformity),
value: uniformity ?? 0, // Keep full precision, will format in display
unit: uniformity === null ? 'N/A' : '%',
status: getCp707UniformityStatus(uniformity),
},
dataDate: selectedDateLabel, // Add the date label
};
+11
View File
@@ -0,0 +1,11 @@
const fs = require('fs');
let content = fs.readFileSync('./components/AiInsightCard.tsx', 'utf8');
content = content.replace(/import \{ jsPDF \} from 'jspdf';\nimport html2canvas from 'html2canvas';\n/g, '');
content = `import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas';
` + content;
fs.writeFileSync('./components/AiInsightCard.tsx', content, 'utf8');
+81
View File
@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import {
calculateCp707Uniformity,
getCp707UniformityStatus,
CP707_UNIFORMITY_TARGET,
} from '../weightUtils.ts';
/**
* Keseragaman dihitung ulang dari `avg` + `Standard_deviasi` menurut CP 707
* (persen ayam dalam ±10% dari rata-rata), bukan diambil dari field
* `uniformity` milik API `distribusi_bobot` yang memakai band ±5%.
*
* Nilai acuan di bawah berasal dari respons API sungguhan dan dicocokkan
* dengan perhitungan referensi `math.erf` di Python.
*/
describe('calculateCp707Uniformity', () => {
it.each([
// tanggal, avg (g), Standard_deviasi (g), harapan (%)
['2026-08-10', 1123.53, 161.3, 51.4],
['2026-08-05', 806.79, 26.87, 99.7],
['2026-08-04', 667.31, 139.71, 36.7],
['2026-06-18', 1324.8, 106.5, 78.6],
])('cocok dengan perhitungan referensi untuk %s', (_tanggal, avg, sd, harapan) => {
expect(calculateCp707Uniformity(avg, sd)).toBeCloseTo(harapan as number, 0);
});
it('tidak terpengaruh lebar bucket 50 g di awal siklus', () => {
// Bobot 75 g: band ±10% hanya 15 g, jauh lebih sempit dari bucket 50 g milik
// API. Perhitungan berbasis bucket gagal di sini; berbasis SD tidak.
expect(calculateCp707Uniformity(75.4, 3.3)).toBeCloseTo(97.8, 0);
});
it('mengembalikan 100 saat tidak ada variasi bobot', () => {
expect(calculateCp707Uniformity(1000, 0)).toBe(100);
});
it('hasilnya selalu berada di rentang 0-100', () => {
for (const [avg, sd] of [
[1000, 1],
[1000, 5000],
[50, 0.1],
]) {
const v = calculateCp707Uniformity(avg, sd);
expect(v).not.toBeNull();
expect(v as number).toBeGreaterThanOrEqual(0);
expect(v as number).toBeLessThanOrEqual(100);
}
});
it('mengembalikan null, bukan angka karangan, saat data tidak memadai', () => {
expect(calculateCp707Uniformity(null, 10)).toBeNull();
expect(calculateCp707Uniformity(1000, null)).toBeNull();
expect(calculateCp707Uniformity(undefined, undefined)).toBeNull();
expect(calculateCp707Uniformity(0, 10)).toBeNull();
expect(calculateCp707Uniformity(-100, 10)).toBeNull();
expect(calculateCp707Uniformity(1000, -5)).toBeNull();
expect(calculateCp707Uniformity(NaN, 10)).toBeNull();
expect(calculateCp707Uniformity(1000, Infinity)).toBeNull();
});
it('naik saat sebaran menyempit', () => {
const ketat = calculateCp707Uniformity(1000, 30) as number;
const longgar = calculateCp707Uniformity(1000, 150) as number;
expect(ketat).toBeGreaterThan(longgar);
});
});
describe('getCp707UniformityStatus', () => {
it('memakai ambang CP 707 (>80%), bukan ambang lama 85/75', () => {
expect(getCp707UniformityStatus(CP707_UNIFORMITY_TARGET)).toBe('ok');
expect(getCp707UniformityStatus(82)).toBe('ok');
// 82 dulu berstatus "warning" dengan ambang lama 85.
expect(getCp707UniformityStatus(75)).toBe('warning');
expect(getCp707UniformityStatus(70)).toBe('warning');
expect(getCp707UniformityStatus(69.9)).toBe('critical');
});
it('memperlakukan data tidak tersedia sebagai critical', () => {
expect(getCp707UniformityStatus(null)).toBe('critical');
});
});
+94
View File
@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest';
import { scopeContextToTimeframe, describeTimeframe } from '../insightScope.ts';
/**
* Picking a day in the AI Insight day picker means "the cycle so far, up to
* that day" — day 0 through day N — not that day in isolation. A single row
* shows no trend and cannot support a cumulative figure such as
* mortality-to-date, which is what these cards lead with.
*/
const ctx = {
riwayat: [
{ hari: 0, nilai: 5 },
{ hari: 1, nilai: 10 },
{ hari: 5, nilai: 50 },
{ hari: 12, nilai: 120 },
{ hari: 30, nilai: 300 },
],
// Snapshot list: no time dimension, must survive slicing intact.
perangkat: [{ nama: 'B1' }, { nama: 'B2' }],
bobot: 1234,
};
const daysIn = (scoped: typeof ctx) => scoped.riwayat.map((row) => row.hari);
describe('scopeContextToTimeframe — jendela kumulatif hari 0..N', () => {
it('menyertakan seluruh hari sampai hari yang dipilih', () => {
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 5))).toEqual([0, 1, 5]);
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 12))).toEqual([0, 1, 5, 12]);
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 30))).toEqual([0, 1, 5, 12, 30]);
});
it('tidak menyertakan hari setelah hari yang dipilih', () => {
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 5))).not.toContain(12);
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 5))).not.toContain(30);
});
it('menyertakan hari 0 sebagai awal siklus', () => {
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 1))).toEqual([0, 1]);
});
it('hari tanpa data sendiri tetap memuat hari-hari sebelumnya', () => {
// Hari 7 tidak ada barisnya, tapi jendela 0..7 tetap berisi hari 0, 1, 5.
expect(daysIn(scopeContextToTimeframe(ctx, 'harian', 7))).toEqual([0, 1, 5]);
});
it('tetap memakai hari terakhir saja bila tidak ada hari yang diminta', () => {
// Perilaku lama, dipakai saat pengguna belum menyentuh dropdown.
expect(daysIn(scopeContextToTimeframe(ctx, 'harian'))).toEqual([30]);
});
it('tidak memotong daftar snapshot dan tidak menyentuh skalar', () => {
const scoped = scopeContextToTimeframe(ctx, 'harian', 5);
expect(scoped.perangkat).toHaveLength(2);
expect(scoped.bobot).toBe(1234);
});
it('tidak mengubah perilaku mingguan', () => {
// Jendela 7 hari tetap dihitung dari hari terakhir, apa pun hari yang dipilih.
expect(daysIn(scopeContextToTimeframe(ctx, 'mingguan', 5))).toEqual([30]);
});
it('mengenali penamaan field "day" maupun "hari"', () => {
const inggris = {
riwayat: [
{ day: 3, nilai: 1 },
{ day: 9, nilai: 2 },
],
};
expect(scopeContextToTimeframe(inggris, 'harian', 3).riwayat.map((r) => r.day)).toEqual([3]);
});
});
describe('describeTimeframe — label periode', () => {
it('menyatakan rentang kumulatif, bukan satu hari', () => {
// Menyebut "hari ke-30" saja membuat model melaporkan angka kumulatif
// 31 hari seolah-olah itu capaian satu hari.
expect(describeTimeframe('harian', 48, 30)).toContain('hari ke-0 sampai hari ke-30');
expect(describeTimeframe('harian', 48, 30)).toContain('kumulatif');
});
it('menandai bahwa periode berakhir bukan di hari ini', () => {
expect(describeTimeframe('harian', 48, 30)).toContain('BUKAN hari ini');
});
it('menandai bahwa periode berakhir di hari ini bila memang begitu', () => {
expect(describeTimeframe('harian', 30, 30)).toContain('berakhir hari ini');
expect(describeTimeframe('harian', 30, 30)).not.toContain('BUKAN hari ini');
});
it('mempertahankan perilaku lama saat tidak ada hari dipilih', () => {
expect(describeTimeframe('harian', 30)).toBe('Hari ini saja (hari ke-30)');
expect(describeTimeframe('mingguan', 30)).toContain('7 hari terakhir');
});
});
+25
View File
@@ -32,6 +32,31 @@ export const AIR_QUALITY = {
co2Ideal_ppm: 3000,
};
/**
* Standar konsumsi air per 1000 ekor per hari pada suhu 21°C (buku CP 707).
* Di atas 21°C kebutuhan naik rata-rata 6.5% per derajat.
*/
const WATER_CONSUMPTION_STANDARD: { week: number; minLiter: number; maxLiter: number }[] = [
{ week: 1, minLiter: 58, maxLiter: 65 },
{ week: 2, minLiter: 102, maxLiter: 115 },
{ week: 3, minLiter: 149, maxLiter: 167 },
{ week: 4, minLiter: 192, maxLiter: 216 },
{ week: 5, minLiter: 232, maxLiter: 261 },
{ week: 6, minLiter: 274, maxLiter: 308 },
{ week: 7, minLiter: 309, maxLiter: 347 },
{ week: 8, minLiter: 342, maxLiter: 385 },
];
/** Standar konsumsi air (rata-rata min–max) untuk umur hari tertentu, atau null. */
export const getWaterStandardByDay = (
dayAge: number | null | undefined
): { min: number; max: number } | null => {
if (typeof dayAge !== 'number' || !Number.isFinite(dayAge) || dayAge <= 0) return null;
const week = Math.min(Math.ceil(dayAge / 7), WATER_CONSUMPTION_STANDARD.length);
const row = WATER_CONSUMPTION_STANDARD.find((r) => r.week === week);
return row ? { min: row.minLiter, max: row.maxLiter } : null;
};
/**
* Suhu target untuk umur tertentu, atau null bila umurnya tidak masuk akal.
* Umur di luar tabel jatuh ke baris terakhir (36+ hari), sesuai isi buku.
+43 -11
View File
@@ -53,15 +53,24 @@ const isDayIndexedArray = (value: unknown[]): value is DayRow[] =>
isPlainObject(item) && (dayOf(item as DayRow) !== null || hasDateField(item as DayRow))
);
const sliceHistory = (rows: DayRow[], timeframe: InsightTimeframe): DayRow[] => {
const sliceHistory = (
rows: DayRow[],
timeframe: InsightTimeframe,
selectedDay: number | null = null
): DayRow[] => {
const days = rows.map(dayOf).filter((day): day is number => day !== null);
if (timeframe === 'harian') {
if (days.length > 0) {
const latestDay = Math.max(...days);
return rows.filter((row) => dayOf(row) === latestDay);
// Picking a day means "the cycle so far, up to that day" — day 0 through
// day N — not that day in isolation. A single row shows no trend and
// cannot support a cumulative figure like mortality-to-date.
if (selectedDay !== null)
return rows.filter((row) => (dayOf(row) ?? Infinity) <= selectedDay);
return rows.filter((row) => dayOf(row) === Math.max(...days));
}
// Date-only history: the last row is the most recent one.
// Date-only history: the array position is the only ordering we have.
if (selectedDay !== null) return rows.slice(0, selectedDay + 1);
return rows.slice(-1);
}
@@ -81,18 +90,24 @@ const sliceHistory = (rows: DayRow[], timeframe: InsightTimeframe): DayRow[] =>
* Returns a copy of `contextData` where every day-indexed history is trimmed to
* `timeframe`. Snapshot lists, scalars, and nested objects are preserved.
*/
export const scopeContextToTimeframe = <T>(contextData: T, timeframe: InsightTimeframe): T => {
export const scopeContextToTimeframe = <T>(
contextData: T,
timeframe: InsightTimeframe,
selectedDay: number | null = null
): T => {
if (Array.isArray(contextData)) {
const scoped = isDayIndexedArray(contextData)
? sliceHistory(contextData as DayRow[], timeframe)
? sliceHistory(contextData as DayRow[], timeframe, selectedDay)
: contextData;
return scoped.map((item) => scopeContextToTimeframe(item, timeframe)) as unknown as T;
return scoped.map((item) =>
scopeContextToTimeframe(item, timeframe, selectedDay)
) as unknown as T;
}
if (isPlainObject(contextData)) {
const scoped: Record<string, unknown> = {};
for (const [key, value] of Object.entries(contextData)) {
scoped[key] = scopeContextToTimeframe(value, timeframe);
scoped[key] = scopeContextToTimeframe(value, timeframe, selectedDay);
}
return scoped as unknown as T;
}
@@ -184,10 +199,12 @@ export const scopeScalarsToTimeframe = (
for (const key of spec.drop ?? []) delete out[key];
const days = rows.map(dayOf).filter((day): day is number => day !== null);
// A window covering more than one day must be named as a range, or the model
// reads "hari ke-30" and reports a 31-day cumulative figure as one day's.
const label =
days.length === 0
? UNAVAILABLE
: timeframe === 'harian'
: Math.min(...days) === Math.max(...days)
? `hari ke-${Math.max(...days)}`
: `hari ke-${Math.min(...days)} s/d ke-${Math.max(...days)}`;
@@ -200,7 +217,11 @@ export const scopeScalarsToTimeframe = (
out.periode = label;
// Only the multi-day window needs the "not today" warning; saying it in daily
// mode contradicts the label directly above it.
const bukanHariIni = timeframe === 'harian' ? '' : 'BUKAN kondisi hari ini. ';
// Only a window that is not today needs the "not today" warning; saying it in
// a single-day-today report contradicts the label directly above it.
const satuHariTerakhir =
timeframe === 'harian' && days.length > 0 && Math.min(...days) === Math.max(...days);
const bukanHariIni = satuHariTerakhir ? '' : 'BUKAN kondisi hari ini. ';
out.catatan_periode =
`Angka ringkas di objek ini dihitung dari periode ${label} (${rows.length} hari data), ` +
`${bukanHariIni}Satuan setiap angka tertulis di akhir nama field ` +
@@ -212,8 +233,19 @@ export const scopeScalarsToTimeframe = (
};
/** Human-readable description of the window, for the prompt and the UI. */
export const describeTimeframe = (timeframe: InsightTimeframe, currentDay?: number | null) => {
export const describeTimeframe = (
timeframe: InsightTimeframe,
currentDay?: number | null,
selectedDay: number | null = null
) => {
if (timeframe === 'harian') {
// Daily now means "the cycle so far", so the label must state the range. A
// past end-day must not be described as "hari ini" either — the model
// repeats that phrasing back and claims the figures are today's.
if (selectedDay !== null) {
const ekor = `hari ke-0 sampai hari ke-${selectedDay} (kumulatif sejak awal siklus)`;
return selectedDay === currentDay ? `${ekor}, berakhir hari ini` : `${ekor}, BUKAN hari ini`;
}
return currentDay ? `Hari ini saja (hari ke-${currentDay})` : 'Hari ini saja (hari terakhir)';
}
return currentDay
+58
View File
@@ -119,3 +119,61 @@ export function formatWeight(
return `${formatted} kg${suffix}`;
}
/** CP 707 mendefinisikan keseragaman sebagai persen ayam dalam +/-10% dari bobot rata-rata. */
export const CP707_UNIFORMITY_BAND_PERCENT = 10;
/** Ambang CP 707: >80% dianggap seragam. */
export const CP707_UNIFORMITY_TARGET = 80;
/**
* Abramowitz & Stegun 7.1.26 -- galat maksimum 1.5e-7, jauh lebih kecil
* daripada ketidakpastian data timbangan itu sendiri.
*/
function erf(x: number): number {
const sign = x < 0 ? -1 : 1;
const z = Math.abs(x);
const t = 1 / (1 + 0.3275911 * z);
const poly =
t *
(0.254829592 + t * (-0.284496736 + t * (1.421413741 + t * (-1.453152027 + t * 1.061405429))));
return sign * (1 - poly * Math.exp(-z * z));
}
/**
* Keseragaman bobot menurut standar CP 707: persen ayam yang bobotnya berada
* dalam +/-10% dari rata-rata.
*
* Field `uniformity` bawaan API `distribusi_bobot` TIDAK dipakai karena
* memakai band +/-5% -- setengah lebih sempit dari CP 707 -- sehingga nilainya
* (median 70 hari = 39.8) tidak sebanding dengan ambang 80% milik CP 707.
*
* Perhitungan memakai `avg` dan `Standard_deviasi` asli dari API, bukan bucket
* distribusi, karena bucket selebar 50 g jauh melebihi lebar band di awal
* siklus (pada bobot 75 g, band +/-10% hanya 15 g) sehingga hasil dari bucket
* tidak dapat dipakai.
*
* @returns persentase 0-100, atau null bila data tidak memadai.
*/
export function calculateCp707Uniformity(
avg: number | null | undefined,
standardDeviation: number | null | undefined
): number | null {
if (typeof avg !== 'number' || !Number.isFinite(avg) || avg <= 0) return null;
if (typeof standardDeviation !== 'number' || !Number.isFinite(standardDeviation)) return null;
if (standardDeviation < 0) return null;
// Sebaran tanpa variasi: seluruh ayam otomatis berada di dalam band.
if (standardDeviation === 0) return 100;
const halfBand = (CP707_UNIFORMITY_BAND_PERCENT / 100) * avg;
const pct = erf(halfBand / (standardDeviation * Math.SQRT2)) * 100;
return Math.max(0, Math.min(100, pct));
}
/** Status kartu keseragaman berdasarkan ambang CP 707. */
export function getCp707UniformityStatus(uniformity: number | null): 'ok' | 'warning' | 'critical' {
if (uniformity === null) return 'critical';
if (uniformity >= CP707_UNIFORMITY_TARGET) return 'ok';
if (uniformity >= CP707_UNIFORMITY_TARGET - 10) return 'warning';
return 'critical';
}