/** * Frontend Date Filtering Utilities for D-1 Logic * Mirrors backend dateUtils for client-side awareness */ const CUTOFF_HOUR = 17; const WIB_TIME_ZONE = 'Asia/Jakarta'; type WibDateParts = { year: number; month: number; day: number; hour: number; minute: number; second: number; }; function getWibDateParts(referenceDate?: Date): WibDateParts { const now = referenceDate || new Date(); const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: WIB_TIME_ZONE, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }); const parts = formatter.formatToParts(now); const lookup = Object.fromEntries( parts.filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]) ) as Record; return { year: Number(lookup.year), month: Number(lookup.month), day: Number(lookup.day), hour: Number(lookup.hour), minute: Number(lookup.minute), second: Number(lookup.second), }; } function createLocalDateFromWibParts(parts: WibDateParts): Date { const date = new Date( parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second ); date.setMilliseconds(0); return date; } /** * Get current date/time in WIB (Asia/Jakarta, UTC+7) timezone * @param referenceDate - Optional reference date for testing * @returns Current date in WIB */ export function getCurrentWibDate(referenceDate?: Date): Date { return createLocalDateFromWibParts(getWibDateParts(referenceDate)); } /** * Check if current WIB time is before 17:00 * @param referenceDate - Optional reference date for testing * @returns True if before cutoff, false otherwise */ export function shouldUsePreviousDay(referenceDate?: Date): boolean { return getWibDateParts(referenceDate).hour < CUTOFF_HOUR; } /** * Get effective date for data queries (D-1 or today) * @param referenceDate - Optional reference date for testing * @returns Effective date for queries */ export function getEffectiveDate(referenceDate?: Date): Date { const wibParts = getWibDateParts(referenceDate); const wibToday = createLocalDateFromWibParts(wibParts); if (shouldUsePreviousDay(referenceDate)) { // Return D-1 (previous day) const yesterday = new Date(wibToday); yesterday.setDate(yesterday.getDate() - 1); return yesterday; } return wibToday; } /** * Calculate effective cycle day with D-1 adjustment * @param cycleStartDate - Cycle start date * @param referenceDate - Optional reference date for testing * @returns Effective cycle day (0-indexed) */ export function getEffectiveCycleDay(cycleStartDate: Date | string, referenceDate?: Date): number { const effectiveDate = getEffectiveDate(referenceDate); const startDate = new Date(cycleStartDate); // Reset hours to compare dates only effectiveDate.setHours(0, 0, 0, 0); startDate.setHours(0, 0, 0, 0); const diffMs = effectiveDate.getTime() - startDate.getTime(); // Return -1 if effective date is before cycle start if (diffMs < 0) { return -1; } const dayOfCycle = Math.floor(diffMs / (1000 * 60 * 60 * 24)); return dayOfCycle; } /** * Format date as YYYY-MM-DD * @param date - Date object to format * @returns Date in YYYY-MM-DD format */ export function formatDateForDb(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } /** * Format effective date label with disclaimer if D-1 active * @param date - Date to format * @param referenceDate - Optional reference date for testing * @returns Formatted date string with optional disclaimer */ export function formatEffectiveDateLabel(date: Date, referenceDate?: Date): string { const monthNames = [ 'Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember', ]; const day = date.getDate(); const month = monthNames[date.getMonth()]; const year = date.getFullYear(); const baseLabel = `${day} ${month} ${year}`; if (shouldUsePreviousDay(referenceDate)) { return `${baseLabel} (Data Hari Kemarin)`; } return baseLabel; } /** * Get adjusted date range for API queries (applies D-1 filter if needed) * @param startDate - Start date string (YYYY-MM-DD) * @param endDate - End date string (YYYY-MM-DD) * @param referenceDate - Optional reference date for testing * @returns Adjusted start and end dates */ export function getAdjustedDateRange( startDate: string, endDate: string, referenceDate?: Date ): { start: string; end: string } { // Only adjust if querying a single day and before cutoff if (startDate === endDate && shouldUsePreviousDay(referenceDate)) { const adjusted = getEffectiveDate(referenceDate); const adjustedStr = formatDateForDb(adjusted); return { start: adjustedStr, end: adjustedStr }; } 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(); }