This commit is contained in:
1 parent
b6117ae7d5
commit
4726437f38
5 files changed
+346
-63
No files matched your search
@@ -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<PayloadData | null>(null);
|
||||
|
||||
const [error, setError] = useState<string | null>(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(() => {
|
||||
|
||||
+137
-13
@@ -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<FlocksPageJson> {
|
||||
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<TabelAlarmPaylo
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDashboardData(): Promise<{ raw: IotFlocksRaw; data: PayloadData }> {
|
||||
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');
|
||||
const raw = (await response.json()) as IotFlocksRaw;
|
||||
const latestRow = latestResultRow(raw);
|
||||
raw = (await response.json()) as IotFlocksRaw;
|
||||
latestRow = latestResultRow(raw);
|
||||
}
|
||||
|
||||
const result = latestRow?.payload?.data;
|
||||
const latestId = latestRow?.id;
|
||||
|
||||
|
||||
@@ -119,34 +119,129 @@ function mergeDeviceHistoryEvents(events: TimedEvent[]): IotDeviceHistoryPoint[]
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function loadFlockSnapshots24h(): Promise<FlockSnapshot[]> {
|
||||
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<IotFlocksRaw & { next?: string | null }> {
|
||||
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<FlockSnapshot[]> {
|
||||
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) {
|
||||
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<string, 0 | 1> = {};
|
||||
for (let fanNumber = 1; fanNumber <= 11; fanNumber += 1) {
|
||||
@@ -165,7 +260,7 @@ async function loadFlockSnapshots24h(): Promise<FlockSnapshot[]> {
|
||||
});
|
||||
}
|
||||
|
||||
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<TimedEvent[]> {
|
||||
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<TimedEvent[]> {
|
||||
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<IotDeviceHistoryPoint[]> {
|
||||
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<IotDeviceHistoryPoint[]> {
|
||||
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<IotDeviceHistoryPoint[]> {
|
||||
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<IotSensorHistoryPoint[]> {
|
||||
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<IotSensorHistoryPoint[]> {
|
||||
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) {
|
||||
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;
|
||||
|
||||
@@ -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<AppContextProviderProps> = ({
|
||||
}
|
||||
|
||||
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<AppContextProviderProps> = ({
|
||||
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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Reference in new issue
Block a user