feat: add date range option to create scan dialog
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:
2026-03-02 21:38:02 +01:00
parent 4f4f7e86d1
commit cf40736f0e

View File

@@ -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,54 +372,141 @@ export default function Scans() {
Parameters
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Search Window */}
<div>
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
Search Window
</label>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => adjustNumber('window_months', -1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Decrease months"
>
<Minus size={14} />
</button>
<div className="flex-1 h-12 flex items-center justify-center border border-outline rounded-xs bg-surface text-on-surface text-sm font-medium">
{formData.window_months} {formData.window_months === 1 ? 'month' : 'months'}
</div>
<button
type="button"
onClick={() => adjustNumber('window_months', 1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Increase months"
>
<Plus size={14} />
</button>
</div>
<p className="mt-1 text-xs text-on-surface-variant">Months to look ahead (112)</p>
</div>
{/* Seat Class */}
<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>
{/* 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"
onClick={() => adjustNumber('window_months', -1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Decrease months"
>
<Minus size={14} />
</button>
<div className="flex-1 h-12 flex items-center justify-center border border-outline rounded-xs bg-surface text-on-surface text-sm font-medium">
{formData.window_months} {formData.window_months === 1 ? 'month' : 'months'}
</div>
<button
type="button"
onClick={() => adjustNumber('window_months', 1)}
className="w-9 h-9 flex items-center justify-center rounded-full border border-outline text-on-surface-variant hover:bg-surface-2 transition-colors"
aria-label="Increase months"
>
<Plus size={14} />
</button>
</div>
<p className="mt-1 text-xs text-on-surface-variant">Months to look ahead (112)</p>
</div>
{/* Seat Class */}
<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>
) : (
<>
{/* 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">
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">