feat: add date range option to create scan dialog
All checks were successful
Deploy / deploy (push) Successful in 49s
All checks were successful
Deploy / deploy (push) Successful in 49s
Adds a Rolling Window / Date Range toggle to the Parameters section. Rolling Window mode (default) keeps the existing window_months stepper. Date Range mode shows From/To date pickers and sends start_date/end_date to the API instead of window_months. Validation covers empty dates, past start dates, end ≤ start, and ranges exceeding 12 months. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Globe, PlaneTakeoff, Minus, Plus, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
import { Globe, PlaneTakeoff, Minus, Plus, ArrowRight, ArrowLeft, CalendarDays, CalendarRange } from 'lucide-react';
|
||||
import { scanApi } from '../api';
|
||||
import type { CreateScanRequest } from '../api';
|
||||
import AirportSearch from '../components/AirportSearch';
|
||||
@@ -16,6 +16,8 @@ interface FormErrors {
|
||||
airports?: string;
|
||||
window_months?: string;
|
||||
adults?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
|
||||
export default function Scans() {
|
||||
@@ -34,6 +36,11 @@ export default function Scans() {
|
||||
adults: 1,
|
||||
});
|
||||
|
||||
// Window mode: rolling N months or specific date range
|
||||
const [windowMode, setWindowMode] = useState<'window' | 'range'>('window');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
|
||||
// Shared state
|
||||
const [selectedAirports, setSelectedAirports] = useState<string[]>([]);
|
||||
const [selectedOriginCountry, setSelectedOriginCountry] = useState('');
|
||||
@@ -64,6 +71,26 @@ export default function Scans() {
|
||||
}
|
||||
}
|
||||
|
||||
if (windowMode === 'range') {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
if (!startDate) {
|
||||
next.start_date = 'Select a start date';
|
||||
} else if (new Date(startDate) <= today) {
|
||||
next.start_date = 'Start date must be in the future';
|
||||
}
|
||||
if (!endDate) {
|
||||
next.end_date = 'Select an end date';
|
||||
} else if (startDate && endDate <= startDate) {
|
||||
next.end_date = 'End date must be after start date';
|
||||
} else if (startDate && endDate) {
|
||||
const s = new Date(startDate);
|
||||
const e = new Date(endDate);
|
||||
const months = (e.getFullYear() - s.getFullYear()) * 12 + (e.getMonth() - s.getMonth());
|
||||
if (months > 12) next.end_date = 'Date range cannot exceed 12 months';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
@@ -76,22 +103,26 @@ export default function Scans() {
|
||||
try {
|
||||
let requestData: CreateScanRequest;
|
||||
|
||||
const windowParams = windowMode === 'range'
|
||||
? { start_date: startDate, end_date: endDate }
|
||||
: { window_months: formData.window_months };
|
||||
|
||||
if (scanMode === 'reverse') {
|
||||
requestData = {
|
||||
scan_mode: 'reverse',
|
||||
origin: selectedOriginCountry,
|
||||
destinations: selectedAirports,
|
||||
window_months: formData.window_months,
|
||||
seat_class: formData.seat_class,
|
||||
adults: formData.adults,
|
||||
...windowParams,
|
||||
};
|
||||
} else {
|
||||
requestData = {
|
||||
scan_mode: 'forward',
|
||||
origin: formData.origin,
|
||||
window_months: formData.window_months,
|
||||
seat_class: formData.seat_class,
|
||||
adults: formData.adults,
|
||||
...windowParams,
|
||||
...(destinationMode === 'country'
|
||||
? { country: formData.country }
|
||||
: { destinations: selectedAirports }),
|
||||
@@ -124,6 +155,16 @@ export default function Scans() {
|
||||
? 'border-error focus:border-error focus:ring-2 focus:ring-error/20'
|
||||
: 'border-outline focus:border-primary focus:ring-2 focus:ring-primary/20');
|
||||
|
||||
const tomorrowStr = (() => {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d.toISOString().split('T')[0];
|
||||
})();
|
||||
|
||||
const minEndDate = startDate
|
||||
? (() => { const d = new Date(startDate); d.setDate(d.getDate() + 1); return d.toISOString().split('T')[0]; })()
|
||||
: tomorrowStr;
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
||||
@@ -331,12 +372,30 @@ export default function Scans() {
|
||||
Parameters
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Search Window */}
|
||||
<div>
|
||||
{/* Search Window toggle */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
Search Window
|
||||
</label>
|
||||
<SegmentedButton
|
||||
options={[
|
||||
{ value: 'window', label: 'Rolling Window', icon: CalendarDays },
|
||||
{ value: 'range', label: 'Date Range', icon: CalendarRange },
|
||||
]}
|
||||
value={windowMode}
|
||||
onChange={(v) => {
|
||||
setWindowMode(v as 'window' | 'range');
|
||||
setStartDate('');
|
||||
setEndDate('');
|
||||
setErrors(prev => ({ ...prev, start_date: undefined, end_date: undefined }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{windowMode === 'window' ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Stepper */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -378,6 +437,75 @@ export default function Scans() {
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Date pickers */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
From
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={startDate}
|
||||
min={tomorrowStr}
|
||||
onChange={(e) => {
|
||||
setStartDate(e.target.value);
|
||||
if (errors.start_date) setErrors(prev => ({ ...prev, start_date: undefined }));
|
||||
}}
|
||||
className={inputCls(!!errors.start_date)}
|
||||
/>
|
||||
{errors.start_date ? (
|
||||
<p className="mt-1 text-xs text-error">{errors.start_date}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-on-surface-variant">First date to scan</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
To
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={endDate}
|
||||
min={minEndDate}
|
||||
onChange={(e) => {
|
||||
setEndDate(e.target.value);
|
||||
if (errors.end_date) setErrors(prev => ({ ...prev, end_date: undefined }));
|
||||
}}
|
||||
className={inputCls(!!errors.end_date)}
|
||||
/>
|
||||
{errors.end_date ? (
|
||||
<p className="mt-1 text-xs text-error">{errors.end_date}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-on-surface-variant">Last date to scan</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-on-surface-variant">
|
||||
Flights will be sampled on the 15th of each month within this range
|
||||
</p>
|
||||
|
||||
{/* Seat Class */}
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
Seat Class
|
||||
</label>
|
||||
<select
|
||||
value={formData.seat_class}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, seat_class: e.target.value as 'economy' | 'premium' | 'business' | 'first' }))}
|
||||
className={inputCls()}
|
||||
>
|
||||
<option value="economy">Economy</option>
|
||||
<option value="premium">Premium Economy</option>
|
||||
<option value="business">Business</option>
|
||||
<option value="first">First Class</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Adults — full width below */}
|
||||
<div className="mt-4">
|
||||
|
||||
Reference in New Issue
Block a user