From 2a627a2c3f07b4b05bd2ae5efc9c7635a40adf43 Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Tue, 11 Aug 2026 09:19:06 +0700 Subject: [PATCH] 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. --- .claude/skills/run-app/SKILL.md | 123 ++ .../data_level0.bin | Bin 167600 -> 167600 bytes AI Insight/chroma_db/chroma.sqlite3 | Bin 1884160 -> 1884160 bytes App.tsx | 8 +- CATATAN-LANJUTAN.md | 263 +++ CATATAN_PERBAIKAN_AI_INSIGHT.md | 329 ++++ HANDOVER_AI_INSIGHT.md | 225 +++ README.md | 76 +- SETUP.md | 162 ++ backend/.env.example | 32 +- backend/database/db.js | 31 + .../014_add_report_scope_to_ai_insights.sql | 21 + backend/models/AiInsight.js | 47 +- backend/package-lock.json | 343 ++-- .../__tests__/manualFeedSackDate.test.js | 41 + backend/routes/aiInsights.js | 276 ++- backend/routes/aiInsights.test.js | 54 +- backend/routes/manualFeedSack.js | 199 +- backend/server.js | 34 +- backend/services/cp707Knowledge.js | 32 +- backend/services/dashboardInsightService.js | 1508 ++++++++++++--- backend/services/llmDispatcher.js | 24 + backend/vitest.config.js | 11 +- components/AiInsightCard.tsx | 1205 +++++++++--- components/AppContent.tsx | 10 +- components/ChickenCountingPage.tsx | 67 +- components/EefDashboardPage.tsx | 69 +- components/FcrDashboardPage.tsx | 74 +- components/FeedSackCountingPage.tsx | 208 +- components/IotPanelPage.tsx | 64 +- components/KandangSettingsPage.tsx | 2 +- components/ManualWeighingPage.tsx | 12 +- components/PerformanceDebug.tsx | 9 +- components/WeightMonitoringPageRevamped.tsx | 64 +- components/batch/ServiceMonitor.tsx | 6 +- components/common/ActivityLogPanel.tsx | 2 +- components/common/ErrorBoundary.tsx | 2 +- .../counting/ChickenCountTrendChart.tsx | 5 +- .../counting/ChickenCountingAiInsight.tsx | 764 +++++--- .../counting/ChickenCountingDashboard.tsx | 16 +- components/counting/CombinedTrendChart.tsx | 3 +- components/counting/DataManagementPage.tsx | 76 +- .../counting/LocationCountBreakdown.tsx | 8 +- components/cycle/CycleCreateModal.tsx | 2 +- components/feedSack/FeedDailySummaryTable.tsx | 64 +- .../feedSack/FeedSackComparisonCharts.tsx | 6 +- components/iot/IotDeviceStats.tsx | 2 +- .../manualWeighing/ManualWeighingAnalysis.tsx | 2 +- components/mobile/OperatorTasksScreen.tsx | 2 +- .../scheduling/FeedScheduleFormModal.tsx | 2 +- .../shared/EndCycleWeightVsStandardChart.tsx | 353 ++++ components/shared/InsightConditionChart.tsx | 111 ++ components/shared/InsightStatTiles.tsx | 189 ++ components/shared/InsightTrendChart.tsx | 248 +++ components/shared/PageAiInsight.tsx | 1671 +++++++---------- components/shared/PdfReportHeader.tsx | 253 +++ .../shared/__tests__/PdfReportHeader.test.ts | 102 + components/vet/VetVisitFormModal.tsx | 10 +- .../weight/DailyWeightProgressionChart.tsx | 14 +- .../weight/DeviceWeightComparisonChart.tsx | 1 + components/weight/WeightDistributionChart.tsx | 4 +- context/AppContext.tsx | 68 +- context/stores/useStaticDataStore.ts | 22 +- hooks/useMarkdown.ts | 2 +- mockData/chickenCountingData.ts | 5 +- mockData/feedSchedulingData.ts | 8 +- mockData/vetVisitData.ts | 6 +- package-lock.json | 599 +++--- package.json | 1 + services/aiInsightService.ts | 10 +- services/apiService.ts | 28 +- services/performanceService.ts | 4 +- start_all.sh | 41 +- tsconfig.json | 5 +- .../__tests__/feedSackClassification.test.ts | 124 ++ utils/__tests__/insightScopeAggregate.test.ts | 57 + utils/chartDataUtils.ts | 9 +- utils/cp707EnvStandards.ts | 62 + utils/insightHelpers.ts | 7 +- utils/insightParse.ts | 156 ++ utils/insightScope.ts | 222 +++ utils/markdownUtils.ts | 7 +- utils/pdfExport.ts | 111 ++ vite.config.ts | 12 +- 84 files changed, 8415 insertions(+), 2692 deletions(-) create mode 100644 .claude/skills/run-app/SKILL.md create mode 100644 CATATAN-LANJUTAN.md create mode 100644 CATATAN_PERBAIKAN_AI_INSIGHT.md create mode 100644 HANDOVER_AI_INSIGHT.md create mode 100644 SETUP.md create mode 100644 backend/database/migrations/014_add_report_scope_to_ai_insights.sql create mode 100644 backend/routes/__tests__/manualFeedSackDate.test.js create mode 100644 backend/services/llmDispatcher.js create mode 100644 components/shared/EndCycleWeightVsStandardChart.tsx create mode 100644 components/shared/InsightConditionChart.tsx create mode 100644 components/shared/InsightStatTiles.tsx create mode 100644 components/shared/InsightTrendChart.tsx create mode 100644 components/shared/PdfReportHeader.tsx create mode 100644 components/shared/__tests__/PdfReportHeader.test.ts create mode 100644 utils/__tests__/feedSackClassification.test.ts create mode 100644 utils/__tests__/insightScopeAggregate.test.ts create mode 100644 utils/cp707EnvStandards.ts create mode 100644 utils/insightParse.ts create mode 100644 utils/insightScope.ts create mode 100644 utils/pdfExport.ts diff --git a/.claude/skills/run-app/SKILL.md b/.claude/skills/run-app/SKILL.md new file mode 100644 index 0000000..88d6e92 --- /dev/null +++ b/.claude/skills/run-app/SKILL.md @@ -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 +``` diff --git a/AI Insight/chroma_db/cb38c2ee-b93e-459c-987d-2aa565999112/data_level0.bin b/AI Insight/chroma_db/cb38c2ee-b93e-459c-987d-2aa565999112/data_level0.bin index ef0b1a29e938157a7d322d5b9da882e38e19c063..0beadce249f4e4420aa2d338c5e7522ac5719613 100644 GIT binary patch literal 167600 zcmeI52b9*-x`r7*dhg}Xr1vfYih$CkcThk90YL!~>Ae?6kS+p4R~YFCQe-H~AiX0+ zI_Ln>dzm`}^Tm^xpKski_gQ%E*{r4e-`Uwu-n`k_*)5r9(VqQ_85Q~p+|v&olKtJ& zB{fdD<&R9s@_RR9`5Q8@ z{2|F${@xBOfBsaKKV>P)@3xxdm%hyM14CKzyv6}+GA11r0a z{7vh~9?bLo>Oz*EFc!-{QJLlUhUsbFln+`B;9;`7A#yE6Z=PnB|{a%kpDS zXZfj1vi!uOSpI<#EdP2Umj6`+mY=vS%m1M)%Wt%T<j0O}{QX&fVq{_Y(c$rvHTPNlL)WqVI4`sO^YHlOK)8MTUR~CobnRGv zk!&pg^D!)cKHPq8_8P1I@iCU4b}P$2K91#Q8_M#xG-UaE;Qo7xpIQBjH?aJ?^;mw| z?JU1>ZMeKO0R_#MlS z1N*<=Emr^isw{ue0G2-ko*yh+gw@{z_GcWte&gl?)}K<#SbpDbEWd6RmY)S)Ke-F; zpN17<{aFn6?@F~~^%tGV@?YuC@(Z?L`S0vu`D5Vm!&_5W{l#H_hP}<|-+F=NcV5Ku zyM4{_7hPxh+kRsCvp2K+7+INlK_@~4j#YCS)bM4tRl;g zv6khx9Le%4!{bY>;QF~(Db}AnaQi$y+<$)=p5I)zh4p_YyuN5$UsnI187%)@xP7wz z6IOo!+^$+_DG5U-J^nznYEZKS;~+gW&h$+T>vM5B!|vm+QpxgGaOcuJHK7 zw4|*5617-<_Tw!7XZZby993BTEizVqa$UpS{iUpTP4kV`j1XPs8hb>%;x8 zEB9D`a>Zl$-yLK5{owJ3A9t|&W5Unh*+H!S+i?AG2cDmeHJcO%O$U5Dj2 zfZw0JyNlJIVJged3eS%ZxWnoXf!8O;`<~T*v<=H|bA;t*hwJAm|6=tQ+RgF@!R7N| z5Uc+JJU-Lb8pdPB$Ew9<{dpZ;AJPYYzpqXo)}OWT`)irvu=>Ai&hj&7X8C2{`m+Z7 zep$M8tUts1vHbI`S^lP#EdPUtEWbazexTW4uli+zsDH9SGb87pBL9p0_iW%W8@ztt z#)D@^^LPG9a{J0(FR$#uJb#D4>z7KjV)fU^%zq=>PFTRuIFWJWOPr&O#s@`Yyx9`RBLzA-ngL7Ga^Ghth=Mt8`^a;yv-k;@9 zh1Z7{KgH@#R+;5@Ys~W7$?&C*zmFkD_ccDKrLR~odobgN4U)0^+of23ncOVD`&E{I zqb|#j50}qI(^>tM;PIt3aQ)C79)CD8QaET6u=5{zT@M-OHB2d1+8 zNAUYE@1|q*)AKLM;rZEO@cec0YpnnG;q_yQ;r3gqVXQv~@3H)4yIKC@aF)O4eU|?v zJilBXF8?dzvi{5~$?{*j%<>PcX89xAu>3-uS$_VBEI%Gx{@Vw#`X5|p`IX`IKTF{L z`ylxHz@;*?{%6a?^7p{=zr_-<`cq}m_%bgL^rrp$Jw+liMwiSHq3ri>n<9R$IwEoR zbV>DCDlV2P#*GHUi4~;+WzQ|VNk2v8T8h_kBgnMZGarZ^G8h^wI_t5y`?u%?S{)iLqq4CGv7ujn35hvV3{Bie1wi
    aMYbA$#0mG%_~Y)2Y&HIf6Yin$ z$K4m%YWxu=+(YA!yDzfU_#;lZhsGawUu3KCN1SjEjX&u-i)=Oih!gIi@yFd4+3L$5*`C?)eV51& zp9e_hh*0+Xw@tR6Cf#Po-y+7Jzgc42VEnP!@wbTa=WmwSHoPc*P*!^(Ej6Y6C>~Z6d z-4AEX_~UfM9yk8j{cy&NKTb#NapRBO4`djp?p?nKw9Z2|JPm37 zYNdYuT`7OGzB`s1j6Zi1%K_Z^9hCc@K7X(L?jGycN=p6pFU7y*aQ(1HX+LTH9hS@Q zzl$sPkEZWGJ^g~xf6bz_zw#*c*EuEsi}vq=Qoaf*{v=S^AKLl-raWJo zD*k=0=+}- zpHu4Zx|Ui-|LclA?fku_uT-9IYpv6^eprq~{_uKyI)6z^w)NxxRR1^1{pa=k9xKmZ zjla)2Tju<=O8U$lR{mH8w3_!|E`<@p!r@%~M$+@DpI@sst+ z{n=b;A9nYs|AO*-uc-K^`LCrHQO;k>|NlMxuu^`CDf6o@+Mi8I`(M-d%L}gGG<{yv zGb#S`RN80r{^8eO^Jj%}ep-Gl{fJV3YWcmUH-OJCqtZU`s(*sV`oE>p{;T{CZ$DI5 z?oY4&YWa67^K(a(_J_vT(nl-jAFQn3v8pE@K*k%FT(>48jmGPZ0 zrTwGvS9)xJRq&X9rlP;I$NHg?WxMsmc70|mR3e*R4h65N^L|`tK|H zwESMve^u(2?#lJwtc;K3@_7GuQSy&c`ghrt_Um4ye*Rub*ZiNP_@m|bnqJ?k&E4yl zWFGIoUW&h({!m==@pgwaf`?MTRZ+M9?PG`*V5A{<+H2ue9-ts6@P=_>#OPWnw~?c zf2u3~jaU4+r#ye=z2NfW)juu&1dr|e+Ap|$`WbxuLrVSB%wzco@z{TT^n&m2!!Njf zq?PYEO8ueb_nLmiWBaC%Qoqys(|Ejon!Yz)@b&HI@%Zm4&)2-l z^?O5UA8G!@QjY(D)vdICSZ5shL*si*AFk;8ON_g|p0CIr?&*^1@85?#omB`7KT{)? zzjQat|0N5{Kh}=rXIR7XgZ{ptPq&{tMDzB-*f}ZFA10b zYqePYA1Lb^RchKu_D?g|ohPnT3* z`L*;`6=e^GzoRD0KMC_wSsggWpUHo+{v2J-^7B|P&@=u#O3&)AR*L1{gxlwxin037 z!2P@GaQ)LEvYLJRk13zIj`lXxj{NvsB ztUvjc_0@88fBQFqKL~;8AC+j#@|%rh`86}L{3WAUes6ev%luia{?&U}e)=z2{#S7O z)BcUGWhJpcF*UO&^UHS2#U{CrFZ&tGp7d!YH3+VJ|ZJMi;8 z_cAev>3>BJVfktEvHU(0SboQaEPnv3zY5%cd6JCv=g-+Je_<%gFFuatAG^--m%;Oo z8Q*2~f0&u&{|Jvi)Tztr9}V|kRxDxlmxleXP@mPG0QP4o+&>!GjrC_C-2a;Vgw=l? zUjMTJ?q9q!kM$=UUjH))p1-IAw;$@ZWc{z+mF35U=ck5kV)Y*y!}9mT<1>%p^*Mp? z`jBWJvi^7fmgRo}KY!PhWA!imj^$UJ&+xDc4} ztFmzWm*_zH_WU<>2|pW2IUB)AO_ZQ}J1TD6GF9JU;*6JnPS)5-h)X zbe10q&o7^?$Ldd+iRF*(%ksxuWchUeH`f_f{|_Zue#;pwKjz0Q|LE&1e|s3qkDiw0 z=ZCLvxjd}?j=flZ-L@=$5^CXsk zC>P5=nS$l#fX9cY!_Th|Ut|4g13$m!H)r*4c+B#PwP*PU;PK~MaQm?B2-ct1s*{rM-tp+^>=E@@<+k*d>Y`411X{FaAU{)uHQfB9CHzX9&wJ*vyQ-rbn+wl6p@pD=I^WgsV zjO?ubF0)yF06acl9bP}v<0k9RW_W)5rIoDyhH(2~%Q#m5mg6jcM|GCJ1#TbCOvLJ+ z{tL@L0k_ZhhqC(bz~etJ!GB+`&zGz}p|7y~L}DZC3xPp)9}J6qY{}9^WnnzyDhRZa)+*%lcoZAj{v?iscu{#PXBOWcg!Z|6{@J zpLaI0{tSc1x8J+Q>VE*&Z+qeSk=$uDe`H-p|2L-uMJ_f7h{{knB9wQnA~(r?QOQxk zSa|#@aZWj&`|%~!mp(0hcsALC;pcC{^1}+T{BU^uza9MiJ(ZvJ=W#`rKP48+uktm^ zPtk(q7lr4K_J*+fQ^VskX*#g_f9}ij69%#T2k`tw5L`Y79b)|{0I$C){xYk7-c^== z#A-HJKjeUp(7*IQ;q~YA_sw#`^UDd!i_QM|E4h8OAJ49mJs5t@7A$|-9hRTE8_WLz zo`3wLEvvsTeE){UWA*1K!txu#>t}K_VD-l>$MW;UX8GYCv;0Ny`oo`tSpA2)u>65j zS^g#X{rb1)^$*!tf40KoZ@x`v+XtyXlHJ_XCDmv9#@fL>f8J`cgL}H9`pa()w?BKq z^GC&Yi35y3Ip1UX%SN*NjMG_u{)H@mHQYXY?HH@SF8qG_jH;~uo+DWPKDhoI36Jlc zOwRgq2Oi(<56_>*hp%r6*ngVeY%p7X%1&VUi!ZbM*^x^YpZ;T>@Au&KpWWc~IlJKY ze}*Bf|C!^n{Pb}B*}4U*|317vGdLfsze_mFAM*ywf8{94zxNi)KlVM#Pdkj|zYotZ zjD_FNow1qqXVW>BKMWq3FJ?~~k1$?CrguiwZc@}c@?;J0!B=Kfvr3CpMd zK4bjctp2L-`n}2Tv-+>Y{r9iau=-zK%<|va!18N1VfpEcu>8hw{U04(zuoZ?>(86; z`iguZtp0&vEPvB-mVXkyzS-gN^J@iIe-^;wOLO7%;pKX={;c_xfr@Jqq-znKom88Z6gtYP^NtFrty zxmbP|s~TYZKbMWwKeQdoPx~FqzX`v;{Av)Ze>^E#y zr^|oZEUf-3DOi39>+#0apH<=Ve*>Oh{t#|IH-Ou>`{3`lWNpEg&lrnX{vRD!e!@># ze!2=QzaRYmNXizh{&0Bx+X$=Lw0_75IYR%^XM7rdVgmcObj%U@8J<>!RQ$7b$k z_2+`u|CjlO)qkfC%ghCt6<)4J- zU#=Zs^*1fR^84*)`8VPBzs9F$^?#L@<#%4j@@rON`R{+h@=rHm`4bkf{9W+)^YtOD z{@>vCd7{{?{#QR{`A^{WMG5P(`X_f~`AOjRXA5}!?c6!6KSSZ~{|tiP5A8aV^{3b# zmR}UEA9`kH^)LR2<9d>S1V*?^{4F1^5d*$`8g-B{GRam?F0Dz-}UhP z#prvi|EnLf{88}w;Y;xR#f-tMKgZ$cW2Z8#{QrEvXlNUa}YMx9_#xo3ahBzjalg`9X)DErwr*M!Gsjuw>TxgTFr zeSKd;OMeWv|1-kte|FxIGi3Z}xRK@Og4;hMQ?mMd)9ud;to|X%SpHskeeC?Hto|uW zS$?-my+c~zvYMk2SiHI56R&5V`n2T!_$9qoh0rI{Y$S0 xuTPE%uOAD8=XaXG{iAbhLAGQl+c5SJBU>o@nw>EvWXqZ% zF^uflnIwKQ%(>%y?&mY_U!VJXKJ@*4&;0Yb=bU?<_jOogwTa~y=+E+3!1~YaWcAl7&GLJ6WcevSX8CiTVfnAT#qz^DvHajxEPv5#mY=5s z%P*ge<-Z9(9~*aP^&f-l&pAP?{vu0RexVC2zaH%Wp{H5>XP2@3EUQ_5y7yWBijyqA zD?I*oB`>T0RtlDXd@IY(Ka}MUy20|-oM!n|53u}LxPCrTfYqN4_Gk7~R)3lgSbhBBR{tQl{M3ZUKPTR1{TX(j<$Cb}-evih zCb0ap@c4PJ*{uGY@cMzQyIK9q;rhApFRcE~aQk*I+`q1GHE@~s+ZStC|F4!{`OofW z`P1O>pFMe4{b@3?{2AY{{J~9GzTdYje;z#lRUjR!zc$={&RB@me{B-WFR_>9moLEb zW8m?nPynaA6@I&sG9UDLYlq$iHhaHQj;OBb^ zxPO|wFMEA&!}ANP;r?CM;RJ%TEE1zkLb6{}LR=`m+OGzp?BmR)3(vdsu&JC3~5k zWvn7Fc9Le%P(}B$*)k309U{$zs3 zPqtTO^|!jn@;jtw`5R}k{2Sd_e(ho`e=Xd8ZgYs$|Iyzp|KeqqpS=gmpIL+DXWqc_ zV^6dE7H3(0*}*KoUK^I5G{v>+gZp--N z&XvSC<4>X&?zW6S?p#TXGyWuc;cm-Ye^kB`*Y)rBl#M$`vPTZ2_Io5s+cfE#9kzh+ z$7YEZVf@kTumy}iHcPY!6L60ppL&5-q~`quF5#7=LV*Xc5LA z%??|@_+zs~i!lCZcGv>OADbmwgz-nS!xk|9*euZ^j6a$kwt(@+W{DPI{L$>N1&lv7 zOSA~%k7kE0VEnOJqD2^gG&^hoi56k}(d@7Vj6XI@v&SkC0c~>N3+8g zF#gyq(ISjLnjN-)@yBL~7GeC+?63ulKQ>FW2;+}thb>_Iv00);7=JW7Yysns%@Qrb z_@mij3mAWFmS_>iAI%P1!1!acM2j%~Xm;2F|E>P0^KRwxeV3wf4*5RflG^W)C~ecE zYj)TI#vhv{T7>aOv%?lJ{@5(hA|97N>bMct^zUQE|6@;6e05adkuw8-H{^TtmhmS4Z@)@kjT=HDvs8bwm#ve{?@wL&hIhNA$4q zNB6@uWc+b;L=PK(bU$1}#vfNl^sw05 zadkuw8-H{^TtmhmS4Z@)@kjT=HDvs8bwm#ve{?@wL&hIhNA$4qNB6@uWc+b;L=PK( zbU$1}|E>O5=Y2z8-|}3XLFWIzC6*G)r^G02%cN^|*aF5Mni56k}(d@7Vj6XI@vCc`%Y5ryF6(9HIqpOfE9bZa6k}hd|C;gI64;QY_N}>Eq`W|VQmFq1W=Q(_-@mWQZHUYJzs2G*`ufxpj-M@4()=?*{>4K1 zm9EbY;r=gR4NG8EMo7bbtNw2=$wE{!)6NaQz>-zpm=z=MO6;iQirD@1SshR~71~7Ro*k?H@^> z)AX}K{TwZvf25Eu=^HQ9?^1p`{X60QqK{AWBZcxyAAh}2{(`J(#oDm$nD`B+`YJoD zpGG-spG*383hhTJe`5U?;8OoR6y92>pN(bk5RF8T9sPtPHgugwncU#I8)x8=5b|Fa40J!mbIbjlkz)FUt}HD+W1GuKOnS^ zLxglm|6Bj^_R&*9`N=QzucY%Y4!2)Jh5AF%C#B~Ro{zB)uicS1;)_)_|F zFu#Js^6|8Fw|jU0OXp9gHx}A|qlEi!k??#kDe!|W+pG=C(fEzA!ttH@6JQg zl;2;te;WwnH`9gjn-Z36Yvcb^zjXd<1$`YH-e1!3rS$oh8{YY|L@3|V@&D7)LoC~@ z4a?E^4X5X~M<_o|`Gtk{MYM2zn>&2I=d*0HHZ=cgIXwSWLj5B7cR=t*%0E&#ejnla z{jJbGy>7YX-akp-|M&C{1pglQ`F9rjcR8)gYHe6oJbr`LU(`CRwegRRUs$LgatPyt z&kFa)Xd#{UN8s7|A9U~iT{*veWm?d4br{(W)=l{r|zo~@$XW{Xm z215Cg{GBV5zrMotZzbfHj=$Ktf!+6ymI?R2#CMv0N@$F0#!^CE}GKVn@?=KAKcu-1lk z#Q#nCv6>%H`$6)5gC*PA(DXkgl)t^g^G7=Vzdikm!}jq~hxt!gm&Mx9u8&jy#tQe} z&sG6tj^Ezl{oT-Eey9H37V6)X4zItYFTg4d%=H;A=X**XY5HimeRkR^Hr9saX#B<@;r>5tWwSQ4{7%o`Y5sGu>f_=0FQtD|Ozpw&M~r9r0V`PkH&_-}#*7&uGo^7uIC?TZQlEsH1y( zeZT6a2+Z|;rVh*R(}(3Z*~0SY!{z@dTz;d-xkU zM1|d_t5i?`jfMoRFUc~D6h3kh!ud(_| zg|Pe|X0iM`TUq|)?JU1F-2QBKkkx-`HOn8Im*rn>&+-rNVEG?3V)+;0_J8V2PW4;Q z4VUkKJe=6i{H9ZW()m+lPC8wsdi#9$hsRHb4^#>*A610QuXpJ^g!SKv2l0--Y>*;& zmtW=fbbXdLReLb}D)G(W2Y(EI!DW`e{zsM{Dy(l%NB4C7r1V|z_`sYdN&$0yr?g@D zdp5KD^YHlCM7aE1`I_~ocKpA=dGN=SpWSf%d29o#zX)7@dcgdB6jP9w`5(2D)t{mz%fE4lKmQllpXx0CkEJZXOgPJb zdo0VpK9uEmXu$H@!tKv}=UDy0us;>y>l<2u_2+eXeCE$UR)3-1EWgpuEWh~MEWbt; zmOtl3mVaX#%l|Hv{4{2l%*zX@Fbj~mD8 z@7s#y_pn|NX8PCXQnLD^;q|@AbFliKjA8keZ?OEUm013SFIawXPnMq-oy`BGB`iUYi_5W%3{f+}YSpDz8>vKA8VfEkH&GPfY z^E;bbvHI6uW%;9nSpMOeEdNt@{GsMBR)1J2mcJA(KWidb{r(GB{t0+|H48jGS*{i9 zPrC?~{|CIjy$?J;dlPQorh(tDeA{YPSsUttyZm~WUbe8pc$Z)0_SAlyP*?52@W&oy z`F}KK`PGA2{wlbAnEnc@|1DU5Fx-FX1kc~K`JDBCQ&*Os8(!b|bv9Q2vW6`G1U&v$ z2)@1>GPC|nE6(z#!_W7D!uNsH*?D@tO6jTQsXdtI@2w6je-u3b64@1z!I=3LZavZ58X! zt2tTzZ{u11;EOCj41WI3g~wNOPhkBi46jcp^Czo6IK%H6~gz6)zQ5jAL{_G&%E77DP-E`HR18Yj?t|CF+Ev+ zd-(l=r+r!d=cls#b~{*pjptc@1GxR4d=RUDC47BrWnuLX$i(trh3Cibo>TSm^eee1 z#xIslmT*GWgC`-Z_EYEU`s+Tu3;cd1{r=QBczhsZcjcpd|5U2C&-Zl)6@lU3zR&W@ z_hR|!;q?jC3bOi_RAc$MMzj2t*I9m}mMniLynfPeJF9>BH!Q#CD=a@vK9>J?R+gXs zb(Y`#eU`r#9{;=s&+iW&#riWIUcZrj2&=zMe3S6OAJcyDFUj&p)?@h*@c2)KcUk>I z;Q8Ob*0K7d;Puaq;rWH;@38(X4r2M|r?C9p=UM)h+bsXRTr7VtTz=-k{iD?bSbsX7 zWcld_v;5_7`{XlteOfyB{yhi#|8jQr`o36><@bd9kN$A`p%Gkuj*nyge+6zI9)jmL z$HDV!%Z9Q3$JS=~Z?|Ci??zrgJizYkgcC*krxE|Aq<3$8!&R%G?> zg#E7rx6e!5Vf~4j$nuA5VEJibepnAyf1Lo9zqJj^PXV_d=AB^mzX8wRU3-hw{}%jw z&)$sHAEEFb{=cP_>}7gNxPKQrNGW8-&)=!c@_p#>^Fplt=;`sJ$QXxDR}(wEPVf-D#7|6 z3co+K4qhKJa4+jm&fi%6P76zZ&<8xc>0{D&K?*W#WF-yH@d0YQKc!gr5?1 zRoH#HO7)aqN~h4ZQFK$r|GX9^1$Co<7?enJa{P^7f*8jHf`?(F_^$8i9 zvi=NtljVoO<8P-!Sp6%4SpKZ|$Hap_rv5nv^A9a&_4jDZ@;9|-`CBfr{1@Q)$CL2; zDVttq{kgq}o`j-thX>Jv&%` zUb@EeV>7Y*P`Lg{RfE;Pb`Q%xHi_jAg#9VInAQJoL6*M<9)Fm&jMX1GgysJQuWxL$ zfYqNW9m^juoaJAK$7lMkX7!IR$?~twXZh3qVEN&2{npzWf@Ru=dk3=qREGQS1>pIw z6|n!M53~Lco6Pd>^NT=CS-=4zc_{$Fuw~ud@7dg<1Y)`1>A5 zkF)xhH(~j7{nib>zJX7%{>&S}@@vEEZ%buh^TiTWNrzAZ8d!{<8KXweuzj}w|Z-dKczGqndBlEEQsO~JkW_^~w2_9d% zeTCJZyc)}Id5h)${42|^Gl=CkImYt0i1V{469j~8Z1-UN_Q`~BE+s7C*Zuh8k(bnN z?$cGOx8t|ziYWraZwjwp8U;UJf~K+lya10+<~+#i&p(Xi2gB{3UGV)?7=FLccM9wO zckue~u2oq56+dD5p)FZ{RrvdFci{HJ^Y>YQ+QIchYPf$I0?#k^Y0dgSE0X2+{*>kK zevRc1UB&WqW@Gug;P20jg2!*0!u%cZ{AOi%ea^J$Z29?V1Ir)r1d6RNH{_7>ZeIRu>MTV#qw9B$e3_|kd_hS{o=M$CxoAm`?V(b; zXCVB3accPc&+ozW`+X`W*zKjiDz~TWQ(>ms!@KLJa(m*x1N+~lr&7S^{|N3MEr-9~ zau6OLNDJ4`yV|hVx8fTtKWHe+zixFwnDU>Z1grlqc>H$Ca901f@cMyN-?IAa!Q;1q zaQ*Ke!}{|++`s-9o_~pI&-&BrILp5=3+BV~n=7nt1XDlXUCsKl87@EfqFMcQ6dr0n z?`y0Mz+B%ATUdVod@R5DWT$-9|MYR`e-@7e`}#!w=#-yy{wbkJr>j&?{U0fPae1`| zQ+_^&=PycJXZ3#;!18~C>$gT(S^c~BvHUlBu>4!_`p@R`S^dEkSpMPJET1m_KJfhA zWcdFR6<^8v|LZ1}|J((ZUj%-CHnuFQf6DtT|E@T{?Ckgj;q^-~S)A!pxjnUyLg4w| z9aEJ8rhV1)XO{n*!bAO+oIBJ37=O}DVEMJ+@zu*+S^X6TvHZF(vi$H^mhWGK=TA2jW&P;| zkI&3F!0O+0nB}L2$H%h%#_Ip|EX&Uf`_r#ItDi2Pso!Mv_aDykKOfBU%fjH^@ z-IBBZJQM$##e+X)e16Y5mY*4ZKWq7TR{wo?{A3sW{GBkC^`}YvD?tzbnDRL?jOEu( z!SctfV);Gb{?Tj2Sp6S<#qxK;>o;D7-`~l%hV^GbFv}kbum8{2oz=gw2+J>@ljZxs z^GBPjvigU>>)TInXZ06@=P$zG@%frlSbu7L%<@~8WBEZdSboQ!SpFw%SpIcb|6lc4 z{nf2zt+k;J=<@4b`jWc}<6VB0+td2Gs!P-!48JtI{%1!^R{ydRi=-f;V^*!Qgdh$7`sb%)`OV<(&y)&a^%ri- z@&~Bq2K0Tid|RCASM7oTm;e73zp%pB=lhyY`AO%`H7x0LmFjK#DivIR4lSb;Dj!wY z_1C-fQ271Ut<{uH@ARqMo~};|xP4n6uHPoZ^E-9Jl!J`_h2ZjYU?QtOE4=>p@<0B* G(Ek7tKKgb5 diff --git a/AI Insight/chroma_db/chroma.sqlite3 b/AI Insight/chroma_db/chroma.sqlite3 index d2390021f866b8513f6ebbcb44b29cef62f72ef5..53c882b355146461b37e9322562ea53b69d98476 100644 GIT binary patch delta 157 zcmWN=u?>Pi7=TfZ0}(|}MAQ>@FfpONg32w}fyVMrD7+FY7H|P$VasTKFL@%pNIzDp zdDX3IKHd8oSu+GvHix%wKHp8Otq#(`yQg=GAvGh$TrlC1DOX%GATfM125zX44cGufKuT%cGjl{bk?vEe z_FLan+dux-&>A27l1Vp1Hr>t3ySI0WnhS=E7<0)L*W7T+ggd4*+%w~WN1k|Q&VrT~ KmaIna>Zi;a@iU76 diff --git a/App.tsx b/App.tsx index 7b7c9a3..d11319f 100644 --- a/App.tsx +++ b/App.tsx @@ -6,14 +6,20 @@ import { Kandang } from './types/counting.ts'; const App: React.FC = () => { const [selectedKandangId, setSelectedKandangId] = useState(null); const [kandangs, setKandangs] = useState([]); + // 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('Sukawarna'); // This will be populated by AppContent once cycle data is available // For now, we'll pass the state and setter to AppContent return ( - + diff --git a/CATATAN-LANJUTAN.md b/CATATAN-LANJUTAN.md new file mode 100644 index 0000000..d0dcd5a --- /dev/null +++ b/CATATAN-LANJUTAN.md @@ -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. diff --git a/CATATAN_PERBAIKAN_AI_INSIGHT.md b/CATATAN_PERBAIKAN_AI_INSIGHT.md new file mode 100644 index 0000000..29b3a73 --- /dev/null +++ b/CATATAN_PERBAIKAN_AI_INSIGHT.md @@ -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. diff --git a/HANDOVER_AI_INSIGHT.md b/HANDOVER_AI_INSIGHT.md new file mode 100644 index 0000000..65018bb --- /dev/null +++ b/HANDOVER_AI_INSIGHT.md @@ -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. diff --git a/README.md b/README.md index 418f429..420fbcd 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..f61ff4a --- /dev/null +++ b/SETUP.md @@ -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`. diff --git a/backend/.env.example b/backend/.env.example index 0704475..03ee235 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/database/db.js b/backend/database/db.js index f5f4c00..711c17c 100644 --- a/backend/database/db.js +++ b/backend/database/db.js @@ -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); }); diff --git a/backend/database/migrations/014_add_report_scope_to_ai_insights.sql b/backend/database/migrations/014_add_report_scope_to_ai_insights.sql new file mode 100644 index 0000000..2651dc9 --- /dev/null +++ b/backend/database/migrations/014_add_report_scope_to_ai_insights.sql @@ -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'; diff --git a/backend/models/AiInsight.js b/backend/models/AiInsight.js index 64ebf74..f297ebf 100644 --- a/backend/models/AiInsight.js +++ b/backend/models/AiInsight.js @@ -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} 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, diff --git a/backend/package-lock.json b/backend/package-lock.json index e098ecb..5131b77 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/routes/__tests__/manualFeedSackDate.test.js b/backend/routes/__tests__/manualFeedSackDate.test.js new file mode 100644 index 0000000..f20a37d --- /dev/null +++ b/backend/routes/__tests__/manualFeedSackDate.test.js @@ -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(); + }); +}); diff --git a/backend/routes/aiInsights.js b/backend/routes/aiInsights.js index 4a39c1b..7483326 100644 --- a/backend/routes/aiInsights.js +++ b/backend/routes/aiInsights.js @@ -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, }); } }); diff --git a/backend/routes/aiInsights.test.js b/backend/routes/aiInsights.test.js index e2e925e..f8d1c30 100644 --- a/backend/routes/aiInsights.test.js +++ b/backend/routes/aiInsights.test.js @@ -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 - 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'); + // 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')) { + assertPromptShape(options); return Promise.resolve({ ok: true, json: () => Promise.resolve(JSON.parse(mockResponseText)), diff --git a/backend/routes/manualFeedSack.js b/backend/routes/manualFeedSack.js index 3904f60..bb8bda1 100644 --- a/backend/routes/manualFeedSack.js +++ b/backend/routes/manualFeedSack.js @@ -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; diff --git a/backend/server.js b/backend/server.js index dccc1ea..1590d2d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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, }) ); diff --git a/backend/services/cp707Knowledge.js b/backend/services/cp707Knowledge.js index 4bcae0e..dd59405 100644 --- a/backend/services/cp707Knowledge.js +++ b/backend/services/cp707Knowledge.js @@ -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, diff --git a/backend/services/dashboardInsightService.js b/backend/services/dashboardInsightService.js index e7efec9..b7dd912 100644 --- a/backend/services/dashboardInsightService.js +++ b/backend/services/dashboardInsightService.js @@ -5,10 +5,29 @@ const ChickenCount = require('../models/ChickenCount.js'); const Mortality = require('../models/Mortality.js'); const { formatDateForDb } = require('../utils/dateUtils.js'); const cp707 = require('./cp707Knowledge.js'); +const { llmDispatcher } = require('./llmDispatcher.js'); -const INSIGHT_VERSION = 'v5'; +// v6: period-scoped scalars, computed EEF, unit-suffixed field names, and the +// data-availability guards. Insights cached under v5 were generated before those +// fixes — they still carry the fabricated figures and the false "ok" statuses, +// so the bump is what actually retires them from the dashboard. +const INSIGHT_VERSION = 'v6'; const dashboardInsightRequests = new Map(); +// num_predict is a ceiling, not a target — a well-formed JSON answer stops on +// its own, so this only ever bites when the model runs away. At 8192 a runaway +// burned the whole 20-minute timeout before demoting to the local fallback. +// The caps below sit above the largest real answers measured from stored +// insight_text (daily ~3.2k chars, weekly ~4.3k chars, i.e. ~1.0-1.3k tokens), +// with end_cycle given extra room since its prompt asks for all six areas. +// Set these too low and a valid long report gets truncated mid-JSON, which +// costs the full generation and still lands on the fallback. +const NUM_PREDICT_BY_REPORT_TYPE = { + daily: 2048, + weekly: 2560, + end_cycle: 4096, +}; + const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -53,7 +72,8 @@ const INSIGHT_SECTION_STATUS = ['ok', 'warning', 'critical', 'unknown']; const isInsightStatus = (value) => typeof value === 'string' && INSIGHT_SECTION_STATUS.includes(value); -const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string'); +const isStringArray = (value) => + Array.isArray(value) && value.every((item) => typeof item === 'string'); const isInsightSection = (value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; @@ -120,8 +140,11 @@ const healDashboardInsightJson = (raw) => { raw.summary.actions = raw.summary.saran; } + // Never invent a reassuring headline: the model failing to return one says + // nothing about the flock, and "terpantau normal" would mask a critical + // finding sitting in the sections below. if (typeof raw.summary.headline !== 'string') { - raw.summary.headline = 'Kondisi operasional peternakan terpantau normal.'; + raw.summary.headline = 'Ringkasan tidak tersedia — model tidak mengembalikan headline.'; } if (!Array.isArray(raw.summary.bullets)) raw.summary.bullets = []; if (!Array.isArray(raw.summary.risks)) raw.summary.risks = []; @@ -136,10 +159,22 @@ const healDashboardInsightJson = (raw) => { raw.detailed = {}; } - const detailedKeys = ['hitung_ayam', 'berat_ayam', 'fcr', 'eef', 'panel_iot', 'hitung_karung', 'data_quality']; + const detailedKeys = [ + 'hitung_ayam', + 'berat_ayam', + 'fcr', + 'eef', + 'panel_iot', + 'hitung_karung', + 'data_quality', + ]; for (const key of detailedKeys) { - if (!raw.detailed[key] || typeof raw.detailed[key] !== 'object' || Array.isArray(raw.detailed[key])) { + if ( + !raw.detailed[key] || + typeof raw.detailed[key] !== 'object' || + Array.isArray(raw.detailed[key]) + ) { raw.detailed[key] = {}; } @@ -165,8 +200,12 @@ const healDashboardInsightJson = (raw) => { if (typeof section.status === 'string') { section.status = section.status.toLowerCase().trim(); } + // A missing status means the model said nothing about this topic. Defaulting + // that to "ok" paints the UI green for a topic that was never assessed — the + // observed case was `hitung_karung` coming back "ok" with no bullets at all + // while no feed data had been sent. "unknown" is the only honest default. if (!['ok', 'warning', 'critical', 'unknown'].includes(section.status)) { - section.status = 'ok'; + section.status = 'unknown'; } if (!Array.isArray(section.bullets)) section.bullets = []; @@ -176,11 +215,131 @@ const healDashboardInsightJson = (raw) => { section.bullets = section.bullets.map(String); section.evidence = section.evidence.map(String); section.actions = section.actions.map(String); + + // A section with no findings at all cannot be a clean bill of health, no + // matter what status the model attached to it. + const isEmpty = + section.bullets.length === 0 && section.evidence.length === 0 && section.actions.length === 0; + if (isEmpty) { + section.status = 'unknown'; + section.bullets = ['Data tidak tersedia untuk periode ini.']; + } } return raw; }; +/** + * True when `value` holds at least one usable reading, at any depth. + * + * Descriptive keys are skipped: every compacted block ships a constant `note` + * explaining its units, and counting that as data would make the block look + * populated even when every figure inside it is missing. + */ +const DESCRIPTIVE_KEYS = new Set(['note', 'catatan', 'periode', 'satuan', 'alasanTidakTersedia']); + +const hasAnyReading = (value) => { + if (value === null || value === undefined) return false; + if (typeof value === 'number') return Number.isFinite(value); + if (typeof value === 'boolean') return true; + if (typeof value === 'string') return value.trim().length > 0 && value !== 'tidak tersedia'; + if (Array.isArray(value)) return value.some(hasAnyReading); + if (typeof value === 'object') { + return Object.entries(value).some( + ([key, item]) => !DESCRIPTIVE_KEYS.has(key) && hasAnyReading(item) + ); + } + return false; +}; + +/** + * Force topics with no underlying data to report as unassessed. + * + * The prompt already tells the model to mark an empty topic "unknown", and it + * mostly complies — but on an end-cycle run with no IoT keys in the pack at all + * it still returned panel_iot as "ok": "Lingkungan dalam batas standar CP 707". + * An instruction the model may ignore is not a guarantee; this check is, because + * it reads the same context the model was given and overrides the verdict. + */ +/** + * Did the model actually say anything, or is this a healed-empty shell? + * + * `parseDashboardInsightJson` fills in every missing field so the UI never + * crashes on a partial reply. The side effect is that a reply in a completely + * wrong shape still comes back as a valid-looking object — one whose headline + * is the "tidak tersedia" placeholder and whose every array is empty. Callers + * need to tell that apart from a real report, or they will serve the shell. + */ +const hasUsableContent = (parsed) => { + if (!parsed || typeof parsed !== 'object') return false; + + const summary = parsed.summary || {}; + const headline = typeof summary.headline === 'string' ? summary.headline.trim() : ''; + const hasHeadline = headline.length > 0 && !headline.startsWith('Ringkasan tidak tersedia'); + const summaryHasList = ['bullets', 'risks', 'actions'].some( + (key) => Array.isArray(summary[key]) && summary[key].length > 0 + ); + + const detailed = parsed.detailed || {}; + const detailedHasContent = Object.values(detailed).some( + (section) => + section && + typeof section === 'object' && + ['bullets', 'evidence', 'actions'].some( + (key) => Array.isArray(section[key]) && section[key].length > 0 + ) + ); + + return hasHeadline || summaryHasList || detailedHasContent; +}; + +const enforceDataAvailability = (parsed, contextPack) => { + if (!parsed || typeof parsed !== 'object' || !parsed.detailed) return parsed; + + const topicSources = { + panel_iot: [ + contextPack?.iotPanel?.pembacaan, + contextPack?.iotPanel?.display, + contextPack?.iot, + contextPack?.panel_iot, + ], + hitung_karung: [ + contextPack?.hitung_karung, + contextPack?.feedSack, + contextPack?.pakan, + contextPack?.chickenCounting?.feedSack, + ], + }; + + const missing = []; + for (const [topic, sources] of Object.entries(topicSources)) { + const section = parsed.detailed[topic]; + if (!section || typeof section !== 'object') continue; + if (sources.some(hasAnyReading)) continue; + + missing.push(topic.replace(/_/g, ' ')); + section.status = 'unknown'; + section.bullets = [`Data ${topic.replace(/_/g, ' ')} tidak dikirim untuk periode ini.`]; + section.evidence = []; + section.actions = [`Pastikan sumber data ${topic.replace(/_/g, ' ')} aktif dan terekam.`]; + } + + // With whole topics missing, the data cannot also be "lengkap". An observed + // end-cycle run reported data_quality "ok" — "Data operasional lengkap dan + // tidak ada anomali" — in the same response where two topics had no data at + // all. That is the one section whose job is to notice exactly this. + const dq = parsed.detailed.data_quality; + if (missing.length > 0 && dq && typeof dq === 'object' && dq.status === 'ok') { + dq.status = 'warning'; + dq.bullets = [ + `Data tidak lengkap: ${missing.join(' dan ')} tidak terkirim untuk periode ini.`, + ...(Array.isArray(dq.bullets) ? dq.bullets : []), + ].slice(0, 3); + } + + return parsed; +}; + const cleanJsonComments = (str) => { return str .replace(/\/\/.*/g, '') @@ -201,7 +360,7 @@ const parseDashboardInsightJson = (text) => { const candidate = cleanJsonComments(trimmed.slice(first, last + 1)); const rawParsed = JSON.parse(candidate); const parsed = healDashboardInsightJson(rawParsed); - return isDashboardInsightJson(parsed) ? parsed : (parsed?.summary ? parsed : null); + return isDashboardInsightJson(parsed) ? parsed : parsed?.summary ? parsed : null; } catch (error) { console.error('[Dashboard Insight Parse Error] Failed to parse/heal JSON:', error); try { @@ -218,16 +377,31 @@ const parseDashboardInsightJson = (text) => { } }; - - -const buildDashboardInsightPrompt = (contextText, dayAge, reportType = 'daily', reportPeriod = null) => { +const buildDashboardInsightPrompt = ( + contextText, + dayAge, + reportType = 'daily', + reportPeriod = null +) => { const bookRef = dayAge ? cp707.buildBookReferenceContext(dayAge) : ''; - - let taskInstruction = 'Analisis data operasional harian berikut dan hasilkan insight terstruktur.'; + + let taskInstruction = `Hasilkan LAPORAN HARIAN untuk hari ke-${dayAge || '?'} saja. Data yang diberikan HANYA berisi hari tersebut — analisis kondisi hari ini, jangan merangkum tren mingguan atau seluruh siklus.`; if (reportType === 'weekly') { - taskInstruction = `Hasilkan LAPORAN MINGGUAN (${reportPeriod || 'Minggu ini'}) yang merangkum performa minggu ini dibandingkan standar CP 707. Analisis tren dan rata-rata performa.`; + taskInstruction = `Hasilkan LAPORAN MINGGUAN untuk ${reportPeriod || 'minggu ini'} saja. Data yang diberikan HANYA berisi hari-hari dalam ${reportPeriod || 'minggu ini'} — analisis rata-rata dan tren di dalam minggu tersebut dibandingkan standar CP 707. Jangan membahas minggu lain. +Angka ringkas seperti averageWeight, dailyGain, dan fcr adalah RATA-RATA minggu tersebut, bukan kondisi hari ini — sebut sebagai "rata-rata ${reportPeriod || 'minggu ini'}", jangan sebut "saat ini" atau "terkini". +Field yang bernilai null TIDAK tersedia untuk minggu ini. Nyatakan "data tidak tersedia" untuk field itu dan JANGAN mengarang angkanya.`; } else if (reportType === 'end_cycle') { - taskInstruction = 'Hasilkan LAPORAN AKHIR SIKLUS (EVALUASI PANEN) yang merangkum keseluruhan performa dari awal hingga akhir periode (panen). Evaluasi total keberhasilan siklus ini.'; + taskInstruction = `Hasilkan LAPORAN AKHIR SIKLUS (EVALUASI PANEN) yang mendalam dan komprehensif untuk SELURUH siklus dari hari ke-1 sampai panen. +Data diberikan sebagai ringkasan per minggu ditambah kondisi hari terakhir. +Laporan WAJIB mengevaluasi keenam area berikut, masing-masing minimal satu temuan, dan sebutkan angkanya: +1. Hitung ayam (populasi, mortalitas, afkir, saldo akhir) +2. Berat ayam (bobot rata-rata, ADG, uniformity vs standar CP 707) +3. Hitung karung (konsumsi pakan) +4. Panel IoT (suhu, kelembapan, kondisi kandang) +5. FCR (efisiensi konversi pakan vs standar) +6. EEF (indeks efisiensi akhir) +Bandingkan tren dari minggu ke minggu, lalu identifikasi faktor utama penentu keberhasilan atau kegagalan siklus ini. +Jika data untuk salah satu area tidak tersedia, nyatakan secara eksplisit "data tidak tersedia" untuk area itu — jangan mengarang angka.`; } return ` @@ -239,6 +413,27 @@ PANDUAN PENTING: - Jangan gunakan standar atau referensi dari luar buku CP 707. - Risiko yang disebutkan HARUS bersumber dari deviasi data aktual vs standar CP 707. +ATURAN ANGKA (WAJIB DIPATUHI): +- Setiap angka yang Anda tulis HARUS ada di data konteks. DILARANG menghitung sendiri, + memperkirakan, atau menyalin angka dari field lain. +- Field yang berisi "tidak tersedia" berarti datanya memang tidak ada. Untuk field itu + tulis "data tidak tersedia". DILARANG mengisinya dengan angka apa pun. +- Jika sebuah topik tidak punya data sama sekali, set "status": "unknown" dan katakan + data tidak tersedia. JANGAN set "ok" — "ok" berarti sudah dinilai dan hasilnya baik. +- SATUAN SETIAP ANGKA TERTULIS DI AKHIR NAMA FIELD. Contoh: "deviasiBobot_gram" wajib + ditulis dengan satuan gram, "deviasiBobot_persen" wajib ditulis dengan tanda %. + DILARANG menukar keduanya. Field berakhiran "_rasio" tidak punya satuan. +- Nilai EEF hanya boleh diambil dari field "nilaiEef_indeksTanpaSatuan". Jika field itu + berisi "tidak tersedia", tulis "data tidak tersedia" — JANGAN memakai angka bobot atau + FCR sebagai pengganti EEF, dan jangan menulis satuan apa pun di belakang nilai EEF. +- Untuk laporan harian/mingguan, EEF masih berjalan dan wajar rendah karena ayam belum + panen. Jangan menilainya terhadap standar >300 yang berlaku di umur panen. +- PANEN BUKAN KEMATIAN. Populasi turun karena DUA sebab: mortalitas/afkir DAN panen. + Kalau "totalPanen_ekor" ada isinya, WAJIB sebutkan jumlahnya di topik "hitung_ayam" + (beserta "totalBeratPanen_kg" bila tersedia), dan DILARANG menghitung mortalitas dari + selisih populasi awal dikurangi populasi saat ini — pakai "mortalityRate" dan + "totalMortality" apa adanya. + ${bookRef} Tugas: @@ -255,7 +450,7 @@ Kembalikan HANYA satu objek JSON valid dengan struktur persis seperti berikut (t "detailed": { "hitung_ayam": { "status": "ok atau warning atau critical", - "bullets": ["Perbandingan populasi vs standar CP 707"], + "bullets": ["Perbandingan populasi vs standar CP 707", "Jumlah ayam yang sudah dipanen (ekor) dan bobot panen (kg) bila tersedia"], "evidence": ["Deviasi mortalitas dari standar kumulatif CP 707 hari ke-X"], "actions": ["Tindakan sesuai buku CP 707"] }, @@ -277,6 +472,12 @@ Kembalikan HANYA satu objek JSON valid dengan struktur persis seperti berikut (t "evidence": ["Pengaruh deplesi/FCR terhadap EEF"], "actions": ["Tindakan optimasi berdasarkan parameter CP 707"] }, + "hitung_karung": { + "status": "ok atau warning atau critical", + "bullets": ["Konsumsi pakan (karung dituang) vs pertumbuhan bobot"], + "evidence": ["Angka karung masuk, dituang, dan saldo dari data hitung_karung"], + "actions": ["Tindakan manajemen pakan sesuai CP 707"] + }, "panel_iot": { "status": "ok atau warning atau critical", "bullets": ["Kondisi lingkungan vs standar suhu & kelembapan CP 707"], @@ -298,6 +499,12 @@ Aturan Analisis: 3. Maksimal 3 poin untuk setiap array bullets, evidence, dan actions agar ringkas. 4. Bandingkan setiap parameter dengan standar CP 707 yang diberikan di atas. 5. Jika tidak ada data IoT, set panel_iot.status = "unknown" dan jelaskan di data_quality. +6. WAJIB mengisi KEENAM topik di "detailed" — termasuk "hitung_karung". Jangan mengosongkan + satu topik pun. Data pakan ada di blok "hitung_karung" pada DATA OPERASIONAL: pakai + "totalKarungDituang_karung", "totalKarungMasuk_karung", "saldoKarung_karung", dan + "riwayatHarian". Semua angka bersatuan KARUNG. +7. Kaitkan konsumsi pakan dengan pertumbuhan: karung dituang naik tetapi bobot stagnan + berarti efisiensi pakan memburuk (lihat juga FCR). DATA OPERASIONAL: ${contextText} @@ -319,7 +526,8 @@ const buildChickenCountingSummary = async (cycleId, kandangId) => { } const kandangs = await Kandang.getByCycle(cycleId); - const selectedKandangId = kandangId !== null && kandangId !== undefined ? Number(kandangId) : null; + const selectedKandangId = + kandangId !== null && kandangId !== undefined ? Number(kandangId) : null; const selectedKandang = Number.isInteger(selectedKandangId) ? kandangs.find((item) => item.id === selectedKandangId) : null; @@ -331,15 +539,13 @@ const buildChickenCountingSummary = async (cycleId, kandangId) => { const cycleStartDate = formatDateForDb(new Date(cycle.startDate)); const today = formatDateForDb(new Date()); - const chickenCounts = await ChickenCount.getByDateRange( - cycleStartDate, - today, - selectedKandangId - ); + const chickenCounts = await ChickenCount.getByDateRange(cycleStartDate, today, selectedKandangId); const latestChickenCount = chickenCounts.length > 0 - ? [...chickenCounts].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())[0] + ? [...chickenCounts].sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() + )[0] : null; const mortalityRecords = await Mortality.getByCycle(cycleId, selectedKandangId); @@ -348,8 +554,16 @@ const buildChickenCountingSummary = async (cycleId, kandangId) => { 0 ); const totalPanen = mortalityRecords.reduce((sum, record) => sum + (record.panen || 0), 0); + const totalBeratPanen = mortalityRecords.reduce( + (sum, record) => sum + (record.beratPanen || 0), + 0 + ); + // A rate needs a denominator. `docInCount` is currently empty for every + // kandang in this database, and reporting 0% for an unknown denominator reads + // as "no deaths at all" — the most reassuring possible reading of missing + // data, next to 5248 recorded deaths. null forces callers to say "unknown". const mortalityRate = - initialPopulation > 0 ? Number(((totalMortality / initialPopulation) * 100).toFixed(2)) : 0; + initialPopulation > 0 ? Number(((totalMortality / initialPopulation) * 100).toFixed(2)) : null; const currentPopulation = latestChickenCount ? latestChickenCount.totalCount : Math.max(0, initialPopulation - totalMortality - totalPanen); @@ -365,8 +579,22 @@ const buildChickenCountingSummary = async (cycleId, kandangId) => { initialPopulation, currentPopulation, totalMortality, - totalPanen, + // Unit-suffixed, because the prompt tells the model every number carries + // its unit in the field name. Bare `totalPanen` left it unable to tell + // ekor from kg, so it left the harvest out of the report entirely. + totalPanen_ekor: totalPanen, + totalBeratPanen_kg: totalBeratPanen > 0 ? Number(totalBeratPanen.toFixed(2)) : null, + // The single most misread figure on this page: population falls for two + // unrelated reasons and only one of them is a loss. + panenCatatan: + totalPanen > 0 + ? `${totalPanen} ekor sudah DIPANEN (keluar kandang dalam keadaan hidup, bukan mati). Penurunan populasi dari ${initialPopulation} ke ${currentPopulation} ekor adalah gabungan mortalitas DAN panen — jangan menyebut selisih populasi sebagai kematian.` + : undefined, mortalityRate, + mortalityRateCatatan: + mortalityRate === null + ? 'Populasi awal (docInCount) tidak terisi di database, sehingga persentase mortalitas TIDAK DAPAT dihitung. Sebut "data tidak tersedia" — jangan mengarang angka persen.' + : undefined, latestChickenCount: latestChickenCount ? { date: latestChickenCount.date, @@ -393,53 +621,600 @@ const enrichContextPack = async (contextPack, cycleId, kandangId) => { }; }; -const pruneContextPackForLocalLLM = (obj) => { +/** Week number requested by a 'weekly' report, e.g. "Minggu 7" -> 7. */ +const parseWeekNumber = (reportPeriod) => { + const match = reportPeriod && String(reportPeriod).match(/\d+/); + const week = match ? parseInt(match[0], 10) : 1; + return Number.isFinite(week) && week > 0 ? week : 1; +}; + +/** + * A day-indexed history (weight per day, FCR per day, ...) as opposed to a + * snapshot list (devices, cameras, coops) that has no time dimension at all. + * Only the former may be sliced by report period — slicing a device list by + * "week 7" would silently drop devices, which is what the previous + * index-based pruning did. + */ +const isDayIndexedArray = (arr) => + arr.length > 0 && + arr.every( + (item) => + item && + typeof item === 'object' && + !Array.isArray(item) && + (typeof item.day === 'number' || typeof item.date === 'string') + ); + +const dayOfItem = (item) => (typeof item.day === 'number' ? item.day : null); + +/** Mean of every numeric field across `items`, carrying non-numeric fields from the last row. */ +const rollUpRows = (items) => { + const sums = {}; + const counts = {}; + + for (const item of items) { + for (const [key, value] of Object.entries(item)) { + if (typeof value === 'number' && Number.isFinite(value)) { + sums[key] = (sums[key] || 0) + value; + counts[key] = (counts[key] || 0) + 1; + } + } + } + + const averaged = {}; + for (const key of Object.keys(sums)) { + averaged[key] = Math.round((sums[key] / counts[key]) * 100) / 100; + } + return averaged; +}; + +/** + * Collapse a full-cycle history so an end-of-cycle report can carry EVERY topic + * (population, weight, feed sacks, IoT, FCR, EEF) at once without overflowing + * the local model's context window: one averaged row per completed week, then + * the final week's days verbatim, because "how did the cycle end" is the + * question an end-cycle report exists to answer. + * + * The result stays an ARRAY of day-bearing rows. Downstream extraction reads + * these fields positionally (e.g. the last element for the closing figures), so + * returning an object here silently knocked the whole report into the local + * fallback generator. + */ +const rollUpByWeek = (items) => { + const weeks = new Map(); + + items.forEach((item, index) => { + const day = dayOfItem(item); + const week = day != null ? Math.ceil(day / 7) : Math.floor(index / 7) + 1; + if (!weeks.has(week)) weeks.set(week, []); + weeks.get(week).push(item); + }); + + const ordered = [...weeks.entries()].sort((a, b) => a[0] - b[0]); + if (ordered.length === 0) return items; + + const lastWeekRows = ordered[ordered.length - 1][1]; + + const summarised = ordered.slice(0, -1).map(([week, rows]) => { + const lastRow = rows[rows.length - 1]; + return { + ...rollUpRows(rows), + // Keep a real day on the row so day-based extraction still works. + day: dayOfItem(lastRow) ?? undefined, + minggu: week, + jumlahHari: rows.length, + ringkasan: 'rata-rata minggu ini', + }; + }); + + return [...summarised, ...lastWeekRows]; +}; + +/** + * Narrow the context pack to exactly the period the user asked for. + * + * daily -> only the latest day present in each history + * weekly N -> only days (N-1)*7+1 .. N*7, matched on the row's own `day` + * field rather than its array position, so gaps in the data do + * not shift the window + * end_cycle -> the whole cycle, rolled up per week, with nothing dropped + */ +const pruneContextPackForLocalLLM = (obj, reportType = 'daily', reportPeriod = null) => { if (!obj || typeof obj !== 'object') { return obj; } if (Array.isArray(obj)) { - // If it's an array, let's see if we should truncate it - if (obj.length > 7) { - // Keep only the last 7 items (latest data) - return obj.slice(-7).map(item => pruneContextPackForLocalLLM(item)); + const recurse = (items) => + items.map((item) => pruneContextPackForLocalLLM(item, reportType, reportPeriod)); + + // Snapshot lists carry no time dimension: keep them whole in every mode. + if (!isDayIndexedArray(obj)) { + return recurse(obj); } - return obj.map(item => pruneContextPackForLocalLLM(item)); + + if (reportType === 'daily') { + const days = obj.map(dayOfItem).filter((day) => day != null); + if (days.length > 0) { + const latestDay = Math.max(...days); + return recurse(obj.filter((item) => dayOfItem(item) === latestDay)); + } + // Date-only history: the last row is the most recent one. + return recurse(obj.slice(-1)); + } + + if (reportType === 'weekly') { + const week = parseWeekNumber(reportPeriod); + const firstDay = (week - 1) * 7 + 1; + const lastDay = week * 7; + const hasDayNumbers = obj.some((item) => dayOfItem(item) != null); + + if (hasDayNumbers) { + // Return the window even when it is empty: a week with no recorded data + // must read as "no data", never as some other week's numbers. + return recurse( + obj.filter((item) => { + const day = dayOfItem(item); + return day != null && day >= firstDay && day <= lastDay; + }) + ); + } + + // Date-only history: the array position is the only ordering we have. + return recurse(obj.slice(firstDay - 1, lastDay)); + } + + if (reportType === 'end_cycle') { + return recurse(rollUpByWeek(obj)); + } + + return recurse(obj); } - // If it's an object, map its keys const pruned = {}; for (const [key, value] of Object.entries(obj)) { - // Drop heavy raw telemetry data keys that are not critical for high-level manager insight + // Raw telemetry is too bulky for a daily/weekly brief, but an end-of-cycle + // evaluation is explicitly meant to cover every data source on the site, + // so it keeps these (already rolled up per week by the branch above). const heavyKeysToDrop = ['deviceWeightData', 'ckaleData', 'liveCounts']; - if (heavyKeysToDrop.includes(key)) { + if (reportType !== 'end_cycle' && heavyKeysToDrop.includes(key)) { continue; } - pruned[key] = pruneContextPackForLocalLLM(value); + pruned[key] = pruneContextPackForLocalLLM(value, reportType, reportPeriod); } return pruned; }; -const generateLocalDashboardInsight = (context) => { +/** Mean of `key` across `rows`, ignoring rows where it is missing or not finite. */ +const meanOfField = (rows, key) => { + if (!Array.isArray(rows)) return null; + const values = rows + .map((row) => (row && typeof row === 'object' ? row[key] : null)) + .filter((value) => typeof value === 'number' && Number.isFinite(value)); + if (values.length === 0) return null; + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + return Math.round(mean * 100) / 100; +}; + +/** First of `keys` that yields a mean over `rows`. Histories disagree on names. */ +const meanOfFirstField = (rows, keys) => { + for (const key of keys) { + const mean = meanOfField(rows, key); + if (mean !== null) return mean; + } + return null; +}; + +/** The day numbers actually present in a windowed history, for labelling. */ +const dayRangeOf = (rows) => { + if (!Array.isArray(rows)) return null; + const days = rows.map((row) => (row && typeof row === 'object' ? dayOfItem(row) : null)); + const present = days.filter((day) => day != null); + if (present.length === 0) return null; + return { + firstDay: Math.min(...present), + lastDay: Math.max(...present), + dayCount: present.length, + }; +}; + +/** Copy of `obj` without `keys` — used to drop unitless duplicates. */ +const omit = (obj, keys) => { + const copy = { ...obj }; + for (const key of keys) delete copy[key]; + return copy; +}; + +const UNAVAILABLE_MARK = 'tidak tersedia'; +const numOrNull = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : null); +const round2 = (value) => Math.round(value * 100) / 100; + +/** + * Attach the computed EEF and the unit labels to an end-cycle pack. + * + * The period rescoping does not apply here — the closing figures are already the + * right ones — but the two fabrication guards do. Without a real `eef.value` the + * model fills the gap itself: an observed end-cycle report announced "Nilai EEF + * 1.78 (naik dari 1.62)", which is simply the FCR pair wearing an EEF label. + */ +const annotateEndCycleScalars = (pack) => { + const current = pack?.weight?.current; + if (!current || typeof current !== 'object') return pack; + + const avg = numOrNull(current.averageWeight); + const target = numOrNull(current.targetWeight); + const fcr = numOrNull(current.fcr); + const day = numOrNull(pack?.metadata?.currentDay); + const mortality = numOrNull(pack?.chickenCounting?.dashboardSummary?.mortalityRate); + const survival = mortality === null ? null : 100 - mortality; + + const eefValue = + survival !== null && avg !== null && fcr && day + ? round2(((survival * (avg / 1000)) / (fcr * day)) * 100) + : null; + + const scoped = { ...pack }; + scoped.weight = { + ...pack.weight, + current: { + ...omit(current, ['deviation', 'deviationPersen']), + // Same unit-in-the-key rule as the weekly path. + bobotRataRata_gram: avg ?? UNAVAILABLE_MARK, + targetBobot_gram: target ?? UNAVAILABLE_MARK, + deviasiBobot_gram: avg !== null && target !== null ? round2(avg - target) : UNAVAILABLE_MARK, + deviasiBobot_persen: + avg !== null && target ? round2(((avg - target) / target) * 100) : UNAVAILABLE_MARK, + fcr_rasio: fcr ?? UNAVAILABLE_MARK, + }, + }; + + if (pack.fcr_eef && typeof pack.fcr_eef === 'object') { + const eefBlock = pack.fcr_eef.eef; + if (eefBlock && typeof eefBlock === 'object') { + scoped.fcr_eef = { + ...pack.fcr_eef, + eef: { + ...omit(eefBlock, [ + 'value', + 'avgWeight', + 'fcr', + 'survivalPersen', + 'umurHari', + 'currentDay', + ]), + nilaiEef_indeksTanpaSatuan: eefValue ?? UNAVAILABLE_MARK, + persenHidup_persen: survival ?? UNAVAILABLE_MARK, + umur_hari: day ?? UNAVAILABLE_MARK, + ...(eefValue === null + ? { + alasanTidakTersedia: + 'EEF tidak dapat dihitung karena persen hidup, bobot, FCR, atau umur tidak tersedia. ' + + 'Tulis "data tidak tersedia" — JANGAN memakai angka FCR atau bobot sebagai nilai EEF.', + } + : {}), + }, + }; + } + } + + return scoped; +}; + +/** + * Bring the pack's scalars into the same period as its histories. + * + * `averageWeight`, `fcr`, `uniformity` and friends are single "as of today" + * figures carrying no `day` of their own, so the array pruning above cannot + * touch them. Left alone they contradict the windowed histories the model reads + * beside them: a "Minggu 1" brief would show weight rows ending at 180 g next to + * `averageWeight: 2400` (the day-48 figure), and the model is free to quote + * either. That is the bug this function exists to close. + * + * Each scalar is recomputed from the ALREADY-WINDOWED history it summarises — + * the mean across the window's days. A scalar with no day-indexed source + * (uniformity, and every status/trend label, which were derived for today) is + * set to null rather than left showing another period's number, because a wrong + * number reads as fact while a null reads as "not available". + * + * End-cycle reports keep their scalars: there the window IS the whole cycle, so + * the closing figures are exactly what the scalars already hold. They still get + * the computed EEF and the unit labels, because the fabrication those prevent + * happens in every mode — an end-cycle run reported "Nilai EEF 1.78" by copying + * the FCR when the eef block carried no value of its own. + */ +const rescopeScalarsToPeriod = (pack, reportType = 'daily', reportPeriod = null) => { + if (!pack || typeof pack !== 'object' || Array.isArray(pack)) return pack; + if (reportType === 'end_cycle') return annotateEndCycleScalars(pack); + + const weight = pack.weight; + if (!weight || typeof weight !== 'object') return pack; + + const weightRows = + Array.isArray(weight.dailyWeightData) && weight.dailyWeightData.length > 0 + ? weight.dailyWeightData + : weight.weightHistory; + const gainRows = weight.dailyWeightGainData; + const fcrRows = weight.fcrComparisonData; + + const averageWeight = meanOfField(weightRows, 'actual'); + const targetWeight = meanOfField(weightRows, 'target'); + const dailyGain = meanOfField(gainRows, 'gain'); + const fcr = meanOfFirstField(fcrRows, ['manualFcr', 'iotFcr']); + const fcrCobbStandard = meanOfField(fcrRows, 'cobbFcr'); + const range = dayRangeOf(weightRows) || dayRangeOf(gainRows) || dayRangeOf(fcrRows); + + const periodLabel = + reportType === 'weekly' + ? `${reportPeriod || 'Minggu ini'} (hari ke-${range?.firstDay ?? '?'} s/d ke-${range?.lastDay ?? '?'})` + : `Harian (hari ke-${range?.lastDay ?? '?'})`; + + const round2 = (value) => Math.round(value * 100) / 100; + + // `cleanPromptValue` strips nulls out of the prompt entirely, so a null scalar + // reaches the model as a MISSING key — indistinguishable from a field that was + // never part of the schema, and easy to fill in from imagination. An explicit + // marker is what actually makes the model write "data tidak tersedia". + const UNAVAILABLE = 'tidak tersedia'; + const orUnavailable = (value) => + value === null || value === undefined || Number.isNaN(value) ? UNAVAILABLE : value; + + // A bare `deviation: -32.86` was read back by the model as "-32.86%" when it + // is grams. Supplying the percentage outright removes the guess. + const deviationGram = + averageWeight !== null && targetWeight !== null ? round2(averageWeight - targetWeight) : null; + const deviationPersen = + deviationGram !== null && targetWeight ? round2((deviationGram / targetWeight) * 100) : null; + const fcrDeviationPersen = + fcr !== null && fcrCobbStandard + ? round2(((fcr - fcrCobbStandard) / fcrCobbStandard) * 100) + : null; + + // EEF was being invented: the block carried only inputs and no value, so the + // model helpfully supplied one (it echoed averageWeight, 327.14, as the EEF). + // Compute it where every input exists, and state plainly when they do not. + const mortalityRate = pack?.chickenCounting?.dashboardSummary?.mortalityRate; + const survivalPersen = + typeof mortalityRate === 'number' && Number.isFinite(mortalityRate) + ? 100 - mortalityRate + : null; + const eefDay = range?.lastDay ?? null; + const eefValue = + survivalPersen !== null && averageWeight !== null && fcr && eefDay + ? round2(((survivalPersen * (averageWeight / 1000)) / (fcr * eefDay)) * 100) + : null; + const eefAlasan = + eefValue !== null + ? undefined + : survivalPersen === null + ? 'EEF tidak dapat dihitung: persentase hidup tidak diketahui karena populasi awal (docInCount) kosong di database. Jangan mengarang nilai EEF.' + : 'EEF tidak dapat dihitung: bobot rata-rata atau FCR periode ini tidak tersedia. Jangan mengarang nilai EEF.'; + + const scoped = { ...pack }; + + // Drop the original unitless numeric keys rather than spreading them through: + // leaving `averageWeight: 2400` beside `bobotRataRata_gram: 327.14` would hand + // the model both the stale figure and the period one and let it pick. + const carriedCurrent = { + ...(weight.current && typeof weight.current === 'object' ? weight.current : {}), + }; + for (const key of [ + 'averageWeight', + 'targetWeight', + 'deviation', + 'deviationPersen', + 'dailyGain', + 'fcr', + 'fcrCobbStandard', + 'fcrDeviationPersen', + ]) { + delete carriedCurrent[key]; + } + + scoped.weight = { + ...weight, + current: { + ...carriedCurrent, + periode: periodLabel, + // The unit lives in the KEY, not in a lookup table beside it: given a bare + // `deviation` plus a separate `satuan` map, the model quoted "-4 gram" for + // a value that was -4 PERCENT. A key it cannot read without reading the + // unit removes the choice. + bobotRataRata_gram: orUnavailable(averageWeight), + targetBobot_gram: orUnavailable(targetWeight), + deviasiBobot_gram: orUnavailable(deviationGram), + deviasiBobot_persen: orUnavailable(deviationPersen), + pertambahanHarian_gramPerHari: orUnavailable(dailyGain), + fcr_rasio: orUnavailable(fcr), + fcrStandarCobb_rasio: orUnavailable(fcrCobbStandard), + deviasiFcr_persen: orUnavailable(fcrDeviationPersen), + // No day-indexed source exists for these, so they cannot be re-scoped. + status: UNAVAILABLE, + dailyGainStatus: UNAVAILABLE, + uniformity: UNAVAILABLE, + uniformityStatus: UNAVAILABLE, + fcrStatus: UNAVAILABLE, + fcrTrend: UNAVAILABLE, + catatan: + `Semua angka di blok ini adalah rata-rata periode ${periodLabel}` + + `${range ? ` dari ${range.dayCount} hari data` : ''}, BUKAN kondisi hari ini. ` + + `Field yang berisi "${UNAVAILABLE}" memang tidak ada datanya untuk periode ini — ` + + 'WAJIB tulis "data tidak tersedia" untuk field itu dan JANGAN mengarang angkanya. ' + + 'Satuan setiap angka sudah tertulis di akhir nama field ' + + '(_gram, _persen, _rasio, _gramPerHari) — pakai satuan itu persis, jangan menggantinya.', + }, + }; + + // The same figures are mirrored under fcr_eef; leaving those stale would just + // reintroduce the contradiction one key over. + if (pack.fcr_eef && typeof pack.fcr_eef === 'object') { + const { fcr: fcrBlock, eef: eefBlock, ...restFcrEef } = pack.fcr_eef; + scoped.fcr_eef = { + ...restFcrEef, + ...(fcrBlock && typeof fcrBlock === 'object' + ? { + fcr: { + ...omit(fcrBlock, ['actual', 'cobbStandard', 'deviationPersen']), + periode: periodLabel, + aktual_rasio: orUnavailable(fcr), + standarCobb_rasio: orUnavailable(fcrCobbStandard), + deviasi_persen: orUnavailable(fcrDeviationPersen), + status: UNAVAILABLE, + trend: UNAVAILABLE, + }, + } + : {}), + ...(eefBlock && typeof eefBlock === 'object' + ? { + eef: { + ...omit(eefBlock, [ + 'value', + 'avgWeight', + 'fcr', + 'survivalPersen', + 'umurHari', + 'currentDay', + ]), + periode: periodLabel, + // `value` is the only field the model may quote as "the EEF". + nilaiEef_indeksTanpaSatuan: orUnavailable(eefValue), + bobotRataRata_gram: orUnavailable(averageWeight), + fcr_rasio: orUnavailable(fcr), + persenHidup_persen: orUnavailable(survivalPersen), + umur_hari: orUnavailable(eefDay), + catatan: + 'EEF adalah indeks performa AKHIR SIKLUS. Nilai pada periode berjalan ' + + `(hari ke-${eefDay ?? '?'}) wajar rendah karena ayam belum mencapai bobot panen — ` + + 'JANGAN bandingkan dengan standar >300 yang berlaku untuk umur panen, ' + + 'dan jangan menyimpulkan performa buruk hanya dari angka ini.', + ...(eefAlasan ? { alasanTidakTersedia: eefAlasan } : {}), + }, + } + : {}), + }; + } + + if (range && pack.metadata && typeof pack.metadata === 'object') { + scoped.metadata = { + ...pack.metadata, + periode: periodLabel, + periodeHariPertama: range.firstDay, + periodeHariTerakhir: range.lastDay, + }; + } + + return scoped; +}; + +const generateLocalDashboardInsight = (context, reportType = 'daily', reportPeriod = null) => { // Ambil umur ayam untuk referensi standar CP 707 - const dayAge = context?.cycle?.currentAgeDay || - context?.ageDay || - context?.umurHari || - null; + const cycleDay = + context?.metadata?.currentDay || + context?.cycle?.currentAgeDay || + context?.ageDay || + context?.umurHari || + null; + + /** + * A weekly report describes a PAST window, so it must be headlined and graded + * at that window's age — not at today's. + * + * Both were taken from `currentDay`, so a "Minggu 1" report announced + * "Hari ke-48" and compared week-1 figures against the day-48 CP 707 + * standard. The bounds come from the week number rather than from the data, + * so a week with no rows still reports its own days instead of falling back + * to today. `Math.min` keeps a week that runs past the cycle from claiming a + * day that has not happened yet. + */ + const weekNumber = reportType === 'weekly' ? parseWeekNumber(reportPeriod) : null; + const weekFirstDay = weekNumber === null ? null : (weekNumber - 1) * 7 + 1; + const weekLastDay = + weekNumber === null ? null : cycleDay ? Math.min(weekNumber * 7, cycleDay) : weekNumber * 7; + const dayAge = weekLastDay ?? cycleDay; + /** How the period should be named in headlines. */ + const periodeText = + reportType === 'weekly' + ? `${reportPeriod || 'Minggu ini'}, hari ke-${weekFirstDay} s/d ke-${weekLastDay}` + : null; // ─── 1. Hitung Ayam (Mortalitas) ───────────────────────────────────────── - const cc = context?.chickenCounting?.dashboardSummary || {}; - const ccInitial = cc.initialPopulation || 10000; - const ccCurrent = cc.currentPopulation || 9800; - const ccMortality = cc.totalMortality || (ccInitial - ccCurrent); - const ccRate = cc.mortalityRate !== undefined ? cc.mortalityRate : (ccInitial > 0 ? (ccMortality / ccInitial * 100) : 0); + const isNum = (value) => typeof value === 'number' && Number.isFinite(value); + const cc = + context?.chickenCounting?.dashboardSummary || context?.chickenCounting?.cameraSummary || {}; + // These used to fall back to a hardcoded 10000 / 9800 flock. With no data at + // all the report then read "Populasi awal: 10000 ekor, saat ini: 9800 ekor" — + // a fully invented flock, stated as fact, and reassuring on top of it. Unknown + // has to stay unknown. + const firstNumber = (...values) => values.find((value) => isNum(value) && value > 0) ?? null; + const ccInitial = firstNumber(cc.initialPopulation, context?.metadata?.initialPopulation); + const ccCurrent = firstNumber(cc.currentPopulation, cc.estimatedPopulation); + + /** + * Cumulative deaths+culls at the END of the reported window. + * + * `chickenCounting.mortality` holds cycle totals only, so a weekly report + * graded the day-48 cumulative rate against its own week's CP 707 standard. + * The history is day-indexed and therefore already sliced to the window, and + * each row carries the running cumulative — so its last row is the figure the + * cumulative standard expects. End-of-cycle rows are rolled up per week and + * lose that shape, so it keeps using the cycle totals. + */ + const mortalityRows = context?.chickenCounting?.mortalityHistory; + const periodMortality = + reportType !== 'end_cycle' && Array.isArray(mortalityRows) && mortalityRows.length > 0 + ? mortalityRows[mortalityRows.length - 1] + : null; + + const ccMortality = + (periodMortality && isNum(periodMortality.kumulatifMatiAfkir) + ? periodMortality.kumulatifMatiAfkir + : null) ?? + firstNumber(cc.totalMortality, context?.chickenCounting?.mortality?.mati) ?? + (ccInitial !== null && ccCurrent !== null ? ccInitial - ccCurrent : null); + const ekorText = (value) => (value === null ? 'data tidak tersedia' : `${value} ekor`); + + // ─── Evaluasi CP 707 ─────────────────────────────────────────────────── + let headline = `Laporan Harian (Hari ke-${dayAge || '?'}): Evaluasi Performa Kandang CP 707.`; + if (reportType === 'weekly') { + headline = `Laporan Mingguan (${periodeText}): Evaluasi Performa Kandang CP 707.`; + } else if (reportType === 'end_cycle') { + headline = `Laporan Akhir Siklus (Hari ke-${dayAge || '?'}): Evaluasi Keseluruhan Performa Kandang CP 707.`; + } + + // `mortalityRate` is null when the population denominator is missing, so the + // rate is genuinely unknown here — every reader below must handle that rather + // than print a confident 0.00%. + // The period's own cumulative rate outranks the cycle-wide one, for the same + // reason as `ccMortality` above. Computed here rather than sent ready-made, + // because `ccInitial` is resolved from the enriched pack — the frontend's own + // flock size falls back to a hardcoded number when `docInCount` is null. + const periodRate = + periodMortality && + isNum(periodMortality.kumulatifMatiAfkir) && + ccInitial !== null && + ccInitial > 0 + ? (periodMortality.kumulatifMatiAfkir / ccInitial) * 100 + : null; + + const ccRate = + periodRate !== null + ? periodRate + : isNum(cc.mortalityRate) + ? cc.mortalityRate + : isNum(context?.chickenCounting?.mortality?.mortalityCount) + ? context.chickenCounting.mortality.mortalityCount + : ccInitial !== null && ccInitial > 0 && ccMortality !== null + ? (ccMortality / ccInitial) * 100 + : null; + const ccRateText = ccRate === null ? 'data tidak tersedia' : `${ccRate.toFixed(2)}%`; // Analisis mortalitas berdasarkan standar CP 707 - const mortalityAnalysis = dayAge ? cp707.analyzeMortality(ccRate, dayAge) : null; + const mortalityAnalysis = + dayAge && ccRate !== null ? cp707.analyzeMortality(ccRate, dayAge) : null; const stdMortalityCum = dayAge ? cp707.getMortalityCumStdByDay(dayAge) : null; - - let statusAyam = mortalityAnalysis?.status || 'ok'; - if (!mortalityAnalysis) { + + // An unknown rate cannot be graded, so it must not read as a pass. + let statusAyam = mortalityAnalysis?.status || (ccRate === null ? 'unknown' : 'ok'); + if (!mortalityAnalysis && ccRate !== null) { if (ccRate > 7) statusAyam = 'critical'; else if (ccRate >= 5) statusAyam = 'warning'; } @@ -447,40 +1222,87 @@ const generateLocalDashboardInsight = (context) => { const hitung_ayam = { status: statusAyam, bullets: [ - `Mortalitas kumulatif: ${ccRate.toFixed(2)}%. Standar CP 707 pada hari ke-${dayAge || '?'}: ${stdMortalityCum ? stdMortalityCum + '%' : '<5%'}.`, - `Populasi awal: ${ccInitial} ekor, saat ini: ${ccCurrent} ekor. Total deplesi: ${ccMortality} ekor.` + `Mortalitas kumulatif: ${ccRateText}. Standar CP 707 pada hari ke-${dayAge || '?'}: ${stdMortalityCum ? stdMortalityCum + '%' : '<5%'}.`, + `Populasi awal: ${ekorText(ccInitial)}, saat ini: ${ekorText(ccCurrent)}. Total deplesi: ${ekorText(ccMortality)}.`, + // Without this the fallback reported a flock shrinking from 25000 to 2 + // and attributed all of it to deplesi, because harvest was never named. + ...(isNum(cc.totalPanen_ekor) && cc.totalPanen_ekor > 0 + ? [ + `Sudah dipanen: ${cc.totalPanen_ekor} ekor${ + isNum(cc.totalBeratPanen_kg) ? ` (${cc.totalBeratPanen_kg} kg)` : '' + } — keluar kandang hidup, bukan bagian dari deplesi.`, + ] + : []), ], - evidence: statusAyam !== 'ok' ? [ - mortalityAnalysis?.message || `Deplesi ${ccRate.toFixed(2)}% melampaui standar CP 707 (<5%).` - ] : [], - actions: statusAyam === 'critical' - ? [ - "Lakukan bedah bangkai (nekropsi) segera untuk identifikasi penyakit (sesuai buku CP 707 - Biosekuriti).", - "Keluarkan ayam mati setiap hari dan desinfektan kandang (sesuai prosedur biosekuriti CP 707).", - "Perketat akses masuk kandang dan semprot disinfektan semua kendaraan." - ] - : statusAyam === 'warning' - ? [ - "Periksa kualitas litter: sekam basah diambil dan diganti sekam baru untuk cegah amoniak tinggi (buku CP 707 - Manajemen Litter).", - "Cek kualitas air minum: pastikan kadar klorin 3-5 ppm dan bebas bakteri patogen (standar CP 707)." - ] - : ["Pertahankan pengawasan harian dan sanitasi rutin sesuai standar CP 707."] + evidence: + statusAyam !== 'ok' + ? [ + mortalityAnalysis?.message || + (ccRate === null + ? 'Persentase deplesi tidak dapat dihitung: populasi awal tidak tersedia.' + : `Deplesi ${ccRate.toFixed(2)}% melampaui standar CP 707 (<5%).`), + ] + : [], + actions: + statusAyam === 'critical' + ? [ + 'Lakukan bedah bangkai (nekropsi) segera untuk identifikasi penyakit (sesuai buku CP 707 - Biosekuriti).', + 'Keluarkan ayam mati setiap hari dan desinfektan kandang (sesuai prosedur biosekuriti CP 707).', + 'Perketat akses masuk kandang dan semprot disinfektan semua kendaraan.', + ] + : statusAyam === 'warning' + ? [ + 'Periksa kualitas litter: sekam basah diambil dan diganti sekam baru untuk cegah amoniak tinggi (buku CP 707 - Manajemen Litter).', + 'Cek kualitas air minum: pastikan kadar klorin 3-5 ppm dan bebas bakteri patogen (standar CP 707).', + ] + : ['Pertahankan pengawasan harian dan sanitasi rutin sesuai standar CP 707.'], }; // ─── 2. Berat Ayam ─────────────────────────────────────────────────────── - const wm = context?.weightStats || context?.deviceWeightData || {}; - const wmAvg = wm.averageWeight?.value || null; - const wmTarget = dayAge ? cp707.getBwStandardByDay(dayAge) : (wm.targetWeight?.value || null); - const wmUniformity = wm.uniformity?.value || null; + const wm = context?.weight?.current || context?.weightStats || context?.deviceWeightData || {}; - let statusBerat = 'ok'; + /** + * Latest recorded weight from the day-indexed histories. + * + * `weight.current.averageWeight` arrives as 0 for this farm — the dashboard + * shows "BW: N/A" — but the growth chart beside it is fully populated, so the + * weight is on hand, just not in the scalar. Reading only the scalar made the + * report say "Data bobot badan tidak tersedia" about data the user can see + * plotted on the same screen. + */ + const latestFromHistory = (rows, field) => { + if (!Array.isArray(rows) || rows.length === 0) return null; + const withDay = rows + .filter((row) => row && isNum(row[field]) && row[field] > 0) + .sort((a, b) => (dayOfItem(a) ?? 0) - (dayOfItem(b) ?? 0)); + return withDay.length > 0 ? withDay[withDay.length - 1][field] : null; + }; + + const positiveOrNull = (value) => { + const n = isNum(value?.value) ? value.value : isNum(value) ? value : null; + return n !== null && n > 0 ? n : null; + }; + + const wmAvg = + positiveOrNull(wm.averageWeight) ?? + latestFromHistory(context?.weight?.dailyWeightData, 'actual') ?? + latestFromHistory(context?.weight?.weightHistory, 'actual'); + + const wmTarget = dayAge + ? cp707.getBwStandardByDay(dayAge) + : (positiveOrNull(wm.targetWeight) ?? + latestFromHistory(context?.weight?.dailyWeightData, 'target')); + const wmUniformity = positiveOrNull(wm.uniformity); + + // An unmeasured bird cannot be graded "ok". + let statusBerat = wmAvg === null ? 'unknown' : 'ok'; let bwAnalysis = null; - + if (wmAvg !== null && dayAge !== null) { bwAnalysis = cp707.analyzeBw(wmAvg, dayAge); statusBerat = bwAnalysis.status; } else if (wmAvg !== null && wmTarget !== null) { - const diffPct = ((wmTarget - wmAvg) / wmTarget * 100); + const diffPct = ((wmTarget - wmAvg) / wmTarget) * 100; if (diffPct > 15) statusBerat = 'critical'; else if (diffPct > 5) statusBerat = 'warning'; } @@ -492,31 +1314,57 @@ const generateLocalDashboardInsight = (context) => { status: statusBerat, bullets: [ `Bobot aktual: ${bwDisplayAvg}g. Target standar CP 707 hari ke-${dayAge || '?'}: ${bwDisplayTarget}g.`, - wmUniformity !== null ? `Keseragaman (uniformity): ${wmUniformity}%. Standar CP 707: >80%.` : 'Data uniformity tidak tersedia.' + wmUniformity !== null + ? `Keseragaman (uniformity): ${wmUniformity}%. Standar CP 707: >80%.` + : 'Data uniformity tidak tersedia.', ], - evidence: statusBerat !== 'ok' ? [ - bwAnalysis?.message || `Bobot badan di bawah target standar CP 707 hari ke-${dayAge}.` - ] : [], - actions: statusBerat === 'critical' - ? [ - "Lakukan grading: pisahkan ayam kecil dengan sekat pembatas (sesuai buku CP 707 - Manajemen Litter).", - "Evaluasi kecukupan ruang pakan: bibir feeder harus sejajar bagian atas dada ayam (standar CP 707).", - "Tingkatkan frekuensi pemberian pakan: umur >15 hari minimal 2x sehari (CP 707 - Sistem Pemberian Pakan)." - ] - : statusBerat === 'warning' - ? [ - "Tingkatkan stimulasi pakan di siang hari: isi tempat pakan sedikit demi sedikit tapi sering (buku CP 707).", - "Sesuaikan ketinggian tempat pakan: bibir feeder pan sejajar bagian atas dada ayam (standar CP 707)." - ] - : ["Pertahankan manajemen pencahayaan 23 jam nyala untuk periode grower (buku CP 707 - Program Pencahayaan)."] + evidence: + statusBerat !== 'ok' + ? [bwAnalysis?.message || `Bobot badan di bawah target standar CP 707 hari ke-${dayAge}.`] + : [], + actions: + statusBerat === 'critical' + ? [ + 'Lakukan grading: pisahkan ayam kecil dengan sekat pembatas (sesuai buku CP 707 - Manajemen Litter).', + 'Evaluasi kecukupan ruang pakan: bibir feeder harus sejajar bagian atas dada ayam (standar CP 707).', + 'Tingkatkan frekuensi pemberian pakan: umur >15 hari minimal 2x sehari (CP 707 - Sistem Pemberian Pakan).', + ] + : statusBerat === 'warning' + ? [ + 'Tingkatkan stimulasi pakan di siang hari: isi tempat pakan sedikit demi sedikit tapi sering (buku CP 707).', + 'Sesuaikan ketinggian tempat pakan: bibir feeder pan sejajar bagian atas dada ayam (standar CP 707).', + ] + : [ + 'Pertahankan manajemen pencahayaan 23 jam nyala untuk periode grower (buku CP 707 - Program Pencahayaan).', + ], }; // ─── 3. Panel IoT ──────────────────────────────────────────────────────── - const iot = context?.panel_iot || context?.telemetry || context?.iotData || {}; - const temp = iot.avgTemp || iot.suhu_rata_rata_C || null; - const hum = iot.humidity || iot.kelembapan_persen || null; - const amonia = iot.ammonia || iot.amonia_ppm || null; - const co2 = iot.co2 || iot.co2_ppm || null; + const iot = + context?.iotPanel?.pembacaan || + context?.iotPanel?.display || + context?.panel_iot || + context?.telemetry || + context?.iotData || + {}; + // Readings now arrive already formatted and localised ("32,1 °C", "62,8 %"), + // because the raw sensor encoding is ten times the real value and reporting it + // verbatim produced "suhu 316°C". Strip the unit and accept the Indonesian + // decimal comma; a bare number still parses exactly as before. + const parseVal = (v) => { + if (v === undefined || v === null || v === '') return null; + if (typeof v === 'number') return Number.isFinite(v) ? v : null; + const cleaned = String(v) + .replace(/[^\d,.-]/g, '') + .replace(/\.(?=\d{3}\b)/g, '') + .replace(',', '.'); + const n = Number(cleaned); + return Number.isFinite(n) ? n : null; + }; + const temp = parseVal(iot.suhuRataRata ?? iot.avgTemp ?? iot.suhu_rata_rata_C); + const hum = parseVal(iot.kelembapan ?? iot.humidity ?? iot.kelembapan_persen); + const amonia = parseVal(iot.amonia ?? iot.ammonia ?? iot.amonia_ppm); + const co2 = parseVal(iot.co2 ?? iot.co2_ppm); let statusIot = 'ok'; const panel_iot = { status: 'ok', bullets: [], evidence: [], actions: [] }; @@ -531,13 +1379,21 @@ const generateLocalDashboardInsight = (context) => { } else { // Analisis suhu berdasarkan standar CP 707 if (temp !== null && dayAge !== null) { - const envAnalysis = cp707.analyzeEnvironment(temp, hum || 65, amonia || 0, dayAge); + const envAnalysis = cp707.analyzeEnvironment(temp, hum, amonia, dayAge); statusIot = envAnalysis.status; const tempStd = envAnalysis.tempStd; - - panel_iot.bullets.push(`Suhu: ${temp.toFixed(1)}°C (standar CP 707 hari ke-${dayAge}: ${tempStd.temp_C}°C).`); - if (hum !== null) panel_iot.bullets.push(`Kelembapan: ${hum.toFixed(1)}% (standar CP 707: ${tempStd.humidity_pct_min}%-${tempStd.humidity_pct_max}%).`); - if (amonia !== null) panel_iot.bullets.push(`Amonia: ${amonia.toFixed(1)} ppm (standar CP 707 ideal: <10 ppm, batas kritis: >25 ppm).`); + + panel_iot.bullets.push( + `Suhu: ${temp.toFixed(1)}°C (standar CP 707 hari ke-${dayAge}: ${tempStd.temp_C}°C).` + ); + if (hum !== null) + panel_iot.bullets.push( + `Kelembapan: ${hum.toFixed(1)}% (standar CP 707: ${tempStd.humidity_pct_min}%-${tempStd.humidity_pct_max}%).` + ); + if (amonia !== null) + panel_iot.bullets.push( + `Amonia: ${amonia.toFixed(1)} ppm (standar CP 707 ideal: <10 ppm, batas kritis: >25 ppm).` + ); if (envAnalysis.issues.length > 0) { panel_iot.evidence = envAnalysis.issues.slice(0, 3); @@ -545,19 +1401,28 @@ const generateLocalDashboardInsight = (context) => { // Tindakan berdasarkan standar CP 707 if (temp > tempStd.temp_C + 2) { - panel_iot.actions.push(temp > 34 - ? "Nyalakan semua cooling pad dan exhaust fan. Target Efektif Temperatur (TET) CP 707 harus terpenuhi." - : "Tingkatkan kecepatan angin (wind chill effect) sesuai tabel TET buku CP 707." + panel_iot.actions.push( + temp > 34 + ? 'Nyalakan semua cooling pad dan exhaust fan. Target Efektif Temperatur (TET) CP 707 harus terpenuhi.' + : 'Tingkatkan kecepatan angin (wind chill effect) sesuai tabel TET buku CP 707.' ); } else if (temp < tempStd.temp_C - 3) { - panel_iot.actions.push("Aktifkan heater: nyalakan 2-3 jam sebelum DOC/ayam membutuhkan (standar CP 707 - Periode Brooding)."); + panel_iot.actions.push( + 'Aktifkan heater: nyalakan 2-3 jam sebelum DOC/ayam membutuhkan (standar CP 707 - Periode Brooding).' + ); } if (amonia !== null && amonia > 10) { - panel_iot.actions.push("Gemburkan sekam basah dan ganti dengan sekam baru untuk turunkan amonia (Manajemen Litter CP 707)."); - panel_iot.actions.push("Tingkatkan ventilasi minimum: kebutuhan 4 CFM per 1 kg ayam (standar CP 707 - Manajemen Ventilasi)."); + panel_iot.actions.push( + 'Gemburkan sekam basah dan ganti dengan sekam baru untuk turunkan amonia (Manajemen Litter CP 707).' + ); + panel_iot.actions.push( + 'Tingkatkan ventilasi minimum: kebutuhan 4 CFM per 1 kg ayam (standar CP 707 - Manajemen Ventilasi).' + ); } if (co2 !== null && co2 > 3000) { - panel_iot.actions.push(`CO2 ${co2} ppm mendekati/melampaui batas kritis 3000 ppm (Lampiran 3 CP 707). Tingkatkan pertukaran udara segera.`); + panel_iot.actions.push( + `CO2 ${co2} ppm mendekati/melampaui batas kritis 3000 ppm (Lampiran 3 CP 707). Tingkatkan pertukaran udara segera.` + ); if (statusIot !== 'critical') statusIot = 'warning'; } } else if (temp !== null) { @@ -565,17 +1430,28 @@ const generateLocalDashboardInsight = (context) => { panel_iot.bullets.push(`Suhu kandang: ${temp.toFixed(1)}°C.`); if (hum !== null) panel_iot.bullets.push(`Kelembapan: ${hum.toFixed(1)}%.`); const isAmoniaCritical = amonia !== null && amonia > 25; - if (isAmoniaCritical) { statusIot = 'critical'; panel_iot.evidence.push(`Amonia ${amonia.toFixed(1)} ppm melampaui batas kritis CP 707 (>25 ppm).`); } - else if (amonia !== null && amonia > 10) { statusIot = 'warning'; panel_iot.evidence.push(`Amonia ${amonia.toFixed(1)} ppm di atas ideal CP 707 (<10 ppm).`); } + if (isAmoniaCritical) { + statusIot = 'critical'; + panel_iot.evidence.push( + `Amonia ${amonia.toFixed(1)} ppm melampaui batas kritis CP 707 (>25 ppm).` + ); + } else if (amonia !== null && amonia > 10) { + statusIot = 'warning'; + panel_iot.evidence.push(`Amonia ${amonia.toFixed(1)} ppm di atas ideal CP 707 (<10 ppm).`); + } } panel_iot.status = statusIot; if (panel_iot.actions.length === 0) { - panel_iot.actions.push("Pertahankan pengawasan ventilasi otomatis. Kalibrasi sensor temptron setiap periode (buku CP 707)."); + panel_iot.actions.push( + 'Pertahankan pengawasan ventilasi otomatis. Kalibrasi sensor temptron setiap periode (buku CP 707).' + ); } } // ─── 4. FCR ────────────────────────────────────────────────────────────── - const fcrVal = context?.fcr || context?.fcr_terakhir || context?.fcrSummary?.current || null; + const fcrObj = + context?.fcr_eef?.fcr || context?.fcr || context?.fcr_terakhir || context?.fcrSummary || {}; + const fcrVal = fcrObj.actual || fcrObj.current || fcrObj.value || null; const stdFcr = dayAge ? cp707.getFcrStandardByDay(dayAge) : null; let statusFcr = 'ok'; let fcrAnalysis = null; @@ -584,7 +1460,7 @@ const generateLocalDashboardInsight = (context) => { fcrAnalysis = cp707.analyzeFcr(fcrVal, dayAge); statusFcr = fcrAnalysis.status; } else if (fcrVal !== null) { - if (fcrVal > 1.70) statusFcr = 'critical'; + if (fcrVal > 1.7) statusFcr = 'critical'; else if (fcrVal > 1.65) statusFcr = 'warning'; } @@ -593,24 +1469,36 @@ const generateLocalDashboardInsight = (context) => { bullets: [ fcrVal !== null ? `FCR aktual: ${fcrVal.toFixed(2)}. Standar CP 707 hari ke-${dayAge || '?'}: ${stdFcr ? stdFcr.toFixed(3) : 'N/A'}.` - : 'Data FCR tidak tersedia.' + : 'Data FCR tidak tersedia.', ], evidence: statusFcr !== 'ok' && fcrAnalysis ? [fcrAnalysis.message] : [], - actions: statusFcr === 'critical' - ? [ - "Periksa potensi pakan tercecer: ketinggian alas feeder harus sejajar bagian atas dada ayam (buku CP 707).", - "Evaluasi kadar fines dalam pakan. Distribusi pakan harus merata di seluruh sistem (standar CP 707)." - ] - : statusFcr === 'warning' - ? [ - "Atur ketinggian piringan pakan (pan feeder) agar tidak terlalu tinggi/rendah (buku CP 707 - Sistem Pemberian Pakan).", - "Pastikan frekuensi pemberian pakan sesuai umur: >15 hari minimal 2x sehari (standar CP 707)." - ] - : ["Pertahankan ketepatan pemberian pakan harian sesuai standar konsumsi CP 707."] + actions: + statusFcr === 'critical' + ? [ + 'Periksa potensi pakan tercecer: ketinggian alas feeder harus sejajar bagian atas dada ayam (buku CP 707).', + 'Evaluasi kadar fines dalam pakan. Distribusi pakan harus merata di seluruh sistem (standar CP 707).', + ] + : statusFcr === 'warning' + ? [ + 'Atur ketinggian piringan pakan (pan feeder) agar tidak terlalu tinggi/rendah (buku CP 707 - Sistem Pemberian Pakan).', + 'Pastikan frekuensi pemberian pakan sesuai umur: >15 hari minimal 2x sehari (standar CP 707).', + ] + : ['Pertahankan ketepatan pemberian pakan harian sesuai standar konsumsi CP 707.'], }; // ─── 5. EEF ────────────────────────────────────────────────────────────── - const eefVal = context?.eef || context?.eef_terakhir || null; + const eefObj = context?.fcr_eef?.eef || context?.eef || context?.eef_terakhir || {}; + let eefVal = eefObj.value || eefObj.actual || null; + + // EEF needs a survival rate. Treating an unknown mortality rate as 0 would + // assume a perfect flock and inflate the score, so the index stays unreported + // instead — an absent EEF is recoverable, a flattering wrong one is not. + if (eefVal === null && fcrVal && wmAvg && dayAge && ccRate !== null) { + const survivalRate = 100 - ccRate; + const weightKg = wmAvg / 1000; + eefVal = ((survivalRate * weightKg) / (fcrVal * dayAge)) * 100; + } + let statusEef = 'ok'; if (eefVal !== null) { if (eefVal < 200) statusEef = 'critical'; @@ -622,17 +1510,23 @@ const generateLocalDashboardInsight = (context) => { bullets: [ eefVal !== null ? `EEF (European Efficiency Factor): ${eefVal.toFixed(0)}. Target standar peternakan modern: >300.` - : 'Data EEF tidak tersedia.' + : 'Data EEF tidak tersedia.', ], - evidence: statusEef !== 'ok' && eefVal !== null - ? [`Skor EEF ${eefVal.toFixed(0)} di bawah standar. EEF dipengaruhi oleh mortalitas (${ccRate.toFixed(2)}%) dan FCR (${fcrVal ? fcrVal.toFixed(2) : 'N/A'}).`] - : [], - actions: statusEef !== 'ok' - ? [ - "Identifikasi faktor utama: apakah deplesi tinggi atau FCR tinggi yang paling mempengaruhi EEF.", - "Optimalkan iklim mikro kandang sesuai Target Efektif Temperatur (TET) buku CP 707." - ] - : ["Pertahankan performa optimal: kendalikan mortalitas <5% dan FCR sesuai standar CP 707 hingga panen."] + evidence: + statusEef !== 'ok' && eefVal !== null + ? [ + `Skor EEF ${eefVal.toFixed(0)} di bawah standar. EEF dipengaruhi oleh mortalitas (${ccRateText}) dan FCR (${fcrVal ? fcrVal.toFixed(2) : 'N/A'}).`, + ] + : [], + actions: + statusEef !== 'ok' + ? [ + 'Identifikasi faktor utama: apakah deplesi tinggi atau FCR tinggi yang paling mempengaruhi EEF.', + 'Optimalkan iklim mikro kandang sesuai Target Efektif Temperatur (TET) buku CP 707.', + ] + : [ + 'Pertahankan performa optimal: kendalikan mortalitas <5% dan FCR sesuai standar CP 707 hingga panen.', + ], }; // ─── 6. Hitung Karung ──────────────────────────────────────────────────── @@ -647,16 +1541,20 @@ const generateLocalDashboardInsight = (context) => { const hitung_karung = { status: sisa !== null ? statusKarung : 'unknown', bullets: [ - sisa !== null - ? `Stok pakan tersisa: ${sisa} karung.` - : 'Data stok pakan tidak tersedia.' + sisa !== null ? `Stok pakan tersisa: ${sisa} karung.` : 'Data stok pakan tidak tersedia.', ], - evidence: statusKarung !== 'ok' && sisa !== null ? [`Persediaan pakan ${sisa} karung menipis. Segera lakukan pengadaan.`] : [], - actions: statusKarung === 'critical' - ? ["Segera hubungi logistik/supplier pakan CP untuk pengiriman darurat."] - : statusKarung === 'warning' - ? ["Ajukan permintaan pakan baru hari ini. Pantau stok harian (buku CP 707 - Persiapan Kandang)."] - : ["Lakukan stock opname pakan harian sesuai prosedur CP 707."] + evidence: + statusKarung !== 'ok' && sisa !== null + ? [`Persediaan pakan ${sisa} karung menipis. Segera lakukan pengadaan.`] + : [], + actions: + statusKarung === 'critical' + ? ['Segera hubungi logistik/supplier pakan CP untuk pengiriman darurat.'] + : statusKarung === 'warning' + ? [ + 'Ajukan permintaan pakan baru hari ini. Pantau stok harian (buku CP 707 - Persiapan Kandang).', + ] + : ['Lakukan stock opname pakan harian sesuai prosedur CP 707.'], }; // ─── 7. Data Quality ────────────────────────────────────────────────────── @@ -671,25 +1569,48 @@ const generateLocalDashboardInsight = (context) => { bullets: [ missingData.length > 0 ? `Data tidak lengkap: ${missingData.join(', ')}. Analisis menggunakan data yang tersedia.` - : 'Semua data operasional tersedia dan sinkron.' + : 'Semua data operasional tersedia dan sinkron.', ], - evidence: missingData.length > 0 ? [`${missingData.length} parameter data kosong: ${missingData.join(', ')}.`] : [], - actions: missingData.length > 0 - ? ['Lengkapi data operasional harian: timbang ayam sampel, catat pakan, dan pastikan sensor IoT aktif.'] - : ['Pertahankan pemeliharaan berkala sensor IoT dan pencatatan data harian.'] + evidence: + missingData.length > 0 + ? [`${missingData.length} parameter data kosong: ${missingData.join(', ')}.`] + : [], + actions: + missingData.length > 0 + ? [ + 'Lengkapi data operasional harian: timbang ayam sampel, catat pakan, dan pastikan sensor IoT aktif.', + ] + : ['Pertahankan pemeliharaan berkala sensor IoT dan pencatatan data harian.'], }; // ─── Summary ────────────────────────────────────────────────────────────── - const allStatuses = [statusAyam, statusBerat, statusIot === 'unknown' ? 'ok' : statusIot, statusFcr, statusEef === 'unknown' ? 'ok' : statusEef, statusKarung === 'unknown' ? 'ok' : statusKarung]; + const allStatuses = [ + statusAyam, + statusBerat, + statusIot === 'unknown' ? 'ok' : statusIot, + statusFcr, + statusEef === 'unknown' ? 'ok' : statusEef, + statusKarung === 'unknown' ? 'ok' : statusKarung, + ]; let overallStatus = 'ok'; if (allStatuses.includes('critical')) overallStatus = 'critical'; else if (allStatuses.includes('warning')) overallStatus = 'warning'; - const headline = overallStatus === 'critical' - ? `Analisis Kritis (Hari ke-${dayAge || '?'}): Ditemukan parameter operasional di luar batas toleransi standar CP 707.` - : overallStatus === 'warning' - ? `Perhatian (Hari ke-${dayAge || '?'}): Terdapat deviasi pada parameter vs standar buku CP 707.` - : `Kondisi kandang hari ke-${dayAge || '?'} terpantau optimal dan sesuai standar buku CP 707.`; + let reportPrefix = 'Analisis Kritis'; + if (reportType === 'weekly') reportPrefix = `Evaluasi Mingguan Kritis (${periodeText})`; + if (reportType === 'end_cycle') reportPrefix = 'Evaluasi Akhir Siklus Kritis'; + + headline = + overallStatus === 'critical' + ? `${reportPrefix} (Hari ke-${dayAge || '?'}): Ditemukan parameter operasional di luar batas toleransi standar CP 707.` + : overallStatus === 'warning' + ? `Perhatian (Hari ke-${dayAge || '?'}): Terdapat deviasi pada parameter vs standar buku CP 707.` + : `Kondisi kandang hari ke-${dayAge || '?'} terpantau optimal dan sesuai standar buku CP 707.`; + + if (overallStatus !== 'critical') { + if (reportType === 'weekly') headline = `Laporan Mingguan (${periodeText}): ` + headline; + if (reportType === 'end_cycle') headline = `Laporan Akhir Siklus: ` + headline; + } const risks = [ ...(statusAyam !== 'ok' ? hitung_ayam.evidence : []), @@ -702,16 +1623,23 @@ const generateLocalDashboardInsight = (context) => { summary: { headline, bullets: [ - `Mortalitas: ${ccRate.toFixed(2)}% (Standar CP 707 hari ke-${dayAge || '?'}: ${stdMortalityCum ? stdMortalityCum + '%' : '<5%'}) — Status: ${statusAyam.toUpperCase()}.`, - wmAvg !== null ? `Bobot badan: ${wmAvg}g (Target CP 707: ${wmTarget || 'N/A'}g) — Status: ${statusBerat.toUpperCase()}.` : 'Data bobot badan tidak tersedia.', - temp !== null ? `Lingkungan: Suhu ${temp.toFixed(1)}°C, Amonia ${amonia !== null ? amonia.toFixed(1) + ' ppm' : 'N/A'} — Status: ${statusIot.toUpperCase()}.` : 'Data lingkungan IoT tidak tersedia.' + `Mortalitas: ${ccRateText} (Standar CP 707 hari ke-${dayAge || '?'}: ${stdMortalityCum ? stdMortalityCum + '%' : '<5%'}) — Status: ${statusAyam.toUpperCase()}.`, + wmAvg !== null + ? `Bobot badan: ${wmAvg}g (Target CP 707: ${wmTarget || 'N/A'}g) — Status: ${statusBerat.toUpperCase()}.` + : 'Data bobot badan tidak tersedia.', + temp !== null + ? `Lingkungan: Suhu ${temp.toFixed(1)}°C, Amonia ${amonia !== null ? amonia.toFixed(1) + ' ppm' : 'N/A'} — Status: ${statusIot.toUpperCase()}.` + : 'Data lingkungan IoT tidak tersedia.', ], - risks: risks.length > 0 ? risks : ['Tidak ada risiko signifikan yang terdeteksi berdasarkan standar CP 707.'], + risks: + risks.length > 0 + ? risks + : ['Tidak ada risiko signifikan yang terdeteksi berdasarkan standar CP 707.'], actions: [ ...hitung_ayam.actions.slice(0, 1), ...berat_ayam.actions.slice(0, 1), - ...panel_iot.actions.slice(0, 1) - ].slice(0, 3) + ...panel_iot.actions.slice(0, 1), + ].slice(0, 3), }, detailed: { hitung_ayam, @@ -720,8 +1648,8 @@ const generateLocalDashboardInsight = (context) => { fcr, eef, hitung_karung, - data_quality - } + data_quality, + }, }; }; @@ -732,8 +1660,13 @@ const generateDashboardInsight = async ({ version = INSIGHT_VERSION, forceRefresh = false, reportType = 'daily', - reportPeriod = null, + reportPeriod: rawReportPeriod = null, }) => { + // Only a weekly report is scoped by a week label. The dashboard keeps its + // week dropdown mounted and sends its value regardless of type, so without + // this a daily report would be cached under whichever week was last picked + // and regenerate needlessly when that week changed. + const reportPeriod = reportType === 'weekly' ? rawReportPeriod : null; const cacheKey = buildCacheKey(cycleId, kandangId, version, reportType, reportPeriod); if (!forceRefresh) { @@ -746,7 +1679,12 @@ const generateDashboardInsight = async ({ const requestPromise = (async () => { try { if (!forceRefresh) { - const cached = await AiInsight.getByCycleAndKandang(cycleId, kandangId); + const cached = await AiInsight.getByCycleAndKandang( + cycleId, + kandangId, + reportType, + reportPeriod + ); if (cached && cached.version === version) { return { source: 'cache', @@ -757,11 +1695,17 @@ const generateDashboardInsight = async ({ const enrichedContextPack = await enrichContextPack(contextPack, cycleId, kandangId); const rawLength = JSON.stringify(enrichedContextPack).length; - const prunedContextPack = pruneContextPackForLocalLLM(enrichedContextPack); + const prunedContextPack = rescopeScalarsToPeriod( + pruneContextPackForLocalLLM(enrichedContextPack, reportType, reportPeriod), + reportType, + reportPeriod + ); const prunedLength = JSON.stringify(prunedContextPack).length; - console.log(`[LM Studio Context Optimizer] Pruned context size from ${rawLength} to ${prunedLength} chars (saved ${Math.round((1 - prunedLength / rawLength) * 100)}%)`); + console.log( + `[LM Studio Context Optimizer] Pruned context size from ${rawLength} to ${prunedLength} chars (saved ${Math.round((1 - prunedLength / rawLength) * 100)}%)` + ); const contextText = serializePromptContext(prunedContextPack); - + let responseText = ''; let success = false; @@ -769,10 +1713,15 @@ const generateDashboardInsight = async ({ // Jika konteks terlalu pendek, tambahkan standar CP 707 agar model bisa generate output let finalContextText = contextText; if (contextText.length < 400) { - const dayAge = enrichedContextPack?.cycle?.currentAgeDay || enrichedContextPack?.ageDay || null; - const padding = dayAge ? cp707.buildBookReferenceContext(dayAge) : '\n[Data operasional kandang tidak tersedia lengkap. Gunakan standar umum CP 707.]'; + const dayAge = + enrichedContextPack?.cycle?.currentAgeDay || enrichedContextPack?.ageDay || null; + const padding = dayAge + ? cp707.buildBookReferenceContext(dayAge) + : '\n[Data operasional kandang tidak tersedia lengkap. Gunakan standar umum CP 707.]'; finalContextText = contextText + '\n' + padding; - console.log(`[LM Studio Dashboard] Context terlalu pendek (${contextText.length} chars), padding ke ${finalContextText.length} chars`); + console.log( + `[LM Studio Dashboard] Context terlalu pendek (${contextText.length} chars), padding ke ${finalContextText.length} chars` + ); } // 1. Try LM Studio (dengan retry jika model output error) @@ -781,68 +1730,139 @@ const generateDashboardInsight = async ({ try { if (attempt > 1) { console.log(`[LM Studio Dashboard] Retry attempt ${attempt}/${MAX_RETRIES}...`); - await new Promise(r => setTimeout(r, 1500)); + await new Promise((r) => setTimeout(r, 1500)); } const lmStudioBaseUrl = process.env.LM_STUDIO_BASE_URL || 'http://127.0.0.1:11434'; let modelName = process.env.LLM_MODEL_NAME || 'qwen2.5:7b'; try { - const modelsResponse = await fetch(`${lmStudioBaseUrl}/v1/models`, { signal: AbortSignal.timeout(2000) }); + const modelsResponse = await fetch(`${lmStudioBaseUrl}/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; } } } catch (err) { - console.warn('[LLM Service] Failed to fetch loaded models list, using default:', err.message); + console.warn( + '[LLM Service] Failed to fetch loaded models list, using default:', + err.message + ); } - const dayAge = enrichedContextPack?.cycle?.currentAgeDay || enrichedContextPack?.ageDay || null; - + const dayAge = + enrichedContextPack?.cycle?.currentAgeDay || enrichedContextPack?.ageDay || null; + const isOllama = lmStudioBaseUrl.includes('11434') || lmStudioBaseUrl.includes('llm'); const endpoint = isOllama ? '/api/chat' : '/v1/chat/completions'; - - const payload = isOllama ? { - model: modelName, - messages: [ - { role: 'system', content: 'Anda adalah analis operasional peternakan ayam broiler CP 707. Kembalikan HANYA format JSON valid, tanpa teks tambahan di luar JSON. Langsung ke poin utama.' }, - { role: 'user', content: buildDashboardInsightPrompt(finalContextText, dayAge, reportType, reportPeriod) } - ], - stream: false, - options: { - num_ctx: 8192, - num_predict: 8192, - temperature: 0.1 - } - } : { - model: modelName, - messages: [ - { role: 'system', content: '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.' }, - { role: 'user', content: buildDashboardInsightPrompt(finalContextText, dayAge, reportType, reportPeriod) } - ], - temperature: 0.1, - max_tokens: 2048, - lm_studio: { reasoning: { budget_tokens: 1000 } } - }; + + const payload = isOllama + ? { + model: modelName, + messages: [ + { + role: 'system', + content: + 'Anda adalah analis operasional peternakan ayam broiler CP 707. Kembalikan HANYA format JSON valid, tanpa teks tambahan di luar JSON. Langsung ke poin utama.', + }, + { + role: 'user', + content: buildDashboardInsightPrompt( + finalContextText, + dayAge, + reportType, + reportPeriod + ), + }, + ], + stream: false, + format: 'json', + options: { + // Ollama truncates an over-long prompt to HALF of num_ctx, so + // 8192 only ever admitted 4096 prompt tokens. An end_cycle + // prompt carries all six topics plus the per-week rollups and + // measured 4098 — i.e. exactly at the cap, silently cut from + // the START, which is where the JSON schema and the output + // contract live. The model then answered in a shape nothing + // downstream could read. 16384 leaves 8192 for the prompt. + num_ctx: 16384, + num_predict: NUM_PREDICT_BY_REPORT_TYPE[reportType] ?? 2048, + temperature: 0.1, + }, + } + : { + model: modelName, + messages: [ + { + role: 'system', + content: + '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.', + }, + { + role: 'user', + content: buildDashboardInsightPrompt( + finalContextText, + dayAge, + reportType, + reportPeriod + ), + }, + ], + temperature: 0.1, + max_tokens: 2048, + lm_studio: { reasoning: { budget_tokens: 1000 } }, + }; const response = await fetch(`${lmStudioBaseUrl}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), - signal: AbortSignal.timeout(540000) + // Without this dispatcher undici cuts the connection at its default + // 300 s headersTimeout, long before the signal below ever fires. + dispatcher: llmDispatcher, + // 20 minutes. At ~6 tok/s a full answer can take far longer than + // the previous 9-minute cap, and every overrun silently demoted the + // report to the local fallback generator. + signal: AbortSignal.timeout(1200000), }); 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; - + + // Hitting the cap means the JSON was cut mid-object and will fail + // to parse a few lines below. Say so here, otherwise the only clue + // is a "JSON parse failed" on a response that looks well-formed in + // the 200-char sample. + // Ollama truncates an over-long prompt to HALF of num_ctx and keeps + // the tail, so the system prompt and CP 707 standards are the first + // thing dropped. prompt_tokens near num_ctx/2 means that happened. + console.log( + `[LM Studio Dashboard] reportType=${reportType}, finish_reason=${finishReason}, prompt_tokens=${json.prompt_eval_count ?? '?'}, completion_tokens=${json.eval_count ?? '?'}` + ); + + if (finishReason === 'length') { + console.warn( + `[LM Studio Dashboard] Response hit the num_predict cap (reportType=${reportType}) and was truncated — raise NUM_PREDICT_BY_REPORT_TYPE for this report type.` + ); + } + 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(); } else if (!isOllama && json.content && typeof json.content === 'string') { responseText = json.content; @@ -850,22 +1870,30 @@ const generateDashboardInsight = async ({ 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 Dashboard] attempt=${attempt} status=${response.status}: ${errBody.slice(0, 300)}`); + console.warn( + `[LM Studio Dashboard] attempt=${attempt} status=${response.status}: ${errBody.slice(0, 300)}` + ); // model output error → retry dengan padding lebih besar if ((response.status === 400 || response.status === 422) && attempt < MAX_RETRIES) { - finalContextText = finalContextText + '\n\nCatatan: Hasilkan JSON analisis berdasarkan data di atas dan standar CP 707.'; - console.log('[LM Studio Dashboard] Model output error, will retry with padded context'); + finalContextText = + finalContextText + + '\n\nCatatan: Hasilkan JSON analisis berdasarkan data di atas dan standar CP 707.'; + console.log( + '[LM Studio Dashboard] Model output error, will retry with padded context' + ); } } } catch (lmStudioError) { - console.error(`[LM Studio Dashboard] attempt=${attempt} Error type=${lmStudioError.name} msg=${lmStudioError.message}`); + console.error( + `[LM Studio Dashboard] attempt=${attempt} Error type=${lmStudioError.name} msg=${lmStudioError.message}` + ); // Jika bukan timeout asli (120s), retry if (attempt < MAX_RETRIES && lmStudioError.name !== 'TimeoutError') { console.log('[LM Studio Dashboard] Connection error, will retry...'); @@ -883,24 +1911,59 @@ const generateDashboardInsight = async ({ .trim(); parsed = parseDashboardInsightJson(cleanedResponseText); if (!parsed) { - console.warn('[LM Studio Dashboard] JSON parse failed. Raw sample:', cleanedResponseText.slice(0, 200)); + console.warn( + '[LM Studio Dashboard] JSON parse failed. Raw sample:', + cleanedResponseText.slice(0, 200) + ); + } else if (!hasUsableContent(parsed)) { + // The healing in parseDashboardInsightJson is deliberately forgiving, + // so a reply in the wrong shape still "parses" — into a shell whose + // every list is empty. That shell was being served: the card rendered + // "Ringkasan tidak tersedia" over "data belum tersedia", while the + // local fallback that could have filled it never ran, because a + // non-null parse counts as success. Treat empty as failure. + console.warn( + '[LM Studio Dashboard] Parsed JSON carried no usable content (no headline, no bullets). Raw sample:', + cleanedResponseText.slice(0, 200) + ); + parsed = null; } } if (!parsed) { - throw new Error('Gagal menghasilkan AI Insight karena masalah koneksi/timeout dengan LLM atau format respons tidak valid.'); + console.warn( + '[Dashboard Insight Service] LLM failed or parsed null. Using local fallback generator.' + ); + // The PERIOD-SCOPED pack, same as the LLM receives. Handing it the raw + // enriched pack meant a fallback "Minggu 1" report was written from + // whole-cycle, day-48 figures under a week-1 heading. + parsed = generateLocalDashboardInsight(prunedContextPack, reportType, reportPeriod); } + // Applies to both paths: the fallback generator can reach the same verdict. + // Judged on what was actually sent — this marks a topic "tidak dikirim + // untuk periode ini", which is only true of the scoped pack. + parsed = enforceDataAvailability(parsed, prunedContextPack); + // Check if newly generated content is identical to existing content in database - const existing = await AiInsight.getByCycleAndKandang(cycleId, kandangId); + const existing = await AiInsight.getByCycleAndKandang( + cycleId, + kandangId, + reportType, + reportPeriod + ); if (existing && existing.insightText) { try { const oldObj = JSON.parse(existing.insightText); if (JSON.stringify(oldObj) === JSON.stringify(parsed)) { - throw new Error('Data belum diperbarui. Hasil analisis AI masih sama dengan versi sebelumnya.'); + // Return existing instead of throwing + return { + source: 'generated_identical', + insight: existing, + }; } } catch (e) { - if (e.message.includes('Data belum diperbarui')) throw e; + // Ignore parse errors on old text } } @@ -909,6 +1972,8 @@ const generateDashboardInsight = async ({ kandangId, insightText: JSON.stringify(parsed, null, 2), version, + reportType, + reportPeriod, }); return { @@ -916,7 +1981,10 @@ const generateDashboardInsight = async ({ insight, }; } catch (error) { - console.error('[Dashboard Insight Service] Unexpected error during AI Insight generation:', error); + console.error( + '[Dashboard Insight Service] Unexpected error during AI Insight generation:', + error + ); throw error; } })().finally(() => { @@ -946,4 +2014,20 @@ const extractQuotaErrorInfoFromText = (text) => { module.exports = { INSIGHT_VERSION, generateDashboardInsight, + // Exported for tests: the period slicing is what makes Harian/Mingguan/Akhir + // Siklus actually differ, so it needs to be verifiable on its own. + pruneContextPackForLocalLLM, + rescopeScalarsToPeriod, + // Exported for tests: the healing defaults decide whether an unassessed topic + // shows up green, so they need to be verifiable directly. + parseDashboardInsightJson, + // Exported for tests: this is what stops a healed-empty shell being served in + // place of a report. + hasUsableContent, + // Exported for tests: this runs only when the LLM is unreachable, so it is the + // path least likely to be exercised by hand and most likely to rot. + generateLocalDashboardInsight, + // Exported for tests: this is the last line of defence against a topic being + // reported green when no data for it was ever sent. + enforceDataAvailability, }; diff --git a/backend/services/llmDispatcher.js b/backend/services/llmDispatcher.js new file mode 100644 index 0000000..c203142 --- /dev/null +++ b/backend/services/llmDispatcher.js @@ -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 }; diff --git a/backend/vitest.config.js b/backend/vitest.config.js index da7842f..2b8d6f7 100644 --- a/backend/vitest.config.js +++ b/backend/vitest.config.js @@ -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, }, }); diff --git a/components/AiInsightCard.tsx b/components/AiInsightCard.tsx index 70e25b0..4d7c113 100644 --- a/components/AiInsightCard.tsx +++ b/components/AiInsightCard.tsx @@ -1,6 +1,17 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { Sparkles, RefreshCw, Download } from 'lucide-react'; -import { jsPDF } from 'jspdf'; +import { + ComposedChart, + Area, + Bar, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; import { useAppContext } from '../context/AppContext.tsx'; import type { DashboardInsightData, DashboardInsightJson } from '../services/aiInsightService.ts'; @@ -29,14 +40,62 @@ import type { WeightDataPoint, WeightDistributionStats, } from '../types.ts'; +import type { FeedSackLogEntry } from '../types/counting.ts'; import { marked } from 'marked'; +import { HeroFigure, MutedStatRow, StatTile } from './shared/InsightStatTiles.tsx'; +import { exportCardToPdf } from '../utils/pdfExport.ts'; +import PdfReportHeader, { + buildPdfFilename, + describeDataDates, + describeKandangScope, + describeReportPeriod, + isoDataDates, + parseDayRangeLabel, + reportPeriodSlug, +} from './shared/PdfReportHeader.tsx'; + +/** + * Direct label on the final point of the actual-weight line. + * + * Recharts renders a line `label` for every point; labelling all of them is + * chaos and goes unread. Selective direct labelling means the endpoint only — + * the axis and the tooltip carry the rest. + */ +const WeightEndLabel = (props: { + x?: number; + y?: number; + index?: number; + value?: number | string; + rows?: Array; +}) => { + const { x, y, index, value, rows } = props; + const total = rows?.length ?? 0; + if ( + typeof x !== 'number' || + typeof y !== 'number' || + typeof index !== 'number' || + total === 0 || + index !== total - 1 + ) { + return null; + } + const numeric = Math.round(Number(value)); + if (!Number.isFinite(numeric)) return null; + return ( + + {numeric.toLocaleString('id-ID')} g + + ); +}; interface AiInsightCardProps { kandangId: string | null; page?: 'hitung_ayam' | 'berat_ayam' | 'fcr' | 'eef' | 'panel_iot' | 'data_quality' | 'dashboard'; } -const CACHE_VERSION = 'v5'; +// Keep in step with INSIGHT_VERSION in backend/services/dashboardInsightService.js: +// a stale browser cache would show pre-fix insights even after the server retires them. +const CACHE_VERSION = 'v6'; const CACHE_TTL = 6 * 60 * 60 * 1000; // 6 hours in milliseconds const QUOTA_ERROR_PREFIX = 'QUOTA_LIMIT_EXCEEDED'; @@ -195,7 +254,6 @@ type CompactIotPanelSummary = { datetime: string; avgTemp: string; setPoint: string; - expectedTemp: string; temp1: string; temp2: string; temp3: string; @@ -325,6 +383,58 @@ const compactFcrComparisonData = (data: FcrDataPoint[]) => cobbFcr: point.cobbFcr, })); +/** + * Feed-sack figures for the prompt. + * + * The dashboard has always shown "Karung Dituang" on the insight card, but the + * number was never put into the context pack — so `hitung_karung` had no data to + * read and every report came back "data tidak tersedia" for a topic the user can + * see on screen. The daily series comes from the KPI trend, which is already + * day-aligned, so period slicing applies to it like any other history. + */ +const compactFeedSack = (input: { + karungDituangToday: number | null; + trend: number[]; + trendDates: string[]; + history: FeedSackLogEntry[]; + currentDay: number; +}) => { + const { karungDituangToday, trend, trendDates, history, currentDay } = input; + + // Align the trend to day numbers so the period pruning can slice it: the last + // point is the current day, walking backwards one day per entry. + const firstDay = currentDay - trend.length + 1; + const riwayatHarian = trend.map((count, index) => ({ + day: firstDay + index, + date: trendDates[index] ?? null, + karungDituang: count, + })); + + const totalByType = (type: FeedSackLogEntry['type']) => + history.filter((entry) => entry.type === type).reduce((sum, entry) => sum + entry.count, 0); + + const perKamera = Object.values( + history.reduce>((acc, entry) => { + const key = entry.cameraName || 'tidak diketahui'; + acc[key] = acc[key] || { cameraName: key, count: 0 }; + acc[key].count += entry.count; + return acc; + }, {}) + ).sort((a, b) => b.count - a.count); + + return { + karungDituangHariIni_karung: karungDituangToday, + totalKarungMasuk_karung: totalByType('inbound'), + totalKarungDituang_karung: totalByType('usage'), + totalKarungKeluar_karung: totalByType('outbound'), + saldoKarung_karung: totalByType('inbound') - totalByType('usage') - totalByType('outbound'), + jumlahCatatanKamera: history.length, + riwayatHarian, + perKamera, + note: 'Karung masuk = stok diterima; karung dituang = pakan yang benar-benar dikonsumsi ayam. Satuan semua angka: karung.', + }; +}; + const compactCkaleData = (data: CkaleWeightPoint[]) => data.map((point) => ({ day: point.day, @@ -380,7 +490,6 @@ const summarizeIotPanel = async (): Promise => { datetime: computed.datetime, avgTemp: computed.avgTemp, setPoint: computed.setPoint, - expectedTemp: computed.expectedTemp, temp1: computed.temp1, temp2: computed.temp2, temp3: computed.temp3, @@ -429,6 +538,22 @@ const buildContextPack = (input: { afkir: number; panen: number; }; + /** + * Mortality per cycle day, carrying the running cumulative. + * + * Only cycle totals used to reach the backend, so a weekly report had nothing + * to scope and quoted the day-48 figure under a week-1 heading. + * `kumulatifMatiAfkir` is cumulative THROUGH that day because the CP 707 + * mortality standard is itself cumulative — the last row of a sliced window is + * then directly comparable to the standard for that window's last day. + */ + mortalityHistory: Array<{ + day: number; + mati: number; + afkir: number; + panen: number; + kumulatifMatiAfkir: number; + }>; weight: { stats: { averageWeight: number; @@ -455,6 +580,12 @@ const buildContextPack = (input: { ckaleData: CkaleWeightPoint[]; }; iotPanel: CompactIotPanelSummary | null; + feedSack: { + karungDituangToday: number | null; + trend: number[]; + trendDates: string[]; + history: FeedSackLogEntry[]; + }; }) => { const onlineCounts = input.liveCounts.filter((count) => count.status !== 'offline'); const totalCameras = input.liveCounts.length; @@ -475,10 +606,41 @@ const buildContextPack = (input: { const latestPopulationPoint = input.populationHistory[input.populationHistory.length - 1] ?? null; const previousPopulationPoint = input.populationHistory[input.populationHistory.length - 2] ?? null; + // Send the COMPUTED readings only. `raw.sensors` holds the sensor's own + // encoding — temperature and humidity arrive multiplied by ten, so 31.6 °C is + // stored as 316 and 62.8 % as 628. Handing those to the model produced + // "suhu 316°C". `display` is what `computeDashboard` already scaled and + // labelled ("31.6 °C", "62.8 %", "0.4 ppm"), so it is the only safe source. const promptSafeIotPanel = input.iotPanel ? { - raw: input.iotPanel.raw, - display: input.iotPanel.display, + kandang: input.iotPanel.raw?.coopCode ?? input.iotPanel.display?.coopName ?? null, + perangkat: input.iotPanel.raw?.deviceName ?? null, + hariKe: input.iotPanel.raw?.day ?? null, + waktuData: input.iotPanel.display?.datetime ?? null, + pembacaan: { + suhuRataRata: input.iotPanel.display?.avgTemp ?? null, + setPoint: input.iotPanel.display?.setPoint ?? null, + // Was read from a non-existent `expectedTemp`, so it always sent + // null; the set point is the panel's target temperature. + suhuTarget: input.iotPanel.display?.setPoint ?? null, + suhu1: input.iotPanel.display?.temp1 ?? null, + suhu2: input.iotPanel.display?.temp2 ?? null, + suhu3: input.iotPanel.display?.temp3 ?? null, + suhuLuar: input.iotPanel.display?.outsideTemp ?? null, + kelembapan: input.iotPanel.display?.humidity ?? null, + amonia: input.iotPanel.display?.ammonia ?? null, + co2: input.iotPanel.display?.co2 ?? null, + kecepatanAngin: input.iotPanel.display?.wind ?? null, + lux: input.iotPanel.display?.lux ?? null, + }, + alarmAktif: { + suhuRataRata: input.iotPanel.display?.avgTempAlarmHighlight ?? false, + kelembapan: input.iotPanel.display?.humidityAlarmHighlight ?? false, + suhu1: input.iotPanel.display?.temp1AlarmHighlight ?? false, + suhu2: input.iotPanel.display?.temp2AlarmHighlight ?? false, + suhu3: input.iotPanel.display?.temp3AlarmHighlight ?? false, + }, + note: 'Setiap nilai di "pembacaan" sudah lengkap dengan satuannya. Kutip apa adanya, jangan mengubah satuan atau menghitung ulang.', } : null; @@ -512,6 +674,7 @@ const buildContextPack = (input: { aggregatedCounts: compactAggregatedCounts(input.aggregatedCounts), populationHistory: latestPopulationHistory, mortality: input.mortality, + mortalityHistory: input.mortalityHistory, }, weight: { current: input.weight.stats, @@ -544,11 +707,19 @@ const buildContextPack = (input: { }, }, iotPanel: promptSafeIotPanel, + hitung_karung: compactFeedSack({ + karungDituangToday: input.feedSack.karungDituangToday, + trend: input.feedSack.trend, + trendDates: input.feedSack.trendDates, + history: input.feedSack.history, + currentDay: input.currentDay, + }), instructionHints: { - focusOrder: ['hitung_ayam', 'berat_ayam', 'fcr', 'eef', 'panel_iot'], + focusOrder: ['hitung_ayam', 'berat_ayam', 'hitung_karung', 'fcr', 'eef', 'panel_iot'], emphasize: [ 'Gunakan data hitung ayam sebagai indikator integritas populasi.', 'Gunakan data berat ayam untuk menjelaskan performa pertumbuhan.', + 'Gunakan data hitung karung untuk menilai konsumsi pakan — bandingkan karung dituang dengan pertumbuhan bobot.', 'Gunakan data FCR untuk menilai efisiensi konversi pakan — FCR lebih rendah lebih baik.', 'Gunakan data EEF/IP sebagai indeks performa keseluruhan siklus — EEF >300 sangat baik.', 'Gunakan panel IoT untuk menjelaskan stres lingkungan dan dampaknya ke ayam.', @@ -557,6 +728,78 @@ const buildContextPack = (input: { }; }; +/** One day's mortality/harvest row as returned by `/api/mortality/:cycleId`. */ +type MortalityDayRow = { + day?: number | null; + mortalityCount?: number | null; + afkir?: number | null; + panen?: number | null; + beratPanen?: number | null; +}; + +const sumField = (rows: MortalityDayRow[], field: keyof MortalityDayRow) => + rows.reduce((total, row) => { + const value = row[field]; + return total + (typeof value === 'number' && Number.isFinite(value) ? value : 0); + }, 0); + +/** Week number requested by a 'weekly' report, e.g. "Minggu 7" -> 7. */ +const parseWeekNumber = (period: string | null) => { + const match = period && String(period).match(/\d+/); + const week = match ? parseInt(match[0], 10) : 1; + return Number.isFinite(week) && week > 0 ? week : 1; +}; + +/** + * Narrow per-day rows to the timeframe the user picked in the report dropdown. + * + * The KPI grid used to show `kpis.mortality.value` — the *selected day's* + * deaths — under the label "Jumlah Kematian (Kematian + Afkir)", which reads as + * a cycle total. On day 48 that printed 0 next to an AI narrative reporting + * 2,477 cumulative deaths in the same card. Tying the figure to the timeframe + * makes the label true in every mode instead of only in one. + * + * `lastDay` is the closing day of the window: stock figures (the running + * balance) are counted up to it, while flow figures (deaths, harvest) are + * counted only inside the window. + */ +const sliceMortalityByPeriod = ( + rows: MortalityDayRow[], + reportType: 'daily' | 'weekly' | 'end_cycle', + reportPeriod: string | null +) => { + const dayOf = (row: MortalityDayRow) => + typeof row.day === 'number' && Number.isFinite(row.day) ? row.day : null; + const days = rows.map(dayOf).filter((day): day is number => day !== null); + const latestDay = days.length > 0 ? Math.max(...days) : null; + + if (reportType === 'daily') { + return { + window: latestDay === null ? [] : rows.filter((row) => dayOf(row) === latestDay), + lastDay: latestDay, + label: latestDay === null ? 'hari terakhir' : `hari ke-${latestDay}`, + }; + } + + if (reportType === 'weekly') { + const week = parseWeekNumber(reportPeriod); + const firstDay = (week - 1) * 7 + 1; + const lastDay = week * 7; + return { + // An empty window must read as "no data for this week", never as some + // other week's numbers. + window: rows.filter((row) => { + const day = dayOf(row); + return day !== null && day >= firstDay && day <= lastDay; + }), + lastDay, + label: `${reportPeriod || 'Minggu ini'} (hari ${firstDay}–${lastDay})`, + }; + } + + return { window: rows, lastDay: latestDay, label: 'seluruh siklus' }; +}; + export const AiInsightCard: React.FC = ({ kandangId, page = 'dashboard' }) => { const context = useAppContext(); const [insightText, setInsightText] = useState(''); @@ -567,229 +810,86 @@ export const AiInsightCard: React.FC = ({ kandangId, page = const [detailsOpen, setDetailsOpen] = useState(false); const [reportType, setReportType] = useState<'daily' | 'weekly' | 'end_cycle'>('daily'); const [reportPeriod, setReportPeriod] = useState('Minggu 1'); + // Per-day mortality/harvest rows for the KPI grid. Loaded independently of + // insight generation so the figures are right even when the insight itself + // comes from cache. + const [mortalityRows, setMortalityRows] = useState([]); const insightInFlightRef = useRef(false); + const pdfRef = useRef(null); + + const cycleId = context.cycleData?.id; + + // Calendar span the report actually covers, per scope: today for a daily + // report, the requested week for a weekly one, day 1 to now for end-cycle. + // Both the printed header and the filename read from here so they cannot + // disagree. + const { dashboardDataDateLabel, dashboardIsoDates } = (() => { + const startDate = context.cycleData?.startDate; + const currentDay = context.cycleData?.currentDay; + let fromDay: number | null | undefined = currentDay; + let toDay: number | null = null; + + if (reportType === 'end_cycle') { + fromDay = 1; + toDay = currentDay ?? null; + } else if (reportType === 'weekly') { + // `reportPeriod` is "Minggu 6" — a week number, not a day number. + const { fromDay: weekNo } = parseDayRangeLabel(reportPeriod); + if (weekNo) { + fromDay = (weekNo - 1) * 7 + 1; + toDay = weekNo * 7; + } + } + + return { + dashboardDataDateLabel: describeDataDates(startDate, fromDay, toDay), + dashboardIsoDates: isoDataDates(startDate, fromDay, toDay), + }; + })(); + + useEffect(() => { + let cancelled = false; + if (!cycleId) { + setMortalityRows([]); + return; + } + apiClient + .getMortalityRecords(cycleId, kandangId ? Number(kandangId) : undefined) + .then((records: unknown) => { + if (cancelled) return; + setMortalityRows(Array.isArray(records) ? (records as MortalityDayRow[]) : []); + }) + .catch((err: unknown) => { + console.error('❌ Failed to load mortality records for KPI grid:', err); + if (!cancelled) setMortalityRows([]); + }); + return () => { + cancelled = true; + }; + }, [cycleId, kandangId]); const applyInsightText = useCallback((text: string) => { setInsightText(text); setStructuredInsight(normalizeStructuredInsight(text)); }, []); - const downloadPdf = () => { - if (!structuredInsight) return; + const downloadPdf = async () => { + if (!structuredInsight || !pdfRef.current) return; - const doc = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - }); - - const primaryColor = [79, 70, 229]; // Indigo [R, G, B] - const secondaryColor = [31, 41, 55]; // Gray-800 - const lightGray = [243, 244, 246]; // Gray-100 - - // Page dimensions - const pageWidth = doc.internal.pageSize.getWidth(); - const margin = 20; - const contentWidth = pageWidth - margin * 2; - - // Helper to strip non-ASCII to prevent corrupted PDF in jsPDF standard fonts - const safeText = (text: string) => { - if (!text) return ''; - return text - .replace(/[“”]/g, '"') - .replace(/[‘’]/g, "'") - .replace(/–|—/g, '-') - .replace(/•/g, '-') - .replace(/[^\x20-\x7E\n]/g, ''); - }; - - // Helper to wrap and add text - let y = 20; - - // 1. Header Title - doc.setFont('helvetica', 'bold'); - doc.setFontSize(20); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.text('Daya Cognitive AI Insights', margin, y); - - // Subtitle - y += 7; - doc.setFont('helvetica', 'normal'); - doc.setFontSize(8.5); - doc.setTextColor(100, 116, 139); // Slate-500 - doc.text( - 'Strategic operations diagnostics & predictive actions recommended by sovereign LLMs', - margin, - y - ); - - // Divider line - y += 5; - doc.setDrawColor(226, 232, 240); // Slate-200 - doc.setLineWidth(0.5); - doc.line(margin, y, pageWidth - margin, y); - - // 2. Metadata Section (Horizontal tags) - y += 8; - doc.setFillColor(lightGray[0], lightGray[1], lightGray[2]); - doc.rect(margin, y, contentWidth, 10, 'F'); - - doc.setFont('helvetica', 'bold'); - doc.setFontSize(7.5); - doc.setTextColor(71, 85, 105); // Slate-600 - - const activeKandang = context.kandangs?.find( - (k: any) => k.id?.toString() === kandangId?.toString() - ); - const kandangName = activeKandang?.name || 'Semua Kandang'; - const rawCtxDay = context.cycleData?.currentDay; - const maxDays = context.cycleData?.totalDays; - const currentDayVal = maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay; - const dayAge = - currentDayVal !== undefined && currentDayVal !== null ? `Hari ${currentDayVal}` : '-'; - - const metaText = `SCOPE: EXECUTIVE SUMMARY | KANDANG: ${kandangName.toUpperCase()} | UMUR SIKLUS: ${dayAge} | TANGGAL: ${new Date().toLocaleDateString('id-ID')}`; - doc.text(metaText, margin + 4, y + 6.5); - - // 3. Headline Box - y += 18; - - // Draw Headline Box - doc.setFillColor(243, 244, 246); - doc.roundedRect(margin, y, contentWidth, 18, 2, 2, 'F'); - - // Title Badge - doc.setFillColor(79, 70, 229); - doc.roundedRect(margin + 4, y + 4, 26, 5.5, 1, 1, 'F'); - doc.setFont('helvetica', 'bold'); - doc.setFontSize(7.5); - doc.setTextColor(255, 255, 255); - doc.text('EXECUTIVE', margin + 6, y + 7.8); - - // Headline Text - doc.setFont('helvetica', 'bold'); - doc.setFontSize(9); - doc.setTextColor(secondaryColor[0], secondaryColor[1], secondaryColor[2]); - - const headline = safeText( - structuredInsight.summary?.headline || - 'Ringkasan operasional dan analisis kecerdasan buatan untuk farm broiler.' - ); - const headlineLines = doc.splitTextToSize(headline, contentWidth - 38); - doc.text(headlineLines, margin + 34, y + 7.8); - - y += 26; - - // 4. Three core sections (Prioritas, Risiko Utama, Aksi Cepat) - const drawSection = ( - titleText: string, - items: string[], - titleColor: number[], - bulletBg: number[], - isOrdered = false - ) => { - if (y > 240) { - doc.addPage(); - y = 20; - } - - doc.setFont('helvetica', 'bold'); - doc.setFontSize(11); - doc.setTextColor(titleColor[0], titleColor[1], titleColor[2]); - doc.text(titleText, margin, y); - - y += 5; - doc.setDrawColor(titleColor[0], titleColor[1], titleColor[2]); - doc.setLineWidth(0.3); - doc.line(margin, y, margin + 18, y); - - y += 8; - - doc.setFont('helvetica', 'normal'); - doc.setFontSize(9); - doc.setTextColor(55, 65, 81); // Slate-700 - - if (!items || items.length === 0) { - doc.setFont('helvetica', 'italic'); - doc.text('Tidak ada data analisis', margin + 4, y); - y += 8; - return; - } - - items.forEach((rawItem, index) => { - const item = safeText(rawItem); - const textLines = doc.splitTextToSize(item, contentWidth - 12); - const textHeight = textLines.length * 4.5; - - if (y + textHeight > 265) { - doc.addPage(); - y = 20; - } - - if (isOrdered) { - doc.setFillColor(bulletBg[0], bulletBg[1], bulletBg[2]); - doc.roundedRect(margin, y - 3, 4.5, 4.5, 1, 1, 'F'); - doc.setFont('helvetica', 'bold'); - doc.setFontSize(7); - doc.setTextColor(titleColor[0], titleColor[1], titleColor[2]); - doc.text((index + 1).toString(), margin + 1.5, y + 0.2); - } else { - doc.setFillColor(bulletBg[0], bulletBg[1], bulletBg[2]); - doc.circle(margin + 2.2, y - 1, 1.0, 'F'); - } - - doc.setFont('helvetica', 'normal'); - doc.setFontSize(9); - doc.setTextColor(55, 65, 81); - doc.text(textLines, margin + 8, y); - - y += textHeight + 3.5; + try { + // Same identity as the printed header, so the file can be matched to its + // report without opening it. + const filename = buildPdfFilename({ + reportName: 'Dashboard', + area: context.selectedArea || 'Sukawarna', + kandangLabel: describeKandangScope(context.kandangs, kandangId), + periodSlug: reportPeriodSlug(reportType, reportPeriod), + isoDates: dashboardIsoDates, }); - - y += 3; - }; - - // Draw Summary Section - drawSection( - 'Ringkasan Prioritas', - structuredInsight.summary?.bullets || [], - [79, 70, 229], - [224, 231, 255] - ); - - // Draw Risks Section - drawSection( - 'Risiko Utama', - structuredInsight.summary?.risks || [], - [217, 119, 6], - [253, 243, 219] - ); - - // Draw Actions Section - drawSection( - 'Aksi Cepat', - structuredInsight.summary?.actions || [], - [5, 150, 105], - [209, 250, 229], - true - ); - - // Footer on all pages - const totalPages = doc.getNumberOfPages(); - for (let i = 1; i <= totalPages; i++) { - doc.setPage(i); - doc.setFont('helvetica', 'normal'); - doc.setFontSize(7.5); - doc.setTextColor(148, 163, 184); // Slate-400 - - const footerText = `Laporan AI Insights CP 707 | Halaman ${i} dari ${totalPages}`; - doc.text(footerText, margin, 285); - - doc.text(new Date().toLocaleString('id-ID'), pageWidth - margin - 35, 285); + await exportCardToPdf(pdfRef.current, filename); + } catch (error) { + console.error('Failed to generate PDF:', error); } - - const filename = `AI_Insight_Summary_Kandang_${kandangName.replace(/\s+/g, '_')}_Hari_${currentDayVal || '0'}.pdf`; - doc.save(filename); }; // Generate cache key @@ -798,45 +898,6 @@ export const AiInsightCard: React.FC = ({ kandangId, page = }, [kandangId, context.cycleData?.id]); // Load from local cache as a fallback only - const _loadFromCache = useCallback( - async (options: { allowStale?: boolean } = {}): Promise => { - const { allowStale = false } = options; - try { - const cacheKey = getCacheKey(); - const cached = localStorage.getItem(cacheKey); - if (!cached) return null; - - const parsed: AiInsight = JSON.parse(cached); - - // Check version - if (parsed.version !== CACHE_VERSION) { - if (allowStale) { - console.warn('⚠️ Using stale local insight cache as fallback'); - return parsed; - } - localStorage.removeItem(cacheKey); - return null; - } - - // Check TTL - const age = Date.now() - new Date(parsed.generatedAt).getTime(); - if (age > CACHE_TTL) { - localStorage.removeItem(cacheKey); - return null; - } - - // Check kandangId match - if (parsed.kandangId !== kandangId) { - return null; - } - return parsed; - } catch (err) { - console.error('❌ Error loading insight from cache:', err); - return null; - } - }, - [getCacheKey, kandangId] - ); // Save to local cache only; backend already persists the database copy. const saveToCache = useCallback( @@ -875,6 +936,7 @@ export const AiInsightCard: React.FC = ({ kandangId, page = multiCoopSummaryData, fcrComparisonData, ckaleData, + feedSackHistory, } = context; const kandangs = ((context as any).kandangs || []) as Array<{ id: number; name?: string }>; const sensorWeightSummary = ((context as any).sensorWeightSummary || @@ -940,14 +1002,55 @@ export const AiInsightCard: React.FC = ({ kandangId, page = iotPanelPromise, ]); + const mortalityHistory: Array<{ + day: number; + mati: number; + afkir: number; + panen: number; + kumulatifMatiAfkir: number; + }> = []; + if (mortalityRecords.status === 'fulfilled' && Array.isArray(mortalityRecords.value)) { + // One day can have several rows (one per coop), so fold them together + // before building the running cumulative. + const byDay = new Map(); mortalityRecords.value.forEach( - (record: { mortalityCount?: number; afkir?: number; panen?: number }) => { + (record: { + day?: number | null; + mortalityCount?: number; + afkir?: number; + panen?: number; + }) => { matiTotal += record.mortalityCount || 0; afkirTotal += record.afkir || 0; panenTotal += record.panen || 0; + + if (typeof record.day !== 'number' || !Number.isFinite(record.day)) return; + const bucket = byDay.get(record.day) ?? { mati: 0, afkir: 0, panen: 0 }; + bucket.mati += record.mortalityCount || 0; + bucket.afkir += record.afkir || 0; + bucket.panen += record.panen || 0; + byDay.set(record.day, bucket); } ); + + // Counts only — the rate is left to the backend, which resolves the + // flock size from the enriched pack. Computing it here would have to use + // `initialPopulation`, and that falls back to a hardcoded 20000 whenever + // `docInCount` is null (it is, on this cycle), producing a rate that + // contradicts the one the rest of the card reports. + let running = 0; + for (const day of [...byDay.keys()].sort((a, b) => a - b)) { + const bucket = byDay.get(day)!; + running += bucket.mati + bucket.afkir; + mortalityHistory.push({ + day, + mati: bucket.mati, + afkir: bucket.afkir, + panen: bucket.panen, + kumulatifMatiAfkir: running, + }); + } } else if (mortalityRecords.status === 'rejected') { console.error('❌ Failed to fetch mortality breakdown:', mortalityRecords.reason); } @@ -997,8 +1100,15 @@ export const AiInsightCard: React.FC = ({ kandangId, page = afkir: afkirTotal, panen: panenTotal, }, + mortalityHistory, weight: weightSection, iotPanel: iotPanel.status === 'fulfilled' ? iotPanel.value : null, + feedSack: { + karungDituangToday: kpis.karungDituang?.value ?? null, + trend: kpis.karungDituang?.trend ?? [], + trendDates: kpis.karungDituang?.trendDates ?? [], + history: feedSackHistory || [], + }, }); return { @@ -1280,39 +1390,536 @@ export const AiInsightCard: React.FC = ({ kandangId, page = const renderStructuredInsight = () => { if (!structuredInsight) return null; + const kpis = context.kpis; + const weightStats = context.weightStats; + // The per-coop DOC intake lives in `kandang_cycles`, which + // /api/kandangs/by-cycle already surfaces into `context.kandangs`. + // `cycles.doc_in_count` is NULL on the active cycle, so the old + // `|| 25000` fallback used ONE coop's intake as the whole-farm figure: + // with both coops in scope the balance became max(0, 25000 - 49.966) = 0, + // which is why "Saldo Akhir Ayam" read 0 no matter what. + // Scoped to the same coop selection as `mortalityRows`, or every coop when + // none is selected, so numerator and denominator always describe one flock. + const scopedKandangs = ( + (context.kandangs || []) as Array<{ id?: number; docInCount?: number }> + ).filter((k) => !kandangId || k.id?.toString() === kandangId.toString()); + const docFromKandangs = scopedKandangs.reduce((sum, k) => sum + (Number(k.docInCount) || 0), 0); + // No invented fallback: an unknown intake has to read as unknown, or the + // balance silently becomes a confident wrong number again. + const docInCount = docFromKandangs || Number(context.cycleData?.docInCount) || 0; + // Deaths are a FLOW — count only what happened inside the window. + const { + window: periodRows, + lastDay, + label: periodLabel, + } = sliceMortalityByPeriod(mortalityRows, reportType, reportPeriod); + const hasMortalityData = mortalityRows.length > 0; + const kematianPeriode = sumField(periodRows, 'mortalityCount') + sumField(periodRows, 'afkir'); + // Same window as the deaths beside them: both are flows, so both follow the + // selected period rather than the whole cycle. + const panenPeriode = sumField(periodRows, 'panen'); + const tonasePeriode = sumField(periodRows, 'beratPanen'); + + // Saldo is a STOCK — the birds still in the coop at the close of the window, + // so it counts every departure up to `lastDay`, not just this window's. + // The old formula was `docInCount - `, which ignored both + // earlier deaths and every bird harvested: it printed 25.000 for a coop that + // had already shipped its entire flock. + // + // NOTE: `panen` here is the locally recorded harvest column, which is also + // what "Jumlah terpanen" now displays. If Pak Sugi's API later turns out to + // be authoritative over this column, both the tile and this balance have to + // follow it together. + const rowsUpToLastDay = + lastDay === null + ? mortalityRows + : mortalityRows.filter( + (row) => typeof row.day === 'number' && Number.isFinite(row.day) && row.day <= lastDay + ); + const keluarKumulatif = + sumField(rowsUpToLastDay, 'mortalityCount') + + sumField(rowsUpToLastDay, 'afkir') + + sumField(rowsUpToLastDay, 'panen'); + + const fmt = (value: number) => value.toLocaleString('id-ID'); + const mortality = hasMortalityData ? fmt(kematianPeriode) : '-'; + const panen = hasMortalityData ? fmt(panenPeriode) : '-'; + const tonase = hasMortalityData && tonasePeriode > 0 ? `${fmt(tonasePeriode)} kg` : '-'; + const population = + hasMortalityData && docInCount > 0 ? fmt(Math.max(0, docInCount - keluarKumulatif)) : '-'; + + const rawFcr = kpis?.fcr?.value; + + const rawEef = kpis?.eef?.value; + const eefVal = rawEef ? (typeof rawEef === 'number' ? rawEef.toFixed(2) : rawEef) : 'N/A'; + + // ── Headline figures ────────────────────────────────────────────────── + // Everything below is derived from values already computed above; nothing + // new is fetched and no figure is invented when its source is missing. + const cycleDay = context.cycleData?.currentDay ?? null; + const standard = cycleDay ? getCobbStandardForDay(cycleDay) : null; + + const fcrNumber = typeof rawFcr === 'number' ? rawFcr : Number(rawFcr); + const hasFcr = Number.isFinite(fcrNumber) && fcrNumber > 0; + // Lower FCR is better, so the comparison is inverted against the standard. + const fcrStatus: InsightStatus = + !hasFcr || !standard?.fcr + ? 'unknown' + : fcrNumber <= standard.fcr + ? 'ok' + : fcrNumber <= standard.fcr * 1.1 + ? 'warning' + : 'critical'; + const fcrDelta = + hasFcr && standard?.fcr + ? `${fcrNumber > standard.fcr ? '+' : ''}${(fcrNumber - standard.fcr).toFixed(2)} vs standar` + : null; + + const weightRows = (context.weightHistory ?? []) as Array<{ + day?: number; + actual?: number; + target?: number; + }>; + const weightSpark = weightRows + .map((row) => row.actual) + .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); + // The scale has written 0 every day since it stopped reporting, so the last + // REAL reading is the last non-zero one — same rule the FCR page uses. + const lastWeight = [...weightSpark].reverse().find((value) => value > 0) ?? null; + const lastWeightRow = [...weightRows] + .reverse() + .find((row) => typeof row.actual === 'number' && row.actual > 0); + const weightTarget = + typeof lastWeightRow?.target === 'number' && lastWeightRow.target > 0 + ? lastWeightRow.target + : null; + const weightGapPct = + lastWeight !== null && weightTarget + ? ((lastWeight - weightTarget) / weightTarget) * 100 + : null; + const weightStatus: InsightStatus = + weightGapPct === null + ? 'unknown' + : weightGapPct >= -5 + ? 'ok' + : weightGapPct >= -15 + ? 'warning' + : 'critical'; + + const mortalityPct = + hasMortalityData && docInCount > 0 + ? ((sumField(rowsUpToLastDay, 'mortalityCount') + sumField(rowsUpToLastDay, 'afkir')) / + docInCount) * + 100 + : null; + const mortalityStatus: InsightStatus = + mortalityPct === null + ? 'unknown' + : mortalityPct < 5 + ? 'ok' + : mortalityPct <= 7 + ? 'warning' + : 'critical'; + + // Band geometry for the gap chart: a transparent base up to the lower of + // the two series, then the gap itself stacked on top. Recharts has no + // between-two-series area, so the band is built from two stacked areas. + const gapChartRows = weightRows + .filter((row) => typeof row.actual === 'number' && row.actual > 0) + .map((row) => { + const actual = row.actual as number; + const target = typeof row.target === 'number' && row.target > 0 ? row.target : null; + return { + day: row.day, + actual, + target, + bandBase: target === null ? 0 : Math.min(actual, target), + bandSize: target === null ? 0 : Math.abs(actual - target), + }; + }); + // Status hue, kept translucent so the lines stay readable through it. + const gapFill = + weightStatus === 'critical' + ? 'rgba(208, 59, 59, 0.14)' + : weightStatus === 'warning' + ? 'rgba(250, 178, 25, 0.18)' + : 'rgba(12, 163, 12, 0.12)'; + + // Where the flock went, per week. Deliberately NOT one stacked bar with all + // three: harvest reaches 13.933 in a week while deaths run 85-890, so a + // single stack would render mortality as an invisible sliver — the one + // figure an operator most needs to see. Two charts on a shared week axis + // instead, which is the standard answer to two measures of different scale. + const weeklyDeparture = (() => { + const byWeek = new Map< + number, + { minggu: number; mati: number; afkir: number; panen: number } + >(); + for (const row of mortalityRows) { + if (typeof row.day !== 'number' || !Number.isFinite(row.day) || row.day <= 0) continue; + const minggu = Math.ceil(row.day / 7); + const bucket = byWeek.get(minggu) ?? { minggu, mati: 0, afkir: 0, panen: 0 }; + bucket.mati += row.mortalityCount || 0; + bucket.afkir += row.afkir || 0; + bucket.panen += row.panen || 0; + byWeek.set(minggu, bucket); + } + return [...byWeek.values()].sort((a, b) => a.minggu - b.minggu); + })(); + const hasHarvest = weeklyDeparture.some((row) => row.panen > 0); + + const karungVal = + kpis?.karungDituang?.value !== undefined + ? kpis.karungDituang.value.toLocaleString('id-ID') + : 'N/A'; + + const uniformityVal = weightStats?.uniformity?.value + ? `${weightStats.uniformity.value}%` + : 'N/A'; const { summary, detailed } = structuredInsight; return (
    -
    -

    - Ringkasan Eksekutif -

    -

    {summary.headline}

    +
    + {/* Hero + tiles: the card leads with the metric it is really about, + and the remaining figures stay available but visibly secondary. */} + -
    -
    -

    - Prioritas -

    -
    {renderBulletList(summary.bullets)}
    -
    -
    -

    - Risiko utama -

    -
    - {renderBulletList(summary.risks, 'Tidak ada risiko yang dilaporkan')} +
    + + + + +
    + +
    + 0 ? docInCount.toLocaleString('id-ID') : '-'} + /> + + + + + +
    + + {weightRows.length > 0 && ( +
    +
    +

    Bobot Aktual vs Target CP 707

    + {weightGapPct !== null && ( + + Selisih terakhir{' '} + {weightGapPct.toFixed(0)}% + + )}
    -
    -
    -

    - Aksi cepat -

    -
    - {renderBulletList(summary.actions, 'Tidak ada aksi khusus')} +
    + + + {/* Solid hairline grid: dashes read as a threshold when they + are only a grid. */} + + `H${val}`} + tick={{ fontSize: 11, fill: '#6b7280' }} + axisLine={false} + tickLine={false} + minTickGap={16} + /> + + `Hari ke-${val}`} + formatter={(value: number | string, name: string) => { + if (name === 'bandBase' || name === 'bandSize') return []; + return [`${Math.round(Number(value)).toLocaleString('id-ID')} g`, name]; + }} + /> + + {/* The gap IS the story. Two stacked areas draw the band + between actual and target: an invisible base, then the + shortfall itself in the status colour. */} + + + + } + /> + +
    +

    + Area terarsir menunjukkan jarak bobot aktual terhadap target CP 707. +

    +
    + )} + + {weeklyDeparture.length > 0 && ( +
    +
    +

    Ke Mana Ayam Keluar Kandang

    + per minggu siklus +
    + + {/* Small multiples on a shared week axis. Harvest dwarfs + mortality by two orders of magnitude, so they get their own + scales rather than one stack that hides the smaller series. */} +
    +
    +
    + Deplesi — mati & afkir +
    +
    + + + + `M${val}`} + tick={{ fontSize: 11, fill: '#6b7280' }} + axisLine={false} + tickLine={false} + /> + + `Minggu ${val}`} + formatter={(value: number | string, name: string) => [ + `${Number(value).toLocaleString('id-ID')} ekor`, + name, + ]} + /> + + {/* 2px surface gap between stacked segments — a gap, not + a stroke around the marks. */} + + + + +
    +
    + + {hasHarvest && ( +
    +
    + Panen — keluar hidup +
    +
    + + + + `M${val}`} + tick={{ fontSize: 11, fill: '#6b7280' }} + axisLine={false} + tickLine={false} + /> + + val >= 1000 ? `${Math.round(Number(val) / 1000)}rb` : String(val) + } + /> + `Minggu ${val}`} + formatter={(value: number | string) => [ + `${Number(value).toLocaleString('id-ID')} ekor`, + 'Panen', + ]} + /> + + + +
    +
    + )} +
    +

    + Skalanya sengaja dipisah: panen mencapai belasan ribu ekor sementara deplesi + ratusan, jadi menumpuknya jadi satu batang akan menyembunyikan mortalitas. +

    +
    + )} + +
    +

    + + ✓ + + Kesimpulan +

    +

    + {summary.headline} +

    +
    + +
    +

    + + ✨ + + AI Insight +

    +
    + {renderBulletList([...summary.bullets, ...summary.risks, ...summary.actions])}
    @@ -1365,15 +1972,26 @@ export const AiInsightCard: React.FC = ({ kandangId, page = }; return ( -
    +
    + {/* Header */}

    AI Insight

    -
    +
    {/* Filter controls only visible on dashboard overview */} {(page === 'dashboard' || !page) && ( <> @@ -1384,8 +2002,8 @@ export const AiInsightCard: React.FC = ({ kandangId, page = > - {((context.kpis?.umur?.value || 0) >= 35 || - context.cycleData?.status === 'finished') && ( + {((context.kpis?.umur?.value || context.cycleData?.currentDay || 0) >= 35 || + context.cycleData?.status === 'Completed') && ( )} @@ -1397,7 +2015,14 @@ export const AiInsightCard: React.FC = ({ kandangId, page = className="text-sm border-gray-300 rounded-lg shadow-sm focus:border-purple-500 focus:ring-purple-500" > {Array.from( - { length: Math.max(1, Math.ceil((context.kpis?.umur?.value || 0) / 7)) }, + { + length: Math.max( + 1, + Math.ceil( + (context.kpis?.umur?.value || context.cycleData?.currentDay || 0) / 7 + ) + ), + }, (_, i) => i + 1 ).map((w) => (

    - {populationTrendHistory.length > 0 - ? populationTrendHistory[ - populationTrendHistory.length - 1 - ].population.toLocaleString() - : totalInitialPopulation.toLocaleString()} + {( + populationTrendHistory[populationTrendHistory.length - 1]?.population ?? + totalInitialPopulation + ).toLocaleString()} ekor

    diff --git a/components/EefDashboardPage.tsx b/components/EefDashboardPage.tsx index d2b1bdc..f6f4625 100644 --- a/components/EefDashboardPage.tsx +++ b/components/EefDashboardPage.tsx @@ -295,8 +295,20 @@ const EefDashboardPage: React.FC = () => { })); }, [supportRows]); + const getLatestValid = (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 (

    @@ -325,14 +337,44 @@ const EefDashboardPage: React.FC = () => { ({ hari: r.day, @@ -356,20 +398,27 @@ const EefDashboardPage: React.FC = () => { - +
    diff --git a/components/FcrDashboardPage.tsx b/components/FcrDashboardPage.tsx index 506e9d9..7362f8b 100644 --- a/components/FcrDashboardPage.tsx +++ b/components/FcrDashboardPage.tsx @@ -273,8 +273,20 @@ const FcrDashboardPage: React.FC = () => { })); }, [supportRows]); + const getLatestValid = (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 (
    @@ -304,16 +316,54 @@ const FcrDashboardPage: React.FC = () => { 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 = () => {
    diff --git a/components/FeedSackCountingPage.tsx b/components/FeedSackCountingPage.tsx index 20c32d8..5142824 100644 --- a/components/FeedSackCountingPage.tsx +++ b/components/FeedSackCountingPage.tsx @@ -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 = ({ 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 = ({ 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(); + 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 = ({ setView }) { + 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.', }} diff --git a/components/IotPanelPage.tsx b/components/IotPanelPage.tsx index 5d097ae..97433d4 100644 --- a/components/IotPanelPage.tsx +++ b/components/IotPanelPage.tsx @@ -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 = () => { {
    - {kandang.docInCount > 0 ? ( + {(kandang.docInCount ?? 0) > 0 ? ( Siap Produksi diff --git a/components/ManualWeighingPage.tsx b/components/ManualWeighingPage.tsx index f58ea72..0b6a91a 100644 --- a/components/ManualWeighingPage.tsx +++ b/components/ManualWeighingPage.tsx @@ -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>(() => { - 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); diff --git a/components/PerformanceDebug.tsx b/components/PerformanceDebug.tsx index 0fb3358..2550997 100644 --- a/components/PerformanceDebug.tsx +++ b/components/PerformanceDebug.tsx @@ -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`); diff --git a/components/WeightMonitoringPageRevamped.tsx b/components/WeightMonitoringPageRevamped.tsx index bffc0c5..9fc8963 100644 --- a/components/WeightMonitoringPageRevamped.tsx +++ b/components/WeightMonitoringPageRevamped.tsx @@ -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 = () => { 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, + })), }} /> diff --git a/components/batch/ServiceMonitor.tsx b/components/batch/ServiceMonitor.tsx index 8f50e59..4d555c1 100644 --- a/components/batch/ServiceMonitor.tsx +++ b/components/batch/ServiceMonitor.tsx @@ -11,10 +11,8 @@ const ServiceMonitor: React.FC = ({ 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 diff --git a/components/common/ActivityLogPanel.tsx b/components/common/ActivityLogPanel.tsx index d6a64c1..990c4ea 100644 --- a/components/common/ActivityLogPanel.tsx +++ b/components/common/ActivityLogPanel.tsx @@ -195,7 +195,7 @@ const ActivityLogPanel: React.FC = ({ 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]; diff --git a/components/common/ErrorBoundary.tsx b/components/common/ErrorBoundary.tsx index a627f48..14ee593 100644 --- a/components/common/ErrorBoundary.tsx +++ b/components/common/ErrorBoundary.tsx @@ -1,4 +1,4 @@ -import React, { Component, ErrorInfo, ReactNode } from 'react'; +import { Component, ErrorInfo, ReactNode } from 'react'; interface Props { children: ReactNode; diff --git a/components/counting/ChickenCountTrendChart.tsx b/components/counting/ChickenCountTrendChart.tsx index c78b36f..9406b86 100644 --- a/components/counting/ChickenCountTrendChart.tsx +++ b/components/counting/ChickenCountTrendChart.tsx @@ -61,9 +61,8 @@ const ChickenCountTrendChart: React.FC = ({ 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']; }} /> diff --git a/components/counting/ChickenCountingAiInsight.tsx b/components/counting/ChickenCountingAiInsight.tsx index 7e410c1..71a4b6c 100644 --- a/components/counting/ChickenCountingAiInsight.tsx +++ b/components/counting/ChickenCountingAiInsight.tsx @@ -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, + periodDeparture: ReturnType, + 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 = (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([]); + 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(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [showPerKandang, setShowPerKandang] = useState(false); + const [timeframe, setTimeframe] = useState<'harian' | 'mingguan'>('harian'); const inFlightRef = useRef(false); const hasGeneratedRef = useRef(false); + const lastCacheKeyRef = useRef(null); + const pdfRef = useRef(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(''); + const generateRef = useRef<(force?: boolean) => Promise>(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 = ( // 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 = ( 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((_, 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 = ( } 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 = ( 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 ( -
    +
    +
    + { + const { fromDay, toDay } = parseDayRangeLabel(period.label); + return describeDataDates(cycleData?.startDate, fromDay, toDay); + })()} + /> +
    + {/* Header */}
    @@ -300,18 +552,45 @@ export const ChickenCountingAiInsight: React.FC = (

    - +
    +
    + + +
    + {result && ( + + )} + +
    {/* Body */} @@ -355,126 +634,107 @@ export const ChickenCountingAiInsight: React.FC = ( )} {/* Result */} - {result && cfg && ( + {result && (
    - {/* Headline banner */} -
    - -
    -
    - - {cfg.label} - - {loading && ( - Memperbarui... - )} -
    -

    {result.headline}

    -
    -
    - - {/* 3-column grid */} -
    - {/* Ringkasan */} -
    -

    - 📋 Ringkasan -

    -
      - {result.summary.map((item, i) => ( -
    • - - {item} -
    • - ))} -
    -
    - - {/* Risiko */} -
    -

    - ⚠️ Risiko -

    - {result.risks.length > 0 ? ( -
      - {result.risks.map((item, i) => ( -
    • - - {item} -
    • - ))} -
    - ) : ( -

    Tidak ada risiko signifikan

    - )} -
    - - {/* Tindakan */} -
    -

    - ✅ Tindakan -

    -
      - {result.actions.map((item, i) => ( -
    1. - - {i + 1} - - {item} -
    2. - ))} -
    -
    -
    - - {/* Per-Kandang */} - {result.perKandang.length > 0 && ( -
    - - - {showPerKandang && ( -
    - {result.perKandang.map((k, i) => { - const kCfg = STATUS_CONFIG[k.status] ?? STATUS_CONFIG.ok; - const KIcon = kCfg.icon; - return ( -
    -
    -

    {k.name}

    - - {kCfg.label} - -
    -
    - -

    {k.insight}

    -
    - {k.action && ( -
    -

    - Tindakan: -

    -

    {k.action}

    -
    - )} -
    - ); - })} -
    - )} -
    + {heroSpec && ( + )} +
    +
    + {/* Data Points */} +
    + + Jumlah Populasi (Jumlah DOC) + + + {props.totalInitialPopulation.toLocaleString()} + +
    +
    + + Jumlah Kematian (Kematian + Afkir){' '} + — {period.label} + + + {periodDepartures.adaData + ? periodDepartures.mortalityTotal.toLocaleString('id-ID') + : '-'} + +
    +
    + + Jumlah terpanen{' '} + — {period.label} + + + {periodDepartures.adaData + ? periodDepartures.panen.toLocaleString('id-ID') + : '-'} + +
    +
    + + Jumlah tonase panen{' '} + — {period.label} + + + {periodDepartures.adaData && periodDepartures.beratPanen > 0 + ? `${periodDepartures.beratPanen.toLocaleString('id-ID')} kg` + : '-'} + +
    +
    + + Saldo Akhir Ayam{' '} + — seluruh siklus + + + {departures.adaData ? departures.saldo.toLocaleString('id-ID') : '-'} + +
    +
    + + {/* GRAFIK — konteks visual untuk teks di bawahnya. + `data-pdf-block` keeps a page break from landing inside it. */} +
    + +
    + +
    +

    + Kesimpulan +

    +

    + {result.kesimpulan} +

    +
    + +
    +

    + AI Insight +

    +

    + {result.insight} +

    +
    +
    )}
    diff --git a/components/counting/ChickenCountingDashboard.tsx b/components/counting/ChickenCountingDashboard.tsx index a0cd642..c0b7c8e 100644 --- a/components/counting/ChickenCountingDashboard.tsx +++ b/components/counting/ChickenCountingDashboard.tsx @@ -71,8 +71,8 @@ const ChickenCountingDashboard: React.FC = ({ 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 = ({ 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 }; }, [ diff --git a/components/counting/CombinedTrendChart.tsx b/components/counting/CombinedTrendChart.tsx index 5d83b0c..ec7ca55 100644 --- a/components/counting/CombinedTrendChart.tsx +++ b/components/counting/CombinedTrendChart.tsx @@ -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 = ({ // Use external state if provided, otherwise use internal state const selectedKandangId = externalSelectedKandangId !== undefined ? externalSelectedKandangId : internalSelectedKandangId; - const _setSelectedKandangId = onKandangChange || setInternalSelectedKandangId; const [filteredPopulation, setFilteredPopulation] = useState(populationData); const [filteredChickenCount, setFilteredChickenCount] = diff --git a/components/counting/DataManagementPage.tsx b/components/counting/DataManagementPage.tsx index b93fb1f..83d0bf8 100644 --- a/components/counting/DataManagementPage.tsx +++ b/components/counting/DataManagementPage.tsx @@ -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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ selectedKandang
    {/* AI Insight */} - 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 */} diff --git a/components/counting/LocationCountBreakdown.tsx b/components/counting/LocationCountBreakdown.tsx index aa3bfcd..ed7ea3e 100644 --- a/components/counting/LocationCountBreakdown.tsx +++ b/components/counting/LocationCountBreakdown.tsx @@ -9,12 +9,12 @@ const LocationCountBreakdown: React.FC = () => { const [openAccordions, setOpenAccordions] = useState>(() => { const initialState: Record = {}; - 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 diff --git a/components/cycle/CycleCreateModal.tsx b/components/cycle/CycleCreateModal.tsx index ecc7cbc..a49d640 100644 --- a/components/cycle/CycleCreateModal.tsx +++ b/components/cycle/CycleCreateModal.tsx @@ -7,7 +7,7 @@ interface CycleCreateModalProps { } const CycleCreateModal: React.FC = ({ 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(''); diff --git a/components/feedSack/FeedDailySummaryTable.tsx b/components/feedSack/FeedDailySummaryTable.tsx index c6c949f..81d8ec2 100644 --- a/components/feedSack/FeedDailySummaryTable.tsx +++ b/components/feedSack/FeedDailySummaryTable.tsx @@ -66,7 +66,7 @@ const FeedDailySummaryTable: React.FC = ({ const usageRef = React.useRef(null); const outboundRef = React.useRef(null); - const scrollToSection = (ref: React.RefObject) => { + const scrollToSection = (ref: React.RefObject) => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; @@ -81,6 +81,18 @@ const FeedDailySummaryTable: React.FC = ({ // 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 = ({ 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 = ({ } 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 = ({ 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 = ({ } 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 = ({ 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 = ({ // 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 = ({ 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 = ({ } // 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 = ({ emoji: string, headerColor: string, cameras: FeedSackColumnConfig[], - ref: React.RefObject + ref: React.RefObject ) => { if (cameras.length === 0) return null; diff --git a/components/feedSack/FeedSackComparisonCharts.tsx b/components/feedSack/FeedSackComparisonCharts.tsx index 0201094..a566fce 100644 --- a/components/feedSack/FeedSackComparisonCharts.tsx +++ b/components/feedSack/FeedSackComparisonCharts.tsx @@ -51,7 +51,7 @@ const FeedSackComparisonCharts: React.FC = ({ 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 = ({ 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 = ({ 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)!); diff --git a/components/iot/IotDeviceStats.tsx b/components/iot/IotDeviceStats.tsx index 04b855f..36de633 100644 --- a/components/iot/IotDeviceStats.tsx +++ b/components/iot/IotDeviceStats.tsx @@ -78,7 +78,7 @@ const IotDeviceStats: React.FC = ({ devices }) => { paddingAngle={5} dataKey="value" > - {pieData.map((entry, index) => ( + {pieData.map((_entry, index) => ( ))} diff --git a/components/manualWeighing/ManualWeighingAnalysis.tsx b/components/manualWeighing/ManualWeighingAnalysis.tsx index b6d2715..e2d8e50 100644 --- a/components/manualWeighing/ManualWeighingAnalysis.tsx +++ b/components/manualWeighing/ManualWeighingAnalysis.tsx @@ -70,7 +70,7 @@ const ManualWeighingAnalysis: React.FC = ({ 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)) }; }); diff --git a/components/mobile/OperatorTasksScreen.tsx b/components/mobile/OperatorTasksScreen.tsx index 6d4ab63..4a77c1c 100644 --- a/components/mobile/OperatorTasksScreen.tsx +++ b/components/mobile/OperatorTasksScreen.tsx @@ -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'; diff --git a/components/scheduling/FeedScheduleFormModal.tsx b/components/scheduling/FeedScheduleFormModal.tsx index 459109c..7ae3cf1 100644 --- a/components/scheduling/FeedScheduleFormModal.tsx +++ b/components/scheduling/FeedScheduleFormModal.tsx @@ -54,7 +54,7 @@ const FeedScheduleFormModal: React.FC = ({ 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); diff --git a/components/shared/EndCycleWeightVsStandardChart.tsx b/components/shared/EndCycleWeightVsStandardChart.tsx new file mode 100644 index 0000000..31e5802 --- /dev/null +++ b/components/shared/EndCycleWeightVsStandardChart.tsx @@ -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 = ({ + data, + startDate, +}) => { + const chartData = useMemo(() => { + 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(() => { + 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 ( +
    +

    Hari ke-{point.day}

    + {formatDateForDay(point) && ( +

    {formatDateForDay(point)}

    + )} +
    +

    + + Berat aktual: {formatWeight(point.actual)} +

    +

    + + Standar Cobb: {formatWeight(point.target)} +

    +

    + Selisih:{' '} + + {formatSignedWeight(point.deviation)} ({formatSignedPct(pct)}) + +

    +

    {ahead ? 'Di atas standar' : 'Di bawah standar'}

    +
    +
    + ); + }; + + if (chartData.length === 0) { + return ( +
    +

    + Berat Aktual vs Standar Cobb +

    +

    + Data berat harian belum tersedia untuk siklus ini, jadi grafik perbandingan terhadap + standar Cobb belum bisa ditampilkan. +

    +
    + ); + } + + return ( +
    +
    +

    + Berat Aktual vs Standar Cobb +

    + + {chartData.length} hari data · dihitung dari data timbangan, bukan dari AI + +
    +

    + Area berwarna menunjukkan jarak antara berat aktual dan kurva standar — hijau saat di atas + standar, oranye saat tertinggal. +

    + +
    + + + + `H${day}`} + interval="preserveStartEnd" + minTickGap={24} + /> + `${value}g`} + width={64} + /> + } cursor={{ stroke: '#cbd5e1', strokeWidth: 1 }} /> + + + + + + + + + +
    + + {stats && ( +
    +
    +

    + Selisih akhir siklus +

    +

    = 0 ? COLOR_SURPLUS : COLOR_DEFICIT }} + > + {formatSignedWeight(stats.finalDeviation)} +

    +

    {formatSignedPct(stats.finalDeviationPct)}

    +
    +
    +

    + Hari di atas standar +

    +

    + {stats.daysAbove} + / {chartData.length} +

    +

    {stats.daysBelow} hari tertinggal

    +
    +
    +

    + Rata-rata deviasi +

    +

    = 0 ? COLOR_SURPLUS : COLOR_DEFICIT }} + > + {formatSignedPct(stats.avgDeviationPct)} +

    +

    sepanjang siklus

    +
    +
    +

    + Titik terburuk +

    + {stats.worstDay ? ( + <> +

    + {formatSignedWeight(stats.worstDay.deviation)} +

    +

    pada hari ke-{stats.worstDay.day}

    + + ) : ( + <> +

    + {stats.bestDay ? formatSignedWeight(stats.bestDay.deviation) : '—'} +

    +

    tidak pernah di bawah standar

    + + )} +
    +
    + )} +
    + ); +}; + +export default EndCycleWeightVsStandardChart; diff --git a/components/shared/InsightConditionChart.tsx b/components/shared/InsightConditionChart.tsx new file mode 100644 index 0000000..7469600 --- /dev/null +++ b/components/shared/InsightConditionChart.tsx @@ -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 = ({ + 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 ( +
    + {emptyLabel} +
    + ); + } + + return ( +
    + {title &&

    {title}

    } +
    + + + Aktual + + + + Standar CP 707 + +
    + +
    + {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 ( +
    +
    + {bar.label} + + {bar.actual === null ? 'data tidak tersedia' : fmt(bar.actual, bar.unit)} + {bar.standard !== null && ( + · standar {fmt(bar.standard, bar.unit)} + )} + +
    + +
    +
    + {bar.actual !== null && ( +
    + )} +
    +
    + {bar.standard !== null && ( +
    + )} +
    +
    + + {bar.note &&

    {bar.note}

    } +
    + ); + })} +
    +
    + ); +}; + +export default InsightConditionChart; diff --git a/components/shared/InsightStatTiles.tsx b/components/shared/InsightStatTiles.tsx new file mode 100644 index 0000000..5786b41 --- /dev/null +++ b/components/shared/InsightStatTiles.tsx @@ -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 = { + 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 = ({ status, label }) => { + const chip = STATUS_CHIP[status]; + return ( + + + {label} + + ); +}; + +/** + * 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 ( + + ); +}; + +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 = ({ + label, + value, + unit, + context, + status = 'unknown', + deltaLabel, + sparkline, +}) => ( +
    +
    +
    {label}
    +
    + {/* `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. */} + + {value} + + {unit ? {unit} : null} +
    +
    + {deltaLabel ? : null} + {context ? {context} : null} +
    +
    + {sparkline && sparkline.length > 1 ? ( + + ) : null} +
    +); + +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 = ({ + label, + value, + unit, + hint, + status = 'unknown', + deltaLabel, + sparkline, +}) => ( +
    +
    + {label} + {hint ? {hint} : null} +
    +
    + {/* Same reason as the hero above. */} + {value} + {unit ? {unit} : null} +
    + {deltaLabel ? ( +
    + +
    + ) : null} + {sparkline && sparkline.length > 1 ? ( + + ) : null} +
    +); + +/** 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, +}) => ( +
    + + {label} + {hint ? {hint} : null} + + {value} +
    +); diff --git a/components/shared/InsightTrendChart.tsx b/components/shared/InsightTrendChart.tsx new file mode 100644 index 0000000..fed2827 --- /dev/null +++ b/components/shared/InsightTrendChart.tsx @@ -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>; + 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): 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 = ({ + 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 = { 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 & { 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 ( +
    + {emptyLabel} +
    + ); + } + + 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 ( +
    + {title &&

    {title}

    } + {band && ( +

    + Area berbayang = periode yang dibahas insight + {band.firstDay === band.lastDay + ? ` (hari ke-${band.lastDay})` + : ` (hari ke-${band.firstDay} s/d ke-${band.lastDay})`} +

    + )} + + + + {band && ( + + )} + `H${value}`} + /> + Number(value).toLocaleString('id-ID')} + /> + `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 && } + {gap && gapFill && ( + <> + + + + )} + {drawn.map((s) => ( + + ))} + + +
    + ); +}; + +export default InsightTrendChart; diff --git a/components/shared/PageAiInsight.tsx b/components/shared/PageAiInsight.tsx index 77f4ba4..514b99b 100644 --- a/components/shared/PageAiInsight.tsx +++ b/components/shared/PageAiInsight.tsx @@ -1,17 +1,28 @@ /* eslint-disable react-hooks/preserve-manual-memoization */ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { - Sparkles, - RefreshCw, - AlertTriangle, - CheckCircle, - TrendingDown, - Info, - Download, -} from 'lucide-react'; -import { jsPDF } from 'jspdf'; +import { Sparkles, RefreshCw, AlertTriangle, CheckCircle, Download } from 'lucide-react'; import { useAppContext } from '../../context/AppContext.tsx'; +import PdfReportHeader, { + buildPdfFilename, + describeDataDates, + describeKandangScope, + describeTimeframePeriod, + isoDataDates, +} from './PdfReportHeader.tsx'; +import { exportCardToPdf } from '../../utils/pdfExport.ts'; +import { HeroFigure, type InsightStatus } from './InsightStatTiles.tsx'; +import { getCobbStandardForDay } from '../../utils/insightHelpers.ts'; import { apiClient } from '../../services/apiService.ts'; +import InsightTrendChart, { type InsightSeries } from './InsightTrendChart.tsx'; +import InsightConditionChart, { type ConditionBar } from './InsightConditionChart.tsx'; +import { + scopeContextToTimeframe, + scopeScalarsToTimeframe, + describeTimeframe, + UNAVAILABLE, + type ScalarScopeSpec, +} from '../../utils/insightScope.ts'; +import { parseAiResult, type AiResult } from '../../utils/insightParse.ts'; // ─── Global in-memory cache (persists across page navigation in same session) ── // Key: storageKey string, Value: parsed AiResult @@ -31,30 +42,35 @@ export interface PageAiInsightProps { topic: InsightTopic; contextData: Record; cacheKey: string; + /** + * How this page's summary scalars derive from its day-indexed history. + * + * Without it only the history gets sliced, leaving the scalars showing + * today's figures beside a chart covering the whole window — the model then + * quotes whichever it likes. Pages with no history can omit it. + */ + scalarScope?: ScalarScopeSpec; + /** + * Chart drawn alongside the generated text. + * + * `trend` plots the FULL history and shades the analysed window — the insight + * is often scoped to a single day, and a line of one point shows nothing. + * `condition` is for topics with no per-day history (the IoT panel is a live + * snapshot): current readings against their CP 707 standard. + */ + chartSpec?: + | { + type: 'trend'; + historyKey: string; + series: InsightSeries[]; + unit?: string; + title?: string; + /** Shade the distance between a measurement and its standard. */ + gap?: { actualField: string; targetField: string }; + } + | { type: 'condition'; bars: ConditionBar[]; title?: string }; } -interface AiResult { - headline: string; - status: 'ok' | 'warning' | 'critical'; - summary: string[]; - risks: string[]; - actions: string[]; -} - -// ─── Response Schema ────────────────────────────────────────────────────────── -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const PAGE_INSIGHT_SCHEMA = { - type: 'OBJECT', - properties: { - headline: { type: 'STRING' }, - status: { type: 'STRING', enum: ['ok', 'warning', 'critical'] }, - summary: { type: 'ARRAY', items: { type: 'STRING' } }, - risks: { type: 'ARRAY', items: { type: 'STRING' } }, - actions: { type: 'ARRAY', items: { type: 'STRING' } }, - }, - required: ['headline', 'status', 'summary', 'risks', 'actions'], -} as const; - // Helper: save to both global memory and sessionStorage const _saveToCache = (storageKey: string, result: AiResult): void => { _insightMemoryCache.set(storageKey, result); @@ -129,671 +145,263 @@ const COLOR_MAP: Record< }, }; -const STATUS_CFG = { - ok: { - bg: 'bg-emerald-50', - border: 'border-emerald-200', - text: 'text-emerald-700', - badge: 'bg-emerald-100 text-emerald-700', - label: 'Normal', - Icon: CheckCircle, - }, - warning: { - bg: 'bg-amber-50', - border: 'border-amber-200', - text: 'text-amber-700', - badge: 'bg-amber-100 text-amber-700', - label: 'Perhatian', - Icon: AlertTriangle, - }, - critical: { - bg: 'bg-red-50', - border: 'border-red-200', - text: 'text-red-700', - badge: 'bg-red-100 text-red-700', - label: 'Kritis', - Icon: TrendingDown, - }, +/** Used when a topic or colour name is not in the maps above. */ +const DEFAULT_TOPIC_META = TOPIC_META['hitung_ayam']; +const DEFAULT_COLORS = { + ring: 'border-purple-100', + sparkBg: 'bg-purple-100', + sparkText: 'text-purple-600', + btnBorder: 'border-purple-200', + btnHover: 'hover:bg-purple-50', }; -const PAGE_FOCUSES: Record = { - hitung_ayam: [ - 'Tren mortalitas harian dan dampaknya terhadap populasi akhir', - 'Distribusi ayam di berbagai area/kamera (penumpukan)', - 'Perbandingan populasi aktual dengan target DOC In', - 'Deteksi dini potensi wabah penyakit dari lonjakan mortalitas', - 'Evaluasi efektivitas afkir selektif vs kematian alami', - 'Proyeksi sisa populasi layak panen', - 'Evaluasi tingkat kepadatan (density) vs kapasitas kandang', - ], - berat_ayam: [ - 'Pencapaian target bobot (ADG) vs standar Cobb', - 'Keseragaman (uniformity) bobot antar ayam', - 'Hubungan stres lingkungan (jika ada indikasi) dengan hambatan pertumbuhan', - 'Analisis tren pertumbuhan dalam 7 hari terakhir', - 'Proyeksi waktu panen berdasarkan tren bobot saat ini', - 'Evaluasi performa ayam di area depan vs belakang kandang', - 'Deteksi indikasi stunting atau pertumbuhan terhambat', - ], - fcr: [ - 'Efisiensi konversi pakan secara keseluruhan vs standar', - 'Dampak fluktuasi asupan pakan terhadap nilai FCR harian', - 'Korelasi antara mortalitas dengan pembengkakan FCR', - 'Identifikasi pemborosan pakan atau distribusi pakan yang tidak merata', - 'Proyeksi kerugian/keuntungan finansial berdasarkan FCR saat ini', - 'Evaluasi kualitas pakan terhadap tingkat penyerapan (berdasarkan FCR)', - 'Tindakan perbaikan tata laksana pakan (jam pemberian, feed sorting)', - ], - eef: [ - 'Keseimbangan antara FCR, Bobot, dan Mortalitas dalam skor EEF', - 'Potensi pencapaian bonus indeks performa di akhir siklus', - 'Faktor utama yang menarik turun skor EEF (apakah FCR atau mortalitas?)', - 'Evaluasi keberhasilan manajemen pemeliharaan secara holistik', - 'Bandingkan performa siklus ini dengan target standar perusahaan (>300)', - 'Proyeksi skor akhir EEF jika tren saat ini dipertahankan', - 'Tindakan taktis penyelamatan nilai EEF di sisa hari pemeliharaan', - ], - iot_panel: [ - 'Stabilitas suhu siang-malam dan risiko heat stress/cold stress', - 'Manajemen kelembapan dan dampaknya pada litter (sekam)', - 'Akumulasi gas amonia/CO2 dan pengaruhnya terhadap pernapasan ayam', - 'Kinerja sistem ventilasi (exhaust fan & cooling pad)', - 'Korelasi fluktuasi cuaca luar kandang dengan iklim mikro di dalam', - 'Pengaturan setpoint suhu vs kenyamanan aktual ayam (THI)', - 'Deteksi dini kegagalan fungsi sensor atau blind spot ventilasi', - ], - hitung_karung: [ - 'Keseimbangan antara stok pakan dengan laju konsumsi harian', - 'Deteksi anomali konsumsi pakan (turun drastis/naik tidak wajar)', - 'Pemerataan jadwal penuangan pakan ke dalam silo/hopper', - 'Efisiensi persediaan dan pencegahan pakan berjamur/rusak', - 'Kesesuaian rasio asupan pakan per ekor dengan standar umur', - 'Evaluasi manajemen stok menjelang jadwal panen (menghindari sisa)', - 'Dampak suhu ekstrem terhadap nafsu makan dan penarikan pakan', - ], -}; - -// ─── Prompt builders ───────────────────────────────────────────────────────── -const buildPrompt = ( - topic: InsightTopic, - data: Record, - timeframe: 'harian' | 'mingguan' = 'harian' -): string => { - // Limit data to 2000 chars to avoid oversized prompts - const fullJson = JSON.stringify(data, null, 2); - const dataStr = fullJson.length > 2000 ? fullJson.slice(0, 2000) + '\n...' : fullJson; - - const focuses = PAGE_FOCUSES[topic] || []; - const selectedFocus = - focuses[Math.floor(Math.random() * focuses.length)] || 'Analisis menyeluruh'; - - const INSTRUCTIONS: Record = { - hitung_ayam: `Anda adalah manajer peternakan ayam broiler strain CP 707. -Analisis data HITUNG AYAM berikut. Berikan insight singkat, profesional, dan actionable dalam Bahasa Indonesia. -Pedoman mortalitas strain CP 707: di bawah 5 persen = normal (ok), 5-7 persen = perhatian (warning), di atas 7 persen = kritis (critical). - -Konteks & Aturan Variasi: -Fokus utama analisis Anda hari ini adalah: "${selectedFocus}". -Lakukan analisis secara ${timeframe.toUpperCase()}. -Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. - -Data kandang: -${dataStr}`, - - berat_ayam: `Anda adalah ahli nutrisi peternakan ayam broiler strain CP 707. -Analisis data BERAT AYAM berikut. Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia. -Pedoman: bobot di bawah target standar CP 707 lebih dari 5 persen = warning. ADG rendah menunjukkan masalah nutrisi atau penyakit. - -Konteks & Aturan Variasi: -Fokus utama analisis Anda hari ini adalah: "${selectedFocus}". -Lakukan analisis secara ${timeframe.toUpperCase()}. -Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. - -Data bobot: -${dataStr}`, - - fcr: `Anda adalah analis performa peternakan ayam broiler strain CP 707. -Analisis data FCR (Feed Conversion Ratio) berikut. -FCR = Total Pakan dibagi Total Bobot Hidup. Nilai FCR yang lebih kecil berarti lebih efisien. -Pedoman: FCR sesuai standar CP 707 = ok. FCR sedikit di atas standar = warning. FCR jauh di atas standar = critical. -Standar FCR CP 707 umur 35 hari sekitar 1.60 hingga 1.63. -Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia. - -Konteks & Aturan Variasi: -Fokus utama analisis Anda hari ini adalah: "${selectedFocus}". -Lakukan analisis secara ${timeframe.toUpperCase()}. -Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. - -Data FCR: -${dataStr}`, - - eef: `Anda adalah analis performa peternakan ayam broiler strain CP 707. -Analisis data EEF (European Efficiency Factor) berikut. -EEF = persen_hidup dikali bw_panen_gram dibagi fcr dikali hari_panen dikali 100. Nilai lebih tinggi lebih baik. -Pedoman: EEF di atas 300 sangat baik (ok). EEF 250-300 baik (ok). EEF 200-250 perhatian (warning). EEF di bawah 200 kritis (critical). -Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia. - -Konteks & Aturan Variasi: -Fokus utama analisis Anda hari ini adalah: "${selectedFocus}". -Lakukan analisis secara ${timeframe.toUpperCase()}. -Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. - -Data EEF: -${dataStr}`, - - iot_panel: `Anda adalah ahli manajemen lingkungan kandang ayam broiler berbasis IoT strain CP 707. -Analisis kondisi lingkungan kandang dari data sensor IoT berikut. -Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia. -Pedoman: Suhu ideal 20-30 derajat Celsius. Kelembapan ideal 60-70 persen. Amonia di bawah 25 ppm aman. CO2 di bawah 3000 ppm aman. - -Konteks & Aturan Variasi: -Fokus utama analisis Anda hari ini adalah: "${selectedFocus}". -Lakukan analisis secara ${timeframe.toUpperCase()}. -Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. - -Data sensor IoT: -${dataStr}`, - - hitung_karung: `Anda adalah manajer logistik pakan peternakan ayam broiler strain CP 707. -Analisis data HITUNG KARUNG PAKAN berikut. -Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia. -Pedoman: konsumsi pakan harian wajar strain CP 707 sekitar 170 gram per ekor per hari untuk broiler umur 35 hari. - -Konteks & Aturan Variasi: -Fokus utama analisis Anda hari ini adalah: "${selectedFocus}". -Lakukan analisis secara ${timeframe.toUpperCase()}. -Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir. - -Data karung pakan: -${dataStr}`, - }; - - return `${INSTRUCTIONS[topic]}`; -}; - -// ─── Result parser (fallback jika schema mode gagal) ───────────────────────── -const parseResult = (text: string, topic?: InsightTopic): AiResult | null => { - if (!text || typeof text !== 'string') return null; - const stripped = text.replace(/[\s\S]*?<\/think>/gi, '').trim(); - const cleaned = stripped - .replace(/^```(?:json)?\s*/i, '') - .replace(/\s*```\s*$/, '') - .replace(/\/\/.*/g, '') - .replace(/\/\*[\s\S]*?\*\//g, '') - .replace(/\.\.\.[\s\S]*?(?=[,}\]\n])/g, '') - .replace(/,\s*([}\]])/g, '$1') - .trim(); - - const first = cleaned.indexOf('{'); - const last = cleaned.lastIndexOf('}'); - if (first === -1 || last === -1 || last <= first) return null; - - try { - const parsed = JSON.parse(cleaned.slice(first, last + 1)); - if (!parsed || typeof parsed !== 'object') return null; - - // Check if it's nested dashboard format ({ summary: {...}, detailed: {...} }) - if (parsed.detailed && typeof parsed.detailed === 'object') { - const topicMap: Record = { - hitung_ayam: 'hitung_ayam', - berat_ayam: 'berat_ayam', - fcr: 'fcr', - eef: 'eef', - iot_panel: 'panel_iot', - hitung_karung: 'hitung_karung', - }; - const sectionKey = topic ? topicMap[topic] || topic : 'hitung_ayam'; - const section = parsed.detailed[sectionKey] || Object.values(parsed.detailed)[0]; - - if (section && typeof section === 'object') { - let secStatus: 'ok' | 'warning' | 'critical' = 'ok'; - if (section.status === 'critical') secStatus = 'critical'; - else if (section.status === 'warning') secStatus = 'warning'; - - return { - headline: String( - section.bullets?.[0] || - parsed.summary?.headline || - 'Analisis operasional kandang terpantau normal.' - ), - status: secStatus, - summary: Array.isArray(section.bullets) - ? section.bullets.map(String) - : Array.isArray(parsed.summary?.bullets) - ? parsed.summary.bullets.map(String) - : [], - risks: Array.isArray(section.evidence) ? section.evidence.map(String) : [], - actions: Array.isArray(section.actions) ? section.actions.map(String) : [], - }; - } - } - - // Support flat JSON format - const headline = - parsed.headline || - parsed.summary?.headline || - parsed.judul || - parsed.headlineText || - parsed.headline_utama || - parsed.message || - 'Analisis operasional kandang terpantau normal.'; - - const rawStatus = String( - parsed.status || parsed.summary?.status || parsed.kondisi || 'ok' - ).toLowerCase(); - let status: 'ok' | 'warning' | 'critical' = 'ok'; - if ( - rawStatus.includes('crit') || - rawStatus.includes('kritis') || - rawStatus.includes('tinggi') || - rawStatus.includes('parah') || - rawStatus.includes('critical') - ) { - status = 'critical'; - } else if ( - rawStatus.includes('warn') || - rawStatus.includes('perhatian') || - rawStatus.includes('kurang') || - rawStatus.includes('sedang') || - rawStatus.includes('warning') - ) { - status = 'warning'; - } - - // Support synonyms for lists, with fallback formatting for objects - const toArr = (v: unknown) => - Array.isArray(v) - ? v - .map((item) => { - if (item && typeof item === 'object') { - const obj = item as Record; - const desc = - obj.description || - obj.deskripsi || - obj.text || - obj.action || - obj.tindakan || - obj.parameter; - if (desc) { - return obj.parameter ? `[${obj.parameter}] ${desc}` : String(desc); - } - return JSON.stringify(item); - } - return String(item); - }) - .filter(Boolean) - : []; - - const summaryData = - parsed.summary?.bullets || - parsed.summary || - parsed.ringkasan || - parsed.poin_ringkasan || - parsed.bullets || - []; - const risksData = - parsed.summary?.risks || - parsed.risks || - parsed.risiko || - parsed.resiko || - parsed.bahaya || - parsed.evidence || - []; - const actionsData = - parsed.summary?.actions || - parsed.actions || - parsed.tindakan || - parsed.saran || - parsed.rekomendasi || - parsed.langkah_konkrit || - parsed.langkah || - []; - - return { - headline: String(headline), - status, - summary: toArr(summaryData), - risks: toArr(risksData), - actions: toArr(actionsData), - }; - } catch { - return null; - } -}; - -// Helper to safely parse strings containing units and Indonesian commas into standard numbers -const toNum = (val: unknown, fallback: number): number => { - if (val === undefined || val === null) return fallback; - if (typeof val === 'number') return Number.isFinite(val) ? val : fallback; - const str = String(val) - .replace(/[^0-9,.-]/g, '') - .replace(',', '.') - .trim(); - const num = Number(str); - return Number.isFinite(num) ? num : fallback; -}; - -// ─── Local Rule-Based Fallback Generator (CP 707 Standard Compliance) ───────── -const _generateLocalInsight = (topic: InsightTopic, data: Record): AiResult => { - switch (topic) { - case 'hitung_ayam': { - const initial = toNum(data.initialPopulation || data.totalInitialPopulation, 10000); - const current = toNum(data.currentPopulation || data.iotCurrentPopulation, 9800); - const mortality = toNum(data.totalMortality, initial - current); - const rate = - data.mortalityRate !== undefined - ? toNum(data.mortalityRate, 0) - : initial > 0 - ? (mortality / initial) * 100 - : 0; - const day = toNum(data.currentDay || data.hari_ke, 1); - - let status: 'ok' | 'warning' | 'critical' = 'ok'; - if (rate > 7) status = 'critical'; - else if (rate >= 5) status = 'warning'; - - const risks = []; - const actions = []; - if (status === 'critical') { - risks.push( - `Mortalitas kumulatif mencapai ${rate.toFixed(2)}%, jauh di atas batas toleransi CP 707 (>7%).` - ); - actions.push('Lakukan bedah bangkai (nekropsi) segera untuk analisa patologi.'); - actions.push('Perketat biosekuriti kandang dan berikan multivitamin suportif.'); - } else if (status === 'warning') { - risks.push( - `Mortalitas kumulatif mencapai ${rate.toFixed(2)}%, mendekati batas toleransi CP 707 (5-7%).` - ); - actions.push('Tingkatkan kualitas sanitasi dan sekam kandang.'); - actions.push('Evaluasi kecukupan asupan air minum ayam.'); - } else { - actions.push('Pertahankan manajemen biosekuriti dan sanitasi harian.'); - } - - return { - headline: `Tingkat deplesi/mortalitas kumulatif terpantau ${rate.toFixed(2)}% pada hari ke-${day}.`, - status, - summary: [ - `Populasi awal siklus: ${initial.toLocaleString()} ekor.`, - `Populasi aktif saat ini: ${current.toLocaleString()} ekor.`, - `Total kematian kumulatif: ${mortality.toLocaleString()} ekor.`, - ], - risks, - actions, - }; - } - - case 'berat_ayam': { - const avg = toNum(data.averageWeight, 0); - const target = toNum(data.targetWeight, 0); - const _day = toNum(data.currentDay, 1); - const uniformity = toNum(data.uniformity, 85); - const diffPct = target > 0 ? ((target - avg) / target) * 100 : 0; - - let status: 'ok' | 'warning' | 'critical' = 'ok'; - if (diffPct > 15 || uniformity < 75) status = 'critical'; - else if (diffPct > 5 || uniformity < 85) status = 'warning'; - - const risks = []; - const actions = []; - if (status === 'critical') { - risks.push(`Bobot aktual tertinggal ${diffPct.toFixed(1)}% dari standar CP 707.`); - risks.push(`Keseragaman (uniformity) di bawah batas toleransi (${uniformity}%).`); - actions.push('Lakukan sekat pembatas (grading) untuk memisahkan ayam berbobot kecil.'); - actions.push('Evaluasi kecukupan ruang pakan dan asupan nutrisi.'); - } else if (status === 'warning') { - risks.push(`Bobot aktual tertinggal ${diffPct.toFixed(1)}% dari standar pertumbuhan.`); - actions.push('Tingkatkan stimulasi pakan di siang hari dengan cara digoyang.'); - actions.push('Sesuaikan tinggi tempat pakan agar sejajar dengan punggung ayam.'); - } else { - actions.push('Pertahankan manajemen ventilasi untuk mendukung ADG harian.'); - } - - return { - headline: `Rata-rata bobot mencapai ${avg}g vs Target standar ${target}g.`, - status, - summary: [ - `Bobot rata-rata aktual: ${avg} gram.`, - `Target bobot standar strain CP 707: ${target} gram.`, - `Tingkat keseragaman (uniformity) populasi: ${uniformity}%.`, - ], - risks, - actions, - }; - } - - case 'fcr': { - const fcr = toNum(data.fcr_terakhir, 1.62); - const day = toNum(data.hari_terakhir, 35); - - let status: 'ok' | 'warning' | 'critical' = 'ok'; - if (fcr > 1.7) status = 'critical'; - else if (fcr > 1.63) status = 'warning'; - - const risks = []; - const actions = []; - if (status === 'critical') { - risks.push(`FCR mencapai ${fcr.toFixed(2)}, jauh di atas target standar CP 707 (>1.70).`); - actions.push('Periksa kebocoran silo/hopper dan potensi pakan tercecer.'); - actions.push('Evaluasi kesehatan pencernaan ayam terhadap infeksi.'); - } else if (status === 'warning') { - risks.push( - `FCR mencapai ${fcr.toFixed(2)}, sedikit di atas standar efisiensi (1.60-1.63).` - ); - actions.push('Atur ketinggian piringan pakan untuk meminimalkan pakan tumpah.'); - actions.push('Pantau kualitas fisik pakan (kadar debu/fines).'); - } else { - actions.push('Pertahankan ketepatan penimbangan dan pemberian pakan harian.'); - } - - return { - headline: `Feed Conversion Ratio (FCR) tercatat sebesar ${fcr.toFixed(2)} pada hari ke-${day}.`, - status, - summary: [ - `FCR aktual saat ini: ${fcr.toFixed(2)}.`, - `Nilai FCR memenuhi standar efisiensi konversi pakan strain CP 707.`, - ], - risks, - actions, - }; - } - - case 'eef': { - const eef = toNum(data.eef_terakhir, 300); - const day = toNum(data.hari_terakhir, 35); - - let status: 'ok' | 'warning' | 'critical' = 'ok'; - if (eef < 200) status = 'critical'; - else if (eef < 250) status = 'warning'; - - const risks = []; - const actions = []; - if (status === 'critical') { - risks.push(`EEF di zona kritis (<200) akibat penurunan viabilitas dan FCR buruk.`); - actions.push('Lakukan audit manajemen menyeluruh terkait ventilasi dan biosekuriti.'); - } else if (status === 'warning') { - risks.push( - `Skor EEF (${eef.toFixed(0)}) berada di batas minimal standar optimal (200-250).` - ); - actions.push('Tingkatkan pengelolaan sirkulasi udara kandang.'); - } else { - actions.push('Pertahankan performa pemeliharaan hingga masa panen selesai.'); - } - - return { - headline: `Indeks performa EEF mencapai skor ${eef.toFixed(0)} pada hari ke-${day}.`, - status, - summary: [ - `EEF aktual saat ini: ${eef.toFixed(0)}.`, - `Skor EEF melambangkan efisiensi produksi siklus berjalan secara keseluruhan.`, - ], - risks, - actions, - }; - } - - case 'iot_panel': { - const temp = toNum(data.suhu_rata_rata_C, 25); - const hum = toNum(data.kelembapan_persen, 65); - const ammonia = toNum(data.amonia_ppm, 10); - const co2 = toNum(data.co2_ppm, 1500); - - const isTempBad = temp < 20 || temp > 30; - const isHumBad = hum < 60 || hum > 70; - const isAmoniaBad = ammonia > 25; - - let status: 'ok' | 'warning' | 'critical' = 'ok'; - if (ammonia > 35 || temp > 34) status = 'critical'; - else if (isTempBad || isHumBad || isAmoniaBad) status = 'warning'; - - const risks = []; - const actions = []; - if (isTempBad) { - risks.push( - `Suhu kandang (${temp.toFixed(1)}°C) berada di luar batas ideal CP 707 (20-30°C).` - ); - actions.push( - temp > 30 - ? 'Aktifkan kipas pendingin tambahan atau exhaust fan.' - : 'Nyalakan pemanas tambahan.' - ); - } - if (isAmoniaBad) { - risks.push( - `Kadar amonia tinggi (${ammonia.toFixed(1)} ppm), dapat mengiritasi mata dan paru ayam.` - ); - actions.push('Tingkatkan minimal ventilasi kandang untuk mengeluarkan gas amonia.'); - } - - if (status === 'ok') { - actions.push('Pertahankan setting otomatis iklim mikro kandang.'); - } - - return { - headline: `Kondisi Lingkungan Kandang: Suhu Rata-rata ${temp.toFixed(1)}°C, Kelembapan ${hum.toFixed(1)}%.`, - status, - summary: [ - `Suhu rata-rata: ${temp.toFixed(1)}°C.`, - `Kelembapan udara: ${hum.toFixed(1)}%.`, - `Kadar amonia: ${ammonia.toFixed(1)} ppm.`, - `Kadar CO2: ${co2.toFixed(1)} ppm.`, - ], - risks, - actions, - }; - } - - case 'hitung_karung': { - const dituang = toNum(data.total_karung_dituang_iot, 0); - const masuk = toNum(data.total_karung_masuk_iot, 0); - const saldo = toNum(data.saldo_awal_karung, 100); - const sisa = saldo + masuk - dituang; - - let status: 'ok' | 'warning' | 'critical' = 'ok'; - if (sisa < 10) status = 'critical'; - else if (sisa < 30) status = 'warning'; - - const risks = []; - const actions = []; - if (status === 'critical') { - risks.push(`Stok pakan menipis secara kritis (${sisa} karung tersisa).`); - actions.push('Segera ajukan pengiriman pakan darurat.'); - } else if (status === 'warning') { - risks.push(`Stok pakan tersisa ${sisa} karung, cukup untuk 1-2 hari.`); - actions.push('Buat permintaan pakan baru hari ini.'); - } else { - actions.push('Lakukan pemantauan konsumsi pakan harian.'); - } - - return { - headline: `Sisa stok pakan terhitung sebanyak ${sisa} karung.`, - status, - summary: [ - `Pakan masuk: ${masuk} karung.`, - `Pakan dituang: ${dituang} karung.`, - `Sisa cadangan pakan: ${sisa} karung.`, - ], - risks, - actions, - }; - } - - default: - return { - headline: 'Analisis operasional kandang terpantau normal.', - status: 'ok', - summary: ['Data operasional normal.'], - risks: [], - actions: ['Lanjutkan pengawasan berkala.'], - }; - } +// Helper to safely render values that might be 0, avoiding falsy checks +const renderValue = (val: unknown, suffix: string = ''): string => { + if (val === undefined || val === null || val === '') return 'N/A'; + // A scalar marked unavailable has no magnitude, so a unit after it reads as + // nonsense ("tidak tersedia %"). + if (val === UNAVAILABLE) return UNAVAILABLE; + return `${val} ${suffix}`.trim(); }; // ─── Component ─────────────────────────────────────────────────────────────── -export const PageAiInsight: React.FC = ({ topic, contextData, cacheKey }) => { - const { cycleData, kandangs, selectedKandangId } = useAppContext(); +export const PageAiInsight: React.FC = ({ + topic, + contextData, + cacheKey, + scalarScope, + chartSpec, +}) => { + const appCtx = useAppContext(); + const { cycleData } = appCtx; + // Removed unused variable declarations + + // Removed unused variable declarations + const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [timeframe, setTimeframe] = useState<'harian' | 'mingguan'>('harian'); - const inFlightRef = useRef(false); - const lastCacheKeyRef = useRef(null); - - const meta = TOPIC_META[topic] ?? TOPIC_META['hitung_ayam']; - const colors = COLOR_MAP[meta.color] ?? COLOR_MAP['purple']; - - const fetchFromBackend = useCallback( - async (cycleId: string): Promise => { + // Persisted per topic: the FCR and EEF pages render this card behind + // `{!isLoading && ...}`, so every data refresh unmounts it. Plain state came + // back as "harian" and silently discarded the user's choice, which looked + // like the weekly insight being identical to the daily one. + // Only pages that declare how their scalars follow a day-indexed history can + // produce a genuinely different answer per period. + const supportsTimeframe = Boolean(scalarScope); + const timeframeKey = `insight-timeframe-${topic}`; + const [timeframe, setTimeframeState] = useState<'harian' | 'mingguan'>(() => { + try { + return sessionStorage.getItem(timeframeKey) === 'mingguan' ? 'mingguan' : 'harian'; + } catch { + return 'harian'; + } + }); + const setTimeframe = useCallback( + (next: 'harian' | 'mingguan') => { + setTimeframeState(next); try { - const res = await apiClient.getAiInsight(cycleId); - if (!res.success || !res.data?.insightText) { - return null; - } - - const parsedDashboard = JSON.parse(res.data.insightText); - const detailed = parsedDashboard.detailed; - if (!detailed) return null; - - const topicMap: Record = { - hitung_ayam: 'hitung_ayam', - berat_ayam: 'berat_ayam', - fcr: 'fcr', - eef: 'eef', - iot_panel: 'panel_iot', - hitung_karung: 'hitung_karung', - }; - - const key = topicMap[topic]; - const section = detailed[key]; - if (!section) return null; - - let status: 'ok' | 'warning' | 'critical' = 'ok'; - if (section.status === 'critical') status = 'critical'; - else if (section.status === 'warning') status = 'warning'; - - const headlineMap: Record = { - hitung_ayam: 'Analisis estimasi populasi harian dan tingkat mortalitas kandang.', - berat_ayam: 'Evaluasi bobot harian (ADG) dan tingkat keseragaman ayam.', - fcr: 'Analisis efisiensi konversi pakan harian terhadap standar.', - eef: 'Indeks performa keseluruhan efisiensi produksi siklus berjalan.', - iot_panel: 'Pemantauan parameter suhu, kelembaban, dan gas amonia kandang.', - hitung_karung: 'Analisis stok pakan dan efisiensi konsumsi harian.', - }; - - return { - headline: section.bullets?.[0] || headlineMap[topic], - status, - summary: section.bullets || [], - risks: section.evidence || [], - actions: section.actions || [], - }; - } catch (err) { - console.warn('Failed to load insight from backend cache:', err); - return null; + sessionStorage.setItem(timeframeKey, next); + } catch { + /* ignore */ } }, - [topic] + [timeframeKey] ); + const inFlightRef = useRef(false); + const lastCacheKeyRef = useRef(null); + const pdfRef = useRef(null); + + // The scope the user is currently asking for. A request already in flight is + // dropped rather than queued, so without recording the want, switching the + // timeframe mid-request left the previous period's text on screen forever. + const desiredKeyRef = useRef(''); + // Lets the scheduling effect call the latest `generate` without listing it as + // a dependency — see the effect below for why that matters. + const generateRef = useRef<(force?: boolean) => Promise>(async () => {}); + + // Which days the insight text covers, computed the same way the payload is + // sliced so the shaded band can never disagree with the words. + const chartHighlight = React.useMemo(() => { + if (!chartSpec || chartSpec.type !== 'trend') return null; + const rows = (contextData as Record)[chartSpec.historyKey]; + if (!Array.isArray(rows)) return null; + const days = rows + .map((row) => { + const r = row as Record; + for (const key of ['hari', 'day']) { + const value = r[key]; + if (typeof value === 'number' && Number.isFinite(value)) return value; + } + return null; + }) + .filter((day): day is number => day !== null); + if (days.length === 0) return null; + const lastDay = Math.max(...days); + const firstDay = timeframe === 'harian' ? lastDay : Math.max(Math.min(...days), lastDay - 6); + return { firstDay, lastDay }; + }, [chartSpec, contextData, timeframe]); + + /** + * The figures shown in the summary grid, scoped exactly like the prompt. + * + * The grid used to read `contextData` directly, so it kept showing + * cycle-to-date numbers while the narrative below described a single day or + * week — the toggle appeared to change only the text. + */ + const displayContext = React.useMemo(() => { + const raw = contextData as Record; + if (!scalarScope) return raw; + const sliced = scopeContextToTimeframe(raw, timeframe); + const scoped = scopeScalarsToTimeframe( + sliced as Record, + timeframe, + scalarScope + ); + const out: Record = { ...raw }; + for (const [outKey, rule] of Object.entries(scalarScope.derive)) { + if (rule.replaces) out[rule.replaces] = scoped[outKey]; + } + // Mirror what the model was told: a scalar with no per-day column cannot be + // attributed to this period, so it must not sit beside ones that can. + for (const key of scalarScope.unavailable ?? []) out[key] = UNAVAILABLE; + out.periode = scoped.periode; + return out; + }, [contextData, timeframe, scalarScope]); + + const periodeLabel = typeof displayContext.periode === 'string' ? displayContext.periode : null; + + /** + * The one figure this card is about. + * + * A single-topic card has an obvious headline number, but every value was + * rendered at the same weight, so nothing led the eye. The hero promotes that + * number and — where a CP 707 standard exists for the day — states the + * comparison instead of leaving the reader to do it. + */ + const heroSpec = React.useMemo(() => { + const num = (value: unknown): number | null => + typeof value === 'number' && Number.isFinite(value) ? value : null; + const standard = cycleData?.currentDay ? getCobbStandardForDay(cycleData.currentDay) : null; + const dayLabel = cycleData?.currentDay ? `hari ke-${cycleData.currentDay}` : null; + + if (topic === 'berat_ayam') { + const actual = num(displayContext.averageWeight); + const target = num(displayContext.targetWeight) ?? standard?.weight ?? null; + if (actual === null) return null; + const dev = target ? ((actual - target) / target) * 100 : null; + return { + label: 'Rata-rata Bobot Aktual', + value: Math.round(actual).toLocaleString('id-ID'), + unit: 'g', + context: target + ? `Target CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${Math.round(target).toLocaleString('id-ID')} g` + : null, + status: (dev === null + ? 'unknown' + : dev >= -5 + ? 'ok' + : dev >= -15 + ? 'warning' + : 'critical') as InsightStatus, + deltaLabel: dev === null ? null : `${dev.toFixed(0)}% vs target`, + }; + } + + if (topic === 'fcr') { + const actual = num(displayContext.fcr_terakhir); + if (actual === null) return null; + const std = standard?.fcr ?? null; + // Lower FCR is better, so the comparison runs the other way. + return { + label: 'FCR Aktual', + value: actual.toFixed(2), + unit: undefined, + context: std ? `Standar CP 707${dayLabel ? ` ${dayLabel}` : ''}: ${std.toFixed(2)}` : null, + status: (std === null + ? 'unknown' + : actual <= std + ? 'ok' + : actual <= std * 1.1 + ? 'warning' + : 'critical') as InsightStatus, + deltaLabel: + std === null ? null : `${actual > std ? '+' : ''}${(actual - std).toFixed(2)} vs standar`, + }; + } + + if (topic === 'eef') { + const actual = num(displayContext.eef_terakhir); + if (actual === null) return null; + // EEF is only judged against >300 at harvest age; mid-cycle it is + // legitimately low, so no verdict is offered before then. + const atHarvest = (cycleData?.currentDay ?? 0) >= 35; + return { + label: 'EEF', + value: actual.toFixed(2), + unit: undefined, + context: atHarvest ? 'Standar umur panen: >300' : 'Belum umur panen — wajar masih rendah', + status: (!atHarvest + ? 'unknown' + : actual >= 300 + ? 'ok' + : actual >= 250 + ? 'warning' + : 'critical') as InsightStatus, + deltaLabel: atHarvest ? (actual >= 300 ? 'di atas standar' : 'di bawah standar') : null, + }; + } + + if (topic === 'iot_panel') { + const actual = num(displayContext.suhu_rata_rata_C); + const setpoint = num(displayContext.setpoint_C); + if (actual === null) return null; + const diff = setpoint === null ? null : actual - setpoint; + return { + label: 'Suhu Rata-rata Kandang', + value: actual.toLocaleString('id-ID'), + unit: '°C', + context: setpoint === null ? null : `Setpoint: ${setpoint.toLocaleString('id-ID')} °C`, + status: (diff === null + ? 'unknown' + : Math.abs(diff) <= 1 + ? 'ok' + : Math.abs(diff) <= 2.5 + ? 'warning' + : 'critical') as InsightStatus, + deltaLabel: + diff === null ? null : `${diff > 0 ? '+' : ''}${diff.toFixed(1)} °C vs setpoint`, + }; + } + + if (topic === 'hitung_karung') { + const actual = num(displayContext.total_karung_dituang_iot); + if (actual === null) return null; + const manual = num(displayContext.total_karung_dituang_manual); + const perDay = num(displayContext.rata_karung_per_hari); + // Manual sits in the same line rather than a second hero: both count the + // same sacks, so two big numbers would read as a total. + const parts = [ + manual === null ? null : `Manual: ${manual.toLocaleString('id-ID')} karung`, + perDay === null ? null : `${perDay.toLocaleString('id-ID')} karung/hari`, + ].filter(Boolean); + return { + label: 'Pakan Dituang (IOT)', + value: actual.toLocaleString('id-ID'), + unit: 'karung', + context: parts.length > 0 ? parts.join(' · ') : null, + status: 'unknown' as InsightStatus, + deltaLabel: null, + }; + } + + return null; + }, [topic, displayContext, cycleData?.currentDay]); + + // Indexing a Record yields `T | undefined`, so a lookup fallback is undefined + // too. Naming the defaults directly gives both a value the type system can + // rely on. + const meta = TOPIC_META[topic] ?? DEFAULT_TOPIC_META; + const colors = COLOR_MAP[meta.color] ?? DEFAULT_COLORS; // eslint-disable-next-line react-hooks/preserve-manual-memoization const generate = useCallback( @@ -815,7 +423,10 @@ export const PageAiInsight: React.FC = ({ topic, contextData const memCached = _insightMemoryCache.get(storageKey); if (memCached) { setResult(memCached); - lastCacheKeyRef.current = cacheKey; + // Must carry the timeframe: the effect guard compares against + // `${cacheKey}-${timeframe}`, so a bare cacheKey never matched and + // this path re-triggered generation on every render. + lastCacheKeyRef.current = `${cacheKey}-${timeframe}`; return; } } @@ -836,7 +447,7 @@ export const PageAiInsight: React.FC = ({ topic, contextData try { const raw = sessionStorage.getItem(storageKey); if (raw) { - const parsed = parseResult(raw); + const parsed = parseAiResult(raw); if (parsed) { _insightMemoryCache.set(storageKey, parsed); // promote to memory setResult(parsed); @@ -855,27 +466,84 @@ export const PageAiInsight: React.FC = ({ topic, contextData setLoading(true); setError(null); + const rawCtxDay = + (contextData as any)?.currentDay ?? + (contextData as any)?.hari_ke ?? + (contextData as any)?.hari_terakhir ?? + cycleData?.currentDay; + const maxDays = (contextData as any)?.totalDays ?? cycleData?.totalDays; + const currentDayVal = maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay; + + // Trim the data itself to the selected window. Previously the full cycle + // was sent in both modes and only this label changed, so "Harian" and + // "Mingguan" came back nearly identical. + const slicedContextData = scopeContextToTimeframe(contextData, timeframe); + // Scalars must follow the same window as the history they summarise. + const scopedContextData = scalarScope + ? scopeScalarsToTimeframe( + slicedContextData as Record, + timeframe, + scalarScope + ) + : slicedContextData; + // The label must describe the days actually in the payload, not the cycle + // day. Weight history stops at day 38 while the cycle is on day 48, so a + // label derived from `currentDay` announced "hari ke-48" over day-38 + // numbers and the model duly reported day-38 weight as day-48 weight. + // `periode` is produced by the scalar scoping from the sliced rows, so it + // is the one value that always matches the data. + const dataPeriode = (scopedContextData as Record)?.periode; + const timeframeLabel = !supportsTimeframe + ? 'Kondisi terkini (pembacaan sesaat, bukan rentang waktu)' + : typeof dataPeriode === 'string' && dataPeriode.trim().length > 0 + ? dataPeriode + : describeTimeframe(timeframe, currentDayVal); + + // Surface the gap rather than hide it: data lagging the cycle is itself a + // finding, and leaving it implicit is what let the mismatch through. + const dataLagNote = + typeof dataPeriode === 'string' && + currentDayVal && + !dataPeriode.includes(`ke-${currentDayVal}`) + ? `\n- CATATAN: data terbaru yang tersedia adalah ${dataPeriode}, sedangkan siklus sudah di hari ke-${currentDayVal}. Sebut angka sesuai harinya sendiri, jangan mengaitkannya ke hari ke-${currentDayVal}.` + : ''; + + const prompt = `Sebagai konsultan manajemen peternakan ayam broiler profesional (CP 707 Standard). +Anda ditugaskan untuk menganalisis data operasional kandang: +- Topik Analisis: ${topic} +- Rentang Waktu: ${timeframeLabel} +- Siklus: Hari ke-${currentDayVal} (Total: ${maxDays} hari)${dataLagNote} + +Data Konteks (SUDAH dibatasi pada rentang waktu di atas): +${JSON.stringify(scopedContextData, null, 2)} + +Tugas Anda: +1. Analisis HANYA rentang waktu di atas (${timeframeLabel}). Data yang diberikan sudah dipotong sesuai rentang itu, jadi jangan menyimpulkan tren di luar rentang tersebut dan jangan mengarang angka dari hari lain. +2. Buat "kesimpulan" singkat (1-2 kalimat) terkait status performa saat ini berdasarkan standar CP 707. +3. Buat "insight" (rekomendasi/tindakan) operasional yang spesifik, praktis, dan dapat langsung diterapkan di lapangan. + +Keluarkan hasil analisis HANYA dalam format JSON dengan skema berikut: +{ + "kesimpulan": "Kesimpulan analisis Anda", + "insight": "Insight/rekomendasi praktis Anda" +} + +Pastikan respons Anda valid JSON tanpa teks markdown tambahan.`; + // Register this promise for deduplication const fetchPromise = (async () => { try { - // ── 4. Backend database cache (bypass if force-refreshing) ──────────── - if (!force && cycleData?.id) { - const backendResult = await fetchFromBackend(cycleData.id); - if (backendResult) { - _saveToCache(storageKey, backendResult); - setResult(backendResult); - lastCacheKeyRef.current = `${cacheKey}-${timeframe}`; - return; - } - } - - // ── 5. Generate via LM Studio proxy with 4000ms timeout ─────────────── - const prompt = buildPrompt(topic, contextData, timeframe); + // ── 4. Generate via LM Studio proxy ─────────────────────────────────── + // NOTE: the backend insight cache (`getAiInsight(cycleId)`) is keyed by + // cycle only — not by topic and not by timeframe — so reading it here + // handed every page the same text and made the Harian/Mingguan toggle + // look broken. The memory + sessionStorage caches above are keyed by + // `${cacheKey}-${timeframe}`, which is the scope this component needs. const system_prompt = - 'Anda adalah analis peternakan ayam broiler berpengalaman. Kembalikan HANYA objek JSON valid dengan format: {"headline": "kesimpulan singkat", "status": "ok/warning/critical", "summary": ["poin 1", "poin 2"], "risks": ["risiko 1"], "actions": ["tindakan 1"]}. Dilarang memberikan teks penjelasan di luar objek JSON.'; + 'Anda adalah analis peternakan ayam broiler berpengalaman. Kembalikan HANYA objek JSON valid dengan format: {"kesimpulan": "kesimpulan analisis", "insight": "rekomendasi/tindakan praktis"}. Dilarang memberikan teks penjelasan di luar objek JSON.'; // Build a short semantic query for RAG (describe what we're analyzing) - const context_query = `Analisis ${timeframe} ${topic.replace(/_/g, ' ')} hari ke-${contextData.currentDay || contextData.hari_ke || contextData.hari_terakhir || 0} broiler CP707`; + const context_query = `Analisis ${timeframe} ${topic.replace(/_/g, ' ')} hari ke-${currentDayVal || 0} broiler CP707`; const fetchPromiseCall = apiClient.proxyLmStudio( prompt, @@ -883,15 +551,17 @@ export const PageAiInsight: React.FC = ({ topic, contextData 0.2, topic, context_query, - contextData + scopedContextData ); const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('TIMEOUT_LIMIT')), 600000) + // 20 minutes, matching the backend. A shorter client cap just makes + // the browser give up on a request the server is still answering. + setTimeout(() => reject(new Error('TIMEOUT_LIMIT')), 1200000) ); const json = await Promise.race([fetchPromiseCall, timeoutPromise]); const text = json.response ?? ''; - const parsed = parseResult(text, topic); + const parsed = parseAiResult(text); if (!parsed) { console.error(`[PageAiInsight:${topic}] Raw response:`, text.slice(0, 500)); @@ -916,6 +586,11 @@ export const PageAiInsight: React.FC = ({ topic, contextData setLoading(false); inFlightRef.current = false; _insightInFlight.delete(storageKey); + // The user may have switched timeframe while this was in flight; that + // request was dropped, so run the scope they actually want now. + if (desiredKeyRef.current !== lastCacheKeyRef.current) { + setTimeout(() => generateRef.current(false), 0); + } } })(); @@ -924,243 +599,84 @@ export const PageAiInsight: React.FC = ({ topic, contextData // eslint-disable-next-line react-hooks/preserve-manual-memoization // eslint-disable-next-line react-hooks/exhaustive-deps }, - [topic, cacheKey, contextData, cycleData?.id, fetchFromBackend, result, timeframe] + [topic, cacheKey, contextData, cycleData?.id, result, timeframe, scalarScope] ); + // Refreshed after commit, not during render: writing a ref while rendering is + // unsafe under concurrent rendering, and both are only ever read from async + // callbacks that run well after this effect. + useEffect(() => { + desiredKeyRef.current = `${cacheKey}-${timeframe}`; + generateRef.current = generate; + }); + // Depends on the scope only, NOT on `generate`. + // + // `generate` is rebuilt whenever `contextData` changes, and every page passes + // it as an inline object literal — so it is a new reference on each parent + // render. With `generate` in the dependency list the cleanup kept clearing the + // 800ms timer and rescheduling it, and on a page that re-renders faster than + // that the insight was never requested at all. useEffect(() => { if (lastCacheKeyRef.current === `${cacheKey}-${timeframe}`) return; - const timer = setTimeout(() => generate(false), 800); + const timer = setTimeout(() => generateRef.current(false), 800); return () => clearTimeout(timer); - }, [cacheKey, timeframe, generate]); + }, [cacheKey, timeframe]); - const downloadPdf = () => { - if (!result) return; + const downloadPdf = async () => { + if (!result || !pdfRef.current) return; - const doc = new jsPDF({ - orientation: 'portrait', - unit: 'mm', - format: 'a4', - }); - - const primaryColor = [79, 70, 229]; // Indigo [R, G, B] - const secondaryColor = [31, 41, 55]; // Gray-800 - const lightGray = [243, 244, 246]; // Gray-100 - - // Page dimensions - const pageWidth = doc.internal.pageSize.getWidth(); - const margin = 20; - const contentWidth = pageWidth - margin * 2; - - // Helper to strip non-ASCII to prevent corrupted PDF in jsPDF standard fonts - const safeText = (text: string) => { - if (!text) return ''; - return text - .replace(/[“”]/g, '"') - .replace(/[‘’]/g, "'") - .replace(/–|—/g, '-') - .replace(/•/g, '-') - .replace(/[^\x20-\x7E\n]/g, ''); - }; - - // Helper to wrap and add text - let y = 20; - - // 1. Header Title - doc.setFont('helvetica', 'bold'); - doc.setFontSize(20); - doc.setTextColor(primaryColor[0], primaryColor[1], primaryColor[2]); - doc.text('Daya Cognitive AI Insights', margin, y); - - // Subtitle - y += 7; - doc.setFont('helvetica', 'normal'); - doc.setFontSize(8.5); - doc.setTextColor(100, 116, 139); // Slate-500 - doc.text( - 'Strategic operations diagnostics & predictive actions recommended by sovereign LLMs', - margin, - y - ); - - // Divider line - y += 5; - doc.setDrawColor(226, 232, 240); // Slate-200 - doc.setLineWidth(0.5); - doc.line(margin, y, pageWidth - margin, y); - - // 2. Metadata Section (Horizontal tags) - y += 8; - doc.setFillColor(lightGray[0], lightGray[1], lightGray[2]); - doc.rect(margin, y, contentWidth, 10, 'F'); - - doc.setFont('helvetica', 'bold'); - doc.setFontSize(7.5); - doc.setTextColor(71, 85, 105); // Slate-600 - - const activeKandang = kandangs?.find((k: any) => k.id === selectedKandangId); - const kandangName = activeKandang?.name || 'Semua Kandang'; - const rawCtxDay = - (contextData as any)?.currentDay ?? - (contextData as any)?.hari_ke ?? - (contextData as any)?.hari_terakhir ?? - cycleData?.currentDay; - const maxDays = (contextData as any)?.totalDays ?? cycleData?.totalDays; - const currentDayVal = maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay; - const dayAge = - currentDayVal !== undefined && currentDayVal !== null ? `Hari ${currentDayVal}` : '-'; - - const metaText = `SCOPE: ${meta.title.toUpperCase()} | KANDANG: ${kandangName.toUpperCase()} | UMUR SIKLUS: ${dayAge} | TANGGAL: ${new Date().toLocaleDateString('id-ID')}`; - doc.text(metaText, margin + 4, y + 6.5); - - // 3. Headline Box (Status-based background) - y += 18; - - // Determine status badge properties - let statusLabel = 'OK'; - let badgeColor = [16, 185, 129]; // Emerald (OK) - let badgeBg = [209, 250, 229]; - if (result.status === 'warning') { - statusLabel = 'PERINGATAN'; - badgeColor = [245, 158, 11]; // Amber - badgeBg = [254, 243, 199]; - } else if (result.status === 'critical') { - statusLabel = 'KRITIS'; - badgeColor = [239, 68, 68]; // Red - badgeBg = [254, 226, 226]; - } - - // Draw Headline status background - doc.setFillColor(badgeBg[0], badgeBg[1], badgeBg[2]); - doc.roundedRect(margin, y, contentWidth, 18, 2, 2, 'F'); - - // Status Badge text - doc.setFillColor(badgeColor[0], badgeColor[1], badgeColor[2]); - doc.roundedRect(margin + 4, y + 4, 26, 5.5, 1, 1, 'F'); - doc.setFont('helvetica', 'bold'); - doc.setFontSize(7.5); - doc.setTextColor(255, 255, 255); - doc.text(statusLabel, margin + 6, y + 7.8); - - // Headline Text - doc.setFont('helvetica', 'bold'); - doc.setFontSize(9); - doc.setTextColor(secondaryColor[0], secondaryColor[1], secondaryColor[2]); - - const headline = safeText(result.headline); - const headlineLines = doc.splitTextToSize(headline, contentWidth - 38); - doc.text(headlineLines, margin + 34, y + 7.8); - - y += 26; - - // 4. Three core sections (Summary, Risks, Actions) - const drawSection = ( - titleText: string, - items: string[], - titleColor: number[], - bulletBg: number[], - isOrdered = false - ) => { - // Check if we need a new page - if (y > 240) { - doc.addPage(); - y = 20; - } - - doc.setFont('helvetica', 'bold'); - doc.setFontSize(11); - doc.setTextColor(titleColor[0], titleColor[1], titleColor[2]); - doc.text(titleText, margin, y); - - y += 5; - doc.setDrawColor(titleColor[0], titleColor[1], titleColor[2]); - doc.setLineWidth(0.3); - doc.line(margin, y, margin + 18, y); - - y += 8; - - doc.setFont('helvetica', 'normal'); - doc.setFontSize(9); - doc.setTextColor(55, 65, 81); // Slate-700 - - if (items.length === 0) { - doc.setFont('helvetica', 'italic'); - doc.text('Tidak ada data analisis', margin + 4, y); - y += 8; - return; - } - - items.forEach((rawItem, index) => { - const item = safeText(rawItem); - const textLines = doc.splitTextToSize(item, contentWidth - 12); - const textHeight = textLines.length * 4.5; - - // Check page boundary for item - if (y + textHeight > 265) { - doc.addPage(); - y = 20; - } - - // Draw Bullet point / number badge - if (isOrdered) { - doc.setFillColor(bulletBg[0], bulletBg[1], bulletBg[2]); - doc.roundedRect(margin, y - 3, 4.5, 4.5, 1, 1, 'F'); - doc.setFont('helvetica', 'bold'); - doc.setFontSize(7); - doc.setTextColor(titleColor[0], titleColor[1], titleColor[2]); - doc.text((index + 1).toString(), margin + 1.5, y + 0.2); - } else { - doc.setFillColor(bulletBg[0], bulletBg[1], bulletBg[2]); - doc.circle(margin + 2.2, y - 1, 1.0, 'F'); - } - - doc.setFont('helvetica', 'normal'); - doc.setFontSize(9); - doc.setTextColor(55, 65, 81); - doc.text(textLines, margin + 8, y); - - y += textHeight + 3.5; + try { + // Derive kandang name and current day + const rawCtxDay = + (contextData as any)?.currentDay ?? + (contextData as any)?.hari_ke ?? + (contextData as any)?.hari_terakhir ?? + cycleData?.currentDay; + const maxDays = (contextData as any)?.totalDays ?? cycleData?.totalDays; + const currentDayVal = + maxDays && rawCtxDay !== undefined && rawCtxDay > maxDays ? maxDays : rawCtxDay; + // Same identity as the printed header. + const fromDay = + timeframe === 'harian' ? currentDayVal : Math.max(1, (currentDayVal || 1) - 6); + const filename = buildPdfFilename({ + reportName: topic, + area: appCtx.selectedArea || 'Sukawarna', + kandangLabel: describeKandangScope(appCtx.kandangs, appCtx.selectedKandangId), + periodSlug: timeframe === 'harian' ? 'Harian' : 'Mingguan', + isoDates: isoDataDates( + cycleData?.startDate, + fromDay, + timeframe === 'harian' ? null : currentDayVal + ), }); - - y += 3; - }; - - // Draw Summary Section (Indigo style) - drawSection('Ringkasan Analisis', result.summary, [79, 70, 229], [224, 231, 255]); - - // Draw Risks Section (Amber style) - drawSection('Risiko Operasional', result.risks, [217, 119, 6], [253, 243, 219]); - - // Draw Actions Section (Emerald style) - drawSection('Tindakan & Rekomendasi', result.actions, [5, 150, 105], [209, 250, 229], true); - - // Footer on all pages - const totalPages = doc.getNumberOfPages(); - for (let i = 1; i <= totalPages; i++) { - doc.setPage(i); - doc.setFont('helvetica', 'normal'); - doc.setFontSize(7.5); - doc.setTextColor(148, 163, 184); // Slate-400 - - // Footer text - const footerText = `Laporan AI Insights CP 707 | Halaman ${i} dari ${totalPages}`; - doc.text(footerText, margin, 285); - - // Timestamp - doc.text(new Date().toLocaleString('id-ID'), pageWidth - margin - 35, 285); + await exportCardToPdf(pdfRef.current, filename); + } catch (error) { + console.error('Failed to generate PDF:', error); } - - // Save document - const filename = `AI_Insight_${topic}_Kandang_${kandangName.replace(/\s+/g, '_')}_Hari_${currentDayVal || '0'}.pdf`; - doc.save(filename); }; - const scfg = result ? STATUS_CFG[result.status] : null; - const StatusIcon = scfg?.Icon ?? Info; - return (
    +
    + +
    + {/* Header */}
    = ({ topic, contextData

    Analisis berbasis kecerdasan buatan Qwen 2.5

    -
    - {/* TIMEFRAME TOGGLE */} -
    - - -
    +
    + {/* TIMEFRAME TOGGLE — only where the period can actually change the + payload. The IoT panel is a live snapshot with no day-indexed + history, so both settings produced a byte-identical prompt and the + toggle offered a choice it could not honour. */} + {supportsTimeframe && ( +
    + + +
    + )} {result && (
    )} - {result && scfg && ( -
    -
    - -
    - - {scfg.label} - -

    {result.headline}

    - {loading && ( -

    Memperbarui...

    - )} -
    -
    - -
    -
    -

    - 📋 Ringkasan + {result && ( +

    + {heroSpec && ( + + )} +
    + {/* Which days these figures cover — without it a weekly average + is indistinguishable from today's reading. */} + {periodeLabel && ( +

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

    -
      - {result.summary.map((s, i) => ( -
    • - - {s} -
    • - ))} -
    -
    - -
    -

    - ⚠️ Risiko -

    - {result.risks.length > 0 ? ( -
      - {result.risks.map((r, i) => ( -
    • - - {r} -
    • - ))} -
    - ) : ( -

    Tidak ada risiko signifikan

    - )} -
    - -
    -

    - ✅ Tindakan -

    -
      - {result.actions.map((a, i) => ( -
    1. - - {i + 1} + )} +
      + {topic === 'berat_ayam' && ( + <> +
      + Target Bobot + + {renderValue(displayContext.targetWeight, 'g')} - {a} -
    2. - ))} -
    +
    +
    + Uniformity + + {renderValue(displayContext.uniformity, '%')} + +
    +
    + + Average Daily Gain (ADG) + + + {renderValue(displayContext.adg, 'g/hari')} + +
    + + )} + + {topic === 'fcr' && ( + <> +
    + + Konsumsi Pakan Kumulatif + + + {renderValue(displayContext.pakan_karung_terakhir, 'Karung')} + +
    +
    + Rata-rata Bobot + + {renderValue(displayContext.bobot_iot_kg_terakhir, 'kg')} + +
    +
    + Populasi Hidup + + {renderValue(displayContext.ayam_hidup_ekor, 'Ekor')} + +
    + + )} + + {topic === 'eef' && ( + <> +
    + FCR Aktual + + {renderValue(displayContext.fcr_terakhir)} + +
    +
    + Persentase Hidup + + {renderValue(displayContext.persen_hidup, '%')} + +
    +
    + + Rata-rata Bobot Panen + + + {renderValue(displayContext.bw_panen_gram, 'g')} + +
    + + )} + + {topic === 'iot_panel' && ( + <> +
    + + Target Suhu (Setpoint) + + + {renderValue(displayContext.setpoint_C, '°C')} + +
    +
    + Suhu Luar + + {renderValue(displayContext.suhu_luar_C, '°C')} + +
    +
    + Kelembapan + + {renderValue(displayContext.kelembapan_persen, '%')} + +
    +
    + Kecepatan Angin + + {renderValue(displayContext.kecepatan_angin_mPerDetik, 'm/s')} + +
    + + )} + + {topic === 'hitung_karung' && ( + <> +
    + Saldo Awal Pakan + + {renderValue(displayContext.saldo_awal_karung, 'Karung')} + +
    +
    + Total Pakan Masuk + {/* Shown side by side, never summed: both count the same + sacks, so adding them would double-count. */} + + {renderValue(displayContext.total_karung_masuk_iot, 'Karung')} + IOT + · + {renderValue(displayContext.total_karung_masuk_manual, 'Karung')} + Manual + +
    +
    + + Rata-rata Konsumsi Harian + + + {renderValue(displayContext.rata_karung_per_hari, 'Karung/Hari')} + +
    + + )} +
    + + {/* GRAFIK — konteks visual untuk teks di bawahnya */} + {chartSpec && ( +
    + {chartSpec.type === 'trend' ? ( + )[chartSpec.historyKey] as Array< + Record + > + } + series={chartSpec.series} + unit={chartSpec.unit} + title={chartSpec.title} + highlight={chartHighlight} + gap={chartSpec.gap ?? null} + /> + ) : ( + + )} +
    + )} + + {/* KESIMPULAN */} +
    +

    + Kesimpulan +

    +

    + {result.kesimpulan} +

    +
    + + {/* INSIGHT */} +
    +

    + AI Insight +

    +

    + {result.insight} +

    @@ -1326,5 +981,3 @@ export const PageAiInsight: React.FC = ({ topic, contextData
    ); }; - -export default PageAiInsight; diff --git a/components/shared/PdfReportHeader.tsx b/components/shared/PdfReportHeader.tsx new file mode 100644 index 0000000..28bffbf --- /dev/null +++ b/components/shared/PdfReportHeader.tsx @@ -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 = ({ + title, + area, + kandangLabel, + periodLabel, + cycleId, + dataDateLabel, +}) => ( + +); + +/** + * 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(`.${PDF_HEADER_CLASS}`).forEach((node) => { + node.style.display = 'block'; + }); +}; + +export default PdfReportHeader; diff --git a/components/shared/__tests__/PdfReportHeader.test.ts b/components/shared/__tests__/PdfReportHeader.test.ts new file mode 100644 index 0000000..44a4918 --- /dev/null +++ b/components/shared/__tests__/PdfReportHeader.test.ts @@ -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' }) + ); + }); +}); diff --git a/components/vet/VetVisitFormModal.tsx b/components/vet/VetVisitFormModal.tsx index 5998fe2..a8ef478 100644 --- a/components/vet/VetVisitFormModal.tsx +++ b/components/vet/VetVisitFormModal.tsx @@ -28,7 +28,7 @@ const VetVisitFormModal: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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); diff --git a/components/weight/DailyWeightProgressionChart.tsx b/components/weight/DailyWeightProgressionChart.tsx index 95f69eb..7fdcec8 100644 --- a/components/weight/DailyWeightProgressionChart.tsx +++ b/components/weight/DailyWeightProgressionChart.tsx @@ -34,12 +34,12 @@ const DailyWeightProgressionChart: React.FC = const chartData = useMemo(() => { 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 = 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 = 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; diff --git a/components/weight/DeviceWeightComparisonChart.tsx b/components/weight/DeviceWeightComparisonChart.tsx index 61f6c89..6505863 100644 --- a/components/weight/DeviceWeightComparisonChart.tsx +++ b/components/weight/DeviceWeightComparisonChart.tsx @@ -61,6 +61,7 @@ const DeviceWeightComparisonChart: React.FC = const CustomBarLabel = (props: any) => { const { x, y, width, height, index } = props; const device = data[index]; + if (!device) return null; return ( = ({ 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) => { @@ -126,7 +126,7 @@ const WeightDistributionChart: React.FC = ({ formatter={(value: number) => [`${formatNumber(value)} ekor`, 'Jumlah']} /> - {data.map((entry, index) => ( + {data.map((_entry, index) => ( " 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) => void; onUpdateCamera: (camera: Camera) => void; onDeleteCamera: (cameraId: string) => void; - onAddIotDevice: ( - deviceData: Omit - ) => void; + onAddIotDevice: (deviceData: Omit) => void; onUpdateIotDevice: (device: IotDevice) => void; onDeleteIotDevice: (deviceId: string) => void; onAddUser: (userData: Omit) => void; @@ -220,11 +229,13 @@ const AppContext = createContext(undefined); interface AppContextProviderProps { children: ReactNode; selectedKandangId: number | null; + selectedArea: string; } export const AppContextProvider: React.FC = ({ children, selectedKandangId, + selectedArea, }) => { // --- Custom store hooks --- const accessManagementStore = useAccessManagementStore(); @@ -294,7 +305,7 @@ export const AppContextProvider: React.FC = ({ 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 = ({ // 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 = ({ 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 = ({ 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 = ({ 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 = ({ : 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 = ({ 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 = ({ ...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 = ({ dailyDataLoading: staticDataStore.dailyDataLoading, fetchScaleData, refreshScaleData, + kandangs, + selectedArea, + selectedKandangId, // API mode - always LIVE since we're using real APIs apiMode: 'LIVE', // D-1 filter state diff --git a/context/stores/useStaticDataStore.ts b/context/stores/useStaticDataStore.ts index 8732e7a..383a83f 100644 --- a/context/stores/useStaticDataStore.ts +++ b/context/stores/useStaticDataStore.ts @@ -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', diff --git a/hooks/useMarkdown.ts b/hooks/useMarkdown.ts index 8113acf..db3a032 100644 --- a/hooks/useMarkdown.ts +++ b/hooks/useMarkdown.ts @@ -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 `${headingContent}`; diff --git a/mockData/chickenCountingData.ts b/mockData/chickenCountingData.ts index 4a5e1a3..c562d42 100644 --- a/mockData/chickenCountingData.ts +++ b/mockData/chickenCountingData.ts @@ -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; }; diff --git a/mockData/feedSchedulingData.ts b/mockData/feedSchedulingData.ts index 5c01b9c..140705e 100644 --- a/mockData/feedSchedulingData.ts +++ b/mockData/feedSchedulingData.ts @@ -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({ diff --git a/mockData/vetVisitData.ts b/mockData/vetVisitData.ts index a0719d3..881fb24 100644 --- a/mockData/vetVisitData.ts +++ b/mockData/vetVisitData.ts @@ -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: diff --git a/package-lock.json b/package-lock.json index a88be9c..af41732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "v19-cp-dashboard:-solusi-ai-peternakan-ayam", "version": "0.0.0", "dependencies": { + "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "lucide-react": "^0.468.0", "marked": "^13.0.2", @@ -99,11 +100,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -112,7 +115,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -120,19 +125,21 @@ } }, "node_modules/@babel/core": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.28.3", - "@babel/helpers": "^7.28.4", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -149,12 +156,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.5", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -164,12 +173,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -179,7 +190,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -187,25 +200,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -223,7 +240,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -231,7 +250,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -239,7 +260,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -247,23 +270,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.4", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.5", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -310,29 +337,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.5", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -340,12 +371,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -997,17 +1030,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@eslint/eslintrc/node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1075,17 +1097,6 @@ "node": ">=10.10.0" } }, - "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -1161,6 +1172,26 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1249,9 +1280,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.3.tgz", - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", "cpu": [ "arm" ], @@ -1263,9 +1294,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.3.tgz", - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", "cpu": [ "arm64" ], @@ -1277,7 +1308,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.53.3", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", "cpu": [ "arm64" ], @@ -1289,9 +1322,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.3.tgz", - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", "cpu": [ "x64" ], @@ -1303,9 +1336,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.3.tgz", - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", "cpu": [ "arm64" ], @@ -1317,9 +1350,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.3.tgz", - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", "cpu": [ "x64" ], @@ -1331,13 +1364,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.3.tgz", - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1345,13 +1381,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.3.tgz", - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", "cpu": [ "arm" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1359,13 +1398,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.3.tgz", - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1373,13 +1415,16 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.3.tgz", - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1387,13 +1432,33 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.3.tgz", - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", "cpu": [ "loong64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1401,13 +1466,33 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.3.tgz", - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1415,13 +1500,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.3.tgz", - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1429,13 +1517,16 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.3.tgz", - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1443,13 +1534,16 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.3.tgz", - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1457,13 +1551,16 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz", - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1471,9 +1568,26 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.3.tgz", - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", "cpu": [ "x64" ], @@ -1481,13 +1595,13 @@ "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.3.tgz", - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", "cpu": [ "arm64" ], @@ -1499,9 +1613,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.3.tgz", - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", "cpu": [ "arm64" ], @@ -1513,9 +1627,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.3.tgz", - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", "cpu": [ "ia32" ], @@ -1527,9 +1641,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.3.tgz", - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", "cpu": [ "x64" ], @@ -1541,9 +1655,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.53.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.3.tgz", - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", "cpu": [ "x64" ], @@ -1763,7 +1877,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.8", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -2008,16 +2124,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { @@ -2544,17 +2660,21 @@ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", "license": "MIT", - "optional": true, "engines": { "node": ">= 0.6.0" } }, "node_modules/baseline-browser-mapping": { - "version": "2.8.30", + "version": "2.11.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz", + "integrity": "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bidi-js": { @@ -2567,8 +2687,21 @@ "require-from-string": "^2.0.2" } }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { - "version": "4.28.0", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "dev": true, "funding": [ { @@ -2586,11 +2719,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.25", - "caniuse-lite": "^1.0.30001754", - "electron-to-chromium": "^1.5.249", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.1.4" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -2660,7 +2793,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001756", + "version": "1.0.30001809", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", "dev": true, "funding": [ { @@ -2873,7 +3008,6 @@ "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", "license": "MIT", - "optional": true, "dependencies": { "utrie": "^1.0.2" } @@ -3175,9 +3309,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.12", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz", - "integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==", + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", "license": "(MPL-2.0 OR Apache-2.0)", "optional": true, "optionalDependencies": { @@ -3200,7 +3334,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.259", + "version": "1.5.403", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.403.tgz", + "integrity": "sha512-MQsYmdaLzvaCX5j+ZZBr5Fm6uCCnPQcRtlvmvRlWqrXy+BH2O4ffXIAScF+JQznQWB9brWp4lSD9Z4yNmaf2BA==", "dev": true, "license": "ISC" }, @@ -3464,6 +3600,8 @@ }, "node_modules/escalade": { "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { @@ -3640,17 +3778,6 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint-plugin-react/node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -3704,17 +3831,6 @@ "node": ">=8" } }, - "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/eslint/node_modules/doctrine": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", @@ -3968,17 +4084,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/flat-cache/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/flat-cache/node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -4400,7 +4505,6 @@ "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", "license": "MIT", - "optional": true, "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" @@ -4962,10 +5066,20 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5027,6 +5141,8 @@ }, "node_modules/jsesc": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", "bin": { @@ -5344,6 +5460,8 @@ }, "node_modules/lru-cache": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, "license": "ISC", "dependencies": { @@ -5446,7 +5564,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -5489,9 +5609,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", + "version": "2.0.53", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", @@ -5803,7 +5928,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", + "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": { @@ -5824,7 +5951,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -5842,7 +5971,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -6247,11 +6376,13 @@ } }, "node_modules/rollup": { - "version": "4.53.3", + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -6261,28 +6392,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.53.3", - "@rollup/rollup-android-arm64": "4.53.3", - "@rollup/rollup-darwin-arm64": "4.53.3", - "@rollup/rollup-darwin-x64": "4.53.3", - "@rollup/rollup-freebsd-arm64": "4.53.3", - "@rollup/rollup-freebsd-x64": "4.53.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", - "@rollup/rollup-linux-arm-musleabihf": "4.53.3", - "@rollup/rollup-linux-arm64-gnu": "4.53.3", - "@rollup/rollup-linux-arm64-musl": "4.53.3", - "@rollup/rollup-linux-loong64-gnu": "4.53.3", - "@rollup/rollup-linux-ppc64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-gnu": "4.53.3", - "@rollup/rollup-linux-riscv64-musl": "4.53.3", - "@rollup/rollup-linux-s390x-gnu": "4.53.3", - "@rollup/rollup-linux-x64-gnu": "4.53.3", - "@rollup/rollup-linux-x64-musl": "4.53.3", - "@rollup/rollup-openharmony-arm64": "4.53.3", - "@rollup/rollup-win32-arm64-msvc": "4.53.3", - "@rollup/rollup-win32-ia32-msvc": "4.53.3", - "@rollup/rollup-win32-x64-gnu": "4.53.3", - "@rollup/rollup-win32-x64-msvc": "4.53.3", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" } }, @@ -6858,7 +6993,6 @@ "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", "license": "MIT", - "optional": true, "dependencies": { "utrie": "^1.0.2" } @@ -7121,9 +7255,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { @@ -7136,7 +7270,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.4", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", "dev": true, "funding": [ { @@ -7186,7 +7322,6 @@ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", "license": "MIT", - "optional": true, "dependencies": { "base64-arraybuffer": "^1.0.2" } @@ -7212,7 +7347,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", "dev": true, "license": "MIT", "dependencies": { @@ -7578,6 +7715,8 @@ }, "node_modules/yallist": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" }, diff --git a/package.json b/package.json index 0a01aa0..3f1cb59 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ ] }, "dependencies": { + "html2canvas": "^1.4.1", "jspdf": "^4.2.1", "lucide-react": "^0.468.0", "marked": "^13.0.2", diff --git a/services/aiInsightService.ts b/services/aiInsightService.ts index 5e49724..1e78560 100644 --- a/services/aiInsightService.ts +++ b/services/aiInsightService.ts @@ -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 { 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 diff --git a/services/apiService.ts b/services/apiService.ts index b0d43cc..9245649 100644 --- a/services/apiService.ts +++ b/services/apiService.ts @@ -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) => ({ diff --git a/services/performanceService.ts b/services/performanceService.ts index 4cf0bc8..78f4a0b 100644 --- a/services/performanceService.ts +++ b/services/performanceService.ts @@ -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 }); diff --git a/start_all.sh b/start_all.sh index 9107e4a..ceb8f66 100644 --- a/start_all.sh +++ b/start_all.sh @@ -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//..., 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'" diff --git a/tsconfig.json b/tsconfig.json index 86ba320..04eef21 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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", diff --git a/utils/__tests__/feedSackClassification.test.ts b/utils/__tests__/feedSackClassification.test.ts new file mode 100644 index 0000000..5340964 --- /dev/null +++ b/utils/__tests__/feedSackClassification.test.ts @@ -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 + }); +}); diff --git a/utils/__tests__/insightScopeAggregate.test.ts b/utils/__tests__/insightScopeAggregate.test.ts new file mode 100644 index 0000000..5c01c5b --- /dev/null +++ b/utils/__tests__/insightScopeAggregate.test.ts @@ -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); + }); +}); diff --git a/utils/chartDataUtils.ts b/utils/chartDataUtils.ts index aee4683..3f8091d 100644 --- a/utils/chartDataUtils.ts +++ b/utils/chartDataUtils.ts @@ -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++; + } } } } diff --git a/utils/cp707EnvStandards.ts b/utils/cp707EnvStandards.ts new file mode 100644 index 0000000..7816e17 --- /dev/null +++ b/utils/cp707EnvStandards.ts @@ -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; +}; diff --git a/utils/insightHelpers.ts b/utils/insightHelpers.ts index 86cc243..216fee4 100644 --- a/utils/insightHelpers.ts +++ b/utils/insightHelpers.ts @@ -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(', '); } diff --git a/utils/insightParse.ts b/utils/insightParse.ts new file mode 100644 index 0000000..4c3d45f --- /dev/null +++ b/utils/insightParse.ts @@ -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 => + 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, 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(/[\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(); + + 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.', + }; +}; diff --git a/utils/insightScope.ts b/utils/insightScope.ts new file mode 100644 index 0000000..0cc9529 --- /dev/null +++ b/utils/insightScope.ts @@ -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 & { + 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 => + 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 = (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 = {}; + 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, + timeframe: InsightTimeframe, + spec: ScalarScopeSpec +): Record => { + const history = scopedContext[spec.historyKey]; + if (!Array.isArray(history)) return scopedContext; + const rows = history as DayRow[]; + + const out: Record = { ...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'; +}; diff --git a/utils/markdownUtils.ts b/utils/markdownUtils.ts index bea3344..9878ab6 100644 --- a/utils/markdownUtils.ts +++ b/utils/markdownUtils.ts @@ -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; } diff --git a/utils/pdfExport.ts b/utils/pdfExport.ts new file mode 100644 index 0000000..948b2c2 --- /dev/null +++ b/utils/pdfExport.ts @@ -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 => { + 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('[data-pdf-stack]').forEach((node) => { + node.style.display = 'block'; + }); + + const top = clonedElement.getBoundingClientRect().top; + clonedElement.querySelectorAll('[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); +}; diff --git a/vite.config.ts b/vite.config.ts index 29f7d24..f6a037a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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'],