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 { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { scanApi } from '../api';
|
||||||
import type { CreateScanRequest } from '../api';
|
import type { CreateScanRequest } from '../api';
|
||||||
import AirportSearch from '../components/AirportSearch';
|
import AirportSearch from '../components/AirportSearch';
|
||||||
@@ -16,6 +16,8 @@ interface FormErrors {
|
|||||||
airports?: string;
|
airports?: string;
|
||||||
window_months?: string;
|
window_months?: string;
|
||||||
adults?: string;
|
adults?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Scans() {
|
export default function Scans() {
|
||||||
@@ -34,6 +36,11 @@ export default function Scans() {
|
|||||||
adults: 1,
|
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
|
// Shared state
|
||||||
const [selectedAirports, setSelectedAirports] = useState<string[]>([]);
|
const [selectedAirports, setSelectedAirports] = useState<string[]>([]);
|
||||||
const [selectedOriginCountry, setSelectedOriginCountry] = useState('');
|
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);
|
setErrors(next);
|
||||||
return Object.keys(next).length === 0;
|
return Object.keys(next).length === 0;
|
||||||
};
|
};
|
||||||
@@ -76,22 +103,26 @@ export default function Scans() {
|
|||||||
try {
|
try {
|
||||||
let requestData: CreateScanRequest;
|
let requestData: CreateScanRequest;
|
||||||
|
|
||||||
|
const windowParams = windowMode === 'range'
|
||||||
|
? { start_date: startDate, end_date: endDate }
|
||||||
|
: { window_months: formData.window_months };
|
||||||
|
|
||||||
if (scanMode === 'reverse') {
|
if (scanMode === 'reverse') {
|
||||||
requestData = {
|
requestData = {
|
||||||
scan_mode: 'reverse',
|
scan_mode: 'reverse',
|
||||||
origin: selectedOriginCountry,
|
origin: selectedOriginCountry,
|
||||||
destinations: selectedAirports,
|
destinations: selectedAirports,
|
||||||
window_months: formData.window_months,
|
|
||||||
seat_class: formData.seat_class,
|
seat_class: formData.seat_class,
|
||||||
adults: formData.adults,
|
adults: formData.adults,
|
||||||
|
...windowParams,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
requestData = {
|
requestData = {
|
||||||
scan_mode: 'forward',
|
scan_mode: 'forward',
|
||||||
origin: formData.origin,
|
origin: formData.origin,
|
||||||
window_months: formData.window_months,
|
|
||||||
seat_class: formData.seat_class,
|
seat_class: formData.seat_class,
|
||||||
adults: formData.adults,
|
adults: formData.adults,
|
||||||
|
...windowParams,
|
||||||
...(destinationMode === 'country'
|
...(destinationMode === 'country'
|
||||||
? { country: formData.country }
|
? { country: formData.country }
|
||||||
: { destinations: selectedAirports }),
|
: { destinations: selectedAirports }),
|
||||||
@@ -124,6 +155,16 @@ export default function Scans() {
|
|||||||
? 'border-error focus:border-error focus:ring-2 focus:ring-error/20'
|
? 'border-error focus:border-error focus:ring-2 focus:ring-error/20'
|
||||||
: 'border-outline focus:border-primary focus:ring-2 focus:ring-primary/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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
||||||
@@ -331,12 +372,30 @@ export default function Scans() {
|
|||||||
Parameters
|
Parameters
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
{/* Search Window toggle */}
|
||||||
{/* Search Window */}
|
<div className="mb-4">
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||||
Search Window
|
Search Window
|
||||||
</label>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -378,6 +437,75 @@ export default function Scans() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Adults — full width below */}
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user