c4efe4453b
- ✅ - ✅ - ✅
115 lines
3.4 KiB
TypeScript
115 lines
3.4 KiB
TypeScript
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<number, T[]> {
|
||
const grouped = new Map<number, T[]>();
|
||
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<number>();
|
||
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;
|
||
}
|