Enhance AI Insight with new state management and PDF export functionality. Added selectedArea state to App for better context in reports. Introduced new markdown files for documentation on AI Insight improvements and setup instructions. Updated package dependencies and fixed TypeScript errors for improved stability.
CI / lint-and-test (push) Canceled after 0s

This commit is contained in:
Alberto-Audrix committed 2026-08-11 09:19:06 +07:00
1 parent a8b49374f1
commit 2a627a2c3f
84 files changed
+8405 -2682

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
---
name: run-app
description: Launch the AI Insight chicken-farm dashboard locally (Postgres + Ollama in Docker, RAG service, Node backend, Vite frontend) and smoke-test that every service answers. Use when asked to run, start, serve, or screenshot this app, or to verify a change works in the real running app.
---
# Running the AI Insight dashboard locally
Five services must be up. `start_all.sh` only handles three of them —
it assumes Postgres and Ollama are already running in Docker. If Docker
is down, the backend exits immediately with `ECONNREFUSED 127.0.0.1:15432`
and the dashboard renders with no data.
| Service | Port | Started by |
| ---------------------- | ------------------------ | ----------------------------- |
| Postgres 16 | 15432 | Docker (`dashboard-database`) |
| Ollama | 11434 | Docker (`dashboard-llm`) |
| RAG service (Python) | 5002 | `start_all.sh` |
| Backend (Node/Express) | 5001 | `start_all.sh` |
| Frontend (Vite) | 3000, falls back to 3001 | `start_all.sh` |
## 1. Bring up Docker
Docker Desktop is frequently not running. Launch it and wait — the daemon
takes 30-60s, so poll rather than assuming.
```bash
open -a Docker
for i in $(seq 1 90); do docker info >/dev/null 2>&1 && break; sleep 2; done
docker info >/dev/null 2>&1 || echo "Docker daemon still down — stop here"
```
Then start only the two infra containers. Do **not** `docker compose up`
everything: the `backend` / `frontend` / `rag` containers would fight the
native processes `start_all.sh` launches for the same ports.
```bash
cd "/Users/alfredaluthfihermana/Documents/MAGANG/AI INSIGHT WEB"
docker compose up -d database llm
until docker exec dashboard-database pg_isready -U dashboard_user -d dashboard_db >/dev/null 2>&1; do sleep 1; done
```
### Ollama port conflict
If Ollama.app is running natively it holds port 11434 and the
`dashboard-llm` container cannot bind it. Quit the native app first:
```bash
osascript -e 'quit app "Ollama"' 2>/dev/null
```
Confirm the model named in `backend/.env` (`LLM_MODEL_NAME`, currently
`qwen2.5:7b`) is actually pulled — AI Insight calls fail with a model-not-found
error otherwise:
```bash
curl -s http://localhost:11434/api/tags | python3 -c "import sys,json;print([m['name'] for m in json.load(sys.stdin).get('models',[])])"
# expect qwen2.5:7b to be present; pull with:
# docker exec dashboard-llm ollama pull qwen2.5:7b
```
## 2. Start the app services
`start_all.sh` ends in `tail -f /tmp/backend.log`, so it never exits — run
it in the background, not the foreground.
```bash
cd "/Users/alfredaluthfihermana/Documents/MAGANG/AI INSIGHT WEB"
bash start_all.sh # run backgrounded
```
It writes logs to `/tmp/rag_service.log`, `/tmp/backend.log`,
`/tmp/frontend.log`. The RAG service lives **outside this repo**, at
`/Users/alfredaluthfihermana/Documents/MAGANG/AI Insight/rag_service.py`.
To restart just the backend after a change:
```bash
pkill -f "node server.js"
cd "/Users/alfredaluthfihermana/Documents/MAGANG/AI INSIGHT WEB/backend" && (npm run dev > /tmp/backend.log 2>&1 &)
```
## 3. Smoke-test — don't stop at "it started"
The backend health route is `/health`, **not** `/api/health` (`/api/health`
returns 404 and looks like a dead backend when it isn't).
```bash
grep -m1 "Local:" /tmp/frontend.log # actual frontend port
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5001/health
curl -s http://localhost:5001/api/kandangs | head -c 200
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5002/health
```
`/api/kandangs` returning `{"success":true,"data":[{"name":"Kandang Atas"...`
is the real proof the DB is wired up — a 200 on `/health` alone does not
tell you Postgres is reachable.
### Frontend port drifts
Port 3000 is often held by an unrelated Python process, and Vite silently
falls back to 3001. Always read the real URL from `/tmp/frontend.log`
rather than assuming 3000. If the AI Insight pages show CORS errors in the
browser console, the origin whitelist in `backend/server.js` (the `cors({
origin: [...] })` call near the top) does not include the port Vite
actually picked. It currently allows 3000, 3001, 3002, 5173, 5174, 5002 —
add the new port there if Vite drifts past those.
## 4. Drive it
For the AI Insight work specifically, open the running URL and exercise
the topic pages (`berat_ayam`, `fcr`, `eef`, `iot_panel`, `hitung_karung`)
in `components/shared/PageAiInsight.tsx` and
`components/counting/ChickenCountingAiInsight.tsx` — check the per-topic
summary grid renders, the insight text is not `[object Object]`, and the
Unduh PDF button produces a readable file. Generating an insight requires
Ollama to be up (step 1); it is the slowest path, so allow time.
## Stopping
```bash
pkill -f "rag_service.py"; pkill -f "node server.js"; pkill -f vite
docker compose stop database llm # optional
```
Binary file not shown.
+7 -1
View File
@@ -6,14 +6,20 @@ import { Kandang } from './types/counting.ts';
const App: React.FC = () => {
const [selectedKandangId, setSelectedKandangId] = useState<number | null>(null);
const [kandangs, setKandangs] = useState<Kandang[]>([]);
// Lifted out of AppContent so the insight cards can read it for their PDF
// headers. It drives the "Sukawarna" dropdown in the header, and a report has
// to name the location it describes.
const [selectedArea, setSelectedArea] = useState<string>('Sukawarna');
// This will be populated by AppContent once cycle data is available
// For now, we'll pass the state and setter to AppContent
return (
<AppContextProvider selectedKandangId={selectedKandangId}>
<AppContextProvider selectedKandangId={selectedKandangId} selectedArea={selectedArea}>
<AppContent
selectedKandangId={selectedKandangId}
setSelectedKandangId={setSelectedKandangId}
selectedArea={selectedArea}
setSelectedArea={setSelectedArea}
kandangs={kandangs}
setKandangs={setKandangs}
/>
+263
View File
@@ -0,0 +1,263 @@
# Catatan Lanjutan — AI Insight
Terakhir dikerjakan: **6 Agustus 2026**. Branch: `opet`.
---
## 1. Status sekarang (baca ini dulu)
| | Status |
| -------------------- | ----------------------------------------------------- |
| Commit terakhir | `bd4fee2` — **sudah di-push** ke `origin/opet` |
| Perubahan setelahnya | **40 file, BELUM di-commit**, hanya tersimpan di disk |
| Error `tsc` | 0 |
| Error lint | 0 |
| Tes backend | 27/27 lolos |
| Tes frontend | 6/6 lolos |
| Build produksi | sukses |
> **PERINGATAN:** 40 file itu belum di-commit. `git checkout .`, `git reset --hard`,
> `git stash`, atau pindah branch **akan menghapusnya permanen**. Kalau ragu,
> buat commit lokal dulu (tanpa push).
Cek cepat kondisi:
```bash
git log --oneline -1 # harus bd4fee2
git status --porcelain | wc -l # harus 40 (+1 fix_imports.cjs yang untracked)
```
---
## 2. Cara menjalankan aplikasi
Lima layanan harus hidup. `start_all.sh` hanya mengurus tiga — Postgres dan
Ollama diasumsikan sudah jalan di Docker.
```bash
# 1. Docker (butuh 30-60 detik)
open -a Docker
docker compose up -d database llm
# 2. Ollama.app bawaan macOS harus dimatikan dulu, kalau tidak port 11434 bentrok
osascript -e 'quit app "Ollama"'
# 3. Layanan aplikasi — WAJIB nohup, skrip ini tidak pernah exit
nohup bash start_all.sh > /tmp/start_all.log 2>&1 < /dev/null &
```
Cek: backend `http://localhost:5001/health` (bukan `/api/health`),
frontend biasanya di **3001** (port 3000 sering dipakai proses lain — baca
`grep "Local:" /tmp/frontend.log`).
**Penting:** jangan reload halaman browser sembarangan — sesi login akan hilang
dan perlu password Admin yang hanya Anda yang tahu.
---
## 3. Yang SUDAH selesai
### Sudah di-push (`bd4fee2`)
- **Toggle Harian/Mingguan** di 5 halaman topik: teks dan angka ringkasan
keduanya berubah sesuai periode.
- **Dashboard**: Harian, Minggu 1–7, Akhir Siklus → 9 teks unik (terverifikasi
lewat hash md5 di database).
- **Migrasi `014`** menambah `report_type`/`report_period` ke tabel
`ai_insights`. Sebelumnya cache hanya berkunci (siklus, kandang), jadi periode
pertama yang di-generate dilayankan untuk semua periode lain selama 6 jam.
- **Grafik tren populasi** di kartu AI Insight Hitung Ayam.
- **Timeout LLM 20 menit** di backend dan kedua batas frontend.
- **Panel IoT**: toggle periode dihapus (datanya snapshot sesaat, payload
Harian dan Mingguan identik byte-per-byte).
- Perbaikan tes: mock Ollama, root Vitest tidak lagi menyapu backend, dan
`await dbReady` di `query`/`transaction` yang menghilangkan deadlock.
### Belum di-commit (40 file)
Membereskan **142 error TypeScript → 0**. Mayoritas null-safety dari
`noUncheckedIndexedAccess`, tapi ada 4 bug nyata yang ikut tertangkap:
1. `apiClient.proxyChatPrompt` **tidak ada** — dipanggil 3× di
`services/aiInsightService.ts`; yang benar `proxyLmStudio`. Pasti error saat
dijalankan.
2. `cobbStandardPerformance` **tidak punya field `weight`** — jadi
`getCobbStandardForDay().weight` selalu `undefined` dan semua pemakainya
jatuh ke 0. Kini bersumber dari `getTargetWeightForDay`.
3. Kartu AI Insight **rusak di `ChickenCountingPage`** — dirender tanpa 5 prop
wajib, `generate()` berhenti di `!props.cycleId`, kartunya memuat selamanya.
4. `tsconfig` tidak memuat tipe `vitest/globals` dan `@testing-library/jest-dom`.
---
## 4. Yang MASIH terbuka
### a. Timeout LLM — SUDAH dikerjakan 7 Agustus 2026
`num_predict` di `backend/services/dashboardInsightService.js` sekarang
per-jenis-laporan lewat konstanta `NUM_PREDICT_BY_REPORT_TYPE`:
daily 2048, weekly 2560, end_cycle 4096 (sebelumnya 8192 untuk semua).
Koreksi atas saran lama: **~1500 terlalu kecil.** Diukur dari `insight_text`
yang tersimpan di database, respons nyata itu 2.800–4.300 karakter (≈1.000–
1.300 token), bukan ratusan token. Di 1500, laporan mingguan akan terpotong di
tengah JSON — parse gagal, dan generate-nya tetap jatuh ke fallback setelah
menghabiskan seluruh waktu generasi. Jadi justru lebih buruk.
Perlu diingat `num_predict` itu **batas atas, bukan target**: respons JSON yang
wajar berhenti sendiri, jadi menurunkannya tidak mempercepat generate normal —
gunanya hanya memotong kasus model ngelantur supaya tidak membakar timeout 20
menit sampai habis.
Ditambahkan juga peringatan log saat `done_reason === 'length'` (respons kena
batas dan terpotong), memakai variabel `finishReason` yang sebelumnya sudah
diambil tapi tidak pernah dipakai.
Terverifikasi: generate mingguan asli (bukan fallback) selesai ~2 menit 56
detik, 4.156 karakter, ketujuh topik terisi, tanpa peringatan terpotong.
**Masih terbuka di jalur lain:** `backend/routes/aiInsights.js:394` (proxy
insight per-halaman) masih `num_predict: 8000` dengan `num_ctx: 8192` — dua
angka itu berebut jendela konteks yang sama, jadi 8000 praktis tidak pernah
tercapai dan hanya menyisakan ruang sempit untuk prompt. Belum disentuh.
### a-bis. Data panen tidak terbawa ke insight — SUDAH diperbaiki 7 Agustus 2026
Gejalanya: jumlah ayam terpanen tidak pernah muncul di AI Insight dashboard
maupun Hitung Ayam. Akar masalahnya ada dua, dan berbeda di tiap halaman.
**Dashboard.** `totalPanen` sebenarnya SUDAH sampai ke prompt (terbukti dari
dump `contextText`: `"totalPanen": 22523`), tapi model tetap mengabaikannya,
karena:
1. Namanya tidak bersatuan (`totalPanen`), padahal prompt yang sama memerintahkan
"SATUAN SETIAP ANGKA TERTULIS DI AKHIR NAMA FIELD". Model tidak bisa tahu itu
ekor atau kg, jadi tidak dipakai.
2. Skema JSON `hitung_ayam` tidak pernah MEMINTA angka panen. Data yang ada tapi
tidak diminta memang akan dilewati.
3. `berat_panen` tidak pernah ikut dijumlahkan sama sekali.
4. Generator fallback lokal juga tidak menyebut panen, jadi di jalur itu mustahil
muncul.
Perbaikan: `totalPanen_ekor` + `totalBeratPanen_kg` (bersatuan), `panenCatatan`
yang menegaskan panen bukan kematian, aturan eksplisit di prompt, dan bullet
panen di generator fallback.
Terverifikasi lewat generate nyata — sebelum: bullet `hitung_ayam` hanya bicara
mortalitas; sesudah: "Total panen 22523 ekor dengan bobot 22924.6 kg".
**Hitung Ayam.** Blok total sudah menyebut "Sudah dipanen" (lihat docstring
`summariseDepartures`), tapi baris DATA PER KANDANG hanya memuat DOC, populasi
kini, mati+afkir, dan mortalitas — tanpa panen. Jadi model melihat populasi per
kandang anjlok tanpa penjelasan. Ditambahkan `dipanen=` per kandang dan baris
"Bobot panen".
**Periode vs kumulatif — sudah diperbaiki juga.** Sebelumnya `buildPrompt`
hanya menerima `departures` SE-SIKLUS padahal prompt-nya menyatakan "Data di
atas sudah dibatasi pada rentang tersebut", jadi mode Harian melaporkan
kematian dan panen seluruh siklus seolah terjadi hari itu. Sekarang prompt
dipecah dua blok berlabel: **blok A** = mati/afkir/panen/bobot panen DI DALAM
rentang, **blok B** = kumulatif sejak hari ke-1 (mortalitas kumulatif dan saldo
tetap kumulatif, karena standar CP 707 pembandingnya juga kumulatif, dan saldo
itu stok bukan aliran). DATA PER KANDANG diberi label kumulatif secara
eksplisit.
Terverifikasi dengan angka asli kandang 1 hari ke-48: model menuliskan 257 ekor
mati dan 1.226 ekor panen sebagai angka periode, dan menyebut 9,91% sebagai
"kumulatif sejak hari ke-1" — tidak lagi tertukar.
**Efek samping yang ikut tertangkap:** uji itu memperlihatkan model mengarang
angka turunan (membagi bobot panen dengan jumlah ekor, mengubah total jadi
per-hari). Blok "ATURAN ANGKA" yang selama ini hanya ada di jalur dashboard
kini ditambahkan juga ke prompt Hitung Ayam.
**Bug pemicu yang sebenarnya:** efek "re-generate when data becomes available"
hanya memantau `populationTrend` dan `kandangInsights`, TIDAK memantau
`mortalityRows`. Ledger mortalitas diambil lewat request terpisah dan sering
tiba SETELAH auto-generate 600 ms berjalan. Semua angka mati/afkir/panen
berasal dari ledger itu, jadi insight yang terlanjur dibuat menulis "data tidak
tersedia" untuk semuanya dan tidak ada yang meminta ulang. Ini menjelaskan
kenapa gejalanya kadang muncul kadang tidak. `mortalityRows.length` sudah
ditambahkan ke dependensi efek tersebut.
### a-ter. Laporan Akhir Siklus kosong — SUDAH diperbaiki 7 Agustus 2026
Gejala: kartu AI Insight dashboard pada mode Akhir Siklus menampilkan
"Ringkasan tidak tersedia — model tidak mengembalikan headline" dan "data belum
tersedia". Tiga sebab bertumpuk:
1. **Prompt terpotong.** Ollama memotong prompt ke SEPARUH `num_ctx`, jadi 8192
hanya memuat 4096 token prompt. Prompt end_cycle terukur 4098 token — persis
di pagu — dan yang dibuang adalah bagian AWAL, tempat skema JSON berada.
Model lalu menjawab dalam bentuk yang tidak dikenali. `num_ctx` dinaikkan ke
16384 di kedua jalur (disamakan supaya Ollama tidak me-reload model tiap kali
berganti jalur). Terbukti: log Ollama `n_ctx_slot = 16384`, prompt 6.622
token, `truncated = 0`.
2. **Undici memutus koneksi di 300 detik.** Ini yang paling menipu. `fetch`
bawaan Node punya `headersTimeout` default 300 detik, dan Ollama dipanggil
dengan `stream: false` sehingga TIDAK mengirim header apa pun sampai seluruh
jawaban selesai. Setiap generate yang lebih lama dari 5 menit mati dengan
`TypeError: fetch failed` pada detik ke-301, lalu diam-diam turun ke fallback
lokal. `AbortSignal.timeout(1200000)` yang 20 menit itu TIDAK PERNAH menjadi
batas efektif — undici menyerah lebih dulu. Diperbaiki lewat
`backend/services/llmDispatcher.js` (undici `Agent` dengan headersTimeout dan
bodyTimeout 30 menit) yang dipakai kedua jalur LLM.
Ini kemungkinan besar juga jawaban keluhan lama "sebagian insight bukan hasil
LLM" di bagian (a) — bukan karena model lambat, tapi karena koneksinya
diputus di menit ke-5.
3. **Parse "berhasil" padahal kosong.** `parseDashboardInsightJson` sengaja
memaafkan respons cacat dan mengisi field yang hilang. Akibatnya respons
berbentuk salah tetap lolos sebagai objek valid — cangkang dengan headline
placeholder dan semua array kosong. Karena hasil parse tidak null, generator
fallback tidak pernah jalan, dan cangkang itulah yang ditampilkan.
Ditambahkan `hasUsableContent()`; kalau kosong, diperlakukan sebagai gagal
parse sehingga fallback lokal mengambil alih.
Terverifikasi end-to-end dengan pack besar (48 hari × 5 topik): `source:
generated`, headline terisi, ketujuh topik punya bullets/evidence/actions,
275 detik.
**Belum disentuh:** tile "Uniformity (AI Scale)" masih N/A dan "Konsumsi pakan
(Sack Counting)" masih 0 di kartu dashboard — dua sumber data berbeda, belum
ditelusuri.
### b. Kompromi tipe di `AppContext.tsx`
Ditulis `cycleData: activeCycle as CycleData`. `activeCycle` bisa `undefined`
kalau database tidak punya siklus sama sekali. Melebarkan tipenya jadi opsional
akan merambat ke puluhan komponen yang membaca `cycleData.id` langsung. Sudah
ada komentar di kodenya.
### c. Denominator mortalitas dikarang
`components/AiInsightCard.tsx`: `const initialPopulation = cycleData.docInCount || 20000`.
Di siklus ini `docInCount` NULL, jadi 20000 dipakai padahal DOC sebenarnya
25.000. Perhitungan persentase sudah saya pindahkan ke backend supaya tidak
kena, tapi `metadata.initialPopulation` masih mengirim angka karangan ini.
### d. `fix_imports.cjs`
Skrip 11 baris yang menghapus baris import dari `AiInsightCard.tsx`. Sengaja
TIDAK di-commit — sekali pakai, dan berbahaya kalau orang lain menjalankannya.
---
## 5. Kalau menarik branch ini di komputer lain
**Migrasi `014` wajib dijalankan**, kalau tidak semua endpoint insight dashboard
akan error karena kolom `report_type`/`report_period` belum ada. Migrasi jalan
otomatis saat backend start — cukup restart backend.
---
## 6. Hal yang mudah bikin salah paham
- Halaman **"Hitung Ayam"** di sidebar itu `DataManagementPage.tsx`, BUKAN
`ChickenCountingPage.tsx`. Keduanya merender kartu AI Insight yang sama.
- Backend health ada di `/health`, bukan `/api/health`.
- Generate insight memang lambat (~75 detik sampai beberapa menit). Kalau
kartunya lama memuat, itu belum tentu rusak — cek `/tmp/backend.log`.
- Frontend hampir selalu di port **3001**, bukan 3000.
+329
View File
@@ -0,0 +1,329 @@
# Catatan Perbaikan AI Insight
**Tanggal:** 5 Agustus 2026
**Branch:** `opet`
**Ruang lingkup:** Seluruh fitur AI Insight — dashboard utama dan lima halaman menu
---
## Ringkasan Eksekutif
Fitur AI Insight sebelumnya menghasilkan angka yang **tidak dapat ditelusuri ke data**:
standar yang dikarang, satuan yang tertukar, topik berstatus "baik" padahal datanya
tidak pernah dikirim, dan mortalitas dilaporkan 100% untuk siklus yang sebenarnya
panen normal.
Sebanyak **14 cacat** diperbaiki. Setelah perbaikan, setiap angka yang muncul di
insight dapat ditelusuri ke database atau ke tabel buku CP 707, lengkap dengan
satuan yang benar. Ditambahkan pula **grafik di setiap kartu insight**.
Yang **belum** selesai adalah kualitas penarikan kesimpulan oleh model — angkanya
benar, tapi arah perbandingannya kadang terbalik. Ini keterbatasan model
`qwen2.5:7b` dan memerlukan keputusan terpisah (lihat bagian akhir).
---
## A. Yang Diperbaiki
### A.1 Angka yang dikarang model
| # | Masalah | Contoh nyata sebelum perbaikan | Penyebab | Perbaikan |
| --- | ---------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| 1 | Standar EEF dikarang | _"melebihi standar CP 707 yang maksimal **164,5**"_ | 164,5 adalah kolom **Feed Used CUM (gram)** di Lampiran 2 buku. Chunk RAG kehilangan judul kolom, sehingga model menempelkan angka pakan sebagai standar EEF. Standar IP/EEF asli di buku: **327–380**. | Standar buku disuntikkan terstruktur ke prompt, lengkap dengan satuan. Baris tabel tanpa judul kolom dihapus dari kutipan RAG. |
| 2 | Nilai EEF disalin dari bobot | _"EEF = **327,14**"_ — itu angka bobot rata-rata | Blok EEF hanya berisi input, tanpa nilai. Model mengisi sendiri. | EEF dihitung sungguhan dari persen hidup × bobot ÷ (FCR × umur). Bila input kurang, ditandai "tidak tersedia". |
| 3 | Populasi fiktif | _"Populasi awal: **10.000** ekor, saat ini: **9.800** ekor"_ | Nilai default hardcoded saat data tidak ada. | Diganti `null` + keterangan "data tidak tersedia". |
| 4 | Sensor hilang dinilai sehat | _"Lingkungan kandang **sesuai standar CP 707**"_ tanpa satu pun sensor terbaca | Kode menyuntikkan kelembapan 65% dan amonia 0 saat data kosong. | Parameter tak terukur dilewati; status menjadi `unknown`. |
### A.2 Satuan tertukar
| # | Masalah | Contoh | Perbaikan |
| --- | --------------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 5 | Gram dilaporkan sebagai persen | _"Deviasi **−32,86%**"_ — nilainya gram | Satuan dibenamkan ke **nama field**: `deviasiBobot_gram`, `deviasiBobot_persen`, `fcr_rasio`, `persenHidup_persen`, `nilaiEef_indeksTanpaSatuan`. Nama lama yang polos dihapus agar model tidak punya dua versi untuk dipilih. |
| 6 | Sensor mentah dikirim tanpa skala | Suhu 31,6 °C tersimpan sebagai **316**, dilaporkan _"suhu 316 °C"_ | Hanya nilai terhitung yang dikirim (`"32,1 °C"`, `"62,8 %"`), bukan encoding mentah sensor. |
| 7 | Persen hidup disebut mortalitas | _"mortalitas kumulatif (90,09%)"_ — 90,09 adalah persen **hidup** | Nama field diberi satuan + aturan eksplisit melarang menyamakan metrik berbeda. |
### A.3 Kesimpulan menenangkan tanpa dasar
| # | Masalah | Perbaikan |
| --- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 8 | Topik tanpa data berstatus **"ok"** (hijau). Contoh: `hitung_karung` hijau padahal nol data pakan dikirim. | Default status diubah menjadi `unknown`. Section tanpa temuan dipaksa `unknown`. Ditambah penjaga deterministik yang memeriksa ketersediaan data dan menimpa vonis model. |
| 9 | Headline default _"Kondisi operasional peternakan terpantau normal."_ muncul saat model gagal menjawab. | Diganti teks netral. Kalimat menenangkan bisa menutupi temuan kritis di bawahnya. |
| 10 | Mortalitas 0% saat populasi awal tidak diketahui | Diganti `null`; "0%" terbaca sebagai "tidak ada yang mati", pembacaan paling menyenangkan atas data yang hilang. |
### A.4 Kesalahan hitung
| # | Masalah | Angka salah | Angka benar | Penyebab |
| --- | ------------------------------------- | --------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------ |
| 11 | **Saldo Akhir Ayam** di dashboard | 25.000 | **0** | Rumus `populasi awal − kematian hari ini`. Mengabaikan kematian hari sebelumnya **dan** seluruh panen. |
| 12 | **Jumlah Kematian** di dashboard | 0 (kematian hari itu) | **2.477** (kumulatif) | Label berbunyi total siklus, isinya kematian satu hari. |
| 13 | **Mortalitas di halaman Hitung Ayam** | **100%** — _"seluruh populasi telah kematian atau afkir"_ | **9,91%** | Rumus `populasi awal − populasi kini` menghitung 22.523 ekor yang **dipanen** sebagai mati. |
| 14 | Mortalitas per kandang identik | Kandang Atas & Bawah sama-sama 9,91% | 9,91% dan **11,08%** | Statistik satu kandang disebarkan ke semua kandang. |
> **Catatan penting untuk verifikasi angka:** Kandang Atas, siklus `CYCLE-JBW-2026-05-22`
> — DOC 25.000, mati 1.986, afkir 491, panen 22.523, saldo 0.
> Cek silang: 1.986 + 491 + 22.523 = 25.000 ✓
### A.5 Data yang tidak pernah sampai ke model
| # | Masalah | Perbaikan |
| --- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| 15 | Data **hitung karung tidak pernah dikirim** — dan juga **tidak ada di skema prompt**, jadi model tidak pernah diminta mengisinya. | Data karung ditambahkan ke context pack; `hitung_karung` ditambahkan ke skema JSON dan aturan prompt. |
| 16 | Tombol **Harian/Mingguan tidak berfungsi** di halaman Berat Ayam dan Hitung Karung — keduanya hanya mengirim skalar tanpa riwayat harian, jadi tidak ada yang bisa dipotong. | Riwayat harian ditambahkan; kini Harian mengirim 1 baris, Mingguan 7 baris. |
---
## B. Yang Diubah (perilaku)
### B.1 Skalar mengikuti periode yang dipilih
Sebelumnya grafik menampilkan hari 42–48 sementara angka ringkas di sebelahnya
menampilkan hari 48 saja — model bebas mengutip salah satunya.
Sekarang angka ringkas dihitung ulang dari jendela periode yang sama:
| Mode | Bobot rata-rata | Karung dituang |
| -------- | ------------------------------ | ----------------------- |
| Harian | 4.800 g (hari terakhir) | 20 |
| Mingguan | **4.500 g** (rata-rata 7 hari) | **140** (jumlah 7 hari) |
Agregasi disesuaikan sifat metrik: bobot dirata-ratakan, karung dijumlahkan.
Metrik tanpa sumber harian (uniformity) ditandai `"tidak tersedia"`.
### B.2 Label periode mengikuti data, bukan hari siklus
Riwayat bobot berhenti di hari ke-38 sementara siklus sudah hari ke-48. Label
sebelumnya berbunyi "hari ke-48" di atas angka hari ke-38, dan model melaporkan
_"bobot pada hari ke-48"_.
Sekarang label mengikuti data (`hari ke-38`), ditambah catatan otomatis:
> _"CATATAN: data terbaru yang tersedia adalah hari ke-38, sedangkan siklus sudah
> di hari ke-48. Sebut angka sesuai harinya sendiri."_
Selisih data terhadap siklus itu sendiri adalah temuan yang berguna.
### B.3 Aturan anti-karangan dibawa ke seluruh halaman menu
Semua pengaman yang dibangun untuk dashboard kini juga berlaku di endpoint
`/ai-insights/proxy` yang melayani lima halaman menu: aturan satuan, larangan
menghitung sendiri, larangan menyamakan metrik berbeda, dan kewajiban menulis
"data tidak tersedia".
### B.4 Standar mortalitas berbasis umur
Prompt halaman Hitung Ayam sebelumnya memakai ambang tetap (_">5% KRITIS"_),
padahal standar kumulatif CP 707 di hari ke-48 adalah **5,75%** — kawanan yang
persis sesuai buku justru dicap kritis. Sekarang membandingkan ke standar
per-hari dari Lampiran 2.
### B.5 Cache dinaikkan ke v6
Tanpa ini, dashboard akan terus menyajikan insight lama yang dibuat **sebelum**
seluruh perbaikan di atas.
### B.6 AI Insight Hitung Ayam dihapus dari dashboard utama
Atas permintaan. Tetap tersedia di menu **Hitung Ayam** dan **Data Management**.
---
## C. Yang Ditambahkan
### C.1 Grafik di setiap kartu AI Insight
Sebelumnya insight hanya berupa teks. Sekarang setiap kartu disertai grafik.
**Dua komponen baru:**
- `InsightTrendChart.tsx` — grafik garis menampilkan **riwayat penuh** dengan
**periode yang dibahas insight disorot** (area abu-abu). Dipilih demikian karena
di mode Harian datanya hanya satu titik; menampilkan tren di sekitarnya membuat
grafik tetap terbaca sekaligus menunjukkan bagian mana yang dibahas teksnya.
- `InsightConditionChart.tsx` — untuk Panel IoT yang tidak punya riwayat (snapshot
sensor). Batang berpasangan aktual vs standar CP 707, tiap baris berskala sendiri
karena satuannya berbeda, dan setiap batang diberi label angka langsung.
**Terpasang di lima halaman:**
| Halaman | Grafik | Terverifikasi di browser |
| ------------- | ---------------------------------------- | ------------------------ |
| Berat Ayam | Bobot aktual vs target (H2–H38, 2 garis) | ✅ |
| FCR | Tren FCR (H42–H48) | ✅ |
| EEF | Tren EEF (H42–H48) | ✅ |
| Hitung Karung | Karung dituang vs masuk (H1–H49) | ✅ |
| Panel IoT | Kondisi vs standar CP 707 | ✅ |
Palet warna divalidasi terhadap keterbacaan buta warna (ΔE 30,8 deutan) — bukan
dipilih berdasarkan selera.
### C.2 Standar IP/EEF dari buku
Fungsi `getIpStandardByDay()` mengembalikan nilai IP resmi dari Lampiran 2.
Di luar hari 7–37 buku tidak mencantumkan standar, dan fungsi mengembalikan
`null` — **bukan tebakan**. Ini yang mencegah pengulangan cacat #1.
### C.3 Penyaring tabel RAG tanpa header
Baris tabel yang kehilangan judul kolom dihapus dari kutipan buku sebelum sampai
ke model, sementara prosa panduannya tetap dipertahankan. Terpantau di log:
```
[RAG] Stripped 5 headerless table row(s); standards come
from the structured block instead
```
### C.4 Grid KPI dashboard mengikuti periode
Jumlah Kematian, Jumlah terpanen, dan tonase kini mengikuti dropdown
Harian/Mingguan/Akhir Siklus, dengan label periode di tiap baris.
> Kolom **Jumlah terpanen** dan **tonase** untuk sementara dikembalikan ke `N/A`
> atas permintaan, menunggu integrasi API tim Pak Sugi.
> **Perlu diperhatikan:** Saldo Akhir Ayam tetap memakai kolom `panen` yang sama.
> Bila API tersebut menjadi sumber yang sah dan angkanya berbeda, saldo ikut harus
> disesuaikan.
---
## D. Bukti Verifikasi
### D.1 Pengujian otomatis
Lima berkas assertion, seluruhnya lolos:
| Berkas | Menguji |
| --------------- | ----------------------------------------------------------------- |
| `test_rescope` | Pemotongan periode & satuan di jalur dashboard (30+ assertion) |
| `test_heal` | Default status tidak lagi "ok" untuk topik tanpa data |
| `test_fallback` | Generator lokal aman saat data mortalitas kosong |
| `test_scope` | Pengenalan field `hari`/`tanggal` |
| `test_scalar` | Pemotongan skalar per-menu dengan angka yang diketahui jawabannya |
### D.2 Pengujian langsung di browser
Seluruh halaman dibuka dan di-generate sungguhan (`source: generated`, bukan cache),
lalu payload yang dikirim ke model diperiksa satu per satu.
Contoh hasil setelah perbaikan (Kandang Atas, hari ke-48):
- FCR: _"1,704 melebihi standar CP 707 yang seharusnya **1,65**"_ — 1,65 adalah nilai
buku untuk hari ke-48
- Berat Ayam: _"Bobot ayam pada **hari ke-38** masih di bawah standar"_ — hari yang benar
- Hitung Ayam: _"mortalitas keseluruhan sebesar **9,91%**"_ — sebelumnya 100%
- Panel IoT: suhu 24,2 °C vs standar 22 °C, kelembapan 82% vs batas 70%
### D.3 Kualitas kode
| Ukuran | Sebelum | Sesudah |
| ------------------------------- | --------- | ------------- |
| `npx tsc --noEmit` | 179 error | **175 error** |
| `npx eslint` (berkas tersentuh) | 41 error | **0–4 error** |
| `npx vite build` | sukses | **sukses** |
| Error runtime | — | **0** |
Tidak ada error baru yang diperkenalkan. Penurunan jumlah error berasal dari kode
bermasalah yang ikut terhapus.
---
## E. Yang Belum Selesai
### E.1 Kualitas penalaran model — perlu keputusan
Setelah lapisan data bersih, kesalahan yang tersisa **seluruhnya satu jenis**:
angka benar, satuan benar, sumber benar, tetapi **penarikan kesimpulan meleset**.
| Contoh nyata | Masalah |
| ----------------------------------------------------- | ---------------------------------------------------------------------------- |
| _"FCR naik dari 1,80 hingga 1,70"_ | 1,80 → 1,70 itu **turun** |
| _"FCR naik = penurunan efisiensi"_ | FCR turun berarti efisiensi **membaik** |
| _"9,91% **di bawah** standar 5,75%, kategori normal"_ | 9,91 itu **di atas** 5,75 |
| _"EEF melebihi standar"_ | Standar EEF untuk hari ke-48 tidak ada di buku |
| _"312 karung = performa baik"_ | Konsumsi tinggi tanpa dibandingkan pertambahan bobot bisa berarti pemborosan |
Aturan eksplisit sudah ditambahkan ke prompt (_"FCR yang MENURUN berarti efisiensi
MEMBAIK"_) lalu diuji, dan **kesalahannya tetap muncul**. Ini hasil percobaan, bukan
dugaan — dan menunjukkan batasnya ada pada model, bukan pada data.
**Opsi yang diusulkan:** naik ke `qwen2.5:14b` (tetap on-premise, muat di mesin
saat ini, tanpa perubahan kode — hanya satu baris `LLM_MODEL_NAME`).
**Konsekuensi yang perlu dipertimbangkan:** kecepatan generasi turun sekitar
setengah. Saat ini saja sudah 2–5 menit per insight; di 14B bisa mencapai 10 menit
untuk halaman terberat. Karena itu **cache per-menu sebaiknya diaktifkan lebih dulu**
(lihat E.2), atau insight yang lebih benar tidak akan terpakai karena terlalu lambat.
Tersedia lima kasus uji dengan jawaban yang sudah diketahui, siap dipakai untuk
membandingkan 7B vs 14B secara terukur sebelum memutuskan.
### E.2 Pekerjaan teknis yang tersisa
| Prioritas | Item |
| --------- | -------------------------------------------------------------------------------------------------------- |
| Tinggi | Cache backend per-menu masih mati — setiap membuka halaman memanggil model dari nol |
| Sedang | Toggle Harian/Mingguan di Panel IoT masih tampil padahal tidak berfungsi (data snapshot) |
| Sedang | Grid KPI halaman Berat Ayam menampilkan 0 untuk semua metrik padahal grafiknya berisi data |
| Rendah | Grid KPI Panel IoT menampilkan amonia `N/A` sementara grafik menampilkan 0,1 ppm — membaca field berbeda |
### E.3 Batas cakupan pengujian
Seluruh pengujian dilakukan pada **satu siklus dan satu kandang** (Kandang Atas,
`CYCLE-JBW-2026-05-22`, siklus sudah selesai). Siklus yang sedang berjalan atau
kandang lain berpotensi memunculkan pola berbeda dan sebaiknya diuji terpisah.
### E.4 Status commit
Seluruh pekerjaan ini sudah di-commit dan di-push ke branch `opet`.
Belum di-merge ke `main`.
---
## F. Berkas yang Tersentuh
### Berkas baru
```
components/shared/InsightTrendChart.tsx grafik tren + sorotan periode
components/shared/InsightConditionChart.tsx grafik kondisi vs standar (Panel IoT)
utils/cp707EnvStandards.ts standar suhu/udara CP 707 sisi frontend
```
### Berkas yang diubah
```
backend/routes/aiInsights.js +236 standar buku, aturan angka, penyaring RAG
backend/services/dashboardInsightService.js +606 pemotongan periode, EEF, penjaga data
backend/services/cp707Knowledge.js +23 standar IP/EEF dari Lampiran 2
components/AiInsightCard.tsx +261 data karung, IoT terskala, grid KPI
components/shared/PageAiInsight.tsx +114 skalar per periode, label, grafik
utils/insightScope.ts +114 pemotongan skalar, penanda satuan
components/counting/ChickenCountingAiInsight.tsx +143 mortalitas dari catatan asli
components/FeedSackCountingPage.tsx +56 riwayat harian karung
components/IotPanelPage.tsx +46 grafik kondisi
components/WeightMonitoringPageRevamped.tsx +35 riwayat harian bobot
components/FcrDashboardPage.tsx +25 satuan + grafik
components/counting/DataManagementPage.tsx +25 perbaikan data per kandang
components/EefDashboardPage.tsx +21 satuan + grafik
components/counting/ChickenCountingDashboard.tsx −55 kartu insight dihapus dari dashboard
```
Total: **14 berkas diubah, 3 berkas baru**, 1.564 baris ditambahkan.
---
## G. Ringkasan untuk Pembahasan
1. **Lapisan data sudah bersih.** Setiap angka dapat ditelusuri ke database atau
tabel buku CP 707, dengan satuan yang benar. Empat belas cacat diperbaiki,
seluruhnya terverifikasi langsung di aplikasi.
2. **Grafik sudah terpasang** di kelima halaman, terverifikasi satu per satu.
3. **Satu keputusan menunggu:** peningkatan model untuk memperbaiki penarikan
kesimpulan, dengan konsekuensi kecepatan yang perlu ditimbang bersama
pengaktifan cache.
4. **Catatan kehati-hatian:** dalam kondisi saat ini insight sudah layak dipakai
oleh staf yang memahami datanya dan dapat menangkap kesimpulan yang meleset.
Untuk dipakai mandiri tanpa pendampingan, sebaiknya menunggu perbaikan
penalaran diselesaikan.
+225
View File
@@ -0,0 +1,225 @@
# Handover — AI Insight (sesi 4 Agustus 2026)
Dokumen ini ditulis untuk agen/developer berikutnya yang melanjutkan pekerjaan
AI Insight. Isinya: apa yang sudah diperbaiki dan **bagaimana cara membuktikannya**,
apa yang belum, dan jebakan-jebakan yang sudah diketahui.
Branch: `Alfreda`. Baseline sebelum sesi ini: commit `2b7fbad`.
---
## 1. Cara cepat memahami perubahan
```bash
git log --oneline 2b7fbad..HEAD # commit dari sesi ini
git diff 2b7fbad..HEAD --stat # file yang tersentuh
```
File yang berubah/baru di sesi ini:
| File | Status | Isi |
| ----------------------------------------------------- | ------ | ------------------------------------------------------------ |
| `components/shared/EndCycleWeightVsStandardChart.tsx` | baru | Grafik berat aktual vs standar Cobb untuk mode Akhir Siklus |
| `utils/insightScope.ts` | baru | Pemotongan data per timeframe untuk halaman per-menu |
| `utils/insightParse.ts` | baru | Normalisasi JSON respons LLM ke `{kesimpulan, insight}` |
| `backend/services/dashboardInsightService.js` | diubah | Pemotongan periode berbasis nomor hari + prompt per mode |
| `components/AiInsightCard.tsx` | diubah | Memasang grafik di mode `end_cycle` |
| `components/shared/PageAiInsight.tsx` | diubah | Pakai `insightScope` + `insightParse`; cache backend dilepas |
| `components/counting/ChickenCountingAiInsight.tsx` | diubah | Harian vs Mingguan benar-benar beda; pakai `insightParse` |
| `.claude/skills/run-app/SKILL.md` | baru | Cara menjalankan aplikasi (lihat bagian 5) |
---
## 2. SUDAH selesai — dan bukti verifikasinya
### 2.1 Grafik Aktual vs Standar Cobb (mode Akhir Siklus)
Sebelumnya mode Akhir Siklus hanya grid angka + bullet list, tanpa satu pun grafik.
Sekarang: line chart berat aktual vs kurva Cobb, satu sumbu Y, area terarsir
di antara kedua garis (hijau = di atas standar, oranye = tertinggal), plus empat
angka ringkasan (selisih akhir, hari di atas/di bawah standar, rata-rata deviasi,
titik terburuk).
**Semua angka dihitung di frontend**, tidak ada yang dari LLM — jadi grafik tetap
tampil walau Ollama mati, dan narasi AI tidak mungkin berbeda angka dari grafik.
_Terverifikasi:_ dilihat langsung di browser. Tooltip hari ke-15 menampilkan
aktual 418 g, standar Cobb 587 g, selisih −169 g (−28.8%).
### 2.2 Harian / Mingguan / Akhir Siklus sekarang benar-benar berbeda
**Masalah lamanya:** timeframe hanya mengubah label di prompt, datanya dikirim utuh.
- `PageAiInsight` mengirim seluruh `contextData` apa pun timeframe-nya.
- Dashboard mode Harian sebenarnya mengambil 7 hari terakhir (`slice(-7)`).
- Dashboard mode Mingguan memotong **berdasarkan posisi array**, bukan nomor hari —
kalau ada hari bolong, jendelanya bergeser. Dan pemotongan itu kena ke _semua_
array termasuk daftar perangkat/kandang yang tidak punya dimensi waktu.
- `PageAiInsight` membaca cache backend `getAiInsight(cycleId)` yang **hanya di-key
per siklus** — tidak per topik, tidak per timeframe. Semua halaman bisa dapat
teks yang sama.
**Sekarang:**
| Mode | Perilaku |
| ----------------------------- | ------------------------------------------------------------------------- |
| Harian (dashboard & per-menu) | Hanya hari terakhir |
| Mingguan (per-menu) | 7 hari terakhir |
| Mingguan N (dashboard) | Hari `(N-1)*7+1` s/d `N*7`, dicocokkan lewat field `day` tiap baris |
| Akhir Siklus | Seluruh siklus; minggu 1..N-1 diringkas rata-rata, minggu terakhir mentah |
Daftar snapshot (kamera, perangkat, kandang) **tidak** ikut dipotong di mode apa pun.
_Terverifikasi lewat assertion_ (fungsi `pruneContextPackForLocalLLM` sengaja
di-export dari `dashboardInsightService.js` untuk keperluan ini):
- Harian → `[35]`
- Minggu 1 → `[1..7]`
- Minggu 7 di siklus 35 hari → `[]` (bukan data minggu lain)
- Data bolong 1–5 lalu 30–35, Minggu 5 → `[30..35]` tanpa geser
- Akhir Siklus: 35 hari → 11 baris (hari 7, 14, 21, 28 rata-rata + hari 29–35 mentah),
dan baris terakhir **persis nilai asli**, bukan rata-rata
_Terverifikasi lewat generasi LLM sungguhan_ (Ollama `qwen2.5:7b`):
- Harian → model mengutip "FCR aktual **1.776**" = nilai hari ke-48 di data uji
(`1.2 + 48×0.012`), bukan rata-rata 7 hari
- Minggu 7 → `source: generated`, enam area terisi
- Akhir Siklus → `source: generated`, keenam area disebut, yang kosong ditandai `unknown`
### 2.3 Mode Akhir Siklus wajib mencakup enam topik
Prompt `end_cycle` sekarang mewajibkan model mengevaluasi hitung ayam, berat ayam,
hitung karung, panel IoT, FCR, dan EEF — masing-masing minimal satu temuan dengan
angkanya — dan **melarang mengarang** kalau datanya kosong (harus bilang "data tidak
tersedia"). Heavy key (`deviceWeightData`, `ckaleData`, `liveCounts`) yang tadinya
selalu dibuang, kini disertakan khusus di mode ini.
### 2.4 Parser JSON tahan skema bebas
Model sering mengarang nama field, contoh nyata yang ditemui:
```json
{ "tren_mortalitas_harian": "...", "dampak_populasi": "...", "rekomendasi_tindakan": [...] }
```
**Perilaku lama:**
- `ChickenCountingAiInsight`: `if (!parsed.kesimpulan) return null` → seluruh isi dibuang,
UI menampilkan "Respons AI tidak dapat diproses".
- `PageAiInsight`: lolos tapi jatuh ke default **"Analisis operasional kandang terpantau
dengan baik."** — kalimat menenangkan yang dikarang kode, padahal model melaporkan
populasi habis total. Ini yang paling berbahaya.
**Sekarang** (`utils/insightParse.ts`, dipakai kedua komponen):
- Nama field bebas diselamatkan jadi label (`tren_mortalitas_harian` → "Tren mortalitas harian")
- Sinonim dikenali (`rekomendasi_tindakan`, `saran`, `actions`, `ringkasan`, dll.)
- **Tidak ada lagi kalimat default yang menenangkan** — kalau tidak ada kesimpulan
eksplisit, field pertama dipromosikan jadi kesimpulan
- Objek bersarang tidak lagi jadi `[object Object]` atau blok JSON mentah
- Respons tanpa JSON tetap `null` supaya UI menampilkan error asli
---
## 3. BELUM selesai / perlu keputusan
1. **Halaman per-menu belum dibuka di browser setelah parser diganti.**
Parser sudah diuji unit dan build + typecheck lolos, tapi halaman FCR/EEF/Berat Ayam
belum pernah dilihat merender hasil barunya. **Ini prioritas pertama.**
2. **Jalur hijau grafik ("Di atas standar") belum pernah muncul di data nyata**,
karena kandang uji tertinggal di semua 34 hari. Logikanya simetris dengan jalur
oranye yang sudah terbukti, tapi belum teruji.
3. **Nilai skalar tidak ikut dipotong per periode.** `weight.stats.averageWeight`,
`uniformity`, `fcr` adalah angka tunggal "kondisi terkini", bukan deret waktu.
Akibatnya di Minggu 7 model tetap mengutip bobot terkini meski riwayat berat
berhenti di hari 35. Kalau ingin skalar juga ikut periode, angka itu harus
dihitung ulang per jendela di frontend sebelum dikirim. **Perlu keputusan.**
4. **Akhir Siklus dikirim sebagai ringkasan mingguan, bukan baris harian mentah.**
Alasannya data mentah seluruh siklus untuk enam topik melebihi context window
`qwen2.5:7b`. Semua topik tetap terwakili, tapi granularitasnya mingguan.
Kalau mau harian penuh, perlu model dengan context lebih besar.
5. **Cache backend di halaman per-menu dimatikan** karena tidak di-key per topik/timeframe.
Konsekuensi: setiap halaman selalu memanggil LLM — lebih lambat, tidak ada cache
lintas sesi. Cache memori + sessionStorage per `topik+timeframe` masih jalan.
Perbaikan sebenarnya: bikin endpoint cache yang menerima topik + timeframe.
6. **Populasi = 0 di database.** Dashboard menampilkan "Saldo Akhir Ayam = 0" dan
model melaporkan "populasi akhir nol ekor". Kemungkinan besar ini benar-benar isi
database, **bukan** bug kode insight. Belum diselidiki sama sekali.
7. **Data berat berhenti di H35 padahal siklus sudah H48.** Label "Selisih akhir siklus"
di grafik sebenarnya selisih di hari data terakhir, bukan hari panen. Pertimbangkan
mengubah labelnya.
---
## 4. Jebakan yang sudah diketahui (jangan diulang)
- **Jangan potong array berdasarkan posisi.** Selalu pakai field `day`. Bug lama
menggeser jendela saat ada hari bolong.
- **Minggu tanpa data harus mengembalikan array kosong**, bukan fallback ke 7 hari
terakhir. Bug ini sempat saya buat lalu diperbaiki — kalau tidak, "Minggu 7"
diam-diam menampilkan hari 29–35.
- **Jangan ubah array riwayat jadi objek.** Kode ekstraksi di
`dashboardInsightService.js` membacanya sebagai array; kalau dijadikan objek,
`dayAge` dan semua metrik gagal terbaca dan sistem **diam-diam** jatuh ke
generator fallback lokal. Gejalanya: headline berbunyi
"Evaluasi Akhir Siklus Kritis (Hari ke-**?**)" dengan metrik N/A.
- **Jangan pernah menambahkan kalimat default yang menenangkan di parser.**
Itu bisa menutupi temuan kritis.
- Cek `source` di respons `/api/ai-insights/dashboard`: `generated` = LLM beneran,
`cache` = dari DB, kalau isinya template "Hari ke-?" berarti fallback lokal.
---
## 5. Cara menjalankan (ringkas)
Detail lengkap ada di `.claude/skills/run-app/SKILL.md`. Ringkasnya:
```bash
open -a Docker # tunggu daemon siap
osascript -e 'quit app "Ollama"' # hindari rebutan port 11434
docker compose up -d database llm # HANYA dua ini
bash start_all.sh # jalankan di background
```
- Frontend sering geser ke **3001** (port 3000 dipakai proses lain). Baca URL asli
dari `/tmp/frontend.log`.
- Backend health ada di **`/health`**, bukan `/api/health` (yang terakhir 404 dan
terlihat seperti backend mati).
- Bukti DB tersambung: `curl localhost:5001/api/kandangs` harus mengembalikan data.
- Model yang dipakai: `qwen2.5:7b` (sesuai `LLM_MODEL_NAME` di `backend/.env`).
### Menguji pemotongan periode tanpa UI
```js
const { pruneContextPackForLocalLLM } = require('./backend/services/dashboardInsightService.js');
const rows = Array.from({ length: 35 }, (_, i) => ({ day: i + 1, actual: 40 + i * 50 }));
pruneContextPackForLocalLLM({ h: rows }, 'daily'); // -> hari 35 saja
pruneContextPackForLocalLLM({ h: rows }, 'weekly', 'Minggu 7'); // -> []
pruneContextPackForLocalLLM({ h: rows }, 'end_cycle'); // -> 11 baris
```
### Memicu generasi nyata tanpa login
```bash
curl -s -X POST http://localhost:5001/api/ai-insights/dashboard \
-H 'Content-Type: application/json' \
-d '{"cycleId":"CYCLE-JBW-2026-05-22","forceRefresh":true,"reportType":"weekly","reportPeriod":"Minggu 7","contextPack":{...}}'
```
---
## 6. Status kualitas kode
- `npx tsc --noEmit` → 165 error, **sama persis dengan baseline sebelum sesi ini**
(nol error baru). Repo ini memang belum bersih typecheck sejak sebelumnya.
- `npx eslint` pada file yang disentuh → 0 error (hanya warning `any` yang sudah ada).
- `npx vite build` → sukses.
- `node --check backend/services/dashboardInsightService.js` → OK.
+59 -17
View File
@@ -28,6 +28,9 @@ Dashboard monitoring dan manajemen peternakan ayam berbasis web dengan integrasi
- **Standar Performa**: Perbandingan dengan standar Cobb
- **Penjadwalan Pakan**: Rekomendasi jadwal pemberian pakan
- **Feed Wastage**: Monitoring pemborosan pakan
- **AI Insight**: Analisis otomatis per topik (hitung ayam, berat, FCR, EEF, panel IoT,
karung pakan) memakai LLM lokal + RAG buku standar CP 707. Tiap kartu punya toggle
Harian/Mingguan, grafik pendukung, dan ekspor PDF berlabel lokasi & periode
- **Behavior Analysis**: Analisis perilaku ayam dengan AI
- **Network Monitoring**: Status koneksi IoT devices
- **ERP Integration**: Sinkronisasi dengan sistem ERP
@@ -36,7 +39,9 @@ Dashboard monitoring dan manajemen peternakan ayam berbasis web dengan integrasi
- **Database**: PostgreSQL untuk penyimpanan data siklus dan mortalitas
- **RESTful API**: Endpoint untuk CRUD operations
- **Auto-seeding**: Database otomatis terisi data awal saat pertama kali dijalankan
- **Migrasi otomatis**: Skema dan migrasi dijalankan sendiri saat backend start
- **Data TIDAK terisi otomatis**: tabel dibuat kosong. Isi dengan `backup.sql`
(lihat [SETUP.md](SETUP.md)) — tanpa itu dashboard terbuka tanpa data sama sekali
- **Persistent Storage**: Semua perubahan data tersimpan permanen
## 🛠 Teknologi
@@ -55,10 +60,17 @@ Dashboard monitoring dan manajemen peternakan ayam berbasis web dengan integrasi
- **PostgreSQL** (pg) - Database
- **CORS** - Cross-origin support
### AI
- **Ollama** - LLM lokal (default `qwen2.5:7b`, ~5 GB)
- **Python + FastAPI** - layanan RAG di folder `AI Insight/`
- **ChromaDB** - penyimpanan vektor buku standar CP 707
### Deployment
- **Docker** - Containerization
- **Docker Compose** - Multi-container orchestration (3 containers: frontend, backend, database)
- **Docker Compose** - Orkestrasi 6 service: `database`, `backend`, `frontend`, `rag`,
`llm` (Ollama), dan `llm-model-downloader`
- **Nginx** - Reverse proxy & static file serving
- **PostgreSQL** - Database container
@@ -66,16 +78,16 @@ Dashboard monitoring dan manajemen peternakan ayam berbasis web dengan integrasi
### Docker Deployment (Recommended for Production)
Deploy dengan 3 container (frontend + backend + PostgreSQL database) menggunakan Docker Compose:
Deploy dengan Docker Compose:
```bash
# Quick start
docker-compose up -d --build
# Access application
http://your-server-ip
docker compose up -d --build
```
Di mode Docker penuh, frontend disajikan Nginx di **port 5002**, backend di 15001,
dan RAG di 5005 — berbeda dari mode pengembangan native. Jangan menjalankan kedua
mode bersamaan; keduanya berebut port yang sama.
**Keuntungan Docker:**
- ✅ Easy deployment dan scaling
@@ -86,7 +98,9 @@ http://your-server-ip
**📚 Complete Docker Guide:** [DOCKER.md](DOCKER.md)
**Local Postgres / sync from server / DBeaver:** [docs/LOCAL_DATABASE_GUIDE.md](docs/LOCAL_DATABASE_GUIDE.md)
**Setup dari nol sampai jalan:** [SETUP.md](SETUP.md)
**Catatan teknis & pekerjaan yang masih terbuka:** [CATATAN-LANJUTAN.md](CATATAN-LANJUTAN.md)
### Manual Deployment
@@ -98,20 +112,30 @@ Untuk development atau testing lokal, lihat [Instalasi](#instalasi) dan [Cara Me
- **Docker**: v20.10+
- **Docker Compose**: v2.0+
- **RAM**: Minimum 2GB
- **Disk**: 10GB free space
- **Port**: 80 (frontend)
- **RAM**: minimum 8GB (model LLM berjalan di CPU)
- **Disk**: 15GB free space — model `qwen2.5:7b` saja ~5GB
- **Port**: 5002 (frontend), 15001 (backend), 15432 (Postgres), 11434 (Ollama), 5005 (RAG)
### Untuk Manual Installation
- **Node.js**: v18.0.0 atau lebih tinggi
- **npm**: v9.0.0 atau lebih tinggi
- **Python**: v3.10+ — untuk layanan RAG
- **Docker**: tetap dibutuhkan untuk PostgreSQL dan Ollama
- **Port yang tersedia**:
- Frontend: 3001 (atau port lain jika 3001 terpakai)
- Frontend: 3000, mundur ke 3001/3002/… bila terpakai
- Backend: 5001
- RAG: 5002
- PostgreSQL: **15432** (bukan 5432 — dipetakan begitu agar tidak bentrok)
- Ollama: 11434
## 💿 Instalasi
> **Panduan lengkap dan berurutan ada di [SETUP.md](SETUP.md).** Bagian di bawah ini
> hanya memasang dependensi JavaScript. Aplikasi belum bisa jalan hanya dengan ini —
> masih perlu file `.env`, container PostgreSQL dan Ollama, model LLM, dependensi
> Python untuk RAG, serta pengisian database dari `backup.sql`.
### 1. Clone Repository
```bash
@@ -135,6 +159,17 @@ cd ..
## ▶️ Cara Menjalankan
> Menjalankan backend dan frontend saja **tidak cukup** — AI Insight butuh layanan
> RAG (port 5002) dan Ollama (port 11434) ikut hidup. Cara termudah:
>
> ```bash
> docker compose up -d database llm # PostgreSQL + Ollama
> bash start_all.sh # RAG + backend + frontend sekaligus
> ```
>
> `start_all.sh` menampilkan alamat frontend yang benar-benar dipakai di akhir
> keluarannya. Bukalah alamat itu, jangan diasumsikan.
### Opsi 1: Menjalankan Secara Manual (Recommended untuk Development)
#### Terminal 1 - Backend Server
@@ -166,7 +201,9 @@ API Endpoints:
npm run dev
```
Frontend akan berjalan di: **http://localhost:3001** (atau port lain jika terpakai)
Frontend akan berjalan di: **http://localhost:3000** — atau 3001, 3002, dan seterusnya
kalau port itu sedang dipakai proses lain. Backend menerima port localhost mana pun
saat `NODE_ENV=development`, jadi berapa pun portnya aplikasi tetap jalan.
Output yang diharapkan:
@@ -231,7 +268,11 @@ dashboard-solusi-ai-peternakan-ayam/
├── mockData/ # Mock data for demo mode
├── types/ # TypeScript type definitions
├── public/ # Static assets
├── docker-compose.yml # 3-container orchestration
├── AI Insight/ # Layanan RAG (Python) + ChromaDB + dokumen CP 707
├── SETUP.md # Panduan setup dari nol
├── start_all.sh # Menyalakan RAG + backend + frontend
├── backup.sql # Data awal — WAJIB di-restore, tidak otomatis
├── docker-compose.yml # Orkestrasi 6 service
├── package.json
└── README.md
```
@@ -626,9 +667,10 @@ curl http://localhost:5001/api/mortality/CYCLE-JBW-2025-12-10
- ✅ CRUD Cycles
- ✅ CRUD Mortality Records
- ✅ Frontend-Backend Integration
- ✅ Auto-seeding Data
- ✅ Data Persistence
- ✅ Docker Deployment (3-container architecture)
- ✅ AI Insight per topik dengan LLM lokal + RAG CP 707
- ✅ Ekspor PDF berlabel lokasi, kandang, dan periode
- ✅ Docker Deployment (6 service)
- ✅ Database Backup & Restore Scripts
- ⏳ User Authentication (coming soon)
- ⏳ Real-time WebSocket (coming soon)
+162
View File
@@ -0,0 +1,162 @@
# Setup — dari clone sampai aplikasi terbuka dengan data
Panduan ini untuk komputer yang **belum pernah** menjalankan proyek ini.
Ikuti berurutan; setiap langkah punya cara memastikan ia berhasil.
Perkiraan waktu: 20–40 menit, sebagian besar menunggu unduhan model LLM (~5 GB).
## Yang harus sudah terpasang
| | Cara memeriksa |
| -------------- | ------------------- |
| Docker Desktop | `docker info` |
| Node.js 18+ | `node -v` |
| Python 3.10+ | `python3 --version` |
---
## 1. Clone dan pasang dependensi
```bash
git clone https://github.com/abdshomad/dashboard-solusi-ai-peternakan-ayam.git
cd dashboard-solusi-ai-peternakan-ayam
git checkout Alfreda
npm install
npm install --prefix backend
pip3 install -r "AI Insight/requirements.txt"
```
## 2. Siapkan file `.env`
Ada **dua** file, dan `DB_PASSWORD` di keduanya harus sama persis.
```bash
cp .env.example .env
cp backend/.env.example backend/.env
```
Buka keduanya, ganti `DB_PASSWORD` dengan satu password yang sama.
> **Jebakan paling sering:** `DB_PORT` harus **15432**, bukan 5432. Postgres di
> Docker dipetakan ke 15432 supaya tidak bentrok dengan Postgres yang mungkin
> sudah terpasang di komputer Anda. Nilai ini sudah benar di
> `backend/.env.example` — jangan diubah jadi 5432.
## 3. Nyalakan Postgres dan Ollama
Hanya dua container ini. Jangan `docker compose up` seluruhnya — container
`backend`/`frontend`/`rag` akan berebut port dengan proses yang dijalankan
`start_all.sh` di langkah 6.
```bash
docker compose up -d database llm
```
Tunggu sampai database siap:
```bash
until docker exec dashboard-database pg_isready -U dashboard_user -d dashboard_db; do sleep 1; done
```
**Kalau port 11434 bentrok:** aplikasi Ollama bawaan macOS menahannya. Matikan
dulu dengan `osascript -e 'quit app "Ollama"'`.
## 4. Unduh model LLM
Sekitar 5 GB, jalan sekali saja.
```bash
docker exec dashboard-llm ollama pull qwen2.5:7b
```
Pastikan sudah ada:
```bash
curl -s http://localhost:11434/api/tags | grep qwen2.5
```
Nama model ini harus sama dengan `LLM_MODEL_NAME` di `backend/.env`. Kalau
berbeda, semua AI Insight gagal dengan error _model not found_.
## 5. Isi database
**Langkah ini tidak otomatis.** Skema dan migrasi memang dibuat sendiri saat
backend start, tapi **tabelnya akan kosong**. Tanpa langkah ini dashboard
terbuka tanpa kandang, tanpa siklus, dan semua AI Insight menulis
"data tidak tersedia" — yang mudah disalahartikan sebagai aplikasi rusak.
```bash
docker exec -i dashboard-database psql -U dashboard_user -d dashboard_db < backup.sql
```
Pastikan datanya masuk:
```bash
docker exec dashboard-database psql -U dashboard_user -d dashboard_db -c \
"select count(*) from mortality_records;"
```
Harusnya **270**.
## 6. Jalankan aplikasinya
```bash
bash start_all.sh
```
Skrip ini menyalakan tiga layanan (RAG, backend, frontend) lalu menampilkan
alamat yang harus dibuka, misalnya:
```
🌐 Frontend: http://localhost:3000
🔧 Backend API: http://localhost:5001 (health: /health)
🧠 RAG Service: http://localhost:5002
```
**Bukalah alamat yang tertulis di keluaran itu**, jangan diasumsikan. Vite
memakai port 3000, tapi mundur ke 3001, 3002, dan seterusnya kalau port itu
sedang dipakai proses lain. Backend menerima port localhost mana pun saat
`NODE_ENV=development`, jadi berapa pun portnya aplikasinya tetap jalan.
Skrip berakhir dengan `tail -f` pada log backend dan tidak pernah keluar
sendiri. Tekan Ctrl+C untuk berhenti menonton log — layanannya tetap hidup.
## 7. Pastikan semuanya benar-benar hidup
```bash
curl -s -o /dev/null -w "backend %{http_code}\n" http://localhost:5001/health
curl -s -o /dev/null -w "rag %{http_code}\n" http://localhost:5002/health
curl -s http://localhost:5001/api/kandangs | head -c 80
```
Ketiganya harus `200`, dan perintah terakhir harus mengembalikan
`{"success":true,"data":[{"id":1,"name":"Kandang Atas"...`.
Kalau `/health` sudah 200 tapi `/api/kandangs` gagal, berarti backend hidup
tapi database tidak terjangkau — periksa ulang `DB_PORT=15432` di langkah 2.
---
## Menghentikan semuanya
```bash
pkill -f "rag_service.py"; pkill -f "node server.js"; pkill -f vite
docker compose stop database llm # opsional
```
## Kalau ada yang salah
| Gejala | Penyebab yang paling mungkin |
| ----------------------------------------- | ----------------------------------------------------------- |
| `ECONNREFUSED 127.0.0.1:15432` | container `database` belum jalan, atau `DB_PORT` salah |
| Dashboard terbuka tapi semua angka kosong | langkah 5 (isi database) terlewat |
| AI Insight error _model not found_ | `LLM_MODEL_NAME` tidak cocok dengan model yang di-pull |
| `Folder tidak ditemukan: .../AI Insight` | `start_all.sh` dijalankan dari salinan di luar repo |
| Halaman tampil tapi data gagal dimuat | backend mati — cek `/tmp/backend.log` |
| Generate AI Insight lama sekali | wajar: 2–5 menit per laporan di CPU. Cek `/tmp/backend.log` |
Log ada di `/tmp/backend.log`, `/tmp/frontend.log`, dan `/tmp/rag_service.log`.
Catatan teknis yang lebih dalam — keputusan desain, jebakan yang sudah
ditemukan, dan pekerjaan yang masih terbuka — ada di `CATATAN-LANJUTAN.md`.
+24 -8
View File
@@ -1,20 +1,36 @@
# Backend Environment Variables
# Copy this file to .env for local development
# Salin file ini menjadi .env: cp backend/.env.example backend/.env
# Server port
# ── Server ───────────────────────────────────────────────────────────────────
PORT=5001
NODE_ENV=development
# PostgreSQL Database Configuration
# ── PostgreSQL ───────────────────────────────────────────────────────────────
# PENTING: 15432, BUKAN 5432.
# docker-compose memetakan Postgres di container ke port 15432 di host, supaya
# tidak bentrok dengan Postgres yang mungkin sudah terpasang di komputer Anda.
# Isi 5432 di sini adalah penyebab error "ECONNREFUSED" saat backend start.
DB_HOST=localhost
DB_PORT=5432
DB_PORT=15432
DB_USER=dashboard_user
DB_PASSWORD=your_secure_password_here
DB_PASSWORD=isi_dengan_password_yang_sama_seperti_di_.env_root
DB_NAME=dashboard_db
DB_SSL=false
# Connection pool settings (optional)
# Connection pool (opsional)
DB_POOL_MIN=2
DB_POOL_MAX=10
# Node environment
NODE_ENV=development
# ── LLM (Ollama) ─────────────────────────────────────────────────────────────
# Tanpa LLM_MODEL_NAME, semua AI Insight gagal dengan error "model not found".
# Model ini harus sudah di-pull:
# docker exec dashboard-llm ollama pull qwen2.5:7b
LLM_MODEL_NAME=qwen2.5:7b
LM_STUDIO_BASE_URL=http://localhost:11434
# ── Layanan RAG (Python, port 5002) ──────────────────────────────────────────
RAG_SERVICE_URL=http://localhost:5002
# ── Opsional ─────────────────────────────────────────────────────────────────
# Jam batas filter D-1 (default 17). Ubah hanya untuk pengujian.
# CUTOFF_HOUR_OVERRIDE=17
+31
View File
@@ -37,16 +37,36 @@ pool.on('error', (err) => {
process.exit(-1);
});
/**
* Arbitrary but fixed key identifying the "applying schema.sql" critical
* section. Any value works as long as every process uses the same one.
*/
const SCHEMA_LOCK_KEY = 918273645;
// Initialize database with schema
async function initializeDatabase() {
const client = await pool.connect();
let locked = false;
try {
// Applying the schema is DDL, so it takes AccessExclusiveLock on each table
// it touches. This module runs it on import, and several processes import
// it at once (the server plus one worker per test file) — that DDL racing
// against ordinary queries is what surfaced as "deadlock detected" in the
// Mortality suite. A session-level advisory lock serialises just this
// section; it blocks nothing else.
await client.query('SELECT pg_advisory_lock($1)', [SCHEMA_LOCK_KEY]);
locked = true;
const schema = fs.readFileSync(SCHEMA_PATH, 'utf8');
await client.query(schema);
} catch (error) {
console.error('✗ Error initializing database:', error.message);
throw error;
} finally {
if (locked) {
await client
.query('SELECT pg_advisory_unlock($1)', [SCHEMA_LOCK_KEY])
.catch((err) => console.error('Failed to release schema lock:', err.message));
}
client.release();
}
}
@@ -68,6 +88,11 @@ function parseISODate(dateString) {
// Query helper with parameter binding
async function query(text, params) {
// Wait for the schema DDL to finish. Without this, ordinary queries ran while
// `initializeDatabase` still held AccessExclusiveLocks, which Postgres
// resolved by killing one side — "deadlock detected". It also means early
// requests can no longer hit a half-applied schema on startup.
await dbReady;
const start = Date.now();
try {
const res = await pool.query(text, params);
@@ -85,6 +110,7 @@ async function query(text, params) {
// Transaction helper
async function transaction(callback) {
await dbReady; // same reason as in `query`
const client = await pool.connect();
try {
await client.query('BEGIN');
@@ -107,6 +133,11 @@ async function closePool() {
// Initialize on first load - store promise so callers can await it
const dbReady = initializeDatabase().catch((error) => {
console.error('Failed to initialize database:', error);
// Under a test runner, exiting the process kills the worker and fails every
// other suite sharing it — Cycle.test.js passes alone but took unrelated
// tests down with it in a full run. Tests that need the database mock it or
// await `dbReady`, so leaving it resolved keeps the failure local.
if (process.env.VITEST || process.env.NODE_ENV === 'test') return;
process.exit(1);
});
@@ -0,0 +1,21 @@
-- Scope cached dashboard insights by the report period they describe.
--
-- The cache was keyed on (cycle_id, kandang_id) alone, while the dropdown can
-- ask for Harian, Minggu 1..7, or Akhir Siklus. The first generated period was
-- stored and then served back for every other one for the full 6-hour TTL, so
-- switching the period appeared to change nothing.
ALTER TABLE ai_insights
ADD COLUMN IF NOT EXISTS report_type VARCHAR(20) NOT NULL DEFAULT 'daily',
ADD COLUMN IF NOT EXISTS report_period VARCHAR(50);
DROP INDEX IF EXISTS idx_ai_insights_cycle_kandang;
CREATE INDEX IF NOT EXISTS idx_ai_insights_scope
ON ai_insights (cycle_id, kandang_id, report_type, report_period);
-- Existing rows predate the column and cannot be attributed to a period, so
-- expire them rather than let them answer for a period they may not describe.
UPDATE ai_insights SET expires_at = NOW() WHERE report_type = 'daily';
COMMENT ON COLUMN ai_insights.report_type IS 'daily | weekly | end_cycle — the period this insight describes';
COMMENT ON COLUMN ai_insights.report_period IS 'Week label for weekly reports, e.g. "Minggu 3"; NULL otherwise';
+38 -9
View File
@@ -2,21 +2,35 @@ const { query } = require('../database/db');
class AiInsight {
/**
* Get AI insight by cycle and kandang
* Get AI insight by cycle, kandang and report scope.
*
* The scope is part of the key: an insight describing "Minggu 3" must never be
* served for "Minggu 5" or for Akhir Siklus. Without it the first period
* generated answered for every other one until the 6-hour TTL expired.
*
* @param {string} cycleId - Cycle ID
* @param {string|null} kandangId - Kandang ID (null for all kandangs)
* @param {'daily'|'weekly'|'end_cycle'} reportType - Period type
* @param {string|null} reportPeriod - Week label for weekly reports
* @returns {Promise<Object|null>} Insight object or null if not found or expired
*/
static async getByCycleAndKandang(cycleId, kandangId = null) {
static async getByCycleAndKandang(
cycleId,
kandangId = null,
reportType = 'daily',
reportPeriod = null
) {
try {
const result = await query(
`SELECT * FROM ai_insights
WHERE cycle_id = $1
AND ($2::text IS NULL AND kandang_id IS NULL OR kandang_id = $2)
AND report_type = $3
AND report_period IS NOT DISTINCT FROM $4
AND expires_at > NOW()
ORDER BY generated_at DESC
LIMIT 1`,
[cycleId, kandangId]
[cycleId, kandangId, reportType, reportPeriod]
);
if (result.rows.length === 0) return null;
@@ -35,14 +49,27 @@ class AiInsight {
*/
static async upsert(insightData) {
try {
const { cycleId, kandangId, insightText, version = 'v1' } = insightData;
const {
cycleId,
kandangId,
insightText,
version = 'v1',
reportType = 'daily',
reportPeriod = null,
} = insightData;
// Calculate expiry date (6 hours from now)
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 6);
// Check if insight already exists
const existing = await this.getByCycleAndKandang(cycleId, kandangId);
// Check if an insight for THIS scope already exists. Matching on cycle and
// kandang alone would overwrite another period's row.
const existing = await this.getByCycleAndKandang(
cycleId,
kandangId,
reportType,
reportPeriod
);
if (existing) {
// Update existing
@@ -58,10 +85,10 @@ class AiInsight {
} else {
// Create new
const result = await query(
`INSERT INTO ai_insights (cycle_id, kandang_id, insight_text, version, generated_at, expires_at)
VALUES ($1, $2, $3, $4, NOW(), $5)
`INSERT INTO ai_insights (cycle_id, kandang_id, insight_text, version, generated_at, expires_at, report_type, report_period)
VALUES ($1, $2, $3, $4, NOW(), $5, $6, $7)
RETURNING *`,
[cycleId, kandangId, insightText, version, expiresAt]
[cycleId, kandangId, insightText, version, expiresAt, reportType, reportPeriod]
);
return this.formatInsight(result.rows[0]);
@@ -102,6 +129,8 @@ class AiInsight {
kandangId: row.kandang_id,
insightText: row.insight_text,
version: row.version,
reportType: row.report_type,
reportPeriod: row.report_period,
generatedAt: row.generated_at,
expiresAt: row.expires_at,
createdAt: row.created_at,
+152 -191
View File
@@ -26,7 +26,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
@@ -39,7 +38,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -51,7 +49,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -64,14 +61,14 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
@@ -83,9 +80,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.127.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"dev": true,
"license": "MIT",
"funding": {
@@ -100,9 +97,9 @@
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [
"arm64"
],
@@ -117,9 +114,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [
"arm64"
],
@@ -134,9 +131,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [
"x64"
],
@@ -151,9 +148,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [
"x64"
],
@@ -168,9 +165,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [
"arm"
],
@@ -185,13 +182,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -202,13 +202,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -219,13 +222,16 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -236,13 +242,16 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -253,13 +262,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -270,13 +282,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -287,9 +302,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [
"arm64"
],
@@ -304,9 +319,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
@@ -314,52 +329,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
"arm64"
],
@@ -374,9 +355,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [
"x64"
],
@@ -391,9 +372,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@@ -405,9 +386,9 @@
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -440,18 +421,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz",
"integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
@@ -617,9 +586,9 @@
}
},
"node_modules/body-parser": {
"version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
"version": "1.20.6",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
@@ -630,7 +599,7 @@
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"on-finished": "~2.4.1",
"qs": "~6.14.0",
"qs": "~6.15.1",
"raw-body": "~2.5.3",
"type-is": "~1.6.18",
"unpipe": "~1.0.0"
@@ -849,9 +818,9 @@
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -896,14 +865,14 @@
}
},
"node_modules/express": {
"version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
"version": "4.22.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.8",
"array-flatten": "1.1.1",
"body-parser": "~1.20.3",
"body-parser": "~1.20.5",
"content-disposition": "~0.5.4",
"content-type": "~1.0.4",
"cookie": "~0.7.1",
@@ -922,7 +891,7 @@
"parseurl": "~1.3.3",
"path-to-regexp": "~0.1.12",
"proxy-addr": "~2.0.7",
"qs": "~6.14.0",
"qs": "~6.15.1",
"range-parser": "~1.2.1",
"safe-buffer": "5.2.1",
"send": "~0.19.0",
@@ -1095,9 +1064,9 @@
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -1510,9 +1479,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"dev": true,
"funding": [
{
@@ -1591,9 +1560,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/pathe": {
@@ -1700,9 +1669,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -1713,9 +1682,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.22",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
"dev": true,
"funding": [
{
@@ -1733,7 +1702,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -1794,12 +1763,13 @@
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -1833,14 +1803,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.127.0",
"@rolldown/pluginutils": "1.0.0-rc.17"
"@oxc-project/types": "=0.139.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -1849,21 +1819,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
"node_modules/safe-buffer": {
@@ -1944,14 +1914,14 @@
"license": "ISC"
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -1963,13 +1933,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
@@ -2097,9 +2067,9 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2163,15 +2133,6 @@
"node": ">= 0.6"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -2200,17 +2161,17 @@
}
},
"node_modules/vite": {
"version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
"version": "8.1.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.10",
"rolldown": "1.0.0-rc.17",
"tinyglobby": "^0.2.16"
"picomatch": "^4.0.5",
"postcss": "^8.5.17",
"rolldown": "~1.1.5",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
@@ -2226,7 +2187,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
const { formatDateForDb } = require('../../utils/dateUtils.js');
/**
* The serialisation the route now uses. node-postgres hands back a DATE column
* as a JS Date at LOCAL midnight; the old `toISOString().split('T')[0]` moved
* that to UTC and lost a day in any positive-offset zone such as WIB.
*/
const toDateOnlyString = (value) =>
value instanceof Date
? formatDateForDb(value)
: typeof value === 'string'
? value.split('T')[0]
: value;
describe('manual feed sack date serialisation', () => {
it('keeps the stored day for a local-midnight Date', () => {
// What pg produces for a DATE of 2026-07-03.
const fromPg = new Date(2026, 6, 3, 0, 0, 0, 0);
expect(toDateOnlyString(fromPg)).toBe('2026-07-03');
});
it('does not repeat the UTC shift that lost a day', () => {
const fromPg = new Date(2026, 6, 3, 0, 0, 0, 0);
const buggy = fromPg.toISOString().split('T')[0];
if (fromPg.getTimezoneOffset() < 0) {
// Positive UTC offset (WIB is -420): the old form really did shift back.
expect(buggy).toBe('2026-07-02');
}
expect(toDateOnlyString(fromPg)).not.toBe(buggy === '2026-07-03' ? 'x' : buggy);
});
it('leaves an already-formatted string alone', () => {
expect(toDateOnlyString('2026-07-03')).toBe('2026-07-03');
expect(toDateOnlyString('2026-07-03T00:00:00.000Z')).toBe('2026-07-03');
});
it('passes through null', () => {
expect(toDateOnlyString(null)).toBeNull();
});
});
+229 -43
View File
@@ -5,17 +5,23 @@ const {
generateDashboardInsight,
INSIGHT_VERSION,
} = require('../services/dashboardInsightService.js');
const cp707 = require('../services/cp707Knowledge.js');
const { llmDispatcher } = require('../services/llmDispatcher.js');
// GET /api/ai-insights/:cycleId
// Optional query params: kandangId
// Optional query params: kandangId, reportType, reportPeriod
router.get('/:cycleId', async (req, res) => {
try {
const { cycleId } = req.params;
const { kandangId } = req.query;
const { kandangId, reportType = 'daily', reportPeriod = null } = req.query;
// Find the most recent non-expired insight
const insight = await AiInsight.getByCycleAndKandang(cycleId, kandangId || null);
// Find the most recent non-expired insight for this report scope
const insight = await AiInsight.getByCycleAndKandang(
cycleId,
kandangId || null,
reportType,
reportPeriod
);
if (!insight) {
return res.status(404).json({
@@ -37,10 +43,17 @@ router.get('/:cycleId', async (req, res) => {
});
// POST /api/ai-insights
// Body: { cycleId, kandangId, insightText, version }
// Body: { cycleId, kandangId, insightText, version, reportType, reportPeriod }
router.post('/', async (req, res) => {
try {
const { cycleId, kandangId, insightText, version = 'v1' } = req.body;
const {
cycleId,
kandangId,
insightText,
version = 'v1',
reportType = 'daily',
reportPeriod = null,
} = req.body;
if (!cycleId || !insightText) {
return res.status(400).json({
@@ -55,6 +68,8 @@ router.post('/', async (req, res) => {
kandangId: kandangId || null,
insightText,
version,
reportType,
reportPeriod,
});
res.json({
success: true,
@@ -141,7 +156,7 @@ async function fetchCp707Chunks(query, topic, nResults = 4) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, topic, n_results: nResults }),
signal: AbortSignal.timeout(5000) // max 5 detik untuk RAG query
signal: AbortSignal.timeout(5000), // max 5 detik untuk RAG query
});
if (!response.ok) return [];
const json = await response.json();
@@ -153,11 +168,111 @@ async function fetchCp707Chunks(query, topic, nResults = 4) {
}
}
/**
* Strip headerless numeric table rows out of a RAG chunk, keeping its prose.
*
* The book's tables survive extraction with their column headers severed:
*
* CR
* 1 | 195 | 34 | 164,5 | 0,844
* 2 | 499 | 50 | 530,5 | 1,063
*
* Nothing there says which column is which, so any figure can be attached to
* any metric — an EEF insight quoted "standar CP 707 maksimal 164,5", which is
* the Feed-Used-CUM column in grams, while the book's IP standard is 327-380.
*
* Chunks usually mix such a table with genuinely useful prose, so dropping the
* whole chunk would throw away guidance. Only the ambiguous rows go; the
* authoritative figures come from buildCp707StandardBlock() instead.
*/
function stripHeaderlessTables(chunk) {
const lines = String(chunk || '').split('\n');
let removed = 0;
const kept = lines.filter((line) => {
const trimmed = line.trim();
if (!trimmed) return true;
// A row of pipe-separated numbers and nothing else.
const isNumericRow = /^[\d.,]+(\s*\|\s*[\d.,]*)+$/.test(trimmed);
if (isNumericRow) removed += 1;
return !isNumericRow;
});
if (removed === 0) return { text: chunk, removed: 0 };
return {
text:
kept.join('\n').trim() +
'\n[Tabel angka tanpa judul kolom dihapus dari kutipan ini karena tidak dapat ' +
'dibaca dengan benar. Gunakan blok STANDAR CP 707 di atas untuk angka standar.]',
removed,
};
}
/**
* Build an explicit, unit-labelled CP 707 standard block for the day in view.
*
* The RAG chunks carry the book's tables as pipe-separated text with the column
* headers severed from the values, so a number can be attached to any metric:
* an EEF insight quoted "standar CP 707 maksimal 164,5", which is the
* "Feed Used CUM" column in GRAMS, while the book's actual IP standard for that
* table is 327-380. Handing the model the right figures, named and with units,
* removes the guess that produced that.
*/
function buildCp707StandardBlock(contextData) {
const raw =
contextData?.hari_ke ??
contextData?.hari_terakhir ??
contextData?.currentDay ??
contextData?.dayAge;
const day = Number(raw);
if (!Number.isFinite(day) || day <= 0) return '';
const ip = cp707.getIpStandardByDay(day);
const ipRange = cp707.getIpStandardRange();
const bw = cp707.getBwStandardByDay(day);
const fcr = cp707.getFcrStandardByDay(day);
const mort = cp707.getMortalityCumStdByDay(day);
const lines = [
`- Bobot badan standar hari ke-${day}: ${bw !== null ? bw + ' gram' : 'tidak tercantum di buku'}`,
`- FCR standar hari ke-${day}: ${fcr !== null ? fcr + ' (rasio, tanpa satuan)' : 'tidak tercantum di buku'}`,
`- Mortalitas kumulatif standar hari ke-${day}: ${mort !== null ? mort + ' %' : 'tidak tercantum di buku'}`,
mort !== null
? `- Persen hidup standar hari ke-${day}: ${Math.round((100 - mort) * 100) / 100} % (turunan langsung dari mortalitas standar di atas — pakai angka ini, jangan menghitung sendiri)`
: `- Persen hidup standar hari ke-${day}: tidak tersedia`,
ip !== null
? `- IP/EEF standar hari ke-${day}: ${ip} (indeks, TANPA satuan)`
: `- IP/EEF standar hari ke-${day}: TIDAK tercantum di buku. Kolom IP pada Lampiran 2 hanya terisi hari ke-7 s/d ke-${ipRange ? ipRange.lastDay : 37} dengan rentang ${ipRange ? ipRange.min + '-' + ipRange.max : '327-380'}. Jangan mengarang standar untuk hari ini.`,
];
return `
═══════════════════════════════════════════════
STANDAR CP 707 UNTUK HARI INI (angka resmi dari Lampiran 2 buku)
${lines.join('\n')}
ATURAN WAJIB saat membandingkan dengan standar:
- Pakai HANYA angka di blok ini sebagai standar. DILARANG mengambil angka
standar dari tabel mentah di kutipan buku di bawah — kolomnya tidak berjudul,
dan angka pakan (gram) sering tertukar menjadi standar IP/EEF.
- Sebutkan satuan dengan benar: gram untuk bobot dan pakan, persen untuk
mortalitas, dan IP/EEF adalah indeks TANPA satuan.
- Jika standar untuk suatu metrik tidak tercantum, tulis "standar tidak
tersedia di buku untuk hari ini" — jangan mengganti dengan angka lain.
- Semua angka aktual harus berasal dari [Data Halaman (JSON)]. Dilarang
menghitung sendiri atau mengarang angka yang tidak ada di sana.
═══════════════════════════════════════════════`;
}
// POST /api/ai-insights/proxy
// Proxies chat requests to LM Studio, auto-detecting the loaded model
router.post('/proxy', async (req, res) => {
try {
let { input, system_prompt, temperature = 0.2, topic = '', context_query = '', contextData = null } = req.body;
let {
input,
system_prompt,
temperature = 0.2,
topic = '',
context_query = '',
contextData = null,
} = req.body;
let responseText = '';
let success = false;
@@ -172,10 +287,47 @@ router.post('/proxy', async (req, res) => {
// 1. Ambil chunk CP707 yang relevan dari RAG service
const ragQuery = context_query || input.slice(0, 300); // gunakan konteks query atau 300 char pertama dari input
const cp707Chunks = await fetchCp707Chunks(ragQuery, topic, 4);
const rawChunks = await fetchCp707Chunks(ragQuery, topic, 4);
let strippedRows = 0;
const cp707Chunks = rawChunks.map((c) => {
const { text, removed } = stripHeaderlessTables(c);
strippedRows += removed;
return text;
});
if (strippedRows > 0) {
console.log(
`[RAG] Stripped ${strippedRows} headerless table row(s); standards come from the structured block instead`
);
}
// 2. Inject CP707 chunks ke dalam system_prompt
let enrichedSystemPrompt = system_prompt || 'You are a helpful assistant.';
// Structured book standards FIRST, so they outrank anything the RAG chunks
// below may appear to say.
const standardBlock = buildCp707StandardBlock(contextData);
if (standardBlock) enrichedSystemPrompt = `${enrichedSystemPrompt}\n${standardBlock}`;
// Same anti-fabrication contract the dashboard path already enforces.
enrichedSystemPrompt = `${enrichedSystemPrompt}
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
angka baru dari angka lain.
- SATUAN SETIAP ANGKA TERTULIS DI AKHIR NAMA FIELD: _gram, _persen, _rasio,
_karung, _ekor, _kg, _hari, _indeksTanpaSatuan. Pakai satuan itu persis dan
DILARANG menukarnya. Field berakhiran _rasio dan _indeksTanpaSatuan tidak
punya satuan — jangan menambahkan "%" atau "gram" di belakangnya.
- Jangan menyamakan metrik yang berbeda. "persenHidup_persen" adalah persentase
AYAM HIDUP, bukan mortalitas; jangan mengurangkannya dari 100 lalu
menyebutnya standar.
- Field yang berisi "tidak tersedia" memang tidak ada datanya. Tulis
"data tidak tersedia" untuk field itu dan JANGAN mengisinya dengan angka.
- Jika sebuah metrik tidak punya data sama sekali, katakan demikian. Jangan
menyimpulkan kondisi baik hanya karena tidak ada angka yang buruk.
- Perhatikan arah tren: FCR yang MENURUN berarti efisiensi MEMBAIK; bobot yang
menurun berarti performa memburuk. Periksa ulang arahnya sebelum menyimpulkan.`;
if (cp707Chunks.length > 0) {
const chunksText = cp707Chunks
.map((chunk, i) => `[Referensi CP707 #${i + 1}]\n${chunk}`)
@@ -196,20 +348,30 @@ ${chunksText}
// 3. Try LM Studio / Ollama
try {
const lmStudioBaseUrl = process.env.LM_STUDIO_BASE_URL || process.env.OLLAMA_BASE_URL ||
(process.env.DOCKER_ENV === 'true' ? 'http://host.docker.internal:11434' : 'http://127.0.0.1:11434');
const lmStudioBaseUrl =
process.env.LM_STUDIO_BASE_URL ||
process.env.OLLAMA_BASE_URL ||
(process.env.DOCKER_ENV === 'true'
? 'http://host.docker.internal:11434'
: 'http://127.0.0.1:11434');
isOllama = lmStudioBaseUrl.includes('11434') || lmStudioBaseUrl.includes('llm');
let modelName = process.env.LLM_MODEL_NAME || 'qwen2.5:7b';
// Only auto-detect if LLM_MODEL_NAME is not explicitly configured
if (!process.env.LLM_MODEL_NAME) {
try {
const modelsResponse = await fetch(`${lmStudioBaseUrl}/api/v1/models`, { signal: AbortSignal.timeout(2000) });
const modelsResponse = await fetch(`${lmStudioBaseUrl}/api/v1/models`, {
signal: AbortSignal.timeout(2000),
});
if (modelsResponse.ok) {
const modelsJson = await modelsResponse.json();
if (modelsJson && modelsJson.data && modelsJson.data.length > 0) {
modelName = modelsJson.data[0].id;
} else if (modelsJson && Array.isArray(modelsJson.models) && modelsJson.models.length > 0) {
} else if (
modelsJson &&
Array.isArray(modelsJson.models) &&
modelsJson.models.length > 0
) {
modelName = modelsJson.models[0].key;
}
}
@@ -218,47 +380,67 @@ ${chunksText}
}
}
const endpoint = isOllama ? '/api/chat' : '/v1/chat/completions';
const payload = isOllama ? {
model: modelName,
messages: [
{ role: 'system', content: enrichedSystemPrompt },
{ role: 'user', content: input }
],
stream: false,
options: {
num_ctx: 8192,
num_predict: 8000,
temperature
}
} : {
model: modelName,
messages: [
{ role: 'system', content: enrichedSystemPrompt },
{ role: 'user', content: input }
],
temperature,
max_tokens: 8000
};
const payload = isOllama
? {
model: modelName,
messages: [
{ role: 'system', content: enrichedSystemPrompt },
{ role: 'user', content: input },
],
stream: false,
options: {
// Kept identical to the dashboard path on purpose: Ollama reloads
// the model whenever num_ctx changes, so two different values on
// the same model would pay a reload on every alternating request.
num_ctx: 16384,
// Measured completions on this route run 106-771 tokens. 8000 was
// unreachable anyway and only served to let a runaway model burn
// the full timeout.
num_predict: 1536,
temperature,
},
}
: {
model: modelName,
messages: [
{ role: 'system', content: enrichedSystemPrompt },
{ role: 'user', content: input },
],
temperature,
max_tokens: 8000,
};
const response = await fetch(`${lmStudioBaseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(1800000)
// Same reason as the dashboard path: undici's 300 s headersTimeout
// fires long before this signal unless the dispatcher raises it.
dispatcher: llmDispatcher,
signal: AbortSignal.timeout(1800000),
});
if (response.ok) {
const json = await response.json();
const msg = isOllama ? json.message : json.choices?.[0]?.message;
const finishReason = isOllama ? json.done_reason : json.choices?.[0]?.finish_reason;
console.log(`[LM Studio Proxy] finish_reason=${finishReason}, content_len=${msg?.content?.length || 0}`);
// prompt_eval_count is the only honest measure of how much of num_ctx
// the prompt eats — everything else here is a guess from char counts.
// If it approaches num_ctx, Ollama has silently dropped the oldest part
// of the prompt and the model answered without seeing all the data.
console.log(
`[LM Studio Proxy] finish_reason=${finishReason}, content_len=${msg?.content?.length || 0}, prompt_tokens=${json.prompt_eval_count ?? '?'}, completion_tokens=${json.eval_count ?? '?'}`
);
if (msg?.content && typeof msg.content === 'string' && msg.content.trim()) {
responseText = msg.content.trim();
} else if (msg?.reasoning_content && typeof msg.reasoning_content === 'string' && msg.reasoning_content.trim()) {
} else if (
msg?.reasoning_content &&
typeof msg.reasoning_content === 'string' &&
msg.reasoning_content.trim()
) {
responseText = msg.reasoning_content.trim();
console.log('[LM Studio Proxy] content empty, using reasoning_content as fallback');
} else if (!isOllama && json.content && typeof json.content === 'string') {
@@ -267,21 +449,25 @@ ${chunksText}
responseText = json.response;
} else if (!isOllama && Array.isArray(json.output)) {
const parts = json.output
.map(item => (typeof item.content === 'string' ? item.content : ''))
.map((item) => (typeof item.content === 'string' ? item.content : ''))
.filter(Boolean);
responseText = parts.join('\n').trim();
}
success = !!responseText;
} else {
const errBody = await response.text().catch(() => '');
console.warn(`[LM Studio Proxy] Returned status ${response.status}: ${errBody.slice(0, 200)}`);
console.warn(
`[LM Studio Proxy] Returned status ${response.status}: ${errBody.slice(0, 200)}`
);
}
} catch (lmStudioError) {
console.error('[LM Studio Proxy] Failed/not running:', lmStudioError.message);
}
if (!success) {
const errMsg = isOllama ? 'Gagal menghubungi Ollama lokal. Pastikan Ollama aktif di port 11434.' : 'Gagal menghubungi LM Studio lokal. Pastikan server LM Studio aktif di port 1234.';
const errMsg = isOllama
? 'Gagal menghubungi Ollama lokal. Pastikan Ollama aktif di port 11434.'
: 'Gagal menghubungi LM Studio lokal. Pastikan server LM Studio aktif di port 1234.';
throw new Error(errMsg);
}
@@ -295,7 +481,7 @@ ${chunksText}
console.error('Error in proxy route:', error);
res.status(500).json({
success: false,
error: error.message
error: error.message,
});
}
});
+42 -12
View File
@@ -52,6 +52,37 @@ describe('AI Insights Proxy Route', () => {
],
});
// Ollama replies in its own shape and on its own endpoint; the route picks
// between the two by base URL, and defaults to Ollama on port 11434.
const mockOllamaResponse = {
message: {
content: JSON.stringify({
headline: 'Analisis Test FCR',
status: 'ok',
summary: ['FCR normal'],
risks: [],
actions: ['Pertahankan'],
}),
},
done_reason: 'stop',
};
/** The prompt assertions hold for either backend. */
const assertPromptShape = (options) => {
const body = JSON.parse(options.body);
// System prompt must carry the RAG chunks
expect(body.messages[0].role).toBe('system');
expect(body.messages[0].content).toContain('REFERENSI BUKU: Manajemen Broiler CP 707');
expect(body.messages[0].content).toContain(
'Ini adalah chunk standar FCR broiler CP 707 dari buku.'
);
// User message must carry contextData
expect(body.messages[1].role).toBe('user');
expect(body.messages[1].content).toContain('"FCR_aktual": 1.25');
};
const mockFetch = vi.fn().mockImplementation((url, options) => {
// Mock /api/v1/models (checking loaded models)
if (url.includes('/api/v1/models')) {
@@ -72,19 +103,18 @@ describe('AI Insights Proxy Route', () => {
});
}
// Mock LM Studio chat completions
// Mock Ollama chat (the default path)
if (url.includes('/api/chat')) {
assertPromptShape(options);
return Promise.resolve({
ok: true,
json: () => Promise.resolve(mockOllamaResponse),
});
}
// Mock LM Studio chat completions (used when pointed at LM Studio)
if (url.includes('/v1/chat/completions')) {
const body = JSON.parse(options.body);
// Assert that body.messages contains system prompt with RAG chunks
expect(body.messages[0].role).toBe('system');
expect(body.messages[0].content).toContain('REFERENSI BUKU: Manajemen Broiler CP 707');
expect(body.messages[0].content).toContain('Ini adalah chunk standar FCR broiler CP 707 dari buku.');
// Assert that body.messages contains user message with contextData
expect(body.messages[1].role).toBe('user');
expect(body.messages[1].content).toContain('"FCR_aktual": 1.25');
assertPromptShape(options);
return Promise.resolve({
ok: true,
json: () => Promise.resolve(JSON.parse(mockResponseText)),
+104 -95
View File
@@ -3,55 +3,68 @@ const router = express.Router();
const ManualFeedSack = require('../models/ManualFeedSack');
const { convertKeysToCamelCase } = require('../utils/caseConverter');
const {
isBeforeCutoffTime,
getPreviousDate,
createResponseMetadata
isBeforeCutoffTime,
getPreviousDate,
createResponseMetadata,
formatDateForDb,
} = require('../utils/dateUtils');
/**
* Serialise a DATE column without shifting the day.
*
* node-postgres turns a DATE into a JS Date at LOCAL midnight. `toISOString()`
* then re-expresses that in UTC, which in WIB (+7) is 17:00 the PREVIOUS day —
* so an entry saved on 3 Jul was served to the browser as 2 Jul, and the daily
* table showed every manual count against the wrong date. `formatDateForDb`
* reads the local parts instead.
*/
const toDateOnlyString = (value) =>
value instanceof Date
? formatDateForDb(value)
: typeof value === 'string'
? value.split('T')[0]
: value;
/**
* GET /api/manual-feed-sack?startDate=YYYY-MM-DD&endDate=YYYY-MM-DD
* Get all manual entries for a date range
*/
router.get('/', async (req, res) => {
try {
let { startDate, endDate } = req.query;
try {
let { startDate, endDate } = req.query;
if (!startDate || !endDate) {
return res.status(400).json({
success: false,
error: 'Missing required query parameters: startDate, endDate'
});
}
// Apply D-1 filter if querying single day and before cutoff (17:00 WIB)
let isD1Applied = false;
if (startDate === endDate && isBeforeCutoffTime()) {
startDate = getPreviousDate(startDate);
endDate = startDate;
isD1Applied = true;
}
const entries = await ManualFeedSack.getByDateRange(startDate, endDate);
// Transform to camelCase and ensure date is a string
const transformedEntries = entries.map(entry => ({
...convertKeysToCamelCase(entry),
date: entry.date instanceof Date
? entry.date.toISOString().split('T')[0]
: typeof entry.date === 'string'
? entry.date.split('T')[0]
: entry.date
}));
res.json({
success: true,
data: transformedEntries,
meta: createResponseMetadata(isD1Applied)
});
} catch (error) {
console.error('Error fetching manual feed sack entries:', error);
res.status(500).json({ success: false, error: error.message });
if (!startDate || !endDate) {
return res.status(400).json({
success: false,
error: 'Missing required query parameters: startDate, endDate',
});
}
// Apply D-1 filter if querying single day and before cutoff (17:00 WIB)
let isD1Applied = false;
if (startDate === endDate && isBeforeCutoffTime()) {
startDate = getPreviousDate(startDate);
endDate = startDate;
isD1Applied = true;
}
const entries = await ManualFeedSack.getByDateRange(startDate, endDate);
// Transform to camelCase and ensure date is a string
const transformedEntries = entries.map((entry) => ({
...convertKeysToCamelCase(entry),
date: toDateOnlyString(entry.date),
}));
res.json({
success: true,
data: transformedEntries,
meta: createResponseMetadata(isD1Applied),
});
} catch (error) {
console.error('Error fetching manual feed sack entries:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
@@ -59,55 +72,51 @@ router.get('/', async (req, res) => {
* Upsert manual entry
*/
router.put('/', async (req, res) => {
try {
const { date, cameraName, count, createdBy, keterangan } = req.body;
try {
const { date, cameraName, count, createdBy, keterangan } = req.body;
// Validation
if (!date || !cameraName || count === undefined) {
return res.status(400).json({
success: false,
error: 'Missing required fields: date, cameraName, count'
});
}
if (count < 0) {
return res.status(400).json({
success: false,
error: 'Count cannot be negative'
});
}
// Validate keterangan length
if (keterangan && keterangan.length > 200) {
return res.status(400).json({
success: false,
error: 'Keterangan cannot exceed 200 characters'
});
}
const entry = await ManualFeedSack.upsert(
date,
cameraName,
count,
createdBy,
keterangan || null
);
// Transform to camelCase and ensure date is a string
const transformedEntry = {
...convertKeysToCamelCase(entry),
date: entry.date instanceof Date
? entry.date.toISOString().split('T')[0]
: typeof entry.date === 'string'
? entry.date.split('T')[0]
: entry.date
};
res.json({ success: true, data: transformedEntry });
} catch (error) {
console.error('Error saving manual feed sack entry:', error);
res.status(500).json({ success: false, error: error.message });
// Validation
if (!date || !cameraName || count === undefined) {
return res.status(400).json({
success: false,
error: 'Missing required fields: date, cameraName, count',
});
}
if (count < 0) {
return res.status(400).json({
success: false,
error: 'Count cannot be negative',
});
}
// Validate keterangan length
if (keterangan && keterangan.length > 200) {
return res.status(400).json({
success: false,
error: 'Keterangan cannot exceed 200 characters',
});
}
const entry = await ManualFeedSack.upsert(
date,
cameraName,
count,
createdBy,
keterangan || null
);
// Transform to camelCase and ensure date is a string
const transformedEntry = {
...convertKeysToCamelCase(entry),
date: toDateOnlyString(entry.date),
};
res.json({ success: true, data: transformedEntry });
} catch (error) {
console.error('Error saving manual feed sack entry:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
@@ -115,14 +124,14 @@ router.put('/', async (req, res) => {
* Delete manual entry
*/
router.delete('/:date/:cameraName', async (req, res) => {
try {
const { date, cameraName } = req.params;
await ManualFeedSack.delete(date, cameraName);
res.json({ success: true });
} catch (error) {
console.error('Error deleting manual feed sack entry:', error);
res.status(500).json({ success: false, error: error.message });
}
try {
const { date, cameraName } = req.params;
await ManualFeedSack.delete(date, cameraName);
res.json({ success: true });
} catch (error) {
console.error('Error deleting manual feed sack entry:', error);
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;
+33 -1
View File
@@ -14,9 +14,41 @@ const app = express();
const PORT = process.env.PORT || 5001;
// Middleware
/**
* Origins allowed to call this API.
*
* Production keeps an explicit list. Development also accepts ANY port on
* localhost, because Vite is configured for 3000 but silently falls back when
* that port is taken — 3001, 3002, 3003 and up, depending on what else the
* developer happens to be running. A hardcoded list meant the app loaded fine
* on the fallback port while every request failed CORS, which reads as "the
* data is gone" rather than "the port moved".
*
* The loopback-only test is what keeps this safe: it never reflects a remote
* origin, so `credentials: true` cannot be used by another site.
*/
const ALLOWED_ORIGINS = [
'http://localhost:3000',
'http://localhost:3001',
'http://localhost:3002',
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:5002',
];
const isDevelopment = process.env.NODE_ENV !== 'production';
const LOOPBACK_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
app.use(
cors({
origin: ['http://localhost:3000', 'http://localhost:3001', 'http://localhost:3002', 'http://localhost:5173', 'http://localhost:5174', 'http://localhost:5002'],
origin: (origin, callback) => {
// No Origin header: same-origin, curl, or a server-to-server call.
if (!origin) return callback(null, true);
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true);
if (isDevelopment && LOOPBACK_ORIGIN.test(origin)) return callback(null, true);
return callback(new Error(`Origin tidak diizinkan oleh CORS: ${origin}`));
},
credentials: true,
})
);
+30 -2
View File
@@ -149,6 +149,27 @@ function getTetByDay(dayAge) {
}
// ─── Standar Mortalitas Kumulatif per Hari ───────────────────────────────────
/**
* Standar IP (Indeks Performans / EEF) menurut Lampiran 2 buku CP 707.
*
* Kolom IP di tabel buku hanya terisi mulai hari ke-7 dan berhenti di hari
* ke-37 (nilai 380). Di luar rentang itu buku TIDAK menyatakan standar, jadi
* fungsi ini mengembalikan null — jangan menggantinya dengan tebakan. Model
* pernah membaca angka 164,5 (kolom "Feed Used CUM", satuan gram) dari tabel
* yang sama dan melaporkannya sebagai standar EEF; menyediakan angka yang
* benar secara eksplisit adalah cara mencegah itu terulang.
*/
function getIpStandardByDay(dayAge) {
const std = PERFORMANCE_STANDARD_DAILY.find(d => d.day === dayAge);
return std && typeof std.ip === 'number' ? std.ip : null;
}
/** Rentang IP yang tercantum di buku, untuk konteks saat hari di luar tabel. */
function getIpStandardRange() {
const values = PERFORMANCE_STANDARD_DAILY.map(d => d.ip).filter(v => typeof v === 'number');
return values.length ? { min: Math.min(...values), max: Math.max(...values), lastDay: 37 } : null;
}
function getMortalityCumStdByDay(dayAge) {
const std = PERFORMANCE_STANDARD_DAILY.find(d => d.day === dayAge);
if (std) return std.mortalityCum_pct;
@@ -358,7 +379,8 @@ CATATAN PENTING:
// ─── Analisis Komprehensif berbasis CP 707 ───────────────────────────────────
function analyzeWithCP707Standards(contextPack) {
const dayAge = contextPack?.cycle?.currentAgeDay ||
const dayAge = contextPack?.metadata?.currentDay ||
contextPack?.cycle?.currentAgeDay ||
contextPack?.ageDay ||
contextPack?.umurHari ||
null;
@@ -367,6 +389,7 @@ function analyzeWithCP707Standards(contextPack) {
// Berat badan
const actualBw = contextPack?.weightStats?.averageWeight?.value ||
contextPack?.weight?.current?.averageWeight ||
contextPack?.berat_rata_rata ||
null;
if (actualBw !== null && dayAge !== null) {
@@ -375,6 +398,8 @@ function analyzeWithCP707Standards(contextPack) {
// FCR
const actualFcr = contextPack?.fcr ||
contextPack?.fcr_eef?.fcr?.actual ||
contextPack?.weight?.current?.fcr ||
contextPack?.fcr_terakhir ||
contextPack?.fcrSummary?.current ||
null;
@@ -384,6 +409,7 @@ function analyzeWithCP707Standards(contextPack) {
// Mortalitas
const mortalityRate = contextPack?.chickenCounting?.dashboardSummary?.mortalityRate ||
contextPack?.chickenCounting?.mortality?.mortalityCount ||
contextPack?.mortalityRate ||
null;
if (mortalityRate !== null) {
@@ -391,7 +417,7 @@ function analyzeWithCP707Standards(contextPack) {
}
// Lingkungan (IoT)
const iot = contextPack?.panel_iot || contextPack?.telemetry || contextPack?.iotData || {};
const iot = contextPack?.iotPanel?.display || contextPack?.panel_iot || contextPack?.telemetry || contextPack?.iotData || {};
const temp = iot?.avgTemp || iot?.suhu_rata_rata_C || null;
const hum = iot?.humidity || iot?.kelembapan_persen || null;
const ammonia = iot?.ammonia || iot?.amonia_ppm || null;
@@ -422,6 +448,8 @@ module.exports = {
getTempStandardByDay,
getTetByDay,
getMortalityCumStdByDay,
getIpStandardByDay,
getIpStandardRange,
analyzeFcr,
analyzeBw,
analyzeMortality,
File diff suppressed because it is too large. Load diff
+24
View File
@@ -0,0 +1,24 @@
const { Agent } = require('undici');
/**
* The dispatcher every LLM call must use.
*
* Node's global `fetch` is undici, whose `headersTimeout` defaults to 300 s.
* Ollama is called with `stream: false`, so it sends NO response headers until
* the whole answer is generated — an end-cycle report takes longer than that.
* The connection was therefore being torn down at almost exactly 301 s with a
* bare `TypeError: fetch failed`, and every such run was quietly demoted to the
* local fallback generator.
*
* The `AbortSignal.timeout(...)` on those calls was never the effective limit:
* undici gave up first, so raising the signal to 20 minutes changed nothing.
* Both numbers have to be long for either to matter.
*/
const LLM_REQUEST_TIMEOUT_MS = 1_800_000; // 30 minutes
const llmDispatcher = new Agent({
headersTimeout: LLM_REQUEST_TIMEOUT_MS,
bodyTimeout: LLM_REQUEST_TIMEOUT_MS,
});
module.exports = { llmDispatcher, LLM_REQUEST_TIMEOUT_MS };
+5 -6
View File
@@ -10,12 +10,11 @@ export default defineConfig({
exclude: ['node_modules/', 'database/migrations/', '**/*.test.js', '**/__tests__/**'],
},
testTimeout: 10000,
// Run tests serially to avoid database connection conflicts
// Run test FILES serially: these suites share one real database, so running
// them in parallel let Cycle and Mortality overwrite each other's rows.
// `poolOptions.forks.singleFork` expressed this before Vitest 4 removed it,
// and its silent no-op is what reintroduced the conflicts.
pool: 'forks',
poolOptions: {
forks: {
singleFork: true,
},
},
fileParallelism: false,
},
});
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -42,6 +42,8 @@ import { apiClient } from '../services/apiService.ts';
interface AppContentProps {
selectedKandangId: number | null;
setSelectedKandangId: (id: number | null) => void;
selectedArea: string;
setSelectedArea: (area: string) => void;
kandangs: Kandang[];
setKandangs: (kandangs: Kandang[]) => void;
}
@@ -49,6 +51,8 @@ interface AppContentProps {
const AppContent: React.FC<AppContentProps> = ({
selectedKandangId,
setSelectedKandangId,
selectedArea,
setSelectedArea,
kandangs,
setKandangs,
}) => {
@@ -56,7 +60,6 @@ const AppContent: React.FC<AppContentProps> = ({
const [view, setView] = useState<View>('dashboard');
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false);
const [isTimelineVisible, setIsTimelineVisible] = useState(false);
const [selectedArea, setSelectedArea] = useState<string>('Sukawarna');
const toggleSidebar = () => {
setIsSidebarCollapsed(!isSidebarCollapsed);
@@ -82,8 +85,9 @@ const AppContent: React.FC<AppContentProps> = ({
// Auto-select first kandang when kandangs load
useEffect(() => {
if (kandangs.length > 0 && selectedKandangId === null) {
setSelectedKandangId(kandangs[0].id);
const firstKandang = kandangs[0];
if (firstKandang && selectedKandangId === null) {
setSelectedKandangId(firstKandang.id);
}
}, [kandangs, selectedKandangId, setSelectedKandangId]);
+17 -50
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import CombinedTrendChart from './counting/CombinedTrendChart.tsx';
import { PageAiInsight } from './shared/PageAiInsight.tsx';
import ChickenCountingAiInsight from './counting/ChickenCountingAiInsight.tsx';
import { CalculatorIcon } from './icons/Icons.tsx';
import { useAppContext } from '../context/AppContext.tsx';
import { apiClient } from '../services/apiService.ts';
@@ -141,18 +141,20 @@ const ChickenCountingPage: React.FC = () => {
const perKandang: KandangPopulationData[] = [];
for (let i = 0; i < kandangs.length; i++) {
const k = kandangs[i];
if (!k) continue;
try {
const kRecords = await apiClient.getMortalityRecords(cycleData.id, k.id);
const kInitPop = k.docInCount || 0;
const { populations: kPop } = buildPopulationData(kRecords, kInitPop);
const mortality = kRecords.reduce(
(sum, record) => sum + (record.mortalityCount || 0) + (record.afkir || 0),
(sum: number, record: { mortalityCount?: number; afkir?: number }) =>
sum + (record.mortalityCount || 0) + (record.afkir || 0),
0
);
perKandang.push({
name: k.name,
data: kPop,
color: KANDANG_COLORS[i % KANDANG_COLORS.length],
color: KANDANG_COLORS[i % KANDANG_COLORS.length] ?? '#6b7280',
mortality,
});
} catch {
@@ -171,38 +173,7 @@ const ChickenCountingPage: React.FC = () => {
fetchData();
}, [cycleData, kandangs, totalInitialPopulation, buildPopulationData]);
// Build per-kandang insight summary for AI
const kandangInsights = useMemo(() => {
return perKandangData.map((k) => {
const latestPop = k.data[k.data.length - 1]?.population ?? 0;
const docIn = kandangs.find((kd) => kd.name === k.name)?.docInCount ?? 0;
const mortalityTotal = k.mortality || 0;
const mortalityRate = docIn > 0 ? (mortalityTotal / docIn) * 100 : 0;
return {
name: k.name,
docIn,
currentPopulation: latestPop,
mortalityTotal,
mortalityRate,
};
});
}, [perKandangData, kandangs]);
const currentDay = useMemo(() => {
if (cycleData?.currentDay) {
return cycleData.totalDays
? Math.min(cycleData.currentDay, cycleData.totalDays)
: cycleData.currentDay;
}
const today = new Date();
const startDate = new Date(cycleData.startDate);
today.setHours(0, 0, 0, 0);
startDate.setHours(0, 0, 0, 0);
const day = Math.floor((today.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
return cycleData?.totalDays
? Math.min(Math.max(1, day), cycleData.totalDays)
: Math.max(1, day);
}, [cycleData.startDate, cycleData.currentDay, cycleData.totalDays]);
// Removed unused kandangInsights and currentDay hooks
return (
<main className="px-4 sm:px-6 lg:px-8 pb-8">
@@ -219,16 +190,13 @@ const ChickenCountingPage: React.FC = () => {
{/* AI Insight */}
<div className="mb-8">
<PageAiInsight
topic="hitung_ayam"
cacheKey={`cc-${cycleData.id}-${currentDay}`}
contextData={{
currentDay,
totalDays: cycleData.totalDays,
totalInitialPopulation,
populationTrend: populationTrendHistory,
kandangInsights,
}}
<ChickenCountingAiInsight
cycleId={cycleData.id}
currentDay={cycleData.currentDay}
totalDays={cycleData.totalDays || 1}
totalInitialPopulation={totalInitialPopulation}
populationTrend={populationTrendHistory as never}
kandangInsights={[]}
/>
</div>
@@ -241,11 +209,10 @@ const ChickenCountingPage: React.FC = () => {
<p className="text-sm font-semibold text-gray-700">Total Semua Kandang</p>
</div>
<p className="text-2xl font-bold text-gray-800">
{populationTrendHistory.length > 0
? populationTrendHistory[
populationTrendHistory.length - 1
].population.toLocaleString()
: totalInitialPopulation.toLocaleString()}
{(
populationTrendHistory[populationTrendHistory.length - 1]?.population ??
totalInitialPopulation
).toLocaleString()}
<span className="text-sm font-normal text-gray-500 ml-1">ekor</span>
</p>
<p className="text-xs text-gray-500">
+59 -10
View File
@@ -295,8 +295,20 @@ const EefDashboardPage: React.FC = () => {
}));
}, [supportRows]);
const getLatestValid = <K extends keyof EefSupportRow>(key: K): EefSupportRow[K] | null => {
for (let i = supportRows.length - 1; i >= 0; i--) {
// Indexed access can be undefined, and the row's own fields are not
// necessarily strings — compare against the empty string loosely.
const val = supportRows[i]?.[key];
if (val !== null && val !== undefined && val !== 0 && String(val) !== '') {
return val;
}
}
return null;
};
const latestRow = supportRows[supportRows.length - 1];
const latestEef = latestRow?.iotEef ?? null;
const latestEef = getLatestValid('iotEef');
return (
<main className="px-4 sm:px-6 lg:px-8 pb-8">
@@ -325,14 +337,44 @@ const EefDashboardPage: React.FC = () => {
<PageAiInsight
topic="eef"
cacheKey={`eef-rows${supportRows.length}-eef${String(latestEef ?? 'null').replace('.', '_')}`}
chartSpec={{
type: 'trend',
historyKey: 'tren_7_hari_terakhir',
title: 'Tren EEF',
series: [{ field: 'eef', label: 'EEF', color: '#dc2626' }],
}}
scalarScope={{
historyKey: 'tren_7_hari_terakhir',
derive: {
eefRataRata_indeksTanpaSatuan: {
field: 'eef',
agg: 'mean',
replaces: 'eef_terakhir',
},
fcrRataRata_rasio: { field: 'fcr', agg: 'mean', replaces: 'fcr_terakhir' },
// Same reason as the FCR page: 0 here means "no reading".
persenHidupTerakhir_persen: {
field: 'persen_hidup',
agg: 'lastValid',
replaces: 'persen_hidup',
},
bobotPanenTerakhir_gram: {
field: 'bw_panen',
agg: 'lastValid',
replaces: 'bw_panen_gram',
},
},
// `persen_hidup` unitless was read back as a mortality figure.
drop: ['eef_terakhir', 'fcr_terakhir', 'persen_hidup', 'bw_panen_gram'],
}}
contextData={{
hari_terakhir: latestRow?.day ?? null,
eef_terakhir: latestEef,
fcr_terakhir: latestRow?.iotFcr ?? null,
persen_hidup: latestRow?.chickenLifePercentage ?? null,
bw_panen_gram: latestRow?.iotAverageHarvestBw ?? null,
bw_hari_gram: latestRow?.iotAverageDayWeight ?? null,
total_hari_data: supportRows.length,
fcr_terakhir: getLatestValid('iotFcr') ?? null,
persen_hidup: getLatestValid('chickenLifePercentage') ?? null,
bw_panen_gram: getLatestValid('iotAverageHarvestBw') ?? null,
bobotHarian_gram: getLatestValid('iotAverageDayWeight') ?? null,
jumlahHariData_hari: supportRows.length,
// Kirim hanya 7 hari terakhir untuk menghindari prompt terlalu panjang
tren_7_hari_terakhir: supportRows.slice(-7).map((r) => ({
hari: r.day,
@@ -356,20 +398,27 @@ const EefDashboardPage: React.FC = () => {
<StatCard title="EEF" value={formatEef(latestEef)} unit="" />
<StatCard
title="Persentase Ayam Hidup"
value={formatNullableNumber(latestRow?.chickenLifePercentage ?? null, 3)}
value={formatNullableNumber(
getLatestValid('chickenLifePercentage') as number | null,
3
)}
unit="%"
/>
<StatCard
title="BW Rata-rata Panen"
value={formatNullableNumber(latestRow?.iotAverageHarvestBw ?? null, 2)}
value={formatNullableNumber(getLatestValid('iotAverageHarvestBw') as number | null, 2)}
unit="g"
/>
<StatCard
title="Umur Rata-rata Panen"
value={formatNullableNumber(latestRow?.iotAverageDayWeight ?? null, 2)}
value={formatNullableNumber(getLatestValid('iotAverageDayWeight') as number | null, 2)}
unit="hari"
/>
<StatCard title="FCR" value={formatFcr(latestRow?.iotFcr ?? null)} unit="" />
<StatCard
title="FCR"
value={formatFcr(getLatestValid('iotFcr') as number | null)}
unit=""
/>
</div>
<EefTrendChart data={eefTrendData} />
+62 -12
View File
@@ -273,8 +273,20 @@ const FcrDashboardPage: React.FC = () => {
}));
}, [supportRows]);
const getLatestValid = <K extends keyof FcrSupportRow>(key: K): FcrSupportRow[K] | null => {
for (let i = supportRows.length - 1; i >= 0; i--) {
// Indexed access can be undefined, and the row's own fields are not
// necessarily strings — compare against the empty string loosely.
const val = supportRows[i]?.[key];
if (val !== null && val !== undefined && val !== 0 && String(val) !== '') {
return val;
}
}
return null;
};
const latestRow = supportRows[supportRows.length - 1];
const latestFcr = latestRow?.iotFcr ?? null;
const latestFcr = getLatestValid('iotFcr');
return (
<main className="px-4 sm:px-6 lg:px-8 pb-8">
@@ -304,16 +316,54 @@ const FcrDashboardPage: React.FC = () => {
<PageAiInsight
topic="fcr"
cacheKey={`fcr-rows${supportRows.length}-fcr${String(latestFcr ?? 'null').replace('.', '_')}`}
chartSpec={{
type: 'trend',
historyKey: 'tren_7_hari_terakhir',
title: 'Tren FCR',
series: [{ field: 'fcr', label: 'FCR aktual', color: '#dc2626' }],
}}
scalarScope={{
historyKey: 'tren_7_hari_terakhir',
derive: {
fcrRataRata_rasio: { field: 'fcr', agg: 'mean', replaces: 'fcr_terakhir' },
// `lastValid`, not `last`: these three carry 0 on every day the
// sensor did not report, and the page beside this card already
// skips those zeros via `getLatestValid`.
pakanTerakhir_karung: {
field: 'pakan',
agg: 'lastValid',
replaces: 'pakan_karung_terakhir',
},
bobotIotTerakhir_kg: {
field: 'bobot_kg',
agg: 'lastValid',
replaces: 'bobot_iot_kg_terakhir',
},
ayamHidupTerakhir_ekor: {
field: 'ayam',
agg: 'lastValid',
replaces: 'ayam_hidup_ekor',
},
},
drop: [
'fcr_terakhir',
'pakan_karung_terakhir',
'bobot_iot_kg_terakhir',
'ayam_hidup_ekor',
],
}}
contextData={
supportRows.length > 0
? {
hari_terakhir: latestRow?.day ?? null,
fcr_terakhir: latestFcr,
pakan_karung_terakhir: latestRow?.feedTotal ?? null,
bobot_iot_kg_terakhir: latestRow?.iotWeight ?? null,
ayam_hidup_ekor: latestRow?.chickenLife ?? null,
berat_panen_kg: latestRow ? latestRow.harvestWeightTotal / 1000 : null,
total_hari_data: supportRows.length,
pakan_karung_terakhir: getLatestValid('feedTotal') ?? null,
bobot_iot_kg_terakhir: getLatestValid('iotWeight') ?? null,
ayam_hidup_ekor: getLatestValid('chickenLife') ?? null,
beratPanen_kg: getLatestValid('harvestWeightTotal')
? (getLatestValid('harvestWeightTotal') as number) / 1000
: null,
jumlahHariData_hari: supportRows.length,
// Kirim hanya 7 hari terakhir untuk menghindari prompt terlalu panjang
tren_7_hari_terakhir: supportRows.slice(-7).map((r) => ({
hari: r.day,
@@ -339,22 +389,22 @@ const FcrDashboardPage: React.FC = () => {
<StatCard title="FCR" value={formatFcr(latestFcr)} unit="" />
<StatCard
title="Total Pakan"
value={formatNumber(latestRow?.feedTotal ?? 0)}
unit="karung"
value={formatNumber((getLatestValid('feedTotal') as number) ?? 0)}
unit="Karung"
/>
<StatCard
title="Bobot IoT"
value={formatNullableNumber(latestRow?.iotWeight ?? null, 2)}
value={formatNullableNumber(getLatestValid('iotWeight') as number | null, 2)}
unit="g"
/>
<StatCard
title="Ayam Hidup"
value={formatNumber(latestRow?.chickenLife ?? 0)}
unit="ekor"
value={formatNumber((getLatestValid('chickenLife') as number) ?? 0)}
unit="Ekor"
/>
<StatCard
title="Total Bobot Panen"
value={formatHarvestWeightKg(latestRow?.harvestWeightTotal ?? 0)}
value={formatHarvestWeightKg((getLatestValid('harvestWeightTotal') as number) ?? 0)}
unit="kg"
/>
</div>
+193 -15
View File
@@ -19,6 +19,18 @@ import { apiClient } from '../services/apiService.ts';
import { View, CycleData } from '../types.ts';
import { PageAiInsight } from './shared/PageAiInsight.tsx';
/** Total sacks across manual entries of one configured type. */
const sumManualRows = (
rows: Array<{ entry: { count: number }; type: 'inbound' | 'usage' | 'outbound' }>,
type: 'inbound' | 'usage' | 'outbound'
): number => rows.reduce((sum, row) => (row.type === type ? sum + (row.entry.count || 0) : sum), 0);
/** Total sacks across rows of one configured type. */
const sumFeedRows = (
rows: Array<{ log: { count: number }; type: 'inbound' | 'usage' | 'outbound' }>,
type: 'inbound' | 'usage' | 'outbound'
): number => rows.reduce((sum, row) => (row.type === type ? sum + (row.log.count || 0) : sum), 0);
interface FeedSackCountingPageProps {
setView?: (view: View) => void;
}
@@ -160,8 +172,10 @@ const FeedSackCountingPage: React.FC<FeedSackCountingPageProps> = ({ setView })
// Fetch manual entries when cycle/date range changes
useEffect(() => {
const startDateStr = startDate.toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
// `split` is typed as possibly-empty, though an ISO string always has a
// date part.
const startDateStr = startDate.toISOString().split('T')[0] ?? '';
const endDateStr = endDate.toISOString().split('T')[0] ?? '';
fetchManualFeedSackData(startDateStr, endDateStr);
}, [startDate, endDate, fetchManualFeedSackData]);
@@ -178,6 +192,67 @@ const FeedSackCountingPage: React.FC<FeedSackCountingPageProps> = ({ setView })
});
}, [feedSackHistory, startDate, endDate]);
/**
* Which camera counts as what, per the admin's saved column configuration.
*
* `log.type` cannot be trusted here: it is guessed in `transformFeedSackData`
* from the camera name, and anything not matching "karung_masuk"/"karung_keluar"
* falls through to "usage". The live site returns two cameras
* (`kandang_atas_feeder_kiri`, `kandang_bawah_feeder_kanan`) that are not feed-sack
* counters at all and are absent from the configuration, yet that fallback
* counted every one of their detections as a poured sack — 2.205 instead of 21
* for one week. The same guess also reads `kandang_1_karung_masuk_out` as
* INBOUND because of the substring, while the configuration files it as outbound.
*
* The table below this card already respects the configuration, which is why
* hand-counting from the table disagreed with the insight.
*/
const cameraTypeByName = useMemo(() => {
const map = new Map<string, 'inbound' | 'usage' | 'outbound'>();
for (const column of columnConfig) {
if (column.cameraName) map.set(column.cameraName, column.type);
}
return map;
}, [columnConfig]);
/**
* Manual entries, typed by the same configuration.
*
* Shown BESIDE the camera figure rather than merged into it: both count the
* same sacks, so adding them would double-count, and the table already
* presents them as a manual-vs-camera comparison.
*/
const classifiedManualRows = useMemo(
() =>
manualFeedSackEntries
.map((entry) => ({ entry, type: cameraTypeByName.get(entry.cameraName) }))
.filter(
(
row
): row is {
entry: (typeof manualFeedSackEntries)[number];
type: 'inbound' | 'usage' | 'outbound';
} => Boolean(row.type)
),
[manualFeedSackEntries, cameraTypeByName]
);
/** Only the rows from configured cameras, typed as the configuration says. */
const classifiedFeedRows = useMemo(
() =>
filteredData
.map((log) => ({ log, type: cameraTypeByName.get(log.cameraName) }))
.filter(
(
row
): row is {
log: (typeof filteredData)[number];
type: 'inbound' | 'usage' | 'outbound';
} => Boolean(row.type)
),
[filteredData, cameraTypeByName]
);
// Handler for saving initial balance
const handleSaveInitialBalance = useCallback(
async (saldoAwal: number) => {
@@ -369,28 +444,131 @@ const FeedSackCountingPage: React.FC<FeedSackCountingPageProps> = ({ setView })
<PageAiInsight
topic="hitung_karung"
cacheKey={`pakan-${selectedCycleId}-day${displayCurrentDay}-entries${filteredData.length}`}
chartSpec={{
type: 'trend',
historyKey: 'tren_harian',
title: 'Karung dituang vs masuk per hari',
unit: 'karung',
series: [
{ field: 'dituang', label: 'Dituang (IOT)', color: '#dc2626' },
{ field: 'masuk', label: 'Masuk (IOT)', color: '#3b82f6' },
{ field: 'masukManual', label: 'Masuk (Manual)', color: '#0ea5e9' },
],
}}
scalarScope={{
historyKey: 'tren_harian',
derive: {
karungMasuk_karung: {
field: 'masuk',
agg: 'sum',
replaces: 'total_karung_masuk_iot',
},
karungDituang_karung: {
field: 'dituang',
agg: 'sum',
replaces: 'total_karung_dituang_iot',
},
karungKeluar_karung: { field: 'keluar', agg: 'sum' },
karungMasukManual_karung: {
field: 'masukManual',
agg: 'sum',
replaces: 'total_karung_masuk_manual',
},
karungDituangManual_karung: {
field: 'dituangManual',
agg: 'sum',
replaces: 'total_karung_dituang_manual',
},
rataKarungDituangPerHari_karung: {
field: 'dituang',
agg: 'mean',
replaces: 'rata_karung_per_hari',
},
},
// Unitless cycle-to-date totals the period figures replace.
drop: [
'total_karung_masuk_iot',
'total_karung_dituang_iot',
'total_karung_masuk_manual',
'total_karung_dituang_manual',
'rata_karung_per_hari',
],
}}
contextData={{
siklus: selectedCycleId,
hari_ke: displayCurrentDay,
total_catatan_iot: filteredData.length,
// Day-indexed history is what makes the Harian/Mingguan toggle
// do anything: the log rows carry a timestamp, not a cycle day,
// so without this rollup the scoping helper finds no array to
// trim and both modes send exactly the same payload.
tren_harian: (() => {
const start = new Date(selectedCycle.startDate);
start.setHours(0, 0, 0, 0);
const perDay = new Map<
number,
{
hari: number;
masuk: number;
dituang: number;
keluar: number;
masukManual: number;
dituangManual: number;
}
>();
const emptyRow = (hari: number) => ({
hari,
masuk: 0,
dituang: 0,
keluar: 0,
masukManual: 0,
dituangManual: 0,
});
const dayOfDate = (value: Date) => {
const at = new Date(value);
at.setHours(0, 0, 0, 0);
return Math.floor((at.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
};
// Configured cameras only — see `classifiedFeedRows`.
for (const { log, type } of classifiedFeedRows) {
const hari = dayOfDate(new Date(log.timestamp));
if (!Number.isFinite(hari) || hari <= 0) continue;
const row = perDay.get(hari) ?? emptyRow(hari);
if (type === 'inbound') row.masuk += log.count;
else if (type === 'usage') row.dituang += log.count;
else row.keluar += log.count;
perDay.set(hari, row);
}
for (const { entry, type } of classifiedManualRows) {
const hari = dayOfDate(new Date(entry.date));
if (!Number.isFinite(hari) || hari <= 0) continue;
const row = perDay.get(hari) ?? emptyRow(hari);
if (type === 'inbound') row.masukManual += entry.count;
else if (type === 'usage') row.dituangManual += entry.count;
perDay.set(hari, row);
}
return [...perDay.values()].sort((a, b) => a.hari - b.hari);
})(),
jumlahCatatanKamera_baris: classifiedFeedRows.length,
// The tile reads `saldo_awal_karung`; sending `saldoAwal_karung`
// meant "Saldo Awal Pakan" was permanently N/A even with a
// balance saved.
saldo_awal_karung: initialBalance?.saldoAwal ?? null,
total_karung_masuk_iot: filteredData.reduce(
(sum: number, d: any) => sum + (d.countIn ?? 0),
0
),
total_karung_dituang_iot: filteredData.reduce(
(sum: number, d: any) => sum + (d.countOut ?? 0),
0
),
// These summed `countIn`/`countOut`, which do not exist on a
// FeedSackLogEntry (it carries `type` and `count`) — the `any`
// cast hid it, so all three were flatly 0 whenever the period
// scoping below did not overwrite them.
total_karung_masuk_iot: sumFeedRows(classifiedFeedRows, 'inbound'),
total_karung_dituang_iot: sumFeedRows(classifiedFeedRows, 'usage'),
total_karung_keluar_iot: sumFeedRows(classifiedFeedRows, 'outbound'),
rata_karung_per_hari:
displayCurrentDay > 0
? Math.round(
(filteredData.reduce((sum: number, d: any) => sum + (d.countOut ?? 0), 0) /
displayCurrentDay) *
10
(sumFeedRows(classifiedFeedRows, 'usage') / displayCurrentDay) * 10
) / 10
: null,
total_entry_manual: manualFeedSackEntries.length,
total_karung_masuk_manual: sumManualRows(classifiedManualRows, 'inbound'),
total_karung_dituang_manual: sumManualRows(classifiedManualRows, 'usage'),
jumlahEntryManual_baris: classifiedManualRows.length,
catatan:
'Karung masuk = stok diterima, karung dituang = pakan dikonsumsi oleh ayam.',
}}
+53 -11
View File
@@ -14,6 +14,12 @@ import { loadDeviceHistory24h, loadIotSensorHistory24h } from './iotPanel/iotSen
import IotPanelDashboard from './iotPanel/IotPanelDashboard.tsx';
import { PageAiInsight } from './shared/PageAiInsight.tsx';
import {
getTempStandardByDay,
HUMIDITY_RANGE_PCT,
AIR_QUALITY,
parseReading,
} from '../utils/cp707EnvStandards.ts';
import { apiResponseCounts, latestResultRow, loadDashboardData } from './iotPanel/iotApi.ts';
@@ -193,21 +199,57 @@ const IotPanelPage: React.FC = () => {
<PageAiInsight
topic="iot_panel"
cacheKey={`iot-${computed.datetime}-${computed.avgTemp}-${computed.humidity}`}
// No per-day history exists for the panel — it is a live snapshot — so
// the chart compares each reading with its CP 707 standard instead of
// drawing a trend.
chartSpec={{
type: 'condition',
title: 'Kondisi kandang vs standar CP 707',
bars: [
{
label: 'Suhu rata-rata',
actual: parseReading(computed.avgTemp),
standard: getTempStandardByDay(computed.dayAgeValue),
unit: '°C',
note:
getTempStandardByDay(computed.dayAgeValue) === null
? 'Umur tidak diketahui, standar suhu tidak dapat ditentukan.'
: undefined,
},
{
label: 'Kelembapan',
actual: parseReading(computed.humidity),
standard: HUMIDITY_RANGE_PCT.max,
unit: '%',
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.`,
},
],
}}
contextData={{
waktu_data: computed.datetime,
hari_ke: computed.dayAgeValue,
kandang: computed.coopName,
suhu_rata_rata_C: computed.avgTemp,
setpoint_C: computed.setPoint,
suhu_luar_C: computed.outsideTemp,
kelembapan_persen: computed.humidity,
amonia_ppm: computed.ammonia,
co2_ppm: computed.co2,
kecepatan_angin: computed.wind,
lux: computed.lux,
suhu1_C: computed.temp1,
suhu2_C: computed.temp2,
suhu3_C: computed.temp3,
// Numbers, not the formatted display strings ("24,2 °C"): the
// unit already lives in the field name, so passing the string
// 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),
kelembapan_persen: parseReading(computed.humidity),
co2_ppm: parseReading(computed.co2),
kecepatan_angin_mPerDetik: parseReading(computed.wind),
lux: parseReading(computed.lux),
suhu1_C: parseReading(computed.temp1),
suhu2_C: parseReading(computed.temp2),
suhu3_C: parseReading(computed.temp3),
alarm_suhu_tinggi: computed.avgTempAlarmHighlight,
alarm_kelembapan_tinggi: computed.humidityAlarmHighlight,
jumlah_fan_aktif: computed.experienceActiveFanCount,
+1 -1
View File
@@ -415,7 +415,7 @@ const KandangSettingsPage: React.FC = () => {
</div>
</div>
<div>
{kandang.docInCount > 0 ? (
{(kandang.docInCount ?? 0) > 0 ? (
<span className="inline-flex items-center px-3 py-1 rounded-full bg-green-100 text-green-800 text-sm font-medium">
<i className="fa-solid fa-check-circle mr-1"></i>
Siap Produksi
+4 -8
View File
@@ -15,10 +15,8 @@ const ManualWeighingPage: React.FC = () => {
const recordsByDate = useMemo(() => {
const groups: { [key: string]: (typeof manualWeighingData)[0] } = {};
manualWeighingData.forEach((record) => {
if (!groups[record.date]) {
groups[record.date] = { ...record, sessions: [] };
}
groups[record.date].sessions.push(...record.sessions);
const group = groups[record.date] ?? (groups[record.date] = { ...record, sessions: [] });
group.sessions.push(...record.sessions);
});
return Object.values(groups).sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
@@ -26,10 +24,8 @@ const ManualWeighingPage: React.FC = () => {
}, [manualWeighingData]);
const [openAccordions, setOpenAccordions] = useState<Record<string, boolean>>(() => {
if (recordsByDate.length > 0) {
return { [recordsByDate[0].date]: true }; // Open first one by default
}
return {};
const first = recordsByDate[0];
return first ? { [first.date]: true } : {}; // Open first one by default
});
const [isAnalysisModalOpen, setIsAnalysisModalOpen] = useState(false);
+4 -5
View File
@@ -16,11 +16,10 @@ const PerformanceDebug: React.FC = () => {
}, []);
useEffect(() => {
if (isOpen) {
refreshMetrics(); // Refresh immediately on open
const interval = setInterval(refreshMetrics, 2000);
return () => clearInterval(interval);
}
if (!isOpen) return undefined;
refreshMetrics(); // Refresh immediately on open
const interval = setInterval(refreshMetrics, 2000);
return () => clearInterval(interval);
}, [isOpen, refreshMetrics]);
const formatMs = (value: number | null) => (value === null ? 'N/A' : `${Math.round(value)} ms`);
+60 -4
View File
@@ -62,7 +62,7 @@ const WeightMonitoringPageRevamped: React.FC = () => {
// Format date for input (YYYY-MM-DD)
const formatDateForInput = (date: Date): string => {
return date.toISOString().split('T')[0];
return date.toISOString().split('T')[0] ?? '';
};
return (
@@ -109,17 +109,73 @@ const WeightMonitoringPageRevamped: React.FC = () => {
<PageAiInsight
topic="berat_ayam"
cacheKey={`wm-${cycleData.id}-${cycleData.currentDay}`}
chartSpec={{
type: 'trend',
historyKey: 'tren_harian',
title: 'Bobot aktual vs target CP 707',
unit: 'gram',
// The gap is the finding, so it is drawn rather than left for the
// reader to estimate between two parallel lines.
gap: { actualField: 'bobot_gram', targetField: 'target_gram' },
series: [
// Emphasis, not two competing series: the measurement carries
// the accent hue and the standard recedes to a dashed grey.
// Red was a status colour doing duty as a series colour, which
// makes every reading look like an alarm.
{ field: 'bobot_gram', label: 'Bobot aktual', color: '#7c3aed' },
{ field: 'target_gram', label: 'Target CP 707', color: '#9ca3af', dashed: true },
],
}}
scalarScope={{
historyKey: 'tren_harian',
derive: {
bobotRataRata_gram: {
field: 'bobot_gram',
agg: 'mean',
replaces: 'averageWeight',
},
targetBobot_gram: {
field: 'target_gram',
agg: 'mean',
replaces: 'targetWeight',
},
pertambahanHarian_gramPerHari: {
field: 'adg_gram_per_hari',
agg: 'mean',
replaces: 'adg',
},
},
// Unitless originals the derived keys replace.
drop: ['averageWeight', 'targetWeight', 'adg'],
// Uniformity has no per-day column, so it cannot be re-scoped.
unavailable: ['uniformity', 'uniformityStatus', 'weightStatus', 'adgStatus'],
}}
contextData={{
currentDay: cycleData.currentDay,
averageWeight: weightStats?.averageWeight?.value ?? 0,
targetWeight: weightStats?.targetWeight?.value ?? 0,
// `WeightStatistics` has no `targetWeight`, so reading it always
// produced 0. The daily series is where the CP 707 target lives —
// the same source `targetBobot_gram` is derived from.
targetWeight:
[...(dailyWeightData ?? [])]
.reverse()
.find((point: any) => typeof point?.target === 'number')?.target ?? 0,
weightStatus: weightStats?.averageWeight?.status ?? 'unknown',
adg: weightStats?.avgDailyGain?.value ?? 0,
adgStatus: weightStats?.avgDailyGain?.status ?? 'unknown',
uniformity: weightStats?.uniformity?.value ?? 0,
uniformityStatus: weightStats?.uniformity?.status ?? 'unknown',
totalSensors: deviceWeightData?.length ?? 0,
onlineSensors: deviceWeightData?.filter((d: any) => d.isOnline)?.length ?? 0,
jumlahSensor_unit: deviceWeightData?.length ?? 0,
sensorOnline_unit: deviceWeightData?.filter((d: any) => d.isOnline)?.length ?? 0,
// Day-indexed history is what makes the Harian/Mingguan toggle do
// anything: without an array carrying `hari`, the scoping helper
// has nothing to trim and both modes send identical data.
tren_harian: (dailyWeightData ?? []).map((point: any, index: number) => ({
hari: point.day,
bobot_gram: point.actual,
target_gram: point.target,
adg_gram_per_hari: dailyWeightGainData?.[index]?.gain ?? null,
})),
}}
/>
+2 -4
View File
@@ -11,10 +11,8 @@ const ServiceMonitor: React.FC<ServiceMonitorProps> = ({ services, jobs }) => {
const jobsByService = useMemo(() => {
return jobs.reduce(
(acc, job) => {
if (!acc[job.serviceId]) {
acc[job.serviceId] = [];
}
acc[job.serviceId].push(job);
const bucket = acc[job.serviceId] ?? (acc[job.serviceId] = []);
bucket.push(job);
return acc;
},
{} as Record<string, BatchJob[]>
+1 -1
View File
@@ -195,7 +195,7 @@ const ActivityLogPanel: React.FC<ActivityLogPanelProps> = ({ tableName, refreshT
// For UPDATE, show the most important changed field
if (log.changed_fields && log.changed_fields.length > 0) {
const primaryField = log.changed_fields[0];
const primaryField = log.changed_fields[0] ?? '';
const oldVal = log.old_values?.[primaryField];
const newVal = log.new_values?.[primaryField];
+1 -1
View File
@@ -1,4 +1,4 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
@@ -61,9 +61,8 @@ const ChickenCountTrendChart: React.FC<ChickenCountTrendChartProps> = ({ data })
border: '1px solid #ccc',
borderRadius: '8px',
}}
formatter={(value: number | null) => {
if (value === null || value === undefined)
return ['Tidak ada data', 'Chicken Count'];
formatter={(value) => {
if (typeof value !== 'number') return ['Tidak ada data', 'Chicken Count'];
return [formatNumber(value), 'Chicken Count'];
}}
/>
+512 -252
View File
@@ -1,6 +1,19 @@
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { Sparkles, RefreshCw, TrendingDown, AlertTriangle, CheckCircle, Info } from 'lucide-react';
import { Sparkles, RefreshCw, AlertTriangle, CheckCircle, Download } from 'lucide-react';
import { apiClient } from '../../services/apiService.ts';
import { parseAiResult, type AiResult } from '../../utils/insightParse.ts';
import { useAppContext } from '../../context/AppContext.tsx';
import InsightTrendChart from '../shared/InsightTrendChart.tsx';
import { exportCardToPdf } from '../../utils/pdfExport.ts';
import { HeroFigure, type InsightStatus } from '../shared/InsightStatTiles.tsx';
import PdfReportHeader, {
buildPdfFilename,
describeDataDates,
describeKandangScope,
describeTimeframePeriod,
isoDataDates,
parseDayRangeLabel,
} from '../shared/PdfReportHeader.tsx';
interface KandangInsight {
name: string;
@@ -8,8 +21,82 @@ interface KandangInsight {
currentPopulation: number;
mortalityTotal: number;
mortalityRate: number;
/** Harvested alive. Absent from this row is why the per-coop line read as if
* the whole population drop were deaths. */
panen?: number;
}
/** One day's mortality/harvest row from `/api/mortality/:cycleId`. */
interface MortalityDayRow {
day?: number | null;
mortalityCount?: number | null;
afkir?: number | null;
panen?: number | null;
beratPanen?: number | null;
}
/**
* The rows belonging to the selected period.
*
* Deaths and culls are flows: "Jumlah Kematian" under a Mingguan heading must
* 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 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);
if (days.length === 0) return { window: rows, label: 'seluruh data' };
const lastDay = Math.max(...days);
if (timeframe === 'harian') {
return {
window: rows.filter((row) => dayOf(row) === lastDay),
label: `hari ke-${lastDay}`,
};
}
const firstDay = Math.max(Math.min(...days), lastDay - 6);
return {
window: rows.filter((row) => {
const day = dayOf(row);
return day !== null && day >= firstDay && day <= lastDay;
}),
label: `hari ke-${firstDay} s/d ke-${lastDay}`,
};
};
/**
* Real departures, split by cause.
*
* `populasi awal - populasi kini` was being reported as "kematian+afkir". That
* counts every harvested bird as dead: this coop shipped 22.523 birds and the
* insight announced "mortalitas 100%, seluruh populasi mati". Deaths, culls and
* harvest each have their own column — read them instead of inferring.
*/
const summariseDepartures = (rows: MortalityDayRow[], docIn: number) => {
const sum = (f: keyof MortalityDayRow) =>
rows.reduce((t, r) => {
const v = r[f];
return t + (typeof v === 'number' && Number.isFinite(v) ? v : 0);
}, 0);
const mati = sum('mortalityCount');
const afkir = sum('afkir');
const panen = sum('panen');
const beratPanen = sum('beratPanen');
const mortalityTotal = mati + afkir;
return {
mati,
afkir,
panen,
beratPanen,
mortalityTotal,
// A rate needs a denominator; without one it is unknown, not zero.
mortalityRate: docIn > 0 ? (mortalityTotal / docIn) * 100 : null,
saldo: Math.max(0, docIn - mortalityTotal - panen),
adaData: rows.length > 0,
};
};
interface PopulationPoint {
date: string;
population: number;
@@ -17,55 +104,20 @@ interface PopulationPoint {
export interface ChickenCountingInsightProps {
cycleId: string;
cycleStartDate: string;
currentDay: number;
totalDays: number;
totalInitialPopulation: number;
populationTrend: PopulationPoint[];
kandangInsights: KandangInsight[];
/**
* Which coop the figures are scoped to; null means all coops combined.
* The mortality ledger must be read for the same scope as
* `totalInitialPopulation`, or the rate is computed against the wrong
* denominator.
*/
kandangId?: number | null;
}
interface AiResult {
headline: string;
status: 'ok' | 'warning' | 'critical';
summary: string[];
risks: string[];
actions: string[];
perKandang: Array<{
name: string;
status: 'ok' | 'warning' | 'critical';
insight: string;
action: string;
}>;
}
const STATUS_CONFIG = {
ok: {
bg: 'bg-emerald-50',
border: 'border-emerald-200',
text: 'text-emerald-700',
badge: 'bg-emerald-100 text-emerald-700',
icon: CheckCircle,
label: 'Normal',
},
warning: {
bg: 'bg-amber-50',
border: 'border-amber-200',
text: 'text-amber-700',
badge: 'bg-amber-100 text-amber-700',
icon: AlertTriangle,
label: 'Perhatian',
},
critical: {
bg: 'bg-red-50',
border: 'border-red-200',
text: 'text-red-700',
badge: 'bg-red-100 text-red-700',
icon: TrendingDown,
label: 'Kritis',
},
};
const CC_FOCUSES = [
'Tren mortalitas harian dan dampaknya terhadap populasi akhir',
'Distribusi ayam di berbagai area/kamera (penumpukan)',
@@ -76,28 +128,71 @@ const CC_FOCUSES = [
'Evaluasi tingkat kepadatan (density) vs kapasitas kandang',
];
const buildPrompt = (props: ChickenCountingInsightProps): string => {
/**
* @param periodDeparture Flows (deaths, culls, harvest) INSIDE the selected
* window. These are what "Harian" and "Mingguan" are supposed to change.
* @param departures The same flows for the whole cycle. Kept separate because
* the cumulative mortality rate and the saldo are stocks — they must count
* every departure since day 1, and grading a week's own deaths against the
* CP 707 *cumulative* standard would understate the flock's real condition.
*
* Both blocks go into the prompt under explicit headings. Previously only the
* cycle-wide figures were sent, under a line claiming the data was already
* limited to the selected range — so "Harian" reported the entire cycle's
* deaths and harvest as if they had happened today.
*/
const buildPrompt = (
props: ChickenCountingInsightProps,
timeframe: 'harian' | 'mingguan',
departures: ReturnType<typeof summariseDepartures>,
periodDeparture: ReturnType<typeof summariseDepartures>,
periodRangeLabel: string
): string => {
const { currentDay, totalDays, totalInitialPopulation, populationTrend, kandangInsights } = props;
const selectedFocus = CC_FOCUSES[Math.floor(Math.random() * CC_FOCUSES.length)];
const latestPop =
populationTrend.length > 0
? populationTrend[populationTrend.length - 1].population
: totalInitialPopulation;
const totalMortality = Math.max(0, totalInitialPopulation - latestPop);
const overallMortalityRate =
totalInitialPopulation > 0 ? ((totalMortality / totalInitialPopulation) * 100).toFixed(2) : '0';
populationTrend[populationTrend.length - 1]?.population ?? totalInitialPopulation;
const recentPops = populationTrend.slice(-7).map((p) => p.population);
// Harian looks at today only; Mingguan at the 7-day window ending today.
// Previously both modes were handed the same 7-day trend, so the toggle
// barely changed the answer.
//
// The window is matched on each point's own DATE, not its array position: a
// missing day would otherwise slide the window silently, so "7 hari terakhir"
// could quietly cover days 35-48. A day with no record must drop out of the
// window, not shift it.
const dated = populationTrend
.map((p) => ({ ...p, ts: new Date(p.date).setHours(0, 0, 0, 0) }))
.filter((p) => Number.isFinite(p.ts))
.sort((a, b) => a.ts - b.ts);
const latestTs = dated[dated.length - 1]?.ts ?? null;
const spanDays = timeframe === 'harian' ? 1 : 7;
const scopedPops =
latestTs === null
? []
: dated.filter((p) => p.ts > latestTs - spanDays * 86400000 && p.ts <= latestTs);
const recentPops = scopedPops.map((p) => p.population);
const trendLabel =
timeframe === 'harian' ? 'Populasi hari ini' : 'Trend populasi (7 hari terakhir)';
const trendStr = recentPops.length > 0 ? recentPops.join(' → ') : 'belum ada data trend';
const periodLabel =
timeframe === 'harian'
? `HARI INI SAJA (hari ke-${currentDay})`
: `7 HARI TERAKHIR (hari ke-${Math.max(1, currentDay - 6)} sampai hari ke-${currentDay})`;
const kandangStr =
kandangInsights.length > 0
? kandangInsights
.map(
(k) =>
`- ${k.name}: DOC=${k.docIn.toLocaleString()}, populasi_kini=${k.currentPopulation.toLocaleString()}, mati+afkir=${k.mortalityTotal.toLocaleString()}, mortalitas=${k.mortalityRate.toFixed(2)}%`
`- ${k.name}: DOC=${k.docIn.toLocaleString()}, populasi_kini=${k.currentPopulation.toLocaleString()}, mati+afkir=${k.mortalityTotal.toLocaleString()}, mortalitas=${k.mortalityRate.toFixed(2)}%, dipanen=${
typeof k.panen === 'number'
? k.panen.toLocaleString() + ' ekor'
: 'data tidak tersedia'
}`
)
.join('\n')
: '- Data per kandang belum tersedia (backend offline). Gunakan data total untuk analisis.';
@@ -107,80 +202,192 @@ const buildPrompt = (props: ChickenCountingInsightProps): string => {
Analisis data hitung ayam berikut dan buat AI Insight yang MUDAH DIPAHAMI, PROFESIONAL, dan ACTIONABLE bagi peternak.
Jika ada data yang belum tersedia, tetap berikan insight dan rekomendasi berdasarkan data yang ada.
DATA SIKLUS:
IDENTITAS SIKLUS:
- Hari ke: ${currentDay} dari total ${totalDays} hari
- Populasi awal (DOC in): ${totalInitialPopulation.toLocaleString()} ekor
- Populasi estimasi saat ini: ${latestPop.toLocaleString()} ekor
- Total kematian+afkir: ${totalMortality.toLocaleString()} ekor
- Tingkat mortalitas keseluruhan: ${overallMortalityRate}%
- Trend populasi (7 hari terakhir): ${trendStr}
DATA PER KANDANG:
A. DATA PERIODE YANG DIANALISIS (${periodRangeLabel}) — angka DI DALAM rentang ini saja:
- Kematian (mati) pada periode ini: ${periodDeparture.adaData ? periodDeparture.mati.toLocaleString() + ' ekor' : 'data tidak tersedia'}
- Afkir pada periode ini: ${periodDeparture.adaData ? periodDeparture.afkir.toLocaleString() + ' ekor' : 'data tidak tersedia'}
- Kematian+afkir pada periode ini: ${periodDeparture.adaData ? periodDeparture.mortalityTotal.toLocaleString() + ' ekor' : 'data tidak tersedia'}
- Dipanen pada periode ini: ${periodDeparture.adaData ? periodDeparture.panen.toLocaleString() + ' ekor' : 'data tidak tersedia'}
- Bobot panen pada periode ini: ${periodDeparture.adaData && periodDeparture.beratPanen > 0 ? periodDeparture.beratPanen.toLocaleString() + ' kg' : 'data tidak tersedia'}
B. DATA KUMULATIF SEJAK HARI KE-1 — konteks, BUKAN angka periode ini:
- Total kematian+afkir kumulatif: ${departures.adaData ? departures.mortalityTotal.toLocaleString() + ' ekor' : 'data tidak tersedia'}
- Tingkat mortalitas kumulatif: ${departures.mortalityRate === null ? 'data tidak tersedia' : departures.mortalityRate.toFixed(2) + '%'}
- Total sudah dipanen: ${departures.adaData ? departures.panen.toLocaleString() + ' ekor' : 'data tidak tersedia'}
- Total bobot panen: ${departures.adaData && departures.beratPanen > 0 ? departures.beratPanen.toLocaleString() + ' kg' : 'data tidak tersedia'}
- Saldo ayam di kandang: ${departures.adaData ? departures.saldo.toLocaleString() + ' ekor' : 'data tidak tersedia'}
- ${trendLabel}: ${trendStr}
PENTING — jangan tertukar antara blok A dan blok B:
Kalau Anda menyebut kematian, afkir, atau panen "pada ${periodRangeLabel}", ambil dari
blok A. Angka blok B adalah total sejak hari ke-1 — hanya boleh disebut dengan kata
"kumulatif" atau "sejak awal siklus". DILARANG menyebut angka blok B seolah terjadi
pada periode ini.
DILARANG memakai kata "total", "keseluruhan", atau "sepanjang siklus" untuk angka blok A —
itu angka SATU periode. Setiap angka blok A wajib Anda tulis bersama keterangan
periodenya, contoh: "dipanen 822 ekor pada ${periodRangeLabel}". Sebaliknya, setiap angka
blok B wajib ditulis dengan kata "kumulatif".
PENTING — jangan salah membaca penurunan populasi:
Populasi turun karena DUA sebab yang berbeda: kematian/afkir DAN panen. Ayam yang
dipanen TIDAK mati. Populasi yang tinggal ${latestPop.toLocaleString()} ekor bukan berarti
sisanya mati — pakai angka "Total kematian+afkir kumulatif" dan "Tingkat mortalitas
kumulatif" apa adanya, dan JANGAN menghitung mortalitas dari selisih populasi.
Satuan: ekor untuk jumlah, kg untuk bobot, persen untuk tingkat mortalitas. Jangan menukarnya.
ATURAN ANGKA (WAJIB DIPATUHI):
Setiap angka yang Anda tulis HARUS disalin apa adanya dari blok A, blok B, atau DATA PER
KANDANG di atas. DILARANG menghitung sendiri: jangan membagi bobot panen dengan jumlah
ekor, jangan menjumlahkan atau mengurangkan antar baris, jangan mengubah total menjadi
rata-rata per hari, dan jangan menurunkan persentase yang tidak tertulis di atas.
Kalau sebuah angka tidak ada di data, tulis "data tidak tersedia".
DATA PER KANDANG (angka KUMULATIF sejak hari ke-1, bukan periode ini):
${kandangStr}
PEDOMAN MORTALITAS:
- Mortalitas <3%: NORMAL (ok)
- Mortalitas 3-5%: PERLU PERHATIAN (warning)
- Mortalitas >5%: KRITIS, perlu tindakan segera (critical)
Nilai statusnya memakai "Tingkat mortalitas kumulatif" dari blok B (bukan angka periode
di blok A), karena standar CP 707 di bawah ini juga kumulatif.
Gunakan angka "Mortalitas kumulatif standar" pada blok STANDAR CP 707 di atas sebagai
pembanding — standar itu berbeda tiap umur, jadi DILARANG memakai ambang tetap.
- Di bawah standar hari ini: NORMAL (ok)
- Sampai 1,5x standar hari ini: PERLU PERHATIAN (warning)
- Di atas 1,5x standar hari ini: KRITIS, perlu tindakan segera (critical)
Konteks & Aturan Variasi:
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
RENTANG WAKTU ANALISIS: ${periodLabel}.
Blok A sudah dibatasi pada rentang tersebut; blok B dan DATA PER KANDANG sengaja kumulatif sebagai konteks. Jadikan blok A sebagai pokok analisis, jangan menyimpulkan tren di luar rentang tersebut, dan jangan mengarang angka dari hari lain.
Jadikan ini sebagai sudut pandang utama analisis. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. Dilarang keras berhalusinasi atau menambahkan angka fiktif.
INSTRUKSI OUTPUT:
Kembalikan HANYA satu objek JSON valid tanpa markdown, tanpa komentar, tanpa teks tambahan di luar JSON.
Format JSON yang harus dikembalikan:
{
"headline": "satu kalimat ringkas tentang kondisi populasi saat ini yang mencerminkan fokus terpilih",
"status": "ok",
"summary": ["poin ringkasan 1", "poin ringkasan 2", "poin ringkasan 3"],
"risks": ["risiko 1", "risiko 2"],
"actions": ["tindakan konkret 1 yang bisa langsung dipraktikkan peternak hari ini", "tindakan 2", "tindakan 3"],
"perKandang": [
{
"name": "nama kandang",
"status": "ok",
"insight": "satu kalimat insight tentang kandang ini",
"action": "satu tindakan konkret spesifik untuk kandang ini"
}
]
"kesimpulan": "Ringkasan kesimpulan dari performa peternakan pada siklus ini berdasarkan data.",
"insight": "Insight mendalam terkait tren populasi, mortalitas, dan rekomendasi tindakan."
}`;
};
const parseResult = (text: string): AiResult | null => {
try {
const first = text.indexOf('{');
const last = text.lastIndexOf('}');
if (first === -1 || last === -1) return null;
const parsed = JSON.parse(text.slice(first, last + 1));
if (!parsed.headline) return null;
return {
headline: String(parsed.headline ?? ''),
status: ['ok', 'warning', 'critical'].includes(parsed.status) ? parsed.status : 'ok',
summary: Array.isArray(parsed.summary) ? parsed.summary.map(String) : [],
risks: Array.isArray(parsed.risks) ? parsed.risks.map(String) : [],
actions: Array.isArray(parsed.actions) ? parsed.actions.map(String) : [],
perKandang: Array.isArray(parsed.perKandang)
? parsed.perKandang.map((k: any) => ({
name: String(k.name ?? ''),
status: ['ok', 'warning', 'critical'].includes(k.status) ? k.status : 'ok',
insight: String(k.insight ?? ''),
action: String(k.action ?? ''),
}))
: [],
};
} catch {
return null;
}
};
export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (props) => {
const appContext = useAppContext();
const { cycleData } = appContext;
// Read the mortality ledger directly. Callers pass different prop subsets
// (one omits cycleId and kandangInsights entirely), so deriving the figures
// here keeps them correct regardless of which page mounts this card.
const [mortalityRows, setMortalityRows] = useState<MortalityDayRow[]>([]);
const activeCycleId = props.cycleId || cycleData?.id;
useEffect(() => {
let cancelled = false;
if (!activeCycleId) {
setMortalityRows([]);
return;
}
apiClient
.getMortalityRecords(activeCycleId, props.kandangId ?? undefined)
.then((rows: unknown) => {
if (!cancelled) setMortalityRows(Array.isArray(rows) ? (rows as MortalityDayRow[]) : []);
})
.catch((err: unknown) => {
console.error('❌ Gagal memuat catatan mortalitas untuk insight hitung ayam:', err);
if (!cancelled) setMortalityRows([]);
});
return () => {
cancelled = true;
};
}, [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 [showPerKandang, setShowPerKandang] = useState(false);
const [timeframe, setTimeframe] = useState<'harian' | 'mingguan'>('harian');
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 scope the user is currently asking for, so a request dropped because
// another was in flight can be re-run once that one finishes.
const desiredKeyRef = useRef<string>('');
const generateRef = useRef<(force?: boolean) => Promise<void>>(async () => {});
// 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.
const period = React.useMemo(
() => sliceRowsByTimeframe(mortalityRows, timeframe),
[mortalityRows, timeframe]
);
const periodDepartures = summariseDepartures(period.window, props.totalInitialPopulation);
/**
* The one figure this card is judged on, matching the hero the topic cards
* carry. Cumulative mortality rather than the balance, because it is the
* figure with a CP 707 standard to compare against — a hero without a
* comparison is just a big number.
*/
const heroSpec = React.useMemo(() => {
const rate = departures.mortalityRate;
if (rate === null) return null;
const status: InsightStatus = rate < 5 ? 'ok' : rate <= 7 ? 'warning' : 'critical';
return {
value: rate.toFixed(2),
status,
context: `${departures.mortalityTotal.toLocaleString('id-ID')} ekor dari ${props.totalInitialPopulation.toLocaleString('id-ID')} ekor DOC`,
};
}, [departures.mortalityRate, departures.mortalityTotal, props.totalInitialPopulation]);
// InsightTrendChart plots against cycle day, but `point.date` is a localised
// display label ("22 Mei"), not a parseable date — and it parses
// INCONSISTENTLY: "Mei" fails while "07 Jul" silently becomes July 2001,
// which put half the points at day -9084. So index the points instead, and
// anchor the last one to `currentDay` so the shaded window lines up with the
// day the insight text actually discusses.
const chartRows = React.useMemo(() => {
const points = props.populationTrend ?? [];
return points.map((point, index) => ({
hari: props.currentDay - (points.length - 1 - index),
populasi: point.population,
}));
}, [props.populationTrend, props.currentDay]);
// 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);
return { firstDay, lastDay };
}, [chartRows, timeframe]);
const downloadPdf = async () => {
if (!result || !pdfRef.current) return;
try {
// Coop and period included: two coops on the same day used to produce
// one identical filename and overwrite each other.
const { fromDay, toDay } = parseDayRangeLabel(period.label);
const filename = buildPdfFilename({
reportName: 'Hitung-Ayam',
area: appContext.selectedArea || 'Sukawarna',
kandangLabel: describeKandangScope(appContext.kandangs, props.kandangId ?? null),
periodSlug: timeframe === 'harian' ? 'Harian' : 'Mingguan',
isoDates: isoDataDates(cycleData?.startDate, fromDay, toDay),
});
await exportCardToPdf(pdfRef.current, filename);
} catch (error) {
console.error('Failed to generate PDF:', error);
}
};
const generate = useCallback(
async (force = false) => {
@@ -189,12 +396,13 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
// Minimal data check
if (!props.cycleId) return;
const cacheKey = `cc-insight-${props.cycleId}-${props.currentDay}`;
lastCacheKeyRef.current = cacheKey;
if (!force) {
try {
const cached = sessionStorage.getItem(cacheKey);
if (cached) {
const parsed = parseResult(cached);
const parsed = parseAiResult(cached);
if (parsed) {
setResult(parsed);
hasGeneratedRef.current = true;
@@ -211,18 +419,23 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
setError(null);
try {
const prompt = buildPrompt(props);
const prompt = buildPrompt(props, timeframe, departures, periodDepartures, period.label);
const system_prompt =
'Anda adalah manajer peternakan ayam broiler CP 707. Analisis HANYA berdasarkan standar buku CP 707. Kembalikan HANYA objek JSON valid. Dilarang memberikan teks di luar JSON.';
// Race antara LM Studio dan timeout 300 detik
const fetchCall = apiClient.proxyLmStudio(prompt, system_prompt, 0.2);
// Race antara LM Studio dan timeout 20 menit (sama dengan backend)
// contextData carries the cycle day so the backend can inject the CP 707
// standard for THIS day; without it the prompt has no book figure to
// compare against and the model falls back to guessing one.
const fetchCall = apiClient.proxyLmStudio(prompt, system_prompt, 0.2, 'hitung_ayam', '', {
hari_ke: props.currentDay,
});
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('LM Studio timeout setelah 300 detik.')), 300000)
setTimeout(() => reject(new Error('LM Studio timeout setelah 20 menit.')), 1200000)
);
const json = await Promise.race([fetchCall, timeout]);
const text = json.response ?? '';
const parsed = parseResult(text);
const parsed = parseAiResult(text);
if (!parsed) throw new Error('Format respons LM Studio tidak valid. Coba lagi.');
if (result && JSON.stringify(parsed) === JSON.stringify(result)) {
@@ -244,6 +457,11 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
} finally {
setLoading(false);
inFlightRef.current = false;
// A timeframe switch while this was in flight was dropped, not queued —
// run the scope the user actually wants now.
if (desiredKeyRef.current !== lastCacheKeyRef.current) {
setTimeout(() => generateRef.current(false), 0);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -253,40 +471,74 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
props.totalInitialPopulation,
props.kandangInsights.length,
props.populationTrend.length,
timeframe,
cacheKey,
result,
]
);
// Auto-generate on mount and when cycle changes
// Refreshed after commit, not during render: writing a ref while rendering is
// unsafe under concurrent rendering, and both are only read from async
// callbacks that run well after this effect.
useEffect(() => {
desiredKeyRef.current = cacheKey;
generateRef.current = generate;
});
// Auto-generate on mount, cycle change, or timeframe change.
//
// Deliberately NOT dependent on `generate`: it is rebuilt whenever `result` or
// any watched prop changes, and the cleanup would then keep clearing the 600ms
// timer before it could fire.
useEffect(() => {
if (!props.cycleId) return;
if (hasGeneratedRef.current) return;
if (lastCacheKeyRef.current === cacheKey) return;
const timer = setTimeout(() => {
generate(false);
generateRef.current(false);
}, 600);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.cycleId]);
}, [props.cycleId, cacheKey]);
// Re-generate when data becomes available (population data loaded from API)
// Re-generate when data becomes available (population data loaded from API).
//
// `mortalityRows` belongs in here too: it is fetched separately and often
// lands after the 600ms auto-generate above has already fired. Every
// deaths/culls/harvest figure comes from that ledger, so an insight built
// before it arrived reported "data tidak tersedia" for all of them — the
// harvest count included — and nothing ever asked for a redo.
useEffect(() => {
if (!hasGeneratedRef.current) return;
if (props.populationTrend.length === 0) return;
if (props.populationTrend.length === 0 && mortalityRows.length === 0) return;
const timer = setTimeout(() => {
generate(true);
generateRef.current(true);
}, 1000);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.populationTrend.length, props.kandangInsights.length]);
const cfg = result ? STATUS_CONFIG[result.status] : null;
const StatusIcon = cfg?.icon ?? Info;
}, [props.populationTrend.length, props.kandangInsights.length, mortalityRows.length]);
return (
<div className="rounded-2xl border border-purple-100 bg-gradient-to-br from-purple-50 via-white to-blue-50 shadow-lg overflow-hidden">
<div
ref={pdfRef}
className="rounded-2xl border border-purple-100 bg-gradient-to-br from-purple-50 via-white to-blue-50 shadow-lg overflow-hidden"
>
<div className="px-5 pt-4">
<PdfReportHeader
title="AI Insight — Hitung Ayam"
area={appContext.selectedArea || 'Sukawarna'}
kandangLabel={describeKandangScope(appContext.kandangs, props.kandangId ?? null)}
periodLabel={describeTimeframePeriod(timeframe, period.label)}
cycleId={props.cycleId}
dataDateLabel={(() => {
const { fromDay, toDay } = parseDayRangeLabel(period.label);
return describeDataDates(cycleData?.startDate, fromDay, toDay);
})()}
/>
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-purple-100 bg-white/60 backdrop-blur-sm">
<div className="flex items-center gap-2">
@@ -300,18 +552,45 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
</p>
</div>
</div>
<button
onClick={() => generate(true)}
disabled={loading}
className={`p-2 rounded-xl transition-all border ${
loading
? 'bg-gray-100 border-gray-200 cursor-not-allowed'
: 'bg-white border-purple-200 hover:bg-purple-50 hover:border-purple-300 active:scale-95 shadow-sm'
}`}
title="Refresh insight"
>
<RefreshCw className={`w-4 h-4 text-purple-600 ${loading ? 'animate-spin' : ''}`} />
</button>
<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'}`}
>
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>
{result && (
<button
onClick={downloadPdf}
disabled={loading}
title="Unduh PDF"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-800 bg-slate-900 text-white hover:bg-slate-800 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-xs font-semibold shadow-sm"
>
<Download className="w-3.5 h-3.5" />
<span>Unduh PDF</span>
</button>
)}
<button
onClick={() => generate(true)}
disabled={loading}
className={`p-1.5 rounded-lg transition-all border ${
loading
? 'bg-gray-100 border-gray-200 cursor-not-allowed'
: 'bg-white border-purple-200 hover:bg-purple-50 hover:border-purple-300 active:scale-95 shadow-sm'
}`}
title="Refresh insight"
>
<RefreshCw className={`w-3.5 h-3.5 text-purple-600 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Body */}
@@ -355,126 +634,107 @@ export const ChickenCountingAiInsight: React.FC<ChickenCountingInsightProps> = (
)}
{/* Result */}
{result && cfg && (
{result && (
<div className="space-y-4">
{/* Headline banner */}
<div className={`rounded-xl p-4 border ${cfg.bg} ${cfg.border} flex items-start gap-3`}>
<StatusIcon className={`w-5 h-5 shrink-0 mt-0.5 ${cfg.text}`} />
<div className="flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`text-xs font-bold uppercase tracking-wider px-2 py-0.5 rounded-full ${cfg.badge}`}
>
{cfg.label}
</span>
{loading && (
<span className="text-xs text-gray-400 animate-pulse">Memperbarui...</span>
)}
</div>
<p className={`mt-1.5 text-sm font-semibold ${cfg.text}`}>{result.headline}</p>
</div>
</div>
{/* 3-column grid */}
<div className="grid md:grid-cols-3 gap-3">
{/* Ringkasan */}
<div className="bg-white rounded-xl border border-gray-100 shadow-sm p-4">
<p className="text-xs font-bold uppercase tracking-widest text-purple-600 mb-3">
📋 Ringkasan
</p>
<ul className="space-y-2">
{result.summary.map((item, i) => (
<li key={i} className="flex gap-2 text-sm text-gray-700">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-purple-400 shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
</div>
{/* Risiko */}
<div className="bg-amber-50 rounded-xl border border-amber-100 shadow-sm p-4">
<p className="text-xs font-bold uppercase tracking-widest text-amber-700 mb-3">
⚠️ Risiko
</p>
{result.risks.length > 0 ? (
<ul className="space-y-2">
{result.risks.map((item, i) => (
<li key={i} className="flex gap-2 text-sm text-amber-800">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-amber-500 shrink-0" />
<span>{item}</span>
</li>
))}
</ul>
) : (
<p className="text-sm text-amber-600 italic">Tidak ada risiko signifikan</p>
)}
</div>
{/* Tindakan */}
<div className="bg-emerald-50 rounded-xl border border-emerald-100 shadow-sm p-4">
<p className="text-xs font-bold uppercase tracking-widest text-emerald-700 mb-3">
✅ Tindakan
</p>
<ol className="space-y-2">
{result.actions.map((item, i) => (
<li key={i} className="flex gap-2 text-sm text-emerald-800">
<span className="shrink-0 w-5 h-5 rounded-full bg-emerald-200 text-emerald-700 flex items-center justify-center text-xs font-bold">
{i + 1}
</span>
<span>{item}</span>
</li>
))}
</ol>
</div>
</div>
{/* Per-Kandang */}
{result.perKandang.length > 0 && (
<div>
<button
onClick={() => setShowPerKandang((v) => !v)}
className="inline-flex items-center gap-2 text-sm font-medium text-purple-700 border border-purple-200 bg-white rounded-full px-4 py-1.5 hover:bg-purple-50 transition shadow-sm"
>
{showPerKandang
? '▲ Sembunyikan detail per kandang'
: '▼ Lihat insight per kandang'}
</button>
{showPerKandang && (
<div className="mt-3 grid sm:grid-cols-2 lg:grid-cols-3 gap-3">
{result.perKandang.map((k, i) => {
const kCfg = STATUS_CONFIG[k.status] ?? STATUS_CONFIG.ok;
const KIcon = kCfg.icon;
return (
<div key={i} className={`rounded-xl border p-4 ${kCfg.bg} ${kCfg.border}`}>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-bold text-gray-800">{k.name}</p>
<span
className={`text-xs font-bold px-2 py-0.5 rounded-full ${kCfg.badge}`}
>
{kCfg.label}
</span>
</div>
<div className="flex gap-2 mb-2">
<KIcon className={`w-4 h-4 shrink-0 mt-0.5 ${kCfg.text}`} />
<p className="text-sm text-gray-700">{k.insight}</p>
</div>
{k.action && (
<div className="mt-2 pt-2 border-t border-white/60">
<p className="text-xs text-gray-500 font-semibold uppercase mb-1">
Tindakan:
</p>
<p className="text-sm text-gray-700">{k.action}</p>
</div>
)}
</div>
);
})}
</div>
)}
</div>
{heroSpec && (
<HeroFigure
label="Mortalitas Kumulatif"
value={heroSpec.value}
unit="%"
context={heroSpec.context}
status={heroSpec.status}
deltaLabel="standar CP 707 <5%"
/>
)}
<div
className="bg-white rounded-xl border border-gray-100 shadow-sm p-5"
data-pdf-block
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-y-3 gap-x-6">
{/* Data Points */}
<div className="flex justify-between items-center py-1 border-b border-gray-50">
<span className="text-sm text-gray-600 font-medium">
Jumlah Populasi (Jumlah DOC)
</span>
<span className="text-sm font-bold text-gray-900">
{props.totalInitialPopulation.toLocaleString()}
</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">
Jumlah Kematian (Kematian + Afkir){' '}
<span className="text-xs font-normal text-gray-400">— {period.label}</span>
</span>
<span className="text-sm font-bold text-gray-900">
{periodDepartures.adaData
? periodDepartures.mortalityTotal.toLocaleString('id-ID')
: '-'}
</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">
Jumlah terpanen{' '}
<span className="text-xs font-normal text-gray-400">— {period.label}</span>
</span>
<span className="text-sm font-bold text-gray-900">
{periodDepartures.adaData
? periodDepartures.panen.toLocaleString('id-ID')
: '-'}
</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">
Jumlah tonase panen{' '}
<span className="text-xs font-normal text-gray-400">— {period.label}</span>
</span>
<span className="text-sm font-bold text-gray-900">
{periodDepartures.adaData && periodDepartures.beratPanen > 0
? `${periodDepartures.beratPanen.toLocaleString('id-ID')} kg`
: '-'}
</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">
Saldo Akhir Ayam{' '}
<span className="text-xs font-normal text-gray-400">— seluruh siklus</span>
</span>
<span className="text-sm font-bold text-gray-900">
{departures.adaData ? departures.saldo.toLocaleString('id-ID') : '-'}
</span>
</div>
</div>
{/* GRAFIK — konteks visual untuk teks di bawahnya.
`data-pdf-block` keeps a page break from landing inside it. */}
<div className="mt-6 pt-4 border-t border-purple-100" data-pdf-block>
<InsightTrendChart
rows={chartRows}
series={[{ field: 'populasi', label: 'Populasi', color: '#7c3aed' }]}
unit="ekor"
title="Tren Populasi Ayam"
highlight={chartHighlight}
emptyLabel="Belum ada data tren populasi untuk digambarkan."
/>
</div>
<div className="mt-6 pt-4 border-t border-purple-100" data-pdf-block>
<h4 className="text-sm font-bold text-purple-700 mb-2 flex items-center gap-2">
<CheckCircle className="w-4 h-4" /> Kesimpulan
</h4>
<p className="text-sm text-gray-700 bg-purple-50/50 p-3 rounded-lg border border-purple-100">
{result.kesimpulan}
</p>
</div>
<div className="mt-4">
<h4 className="text-sm font-bold text-purple-700 mb-2 flex items-center gap-2">
<Sparkles className="w-4 h-4" /> AI Insight
</h4>
<p className="text-sm text-gray-700 bg-blue-50/50 p-3 rounded-lg border border-blue-100 whitespace-pre-line">
{result.insight}
</p>
</div>
</div>
</div>
)}
</div>
@@ -71,8 +71,8 @@ const ChickenCountingDashboard: React.FC<ChickenCountingDashboardProps> = ({
startDate.setHours(0, 0, 0, 0);
today.setHours(23, 59, 59, 999);
const startDateStr = startDate.toISOString().split('T')[0];
const todayStr = today.toISOString().split('T')[0];
const startDateStr = startDate.toISOString().split('T')[0] ?? '';
const todayStr = today.toISOString().split('T')[0] ?? '';
// Fetch chicken counting data for selected kandang or all kandangs
const chickenCounts = await apiClient.getChickenCounting(
@@ -145,16 +145,8 @@ const ChickenCountingDashboard: React.FC<ChickenCountingDashboardProps> = ({
const mortalityRate =
initialPop > 0 ? ((totalMortality / initialPop) * 100).toFixed(2) : '0.00';
// Use IOT Chicken Counting data for all views (Semua Kandang or individual kandang)
// Fall back to calculated population only when IOT data is unavailable
let currentPopulation: number;
if (iotCurrentPopulation !== null) {
// Use latest IOT chicken count detection
currentPopulation = iotCurrentPopulation;
} else {
// Calculate from mortality records when IOT data unavailable
currentPopulation = initialPop - cumulativeMortality - cumulativeAfkir - cumulativePanen;
}
// Gunakan perhitungan manual sebagai patokan utama (Total populasi - mortality - panen)
const currentPopulation = initialPop - cumulativeMortality - cumulativeAfkir - cumulativePanen;
return { totalMortality, currentPopulation, totalPanen, mortalityRate, initialPop };
}, [
+1 -2
View File
@@ -24,7 +24,7 @@ interface ChickenCountDataPoint {
interface KandangData {
id: number;
name: string;
docInCount: number;
docInCount?: number;
}
interface CombinedTrendChartProps {
@@ -64,7 +64,6 @@ const CombinedTrendChart: React.FC<CombinedTrendChartProps> = ({
// Use external state if provided, otherwise use internal state
const selectedKandangId =
externalSelectedKandangId !== undefined ? externalSelectedKandangId : internalSelectedKandangId;
const _setSelectedKandangId = onKandangChange || setInternalSelectedKandangId;
const [filteredPopulation, setFilteredPopulation] =
useState<PopulationDataPoint[]>(populationData);
const [filteredChickenCount, setFilteredChickenCount] =
+41 -35
View File
@@ -4,7 +4,7 @@ import { apiClient } from '../../services/apiService.ts';
import { Kandang, MortalityRecord } from '../../types/counting.ts';
import ActivityLogPanel from '../common/ActivityLogPanel.tsx';
import SimplePopulationChart from './SimplePopulationChart.tsx';
import { PageAiInsight } from '../shared/PageAiInsight.tsx';
import ChickenCountingAiInsight from './ChickenCountingAiInsight.tsx';
interface DataManagementPageProps {
selectedKandangId: number | null;
@@ -53,8 +53,8 @@ const DataManagementPage: React.FC<DataManagementPageProps> = ({ selectedKandang
startDate.setHours(0, 0, 0, 0);
today.setHours(23, 59, 59, 999);
const startDateStr = startDate.toISOString().split('T')[0];
const todayStr = today.toISOString().split('T')[0];
const startDateStr = startDate.toISOString().split('T')[0] ?? '';
const todayStr = today.toISOString().split('T')[0] ?? '';
// Fetch chicken counting data for selected kandang or all kandangs
const chickenCounts = await apiClient.getChickenCounting(
@@ -209,8 +209,8 @@ const DataManagementPage: React.FC<DataManagementPageProps> = ({ selectedKandang
try {
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + maxDay);
const startDateStr = new Date(startDate).toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
const startDateStr = new Date(startDate).toISOString().split('T')[0] ?? '';
const endDateStr = endDate.toISOString().split('T')[0] ?? '';
const chickenCounts = await apiClient.getChickenCounting(
startDateStr,
@@ -220,7 +220,9 @@ const DataManagementPage: React.FC<DataManagementPageProps> = ({ selectedKandang
chickenCounts.forEach((cc: { date: string; totalCount: number }) => {
// Parse date string in local timezone to avoid off-by-one errors
const [year, month, day] = cc.date.split('T')[0].split('-').map(Number);
const [year = 0, month = 1, day = 1] = (cc.date.split('T')[0] ?? '')
.split('-')
.map(Number);
const ccDate = new Date(year, month - 1, day);
ccDate.setHours(0, 0, 0, 0);
const dayNum = Math.floor((ccDate.getTime() - startDateMs) / (1000 * 60 * 60 * 24));
@@ -296,8 +298,8 @@ const DataManagementPage: React.FC<DataManagementPageProps> = ({ selectedKandang
try {
const endDate = new Date(startDate);
endDate.setDate(endDate.getDate() + maxDay);
const startDateStr = new Date(startDate).toISOString().split('T')[0];
const endDateStr = endDate.toISOString().split('T')[0];
const startDateStr = new Date(startDate).toISOString().split('T')[0] ?? '';
const endDateStr = endDate.toISOString().split('T')[0] ?? '';
const chickenCounts = await apiClient.getChickenCounting(
startDateStr,
@@ -307,7 +309,9 @@ const DataManagementPage: React.FC<DataManagementPageProps> = ({ selectedKandang
chickenCounts.forEach((cc: { date: string; totalCount: number }) => {
// Parse date string in local timezone to avoid off-by-one errors
const [year, month, day] = cc.date.split('T')[0].split('-').map(Number);
const [year = 0, month = 1, day = 1] = (cc.date.split('T')[0] ?? '')
.split('-')
.map(Number);
const ccDate = new Date(year, month - 1, day);
ccDate.setHours(0, 0, 0, 0);
const dayNum = Math.floor((ccDate.getTime() - startDateMs) / (1000 * 60 * 60 * 24));
@@ -418,7 +422,7 @@ const DataManagementPage: React.FC<DataManagementPageProps> = ({ selectedKandang
const kandangSlug = kandang.name.toLowerCase().replace(/\s+/g, '-');
const date = new Date(cycleData.startDate);
date.setDate(date.getDate() + editingDay);
const dateStr = date.toISOString().split('T')[0];
const dateStr = date.toISOString().split('T')[0] ?? '';
await apiClient.submitChickenCounting(
dateStr,
kandangSlug,
@@ -476,16 +480,8 @@ const DataManagementPage: React.FC<DataManagementPageProps> = ({ selectedKandang
const mortalityRate = ((totalMortality / initialPopulation) * 100).toFixed(2);
const lastDay = latestRecord?.day || 0;
// Use IOT Chicken Counting data for all views (Semua Kandang or individual kandang)
// Fall back to calculated population only when IOT data is unavailable
let currentPopulation: number;
if (iotCurrentPopulation !== null) {
// Use latest IOT chicken count detection
currentPopulation = iotCurrentPopulation;
} else {
// Calculate from mortality records when IOT data unavailable
currentPopulation = latestRecord?.stockAkhir || initialPopulation;
}
// Gunakan perhitungan manual sebagai patokan utama (Total populasi - mortality - panen)
const currentPopulation = latestRecord?.stockAkhir || initialPopulation;
return {
totalMortality,
@@ -542,21 +538,31 @@ const DataManagementPage: React.FC<DataManagementPageProps> = ({ selectedKandang
</div>
{/* AI Insight */}
<PageAiInsight
topic="hitung_ayam"
cacheKey={`dm-${cycleData.id}-${selectedKandangId ?? 'all'}-${cycleData.currentDay}`}
contextData={{
cycleId: cycleData.id,
currentDay: cycleData.currentDay,
totalDays: cycleData.totalDays,
kandangFilter: selectedKandangId ?? 'semua',
initialPopulation,
currentPopulation: stats.currentPopulation,
totalMortality: stats.totalMortality,
mortalityRate: stats.mortalityRate,
totalPanen: stats.totalPanen,
iotCurrentPopulation,
}}
<ChickenCountingAiInsight
cycleId={cycleData.id}
currentDay={cycleData.currentDay}
totalDays={cycleData.totalDays || 1}
totalInitialPopulation={initialPopulation}
populationTrend={chartData as any}
kandangId={selectedKandangId}
// `stats` describes whatever scope is selected, so fanning it out across
// every coop published one coop's numbers under every name — the insight
// reported both coops at 9,91% when the real rates were 9,91% and 11,08%.
// Emit a row only for the coop those figures actually belong to.
kandangInsights={
selectedKandangId === null
? []
: kandangs
.filter((k) => k.id === selectedKandangId)
.map((k) => ({
name: k.name || '',
docIn: k.docInCount || 0,
currentPopulation: stats.currentPopulation,
mortalityTotal: stats.totalMortality,
mortalityRate: parseFloat(stats.mortalityRate) || 0,
panen: stats.totalPanen,
}))
}
/>
{/* Kandang selection now handled by global header dropdown */}
@@ -9,12 +9,12 @@ const LocationCountBreakdown: React.FC = () => {
const [openAccordions, setOpenAccordions] = useState<Record<string, boolean>>(() => {
const initialState: Record<string, boolean> = {};
if (aggregatedCounts.length > 0) {
const firstLocation = aggregatedCounts[0];
const firstLocation = aggregatedCounts[0];
if (firstLocation) {
initialState[firstLocation.locationId] = true;
if (firstLocation.coops.length > 0) {
const firstCoop = firstLocation.coops[0];
const firstCoop = firstLocation.coops[0];
if (firstCoop) {
initialState[firstCoop.coopId] = true;
// Open all floors within that first coop by default as they don't have sub-items
+1 -1
View File
@@ -7,7 +7,7 @@ interface CycleCreateModalProps {
}
const CycleCreateModal: React.FC<CycleCreateModalProps> = ({ isOpen, onClose, onSave }) => {
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0]);
const [startDate, setStartDate] = useState(new Date().toISOString().split('T')[0] ?? '');
const [docInCount, setDocInCount] = useState('20000');
const [chickInWeight, setChickInWeight] = useState('');
const [error, setError] = useState('');
+43 -21
View File
@@ -66,7 +66,7 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
const usageRef = React.useRef<HTMLDivElement>(null);
const outboundRef = React.useRef<HTMLDivElement>(null);
const scrollToSection = (ref: React.RefObject<HTMLDivElement>) => {
const scrollToSection = (ref: React.RefObject<HTMLDivElement | null>) => {
ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
@@ -81,6 +81,18 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
// Aggregate IOT + Manual data
const aggregatedData = useMemo(() => {
/**
* Key a row by its LOCAL date.
*
* `toISOString()` on a local-midnight Date re-expresses it in UTC, which in
* WIB (+7) is 17:00 the previous day — so every row was filed one day early
* and the table's dates disagreed with the ledger behind it.
*/
const localDateKey = (value: Date) =>
`${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(
value.getDate()
).padStart(2, '0')}`;
const cycleStart = new Date(cycleStartDate);
cycleStart.setHours(0, 0, 0, 0);
@@ -98,13 +110,15 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
const logDate = new Date(log.timestamp);
logDate.setHours(0, 0, 0, 0);
const dayNumber = Math.floor(
(logDate.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)
);
// +1 so the cycle's first day is "hari 1", matching the AI insight card
// and every other page. Without it this table labelled 2 Jul as day 41
// while the insight called the same date day 42.
const dayNumber =
Math.floor((logDate.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)) + 1;
if (dayNumber < 0 || dayNumber > currentDay) return;
if (dayNumber < 1 || dayNumber > currentDay + 1) return;
const dateKey = logDate.toISOString().split('T')[0];
const dateKey = localDateKey(logDate);
if (!dailyMap.has(dateKey)) {
const counts: { [key: string]: { manual: number; iot: number } } = {};
@@ -115,8 +129,9 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
}
const entry = dailyMap.get(dateKey)!;
if (log.cameraName && cameraNames.includes(log.cameraName)) {
entry.cameraCounts[log.cameraName].iot += log.count;
const iotBucket = log.cameraName ? entry.cameraCounts[log.cameraName] : undefined;
if (iotBucket) {
iotBucket.iot += log.count;
}
});
@@ -130,15 +145,14 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
manualDate.setHours(0, 0, 0, 0);
const dayNumber = Math.floor(
(manualDate.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)
);
const dayNumber =
Math.floor((manualDate.getTime() - cycleStart.getTime()) / (1000 * 60 * 60 * 24)) + 1;
if (dayNumber < 0 || dayNumber > currentDay) {
if (dayNumber < 1 || dayNumber > currentDay + 1) {
return;
}
const dateKey = manualDate.toISOString().split('T')[0];
const dateKey = localDateKey(manualDate);
if (!dailyMap.has(dateKey)) {
const counts: { [key: string]: { manual: number; iot: number } } = {};
@@ -149,8 +163,9 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
}
const entry = dailyMap.get(dateKey)!;
if (manual.cameraName && cameraNames.includes(manual.cameraName)) {
entry.cameraCounts[manual.cameraName].manual = manual.count;
const manualBucket = manual.cameraName ? entry.cameraCounts[manual.cameraName] : undefined;
if (manualBucket) {
manualBucket.manual = manual.count;
}
});
@@ -158,14 +173,15 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
for (let d = 0; d <= currentDay; d++) {
const targetDate = new Date(cycleStart);
targetDate.setDate(targetDate.getDate() + d);
const dateKey = targetDate.toISOString().split('T')[0];
const dayLabel = d + 1;
const dateKey = localDateKey(targetDate);
if (!dailyMap.has(dateKey)) {
const counts: { [key: string]: { manual: number; iot: number } } = {};
cameraNames.forEach((name) => {
counts[name] = { manual: 0, iot: 0 };
});
dailyMap.set(dateKey, { day: d, date: targetDate, cameraCounts: counts });
dailyMap.set(dateKey, { day: dayLabel, date: targetDate, cameraCounts: counts });
}
}
@@ -228,7 +244,7 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
// Validate manual counts
cameras.forEach((col) => {
const count = editValues.manualCounts[col.cameraName];
const count = editValues.manualCounts[col.cameraName] ?? 0;
if (count < 0) {
if (!errors.manualCounts) errors.manualCounts = {};
errors.manualCounts[col.cameraName] = 'Tidak boleh negatif';
@@ -261,7 +277,7 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
const savePromises = cameras.map((col) =>
onSaveManualEntry(
col.cameraName,
editValues.manualCounts[col.cameraName],
editValues.manualCounts[col.cameraName] ?? 0,
date,
editValues.keteranganValues[col.cameraName]
)
@@ -303,8 +319,14 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
}
// Get keterangan
// Same local-date rule as the aggregation above: `toISOString()` here
// compared against the previous day's key, so a saved keterangan never
// matched its own row and the note always came back empty.
const summaryDateKey = `${summary.date.getFullYear()}-${String(
summary.date.getMonth() + 1
).padStart(2, '0')}-${String(summary.date.getDate()).padStart(2, '0')}`;
const entry = manualEntries.find(
(e) => e.date === summary.date.toISOString().split('T')[0] && e.cameraName === col.cameraName
(e) => e.date === summaryDateKey && e.cameraName === col.cameraName
);
const keterangan = entry?.keterangan || '';
@@ -448,7 +470,7 @@ const FeedDailySummaryTable: React.FC<FeedDailySummaryTableProps> = ({
emoji: string,
headerColor: string,
cameras: FeedSackColumnConfig[],
ref: React.RefObject<HTMLDivElement>
ref: React.RefObject<HTMLDivElement | null>
) => {
if (cameras.length === 0) return null;
@@ -51,7 +51,7 @@ const FeedSackComparisonCharts: React.FC<FeedSackComparisonChartsProps> = ({
if (dayNumber < 0 || dayNumber > currentDay) return;
const dateKey = logDate.toISOString().split('T')[0];
const dateKey = logDate.toISOString().split('T')[0] ?? '';
if (!dailyMap.has(dateKey)) {
dailyMap.set(dateKey, {
@@ -76,7 +76,7 @@ const FeedSackComparisonCharts: React.FC<FeedSackComparisonChartsProps> = ({
if (isNaN(manualDate.getTime())) return;
manualDate.setHours(0, 0, 0, 0);
const dateKey = manualDate.toISOString().split('T')[0];
const dateKey = manualDate.toISOString().split('T')[0] ?? '';
if (!dailyMap.has(dateKey)) {
dailyMap.set(dateKey, {
@@ -98,7 +98,7 @@ const FeedSackComparisonCharts: React.FC<FeedSackComparisonChartsProps> = ({
for (let d = 0; d <= currentDay; d++) {
const date = new Date(cycleStart);
date.setDate(cycleStart.getDate() + d);
const dateKey = date.toISOString().split('T')[0];
const dateKey = date.toISOString().split('T')[0] ?? '';
if (dailyMap.has(dateKey)) {
filled.push(dailyMap.get(dateKey)!);
+1 -1
View File
@@ -78,7 +78,7 @@ const IotDeviceStats: React.FC<IotDeviceStatsProps> = ({ devices }) => {
paddingAngle={5}
dataKey="value"
>
{pieData.map((entry, index) => (
{pieData.map((_entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
@@ -70,7 +70,7 @@ const ManualWeighingAnalysis: React.FC<ManualWeighingAnalysisProps> = ({ records
const adgData = dailyData.map((d, i, arr) => {
if (i === 0) return { day: d.day, adg: 0 };
const adg = d.average - arr[i - 1].average;
const adg = d.average - (arr[i - 1]?.average ?? 0);
return { day: d.day, adg: parseFloat(adg.toFixed(2)) };
});
+1 -1
View File
@@ -11,7 +11,7 @@ const getTaskFromAlert = (alert: Alert) => {
let taskTitle = 'Tugas Tidak Dikenal';
let icon = 'fa-question-circle';
const locationMatch = alert.message.match(/di (.*?)(?:\(|$)/);
const location = locationMatch ? locationMatch[1].trim() : 'Lokasi tidak diketahui';
const location = locationMatch?.[1]?.trim() || 'Lokasi tidak diketahui';
if (alert.message.toLowerCase().includes('mortalitas')) {
taskTitle = 'Tangani Mortalitas';
@@ -54,7 +54,7 @@ const FeedScheduleFormModal: React.FC<FeedScheduleFormModalProps> = ({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const [hours, minutes] = time.split(':').map(Number);
const [hours = 0, minutes = 0] = time.split(':').map(Number);
const eventDate = eventToEdit ? new Date(eventToEdit.time) : new Date(initialDate);
eventDate.setHours(hours, minutes, 0, 0);
@@ -0,0 +1,353 @@
import React, { useMemo } from 'react';
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { DailyWeightPoint } from '../../types.ts';
import { getCobbStandardForDay } from '../../utils/insightHelpers.ts';
import { formatWeight } from '../../utils/weightUtils';
/**
* Actual weight vs Cobb standard across the whole cycle, with the gap between
* the two shaded by direction (surplus above the standard, deficit below).
*
* Every number shown here is computed from `data` in this file — nothing comes
* from the LLM. The chart therefore still renders when the model is offline,
* and the narrative above it can never disagree with the plotted line.
*/
interface EndCycleWeightVsStandardChartProps {
data: DailyWeightPoint[];
startDate?: string | Date | null;
}
type ChartPoint = {
day: number;
date: string;
actual: number;
target: number;
deviation: number;
/** Range band [target, actual] on days the flock beat the standard. */
surplus: [number, number] | null;
/** Range band [actual, target] on days the flock fell short. */
deficit: [number, number] | null;
};
type CycleStats = {
daysAbove: number;
daysBelow: number;
finalDeviation: number;
finalDeviationPct: number;
worstDay: ChartPoint | null;
bestDay: ChartPoint | null;
avgDeviationPct: number;
};
// Validated for the light chart surface via the dataviz palette checker.
// The app ships no dark mode (zero `dark:` classes, no darkMode in the Tailwind
// setup), so a light-only palette is the whole requirement here.
const COLOR_ACTUAL = '#1d4ed8';
const COLOR_SURPLUS = '#15803d';
const COLOR_DEFICIT = '#b45309';
// The standard is a reference baseline, not a competing series: neutral ink plus
// a dashed stroke so it never fights the actual line for attention.
const COLOR_STANDARD = '#94a3b8';
const formatSignedWeight = (grams: number) =>
`${grams >= 0 ? '+' : '-'}${formatWeight(Math.abs(grams))}`;
const formatSignedPct = (pct: number) => `${pct >= 0 ? '+' : '-'}${Math.abs(pct).toFixed(1)}%`;
export const EndCycleWeightVsStandardChart: React.FC<EndCycleWeightVsStandardChartProps> = ({
data,
startDate,
}) => {
const chartData = useMemo<ChartPoint[]>(() => {
if (!data || data.length === 0) return [];
return data
.map((point) => {
// `target` is already the Cobb figure for most rows; fall back to the
// lookup so a missing target does not silently drop the day.
const target = point.target || getCobbStandardForDay(point.day)?.weight || 0;
const actual = point.actual;
if (!actual || !target) return null;
const deviation = actual - target;
return {
day: point.day,
date: point.date,
actual,
target,
deviation,
surplus: deviation > 0 ? ([target, actual] as [number, number]) : null,
deficit: deviation < 0 ? ([actual, target] as [number, number]) : null,
};
})
.filter((point): point is ChartPoint => point !== null)
.sort((a, b) => a.day - b.day);
}, [data]);
const stats = useMemo<CycleStats | null>(() => {
if (chartData.length === 0) return null;
const last = chartData[chartData.length - 1];
if (!last) return null;
const worstDay = chartData.reduce((acc, point) =>
point.deviation < acc.deviation ? point : acc
);
const bestDay = chartData.reduce((acc, point) =>
point.deviation > acc.deviation ? point : acc
);
const totalPct = chartData.reduce(
(sum, point) => sum + (point.deviation / point.target) * 100,
0
);
return {
daysAbove: chartData.filter((point) => point.deviation > 0).length,
daysBelow: chartData.filter((point) => point.deviation < 0).length,
finalDeviation: last.deviation,
finalDeviationPct: (last.deviation / last.target) * 100,
worstDay: worstDay.deviation < 0 ? worstDay : null,
bestDay: bestDay.deviation > 0 ? bestDay : null,
avgDeviationPct: totalPct / chartData.length,
};
}, [chartData]);
const formatDateForDay = (point: ChartPoint): string => {
if (point.date) {
const parsed = new Date(point.date);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });
}
}
if (!startDate) return '';
const date = new Date(startDate);
if (Number.isNaN(date.getTime())) return '';
date.setDate(date.getDate() + point.day - 1);
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'short' });
};
const CustomTooltip = ({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: ChartPoint }>;
}) => {
const point = active ? payload?.[0]?.payload : null;
if (!point) return null;
const pct = (point.deviation / point.target) * 100;
const ahead = point.deviation >= 0;
return (
<div className="rounded-lg border border-gray-200 bg-white p-3 shadow-lg">
<p className="text-sm font-bold text-gray-900">Hari ke-{point.day}</p>
{formatDateForDay(point) && (
<p className="text-xs text-gray-500">{formatDateForDay(point)}</p>
)}
<div className="mt-2 space-y-1 text-sm">
<p className="flex items-center gap-2 text-gray-700">
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: COLOR_ACTUAL }}
/>
Berat aktual: <span className="font-semibold">{formatWeight(point.actual)}</span>
</p>
<p className="flex items-center gap-2 text-gray-700">
<span
className="inline-block h-2 w-2 shrink-0 rounded-full"
style={{ backgroundColor: COLOR_STANDARD }}
/>
Standar Cobb: <span className="font-semibold">{formatWeight(point.target)}</span>
</p>
<p className="pt-1 text-gray-700">
Selisih:{' '}
<span
className="font-semibold"
style={{ color: ahead ? COLOR_SURPLUS : COLOR_DEFICIT }}
>
{formatSignedWeight(point.deviation)} ({formatSignedPct(pct)})
</span>
</p>
<p className="text-xs text-gray-500">{ahead ? 'Di atas standar' : 'Di bawah standar'}</p>
</div>
</div>
);
};
if (chartData.length === 0) {
return (
<div className="rounded-2xl border border-purple-100 bg-white/90 p-5 shadow-sm">
<h3 className="text-sm font-bold uppercase tracking-widest text-purple-700">
Berat Aktual vs Standar Cobb
</h3>
<p className="mt-4 text-sm text-gray-500">
Data berat harian belum tersedia untuk siklus ini, jadi grafik perbandingan terhadap
standar Cobb belum bisa ditampilkan.
</p>
</div>
);
}
return (
<div className="rounded-2xl border border-purple-100 bg-white/90 p-5 shadow-sm">
<div className="mb-1 flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-bold uppercase tracking-widest text-purple-700">
Berat Aktual vs Standar Cobb
</h3>
<span className="text-xs text-gray-500">
{chartData.length} hari data · dihitung dari data timbangan, bukan dari AI
</span>
</div>
<p className="mb-4 text-sm text-gray-600">
Area berwarna menunjukkan jarak antara berat aktual dan kurva standar — hijau saat di atas
standar, oranye saat tertinggal.
</p>
<div className="h-80 w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid stroke="#eef2f7" vertical={false} />
<XAxis
dataKey="day"
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
tick={{ fill: '#6b7280', fontSize: 12 }}
tickFormatter={(day) => `H${day}`}
interval="preserveStartEnd"
minTickGap={24}
/>
<YAxis
tickLine={false}
axisLine={false}
tick={{ fill: '#6b7280', fontSize: 12 }}
tickFormatter={(value) => `${value}g`}
width={64}
/>
<Tooltip content={<CustomTooltip />} cursor={{ stroke: '#cbd5e1', strokeWidth: 1 }} />
<Legend
wrapperStyle={{ paddingTop: 12, fontSize: 12 }}
iconType="plainline"
iconSize={18}
/>
<Area
dataKey="surplus"
name="Di atas standar"
stroke="none"
fill={COLOR_SURPLUS}
fillOpacity={0.18}
isAnimationActive={false}
legendType="rect"
/>
<Area
dataKey="deficit"
name="Di bawah standar"
stroke="none"
fill={COLOR_DEFICIT}
fillOpacity={0.18}
isAnimationActive={false}
legendType="rect"
/>
<Line
type="monotone"
dataKey="target"
name="Standar Cobb"
stroke={COLOR_STANDARD}
strokeWidth={2}
strokeDasharray="6 4"
dot={false}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="actual"
name="Berat aktual"
stroke={COLOR_ACTUAL}
strokeWidth={2}
dot={false}
activeDot={{ r: 5, strokeWidth: 2, stroke: '#ffffff' }}
isAnimationActive={false}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
{stats && (
<div className="mt-5 grid grid-cols-2 gap-3 border-t border-purple-50 pt-4 md:grid-cols-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Selisih akhir siklus
</p>
<p
className="mt-1 text-lg font-bold"
style={{ color: stats.finalDeviation >= 0 ? COLOR_SURPLUS : COLOR_DEFICIT }}
>
{formatSignedWeight(stats.finalDeviation)}
</p>
<p className="text-xs text-gray-500">{formatSignedPct(stats.finalDeviationPct)}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Hari di atas standar
</p>
<p className="mt-1 text-lg font-bold text-gray-900">
{stats.daysAbove}
<span className="text-sm font-medium text-gray-500"> / {chartData.length}</span>
</p>
<p className="text-xs text-gray-500">{stats.daysBelow} hari tertinggal</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Rata-rata deviasi
</p>
<p
className="mt-1 text-lg font-bold"
style={{ color: stats.avgDeviationPct >= 0 ? COLOR_SURPLUS : COLOR_DEFICIT }}
>
{formatSignedPct(stats.avgDeviationPct)}
</p>
<p className="text-xs text-gray-500">sepanjang siklus</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">
Titik terburuk
</p>
{stats.worstDay ? (
<>
<p className="mt-1 text-lg font-bold" style={{ color: COLOR_DEFICIT }}>
{formatSignedWeight(stats.worstDay.deviation)}
</p>
<p className="text-xs text-gray-500">pada hari ke-{stats.worstDay.day}</p>
</>
) : (
<>
<p className="mt-1 text-lg font-bold" style={{ color: COLOR_SURPLUS }}>
{stats.bestDay ? formatSignedWeight(stats.bestDay.deviation) : '—'}
</p>
<p className="text-xs text-gray-500">tidak pernah di bawah standar</p>
</>
)}
</div>
</div>
)}
</div>
);
};
export default EndCycleWeightVsStandardChart;
+111
View File
@@ -0,0 +1,111 @@
import React from 'react';
export interface ConditionBar {
label: string;
actual: number | null;
standard: number | null;
unit: string;
/** Optional note shown under the bar, e.g. why a standard is absent. */
note?: string;
}
export interface InsightConditionChartProps {
bars: ConditionBar[];
title?: string;
emptyLabel?: string;
}
const AKTUAL = '#dc2626';
const STANDAR = '#3b82f6';
const fmt = (value: number, unit: string) =>
`${Number(value).toLocaleString('id-ID', { maximumFractionDigits: 1 })}${unit ? ` ${unit}` : ''}`;
/**
* Current readings against their CP 707 standard, one row per metric.
*
* The IoT panel has no per-day history — only a live snapshot — so there is no
* trend to draw. Paired bars still answer the question the reader actually has:
* is each reading above or below where the book says it should be. Each bar is
* directly labelled, so the comparison never rests on colour alone.
*/
const InsightConditionChart: React.FC<InsightConditionChartProps> = ({
bars,
title,
emptyLabel = 'Belum ada pembacaan sensor untuk digambarkan.',
}) => {
const usable = bars.filter((bar) => bar.actual !== null || bar.standard !== null);
if (usable.length === 0) {
return (
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
{emptyLabel}
</div>
);
}
return (
<div className="rounded-xl border border-gray-100 bg-white p-4">
{title && <h4 className="mb-1 text-sm font-bold text-gray-700">{title}</h4>}
<div className="mb-3 flex items-center gap-4 text-xs text-gray-500">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: AKTUAL }} />
Aktual
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2.5 w-2.5 rounded-sm" style={{ background: STANDAR }} />
Standar CP 707
</span>
</div>
<div className="space-y-3">
{usable.map((bar) => {
// Scale each metric against its own max: units differ per row, so a
// shared axis would make ammonia invisible next to CO2.
const max = Math.max(bar.actual ?? 0, bar.standard ?? 0, 1);
const pct = (value: number | null) =>
value === null ? 0 : Math.max(2, Math.round((value / max) * 100));
return (
<div key={bar.label}>
<div className="mb-1 flex items-baseline justify-between">
<span className="text-xs font-medium text-gray-600">{bar.label}</span>
<span className="text-xs text-gray-500">
{bar.actual === null ? 'data tidak tersedia' : fmt(bar.actual, bar.unit)}
{bar.standard !== null && (
<span className="text-gray-400"> · standar {fmt(bar.standard, bar.unit)}</span>
)}
</span>
</div>
<div className="space-y-1">
<div className="h-2.5 w-full rounded-sm bg-gray-100">
{bar.actual !== null && (
<div
className="h-full rounded-sm"
style={{ width: `${pct(bar.actual)}%`, background: AKTUAL }}
title={`Aktual ${fmt(bar.actual, bar.unit)}`}
/>
)}
</div>
<div className="h-2.5 w-full rounded-sm bg-gray-100">
{bar.standard !== null && (
<div
className="h-full rounded-sm"
style={{ width: `${pct(bar.standard)}%`, background: STANDAR }}
title={`Standar ${fmt(bar.standard, bar.unit)}`}
/>
)}
</div>
</div>
{bar.note && <p className="mt-1 text-[11px] text-gray-400">{bar.note}</p>}
</div>
);
})}
</div>
</div>
);
};
export default InsightConditionChart;
+189
View File
@@ -0,0 +1,189 @@
import React from 'react';
/**
* Headline figures for the dashboard AI Insight card.
*
* Replaces a nine-row label/value list in which every number wore the same
* weight, so nothing led the eye. A handful of headline numbers is a KPI row of
* stat tiles, not a list — one hero figure carries the metric the card is
* really about and the tiles support it.
*/
export type InsightStatus = 'ok' | 'warning' | 'critical' | 'unknown';
/**
* Status is never carried by hue alone: every chip pairs a glyph and a word
* 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: '▲' },
warning: { cls: 'bg-amber-50 text-amber-800 border-amber-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: '–' },
};
interface DeltaChipProps {
status: InsightStatus;
label: string;
}
export const DeltaChip: React.FC<DeltaChipProps> = ({ status, label }) => {
const chip = STATUS_CHIP[status];
return (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-semibold ${chip.cls}`}
>
<span aria-hidden="true">{chip.glyph}</span>
{label}
</span>
);
};
/**
* A 40px trend line. Deliberately axis-less and label-less: it conveys shape
* only, and the tile's number carries the value. Drawn as a plain polyline so
* the card gains no chart dependency for something this small.
*/
export const Sparkline: React.FC<{ values: number[]; className?: string }> = ({
values,
className,
}) => {
const clean = values.filter((v) => typeof v === 'number' && Number.isFinite(v));
if (clean.length < 2) return null;
const width = 88;
const height = 28;
const min = Math.min(...clean);
const max = Math.max(...clean);
const span = max - min || 1;
const step = width / (clean.length - 1);
const points = clean
.map((value, index) => {
const x = index * step;
// 2px inset top and bottom so the stroke is never clipped by the box.
const y = height - 2 - ((value - min) / span) * (height - 4);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
return (
<svg
viewBox={`0 0 ${width} ${height}`}
className={className}
width={width}
height={height}
role="presentation"
aria-hidden="true"
preserveAspectRatio="none"
>
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
};
interface HeroFigureProps {
label: string;
value: string;
unit?: string;
/** e.g. "Standar CP 707 hari ke-48: 1,60" */
context?: string | null;
status?: InsightStatus;
deltaLabel?: string | null;
sparkline?: number[];
}
export const HeroFigure: React.FC<HeroFigureProps> = ({
label,
value,
unit,
context,
status = 'unknown',
deltaLabel,
sparkline,
}) => (
<div className="flex items-end justify-between gap-4 rounded-xl border border-purple-100 bg-white px-5 py-4">
<div className="min-w-0">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500">{label}</div>
<div className="mt-1 flex items-baseline gap-1.5 pb-0.5">
{/* `leading-none` makes the line box exactly the font size, which is
fine in a browser but clips ascenders and descenders under
html2canvas — the PDF cut the top off every hero number. A little
leading costs nothing on screen and fixes the export. */}
<span className="text-5xl font-bold leading-[1.15] tracking-tight text-gray-900">
{value}
</span>
{unit ? <span className="text-sm font-medium text-gray-500">{unit}</span> : null}
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{deltaLabel ? <DeltaChip status={status} label={deltaLabel} /> : null}
{context ? <span className="text-xs text-gray-500">{context}</span> : null}
</div>
</div>
{sparkline && sparkline.length > 1 ? (
<Sparkline values={sparkline} className="shrink-0 text-purple-400" />
) : null}
</div>
);
interface StatTileProps {
label: string;
value: string;
unit?: string;
/** Small grey qualifier under the label, e.g. the period. */
hint?: string | null;
status?: InsightStatus;
deltaLabel?: string | null;
sparkline?: number[];
}
export const StatTile: React.FC<StatTileProps> = ({
label,
value,
unit,
hint,
status = 'unknown',
deltaLabel,
sparkline,
}) => (
<div className="rounded-xl border border-gray-100 bg-white px-4 py-3">
<div className="text-xs font-medium text-gray-500">
{label}
{hint ? <span className="ml-1 text-[11px] text-gray-400">{hint}</span> : null}
</div>
<div className="mt-1 flex items-baseline gap-1">
{/* Same reason as the hero above. */}
<span className="text-xl font-bold leading-[1.3] text-gray-900">{value}</span>
{unit ? <span className="text-xs font-medium text-gray-500">{unit}</span> : null}
</div>
{deltaLabel ? (
<div className="mt-1.5">
<DeltaChip status={status} label={deltaLabel} />
</div>
) : null}
{sparkline && sparkline.length > 1 ? (
<Sparkline values={sparkline} className="mt-2 text-gray-300" />
) : null}
</div>
);
/** Secondary figures: still present, but visibly subordinate to the tiles. */
export const MutedStatRow: React.FC<{ label: string; hint?: string | null; value: string }> = ({
label,
hint,
value,
}) => (
<div className="flex items-center justify-between border-b border-gray-50 py-1.5">
<span className="text-sm text-gray-600">
{label}
{hint ? <span className="ml-1 text-xs text-gray-400">{hint}</span> : null}
</span>
<span className="text-sm font-semibold text-gray-900">{value}</span>
</div>
);
+248
View File
@@ -0,0 +1,248 @@
import React from 'react';
import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ReferenceArea,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
/** One plotted line: which field in the row, how to name it, what colour. */
export interface InsightSeries {
field: string;
label: string;
color: string;
/** Draw as a dashed reference line — for a target or standard, not a measurement. */
dashed?: boolean;
}
/**
* Shade the distance between a measurement and the standard it is judged against.
*
* Two parallel lines make the reader estimate the gap by eye, when the gap IS
* the finding the insight text is about. Recharts has no between-two-series
* area, so the band is two stacked areas: a transparent base up to the lower of
* the pair, then the gap itself.
*/
export interface InsightGapSpec {
/** The measured series. */
actualField: string;
/** The standard it is compared against. */
targetField: string;
}
export interface InsightTrendChartProps {
/** FULL history, not the sliced window — the window is highlighted, not isolated. */
rows: Array<Record<string, unknown>>;
series: InsightSeries[];
/** Inclusive day range the insight text actually covers. */
highlight?: { firstDay: number; lastDay: number } | null;
unit?: string;
title?: string;
/** Shown instead of the chart when there is nothing to plot. */
emptyLabel?: string;
gap?: InsightGapSpec | null;
}
const dayOf = (row: Record<string, unknown>): number | null => {
for (const key of ['hari', 'day']) {
const value = row[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
}
return null;
};
const numOrNull = (value: unknown) =>
typeof value === 'number' && Number.isFinite(value) ? value : null;
/**
* Trend behind an insight, with the analysed period marked.
*
* The insight text is scoped to one window — often a single day — and a line of
* one point shows nothing. So the chart draws the whole run and shades the
* window the text is about: the reader gets the trend for context and still
* sees exactly which part the words describe.
*/
const InsightTrendChart: React.FC<InsightTrendChartProps> = ({
rows,
series,
highlight,
unit,
title,
emptyLabel = 'Belum ada data untuk digambarkan.',
gap = null,
}) => {
const data = (Array.isArray(rows) ? rows : [])
.map((row) => {
const day = dayOf(row);
if (day === null) return null;
const point: Record<string, number | null> = { hari: day };
for (const s of series) point[s.field] = numOrNull(row[s.field]);
if (gap) {
const actual = numOrNull(row[gap.actualField]);
const target = numOrNull(row[gap.targetField]);
point.bandBase = actual !== null && target !== null ? Math.min(actual, target) : null;
point.bandSize = actual !== null && target !== null ? Math.abs(actual - target) : null;
}
return point;
})
.filter((point): point is Record<string, number | null> & { hari: number } => point !== null)
.sort((a, b) => a.hari - b.hari);
// A series whose values are all missing would draw an empty legend entry.
const drawn = series.filter((s) => data.some((point) => point[s.field] !== null));
if (data.length === 0 || drawn.length === 0) {
return (
<div className="rounded-xl border border-gray-100 bg-gray-50/60 p-4 text-center text-sm text-gray-500">
{emptyLabel}
</div>
);
}
const days = data.map((point) => point.hari);
// Keep the axis readable on a long cycle: at most ~10 labels, always including
// the last day so the highlighted window is never unlabelled.
const step = Math.max(1, Math.ceil(days.length / 10));
const ticks = days.filter((_, i) => i % step === 0 || i === days.length - 1);
const band =
highlight && Number.isFinite(highlight.firstDay) && Number.isFinite(highlight.lastDay)
? highlight
: null;
// A one-day window has zero width as an area; widen it slightly so it reads.
const bandFrom = band
? band.firstDay === band.lastDay
? band.firstDay - 0.4
: band.firstDay
: 0;
const bandTo = band ? (band.firstDay === band.lastDay ? band.lastDay + 0.4 : band.lastDay) : 0;
// Status hue of the gap, taken from the most recent day where both values
// exist. Kept translucent so both lines stay readable through the fill.
const gapFill = (() => {
if (!gap) return null;
const last = [...data]
.reverse()
.find((point) => point[gap.actualField] !== null && point[gap.targetField] !== null);
const actual = last?.[gap.actualField];
const target = last?.[gap.targetField];
if (typeof actual !== 'number' || typeof target !== 'number' || target === 0) {
return 'rgba(107, 114, 128, 0.12)';
}
const deviation = ((actual - target) / target) * 100;
if (deviation >= -5) return 'rgba(12, 163, 12, 0.12)';
if (deviation >= -15) return 'rgba(250, 178, 25, 0.18)';
return 'rgba(208, 59, 59, 0.14)';
})();
return (
<div className="rounded-xl border border-gray-100 bg-white p-4">
{title && <h4 className="mb-1 text-sm font-bold text-gray-700">{title}</h4>}
{band && (
<p className="mb-3 text-xs text-gray-500">
Area berbayang = periode yang dibahas insight
{band.firstDay === band.lastDay
? ` (hari ke-${band.lastDay})`
: ` (hari ke-${band.firstDay} s/d ke-${band.lastDay})`}
</p>
)}
<ResponsiveContainer width="100%" height={230}>
<ComposedChart data={data} margin={{ top: 8, right: 12, bottom: 4, left: 0 }}>
<CartesianGrid stroke="#f1f1ef" vertical={false} />
{band && (
<ReferenceArea
x1={bandFrom}
x2={bandTo}
fill="#6b7280"
fillOpacity={0.12}
stroke="#6b7280"
strokeOpacity={0.25}
/>
)}
<XAxis
dataKey="hari"
// Numeric axis, not the default category axis: the highlight band is
// positioned by day number, and a category axis cannot place a
// fractional boundary like 47.6 — the band silently never renders.
type="number"
domain={[(days[0] ?? 0) - 0.5, (days[days.length - 1] ?? 0) + 0.5]}
ticks={ticks}
allowDecimals={false}
tick={{ fontSize: 11, fill: '#6b7280' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
tickFormatter={(value) => `H${value}`}
/>
<YAxis
tick={{ fontSize: 11, fill: '#6b7280' }}
tickLine={false}
axisLine={false}
width={52}
tickFormatter={(value) => Number(value).toLocaleString('id-ID')}
/>
<Tooltip
contentStyle={{
fontSize: 12,
borderRadius: 10,
border: '1px solid #e5e7eb',
boxShadow: '0 4px 12px rgba(0,0,0,0.06)',
}}
labelFormatter={(value) => `Hari ke-${value}`}
formatter={(value: number | string, name: string) => {
// The two stacked helpers that draw the band are geometry, not data.
if (name === 'bandBase' || name === 'bandSize') return [];
return [`${Number(value).toLocaleString('id-ID')}${unit ? ` ${unit}` : ''}`, name];
}}
/>
{drawn.length > 1 && <Legend wrapperStyle={{ fontSize: 12 }} iconType="plainline" />}
{gap && gapFill && (
<>
<Area
dataKey="bandBase"
stackId="gap"
stroke="none"
fill="none"
legendType="none"
isAnimationActive={false}
connectNulls
/>
<Area
dataKey="bandSize"
stackId="gap"
stroke="none"
fill={gapFill}
fillOpacity={1}
legendType="none"
isAnimationActive={false}
connectNulls
/>
</>
)}
{drawn.map((s) => (
<Line
key={s.field}
type="monotone"
dataKey={s.field}
name={s.label}
stroke={s.color}
strokeWidth={2}
strokeDasharray={s.dashed ? '5 4' : undefined}
dot={!s.dashed && data.length <= 20 ? { r: 3, strokeWidth: 0, fill: s.color } : false}
activeDot={{ r: 5, strokeWidth: 2, stroke: '#ffffff' }}
connectNulls
isAnimationActive={false}
/>
))}
</ComposedChart>
</ResponsiveContainer>
</div>
);
};
export default InsightTrendChart;
File diff suppressed because it is too large. Load diff
+253
View File
@@ -0,0 +1,253 @@
import React from 'react';
/**
* Identity block printed at the top of every exported AI Insight PDF.
*
* Hidden on screen and revealed only while html2canvas photographs the card
* (see `revealPdfHeaders`), because the on-screen card already sits under
* headings and a coop picker that state all of this — but the PDF travels on
* its own, and a report that does not say WHEN, WHERE and WHICH PERIOD it
* covers cannot be filed or checked later.
*/
export const PDF_HEADER_CLASS = 'pdf-report-header';
/** Human period label from the dashboard's report scope. */
export const describeReportPeriod = (
reportType: 'daily' | 'weekly' | 'end_cycle',
reportPeriod?: string | null
): string => {
if (reportType === 'weekly') {
return reportPeriod ? `Laporan Mingguan — ${reportPeriod}` : 'Laporan Mingguan';
}
if (reportType === 'end_cycle') return 'Laporan Akhir Siklus';
return 'Laporan Harian';
};
/** Same, for the cards that use the harian/mingguan toggle instead. */
export const describeTimeframePeriod = (
timeframe: 'harian' | 'mingguan',
rangeLabel?: string | null
): string => {
const base = timeframe === 'harian' ? 'Laporan Harian' : 'Laporan Mingguan';
return rangeLabel ? `${base} — ${rangeLabel}` : base;
};
/**
* "Kandang Atas", or the combined label when the admin has not narrowed to one
* coop. Named explicitly rather than just "Semua Kandang" so a reader of the
* PDF knows exactly which flocks the figures add up.
*/
export const describeKandangScope = (
kandangs: Array<{ id?: number; name?: string }> | undefined,
kandangId: number | string | null | undefined
): string => {
const list = kandangs || [];
if (kandangId === null || kandangId === undefined || kandangId === '') {
const names = list
.map((k) => (k.name || '').replace(/^Kandang\s+/i, '').trim())
.filter(Boolean);
return names.length > 0 ? `Semua Kandang (${names.join(' + ')})` : 'Semua Kandang';
}
const match = list.find((k) => k.id?.toString() === kandangId.toString());
return match?.name || `Kandang ${kandangId}`;
};
/**
* Calendar date of a given cycle day, so the PDF carries a real date and not
* only "hari ke-48" — which means nothing to whoever reads the file later.
* Day 1 is the cycle's start date.
*/
export const cycleDayToDate = (
startDate: Date | string | null | undefined,
day: number | null | undefined
): Date | null => {
if (!startDate || !day || !Number.isFinite(day)) return null;
const start = startDate instanceof Date ? startDate : new Date(startDate);
if (Number.isNaN(start.getTime())) return null;
const result = new Date(start);
result.setDate(result.getDate() + (day - 1));
return result;
};
const shortDate = (value: Date | null): string =>
value
? value.toLocaleDateString('id-ID', { day: 'numeric', month: 'short', year: 'numeric' })
: '';
/**
* "22 Jul 2026" for a single day, "16 Jul 2026 – 22 Jul 2026" for a range.
* Returns null when the cycle start is unknown, so the caller simply omits it
* rather than printing a guessed date.
*/
export const describeDataDates = (
startDate: Date | string | null | undefined,
fromDay: number | null | undefined,
toDay?: number | null
): string | null => {
const from = cycleDayToDate(startDate, fromDay);
if (!from) return null;
const to = toDay && toDay !== fromDay ? cycleDayToDate(startDate, toDay) : null;
return to ? `${shortDate(from)} – ${shortDate(to)}` : shortDate(from);
};
/** Pull the day numbers out of labels like "hari ke-42 s/d ke-48". */
export const parseDayRangeLabel = (
label: string | null | undefined
): { fromDay: number | null; toDay: number | null } => {
if (!label) return { fromDay: null, toDay: null };
const numbers = label.match(/\d+/g);
if (!numbers || numbers.length === 0) return { fromDay: null, toDay: null };
const first = parseInt(numbers[0] ?? '', 10);
const last = parseInt(numbers[numbers.length - 1] ?? '', 10);
if (!Number.isFinite(first)) return { fromDay: null, toDay: null };
return { fromDay: first, toDay: numbers.length > 1 ? last : null };
};
/** ISO dates for filenames: sorts chronologically in a file manager. */
export const isoDataDates = (
startDate: Date | string | null | undefined,
fromDay: number | null | undefined,
toDay?: number | null
): string | null => {
// Built from LOCAL parts, not toISOString(): in WIB (+7) midnight local is
// 17:00 UTC the previous day, so the UTC form printed a filename dated one
// day earlier than the header inside the very same PDF.
const iso = (value: Date | null) =>
value
? `${value.getFullYear()}-${String(value.getMonth() + 1).padStart(2, '0')}-${String(
value.getDate()
).padStart(2, '0')}`
: null;
const from = iso(cycleDayToDate(startDate, fromDay));
if (!from) return null;
const to = toDay && toDay !== fromDay ? iso(cycleDayToDate(startDate, toDay)) : null;
return to ? `${from}_sd_${to}` : from;
};
/** Filename-safe: no spaces, punctuation, or accents. */
const slug = (value: string): string =>
value
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-zA-Z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
/**
* Filename carrying the same identity as the printed header.
*
* The old names were `AI_Insight_HitungAyam_Hari_48.pdf` — two reports from
* different coops, or the daily and weekly view of the same day, collided on
* one name and silently overwrote each other in the downloads folder.
*/
export const buildPdfFilename = (parts: {
/** "Dashboard", "Hitung-Ayam", or the topic key. */
reportName: string;
area: string;
kandangLabel: string;
/** "Harian", "Mingguan-Minggu-6", "Akhir-Siklus". */
periodSlug: string;
/** From `isoDataDates`. */
isoDates?: string | null;
}): string => {
const segments = [
'AI-Insight',
slug(parts.reportName),
slug(parts.area),
slug(parts.kandangLabel),
slug(parts.periodSlug),
parts.isoDates || null,
].filter(Boolean);
return `${segments.join('_')}.pdf`;
};
/** Compact period token for filenames, matching `describeReportPeriod`. */
export const reportPeriodSlug = (
reportType: 'daily' | 'weekly' | 'end_cycle',
reportPeriod?: string | null
): string => {
if (reportType === 'weekly') return reportPeriod ? `Mingguan-${reportPeriod}` : 'Mingguan';
if (reportType === 'end_cycle') return 'Akhir-Siklus';
return 'Harian';
};
const formatDate = (value?: Date | string | null): string => {
if (!value) return '';
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' });
};
interface PdfReportHeaderProps {
/** e.g. "AI Insight — Hitung Ayam" */
title: string;
/** e.g. "Sukawarna" */
area: string;
/** e.g. "Kandang Atas" or "Semua Kandang (Atas + Bawah)" */
kandangLabel: string;
/** e.g. "Laporan Mingguan — Minggu 6" */
periodLabel: string;
/** Cycle the data belongs to. */
cycleId?: string | null;
/** Calendar range the data covers, when the card knows it. */
dataDateLabel?: string | null;
}
export const PdfReportHeader: React.FC<PdfReportHeaderProps> = ({
title,
area,
kandangLabel,
periodLabel,
cycleId,
dataDateLabel,
}) => (
<div
className={PDF_HEADER_CLASS}
style={{ display: 'none' }}
// Printed, never read by screen readers on the live page.
aria-hidden="true"
>
<div
style={{
borderBottom: '2px solid #7c3aed',
paddingBottom: '10px',
marginBottom: '14px',
}}
>
<div style={{ fontSize: '17px', fontWeight: 700, color: '#111827' }}>{title}</div>
<div style={{ fontSize: '13px', color: '#374151', marginTop: '5px' }}>
<strong>Lokasi:</strong> {area} — {kandangLabel}
</div>
<div style={{ fontSize: '13px', color: '#374151', marginTop: '2px' }}>
{/* Deliberately no cycle-day here: it is the cycle's CURRENT day, which
for a weekly or end-cycle report belongs to a different span than
the dates beside it — "Minggu 3 (hari ke-48) 5–11 Jun" read as a
contradiction. The calendar range is the honest answer. */}
<strong>Periode:</strong> {periodLabel}
{dataDateLabel ? ` · ${dataDateLabel}` : ''}
</div>
{cycleId ? (
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '2px' }}>
<strong>Siklus:</strong> {cycleId}
</div>
) : null}
<div style={{ fontSize: '12px', color: '#6b7280', marginTop: '2px' }}>
Dicetak: {formatDate(new Date())}
</div>
</div>
</div>
);
/**
* Un-hide the header inside html2canvas's cloned DOM.
*
* html2canvas photographs a detached clone, so flipping display here shows the
* block in the PDF without it ever flashing on the live page.
*/
export const revealPdfHeaders = (clonedElement: HTMLElement): void => {
clonedElement.querySelectorAll<HTMLElement>(`.${PDF_HEADER_CLASS}`).forEach((node) => {
node.style.display = 'block';
});
};
export default PdfReportHeader;
@@ -0,0 +1,102 @@
import { describe, it, expect } from 'vitest';
import {
describeDataDates,
describeKandangScope,
describeReportPeriod,
describeTimeframePeriod,
parseDayRangeLabel,
buildPdfFilename,
isoDataDates,
reportPeriodSlug,
} from '../PdfReportHeader.tsx';
const START = '2026-05-21T17:00:00.000Z'; // cycle CYCLE-JBW-2026-05-22
describe('PdfReportHeader helpers', () => {
it('maps cycle day 1 to the start date', () => {
expect(describeDataDates(START, 1)).toBe('22 Mei 2026');
});
it('maps day 48 to the right calendar date', () => {
expect(describeDataDates(START, 48)).toBe('8 Jul 2026');
});
it('renders a range for a week window', () => {
expect(describeDataDates(START, 42, 48)).toBe('2 Jul 2026 – 8 Jul 2026');
});
it('omits the date when the cycle start is unknown', () => {
expect(describeDataDates(null, 12)).toBeNull();
});
it('reads day numbers out of a range label', () => {
expect(parseDayRangeLabel('hari ke-42 s/d ke-48')).toEqual({ fromDay: 42, toDay: 48 });
expect(parseDayRangeLabel('hari ke-48')).toEqual({ fromDay: 48, toDay: null });
expect(parseDayRangeLabel('Minggu 6')).toEqual({ fromDay: 6, toDay: null });
});
it('names the combined scope when no coop is selected', () => {
const kandangs = [
{ id: 1, name: 'Kandang Atas' },
{ id: 2, name: 'Kandang Bawah' },
];
expect(describeKandangScope(kandangs, null)).toBe('Semua Kandang (Atas + Bawah)');
expect(describeKandangScope(kandangs, 1)).toBe('Kandang Atas');
expect(describeKandangScope(kandangs, '2')).toBe('Kandang Bawah');
});
it('labels each report scope', () => {
expect(describeReportPeriod('daily')).toBe('Laporan Harian');
expect(describeReportPeriod('weekly', 'Minggu 6')).toBe('Laporan Mingguan — Minggu 6');
expect(describeReportPeriod('end_cycle')).toBe('Laporan Akhir Siklus');
expect(describeTimeframePeriod('mingguan', 'hari ke-42 s/d ke-48')).toBe(
'Laporan Mingguan — hari ke-42 s/d ke-48'
);
});
});
describe('buildPdfFilename', () => {
it('carries coop, period and date range', () => {
expect(
buildPdfFilename({
reportName: 'Dashboard',
area: 'Sukawarna',
kandangLabel: 'Kandang Atas',
periodSlug: reportPeriodSlug('weekly', 'Minggu 6'),
isoDates: isoDataDates(START, 42, 48),
})
).toBe(
'AI-Insight_Dashboard_Sukawarna_Kandang-Atas_Mingguan-Minggu-6_2026-07-02_sd_2026-07-08.pdf'
);
});
it('flattens the combined-coop label safely', () => {
expect(
buildPdfFilename({
reportName: 'Hitung-Ayam',
area: 'Sukawarna',
kandangLabel: 'Semua Kandang (Atas + Bawah)',
periodSlug: 'Harian',
isoDates: isoDataDates(START, 48),
})
).toBe('AI-Insight_Hitung-Ayam_Sukawarna_Semua-Kandang-Atas-Bawah_Harian_2026-07-08.pdf');
});
it('omits the date segment when the cycle start is unknown', () => {
expect(
buildPdfFilename({
reportName: 'fcr',
area: 'Sukawarna',
kandangLabel: 'Kandang Bawah',
periodSlug: reportPeriodSlug('end_cycle'),
isoDates: isoDataDates(null, 1, 48),
})
).toBe('AI-Insight_fcr_Sukawarna_Kandang-Bawah_Akhir-Siklus.pdf');
});
it('two coops on the same day no longer collide', () => {
const base = {
reportName: 'Hitung-Ayam',
area: 'Sukawarna',
periodSlug: 'Harian',
isoDates: isoDataDates(START, 48),
};
expect(buildPdfFilename({ ...base, kandangLabel: 'Kandang Atas' })).not.toBe(
buildPdfFilename({ ...base, kandangLabel: 'Kandang Bawah' })
);
});
});
+5 -5
View File
@@ -28,7 +28,7 @@ const VetVisitFormModal: React.FC<VetVisitFormModalProps> = ({
const [coopId, setCoopId] = useState(coops[0]?.id || '');
const [vetId, setVetId] = useState(vets[0]?.id || '');
const [purpose, setPurpose] = useState('');
const [date, setDate] = useState(new Date().toISOString().split('T')[0]);
const [date, setDate] = useState(new Date().toISOString().split('T')[0] ?? '');
const [time, setTime] = useState('09:00');
const [notes, setNotes] = useState('');
@@ -38,7 +38,7 @@ const VetVisitFormModal: React.FC<VetVisitFormModalProps> = ({
setVetId(visitToEdit.vetId);
setPurpose(visitToEdit.purpose);
const visitDate = new Date(visitToEdit.scheduledTime);
setDate(visitDate.toISOString().split('T')[0]);
setDate(visitDate.toISOString().split('T')[0] ?? '');
setTime(
`${String(visitDate.getHours()).padStart(2, '0')}:${String(visitDate.getMinutes()).padStart(2, '0')}`
);
@@ -48,7 +48,7 @@ const VetVisitFormModal: React.FC<VetVisitFormModalProps> = ({
setCoopId(coops[0]?.id || '');
setVetId(vets[0]?.id || '');
setPurpose('');
setDate(new Date().toISOString().split('T')[0]);
setDate(new Date().toISOString().split('T')[0] ?? '');
setTime('09:00');
setNotes('');
}
@@ -56,8 +56,8 @@ const VetVisitFormModal: React.FC<VetVisitFormModalProps> = ({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const [year, month, day] = date.split('-').map(Number);
const [hours, minutes] = time.split(':').map(Number);
const [year = 0, month = 1, day = 1] = date.split('-').map(Number);
const [hours = 0, minutes = 0] = time.split(':').map(Number);
const scheduledTime = new Date(year, month - 1, day, hours, minutes);
const coop = coops.find((c) => c.id === coopId);
@@ -34,12 +34,12 @@ const DailyWeightProgressionChart: React.FC<DailyWeightProgressionChartProps> =
const chartData = useMemo<DailyWeightChartPoint[]>(() => {
if (hasNoData) return [];
const transformed = data.map((point) => ({
const transformed: DailyWeightChartPoint[] = data.map((point) => ({
...point,
chartDay: point.day - 1,
}));
if (transformed.length > 0 && transformed[0].chartDay > 0) {
if ((transformed[0]?.chartDay ?? 0) > 0) {
transformed.unshift({
day: 0,
date: '',
@@ -69,9 +69,9 @@ const DailyWeightProgressionChart: React.FC<DailyWeightProgressionChartProps> =
const maxDay = days.length > 0 ? Math.max(...days) : 0;
// Custom tick component for x-axis
const CustomTick = (props: { x: number; y: number; payload: { value: number } }) => {
const { x, y, payload } = props;
const day = payload.value;
const CustomTick = (props: { x?: number; y?: number; payload?: { value: number } }) => {
const { x = 0, y = 0, payload } = props;
const day = payload?.value ?? 0;
const isFirstOrLast = day === firstDataDay || day === maxDay;
return (
@@ -97,8 +97,8 @@ const DailyWeightProgressionChart: React.FC<DailyWeightProgressionChartProps> =
payload?: Array<{ payload: DailyWeightChartPoint }>;
}) => {
if (active && payload && payload.length) {
const dataPoint = payload[0].payload;
if (dataPoint.actual == null) {
const dataPoint = payload[0]?.payload;
if (!dataPoint || dataPoint.actual == null) {
return null;
}
const target = dataPoint.target || 0;
@@ -61,6 +61,7 @@ const DeviceWeightComparisonChart: React.FC<DeviceWeightComparisonChartProps> =
const CustomBarLabel = (props: any) => {
const { x, y, width, height, index } = props;
const device = data[index];
if (!device) return null;
return (
<text
x={x + width + 5}
@@ -41,7 +41,7 @@ const WeightDistributionChart: React.FC<WeightDistributionChartProps> = ({
const targetIndexEnd = Math.floor(data.length / 2) + 1;
const formatDateForInput = (date: Date): string => {
return date.toISOString().split('T')[0];
return date.toISOString().split('T')[0] ?? '';
};
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -126,7 +126,7 @@ const WeightDistributionChart: React.FC<WeightDistributionChartProps> = ({
formatter={(value: number) => [`${formatNumber(value)} ekor`, 'Jumlah']}
/>
<Bar dataKey="count" name="Jumlah">
{data.map((entry, index) => (
{data.map((_entry, index) => (
<Cell
key={`cell-${index}`}
fill={
+43 -25
View File
@@ -152,6 +152,17 @@ interface AppContextType {
dailyWeightData: any[]; // DailyWeightPoint[]
dailyWeightGainData: any[]; // DailyWeightGainPoint[]
deviceWeightData: any[]; // DeviceWeightData[]
// Provided by `staticDataStore` and already spread into the context value —
// only the declaration was missing, so consumers failed to type-check.
sensorWeightSummary: any[]; // SensorWeightSummary[]
// Coops of the active cycle. Held as local state but never exposed, so
// `AiInsightCard` read `undefined` and fell back to "Kandang <id>" instead of
// the real name.
kandangs: any[];
// The area picked in the header ("Sukawarna"). Exposed so a report can state
// which location it describes.
selectedArea: string;
selectedKandangId: number | null;
scaleDataLoading: boolean;
scaleDataLastUpdate: Date;
dailyDataLoading: boolean;
@@ -178,9 +189,7 @@ interface AppContextType {
onAddCamera: (cameraData: Omit<Camera, 'id' | 'status'>) => void;
onUpdateCamera: (camera: Camera) => void;
onDeleteCamera: (cameraId: string) => void;
onAddIotDevice: (
deviceData: Omit<IotDevice, 'id' | 'status' | 'lastHeartbeat' | 'currentValue'>
) => void;
onAddIotDevice: (deviceData: Omit<IotDevice, 'id' | 'status' | 'lastHeartbeat'>) => void;
onUpdateIotDevice: (device: IotDevice) => void;
onDeleteIotDevice: (deviceId: string) => void;
onAddUser: (userData: Omit<User, 'id'>) => void;
@@ -220,11 +229,13 @@ const AppContext = createContext<AppContextType | undefined>(undefined);
interface AppContextProviderProps {
children: ReactNode;
selectedKandangId: number | null;
selectedArea: string;
}
export const AppContextProvider: React.FC<AppContextProviderProps> = ({
children,
selectedKandangId,
selectedArea,
}) => {
// --- Custom store hooks ---
const accessManagementStore = useAccessManagementStore();
@@ -294,7 +305,7 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
if (sortedKeys.length === 0) {
return 0;
}
return sortedKeys[sortedKeys.length - 1];
return sortedKeys[sortedKeys.length - 1] ?? 0;
}, []);
const resolveDashboardKpiSelection = useCallback(
@@ -802,14 +813,15 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
// 4. Fetch IoT sensor data for the current day using range API
try {
// Get current date in WIB timezone (API interprets dates in Jakarta timezone)
const wibDateStr = new Date()
.toLocaleString('en-CA', {
timeZone: 'Asia/Jakarta',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.split(',')[0]; // Format: YYYY-MM-DD
const wibDateStr =
new Date()
.toLocaleString('en-CA', {
timeZone: 'Asia/Jakarta',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.split(',')[0] ?? ''; // Format: YYYY-MM-DD
// Query current day in WIB timezone
const iotRangeData = await apiClient.getIotSensorRange(wibDateStr, wibDateStr, 'LIVE');
@@ -841,14 +853,15 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
const fetchIotRangeData = async () => {
try {
// Get current date in WIB timezone (API interprets dates in Jakarta timezone)
const wibDateStr = new Date()
.toLocaleString('en-CA', {
timeZone: 'Asia/Jakarta',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.split(',')[0]; // Format: YYYY-MM-DD
const wibDateStr =
new Date()
.toLocaleString('en-CA', {
timeZone: 'Asia/Jakarta',
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.split(',')[0] ?? ''; // Format: YYYY-MM-DD
// Query current day in WIB timezone
const iotRangeData = await apiClient.getIotSensorRange(wibDateStr, wibDateStr, 'LIVE');
@@ -939,7 +952,7 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
console.error('Error fetching weight distribution:', error);
// Keep existing data on error
}
}, [staticDataStore.distributionSelectedDate, activeCycle.startDate, staticDataStore]);
}, [staticDataStore.distributionSelectedDate, activeCycle?.startDate, staticDataStore]);
// --- Sensor Weight Summary Fetcher (also fetches device weight data for selected date) ---
const fetchSensorWeightSummary = useCallback(async () => {
@@ -991,7 +1004,7 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
activeCycle.startDate instanceof Date
? activeCycle.startDate
: new Date(activeCycle.startDate);
const cycleStartStr = cycleStart.toISOString().split('T')[0];
const cycleStartStr = cycleStart.toISOString().split('T')[0] ?? '';
const statsDate = new Date(effectiveDate);
statsDate.setHours(0, 0, 0, 0);
const previousStatsDate = new Date(statsDate);
@@ -1053,7 +1066,7 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
: previousDistribution.data;
if (!selectedDistData || selectedDistData.total_chicken_count === 0) {
if (previousDistribution.success && comparisonDistData?.total_chicken_count > 0) {
if (previousDistribution.success && (comparisonDistData?.total_chicken_count ?? 0) > 0) {
selectedDistData = comparisonDistData;
selectedDateLabel = formatEffectiveDateLabel(previousStatsDate);
comparisonDistData = Array.isArray(dayBeforePreviousDistribution.data)
@@ -1300,7 +1313,7 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
const selectedDay =
effectiveDay > 0 && sortedPrimary.some((point) => point.day === effectiveDay)
? effectiveDay
: sortedPrimary[sortedPrimary.length - 1].day;
: (sortedPrimary[sortedPrimary.length - 1]?.day ?? 0);
const last =
sortedPrimary.find((point) => point.day === selectedDay) ||
sortedPrimary[sortedPrimary.length - 1];
@@ -1551,7 +1564,9 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
...crossStoreHandlers,
...derivedState,
...locationHelpers,
cycleData: activeCycle,
// Undefined only when the database has no cycles at all — the memo above
// logs that as an error. Consumers all assume a cycle exists.
cycleData: activeCycle as CycleData,
// Explicitly include device add/update from deviceStore to satisfy the type
onAddCamera: deviceStore.onAddCamera,
onUpdateCamera: deviceStore.onUpdateCamera,
@@ -1588,6 +1603,9 @@ export const AppContextProvider: React.FC<AppContextProviderProps> = ({
dailyDataLoading: staticDataStore.dailyDataLoading,
fetchScaleData,
refreshScaleData,
kandangs,
selectedArea,
selectedKandangId,
// API mode - always LIVE since we're using real APIs
apiMode: 'LIVE',
// D-1 filter state
+10 -12
View File
@@ -183,10 +183,8 @@ export const useStaticDataStore = () => {
allData.forEach((coop) => {
coop.history.forEach((point) => {
if (!chartPoints[point.day]) {
chartPoints[point.day] = { day: point.day };
}
chartPoints[point.day][coop.coopName] = point.weight;
const chartPoint = chartPoints[point.day] ?? (chartPoints[point.day] = { day: point.day });
chartPoint[coop.coopName] = point.weight;
});
});
@@ -209,13 +207,13 @@ export const useStaticDataStore = () => {
deviation: 0,
};
}
const currentWeight = history[history.length - 1].weight;
const previousWeight = history[history.length - 2].weight;
const currentWeight = history[history.length - 1]?.weight ?? 0;
const previousWeight = history[history.length - 2]?.weight ?? 0;
const adg = currentWeight - previousWeight;
const targetWeightForDay =
multiCoopWeightData.target.find((t) => t.day === history[history.length - 1].day)?.weight ||
currentWeight;
multiCoopWeightData.target.find((t) => t.day === history[history.length - 1]?.day)
?.weight || currentWeight;
const deviation = currentWeight - targetWeightForDay;
// Mock uniformity
@@ -300,8 +298,8 @@ export const useStaticDataStore = () => {
await apiClient.updateCycle(cycleId, {
totalDays,
currentDay: cycle.currentDay,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
startDate: startDate.toISOString().split('T')[0] ?? '',
endDate: endDate.toISOString().split('T')[0] ?? '',
chickInWeight: chickInWeight || null,
docInCount: docInCount || null,
status: cycle.status,
@@ -361,8 +359,8 @@ export const useStaticDataStore = () => {
id: newId,
totalDays,
currentDay,
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0],
startDate: startDate.toISOString().split('T')[0] ?? '',
endDate: endDate.toISOString().split('T')[0] ?? '',
chickInWeight: chickInWeight || null,
docInCount: docInCount || null,
status: 'Active',
+1 -1
View File
@@ -28,7 +28,7 @@ export const useMarkdown = (fileUrl: string) => {
(renderer as any).heading = (text: string, level: number, raw: string) => {
let headingContent = text;
if (typeof text !== 'string') {
headingContent = marked.parseInline(String(raw || ''));
headingContent = marked.parseInline(String(raw || ''), { async: false }) as string;
}
const id = rendererSlugger.slug(raw || text);
return `<h${level} id="${id}" class="scroll-mt-24">${headingContent}</h${level}>`;
+3 -2
View File
@@ -16,8 +16,9 @@ export const getInitialPopulationHistory = (totalPopulation: number): Population
};
});
// Set the last point to the current total population
if (history.length > 0) {
history[history.length - 1].population = totalPopulation;
const lastPoint = history[history.length - 1];
if (lastPoint) {
lastPoint.population = totalPopulation;
}
return history;
};
+4 -4
View File
@@ -41,10 +41,10 @@ export const initialFeedingEvents: FeedingEvent[] = (() => {
const type: 'AI Recommended' | 'Manual' = Math.random() < 0.3 ? 'AI Recommended' : 'Manual';
const operator =
operators.length > 0
? operators[Math.floor(Math.random() * operators.length)]
: { id: 'user-3', name: 'Siti Aminah' };
const operator = operators[Math.floor(Math.random() * operators.length)] ?? {
id: 'user-3',
name: 'Siti Aminah',
};
const quantityKg = 250 + Math.floor(Math.random() * 100);
events.push({
+3 -3
View File
@@ -40,8 +40,8 @@ export const initialVetVisits: VetVisit[] = (() => {
status = Math.random() < 0.8 ? 'Completed' : 'Canceled';
}
const coop = coops[Math.floor(Math.random() * coops.length)];
const vet = vets[Math.floor(Math.random() * vets.length)];
const coop = coops[Math.floor(Math.random() * coops.length)]!;
const vet = vets[Math.floor(Math.random() * vets.length)]!;
visits.push({
id: `vet-${i + 1}`,
@@ -49,7 +49,7 @@ export const initialVetVisits: VetVisit[] = (() => {
coopName: coop.name,
vetId: vet.id,
vetName: vet.name,
purpose: purposes[Math.floor(Math.random() * purposes.length)],
purpose: purposes[Math.floor(Math.random() * purposes.length)]!,
scheduledTime: visitTime,
status: status,
notes:
+369 -230
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -27,6 +27,7 @@
]
},
"dependencies": {
"html2canvas": "^1.4.1",
"jspdf": "^4.2.1",
"lucide-react": "^0.468.0",
"marked": "^13.0.2",
+5 -5
View File
@@ -185,11 +185,11 @@ Untuk hari ini, fokus analisis utama Anda adalah: "${selectedFocus}".
Analisis seluruh seksi berdasarkan sudut pandang fokus utama ini. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. Dilarang keras berhalusinasi atau menambahkan angka fiktif.
PEDOMAN PER SEKSI:
- hitung_ayam: Analisis populasi, mortalitas, trend jumlah ayam hidup vs DOC in.
- hitung_ayam: Analisis populasi dan mortalitas HANYA berdasarkan perhitungan manual (Total populasi awal - mortality - panen). JANGAN mengandalkan data populasi dari IoT. DILARANG KERAS menyertakan data atau analisis terkait FCR, EEF, atau konsumsi pakan (sack) di seksi ini. Fokus hanya pada jumlah ayam, mortalitas, panen, dan afkir.
- berat_ayam: Analisis bobot rata-rata vs standar Cobb, ADG (pertambahan berat harian), keseragaman.
- fcr: Analisis Feed Conversion Ratio aktual vs standar Cobb. FCR rendah = efisiensi pakan bagus. Berikan rekomendasi konkret jika FCR buruk.
- eef: Analisis European Efficiency Factor (EEF/IP). EEF dihitung dari: (% ayam hidup × bobot panen rata-rata) / (FCR × umur panen × 100). Nilai EEF >300 sangat baik, 250-300 baik, <250 perlu perhatian. Berikan interpretasi dan saran peningkatan.
- panel_iot: Analisis kondisi lingkungan kandang (suhu, kelembaban, amonia, CO2) dan dampaknya ke performa ayam.
- panel_iot: Analisis kondisi lingkungan kandang (suhu, kelembaban, CO2) dan dampaknya ke performa ayam. JANGAN membahas amonia — sensornya tidak dipakai dan angkanya tidak dikirim.
- data_quality: Evaluasi kualitas dan kelengkapan data yang tersedia.
Buat aksi (actions) yang SPESIFIK dan DAPAT LANGSUNG DIPRAKTIKKAN oleh peternak, bukan saran umum.
@@ -212,7 +212,7 @@ export const generateReportSummary = async (dashboardData: string): Promise<stri
${dashboardData}
`;
const response = await apiClient.proxyChatPrompt(
const response = await apiClient.proxyLmStudio(
prompt,
'Anda adalah seorang analis peternakan ayam ahli untuk Charoen Pokphand.'
);
@@ -275,7 +275,7 @@ export const generateWeighingAnalysis = async (
${dataSummary}
`;
const response = await apiClient.proxyChatPrompt(
const response = await apiClient.proxyLmStudio(
prompt,
'Anda adalah seorang ahli nutrisi dan kesehatan peternakan ayam broiler untuk Charoen Pokphand.'
);
@@ -307,7 +307,7 @@ export const generateDashboardInsight = async (data: DashboardInsightData): Prom
const requestPromise = (async () => {
try {
const prompt = buildDashboardInsightPrompt(contextText);
const res = await apiClient.proxyChatPrompt(
const res = await apiClient.proxyLmStudio(
prompt,
'Anda adalah analis operasional peternakan ayam broiler CP 707 (PT Charoen Pokphand Indonesia). Analisis HANYA berdasarkan standar buku CP 707 edisi Juli 2023. Kembalikan HANYA format JSON valid. Dilarang memberikan penjelasan di luar JSON.',
0.1
+14 -14
View File
@@ -226,10 +226,11 @@ async function fetchLiveIotSensorRange(
});
// Extract time part (format is "HH:MM" or "MM/DD/YYYY, HH:MM")
const timePart = wibTimeString.includes(',') ? wibTimeString.split(', ')[1] : wibTimeString;
const timePart =
(wibTimeString.includes(',') ? wibTimeString.split(', ')[1] : wibTimeString) ?? '';
// Round to 5-minute intervals for aggregation
const [hourStr, minuteStr] = timePart.split(':');
const [hourStr = '0', minuteStr = '0'] = timePart.split(':');
const hour = parseInt(hourStr, 10);
const minute = Math.floor(parseInt(minuteStr, 10) / 5) * 5;
@@ -246,13 +247,12 @@ async function fetchLiveIotSensorRange(
[key: string]: { Suhu: number[]; Kelembapan: number[]; Amonia: number[] };
} = {};
transformedData.forEach((item) => {
if (!aggregated[item.time]) {
aggregated[item.time] = { Suhu: [], Kelembapan: [], Amonia: [] };
}
aggregated[item.time].Suhu.push(item.Suhu);
aggregated[item.time].Kelembapan.push(item.Kelembapan);
aggregated[item.time].Amonia.push(item.Amonia);
transformedData.forEach((item: (typeof transformedData)[number]) => {
const bucket =
aggregated[item.time] ?? (aggregated[item.time] = { Suhu: [], Kelembapan: [], Amonia: [] });
bucket.Suhu.push(item.Suhu);
bucket.Kelembapan.push(item.Kelembapan);
bucket.Amonia.push(item.Amonia);
});
// Calculate averages for each time slot
@@ -265,8 +265,8 @@ async function fetchLiveIotSensorRange(
// Sort by time
result.sort((a, b) => {
const [aHour, aMin] = a.time.split(':').map(Number);
const [bHour, bMin] = b.time.split(':').map(Number);
const [aHour = 0, aMin = 0] = a.time.split(':').map(Number);
const [bHour = 0, bMin = 0] = b.time.split(':').map(Number);
return aHour * 60 + aMin - (bHour * 60 + bMin);
});
@@ -327,7 +327,7 @@ function transformFeedSackData(apiData: any[]): {
.split('_')
.slice(0, 2)
.join(' ')
.replace(/^\w/, (c) => c.toUpperCase());
.replace(/^\w/, (c: string) => c.toUpperCase());
cameraData.records.forEach((record: any) => {
entries.push({
@@ -829,7 +829,7 @@ export const apiClient = {
mode: 'yesterday',
range_size: 50.0,
data: {
tanggal: new Date().toISOString().split('T')[0],
tanggal: new Date().toISOString().split('T')[0] ?? '',
total_predictions: 150,
total_chicken_count: initialWeightDistribution.reduce((sum, item) => sum + item.count, 0),
distribusi: initialWeightDistribution.map((item) => ({
@@ -958,7 +958,7 @@ export const apiClient = {
);
mockDataArray.push({
tanggal: currentDate.toISOString().split('T')[0],
tanggal: currentDate.toISOString().split('T')[0] ?? '',
total_predictions: 100 + Math.floor(Math.random() * 100),
total_chicken_count: totalChickenCount + Math.floor(Math.random() * 200 - 100),
distribusi: initialWeightDistribution.map((item) => ({
+2 -2
View File
@@ -59,7 +59,7 @@ class PerformanceService {
new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
if (entries.length > 0) {
this.metrics.lcp = entries[entries.length - 1].startTime;
this.metrics.lcp = entries[entries.length - 1]?.startTime ?? null;
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
}
@@ -76,7 +76,7 @@ class PerformanceService {
new PerformanceObserver((entryList) => {
for (const entry of entryList.getEntries()) {
if (!(entry as LayoutShift).hadRecentInput) {
this.metrics.cls += (entry as LayoutShift).value;
this.metrics.cls = (this.metrics.cls ?? 0) + (entry as LayoutShift).value;
}
}
}).observe({ type: 'layout-shift', buffered: true });
+35 -6
View File
@@ -4,9 +4,35 @@
set -e
RAG_DIR="/Users/alfredaluthfihermana/Documents/MAGANG/AI Insight"
BACKEND_DIR="/Users/alfredaluthfihermana/Documents/MAGANG/AI INSIGHT WEB/backend"
FRONTEND_DIR="/Users/alfredaluthfihermana/Documents/MAGANG/AI INSIGHT WEB"
# Semua path diturunkan dari lokasi skrip ini, bukan ditulis mutlak, supaya
# repo ini bisa di-clone ke folder mana pun di komputer siapa pun. Sebelumnya
# ketiganya menunjuk ke /Users/<satu orang>/..., jadi skrip ini pasti gagal di
# mesin lain.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Layanan RAG ada DI DALAM repo ini (lengkap dengan chroma_db dan documents),
# jadi tidak perlu folder tetangga di luar repo.
RAG_DIR="$SCRIPT_DIR/AI Insight"
BACKEND_DIR="$SCRIPT_DIR/backend"
FRONTEND_DIR="$SCRIPT_DIR"
# `set -e` membuat cd yang gagal berhenti tanpa penjelasan. Periksa dulu supaya
# pesannya bisa dimengerti.
for dir in "$RAG_DIR" "$BACKEND_DIR"; do
if [ ! -d "$dir" ]; then
echo "❌ Folder tidak ditemukan: $dir"
echo " Jalankan skrip ini dari dalam repo hasil clone, jangan disalin keluar."
exit 1
fi
done
if [ ! -f "$BACKEND_DIR/.env" ]; then
echo "⚠️ $BACKEND_DIR/.env belum ada."
echo " Salin dari contohnya dulu: cp backend/.env.example backend/.env"
echo " Tanpa itu backend tidak tahu alamat database dan model LLM."
exit 1
fi
echo "====================================================="
echo " AI Insight - Starting All Services"
@@ -58,10 +84,13 @@ sleep 3
echo ""
echo "====================================================="
echo " All services started!"
echo " 🌐 Frontend: http://localhost:5173 (atau 3000)"
echo " 🔧 Backend API: http://localhost:5001"
# Vite sering jatuh ke port lain kalau 3000 dipakai proses lain, jadi baca
# port yang benar-benar dipilih daripada menebak.
FRONTEND_URL="$(grep -m1 -o 'http://localhost:[0-9]*' /tmp/frontend.log 2>/dev/null || true)"
echo " 🌐 Frontend: ${FRONTEND_URL:-lihat /tmp/frontend.log}"
echo " 🔧 Backend API: http://localhost:5001 (health: /health)"
echo " 🧠 RAG Service: http://localhost:5002"
echo " 🤖 LM Studio: http://localhost:1234 (manual)"
echo " 🤖 Ollama: http://localhost:11434 (docker compose up -d database llm)"
echo "====================================================="
echo ""
echo "To stop all: pkill -f 'rag_service.py'; pkill -f 'node server.js'; pkill -f 'vite'"
+4 -1
View File
@@ -6,7 +6,10 @@
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"types": ["node"],
/* `vitest/globals` supplies describe/it/beforeAll (the suites run with
`globals: true`); `@testing-library/jest-dom` supplies the matchers the
setup file registers, e.g. toBeInTheDocument. */
"types": ["node", "vitest/globals", "@testing-library/jest-dom"],
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import type { FeedSackColumnConfig } from '../../types/counting.ts';
/**
* The classification the insight context now uses: camera -> type comes from
* the saved column configuration, and a camera absent from it is not feed-sack
* data at all. Mirrors `cameraTypeByName` / `sumFeedRows` in
* FeedSackCountingPage.
*/
const classify = (
logs: Array<{ cameraName: string; count: number }>,
config: FeedSackColumnConfig[]
) => {
const byName = new Map(config.map((c) => [c.cameraName, c.type]));
const totals = { masuk: 0, dituang: 0, keluar: 0, diabaikan: 0 };
for (const log of logs) {
const type = byName.get(log.cameraName);
if (type === 'inbound') totals.masuk += log.count;
else if (type === 'usage') totals.dituang += log.count;
else if (type === 'outbound') totals.keluar += log.count;
else totals.diabaikan += log.count;
}
return totals;
};
// Exactly what feed_sack_column_config holds in the database.
const CONFIG: FeedSackColumnConfig[] = [
{
id: 'a',
label: 'Karung Masuk',
cameraName: 'kandang_1_karung_masuk',
color: '#22c55e',
type: 'inbound',
},
{
id: 'b',
label: 'Karung Dituang Kandang Atas',
cameraName: 'karung_tuang_feeder_kiri_atas',
color: '#f97316',
type: 'usage',
},
{
id: 'c',
label: 'Karung Dituang Kandang Bawah',
cameraName: 'karung_tuang_feeder_kanan_bawah',
color: '#a855f7',
type: 'usage',
},
{
id: 'd',
label: 'Karung Keluar',
cameraName: 'kandang_1_karung_masuk_out',
color: '#3b82f6',
type: 'outbound',
},
];
// Real totals from the live API for cycle days 43-49 (3-9 Jul 2026).
const WEEK_43_49 = [
{ cameraName: 'karung_tuang_feeder_kanan_bawah', count: 11 },
{ cameraName: 'karung_tuang_feeder_kiri_atas', count: 10 },
{ cameraName: 'kandang_1_karung_masuk_out', count: 0 },
{ cameraName: 'kandang_1_karung_masuk', count: 14 },
{ cameraName: 'kandang_atas_feeder_kiri', count: 1057 },
{ cameraName: 'kandang_bawah_feeder_kanan', count: 1127 },
];
describe('feed sack classification', () => {
it('counts only configured cameras', () => {
const t = classify(WEEK_43_49, CONFIG);
expect(t.dituang).toBe(21); // was 2205 when unconfigured cameras leaked in
expect(t.masuk).toBe(14);
expect(t.keluar).toBe(0);
expect(t.diabaikan).toBe(2184);
});
it('gives a believable daily average', () => {
const t = classify(WEEK_43_49, CONFIG);
expect(Math.round(t.dituang / 7)).toBe(3); // was 315 karung/hari
});
it('files kandang_1_karung_masuk_out as outbound, not inbound', () => {
// The old substring rule matched "karung_masuk" inside "..._out".
const t = classify([{ cameraName: 'kandang_1_karung_masuk_out', count: 387 }], CONFIG);
expect(t.keluar).toBe(387);
expect(t.masuk).toBe(0);
});
it('ignores a camera that is not feed-sack data', () => {
const t = classify([{ cameraName: 'kandang_atas_feeder_kiri', count: 7695 }], CONFIG);
expect(t.dituang).toBe(0);
expect(t.diabaikan).toBe(7695);
});
});
/** Manual entries for the same cameras, days 43-49 (3-9 Jul 2026). */
const MANUAL_43_49 = [
{ cameraName: 'kandang_1_karung_masuk', count: 8 }, // 3 Jul
{ cameraName: 'kandang_1_karung_masuk', count: 6 }, // 6 Jul
{ cameraName: 'kandang_1_karung_masuk', count: 2 }, // 7 Jul
];
describe('manual figures shown beside the camera figures', () => {
it('totals manual separately from IOT', () => {
const iot = classify(WEEK_43_49, CONFIG);
const manual = classify(MANUAL_43_49, CONFIG);
expect(iot.masuk).toBe(14);
expect(manual.masuk).toBe(16);
});
it('never merges the two into one number', () => {
// Both count the same sacks; 30 would be double-counting.
const iot = classify(WEEK_43_49, CONFIG);
const manual = classify(MANUAL_43_49, CONFIG);
expect(iot.masuk + manual.masuk).toBe(30);
expect(iot.masuk).not.toBe(30);
expect(manual.masuk).not.toBe(30);
});
it('reports zero manual for a camera with no entries in the window', () => {
const manual = classify(MANUAL_43_49, CONFIG);
expect(manual.dituang).toBe(0); // manual "dituang" entries stop at 2 Jul
});
});
@@ -0,0 +1,57 @@
import { describe, it, expect } from 'vitest';
import { scopeScalarsToTimeframe, UNAVAILABLE } from '../insightScope.ts';
/**
* Real shape of the FCR page's 7-day history for this cycle: the IoT scale
* stopped reporting at day 38 and has written 0 ever since, and the flock was
* fully harvested on day 48 so `ayam` ends at 0 too.
*/
const ROWS = [
{ day: 42, fcr: 1.8, pakan: 1064, bobot_kg: 0, ayam: 1424 },
{ day: 44, fcr: 1.81, pakan: 1067, bobot_kg: 0, ayam: 1314 },
{ day: 46, fcr: 1.82, pakan: 1072, bobot_kg: 0, ayam: 1253 },
{ day: 47, fcr: 1.78, pakan: 1073, bobot_kg: 1.38, ayam: 822 },
{ day: 48, fcr: 1.7, pakan: 1073, bobot_kg: 0, ayam: 0 },
];
const spec = (agg: 'last' | 'lastValid') => ({
historyKey: 'tren',
derive: {
bobot_kg_out: { field: 'bobot_kg', agg },
ayam_out: { field: 'ayam', agg },
},
});
describe('lastValid aggregation', () => {
it('last takes the trailing zero — the reported bug', () => {
const out = scopeScalarsToTimeframe({ tren: ROWS }, 'mingguan', spec('last'));
expect(out.bobot_kg_out).toBe(0);
expect(out.ayam_out).toBe(0);
});
it('lastValid walks back past the zeros', () => {
const out = scopeScalarsToTimeframe({ tren: ROWS }, 'mingguan', spec('lastValid'));
expect(out.bobot_kg_out).toBe(1.38);
expect(out.ayam_out).toBe(822);
});
it('reports unavailable when every reading in the window is zero', () => {
const allZero = [
{ day: 47, bobot_kg: 0, ayam: 0 },
{ day: 48, bobot_kg: 0, ayam: 0 },
];
const out = scopeScalarsToTimeframe({ tren: allZero }, 'mingguan', spec('lastValid'));
expect(out.bobot_kg_out).toBe(UNAVAILABLE);
expect(out.ayam_out).toBe(UNAVAILABLE);
});
it('leaves a genuine non-zero final reading alone', () => {
const rows = [
{ day: 47, bobot_kg: 1.3, ayam: 900 },
{ day: 48, bobot_kg: 1.45, ayam: 850 },
];
const out = scopeScalarsToTimeframe({ tren: rows }, 'mingguan', spec('lastValid'));
expect(out.bobot_kg_out).toBe(1.45);
expect(out.ayam_out).toBe(850);
});
});
+6 -3
View File
@@ -67,9 +67,12 @@ export const generateBehaviorChartData = (
if (diffDays >= 0 && diffDays < cycleData.totalDays) {
const weekNumber = Math.floor(diffDays / 7) + 1;
if (weekNumber > 0 && weekNumber <= totalWeeks) {
weeklyAverages[`M${weekNumber}`].totalActivity += point.activity;
weeklyAverages[`M${weekNumber}`].totalStress += point.stress;
weeklyAverages[`M${weekNumber}`].count++;
const week = weeklyAverages[`M${weekNumber}`];
if (week) {
week.totalActivity += point.activity;
week.totalStress += point.stress;
week.count++;
}
}
}
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Environment standards from the CP 707 book, for client-side charting.
*
* Mirrors `TEMPERATURE_STANDARD` and `AIR_QUALITY_STANDARD` in
* `backend/services/cp707Knowledge.js` — the backend copy stays authoritative
* for anything sent to the model; this one exists only so the IoT panel can
* draw a comparison without a round trip. Keep the two in sync when the book
* edition changes.
*/
/** Target suhu pemeliharaan per umur (buku CP 707). */
const TEMPERATURE_STANDARD = [
{ from: 1, to: 2, temp_C: 32 },
{ from: 3, to: 4, temp_C: 31 },
{ from: 5, to: 7, temp_C: 30 },
{ from: 8, to: 14, temp_C: 29 },
{ from: 15, to: 21, temp_C: 28 },
{ from: 22, to: 28, temp_C: 26 },
{ from: 29, to: 35, temp_C: 23 },
{ from: 36, to: 99, temp_C: 22 },
];
/** Kelembapan ideal sama untuk semua umur menurut tabel buku: 50–70 %. */
export const HUMIDITY_RANGE_PCT = { min: 50, max: 70 };
/** Standar kualitas udara (Lampiran 3). */
export const AIR_QUALITY = {
/** Amonia: ideal <10 ppm, batas kritis >25 ppm. */
ammoniaSafe_ppm: 10,
ammoniaCritical_ppm: 25,
/** CO2: ideal <3000 ppm, kritis >3500 ppm. */
co2Ideal_ppm: 3000,
};
/**
* 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.
*/
export const getTempStandardByDay = (dayAge: number | null | undefined): number | null => {
if (typeof dayAge !== 'number' || !Number.isFinite(dayAge) || dayAge <= 0) return null;
const row = TEMPERATURE_STANDARD.find((r) => dayAge >= r.from && dayAge <= r.to);
return row ? row.temp_C : null;
};
/**
* Number out of a display-formatted reading.
*
* `computeDashboard` hands back strings like "32,1 °C" and "62,8 %" — already
* scaled from the raw sensor encoding, which is why they are the safe source —
* so the chart has to strip the unit and accept the Indonesian decimal comma.
* A plain number passes through unchanged.
*/
export const parseReading = (value: unknown): number | null => {
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
if (typeof value !== 'string' || value.trim() === '') return null;
const cleaned = value
.replace(/[^\d,.-]/g, '')
.replace(/\.(?=\d{3}\b)/g, '')
.replace(',', '.');
const n = Number(cleaned);
return Number.isFinite(n) ? n : null;
};
+4 -3
View File
@@ -1,4 +1,5 @@
import { cobbStandardPerformance } from '../mockData/cobbStandard.ts';
import { getTargetWeightForDay } from './weightUtils.ts';
/**
* Analyze trend of numerical data over a window
@@ -34,7 +35,7 @@ export function analyzeTrend(data: number[], windowSize = 7): string {
let denominator = 0;
for (let i = 0; i < n; i++) {
numerator += (i - xMean) * (recentData[i] - yMean);
numerator += (i - xMean) * ((recentData[i] ?? 0) - yMean);
denominator += Math.pow(i - xMean, 2);
}
@@ -79,7 +80,7 @@ export function getCobbStandardForDay(day: number): { fcr: number; weight: numbe
if (standard) {
return {
fcr: standard.fcr,
weight: standard.weight,
weight: getTargetWeightForDay(day) ?? 0,
};
}
return null;
@@ -159,7 +160,7 @@ export function getEnvironmentalStatus(
if (issues.length === 0) {
return 'optimal';
} else if (issues.length === 1) {
return issues[0];
return issues[0] ?? 'optimal';
} else {
return issues.join(', ');
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Turns whatever JSON the local model returns into {kesimpulan, insight}.
*
* The models routinely invent their own field names — `tren_mortalitas_harian`,
* `dampak_populasi`, `rekomendasi_tindakan` instead of `kesimpulan`/`insight`.
* The old per-page parser answered that by substituting a cheerful default
* ("Analisis operasional kandang terpantau dengan baik."), which could mask a
* report of total flock loss, and the counting page answered it by discarding
* the response outright.
*
* Rule here: never invent reassuring text, and never silently drop content. Any
* field the model produced is folded into the output; only a response with no
* usable text at all returns null, so the UI can show a real error instead.
*/
export interface AiResult {
kesimpulan: string;
insight: string;
}
/** Field names seen standing in for the conclusion. */
const KESIMPULAN_KEYS = [
'kesimpulan',
'kesimpulan_analisis',
'ringkasan',
'headline',
'summary',
'conclusion',
];
/** Field names seen standing in for the recommendation. */
const INSIGHT_KEYS = [
'insight',
'insights',
'rekomendasi',
'rekomendasi_tindakan',
'tindakan',
'saran',
'actions',
'recommendations',
];
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
/** Turns a leftover field name into a readable label: `tren_mortalitas_harian` -> `Tren mortalitas harian`. */
const humanizeKey = (key: string) => {
const spaced = key.replace(/[_-]+/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
};
/** Flattens any value into readable plain text. Arrays become bullet lines. */
const renderValue = (value: unknown, depth = 0): string => {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) {
return value
.map((item) => renderValue(item, depth + 1))
.filter((item) => item.length > 0)
.map((item) => (depth === 0 ? `• ${item}` : item))
.join('\n');
}
if (isPlainObject(value)) {
return Object.entries(value)
.map(([key, nested]) => {
const rendered = renderValue(nested, depth + 1);
return rendered ? `${humanizeKey(key)}: ${rendered}` : '';
})
.filter((line) => line.length > 0)
.join('\n');
}
return '';
};
const takeFirst = (source: Record<string, unknown>, keys: string[]) => {
for (const key of keys) {
if (key in source) {
const rendered = renderValue(source[key]);
if (rendered) return { key, text: rendered };
}
}
return null;
};
/** Strips think-tags, code fences, comments and trailing commas, then isolates the JSON object. */
const extractJsonObject = (text: string): string | null => {
const cleaned = text
.replace(/<think>[\s\S]*?<\/think>/gi, '')
.replace(/^```(?:json)?\s*/i, '')
.replace(/\s*```\s*$/, '')
.replace(/\/\/.*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/,\s*([}\]])/g, '$1')
.trim();
const first = cleaned.indexOf('{');
const last = cleaned.lastIndexOf('}');
if (first === -1 || last === -1 || last <= first) return null;
return cleaned.slice(first, last + 1);
};
export const parseAiResult = (text: string): AiResult | null => {
if (!text || typeof text !== 'string') return null;
const candidate = extractJsonObject(text);
if (!candidate) return null;
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
return null;
}
if (!isPlainObject(parsed)) return null;
const used = new Set<string>();
const kesimpulanHit = takeFirst(parsed, KESIMPULAN_KEYS);
if (kesimpulanHit) used.add(kesimpulanHit.key);
const insightHit = takeFirst(parsed, INSIGHT_KEYS);
if (insightHit) used.add(insightHit.key);
// Anything the model invented a name for still carries its analysis, so it is
// appended under a readable label rather than thrown away.
const leftovers = Object.entries(parsed)
.filter(([key]) => !used.has(key))
.map(([key, value]) => {
const rendered = renderValue(value);
return rendered ? `${humanizeKey(key)}: ${rendered}` : '';
})
.filter((line) => line.length > 0);
let kesimpulan = kesimpulanHit?.text ?? '';
let insight = insightHit?.text ?? '';
if (!kesimpulan && leftovers.length > 0) {
// Promote the first salvaged field to the conclusion so the card is never
// headed by a generic sentence the model never wrote.
kesimpulan = leftovers.shift() as string;
}
if (leftovers.length > 0) {
insight = [insight, ...leftovers].filter(Boolean).join('\n\n');
}
if (!kesimpulan && !insight) return null;
return {
kesimpulan: kesimpulan || 'Model tidak mengembalikan kesimpulan eksplisit.',
insight: insight || 'Model tidak mengembalikan rekomendasi eksplisit.',
};
};
+222
View File
@@ -0,0 +1,222 @@
/**
* Narrows a per-page insight's context data down to the period the user picked.
*
* The per-page AI Insight used to send the whole cycle to the model no matter
* which timeframe was selected, and only changed a label in the prompt — so
* "Harian" and "Mingguan" produced near-identical answers. This trims the data
* itself, which is the only thing the model actually reads.
*/
export type InsightTimeframe = 'harian' | 'mingguan';
/**
* Rows of a day-indexed history carry a day number and/or a date.
*
* Pages are inconsistent about the field name: the dashboard builds `day`/`date`
* while the FCR and EEF pages build `hari`/`tanggal`. Recognising only the
* English spelling meant those two pages' histories were never sliced at all —
* their timeframe selector changed nothing.
*/
type DayRow = Record<string, unknown> & {
day?: unknown;
hari?: unknown;
date?: unknown;
tanggal?: unknown;
};
const DAY_KEYS = ['day', 'hari'] as const;
const DATE_KEYS = ['date', 'tanggal'] as const;
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const dayOf = (row: DayRow): number | null => {
for (const key of DAY_KEYS) {
const value = row[key];
if (typeof value === 'number' && Number.isFinite(value)) return value;
}
return null;
};
const hasDateField = (row: DayRow): boolean =>
DATE_KEYS.some((key) => typeof row[key] === 'string');
/**
* A history indexed by time, as opposed to a snapshot list (cameras, devices,
* coops) that has no time dimension. Only the former may be sliced by period —
* trimming a device list to "today" would silently hide devices.
*/
const isDayIndexedArray = (value: unknown[]): value is DayRow[] =>
value.length > 0 &&
value.every(
(item) =>
isPlainObject(item) && (dayOf(item as DayRow) !== null || hasDateField(item as DayRow))
);
const sliceHistory = (rows: DayRow[], timeframe: InsightTimeframe): 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);
}
// Date-only history: the last row is the most recent one.
return rows.slice(-1);
}
if (days.length > 0) {
const latestDay = Math.max(...days);
// The 7-day window ending today, matched on each row's own `day` so gaps in
// the data do not shift the window.
return rows.filter((row) => {
const day = dayOf(row);
return day !== null && day > latestDay - 7 && day <= latestDay;
});
}
return rows.slice(-7);
};
/**
* 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 => {
if (Array.isArray(contextData)) {
const scoped = isDayIndexedArray(contextData)
? sliceHistory(contextData as DayRow[], timeframe)
: contextData;
return scoped.map((item) => scopeContextToTimeframe(item, timeframe)) 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);
}
return scoped as unknown as T;
}
return contextData;
};
export const UNAVAILABLE = 'tidak tersedia';
/**
* How one page's summary scalars relate to its day-indexed history.
*
* `derive` maps an OUTPUT key — which must carry its unit, e.g.
* `bobotRataRata_gram` — to the history field it is computed from and how to
* aggregate it across the window. `drop` lists the original unitless keys the
* derived ones replace; leaving them in would hand the model both the stale
* figure and the period one and let it pick. `unavailable` names scalars with
* no day-indexed source at all: they are marked rather than left showing a
* value from a different period.
*/
export type ScalarScopeSpec = {
historyKey: string;
/**
* `replaces` names the original key this derived value stands in for. The
* prompt drops the original, but the card's summary grid still renders it —
* without the link the grid kept showing cycle-to-date figures beside a
* narrative scoped to one period, which reads as the toggle doing nothing.
*/
derive: Record<
string,
{
field: string;
/**
* `last` takes the final row verbatim. `lastValid` walks backwards past
* zeros — use it for readings where 0 means "the sensor sent nothing",
* not "the measurement was zero". The IoT scale stopped reporting at day
* 38 and has written 0 every day since, so `last` handed the card
* "Rata-rata Bobot 0 kg" while the page beside it showed the real figure.
*/
agg: 'mean' | 'sum' | 'last' | 'lastValid';
replaces?: string;
}
>;
drop?: string[];
unavailable?: string[];
};
const round2 = (value: number) => Math.round(value * 100) / 100;
const aggregate = (rows: DayRow[], field: string, agg: 'mean' | 'sum' | 'last' | 'lastValid') => {
const values = rows
.map((row) => row[field])
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
if (values.length === 0) return null;
if (agg === 'lastValid') {
// Mirrors `getLatestValid` on the FCR/EEF pages, so the insight card and
// the page it sits on cannot report different numbers. All-zero means the
// reading is missing, which has to surface as "tidak tersedia" rather than
// a confident 0.
for (let i = values.length - 1; i >= 0; i--) {
const value = values[i];
if (value !== undefined && value !== 0) return value;
}
return null;
}
if (agg === 'last') return values[values.length - 1] ?? null;
const total = values.reduce((sum, value) => sum + value, 0);
return round2(agg === 'sum' ? total : total / values.length);
};
/**
* Bring a per-page context's scalars into the same period as its history.
*
* Slicing the history alone is not enough: a "Mingguan" payload whose chart
* covers days 42-48 while `averageWeight` still holds the day-48 figure lets
* the model quote either, which is the exact contradiction already fixed on the
* dashboard. Run this AFTER `scopeContextToTimeframe`, on the sliced context.
*/
export const scopeScalarsToTimeframe = (
scopedContext: Record<string, unknown>,
timeframe: InsightTimeframe,
spec: ScalarScopeSpec
): Record<string, unknown> => {
const history = scopedContext[spec.historyKey];
if (!Array.isArray(history)) return scopedContext;
const rows = history as DayRow[];
const out: Record<string, unknown> = { ...scopedContext };
for (const key of spec.drop ?? []) delete out[key];
const days = rows.map(dayOf).filter((day): day is number => day !== null);
const label =
days.length === 0
? UNAVAILABLE
: timeframe === 'harian'
? `hari ke-${Math.max(...days)}`
: `hari ke-${Math.min(...days)} s/d ke-${Math.max(...days)}`;
for (const [outKey, rule] of Object.entries(spec.derive)) {
const value = aggregate(rows, rule.field, rule.agg);
out[outKey] = value === null ? UNAVAILABLE : value;
}
for (const key of spec.unavailable ?? []) out[key] = UNAVAILABLE;
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. ';
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 ` +
`(_gram, _persen, _rasio, _karung, _ekor, _kg) — pakai satuan itu persis. ` +
`Field berisi "${UNAVAILABLE}" memang tidak ada datanya: tulis "data tidak tersedia" ` +
`dan JANGAN mengarang angkanya.`;
return out;
};
/** Human-readable description of the window, for the prompt and the UI. */
export const describeTimeframe = (timeframe: InsightTimeframe, currentDay?: number | null) => {
if (timeframe === 'harian') {
return currentDay ? `Hari ini saja (hari ke-${currentDay})` : 'Hari ini saja (hari terakhir)';
}
return currentDay
? `7 hari terakhir (hari ke-${Math.max(1, currentDay - 6)} sampai hari ke-${currentDay})`
: '7 hari terakhir';
};
+4 -3
View File
@@ -14,9 +14,10 @@ export class CustomSlugger {
.replace(/[^\w-]+/g, '')
.replace(/--+/g, '-');
if (this.seen[slug]) {
this.seen[slug]++;
slug = `${slug}-${this.seen[slug] - 1}`;
const seenCount = this.seen[slug];
if (seenCount) {
this.seen[slug] = seenCount + 1;
slug = `${slug}-${seenCount}`;
} else {
this.seen[slug] = 1;
}
+111
View File
@@ -0,0 +1,111 @@
import { jsPDF } from 'jspdf';
import html2canvas from 'html2canvas';
import { revealPdfHeaders } from '../components/shared/PdfReportHeader.tsx';
/**
* Render a card to a multi-page A4 PDF without slicing through its contents.
*
* The previous approach placed ONE tall image on every page at a shifting
* negative offset, so a page break landed wherever 277mm happened to fall —
* straight through the middle of a chart. This version cuts the canvas at
* declared block boundaries instead: mark the parts that must stay whole with
* `data-pdf-block`, and a break is pushed up to the nearest boundary that fits.
*
* Boundaries are measured inside `onclone`, because html2canvas lays the clone
* out in its own iframe at `windowWidth` — offsets taken from the live DOM would
* describe a different layout.
*/
export const exportCardToPdf = async (
element: HTMLElement,
filename: string,
options?: { widthPx?: number }
): Promise<void> => {
const widthPx = options?.widthPx ?? 800;
const scale = 2;
/** Offsets, in cloned-CSS pixels, where a page may safely break. */
const safeBreaks: number[] = [];
const canvas = await html2canvas(element, {
scale,
useCORS: true,
backgroundColor: '#ffffff',
windowWidth: widthPx,
onclone: (_clonedDoc, clonedElement) => {
clonedElement.style.width = `${widthPx}px`;
clonedElement.style.maxWidth = `${widthPx}px`;
clonedElement.style.margin = '0';
revealPdfHeaders(clonedElement);
// Side-by-side small multiples do not fit an A4 column; stack them so
// each chart keeps a readable width in print.
clonedElement.querySelectorAll<HTMLElement>('[data-pdf-stack]').forEach((node) => {
node.style.display = 'block';
});
const top = clonedElement.getBoundingClientRect().top;
clonedElement.querySelectorAll<HTMLElement>('[data-pdf-block]').forEach((node) => {
const rect = node.getBoundingClientRect();
safeBreaks.push(rect.top - top, rect.bottom - top);
});
},
});
const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' });
const pageWidthMm = doc.internal.pageSize.getWidth();
const pageHeightMm = doc.internal.pageSize.getHeight();
const marginMm = 10;
const contentWidthMm = pageWidthMm - marginMm * 2;
const usableHeightMm = pageHeightMm - marginMm * 2;
const mmPerPx = contentWidthMm / canvas.width;
const usableHeightPx = usableHeightMm / mmPerPx;
// Clone-CSS px -> canvas px, de-duplicated and ordered.
const breakPoints = [...new Set(safeBreaks.map((value) => Math.round(value * scale)))]
.filter((value) => value > 0 && value < canvas.height)
.sort((a, b) => a - b);
let cursor = 0;
let firstPage = true;
while (cursor < canvas.height) {
const remaining = canvas.height - cursor;
let sliceHeight: number;
if (remaining <= usableHeightPx) {
sliceHeight = remaining;
} else {
const limit = cursor + usableHeightPx;
const candidate = [...breakPoints]
.reverse()
.find((point) => point > cursor && point <= limit);
// No declared boundary fits — the block is taller than a page, so fall
// back to a hard cut rather than looping forever.
sliceHeight = candidate ? candidate - cursor : usableHeightPx;
}
const slice = document.createElement('canvas');
slice.width = canvas.width;
slice.height = Math.max(1, Math.round(sliceHeight));
const ctx = slice.getContext('2d');
if (!ctx) break;
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, slice.width, slice.height);
ctx.drawImage(canvas, 0, cursor, canvas.width, slice.height, 0, 0, canvas.width, slice.height);
if (!firstPage) doc.addPage();
doc.addImage(
slice.toDataURL('image/png'),
'PNG',
marginMm,
marginMm,
contentWidthMm,
slice.height * mmPerPx
);
firstPage = false;
cursor += slice.height;
}
doc.save(filename);
};
+8 -4
View File
@@ -1,12 +1,11 @@
import path from 'path';
import { defineConfig, loadEnv } from 'vite';
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
export default defineConfig(() => {
return {
server: {
port: 3000,
port: 3001,
host: '0.0.0.0',
},
plugins: [react()],
@@ -19,6 +18,11 @@ export default defineConfig(({ mode }) => {
globals: true,
environment: 'jsdom',
setupFiles: './tests/setup.ts',
// The backend suite has its own config: node environment, its own .env
// (Postgres on 15432) and serial execution. Swept in here it ran under
// jsdom against the default port and failed wholesale. Run it with
// `npm test` inside backend/.
exclude: ['node_modules/**', 'dist/**', 'backend/**'],
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'json'],