From 4726437f386036f773d1a3349360cdb53113aa6e Mon Sep 17 00:00:00 2001 From: Alberto-Audrix Date: Wed, 29 Jul 2026 15:02:58 +0700 Subject: [PATCH] fix bug fitur kalender berat dan cap panel iot --- components/IotPanelPage.tsx | 20 +- components/iotPanel/iotApi.ts | 160 ++++++++++++++-- components/iotPanel/iotSensorHistoryApi.ts | 208 ++++++++++++++++----- context/AppContext.tsx | 15 +- utils/dateFilter.ts | 36 ++++ 5 files changed, 361 insertions(+), 78 deletions(-) diff --git a/components/IotPanelPage.tsx b/components/IotPanelPage.tsx index 7277017..5d097ae 100644 --- a/components/IotPanelPage.tsx +++ b/components/IotPanelPage.tsx @@ -1,5 +1,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAppContext } from '../context/AppContext.tsx'; +import { getEstimasiPanenEndMs } from '../utils/dateFilter.ts'; + import { baseCard } from './iotPanel/constants.tsx'; import { computeDashboard } from './iotPanel/computeDashboard.ts'; @@ -25,6 +28,13 @@ import type { } from './iotPanel/types.ts'; const IotPanelPage: React.FC = () => { + const { cycleData } = useAppContext(); + + const iotMaxAtMs = useMemo( + () => getEstimasiPanenEndMs(cycleData), + [cycleData.endDate, cycleData.startDate, cycleData.totalDays] + ); + const [data, setData] = useState(null); const [error, setError] = useState(null); @@ -53,7 +63,7 @@ const IotPanelPage: React.FC = () => { setIsRefreshing(true); try { - const { raw, data: next } = await loadDashboardData(); + const { raw, data: next } = await loadDashboardData({ maxAtMs: iotMaxAtMs }); setLastFleetRaw(raw); @@ -71,7 +81,7 @@ const IotPanelPage: React.FC = () => { } finally { setIsRefreshing(false); } - }, []); + }, [iotMaxAtMs]); useEffect(() => { void refreshDashboardData(); @@ -123,9 +133,9 @@ const IotPanelPage: React.FC = () => { const flockId = row?.flock_id || row?.flock || null; if (isDeviceHistoryMetric(metric)) { - setDeviceHistoryPoints(await loadDeviceHistory24h(metric, flockId)); + setDeviceHistoryPoints(await loadDeviceHistory24h(metric, flockId, iotMaxAtMs)); } else { - setHistoryPoints(await loadIotSensorHistory24h(metric, flockId)); + setHistoryPoints(await loadIotSensorHistory24h(metric, flockId, iotMaxAtMs)); } } catch (e) { setHistoryError(e instanceof Error ? e.message : 'Gagal memuat riwayat'); @@ -137,7 +147,7 @@ const IotPanelPage: React.FC = () => { setHistoryLoading(false); } }, - [lastFleetRaw] + [lastFleetRaw, iotMaxAtMs] ); const closeSensorHistory = useCallback(() => { diff --git a/components/iotPanel/iotApi.ts b/components/iotPanel/iotApi.ts index 9d4384b..df83c98 100644 --- a/components/iotPanel/iotApi.ts +++ b/components/iotPanel/iotApi.ts @@ -7,6 +7,117 @@ import { import { normalizeTargetCelsius, resolveFan1DisplayTargetCelsius } from './fan1SetpointResolve.ts'; import type { IotFlockResultRow, IotFlocksRaw, PayloadData } from './types.ts'; +type FlocksPageJson = IotFlocksRaw & { next?: string | null }; + +function rowUpdatedAtMs(row: IotFlockResultRow | null | undefined): number | null { + const rawAt = row?.payload?.data?.updatedAt; + if (!rawAt) return null; + const atMs = new Date(rawAt).getTime(); + return Number.isFinite(atMs) ? atMs : null; +} + +function compareRowIdAsc(a: IotFlockResultRow, b: IotFlockResultRow): number { + const aId = Number(a?.id); + const bId = Number(b?.id); + if (!Number.isFinite(aId) && !Number.isFinite(bId)) return 0; + if (!Number.isFinite(aId)) return -1; + if (!Number.isFinite(bId)) return 1; + return aId - bId; +} + +function pickLatestRowById(rows: IotFlockResultRow[]): IotFlockResultRow | null { + if (rows.length === 0) return null; + const sortedById = [...rows].sort(compareRowIdAsc); + return sortedById[sortedById.length - 1] ?? null; +} + +function pickLatestRowAtOrBefore( + rows: IotFlockResultRow[], + maxAtMs: number +): IotFlockResultRow | null { + const eligible = rows.filter((row) => { + const atMs = rowUpdatedAtMs(row); + return atMs !== null && atMs <= maxAtMs; + }); + return pickLatestRowById(eligible); +} + +async function fetchFlocksPage(page: number): Promise { + const response = await fetch(`${IOT_FLOCKS_API_URL}?page=${page}&_=${Date.now()}`, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error('Gagal memuat data IoT flocks API'); + return (await response.json()) as FlocksPageJson; +} + +/** + * Newest flock snapshot whose `updatedAt` is on/before `maxAtMs`. + * Pages are newest-first; binary-searches when the first page is entirely after the cap. + */ +async function findLatestFlockRowAtOrBefore(maxAtMs: number): Promise<{ + raw: IotFlocksRaw; + row: IotFlockResultRow; +}> { + const firstPage = await fetchFlocksPage(1); + const firstRows = Array.isArray(firstPage.results) ? firstPage.results : []; + if (firstRows.length === 0) { + throw new Error('Data flock tidak ditemukan dari API'); + } + + const fromFirstPage = pickLatestRowAtOrBefore(firstRows, maxAtMs); + if (fromFirstPage) { + return { + raw: { + count: Number(fromFirstPage.id), + results: firstRows, + }, + row: fromFirstPage, + }; + } + + const totalCount = Number(firstPage.count); + const pageSize = firstRows.length; + const totalPages = + Number.isFinite(totalCount) && totalCount > 0 && pageSize > 0 + ? Math.ceil(totalCount / pageSize) + : 1; + + let lo = 2; + let hi = Math.max(1, totalPages); + let bestRow: IotFlockResultRow | null = null; + let bestPageRows: IotFlockResultRow[] = []; + + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const page = await fetchFlocksPage(mid); + const rows = Array.isArray(page.results) ? page.results : []; + const eligible = pickLatestRowAtOrBefore(rows, maxAtMs); + + if (eligible) { + bestRow = eligible; + bestPageRows = rows; + // Look for a newer eligible row on an earlier (newer) page. + hi = mid - 1; + } else { + // Entire page is after the cap — go older. + lo = mid + 1; + } + } + + if (!bestRow) { + throw new Error('Tidak ada data IoT pada/ sebelum tanggal estimasi panen'); + } + + return { + raw: { + count: Number(bestRow.id), + results: bestPageRows.length > 0 ? bestPageRows : [bestRow], + }, + row: bestRow, + }; +} + export function latestResultRow(rawData: IotFlocksRaw | null): IotFlockResultRow | null { const list = Array.isArray(rawData?.results) ? rawData.results : []; if (list.length === 0) return null; @@ -17,16 +128,7 @@ export function latestResultRow(rawData: IotFlocksRaw | null): IotFlockResultRow if (byCountId) return byCountId; } - const sortedById = [...list].sort((a, b) => { - const aId = Number(a?.id); - const bId = Number(b?.id); - if (!Number.isFinite(aId) && !Number.isFinite(bId)) return 0; - if (!Number.isFinite(aId)) return -1; - if (!Number.isFinite(bId)) return 1; - return aId - bId; - }); - - return sortedById[sortedById.length - 1] ?? null; + return pickLatestRowById(list); } export function apiResponseCounts(rawData: IotFlocksRaw | null): number { @@ -285,14 +387,36 @@ async function loadTabelAlarmByFlockId(flockId: string): Promise { - const response = await fetch(`${IOT_FLOCKS_API_URL}?_=${Date.now()}`, { - cache: 'no-store', - headers: { Accept: 'application/json' }, - }); - if (!response.ok) throw new Error('Gagal memuat data IoT flocks API'); - const raw = (await response.json()) as IotFlocksRaw; - const latestRow = latestResultRow(raw); +export type LoadDashboardDataOptions = { + /** Inclusive upper bound (ms). When set and live data is newer, freeze at last reading on/before this time. */ + maxAtMs?: number | null; +}; + +export async function loadDashboardData( + options?: LoadDashboardDataOptions +): Promise<{ raw: IotFlocksRaw; data: PayloadData }> { + const maxAtMs = + options?.maxAtMs !== null && options?.maxAtMs !== undefined && Number.isFinite(options.maxAtMs) + ? Number(options.maxAtMs) + : null; + + let raw: IotFlocksRaw; + let latestRow: IotFlockResultRow | null; + + if (maxAtMs !== null && Date.now() > maxAtMs) { + const capped = await findLatestFlockRowAtOrBefore(maxAtMs); + raw = capped.raw; + latestRow = capped.row; + } else { + const response = await fetch(`${IOT_FLOCKS_API_URL}?_=${Date.now()}`, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error('Gagal memuat data IoT flocks API'); + raw = (await response.json()) as IotFlocksRaw; + latestRow = latestResultRow(raw); + } + const result = latestRow?.payload?.data; const latestId = latestRow?.id; diff --git a/components/iotPanel/iotSensorHistoryApi.ts b/components/iotPanel/iotSensorHistoryApi.ts index 0ba5196..c1a1306 100644 --- a/components/iotPanel/iotSensorHistoryApi.ts +++ b/components/iotPanel/iotSensorHistoryApi.ts @@ -119,34 +119,129 @@ function mergeDeviceHistoryEvents(events: TimedEvent[]): IotDeviceHistoryPoint[] return merged; } -async function loadFlockSnapshots24h(): Promise { - const cutoffMs = Date.now() - 24 * 60 * 60 * 1000; +function historyWindowBounds(maxAtMs?: number | null): { upperMs: number; cutoffMs: number } { + const now = Date.now(); + const upperMs = + maxAtMs !== null && maxAtMs !== undefined && Number.isFinite(maxAtMs) + ? Math.min(now, Number(maxAtMs)) + : now; + return { upperMs, cutoffMs: upperMs - 24 * 60 * 60 * 1000 }; +} + +function rowUpdatedAtMs(row: IotFlockResultRow | null | undefined): number | null { + const rawAt = row?.payload?.data?.updatedAt; + if (!rawAt) return null; + const atMs = new Date(rawAt).getTime(); + return Number.isFinite(atMs) ? atMs : null; +} + +async function fetchFlocksPage(page: number): Promise { + const response = await fetch(`${IOT_FLOCKS_API_URL}?page=${page}&_=${Date.now()}`, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`Gagal memuat riwayat (${response.status})`); + return (await response.json()) as IotFlocksRaw & { next?: string | null }; +} + +/** + * When the history window ends in the past (e.g. Estimasi Panen), jump to the newest + * page that still has rows on/before upperMs so we don't walk dozens of post-harvest pages. + */ +async function resolveFlocksHistoryStart( + upperMs: number +): Promise<{ startUrl: string; firstPage: (IotFlocksRaw & { next?: string | null }) | null }> { + if (Date.now() <= upperMs) { + return { startUrl: `${IOT_FLOCKS_API_URL}?_=${Date.now()}`, firstPage: null }; + } + + const firstPage = await fetchFlocksPage(1); + const firstRows = Array.isArray(firstPage.results) ? firstPage.results : []; + const hasEligibleOnFirst = firstRows.some((row) => { + const atMs = rowUpdatedAtMs(row); + return atMs !== null && atMs <= upperMs; + }); + if (hasEligibleOnFirst) { + return { startUrl: `${IOT_FLOCKS_API_URL}?page=1&_=${Date.now()}`, firstPage }; + } + + const totalCount = Number(firstPage.count); + const pageSize = firstRows.length; + const totalPages = + Number.isFinite(totalCount) && totalCount > 0 && pageSize > 0 + ? Math.ceil(totalCount / pageSize) + : 1; + + let lo = 2; + let hi = Math.max(1, totalPages); + let bestPage = totalPages; + let bestJson: (IotFlocksRaw & { next?: string | null }) | null = null; + + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const pageJson = await fetchFlocksPage(mid); + const rows = Array.isArray(pageJson.results) ? pageJson.results : []; + const hasEligible = rows.some((row) => { + const atMs = rowUpdatedAtMs(row); + return atMs !== null && atMs <= upperMs; + }); + + if (hasEligible) { + bestPage = mid; + bestJson = pageJson; + hi = mid - 1; + } else { + lo = mid + 1; + } + } + + return { + startUrl: `${IOT_FLOCKS_API_URL}?page=${bestPage}&_=${Date.now()}`, + firstPage: bestJson, + }; +} + +async function loadFlockSnapshots24h(maxAtMs?: number | null): Promise { + const { upperMs, cutoffMs } = historyWindowBounds(maxAtMs); const snapshots: FlockSnapshot[] = []; - let nextUrl: string | null = `${IOT_FLOCKS_API_URL}?_=${Date.now()}`; + const { startUrl, firstPage } = await resolveFlocksHistoryStart(upperMs); + let nextUrl: string | null = startUrl; + let pendingFirstPage = firstPage; let safetyPageCount = 0; - const maxPages = 20; + const maxPages = 30; while (nextUrl && safetyPageCount < maxPages) { - const response = await fetch(nextUrl, { - cache: 'no-store', - headers: { Accept: 'application/json' }, - }); - if (!response.ok) throw new Error(`Gagal memuat riwayat kipas (${response.status})`); + let json: IotFlocksRaw & { next?: string | null }; + if (pendingFirstPage) { + json = pendingFirstPage; + pendingFirstPage = null; + } else { + const response = await fetch(nextUrl, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`Gagal memuat riwayat kipas (${response.status})`); + json = (await response.json()) as IotFlocksRaw & { next?: string | null }; + } - const json = (await response.json()) as IotFlocksRaw & { next?: string | null }; const rows = Array.isArray(json.results) ? json.results : []; if (rows.length === 0) break; - let pageHasNewerThanCutoff = false; + let pageHasDataInOrAfterWindow = false; for (const row of rows) { const payloadData = (row as IotFlockResultRow)?.payload?.data; if (!payloadData?.updatedAt) continue; const atMs = new Date(payloadData.updatedAt).getTime(); - if (!Number.isFinite(atMs) || atMs < cutoffMs) continue; + if (!Number.isFinite(atMs)) continue; + if (atMs > upperMs) { + pageHasDataInOrAfterWindow = true; + continue; + } + if (atMs < cutoffMs) continue; - pageHasNewerThanCutoff = true; + pageHasDataInOrAfterWindow = true; const normalized = preprocessFlockPayloadForPanel(payloadData); const deviceStatus: Record = {}; for (let fanNumber = 1; fanNumber <= 11; fanNumber += 1) { @@ -165,7 +260,7 @@ async function loadFlockSnapshots24h(): Promise { }); } - if (!pageHasNewerThanCutoff) break; + if (!pageHasDataInOrAfterWindow) break; const nextFromApi = json.next ?? null; nextUrl = nextFromApi ? nextFromApi.replace('http://', 'https://') : null; @@ -187,12 +282,13 @@ function statusEventsFromSnapshots(snapshots: FlockSnapshot[], deviceKey: string async function loadFanSetpointLogEvents24h( flockId: string, fanNumber: number, - flockSnapshots: FlockSnapshot[] + flockSnapshots: FlockSnapshot[], + maxAtMs?: number | null ): Promise { const safeId = String(flockId || '').trim(); if (!safeId) return []; - const cutoffMs = Date.now() - 24 * 60 * 60 * 1000; + const { upperMs, cutoffMs } = historyWindowBounds(maxAtMs); const base = `${IOT_DASHBOARD_IOT_API_BASE}/flock/${encodeURIComponent(safeId)}/setpoint/logs/`; const events: TimedEvent[] = []; @@ -216,7 +312,7 @@ async function loadFanSetpointLogEvents24h( const rawAt = row.fetched_at || row.updated_at; if (!rawAt) continue; const atMs = new Date(rawAt).getTime(); - if (!Number.isFinite(atMs) || atMs < cutoffMs) continue; + if (!Number.isFinite(atMs) || atMs < cutoffMs || atMs > upperMs) continue; const inner = row.payload?.data; const nearest = nearestFlockSnapshot(flockSnapshots, atMs); @@ -233,12 +329,13 @@ async function loadFanSetpointLogEvents24h( async function loadHvacSetpointLogEvents24h( flockId: string, deviceKey: HvacDeviceKey, - flockSnapshots: FlockSnapshot[] + flockSnapshots: FlockSnapshot[], + maxAtMs?: number | null ): Promise { const safeId = String(flockId || '').trim(); if (!safeId) return []; - const cutoffMs = Date.now() - 24 * 60 * 60 * 1000; + const { upperMs, cutoffMs } = historyWindowBounds(maxAtMs); const base = `${IOT_DASHBOARD_IOT_API_BASE}/flock/${encodeURIComponent(safeId)}/setpoint/logs/`; const events: TimedEvent[] = []; @@ -262,7 +359,7 @@ async function loadHvacSetpointLogEvents24h( const rawAt = row.fetched_at || row.updated_at; if (!rawAt) continue; const atMs = new Date(rawAt).getTime(); - if (!Number.isFinite(atMs) || atMs < cutoffMs) continue; + if (!Number.isFinite(atMs) || atMs < cutoffMs || atMs > upperMs) continue; const inner = row.payload?.data; const nearest = nearestFlockSnapshot(flockSnapshots, atMs); @@ -281,19 +378,21 @@ async function loadHvacSetpointLogEvents24h( /** * 24h device chart: setpoint (°C, from setpoint logs + panel logic) and status (flocks snapshots). + * Optional `maxAtMs` caps the window end (e.g. Estimasi Panen). */ export async function loadDeviceHistory24h( metric: IotDeviceHistoryMetric, - flockId: string | null | undefined + flockId: string | null | undefined, + maxAtMs?: number | null ): Promise { const deviceKey = deviceKeyFromHistoryMetric(metric); - const flockSnapshots = await loadFlockSnapshots24h(); + const flockSnapshots = await loadFlockSnapshots24h(maxAtMs); if (isFanHistoryMetric(metric)) { const fanNumber = fanNumberFromMetric(metric); const [statusEvents, setpointEvents] = await Promise.all([ Promise.resolve(statusEventsFromSnapshots(flockSnapshots, deviceKey)), - loadFanSetpointLogEvents24h(String(flockId || '').trim(), fanNumber, flockSnapshots), + loadFanSetpointLogEvents24h(String(flockId || '').trim(), fanNumber, flockSnapshots, maxAtMs), ]); return mergeDeviceHistoryEvents([...statusEvents, ...setpointEvents]); } @@ -304,7 +403,8 @@ export async function loadDeviceHistory24h( loadHvacSetpointLogEvents24h( String(flockId || '').trim(), deviceKey as HvacDeviceKey, - flockSnapshots + flockSnapshots, + maxAtMs ), ]); return mergeDeviceHistoryEvents([...statusEvents, ...setpointEvents]); @@ -318,17 +418,19 @@ export async function loadDeviceHistory24h( */ export async function loadFanHistory24h( fanNumber: number, - flockId: string | null | undefined + flockId: string | null | undefined, + maxAtMs?: number | null ): Promise { if (!Number.isInteger(fanNumber) || fanNumber < 1 || fanNumber > 11) return []; - return loadDeviceHistory24h(`fan${fanNumber}` as IotDeviceHistoryMetric, flockId); + return loadDeviceHistory24h(`fan${fanNumber}` as IotDeviceHistoryMetric, flockId, maxAtMs); } /** @deprecated Use {@link loadDeviceHistory24h} */ export async function loadFan1History24h( - flockId: string | null | undefined + flockId: string | null | undefined, + maxAtMs?: number | null ): Promise { - return loadDeviceHistory24h('fan1', flockId); + return loadDeviceHistory24h('fan1', flockId, maxAtMs); } type SetpointLogsPageJson = { @@ -342,12 +444,13 @@ const MAX_SETPOINT_LOG_PAGES = 50; * 24h series from `GET .../flock/{id}/setpoint/logs/?page=N` (current setpoint per log row). */ export async function loadSetpointHistory24hFromLogs( - flockId: string + flockId: string, + maxAtMs?: number | null ): Promise { const safeId = String(flockId || '').trim(); if (!safeId) return []; - const cutoffMs = Date.now() - 24 * 60 * 60 * 1000; + const { upperMs, cutoffMs } = historyWindowBounds(maxAtMs); const base = `${IOT_DASHBOARD_IOT_API_BASE}/flock/${encodeURIComponent(safeId)}/setpoint/logs/`; const points: IotSensorHistoryPoint[] = []; @@ -371,7 +474,7 @@ export async function loadSetpointHistory24hFromLogs( const rawAt = row.fetched_at || row.updated_at; if (!rawAt) continue; const atMs = new Date(rawAt).getTime(); - if (!Number.isFinite(atMs) || atMs < cutoffMs) continue; + if (!Number.isFinite(atMs) || atMs < cutoffMs || atMs > upperMs) continue; const raw = row.payload?.data?.setpoint_sekarang?.current_setpoint; const n = Number(raw); @@ -387,35 +490,44 @@ export async function loadSetpointHistory24hFromLogs( /** * Loads 24h history from `/api/iot/flocks/` (same source as panel), except **setPoint** * which uses per-flock setpoint logs on dashboard CPSP. + * Optional `maxAtMs` caps the window end (e.g. Estimasi Panen). */ export async function loadIotSensorHistory24h( metric: FlockHistoryMetric, - flockId?: string | null + flockId?: string | null, + maxAtMs?: number | null ): Promise { if (metric === 'setPoint') { - return loadSetpointHistory24hFromLogs(String(flockId || '').trim()); + return loadSetpointHistory24hFromLogs(String(flockId || '').trim(), maxAtMs); } const extract = EXTRACTORS[metric]; - const now = Date.now(); - const cutoffMs = now - 24 * 60 * 60 * 1000; + const { upperMs, cutoffMs } = historyWindowBounds(maxAtMs); const points: IotSensorHistoryPoint[] = []; - let nextUrl: string | null = `${IOT_FLOCKS_API_URL}?_=${Date.now()}`; + const { startUrl, firstPage } = await resolveFlocksHistoryStart(upperMs); + let nextUrl: string | null = startUrl; + let pendingFirstPage = firstPage; let safetyPageCount = 0; - const maxPages = 20; + const maxPages = 30; while (nextUrl && safetyPageCount < maxPages) { - const response = await fetch(nextUrl, { - cache: 'no-store', - headers: { Accept: 'application/json' }, - }); - if (!response.ok) throw new Error(`Gagal memuat riwayat (${response.status})`); + let json: IotFlocksRaw & { next?: string | null }; + if (pendingFirstPage) { + json = pendingFirstPage; + pendingFirstPage = null; + } else { + const response = await fetch(nextUrl, { + cache: 'no-store', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) throw new Error(`Gagal memuat riwayat (${response.status})`); + json = (await response.json()) as IotFlocksRaw & { next?: string | null }; + } - const json = (await response.json()) as IotFlocksRaw & { next?: string | null }; const rows = Array.isArray(json.results) ? json.results : []; if (rows.length === 0) break; - let pageHasNewerThanCutoff = false; + let pageHasDataInOrAfterWindow = false; for (const row of rows) { const payloadData = (row as IotFlockResultRow)?.payload?.data; @@ -426,16 +538,20 @@ export async function loadIotSensorHistory24h( const atMs = new Date(rawAt).getTime(); if (!Number.isFinite(atMs)) continue; + if (atMs > upperMs) { + pageHasDataInOrAfterWindow = true; + continue; + } if (atMs < cutoffMs) continue; - pageHasNewerThanCutoff = true; + pageHasDataInOrAfterWindow = true; const normalized = preprocessFlockPayloadForPanel(payloadData); const value = extract(normalized); if (value === null) continue; points.push({ atMs, value: Number(value) }); } - if (!pageHasNewerThanCutoff) break; + if (!pageHasDataInOrAfterWindow) break; const nextFromApi = json.next ?? null; nextUrl = nextFromApi ? nextFromApi.replace('http://', 'https://') : null; diff --git a/context/AppContext.tsx b/context/AppContext.tsx index be71744..bc3e677 100644 --- a/context/AppContext.tsx +++ b/context/AppContext.tsx @@ -85,7 +85,6 @@ import { cobbStandardPerformance } from '../mockData/cobbStandard.ts'; import { shouldUsePreviousDay, getEffectiveDate, - getAdjustedDateRange, formatDateForDb, formatEffectiveDateLabel, } from '../utils/dateFilter.ts'; @@ -876,16 +875,15 @@ export const AppContextProvider: React.FC = ({ } try { - // Use the selected date for API call with 14-day caching - const dateStr = staticDataStore.distributionSelectedDate.toISOString().split('T')[0]; - const adjustedRange = getAdjustedDateRange(dateStr, dateStr); + // Use the calendar-selected date as-is (no D-1 override for explicit user picks) + const dateStr = formatDateForDb(staticDataStore.distributionSelectedDate); const cycleStartStr = activeCycle.startDate instanceof Date ? activeCycle.startDate.toISOString().split('T')[0] : activeCycle.startDate; const response: WeightDistributionApiResponse = await apiClient.getWeightDistributionRange( - adjustedRange.start, - adjustedRange.end, + dateStr, + dateStr, 'LIVE', cycleStartStr ); @@ -947,9 +945,8 @@ export const AppContextProvider: React.FC = ({ const fetchSensorWeightSummary = useCallback(async () => { staticDataStore.setDailyDataLoading(true); try { - const dateStr = staticDataStore.distributionSelectedDate.toISOString().split('T')[0]; - const adjustedRange = getAdjustedDateRange(dateStr, dateStr); - const response = await apiClient.getSensorWeightSummary(adjustedRange.start, 'LIVE'); + const dateStr = formatDateForDb(staticDataStore.distributionSelectedDate); + const response = await apiClient.getSensorWeightSummary(dateStr, 'LIVE'); if (response.success) { staticDataStore.setSensorWeightSummary(response.data); diff --git a/utils/dateFilter.ts b/utils/dateFilter.ts index 72c39f2..8d55431 100644 --- a/utils/dateFilter.ts +++ b/utils/dateFilter.ts @@ -186,3 +186,39 @@ export function getAdjustedDateRange( return { start: startDate, end: endDate }; } + +type CycleEstimasiPanenInput = { + startDate?: Date | string | null; + endDate?: Date | string | null; + totalDays?: number | null; +}; + +/** + * Estimasi Panen calendar date: endDate, or startDate + (totalDays - 1). + */ +export function getEstimasiPanenDate(cycle: CycleEstimasiPanenInput): Date | null { + if (cycle.endDate) { + const end = new Date(cycle.endDate); + if (!Number.isFinite(end.getTime())) return null; + end.setHours(0, 0, 0, 0); + return end; + } + + if (!cycle.startDate) return null; + const start = new Date(cycle.startDate); + if (!Number.isFinite(start.getTime())) return null; + start.setHours(0, 0, 0, 0); + start.setDate(start.getDate() + Math.max(0, (cycle.totalDays || 1) - 1)); + return start; +} + +/** + * Inclusive end-of-day timestamp (local) for Estimasi Panen. + * IoT readings with updatedAt after this should not be shown. + */ +export function getEstimasiPanenEndMs(cycle: CycleEstimasiPanenInput): number | null { + const panenDate = getEstimasiPanenDate(cycle); + if (!panenDate) return null; + panenDate.setHours(23, 59, 59, 999); + return panenDate.getTime(); +}