import { LIMITS } from "@/lib/limits"; /** * Item itinerary terstruktur. Format jam: "HH:mm" 24-jam. * Bentuk ini dipakai di form, action, dan render — DB-nya pakai * `TripItineraryItem` (lihat schema.prisma). */ export interface ItineraryItemInput { day: number; startTime: string; endTime?: string | null; activity: string; } const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; export function isValidTimeFormat(value: string): boolean { return TIME_RE.test(value); } /** * Konversi "HH:mm" ke total menit sejak 00:00. Pakai untuk perbandingan * jam mulai vs jam selesai. Mengembalikan NaN kalau format invalid. */ export function timeToMinutes(value: string): number { if (!isValidTimeFormat(value)) return Number.NaN; const [h, m] = value.split(":").map(Number); return h * 60 + m; } /** * Format display jam (sudah HH:mm di DB, sekedar pass-through dengan * trimming defensif). */ export function formatItineraryTime(value: string): string { return value.trim(); } /** * Kelompokkan items per hari, urut ascending. Item dalam satu hari diurut * berdasarkan `order` lalu `startTime`. */ export function groupItineraryByDay< T extends { day: number; order: number; startTime: string }, >(items: T[]): Map { const grouped = new Map(); for (const item of items) { const list = grouped.get(item.day) ?? []; list.push(item); grouped.set(item.day, list); } for (const list of grouped.values()) { list.sort((a, b) => { if (a.order !== b.order) return a.order - b.order; return timeToMinutes(a.startTime) - timeToMinutes(b.startTime); }); } return new Map( [...grouped.entries()].sort(([a], [b]) => a - b) ); } /** * Validasi semantik (selain Zod): jam selesai harus >= jam mulai (kalau diisi), * dan jumlah day harus rapat (1..N tanpa lompat) untuk menjaga UX builder * tetap deterministik. Return error pertama yang ditemui, atau null kalau OK. */ export function validateItineraryItems( items: ItineraryItemInput[] ): string | null { if (items.length === 0) return null; if (items.length > LIMITS.MAX_ITINERARY_ITEMS) { return `Maksimal ${LIMITS.MAX_ITINERARY_ITEMS} aktivitas total`; } const days = new Set(); for (const item of items) { if (!Number.isInteger(item.day) || item.day < 1) { return "Nomor hari tidak valid"; } if (item.day > LIMITS.MAX_ITINERARY_DAYS) { return `Maksimal ${LIMITS.MAX_ITINERARY_DAYS} hari`; } if (!isValidTimeFormat(item.startTime)) { return "Format jam mulai harus HH:mm (00:00 – 23:59)"; } if (item.endTime && !isValidTimeFormat(item.endTime)) { return "Format jam selesai harus HH:mm (00:00 – 23:59)"; } if ( item.endTime && timeToMinutes(item.endTime) < timeToMinutes(item.startTime) ) { return "Jam selesai tidak boleh sebelum jam mulai"; } const trimmedActivity = item.activity.trim(); if (trimmedActivity.length === 0) { return "Deskripsi aktivitas harus diisi"; } if (trimmedActivity.length > LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH) { return `Aktivitas maksimal ${LIMITS.MAX_ITINERARY_ACTIVITY_LENGTH} karakter`; } days.add(item.day); } const sortedDays = [...days].sort((a, b) => a - b); for (let i = 0; i < sortedDays.length; i++) { if (sortedDays[i] !== i + 1) { return "Nomor hari harus berurutan mulai dari Hari 1 (tidak boleh lompat)"; } } return null; }