Add TimeToLeave feature checklist and plan (50)
Add TimeToLeave feature checklist and plan
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# TimeToLeave — Features Implementation Checklist
|
||||
|
||||
## Phase 1 — New Settings Infrastructure (Steps 1-3)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 1 | Extend ReminderSettings type with 3 new fields | [x] | [x] |
|
||||
| 2 | Update useReminderSettings hook with defaults + setters | [x] | [x] |
|
||||
| 3 | Update ReminderSettingsPanel UI (slider + 2 toggles) | [x] | [x] |
|
||||
|
||||
## Phase 2 — Walk Routing Infrastructure (Steps 4-6)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 4 | Create WalkRoutingClient (OSRM foot profile) | [x] | [x] |
|
||||
| 5 | Create /api/walk-route endpoint | [x] | [x] |
|
||||
| 6 | Add getWalkRoute to api-client package | [x] | [x] |
|
||||
|
||||
## Phase 3 — Departure Time Calculation (Steps 7-8)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 7 | Create useDepartureTime hook | [x] | [x] |
|
||||
| 8 | Update useClock to accept departureTime override | [x] | [x] |
|
||||
|
||||
## Phase 4 — Mode Selector & EventCard Updates (Steps 9-11)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 9 | Create useWalkRoute hook | [x] | [x] |
|
||||
| 10 | Create WalkingOption component | [x] | [x] |
|
||||
| 11 | Update EventCard with mode selector + conditional rendering | [x] | [x] |
|
||||
|
||||
## Phase 5 — TrainSection & JourneyList Updates (Steps 12-13)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 12 | Update TrainSection props (arrival buffer + walk option) | [x] | [ ] |
|
||||
| 13 | Update JourneyList with arrival buffer filtering | [x] | [ ] |
|
||||
|
||||
## Phase 6 — Verification & Testing (Steps 14-16)
|
||||
|
||||
| # | Step | ✅ | ✔️ |
|
||||
|---|---|----|----|
|
||||
| 14 | Integration verification (manual testing) | [ ] | [ ] |
|
||||
| 15 | Build verification (typecheck, lint, test, build) | [ ] | [ ] |
|
||||
| 16 | Update api-client exports | [ ] | [ ] |
|
||||
|
||||
---
|
||||
|
||||
**Legend:**
|
||||
- ✅ = Done (code written)
|
||||
- ✔️ = Verified (tests/builds pass)
|
||||
- `[~]` = Optional or deferred (never blocks phase advancement)
|
||||
@@ -0,0 +1,604 @@
|
||||
## Phase 1 — New Settings Infrastructure (Steps 1-3)
|
||||
|
||||
Extend the `ReminderSettings` type, hook, and UI panel with three new options.
|
||||
|
||||
---
|
||||
|
||||
#### Step 1: Extend ReminderSettings Type (~5 min)
|
||||
|
||||
**File:** `packages/core/src/types.ts`
|
||||
|
||||
**Goal:** Add three new fields to `ReminderSettings` for arrival buffer, walking, and bike toggles.
|
||||
|
||||
**Change `ReminderSettings` interface:**
|
||||
|
||||
```typescript
|
||||
export interface ReminderSettings {
|
||||
bufferMinutes: number;
|
||||
enabled: boolean;
|
||||
arrivalBufferMinutes: number; // arrive X minutes before event (default 5)
|
||||
showWalkingOption: boolean; // show walk-from-station option (default true)
|
||||
showBikeOption: boolean; // show bike route section (default true)
|
||||
}
|
||||
```
|
||||
|
||||
**Actions:**
|
||||
1. Add the three new fields to the interface
|
||||
2. No changes needed to other types
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `packages/core` compiles without errors
|
||||
- [ ] `npm run typecheck -w packages/core` passes
|
||||
|
||||
---
|
||||
|
||||
#### Step 2: Update useReminderSettings Hook (~10 min)
|
||||
|
||||
**File:** `apps/web/src/hooks/useReminderSettings.tsx`
|
||||
|
||||
**Goal:** Add default values and setters for the three new settings fields.
|
||||
|
||||
**Changes:**
|
||||
|
||||
Update DEFAULTS:
|
||||
```typescript
|
||||
const DEFAULTS: ReminderSettings = {
|
||||
bufferMinutes: 15,
|
||||
enabled: true,
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
};
|
||||
```
|
||||
|
||||
Update context interface:
|
||||
```typescript
|
||||
interface ReminderContextType extends ReminderSettings {
|
||||
setBufferMinutes: (minutes: number) => void;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
setArrivalBufferMinutes: (minutes: number) => void;
|
||||
setShowWalkingOption: (show: boolean) => void;
|
||||
setShowBikeOption: (show: boolean) => void;
|
||||
}
|
||||
```
|
||||
|
||||
Add setter implementations:
|
||||
```typescript
|
||||
const setArrivalBufferMinutes = useCallback((minutes: number) => {
|
||||
setSettings((prev) => ({ ...prev, arrivalBufferMinutes: Math.max(0, Math.min(30, minutes)) }));
|
||||
}, []);
|
||||
|
||||
const setShowWalkingOption = useCallback((show: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, showWalkingOption: show }));
|
||||
}, []);
|
||||
|
||||
const setShowBikeOption = useCallback((show: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, showBikeOption: show }));
|
||||
}, []);
|
||||
```
|
||||
|
||||
Update Provider value to include new setters.
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Hook exports all three new setters
|
||||
- [ ] localStorage serialization includes new fields
|
||||
- [ ] `npm run typecheck -w apps/web` passes
|
||||
|
||||
---
|
||||
|
||||
#### Step 3: Update ReminderSettingsPanel UI (~15 min)
|
||||
|
||||
**File:** `apps/web/src/app/ui/ReminderSettingsPanel.tsx`
|
||||
|
||||
**Goal:** Add UI controls for the three new settings.
|
||||
|
||||
**Add after the buffer minutes slider, before the permission status section:**
|
||||
|
||||
```tsx
|
||||
{/* Arrival buffer slider */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="arrival-buffer"
|
||||
className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80"
|
||||
>
|
||||
Arrive early
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
(minutes before event)
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="arrival-buffer"
|
||||
type="range"
|
||||
min={0}
|
||||
max={30}
|
||||
step={1}
|
||||
value={arrivalBufferMinutes}
|
||||
onChange={(e) => setArrivalBufferMinutes(Number(e.target.value))}
|
||||
className="flex-1 accent-[#B23CFF]"
|
||||
/>
|
||||
<output
|
||||
htmlFor="arrival-buffer"
|
||||
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-[#F4F1EA]/80"
|
||||
>
|
||||
{arrivalBufferMinutes}
|
||||
</output>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show walking option toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
|
||||
Show walking option
|
||||
</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={showWalkingOption}
|
||||
onClick={() => setShowWalkingOption(!showWalkingOption)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
showWalkingOption ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
showWalkingOption ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Show bike option toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
|
||||
Show bike route
|
||||
</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={showBikeOption}
|
||||
onClick={() => setShowBikeOption(!showBikeOption)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
showBikeOption ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
showBikeOption ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Destructure new values from the hook at the top of the component:**
|
||||
```typescript
|
||||
const {
|
||||
bufferMinutes, enabled, setBufferMinutes, setEnabled,
|
||||
arrivalBufferMinutes, showWalkingOption, showBikeOption,
|
||||
setArrivalBufferMinutes, setShowWalkingOption, setShowBikeOption,
|
||||
} = useReminderSettings();
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Panel renders all new controls
|
||||
- [ ] Slider range is 0-30 for arrival buffer
|
||||
- [ ] Toggles match existing visual style
|
||||
- [ ] `npm run build -w apps/web` succeeds
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Walk Routing Infrastructure (Steps 4-6)
|
||||
|
||||
Add the ability to calculate walking routes using OSRM's foot profile.
|
||||
|
||||
---
|
||||
|
||||
#### Step 4: Create WalkRoutingClient (~10 min)
|
||||
|
||||
**File:** `apps/web/src/lib/walk-routing-client.ts` (create new)
|
||||
|
||||
**Goal:** Clone `BikeRoutingClient` but targeting the OSRM foot profile.
|
||||
|
||||
**Actions:**
|
||||
1. Copy `apps/web/src/lib/bike-routing-client.ts` to `walk-routing-client.ts`
|
||||
2. Rename class to `WalkRoutingClient`
|
||||
3. Change OSRM path from `/route/v1/bicycle/` to `/route/v1/foot/`
|
||||
4. Change cache key prefix from `osrm:bike:` to `osrm:walk:`
|
||||
5. Rename `getBikeRoute` to `getWalkRoute`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] File compiles without errors
|
||||
- [ ] Class mirrors BikeRoutingClient structure exactly
|
||||
- [ ] Cache keys are namespaced separately
|
||||
|
||||
---
|
||||
|
||||
#### Step 5: Create Walk Route API Endpoint (~10 min)
|
||||
|
||||
**File:** `apps/web/src/app/api/walk-route/route.ts` (create new)
|
||||
|
||||
**Goal:** Server-side handler that delegates to WalkRoutingClient.
|
||||
|
||||
**Actions:**
|
||||
1. Copy `apps/web/src/app/api/bike-route/route.ts` to `walk-route/route.ts`
|
||||
2. Import `WalkRoutingClient` instead of `BikeRoutingClient`
|
||||
3. Update error log messages to reference "Walk route"
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Endpoint responds at `/api/walk-route`
|
||||
- [ ] Accepts same query params as bike-route
|
||||
- [ ] Returns same `BikeRoute` shape (reused type)
|
||||
|
||||
---
|
||||
|
||||
#### Step 6: Add getWalkRoute to ApiClient (~5 min)
|
||||
|
||||
**File:** `packages/api-client/src/client.ts`
|
||||
|
||||
**Goal:** Add `getWalkRoute` method mirroring `getBikeRoute`.
|
||||
|
||||
**Actions:**
|
||||
1. Copy `getBikeRoute` method
|
||||
2. Rename to `getWalkRoute`
|
||||
3. Change URL from `/api/bike-route` to `/api/walk-route`
|
||||
4. Export from `packages/api-client/src/index.ts`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Method accepts same signature as `getBikeRoute`
|
||||
- [ ] Returns `Promise<BikeRoute>`
|
||||
- [ ] `npm run typecheck -w packages/api-client` passes
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Departure Time Calculation (Steps 7-8)
|
||||
|
||||
Build the logic for calculating when you should leave based on transport mode.
|
||||
|
||||
---
|
||||
|
||||
#### Step 7: Create useDepartureTime Hook (~15 min)
|
||||
|
||||
**File:** `apps/web/src/hooks/useDepartureTime.ts` (create new)
|
||||
|
||||
**Goal:** Calculate the optimal departure time based on selected mode, journeys, bike route, and arrival buffer.
|
||||
|
||||
**Actions:**
|
||||
1. Create hook accepting `eventTime`, `journeys`, `bikeRoute`, `activeMode`
|
||||
2. Import `useReminderSettings` for `arrivalBufferMinutes`
|
||||
3. Calculate `targetArrivalTime = eventTime - arrivalBufferMinutes`
|
||||
4. For train mode: find journeys arriving before target, pick latest departure
|
||||
5. For bike mode: calculate departure from target minus bike duration
|
||||
6. Return `{ departureTime, arrivalTime, mode }`
|
||||
|
||||
**Key implementation notes:**
|
||||
- Bike duration from OSRM is in seconds, convert to milliseconds for Date math
|
||||
- Filter cancelled journeys
|
||||
- Return null values if no valid option exists for the mode
|
||||
- Use `useMemo` to avoid recalculating on unrelated renders
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Hook returns correct departure time for train mode
|
||||
- [ ] Hook returns correct departure time for bike mode
|
||||
- [ ] Returns null when no valid journey/route exists
|
||||
- [ ] Respects arrival buffer setting
|
||||
|
||||
---
|
||||
|
||||
#### Step 8: Update useClock Hook (~10 min)
|
||||
|
||||
**File:** `apps/web/src/hooks/useClock.ts`
|
||||
|
||||
**Goal:** Accept optional departure time override so countdown reflects "time to leave" instead of "time to event".
|
||||
|
||||
**Changes:**
|
||||
|
||||
Update signature:
|
||||
```typescript
|
||||
export function useClock(targetDate: Date, departureTime?: Date | null): ClockResult {
|
||||
```
|
||||
|
||||
Update memoized logic:
|
||||
```typescript
|
||||
const effectiveTarget = departureTime ?? targetDate;
|
||||
|
||||
return useMemo(() => {
|
||||
const countdown = calculateCountdown(effectiveTarget);
|
||||
const diffMs = effectiveTarget.getTime() - now.getTime();
|
||||
// ... rest of status logic using effectiveTarget
|
||||
}, [targetDate, departureTime, now]);
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] When departureTime is provided, countdown is to departure time
|
||||
- [ ] When departureTime is null/undefined, countdown is to event time (backward compatible)
|
||||
- [ ] `npm run typecheck -w apps/web` passes
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Mode Selector & EventCard Updates (Steps 9-11)
|
||||
|
||||
Wire everything together in the EventCard with a mode selector and conditional rendering.
|
||||
|
||||
---
|
||||
|
||||
#### Step 9: Add Walk Route Hook (~10 min)
|
||||
|
||||
**File:** `apps/web/src/hooks/useWalkRoute.ts` (create new)
|
||||
|
||||
**Goal:** Create hook for fetching walk routes, similar to `useBikeRoute`.
|
||||
|
||||
**Actions:**
|
||||
1. Copy `apps/web/src/hooks/useBikeRoute.ts` to `useWalkRoute.ts`
|
||||
2. Rename state variables from `bikeRoute` to `walkRoute`
|
||||
3. Call `client.getWalkRoute` instead of `client.getBikeRoute`
|
||||
4. Return `{ walkRoute, loading, error }`
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Hook fetches walk route via the walk-route API
|
||||
- [ ] Follows same pattern as useBikeRoute
|
||||
- [ ] Compiles without errors
|
||||
|
||||
---
|
||||
|
||||
#### Step 10: Create WalkingOption Component (~10 min)
|
||||
|
||||
**File:** `apps/web/src/app/event/WalkingOption.tsx` (create new)
|
||||
|
||||
**Goal:** Show walking duration from the arrival station to the destination.
|
||||
|
||||
**Actions:**
|
||||
1. Create component accepting `walkRoute`, `walkLoading`, `walkError` props
|
||||
2. Show loading spinner while fetching
|
||||
3. When route exists, show walk time in minutes and distance in km
|
||||
4. Use a pedestrian icon (inline SVG or emoji)
|
||||
5. Return null if no route or error
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Component renders walk duration badge
|
||||
- [ ] Matches existing visual style
|
||||
- [ ] Graceful handling of loading/error states
|
||||
|
||||
---
|
||||
|
||||
#### Step 11: Update EventCard with Mode Selector (~20 min)
|
||||
|
||||
**File:** `apps/web/src/app/event/EventCard.tsx`
|
||||
|
||||
**Goal:** Add transport mode selector, wire departure time to countdown, conditionally render sections.
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Add mode state:
|
||||
```typescript
|
||||
type TransportMode = "train" | "bike";
|
||||
const [activeMode, setActiveMode] = useState<TransportMode>("train");
|
||||
```
|
||||
|
||||
2. Use settings:
|
||||
```typescript
|
||||
const { arrivalBufferMinutes, showWalkingOption, showBikeOption } = useReminderSettings();
|
||||
```
|
||||
|
||||
3. Fetch walk route from destination station to destination:
|
||||
```typescript
|
||||
const { walkRoute, loading: walkLoading, error: walkError } = useWalkRoute(
|
||||
destStation.station?.lat,
|
||||
destStation.station?.lng,
|
||||
destCoords.coords?.lat,
|
||||
destCoords.coords?.lng,
|
||||
);
|
||||
```
|
||||
|
||||
4. Use departure time hook:
|
||||
```typescript
|
||||
const { departureTime } = useDepartureTime(event.eventTime, journeys, bikeRoute, activeMode);
|
||||
```
|
||||
|
||||
5. Pass departureTime to useClock:
|
||||
```typescript
|
||||
const { countdown, status } = useClock(event.eventTime, departureTime);
|
||||
```
|
||||
|
||||
6. Add mode selector UI between the header info and the sections:
|
||||
```tsx
|
||||
<div className="mb-4 flex gap-2">
|
||||
<button
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
activeMode === "train"
|
||||
? "bg-[#B23CFF] text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveMode("train")}
|
||||
>
|
||||
Train
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
activeMode === "bike"
|
||||
? "bg-[#B23CFF] text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveMode("bike")}
|
||||
>
|
||||
Bike
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
7. Conditional section rendering:
|
||||
- TrainSection renders when `activeMode === "train"` (pass arrival buffer + walk option props)
|
||||
- BikeSection renders when `showBikeOption && activeMode === "bike"`
|
||||
- WienerLinienSection stays unchanged (always visible)
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Mode selector toggles between Train and Bike
|
||||
- [ ] Countdown badge updates to reflect departure time for selected mode
|
||||
- [ ] Bike section hidden when `showBikeOption` is false
|
||||
- [ ] Train section receives arrival buffer for filtering
|
||||
- [ ] Walk route fetched from station to destination
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — TrainSection & JourneyList Updates (Steps 12-13)
|
||||
|
||||
Pass arrival buffer through the component tree and use it for filtering/countdown.
|
||||
|
||||
---
|
||||
|
||||
#### Step 12: Update TrainSection Props (~10 min)
|
||||
|
||||
**File:** `apps/web/src/app/event/TrainSection.tsx`
|
||||
|
||||
**Goal:** Accept and forward new props for walking option and arrival buffer.
|
||||
|
||||
**Changes:**
|
||||
|
||||
Extend props interface:
|
||||
```typescript
|
||||
type TrainSectionProps = {
|
||||
journeys: Journey[];
|
||||
eventTime: Date;
|
||||
destName: string;
|
||||
loading: boolean;
|
||||
error?: string | null;
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
arrivalBufferMinutes?: number;
|
||||
showWalkingOption?: boolean;
|
||||
walkRoute?: import("@timetoleave/core").BikeRoute | null;
|
||||
walkLoading?: boolean;
|
||||
walkError?: string | null;
|
||||
};
|
||||
```
|
||||
|
||||
Pass through to JourneyList:
|
||||
```tsx
|
||||
<JourneyList
|
||||
journeys={journeys}
|
||||
eventTime={eventTime}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes ?? 0}
|
||||
/>
|
||||
```
|
||||
|
||||
Add WalkingOption at bottom when enabled:
|
||||
```tsx
|
||||
{showWalkingOption && (
|
||||
<div className="p-4 border-t border-gray-200 dark:border-white/10">
|
||||
<WalkingOption walkRoute={walkRoute} walkLoading={walkLoading} walkError={walkError} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Props are optional with defaults
|
||||
- [ ] WalkingOption renders below journey list
|
||||
- [ ] Arrival buffer forwarded to JourneyList
|
||||
|
||||
---
|
||||
|
||||
#### Step 13: Update JourneyList with Arrival Buffer (~10 min)
|
||||
|
||||
**File:** `apps/web/src/app/event/JourneyList.tsx`
|
||||
|
||||
**Goal:** Use arrival buffer when calculating countdown and filtering invalid journeys.
|
||||
|
||||
**Changes:**
|
||||
|
||||
Update props:
|
||||
```typescript
|
||||
type JourneyListProps = {
|
||||
journeys: Journey[];
|
||||
eventTime: Date;
|
||||
arrivalBufferMinutes: number;
|
||||
className?: string;
|
||||
};
|
||||
```
|
||||
|
||||
Calculate adjusted target:
|
||||
```typescript
|
||||
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
||||
```
|
||||
|
||||
Filter out journeys arriving after target:
|
||||
```typescript
|
||||
const validJourneys = journeys.filter(j => !j.cancelled);
|
||||
```
|
||||
|
||||
Dim journeys that arrive too late:
|
||||
```tsx
|
||||
const arrivesTooLate = j.rA.getTime() > targetArrivalTime.getTime();
|
||||
```
|
||||
|
||||
Apply dimmed styling to late journeys:
|
||||
```tsx
|
||||
<li key={j.id} className={`... ${arrivesTooLate ? "opacity-40 line-through" : ""}`}>
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Late journeys are visually dimmed
|
||||
- [ ] Countdown reflects arrival buffer
|
||||
- [ ] Late journeys are not removed, just dimmed (user can still see them)
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Verification & Testing (Steps 14-16)
|
||||
|
||||
Ensure everything works together correctly.
|
||||
|
||||
---
|
||||
|
||||
#### Step 14: Integration Verification (~10 min)
|
||||
|
||||
**Goal:** Verify the full flow from settings through to the UI.
|
||||
|
||||
**Manual test steps:**
|
||||
1. Open settings, set arrival buffer to 10 minutes
|
||||
2. Verify countdown badge shows earlier departure time than event time
|
||||
3. Switch to bike mode, verify countdown updates to bike departure time
|
||||
4. Enable walking option, verify walk duration appears under train section
|
||||
5. Toggle bike option off, verify bike section disappears
|
||||
6. Toggle bike option on, verify bike section reappears
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] All settings persist in localStorage
|
||||
- [ ] Countdown reflects selected mode and arrival buffer
|
||||
- [ ] Walk option shows correctly when enabled
|
||||
- [ ] Bike section toggles correctly
|
||||
|
||||
---
|
||||
|
||||
#### Step 15: Run Build Verification (~5 min)
|
||||
|
||||
**Commands:**
|
||||
```bash
|
||||
npm run typecheck -w packages/core
|
||||
npm run typecheck -w packages/api-client
|
||||
npm run typecheck -w apps/web
|
||||
npm run lint -w apps/web
|
||||
npm run build -w apps/web
|
||||
npm test
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] All typechecks pass with zero errors
|
||||
- [ ] Lint passes with zero errors
|
||||
- [ ] Build completes successfully
|
||||
- [ ] All existing tests still pass
|
||||
|
||||
---
|
||||
|
||||
#### Step 16: Update api-client exports (~5 min)
|
||||
|
||||
**File:** `packages/api-client/src/index.ts`
|
||||
|
||||
**Goal:** Ensure `getWalkRoute` is exported from the package.
|
||||
|
||||
**Actions:**
|
||||
1. Check current exports in index.ts
|
||||
2. Add `getWalkRoute` method to the exported class if not already present
|
||||
3. Verify the method is accessible from web app imports
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `getWalkRoute` is importable from `@timetoleave/api-client`
|
||||
- [ ] No breaking changes to existing exports
|
||||
|
||||
---
|
||||
@@ -0,0 +1,80 @@
|
||||
# Review Agent Rules
|
||||
|
||||
These rules apply when reviewing completed implementation steps on the `main` branch.
|
||||
|
||||
## Checklist Tracking
|
||||
|
||||
`FEATURES_CHECKLIST.md` uses three checkbox states:
|
||||
- `[ ]` — pending and **required**; blocks the next phase
|
||||
- `[x]` — done
|
||||
- `[~]` — optional or deferred; **never blocks phase advancement**
|
||||
|
||||
Rules:
|
||||
- The ✅ column belongs to the implementation agent; the ✔️ column is yours.
|
||||
- Only review items whose ✅ box is already `[x]`. Do not attempt to review unimplemented items.
|
||||
- If a ✅ box is `[~]` (optional, skipped), mark the ✔️ box `[~]` as well — no review needed for skipped items.
|
||||
- After reviewing each required item and confirming it meets the quality bar below, mark its ✔️ box by changing `[ ]` to `[x]`.
|
||||
- Before reviewing any item in a new phase, read `FEATURES_CHECKLIST.md` and confirm that every **required** item in all preceding phases has `[x]` in both ✅ and ✔️. Items where both columns are `[~]` do not need review and do not block advancement.
|
||||
- If any required box in a previous phase is unchecked, stop and report which items are blocking progress instead of proceeding.
|
||||
|
||||
## Review Scope
|
||||
|
||||
- Review one phase at a time. Within a phase, review items in the order they appear in `FEATURES_CHECKLIST.md`.
|
||||
- For each item, cross-reference the implementation against `FEATURES_PLAN.md` and the quality criteria below.
|
||||
- Report concrete issues with file paths and line numbers. Do not flag style nitpicks that are not covered by a project guideline.
|
||||
|
||||
## What to Check
|
||||
|
||||
**Correctness**
|
||||
- The behavior matches the intent described in `FEATURES_PLAN.md` and the checklist item.
|
||||
- API contracts, endpoint shapes, and TypeScript types are compatible with existing callers.
|
||||
- No regressions are introduced in previously working behavior.
|
||||
|
||||
**Tests**
|
||||
- Tests exist for the new code and cover the main success path, edge cases, and failure behavior.
|
||||
- Tests are not weakened or removed just to make the suite pass.
|
||||
- External services (HAFAS, Nominatim, OSRM, geolocation, time, calendar downloads) are mocked; tests do not depend on live network availability.
|
||||
|
||||
**Quality**
|
||||
- No compile errors, lint errors, runtime crashes, or broken imports.
|
||||
- TypeScript strictness is intact — no `any` used as a shortcut.
|
||||
- Server-only code is not imported into client components.
|
||||
- Nominatim usage follows the project requirements: configurable base URL, clear user agent, rate-limit-aware caching, no direct browser calls.
|
||||
- Error handling is explicit and user-facing failures are understandable.
|
||||
- No generated artifacts, caches, logs, or local environment files are committed.
|
||||
- Dependencies are unchanged unless necessary and justified.
|
||||
- Package boundaries are respected — shared types in `packages/core`, API wrappers in `packages/api-client`, app code in `apps/web`.
|
||||
|
||||
**Scope**
|
||||
- The change is scoped to the checklist item — no unrelated modifications.
|
||||
- Existing implementation files were not removed unless parity is tested and cleanup was explicitly requested.
|
||||
|
||||
**Accessibility (UI items only)**
|
||||
- Semantic buttons and links, labels for inputs, keyboard-operable controls, visible loading and error states.
|
||||
|
||||
## Verification
|
||||
|
||||
- Run the relevant test and build checks to confirm the implementation passes before marking ✔️:
|
||||
|
||||
```bash
|
||||
npm run typecheck -w packages/core
|
||||
npm run typecheck -w packages/api-client
|
||||
npm run typecheck -w apps/web
|
||||
npm run lint -w apps/web
|
||||
npm run build -w apps/web
|
||||
npm test
|
||||
```
|
||||
|
||||
- If a check fails, do not mark the ✔️ box. Report the failure with the exact output and leave the item for the implementation agent to fix.
|
||||
|
||||
## Completion Checklist
|
||||
|
||||
Before marking a ✔️ box, confirm:
|
||||
|
||||
- The ✅ box for this item is already checked by the implementation agent.
|
||||
- All preceding phase items have both ✅ and ✔️ checked.
|
||||
- The implementation matches the intent in `FEATURES_PLAN.md`.
|
||||
- Tests exist, are meaningful, and pass.
|
||||
- Build and type checks pass.
|
||||
- No quality issues from the criteria above remain unresolved.
|
||||
- Any limitations or known gaps are reported clearly to the user.
|
||||
@@ -11,7 +11,7 @@ const nextConfig: NextConfig = {
|
||||
};
|
||||
|
||||
// Validate environment variables at build time
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.SKIP_ENV_VALIDATION) {
|
||||
if (!process.env.CORS_ALLOWED_ORIGINS) {
|
||||
throw new Error('Missing CORS_ALLOWED_ORIGINS environment variable in production');
|
||||
}
|
||||
|
||||
Generated
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":4159.340512000001,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":95.16407600000002,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":59.938895999999886,"failed":true}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":12.873146000000133,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2238.9023720000005,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":44.105199000000084,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":98.67802000000006,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":16.264838999999938,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":63.190515000000005,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":23.48364700000002,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":36.42245700000012,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":8.38964400000009,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":280.1331460000001,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":155.24353599999995,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":20.66457299999979,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":17.434574999999995,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":2.918452000000002,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":231.128872,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":99.0487290000001,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":3.0585660000000416,"failed":false}]]}
|
||||
{"version":"4.1.5","results":[[":src/lib/__tests__/hafas-client.test.ts",{"duration":4073.7258190000002,"failed":false}],[":src/app/event/__tests__/EventCard.test.tsx",{"duration":95.46945000000005,"failed":false}],[":src/lib/__tests__/hafas-time.test.ts",{"duration":49.78008,"failed":false}],[":src/lib/__tests__/calendar-utils.test.ts",{"duration":11.022554000000014,"failed":false}],[":src/lib/__tests__/geocoding-client.test.ts",{"duration":2122.026867,"failed":false}],[":src/hooks/__tests__/useReminder.test.tsx",{"duration":65.458756,"failed":false}],[":src/hooks/__tests__/useWienerLinien.test.ts",{"duration":65.25084599999991,"failed":false}],[":src/lib/__tests__/wienerlinien-client.test.ts",{"duration":11.561225000000036,"failed":false}],[":src/lib/__tests__/api-service.test.ts",{"duration":59.768212000000176,"failed":false}],[":src/app/api/wienerlinien/monitor/__tests__/route.test.ts",{"duration":28.255799000000025,"failed":false}],[":src/app/api/wienerlinien/stops/__tests__/route.test.ts",{"duration":36.11284100000012,"failed":false}],[":src/__tests__/middleware.test.ts",{"duration":6.664823000000069,"failed":false}],[":src/hooks/__tests__/useJourneys.test.ts",{"duration":218.19959199999994,"failed":false}],[":src/app/event/__tests__/WienerLinienSection.test.tsx",{"duration":75.173135,"failed":false}],[":src/app/api/__tests__/geocode.test.ts",{"duration":27.597884000000022,"failed":false}],[":src/app/api/__tests__/bike-route.test.ts",{"duration":18.47304299999996,"failed":false}],[":src/lib/__tests__/countdown-utils.test.ts",{"duration":4.763263000000052,"failed":false}],[":src/hooks/__tests__/useBikeRoute.test.ts",{"duration":194.54431,"failed":false}],[":src/app/calendar/__tests__/CalendarView.test.tsx",{"duration":146.23636399999987,"failed":false}],[":src/lib/__tests__/constants.test.ts",{"duration":5.533337999999958,"failed":false}],[":src/app/api/__tests__/walk-route.test.ts",{"duration":26.01176600000008,"failed":false}],[":src/hooks/__tests__/useDepartureTime.test.ts",{"duration":35.07896900000014,"failed":false}],[":src/app/event/__tests__/JourneyList.test.tsx",{"duration":72.39975799999979,"failed":false}],[":src/app/event/__tests__/TrainSection.test.tsx",{"duration":130.17408799999998,"failed":false}]]}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 554 KiB |
@@ -80,15 +80,16 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-xl max-w-md w-full border border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<div className={`brand-panel w-full max-w-md rounded-2xl ${className}`}>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA] mb-6">Add Manual Event</h3>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.24em] text-[#D946EF]">Manual event</p>
|
||||
<h3 className="mb-6 text-2xl font-bold text-white">Add a departure target</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="event-title" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
|
||||
<label htmlFor="event-title" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
|
||||
Event Title
|
||||
</label>
|
||||
<input
|
||||
@@ -97,14 +98,14 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Team Meeting"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
|
||||
className="brand-input px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
htmlFor="event-destination"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1"
|
||||
className="mb-1 block text-sm font-medium text-[#F4F1EA]/76"
|
||||
>
|
||||
Destination
|
||||
</label>
|
||||
@@ -114,13 +115,13 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
value={destination}
|
||||
onChange={(e) => setDestination(e.target.value)}
|
||||
placeholder="Vienna Main Station"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
|
||||
className="brand-input px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="event-date" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
|
||||
<label htmlFor="event-date" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
|
||||
Date
|
||||
</label>
|
||||
<input
|
||||
@@ -128,12 +129,12 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
type="date"
|
||||
value={eventDate}
|
||||
onChange={(e) => setEventDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
|
||||
className="brand-input px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="event-time" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
|
||||
<label htmlFor="event-time" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
|
||||
Time
|
||||
</label>
|
||||
<input
|
||||
@@ -141,7 +142,7 @@ const AddEventModal: React.FC<AddEventModalProps> = ({ isOpen, onClose, classNam
|
||||
type="time"
|
||||
value={eventTime}
|
||||
onChange={(e) => setEventTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
|
||||
className="brand-input px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mockGetWalkRoute = vi.fn();
|
||||
|
||||
vi.mock("@/lib/walk-routing-client", () => ({
|
||||
WalkRoutingClient: class MockWalkRoutingClient {
|
||||
getWalkRoute = mockGetWalkRoute;
|
||||
},
|
||||
}));
|
||||
|
||||
const { GET } = await import("../walk-route/route");
|
||||
|
||||
describe("api/walk-route/route", () => {
|
||||
beforeEach(() => {
|
||||
mockGetWalkRoute.mockReset();
|
||||
});
|
||||
|
||||
it("should return error when no required parameters are provided", async () => {
|
||||
const request = new NextRequest("http://localhost/api/walk-route");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const data = await response.json();
|
||||
expect(data).toEqual({ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" });
|
||||
});
|
||||
|
||||
it("should handle valid walk route request", async () => {
|
||||
mockGetWalkRoute.mockResolvedValue({
|
||||
distance: 800,
|
||||
duration: 600,
|
||||
steps: [
|
||||
{ name: "Start", distance: 50, duration: 40, instruction: "Head north" },
|
||||
{ name: "Main St", distance: 150, duration: 120, instruction: "Turn right" },
|
||||
],
|
||||
});
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/walk-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const data = await response.json();
|
||||
expect(data).toHaveProperty("distance");
|
||||
expect(data).toHaveProperty("duration");
|
||||
expect(data).toHaveProperty("steps");
|
||||
});
|
||||
|
||||
it("should handle client error", async () => {
|
||||
mockGetWalkRoute.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/walk-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
const data = await response.json();
|
||||
expect(data.error).toBe("Internal server error");
|
||||
expect(data.correlationId).toHaveLength(8);
|
||||
});
|
||||
|
||||
it("should handle no route found", async () => {
|
||||
mockGetWalkRoute.mockResolvedValue(null);
|
||||
|
||||
const request = new NextRequest(
|
||||
"http://localhost/api/walk-route?fromLat=48.2082&fromLng=16.3738&toLat=48.2100&toLng=16.3800",
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
const data = await response.json();
|
||||
expect(data).toEqual({ error: "No route found" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { WalkRoutingClient } from "@/lib/walk-routing-client";
|
||||
|
||||
// Module-level singleton — cache persists across requests
|
||||
const client = new WalkRoutingClient();
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const fromLat = parseFloat(searchParams.get("fromLat") ?? "");
|
||||
const fromLng = parseFloat(searchParams.get("fromLng") ?? "");
|
||||
const toLat = parseFloat(searchParams.get("toLat") ?? "");
|
||||
const toLng = parseFloat(searchParams.get("toLng") ?? "");
|
||||
|
||||
if (isNaN(fromLat) || isNaN(fromLng) || isNaN(toLat) || isNaN(toLng)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Missing required parameters (fromLat, fromLng, toLat, toLng)" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const route = await client.getWalkRoute(fromLat, fromLng, toLat, toLng);
|
||||
|
||||
if (!route) {
|
||||
return NextResponse.json({ error: "No route found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(route);
|
||||
} catch (error) {
|
||||
const corrId = randomUUID().slice(0, 8);
|
||||
console.error(`[${corrId}] Walk route API error:`, error);
|
||||
return NextResponse.json({ error: "Internal server error", correlationId: corrId }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -31,24 +31,24 @@ const CalendarPanel: React.FC<CalendarPanelProps> = ({ className = "" }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden border border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-white/10">
|
||||
<div className={`brand-panel overflow-hidden rounded-2xl ${className}`}>
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Import Calendar</h3>
|
||||
<h3 className="text-lg font-semibold text-white">Import Calendar</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="border-b border-gray-200 dark:border-white/10 mb-4">
|
||||
<nav className="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<div className="mb-4">
|
||||
<nav className="inline-flex rounded-full border border-white/10 bg-black/20 p-1" aria-label="Tabs">
|
||||
<button
|
||||
className={`pb-2 px-1 border-b-2 font-medium text-sm transition-colors ${activeTab === "url" ? "border-[#B23CFF] text-[#B23CFF]" : "border-transparent text-gray-500 hover:text-[#D946EF] hover:border-gray-300"}`}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${activeTab === "url" ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" : "text-[#F4F1EA]/58 hover:text-white"}`}
|
||||
onClick={() => setActiveTab("url")}
|
||||
disabled={loading}
|
||||
>
|
||||
URL
|
||||
</button>
|
||||
<button
|
||||
className={`pb-2 px-1 border-b-2 font-medium text-sm transition-colors ${activeTab === "file" ? "border-[#B23CFF] text-[#B23CFF]" : "border-transparent text-gray-500 hover:text-[#D946EF] hover:border-gray-300"}`}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold transition-colors ${activeTab === "file" ? "bg-[#B23CFF] text-white shadow-[0_8px_22px_rgba(178,60,255,0.32)]" : "text-[#F4F1EA]/58 hover:text-white"}`}
|
||||
onClick={() => setActiveTab("file")}
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
@@ -30,19 +30,19 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
|
||||
const renderHeader = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1))}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.05] text-[#F4F1EA]/76 transition-colors hover:border-[#D946EF]/45 hover:text-white"
|
||||
>
|
||||
←
|
||||
<
|
||||
</button>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-[#F4F1EA]">{format(currentDate, "MMMM yyyy")}</h2>
|
||||
<h2 className="text-xl font-semibold text-white">{format(currentDate, "MMMM yyyy")}</h2>
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() + 1, 1))}
|
||||
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-white/10 transition-colors"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.05] text-[#F4F1EA]/76 transition-colors hover:border-[#D946EF]/45 hover:text-white"
|
||||
>
|
||||
→
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -54,7 +54,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
headers.push(
|
||||
<div key={i} className="text-center font-medium text-gray-600 dark:text-[#F4F1EA]/80 py-2">
|
||||
<div key={i} className="py-2 text-center text-xs font-semibold uppercase tracking-[0.16em] text-[#F4F1EA]/46">
|
||||
{daysOfWeek[i]}
|
||||
</div>,
|
||||
);
|
||||
@@ -87,16 +87,16 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className={`
|
||||
min-h-24 p-2 border border-gray-200 dark:border-white/10 rounded-lg cursor-pointer
|
||||
${!isCurrentMonth ? "bg-gray-50 dark:bg-[#0F0E1A] text-gray-400 dark:text-[#F4F1EA]/40" : "bg-white dark:bg-[#17112A]"}
|
||||
${isTodayDate ? "ring-2 ring-[#B23CFF]" : ""}
|
||||
hover:bg-gray-50 dark:hover:bg-white/5 transition-colors
|
||||
min-h-24 cursor-pointer rounded-xl border p-2
|
||||
${!isCurrentMonth ? "border-white/[0.06] bg-black/18 text-[#F4F1EA]/28" : "border-white/10 bg-white/[0.045] text-[#F4F1EA]"}
|
||||
${isTodayDate ? "ring-2 ring-[#D946EF]/80" : ""}
|
||||
transition-colors hover:border-[#D946EF]/42 hover:bg-white/[0.075]
|
||||
`}
|
||||
>
|
||||
<div className="text-right">
|
||||
<span
|
||||
className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-sm ${
|
||||
isTodayDate ? "bg-[#B23CFF] text-white" : ""
|
||||
isTodayDate ? "bg-gradient-to-br from-[#8B5CF6] to-[#FF2D8D] text-white" : ""
|
||||
}`}
|
||||
>
|
||||
{format(day, "d")}
|
||||
@@ -106,13 +106,13 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
{dayEvents.slice(0, 3).map((event) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className="text-xs truncate bg-[#B23CFF]/10 dark:bg-[#B23CFF]/20 text-[#B23CFF] dark:text-[#D946EF] px-2 py-1 rounded"
|
||||
className="truncate rounded bg-[#B23CFF]/22 px-2 py-1 text-xs font-medium text-[#F4F1EA]"
|
||||
>
|
||||
{event.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div className="text-xs text-gray-500 dark:text-[#F4F1EA]/50">+{dayEvents.length - 3} more</div>
|
||||
<div className="text-xs text-[#F4F1EA]/50">+{dayEvents.length - 3} more</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
@@ -133,7 +133,7 @@ const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selec
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm p-4 border border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<div className={`brand-panel rounded-2xl p-4 ${className}`}>
|
||||
{renderHeader()}
|
||||
{renderDays()}
|
||||
{renderCells()}
|
||||
|
||||
@@ -24,7 +24,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, clas
|
||||
|
||||
if (filteredEvents.length === 0) {
|
||||
return (
|
||||
<div className={`text-center py-8 text-gray-500 dark:text-[#F4F1EA]/60 ${className}`}>
|
||||
<div className={`brand-panel rounded-2xl px-4 py-8 text-center text-[#F4F1EA]/60 ${className}`}>
|
||||
<p>No events scheduled for {format(date, "MMMM d, yyyy")}</p>
|
||||
</div>
|
||||
);
|
||||
@@ -32,7 +32,7 @@ const DayEvents: React.FC<DayEventsProps> = ({ events, date, originStation, clas
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
<h3 className="text-lg font-medium text-gray-900 dark:text-[#F4F1EA]">Events for {format(date, "MMMM d, yyyy")}</h3>
|
||||
<h3 className="text-lg font-semibold text-white">Events for {format(date, "MMMM d, yyyy")}</h3>
|
||||
<div className="space-y-3">
|
||||
{filteredEvents.map((event) => (
|
||||
<EventCard key={event.id} event={event} originStation={originStation} />
|
||||
|
||||
@@ -62,9 +62,9 @@ const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, class
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className={`p-1 ${className}`}>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors ${dragging ? "border-[#B23CFF] bg-[#B23CFF]/5" : "border-gray-300 dark:border-white/20"}`}
|
||||
className={`mb-4 cursor-pointer rounded-2xl border border-dashed p-8 text-center text-sm transition-colors ${dragging ? "border-[#D946EF] bg-[#B23CFF]/12 text-white" : "border-white/20 bg-white/[0.04] text-[#F4F1EA]/68 hover:border-[#D946EF]/50 hover:bg-white/[0.06]"}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -74,7 +74,7 @@ const FileTab: React.FC<FileTabProps> = ({ onLoadCalendar, loading, error, class
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileSelect} accept=".ics" className="hidden" />
|
||||
{loading ? <>Loading calendar...</> : <>Click to upload or drag and drop an .ics file here</>}
|
||||
</div>
|
||||
{error && <div className="mt-4 text-[#FF2D8D] text-sm">{error}</div>}
|
||||
{error && <div className="mb-4 text-sm text-[#FF2D8D]">{error}</div>}
|
||||
<Button onClick={() => fileInputRef.current?.click()} disabled={loading}>
|
||||
{loading ? <>Select File</> : <>Select File</>}
|
||||
</Button>
|
||||
|
||||
@@ -21,10 +21,10 @@ const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, classNa
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-4 ${className}`}>
|
||||
<div className={`p-1 ${className}`}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="calendar-url" className="block text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80 mb-1">
|
||||
<label htmlFor="calendar-url" className="mb-1 block text-sm font-medium text-[#F4F1EA]/76">
|
||||
Calendar URL
|
||||
</label>
|
||||
<input
|
||||
@@ -33,7 +33,7 @@ const UrlTab: React.FC<UrlTabProps> = ({ onLoadCalendar, loading, error, classNa
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/calendar.ics"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-[#B23CFF] focus:border-[#B23CFF] dark:bg-white/5 dark:border-white/10 dark:text-[#F4F1EA]"
|
||||
className="brand-input px-3 py-2"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,10 +13,11 @@ export default function CalendarPage() {
|
||||
const [selectedDate, setSelectedDate] = React.useState<Date>(new Date());
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-[#F4F1EA]">Calendar</h1>
|
||||
<p className="text-gray-600 dark:text-[#F4F1EA]/80">View and manage your events</p>
|
||||
<div className="mx-auto max-w-6xl p-4 sm:p-6 lg:p-8">
|
||||
<div className="mb-6 rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.24)] backdrop-blur-xl">
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-[#D946EF]">Calendar sync</p>
|
||||
<h1 className="text-3xl font-extrabold text-white sm:text-4xl">Import, inspect, and time your day.</h1>
|
||||
<p className="mt-2 text-[#F4F1EA]/66">View and manage every appointment from a single departure-focused calendar.</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
|
||||
@@ -19,11 +19,11 @@ const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeE
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden mt-4 border border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-white/10">
|
||||
<div className={`mt-4 overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Bicycle Route</h3>
|
||||
<h3 className="text-lg font-semibold text-white">Bicycle Route</h3>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||
@@ -42,25 +42,25 @@ const BikeSection: React.FC<BikeSectionProps> = ({ bikeRoute, bikeLoading, bikeE
|
||||
) : bikeRoute ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-[#F4F1EA]/80">Distance</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-[#F4F1EA]/66">Distance</span>
|
||||
<span className="font-medium text-white">
|
||||
{Math.round(bikeRoute.distance / 1000)} km ({Math.round(bikeRoute.distance)} m)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-[#F4F1EA]/80">Duration</span>
|
||||
<span className="font-medium">
|
||||
<span className="text-[#F4F1EA]/66">Duration</span>
|
||||
<span className="font-medium text-white">
|
||||
{Math.floor(bikeRoute.duration / 60)} min {bikeRoute.duration % 60} s
|
||||
</span>
|
||||
</div>
|
||||
{bikeRoute.steps && bikeRoute.steps.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="font-medium text-gray-800 dark:text-[#F4F1EA] mb-2">Steps</h4>
|
||||
<h4 className="mb-2 font-medium text-[#F4F1EA]">Steps</h4>
|
||||
<ul className="space-y-2">
|
||||
{bikeRoute.steps.map((step, index) => (
|
||||
<li key={index} className="text-sm">
|
||||
<span className="font-medium text-[#B23CFF] dark:text-[#D946EF]">{step.name}</span>
|
||||
<span className="ml-2 text-gray-600 dark:text-[#F4F1EA]/80">{step.instruction}</span>
|
||||
<span className="font-medium text-[#D946EF]">{step.name}</span>
|
||||
<span className="ml-2 text-[#F4F1EA]/70">{step.instruction}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { useJourneys } from "@/hooks/useJourneys";
|
||||
import { useDestinationStation } from "@/hooks/useDestinationStation";
|
||||
import { useBikeRoute } from "@/hooks/useBikeRoute";
|
||||
import { useWalkRoute } from "@/hooks/useWalkRoute";
|
||||
import { useGeocode } from "@/hooks/useGeocode";
|
||||
import { useClock } from "@/hooks/useClock";
|
||||
import { useDepartureTime } from "@/hooks/useDepartureTime";
|
||||
import { useReminderSettings } from "@/hooks/useReminderSettings";
|
||||
import { useWienerLinien } from "@/hooks/useWienerLinien";
|
||||
import type { Event, Station } from "@timetoleave/core";
|
||||
import TrainSection from "./TrainSection";
|
||||
@@ -35,6 +39,17 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
error: bikeError,
|
||||
} = useBikeRoute(originStation?.lat, originStation?.lng, destCoords.coords?.lat, destCoords.coords?.lng);
|
||||
|
||||
const {
|
||||
walkRoute,
|
||||
loading: walkLoading,
|
||||
error: walkError,
|
||||
} = useWalkRoute(
|
||||
destStation.station?.lat,
|
||||
destStation.station?.lng,
|
||||
destCoords.coords?.lat,
|
||||
destCoords.coords?.lng,
|
||||
);
|
||||
|
||||
const {
|
||||
stops,
|
||||
departures,
|
||||
@@ -42,32 +57,78 @@ export default function EventCard({ event, originStation }: EventCardProps) {
|
||||
error: wlError,
|
||||
} = useWienerLinien(destCoords.coords?.lat, destCoords.coords?.lng);
|
||||
|
||||
const { countdown, status } = useClock(event.eventTime);
|
||||
const { showWalkingOption, showBikeOption, arrivalBufferMinutes } = useReminderSettings();
|
||||
|
||||
type TransportMode = "train" | "bike";
|
||||
const [activeMode, setActiveMode] = useState<TransportMode>("train");
|
||||
|
||||
const { departureTime } = useDepartureTime(event.eventTime, journeys, bikeRoute?.duration || null, activeMode);
|
||||
|
||||
const { countdown, status } = useClock(event.eventTime, departureTime);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-[#17112A]">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">{event.title}</h3>
|
||||
<div className="brand-panel overflow-hidden rounded-2xl p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-semibold uppercase tracking-\[0.24em\] text-\[#D946EF\]">Next stop</p>
|
||||
<h3 className="text-2xl font-bold text-white">{event.title}</h3>
|
||||
</div>
|
||||
<CountdownBadge countdown={countdown} status={status} />
|
||||
</div>
|
||||
|
||||
<div className="mb-2 text-sm text-gray-600 dark:text-[#F4F1EA]/80">
|
||||
<span className="font-medium">Destination:</span> {event.destination}
|
||||
<div className="mb-5 grid gap-3 sm:grid-cols-2">
|
||||
<div className="brand-panel-soft rounded-xl p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-\[0.18em\] text-\[#F4F1EA\]/42">Destination</p>
|
||||
<p className="mt-1 text-sm font-medium text-\[#F4F1EA\]">{event.destination}</p>
|
||||
</div>
|
||||
<div className="brand-panel-soft rounded-xl p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-\[0.18em\] text-\[#F4F1EA\]/42">Appointment</p>
|
||||
<p className="mt-1 text-sm font-medium text-\[#F4F1EA\]">{format(event.eventTime, "EEE dd MMM yyyy HH:mm")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-[#F4F1EA]/80">
|
||||
<span className="font-medium">Time:</span> {format(event.eventTime, "EEE dd MMM yyyy HH:mm")}
|
||||
|
||||
<div className="mb-4 flex gap-2">
|
||||
<button
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
activeMode === "train"
|
||||
? "bg-\[#B23CFF\] text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveMode("train")}
|
||||
>
|
||||
Train
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded text-sm font-medium transition-colors ${
|
||||
activeMode === "bike"
|
||||
? "bg-\[#B23CFF\] text-white"
|
||||
: "bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
}`}
|
||||
onClick={() => setActiveMode("bike")}
|
||||
>
|
||||
Bike
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<TrainSection
|
||||
journeys={journeys}
|
||||
eventTime={event.eventTime}
|
||||
destName={event.destination}
|
||||
loading={journeysLoading}
|
||||
error={journeysError}
|
||||
/>
|
||||
{activeMode === "train" && (
|
||||
<TrainSection
|
||||
journeys={journeys}
|
||||
eventTime={event.eventTime}
|
||||
destName={event.destination}
|
||||
loading={journeysLoading}
|
||||
error={journeysError}
|
||||
arrivalBufferMinutes={arrivalBufferMinutes}
|
||||
showWalkingOption={showWalkingOption}
|
||||
walkRoute={walkRoute}
|
||||
walkLoading={walkLoading}
|
||||
walkError={walkError}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} />
|
||||
{showBikeOption && activeMode === "bike" && (
|
||||
<BikeSection bikeRoute={bikeRoute} bikeLoading={bikeLoading} bikeError={bikeError} />
|
||||
)}
|
||||
|
||||
{stops.length > 0 && (
|
||||
<WienerLinienSection stops={stops} departures={departures} loading={wlLoading} error={wlError} />
|
||||
|
||||
@@ -9,40 +9,52 @@ import { calculateCountdown } from "@timetoleave/core";
|
||||
type JourneyListProps = {
|
||||
journeys: Journey[];
|
||||
eventTime: Date;
|
||||
arrivalBufferMinutes: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const JourneyList: React.FC<JourneyListProps> = ({ journeys, eventTime, className = "" }) => {
|
||||
const JourneyList: React.FC<JourneyListProps> = ({
|
||||
journeys,
|
||||
eventTime,
|
||||
arrivalBufferMinutes,
|
||||
className = ""
|
||||
}) => {
|
||||
const targetArrivalTime = new Date(eventTime.getTime() - arrivalBufferMinutes * 60000);
|
||||
|
||||
if (journeys.length === 0) {
|
||||
return <div className={`text-center py-8 text-gray-500 dark:text-[#F4F1EA]/60 ${className}`}>No journeys found</div>;
|
||||
return <div className={`py-8 text-center text-[#F4F1EA]/60 ${className}`}>No journeys found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={`space-y-3 ${className}`}>
|
||||
{journeys.map((journey) => (
|
||||
<li key={journey.id} className="border-b border-gray-200 dark:border-white/10 pb-3 last:border-b-0 last:pb-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{formatTime(journey.sD)}</span>
|
||||
<span className="ml-2 text-sm text-gray-500">{journey.platform}</span>
|
||||
{journey.delay > 0 && (
|
||||
<span className="ml-2 text-sm font-medium text-orange-600">{`+${journey.delay}'`}</span>
|
||||
)}
|
||||
<ul className={`space-y-3 p-4 ${className}`}>
|
||||
{journeys.map((journey) => {
|
||||
const arrivesTooLate = journey.rA.getTime() > targetArrivalTime.getTime();
|
||||
|
||||
return (
|
||||
<li key={journey.id} className={`rounded-xl border border-white/10 bg-white/[0.04] p-3 ${arrivesTooLate ? "opacity-40 line-through" : ""}`}>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<span className="font-semibold text-white">{formatTime(journey.sD)}</span>
|
||||
<span className="ml-2 text-sm text-[#F4F1EA]/48">{journey.platform}</span>
|
||||
{journey.delay > 0 && (
|
||||
<span className="ml-2 text-sm font-medium text-orange-200">{`+${journey.delay}'`}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<span className={`font-medium ${journey.cancelled ? "text-[#FF2D8D]" : "text-[#F4F1EA]"}`}>
|
||||
{journey.cancelled ? "Cancelled" : journey.trains.join(" -> ")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<LeaveByBadge countdown={calculateCountdown(journey.sD)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<span className={`font-medium ${journey.cancelled ? "text-[#FF2D8D]" : "text-gray-800 dark:text-[#F4F1EA]"}`}>
|
||||
{journey.cancelled ? "Cancelled" : journey.trains.join(" → ")}
|
||||
</span>
|
||||
<div className="text-sm text-[#F4F1EA]/64">
|
||||
{journey.changes > 0 ? <span>Change(s): {journey.changes}</span> : <span>Direct</span>}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<LeaveByBadge countdown={calculateCountdown(eventTime)} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 dark:text-[#F4F1EA]/80">
|
||||
{journey.changes > 0 ? <span>Change(s): {journey.changes}</span> : <span>Direct</span>}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,11 +5,11 @@ import { CountdownInfo } from "@timetoleave/core";
|
||||
import Chip from "@/app/ui/Chip";
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
red: "text-[#FF2D8D]",
|
||||
orange: "text-orange-600",
|
||||
yellow: "text-yellow-600",
|
||||
green: "text-green-600",
|
||||
blue: "text-[#B23CFF]",
|
||||
red: "border-[#FF2D8D]/40 bg-[#FF2D8D]/18 text-pink-100",
|
||||
orange: "border-orange-300/30 bg-orange-400/16 text-orange-100",
|
||||
yellow: "border-yellow-300/30 bg-yellow-300/16 text-yellow-100",
|
||||
green: "border-emerald-300/30 bg-emerald-400/16 text-emerald-100",
|
||||
blue: "border-[#D946EF]/30 bg-[#B23CFF]/18 text-[#F4F1EA]",
|
||||
};
|
||||
|
||||
type LeaveByBadgeProps = {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from "react";
|
||||
import { Journey } from "@timetoleave/core";
|
||||
import { formatDateTime } from "@timetoleave/core";
|
||||
import WalkingOption from "./WalkingOption";
|
||||
import JourneyList from "./JourneyList";
|
||||
import LoadingSpinner from "@/app/ui/LoadingSpinner";
|
||||
import Button from "@/app/ui/Button";
|
||||
@@ -15,6 +16,11 @@ type TrainSectionProps = {
|
||||
error?: string | null;
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
arrivalBufferMinutes?: number;
|
||||
showWalkingOption?: boolean;
|
||||
walkRoute?: import("@timetoleave/core").WalkRoute | null;
|
||||
walkLoading?: boolean;
|
||||
walkError?: string | null;
|
||||
};
|
||||
|
||||
const TrainSection: React.FC<TrainSectionProps> = ({
|
||||
@@ -25,14 +31,19 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
||||
error,
|
||||
onRefresh,
|
||||
className = "",
|
||||
arrivalBufferMinutes,
|
||||
showWalkingOption,
|
||||
walkRoute,
|
||||
walkLoading,
|
||||
walkError,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`bg-white dark:bg-[#17112A] rounded-lg shadow-sm overflow-hidden border border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<div className="p-4 border-b border-gray-200 dark:border-white/10">
|
||||
<div className={`overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-[#F4F1EA]">Trains</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-[#F4F1EA]/80">
|
||||
<h3 className="text-lg font-semibold text-white">Trains</h3>
|
||||
<p className="text-sm text-[#F4F1EA]/66">
|
||||
To {destName} <span className="font-mono">{formatDateTime(eventTime)}</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -49,9 +60,16 @@ const TrainSection: React.FC<TrainSectionProps> = ({
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="p-8 text-center text-[#FF2D8D] dark:text-[#FF2D8D] text-sm">{error}</div>
|
||||
<div className="p-8 text-center text-sm text-[#FF2D8D]">{error}</div>
|
||||
) : (
|
||||
<JourneyList journeys={journeys} eventTime={eventTime} />
|
||||
<>
|
||||
<JourneyList journeys={journeys} eventTime={eventTime} arrivalBufferMinutes={arrivalBufferMinutes ?? 0} />
|
||||
{showWalkingOption && walkRoute && (
|
||||
<div className="p-4 border-t border-gray-200 dark:border-white/10">
|
||||
<WalkingOption walkRoute={walkRoute} walkLoading={walkLoading} walkError={walkError} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { WalkRoute } from "@timetoleave/core";
|
||||
import LoadingSpinner from "@/app/ui/LoadingSpinner";
|
||||
import Button from "@/app/ui/Button";
|
||||
|
||||
type WalkingOptionProps = {
|
||||
walkRoute: WalkRoute | null | undefined;
|
||||
walkLoading?: boolean;
|
||||
walkError?: string | null | undefined;
|
||||
onRefresh?: () => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const WalkingOption: React.FC<WalkingOptionProps> = ({ walkRoute, walkLoading, walkError, onRefresh, className = "" }) => {
|
||||
if (!walkRoute && !walkLoading && !walkError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`mt-4 overflow-hidden rounded-xl border border-white/10 bg-black/16 ${className}`}>
|
||||
<div className="border-b border-white/10 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">Walking Option</h3>
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<Button variant="secondary" size="sm" onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
{walkLoading ? (
|
||||
<div className="text-center py-4">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
) : walkError ? (
|
||||
<div className="text-center py-4 text-[#FF2D8D]">Error: {walkError}</div>
|
||||
) : walkRoute ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#F4F1EA]/66">Distance</span>
|
||||
<span className="font-medium text-white">
|
||||
{Math.round(walkRoute.distance / 1000)} km ({Math.round(walkRoute.distance)} m)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[#F4F1EA]/66">Duration</span>
|
||||
<span className="font-medium text-white">
|
||||
{Math.floor(walkRoute.duration / 60)} min {walkRoute.duration % 60} s
|
||||
</span>
|
||||
</div>
|
||||
{walkRoute.steps && walkRoute.steps.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h4 className="mb-2 font-medium text-\[#F4F1EA\]">Steps</h4>
|
||||
<ul className="space-y-2">
|
||||
{walkRoute.steps.map((step: { name: string; instruction: string }, index: number) => (
|
||||
<li key={index} className="text-sm">
|
||||
<span className="font-medium text-\[#D946EF\]">{step.name}</span>
|
||||
<span className="ml-2 text-\[#F4F1EA\]/70">{step.instruction}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WalkingOption;
|
||||
@@ -30,7 +30,7 @@ export default function WienerLinienSection({
|
||||
return (
|
||||
<div className={className}>
|
||||
<LoadingSpinner />
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-[#F4F1EA]/60">Loading nearby stops...</p>
|
||||
<p className="mt-2 text-sm text-[#F4F1EA]/60">Loading nearby stops...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export default function WienerLinienSection({
|
||||
if (error) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-[#FF2D8D] dark:text-[#FF2D8D]">{error}</p>
|
||||
<p className="text-sm text-[#FF2D8D]">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ export default function WienerLinienSection({
|
||||
if (stops.length === 0) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<p className="text-sm text-gray-500 dark:text-[#F4F1EA]/60">No nearby stops found.</p>
|
||||
<p className="text-sm text-[#F4F1EA]/60">No nearby stops found.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,18 +65,18 @@ export default function WienerLinienSection({
|
||||
const stopDepartures = departuresByStop.get(stop.id) ?? [];
|
||||
|
||||
return (
|
||||
<div key={stop.id} className="mb-4 last:mb-0">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-[#F4F1EA]">{stop.name}</h3>
|
||||
<div key={stop.id} className="mb-4 rounded-xl border border-white/10 bg-black/16 p-4 last:mb-0">
|
||||
<h3 className="text-sm font-semibold text-white">{stop.name}</h3>
|
||||
|
||||
{stopDepartures.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-[#F4F1EA]/40">No departures available</p>
|
||||
<p className="mt-1 text-xs text-[#F4F1EA]/40">No departures available</p>
|
||||
) : (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{stopDepartures.map((departure, index) => (
|
||||
<li key={`${departure.lineName}-${departure.direction}-${index}`} className="flex items-center gap-2">
|
||||
<Chip>{departure.lineName}</Chip>
|
||||
<span className="text-sm text-gray-600 dark:text-[#F4F1EA]/80">{departure.direction}</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-[#F4F1EA]">
|
||||
<span className="text-sm text-[#F4F1EA]/70">{departure.direction}</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{departure.minutes} min
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -48,6 +48,46 @@ vi.mock("@/hooks/useBikeRoute", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useWalkRoute", () => ({
|
||||
useWalkRoute: () => ({
|
||||
walkRoute: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useDepartureTime", () => ({
|
||||
useDepartureTime: () => ({
|
||||
departureTime: null,
|
||||
arrivalTime: null,
|
||||
mode: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useReminderSettings", () => ({
|
||||
useReminderSettings: () => ({
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
bufferMinutes: 15,
|
||||
enabled: true,
|
||||
setArrivalBufferMinutes: () => {},
|
||||
setShowWalkingOption: () => {},
|
||||
setShowBikeOption: () => {},
|
||||
setBufferMinutes: () => {},
|
||||
setEnabled: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useWienerLinien", () => ({
|
||||
useWienerLinien: () => ({
|
||||
stops: [],
|
||||
departures: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useClock", () => ({
|
||||
useClock: () => ({
|
||||
countdown: { label: "No deadline set", color: "text-gray-400", urgent: false },
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
// Test to verify JourneyList uses arrivalBufferMinutes correctly
|
||||
import "@testing-library/jest-dom";
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import JourneyList from "@/app/event/JourneyList";
|
||||
import { Journey } from "@timetoleave/core";
|
||||
|
||||
describe("JourneyList component", () => {
|
||||
it("should render journey countdowns based on journey departure times", () => {
|
||||
const mockJourneys: Journey[] = [
|
||||
{
|
||||
id: "1",
|
||||
sD: new Date("2025-01-01T14:00:00Z"),
|
||||
rD: new Date("2025-01-01T14:00:00Z"),
|
||||
sA: new Date("2025-01-01T15:00:00Z"),
|
||||
rA: new Date("2025-01-01T15:00:00Z"),
|
||||
delay: 0,
|
||||
platform: "1",
|
||||
changes: 0,
|
||||
trains: ["R1"],
|
||||
cancelled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const eventTime = new Date("2025-01-01T16:00:00Z");
|
||||
|
||||
render(
|
||||
<JourneyList
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
arrivalBufferMinutes={5}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should show countdown to journey departure (14:00), not to event time (16:00)
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should accept arrivalBufferMinutes prop", () => {
|
||||
const mockJourneys: Journey[] = [
|
||||
{
|
||||
id: "1",
|
||||
sD: new Date("2025-01-01T14:00:00Z"),
|
||||
rD: new Date("2025-01-01T14:00:00Z"),
|
||||
sA: new Date("2025-01-01T15:00:00Z"),
|
||||
rA: new Date("2025-01-01T15:00:00Z"),
|
||||
delay: 0,
|
||||
platform: "1",
|
||||
changes: 0,
|
||||
trains: ["R1"],
|
||||
cancelled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const eventTime = new Date("2025-01-01T16:00:00Z");
|
||||
|
||||
// This should not throw an error
|
||||
render(
|
||||
<JourneyList
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
arrivalBufferMinutes={10}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should dim late journeys based on arrivalBufferMinutes", () => {
|
||||
const lateJourney: Journey = {
|
||||
id: "1",
|
||||
sD: new Date("2025-01-01T14:00:00Z"),
|
||||
rD: new Date("2025-01-01T14:00:00Z"),
|
||||
sA: new Date("2025-01-01T16:00:00Z"), // Arrives after event time (too late)
|
||||
rA: new Date("2025-01-01T16:00:00Z"),
|
||||
delay: 0,
|
||||
platform: "1",
|
||||
changes: 0,
|
||||
trains: ["R1"],
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const onTimeJourney: Journey = {
|
||||
id: "2",
|
||||
sD: new Date("2025-01-01T14:00:00Z"),
|
||||
rD: new Date("2025-01-01T14:00:00Z"),
|
||||
sA: new Date("2025-01-01T15:00:00Z"), // Arrives before event time (on time)
|
||||
rA: new Date("2025-01-01T15:00:00Z"),
|
||||
delay: 0,
|
||||
platform: "2",
|
||||
changes: 0,
|
||||
trains: ["R2"],
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const eventTime = new Date("2025-01-01T15:30:00Z");
|
||||
|
||||
render(
|
||||
<JourneyList
|
||||
journeys={[lateJourney, onTimeJourney]}
|
||||
eventTime={eventTime}
|
||||
arrivalBufferMinutes={15} // 15 minutes buffer
|
||||
/>
|
||||
);
|
||||
|
||||
// Late journey should be dimmed and have line-through
|
||||
const lateJourneyElement = screen.getByText("R1").closest("li");
|
||||
expect(lateJourneyElement).toHaveClass("opacity-40");
|
||||
expect(lateJourneyElement).toHaveClass("line-through");
|
||||
|
||||
// On-time journey should not be dimmed
|
||||
const onTimeJourneyElement = screen.getByText("R2").closest("li");
|
||||
expect(onTimeJourneyElement).not.toHaveClass("opacity-40");
|
||||
expect(onTimeJourneyElement).not.toHaveClass("line-through");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
// Test to verify TrainSection handles new logic (WalkingOption conditional, prop forwarding)
|
||||
import "@testing-library/jest-dom";
|
||||
import { vi, describe, it, expect } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import TrainSection from "@/app/event/TrainSection";
|
||||
import { Journey } from "@timetoleave/core";
|
||||
|
||||
// Mock WalkingOption component
|
||||
vi.mock("../WalkingOption", () => {
|
||||
const MockWalkingOption = vi.fn(() => <div data-testid="walking-option">Walking Option</div>);
|
||||
return { default: MockWalkingOption };
|
||||
});
|
||||
|
||||
describe("TrainSection component", () => {
|
||||
const mockJourneys: Journey[] = [
|
||||
{
|
||||
id: "1",
|
||||
sD: new Date("2025-01-01T14:00:00Z"),
|
||||
rD: new Date("2025-01-01T14:00:00Z"),
|
||||
sA: new Date("2025-01-01T15:00:00Z"),
|
||||
rA: new Date("2025-01-01T15:00:00Z"),
|
||||
delay: 0,
|
||||
platform: "1",
|
||||
changes: 0,
|
||||
trains: ["R1"],
|
||||
cancelled: false,
|
||||
},
|
||||
];
|
||||
|
||||
const eventTime = new Date("2025-01-01T16:00:00Z");
|
||||
|
||||
it("should render trains heading section with destination name and event time", () => {
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
destName="Vienna Station"
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Trains")).toBeInTheDocument();
|
||||
expect(screen.getByText(/To Vienna Station/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render refresh button when onRefresh prop is provided", () => {
|
||||
const mockRefresh = vi.fn();
|
||||
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
destName="Test Station"
|
||||
loading={false}
|
||||
error={null}
|
||||
onRefresh={mockRefresh}
|
||||
/>
|
||||
);
|
||||
|
||||
const refreshButton = screen.getByText("Refresh");
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(refreshButton);
|
||||
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should render loading state when loading is true", () => {
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={[]}
|
||||
eventTime={eventTime}
|
||||
destName="Test Station"
|
||||
loading={true}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render error message when error is present", () => {
|
||||
const errorMessage = "Failed to load journeys";
|
||||
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={[]}
|
||||
eventTime={eventTime}
|
||||
destName="Test Station"
|
||||
loading={false}
|
||||
error={errorMessage}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render journeys when not loading and no error", () => {
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
destName="Test Station"
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass arrivalBufferMinutes prop to JourneyList", () => {
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
destName="Test Station"
|
||||
loading={false}
|
||||
error={null}
|
||||
arrivalBufferMinutes={10}
|
||||
/>
|
||||
);
|
||||
|
||||
// JourneyList should receive the arrivalBufferMinutes prop
|
||||
expect(screen.getByText("R1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should conditionally render WalkingOption when showWalkingOption is true and walkRoute is provided", () => {
|
||||
const mockWalkRoute = {
|
||||
distance: 1000,
|
||||
duration: 300,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
destName="Test Station"
|
||||
loading={false}
|
||||
error={null}
|
||||
showWalkingOption={true}
|
||||
walkRoute={mockWalkRoute}
|
||||
walkLoading={false}
|
||||
walkError={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("walking-option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render WalkingOption when showWalkingOption is false", () => {
|
||||
const mockWalkRoute = {
|
||||
distance: 1000,
|
||||
duration: 300,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
destName="Test Station"
|
||||
loading={false}
|
||||
error={null}
|
||||
showWalkingOption={false}
|
||||
walkRoute={mockWalkRoute}
|
||||
walkLoading={false}
|
||||
walkError={null}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("walking-option")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should pass walkLoading and walkError props to WalkingOption when showWalkingOption is true", () => {
|
||||
const mockWalkRoute = {
|
||||
distance: 1000,
|
||||
duration: 300,
|
||||
steps: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
destName="Test Station"
|
||||
loading={false}
|
||||
error={null}
|
||||
showWalkingOption={true}
|
||||
walkRoute={mockWalkRoute}
|
||||
walkLoading={true}
|
||||
walkError="Walk error"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("walking-option")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should apply custom className to container", () => {
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
destName="Test Station"
|
||||
loading={false}
|
||||
error={null}
|
||||
className="custom-class"
|
||||
/>
|
||||
);
|
||||
|
||||
const container = screen.getByText("Trains").closest(".custom-class");
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should use default className when none is provided", () => {
|
||||
render(
|
||||
<TrainSection
|
||||
journeys={mockJourneys}
|
||||
eventTime={eventTime}
|
||||
destName="Test Station"
|
||||
loading={false}
|
||||
error={null}
|
||||
/>
|
||||
);
|
||||
|
||||
const container = screen.getByText("Trains").closest("div");
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #FAFAF9;
|
||||
--background: #090816;
|
||||
--foreground: #1a1a2e;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,11 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(178, 60, 255, 0.28), transparent 32rem),
|
||||
radial-gradient(circle at 84% 18%, rgba(255, 45, 141, 0.16), transparent 24rem),
|
||||
linear-gradient(180deg, #090816 0%, #100d1e 48%, #080712 100%);
|
||||
color: var(--foreground);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
@@ -41,3 +45,56 @@ body {
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.brand-shell {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.brand-shell::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
background-image:
|
||||
linear-gradient(rgba(244, 241, 234, 0.035) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(244, 241, 234, 0.035) 1px, transparent 1px);
|
||||
background-size: 72px 72px;
|
||||
mask-image: linear-gradient(to bottom, black 0%, transparent 76%);
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
border: 1px solid rgba(244, 241, 234, 0.12);
|
||||
background:
|
||||
linear-gradient(145deg, rgba(23, 17, 42, 0.92), rgba(11, 10, 24, 0.9)),
|
||||
rgba(23, 17, 42, 0.88);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.brand-panel-soft {
|
||||
border: 1px solid rgba(178, 60, 255, 0.18);
|
||||
background: rgba(244, 241, 234, 0.045);
|
||||
}
|
||||
|
||||
.brand-input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(244, 241, 234, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #F4F1EA;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.brand-input::placeholder {
|
||||
color: rgba(244, 241, 234, 0.42);
|
||||
}
|
||||
|
||||
.brand-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(217, 70, 239, 0.74);
|
||||
box-shadow: 0 0 0 3px rgba(217, 70, 239, 0.16);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function RootLayout({
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" />
|
||||
</head>
|
||||
<body className="min-h-full flex flex-col bg-[#FAFAF9] dark:bg-[#090816] text-[#1a1a2e] dark:text-[#F4F1EA]">
|
||||
<body className="brand-shell min-h-full flex flex-col bg-[#090816] text-[#F4F1EA]">
|
||||
<ReminderSettingsProvider>
|
||||
<EventsProvider>
|
||||
<ReminderEngine />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useServerHealth } from "@/hooks/useServerHealth";
|
||||
import { useEventsStore } from "@/hooks/useEventsStore";
|
||||
import { useTheme } from "@/hooks/useTheme";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { LogoHorizontal } from "@/app/ui/logos";
|
||||
|
||||
type HeaderProps = {
|
||||
className?: string;
|
||||
@@ -22,31 +22,34 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
||||
const { dark, toggle, mounted } = useTheme();
|
||||
|
||||
return (
|
||||
<header className={`bg-white dark:bg-[#0F0E1A] border-b border-gray-200 dark:border-white/10 shadow-sm ${className}`}>
|
||||
<header className={`sticky top-0 z-40 border-b border-white/10 bg-[#090816]/82 shadow-[0_18px_60px_rgba(0,0,0,0.28)] backdrop-blur-xl ${className}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/timetoleave_logo_text_right.png"
|
||||
alt="TimeToLeave"
|
||||
width={192}
|
||||
height={48}
|
||||
className="object-cover h-12 w-auto"
|
||||
priority
|
||||
/>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex h-12 items-center rounded-lg text-[#F4F1EA] drop-shadow-[0_0_18px_rgba(178,60,255,0.28)] focus:outline-none focus:ring-2 focus:ring-[#D946EF]/70"
|
||||
aria-label="TimeToLeave home"
|
||||
>
|
||||
<LogoHorizontal height={42} className="block h-[42px] w-auto" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="hidden md:flex items-center text-sm text-gray-600 dark:text-[#F4F1EA]/80">
|
||||
<span>
|
||||
<strong>{events.length}</strong>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="hidden md:flex items-center rounded-full border border-white/10 bg-white/[0.06] px-3 py-1.5 text-sm text-[#F4F1EA]/76">
|
||||
<span className="font-semibold text-white">
|
||||
{events.length}
|
||||
</span>
|
||||
<span className="ml-1">events</span>
|
||||
{status === true ? (
|
||||
<span className="ml-2 text-green-500">Online</span>
|
||||
<span className="ml-3 inline-flex items-center gap-1 text-emerald-300">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-emerald-300" />
|
||||
Online
|
||||
</span>
|
||||
) : status === false ? (
|
||||
<span className="ml-2 text-red-500">Offline</span>
|
||||
<span className="ml-3 inline-flex items-center gap-1 text-[#FF2D8D]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#FF2D8D]" />
|
||||
Offline
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button variant="primary" size="sm" onClick={() => setShowAddEventModal(true)}>
|
||||
@@ -55,32 +58,44 @@ const Header: React.FC<HeaderProps> = ({ className = "" }) => {
|
||||
<button
|
||||
onClick={toggle}
|
||||
aria-label="Toggle dark mode"
|
||||
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-[#F4F1EA]/80 transition-colors"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.06] text-sm text-[#F4F1EA]/80 transition-colors hover:border-[#D946EF]/45 hover:bg-[#D946EF]/12 hover:text-white focus:outline-none focus:ring-2 focus:ring-[#D946EF]/60"
|
||||
>
|
||||
{mounted ? (dark ? "☀️" : "🌙") : "🌙"}
|
||||
{mounted && dark ? (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 12.8A8.5 8.5 0 1 1 11.2 3 6.5 6.5 0 0 0 21 12.8z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowSettingsModal(true)}
|
||||
aria-label="Settings"
|
||||
className="p-2 rounded-md hover:bg-gray-100 dark:hover:bg-white/10 text-gray-600 dark:text-[#F4F1EA]/80 transition-colors"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-white/10 bg-white/[0.06] text-sm text-[#F4F1EA]/80 transition-colors hover:border-[#D946EF]/45 hover:bg-[#D946EF]/12 hover:text-white focus:outline-none focus:ring-2 focus:ring-[#D946EF]/60"
|
||||
>
|
||||
⚙️
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15.5A3.5 3.5 0 1 0 12 8a3.5 3.5 0 0 0 0 7.5z" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.05A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.05A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.05A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9a1.7 1.7 0 0 0 1.55 1H21a2 2 0 1 1 0 4h-.05A1.7 1.7 0 0 0 19.4 15z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddEventModal isOpen={showAddEventModal} onClose={() => setShowAddEventModal(false)} />
|
||||
{showSettingsModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||
<div className="bg-white dark:bg-[#17112A] rounded-lg shadow-lg p-6 w-full max-w-sm relative border border-gray-200 dark:border-white/10">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4 backdrop-blur-sm">
|
||||
<div className="brand-panel relative w-full max-w-sm rounded-2xl p-6">
|
||||
<button
|
||||
onClick={() => setShowSettingsModal(false)}
|
||||
className="absolute top-3 right-3 text-gray-400 hover:text-gray-600 dark:hover:text-[#F4F1EA] text-lg"
|
||||
className="absolute right-3 top-3 inline-flex h-8 w-8 items-center justify-center rounded-lg text-[#F4F1EA]/50 transition-colors hover:bg-white/10 hover:text-white"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
✕
|
||||
x
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-[#F4F1EA]">Settings</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold text-[#F4F1EA]">Settings</h2>
|
||||
<ReminderSettingsPanel />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,18 +17,18 @@ const Navbar: React.FC<NavbarProps> = ({ className = "" }) => {
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className={`bg-white dark:bg-[#0F0E1A] border-t border-gray-200 dark:border-white/10 ${className}`}>
|
||||
<nav className={`sticky bottom-0 z-30 border-t border-white/10 bg-[#090816]/88 backdrop-blur-xl ${className}`}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<div className="flex space-x-8">
|
||||
<div className="flex w-full gap-2 sm:w-auto">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
className={`flex-1 rounded-full px-4 py-2 text-center text-sm font-semibold transition-colors sm:flex-none ${
|
||||
pathname === link.href
|
||||
? "text-[#B23CFF] border-b-2 border-[#B23CFF]"
|
||||
: "text-gray-500 hover:text-[#D946EF] hover:border-gray-300"
|
||||
? "bg-gradient-to-r from-[#8B5CF6] via-[#B23CFF] to-[#FF2D8D] text-white shadow-[0_10px_28px_rgba(178,60,255,0.28)]"
|
||||
: "text-[#F4F1EA]/58 hover:bg-white/[0.06] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{link.name}
|
||||
|
||||
@@ -13,14 +13,39 @@ export default function Home() {
|
||||
.sort((a, b) => a.eventTime.getTime() - b.eventTime.getTime());
|
||||
|
||||
return (
|
||||
<main className="max-w-3xl mx-auto w-full px-4 py-6">
|
||||
<main className="mx-auto w-full max-w-5xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="mb-7 overflow-hidden rounded-2xl border border-white/10 bg-[#17112A]/55 p-5 shadow-[0_22px_70px_rgba(0,0,0,0.28)] backdrop-blur-xl sm:p-7">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-semibold uppercase tracking-[0.28em] text-[#D946EF]">Departure desk</p>
|
||||
<h1 className="max-w-2xl text-3xl font-extrabold leading-tight text-white sm:text-5xl">
|
||||
Know when to leave before the clock turns hostile.
|
||||
</h1>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:min-w-60">
|
||||
<div className="brand-panel-soft rounded-xl p-3">
|
||||
<p className="text-2xl font-bold text-white">{upcoming.length}</p>
|
||||
<p className="text-xs text-[#F4F1EA]/60">upcoming</p>
|
||||
</div>
|
||||
<div className="brand-panel-soft rounded-xl p-3">
|
||||
<p className="text-2xl font-bold text-white">{events.length}</p>
|
||||
<p className="text-xs text-[#F4F1EA]/60">total events</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{upcoming.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-500 dark:text-[#F4F1EA]/60">
|
||||
<p className="text-2xl font-bold brand-gradient-text mb-2">No upcoming events</p>
|
||||
<p className="text-sm mt-1">Use "Add Event" to add one, or import from the Calendar.</p>
|
||||
<div className="brand-panel mx-auto max-w-2xl rounded-2xl px-6 py-14 text-center">
|
||||
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-[#8B5CF6] to-[#FF2D8D] text-2xl font-black text-white shadow-[0_16px_44px_rgba(178,60,255,0.4)]">
|
||||
T
|
||||
</div>
|
||||
<p className="mb-2 text-2xl font-bold text-white">No upcoming events</p>
|
||||
<p className="mx-auto max-w-sm text-sm leading-6 text-[#F4F1EA]/66">
|
||||
Add an event or import your calendar to turn this into a live departure board.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
{upcoming.map((event) => (
|
||||
<EventCard key={event.id} event={event} originStation={originStation} />
|
||||
))}
|
||||
|
||||
@@ -8,14 +8,15 @@ type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
};
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({ children, className = "", variant = "primary", size = "md", ...props }) => {
|
||||
const baseStyles = "font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2";
|
||||
const baseStyles =
|
||||
"inline-flex items-center justify-center font-semibold rounded-lg transition-all focus:outline-none focus:ring-2 focus:ring-[#D946EF]/70 disabled:cursor-not-allowed disabled:opacity-55";
|
||||
const variantStyles = {
|
||||
primary:
|
||||
"bg-gradient-to-r from-[#8B5CF6] via-[#B23CFF] to-[#FF2D8D] text-white hover:opacity-90 focus:ring-brand-purple",
|
||||
"bg-gradient-to-r from-[#8B5CF6] via-[#B23CFF] to-[#FF2D8D] text-white shadow-[0_14px_34px_rgba(178,60,255,0.34)] hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(255,45,141,0.28)]",
|
||||
secondary:
|
||||
"bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500 dark:bg-white/10 dark:text-[#F4F1EA] dark:hover:bg-white/20 dark:focus:ring-brand-purple",
|
||||
"border border-white/10 bg-white/[0.07] text-[#F4F1EA] hover:border-[#D946EF]/45 hover:bg-[#D946EF]/12",
|
||||
accent:
|
||||
"bg-[#FF2D8D] text-white hover:bg-[#E5247D] focus:ring-brand-pink",
|
||||
"bg-[#FF2D8D] text-white shadow-[0_12px_30px_rgba(255,45,141,0.3)] hover:bg-[#E5247D]",
|
||||
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
|
||||
};
|
||||
const sizeStyles = {
|
||||
|
||||
@@ -10,7 +10,7 @@ type ChipProps = {
|
||||
const Chip: React.FC<ChipProps> = ({ children, className = '' }) => {
|
||||
return (
|
||||
<span className={
|
||||
`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-[#B23CFF]/10 dark:bg-[#B23CFF]/20 text-[#B23CFF] dark:text-[#D946EF] ${className}`
|
||||
`inline-flex items-center rounded-full border border-[#D946EF]/24 bg-[#B23CFF]/16 px-2.5 py-1 text-xs font-semibold text-[#F4F1EA] shadow-[inset_0_1px_0_rgba(255,255,255,0.06)] ${className}`
|
||||
}>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
@@ -4,11 +4,11 @@ import React from "react";
|
||||
import Chip from "./Chip";
|
||||
|
||||
const colorMap: Record<string, string> = {
|
||||
red: "text-red-600 bg-red-50 dark:bg-red-900/30",
|
||||
orange: "text-orange-600 bg-orange-50 dark:bg-orange-900/30",
|
||||
yellow: "text-yellow-600 bg-yellow-50 dark:bg-yellow-900/30",
|
||||
green: "text-green-600 bg-green-50 dark:bg-green-900/30",
|
||||
blue: "text-[#B23CFF] bg-[#B23CFF]/10 dark:bg-[#B23CFF]/20",
|
||||
red: "border-red-400/30 bg-red-500/16 text-red-100",
|
||||
orange: "border-orange-300/30 bg-orange-400/16 text-orange-100",
|
||||
yellow: "border-yellow-300/30 bg-yellow-300/16 text-yellow-100",
|
||||
green: "border-emerald-300/30 bg-emerald-400/16 text-emerald-100",
|
||||
blue: "border-[#D946EF]/30 bg-[#B23CFF]/18 text-[#F4F1EA]",
|
||||
};
|
||||
|
||||
type CountdownBadgeProps = {
|
||||
|
||||
@@ -18,9 +18,12 @@ const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={
|
||||
`inline-block animate-spin rounded-full border-4 border-solid border-[#B23CFF] border-t-transparent ${sizeClasses[size]} ${className}`
|
||||
}>
|
||||
<div
|
||||
className={
|
||||
`inline-block animate-spin rounded-full border-4 border-solid border-[#B23CFF] border-t-transparent ${sizeClasses[size]} ${className}`
|
||||
}
|
||||
data-testid="loading-spinner"
|
||||
>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,115 +1,107 @@
|
||||
export default function LogoHorizontal({
|
||||
height = 40,
|
||||
height = 44,
|
||||
className = "",
|
||||
}: {
|
||||
height?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const width = height * 4.5;
|
||||
const width = height * 4.85;
|
||||
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
viewBox="0 0 214 58"
|
||||
width={width}
|
||||
height={height}
|
||||
className={className}
|
||||
aria-label="TimeToLeave"
|
||||
role="img"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="ttt-h-meltGrad"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<linearGradient id="ttl-header-gradient" x1="280" y1="210" x2="900" y2="720" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#8B5CF6" />
|
||||
<stop offset="40%" stopColor="#B23CFF" />
|
||||
<stop offset="42%" stopColor="#B23CFF" />
|
||||
<stop offset="72%" stopColor="#D946EF" />
|
||||
<stop offset="100%" stopColor="#FF2D8D" />
|
||||
</linearGradient>
|
||||
<linearGradient id="ttl-header-text-gradient" x1="116" y1="17" x2="155" y2="43" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0%" stopColor="#B23CFF" />
|
||||
<stop offset="100%" stopColor="#FF2D8D" />
|
||||
</linearGradient>
|
||||
<filter id="ttl-header-glow" x="-35%" y="-35%" width="170%" height="170%">
|
||||
<feGaussianBlur stdDeviation="1.6" result="blur" />
|
||||
<feColorMatrix
|
||||
in="blur"
|
||||
type="matrix"
|
||||
values="0.72 0 0 0 0.26 0 0.18 0 0 0.92 0 0 0.34 0 1 0 0 0 0.32 0"
|
||||
result="glow"
|
||||
/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Melting clock icon on the left */}
|
||||
<g transform={`scale(${height / 48}) translate(0, ${height * 0.05})`}>
|
||||
{/* Clock outline — melting arc */}
|
||||
<g filter="url(#ttl-header-glow)" transform="matrix(0.108 0 0 0.108 -31.6 -27.7)">
|
||||
<path
|
||||
d="M20,36 C19,32 20,27 22,23 C25,18 30,17 34,17 C40,17 44,21 45,27 C45,30 45,32 44,33 C44,34 44,35 46,36 C47,36 47,34 48,33 C49,33 49,35 48,36 C47,37 46,37 45,37"
|
||||
d="M334 552 C324 498 335 437 370 388 C415 324 486 290 562 293 C660 297 744 372 758 471 C763 508 758 541 753 565 C748 589 756 610 779 622 C808 637 810 594 832 590 C855 586 860 620 851 644 C842 669 824 682 803 678"
|
||||
fill="none"
|
||||
stroke="url(#ttt-h-meltGrad)"
|
||||
strokeWidth="2.5"
|
||||
stroke="url(#ttl-header-gradient)"
|
||||
strokeWidth="42"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Melting drip */}
|
||||
<path
|
||||
d="M30,37 C30,39 30,41 31,41 C32,41 32,39 32,37"
|
||||
d="M334 552 C322 588 331 622 365 628 C395 633 386 581 415 580 C447 579 441 641 475 641 C510 641 506 585 544 585 C579 585 571 651 604 670 C650 697 733 695 797 657"
|
||||
fill="none"
|
||||
stroke="url(#ttt-h-meltGrad)"
|
||||
strokeWidth="2.5"
|
||||
stroke="url(#ttl-header-gradient)"
|
||||
strokeWidth="42"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* Flowing arrow to the right */}
|
||||
<path
|
||||
d="M45,33 C47,32 49,31 51,31"
|
||||
d="M475 641 C472 690 474 724 489 724 C506 724 506 690 509 651"
|
||||
fill="none"
|
||||
stroke="url(#ttt-h-meltGrad)"
|
||||
strokeWidth="2.5"
|
||||
stroke="url(#ttl-header-gradient)"
|
||||
strokeWidth="42"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M49,29 L54,31 L49,33 Z"
|
||||
fill="#FF2D8D"
|
||||
d="M797 657 C830 639 862 624 898 615"
|
||||
fill="none"
|
||||
stroke="url(#ttl-header-gradient)"
|
||||
strokeWidth="42"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Clock ticks */}
|
||||
<line x1="32" y1="19" x2="32" y2="20.5" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
<line x1="42" y1="23" x2="41" y2="24" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
<line x1="44" y1="30" x2="42.5" y2="30" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
<line x1="32" y1="35" x2="32" y2="33.5" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
<line x1="22" y1="31" x2="23" y2="30" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
<line x1="20" y1="24" x2="21.5" y2="24" stroke="currentColor" strokeWidth="1" opacity="0.4" />
|
||||
{/* Clock hands */}
|
||||
<line x1="32" y1="27" x2="32" y2="21" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.7" />
|
||||
<line x1="32" y1="27" x2="37" y2="30" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" opacity="0.7" />
|
||||
<circle cx="32" cy="27" r="1.2" fill="currentColor" opacity="0.9" />
|
||||
<path d="M882 572 L1010 620 L904 708 L917 650 Z" fill="#FF2D8D" />
|
||||
<ellipse cx="505" cy="725" rx="17" ry="26" fill="#B23CFF" />
|
||||
|
||||
<g stroke="currentColor" strokeLinecap="round" opacity="0.96">
|
||||
<line x1="556" y1="334" x2="556" y2="360" strokeWidth="15" />
|
||||
<line x1="694" y1="391" x2="716" y2="378" strokeWidth="15" />
|
||||
<line x1="733" y1="526" x2="762" y2="526" strokeWidth="15" />
|
||||
<line x1="405" y1="655" x2="425" y2="633" strokeWidth="15" />
|
||||
<line x1="362" y1="526" x2="390" y2="526" strokeWidth="15" />
|
||||
<line x1="405" y1="398" x2="425" y2="420" strokeWidth="15" />
|
||||
<line x1="658" y1="420" x2="671" y2="398" strokeWidth="15" />
|
||||
<line x1="556" y1="526" x2="556" y2="405" strokeWidth="19" />
|
||||
<line x1="556" y1="526" x2="665" y2="590" strokeWidth="19" />
|
||||
</g>
|
||||
<circle cx="556" cy="526" r="25" fill="currentColor" />
|
||||
</g>
|
||||
|
||||
{/* Wordmark on the right */}
|
||||
<text
|
||||
x={height * 0.7}
|
||||
y={height * 0.65}
|
||||
fill="currentColor"
|
||||
className="dark:fill-[#F4F1EA]"
|
||||
fontSize={height * 0.6}
|
||||
fontWeight="700"
|
||||
fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif"
|
||||
letterSpacing="-0.02em"
|
||||
>
|
||||
Time
|
||||
</text>
|
||||
<text
|
||||
x={height * 0.7 + height * 1.0}
|
||||
y={height * 0.65}
|
||||
fill="url(#ttt-h-meltGrad)"
|
||||
fontSize={height * 0.6}
|
||||
fontWeight="700"
|
||||
fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif"
|
||||
letterSpacing="-0.02em"
|
||||
>
|
||||
To
|
||||
</text>
|
||||
<text
|
||||
x={height * 0.7 + height * 1.55}
|
||||
y={height * 0.65}
|
||||
fill="currentColor"
|
||||
className="dark:fill-[#F4F1EA]"
|
||||
fontSize={height * 0.6}
|
||||
fontWeight="700"
|
||||
fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif"
|
||||
letterSpacing="-0.02em"
|
||||
>
|
||||
Leave
|
||||
</text>
|
||||
<g fontFamily="Inter, Poppins, Montserrat, Avenir Next, Arial, sans-serif" fontSize="26" fontWeight="800">
|
||||
<text x="83" y="37" fill="currentColor">
|
||||
Time
|
||||
</text>
|
||||
<text x="139" y="37" fill="url(#ttl-header-text-gradient)">
|
||||
To
|
||||
</text>
|
||||
<text x="166" y="37" fill="currentColor">
|
||||
Leave
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ type ReminderSettingsPanelProps = {
|
||||
};
|
||||
|
||||
export default function ReminderSettingsPanel({ className = "" }: ReminderSettingsPanelProps) {
|
||||
const { bufferMinutes, enabled, setBufferMinutes, setEnabled } = useReminderSettings();
|
||||
const {
|
||||
bufferMinutes, enabled, setBufferMinutes, setEnabled,
|
||||
arrivalBufferMinutes, showWalkingOption, showBikeOption,
|
||||
setArrivalBufferMinutes, setShowWalkingOption, setShowBikeOption,
|
||||
} = useReminderSettings();
|
||||
|
||||
const permission =
|
||||
typeof window !== "undefined" && "Notification" in window
|
||||
@@ -26,7 +30,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80">
|
||||
<span className="text-sm font-medium text-[#F4F1EA]/80">
|
||||
Leave reminders
|
||||
</span>
|
||||
<button
|
||||
@@ -34,7 +38,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
||||
aria-checked={enabled}
|
||||
onClick={() => setEnabled(!enabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
enabled ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-gray-300 dark:bg-gray-600"
|
||||
enabled ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-white/16"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@@ -50,10 +54,10 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="buffer-minutes"
|
||||
className="text-sm font-medium text-gray-700 dark:text-[#F4F1EA]/80"
|
||||
className="text-sm font-medium text-[#F4F1EA]/80"
|
||||
>
|
||||
Remind me{" "}
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<span className="text-[#F4F1EA]/48">
|
||||
(minutes before event)
|
||||
</span>
|
||||
</label>
|
||||
@@ -70,7 +74,7 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
||||
/>
|
||||
<output
|
||||
htmlFor="buffer-minutes"
|
||||
className="text-sm font-semibold tabular-nums min-w-[3ch] text-center text-gray-700 dark:text-[#F4F1EA]/80"
|
||||
className="min-w-[3ch] text-center text-sm font-semibold tabular-nums text-[#F4F1EA]/80"
|
||||
>
|
||||
{bufferMinutes}
|
||||
</output>
|
||||
@@ -78,9 +82,82 @@ export default function ReminderSettingsPanel({ className = "" }: ReminderSettin
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Arrival buffer slider */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="arrival-buffer"
|
||||
className="text-sm font-medium text-[#F4F1EA]/80"
|
||||
>
|
||||
Arrive early
|
||||
<span className="text-[#F4F1EA]/48">
|
||||
(minutes before event)
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
id="arrival-buffer"
|
||||
type="range"
|
||||
min={0}
|
||||
max={30}
|
||||
step={1}
|
||||
value={arrivalBufferMinutes}
|
||||
onChange={(e) => setArrivalBufferMinutes(Number(e.target.value))}
|
||||
className="flex-1 accent-[#B23CFF]"
|
||||
/>
|
||||
<output
|
||||
htmlFor="arrival-buffer"
|
||||
className="min-w-[3ch] text-center text-sm font-semibold tabular-nums text-[#F4F1EA]/80"
|
||||
>
|
||||
{arrivalBufferMinutes}
|
||||
</output>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show walking option toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[#F4F1EA]/80">
|
||||
Show walking option
|
||||
</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={showWalkingOption}
|
||||
onClick={() => setShowWalkingOption(!showWalkingOption)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
showWalkingOption ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-white/16"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
showWalkingOption ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Show bike option toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[#F4F1EA]/80">
|
||||
Show bike route
|
||||
</span>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={showBikeOption}
|
||||
onClick={() => setShowBikeOption(!showBikeOption)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
showBikeOption ? "bg-gradient-to-r from-[#8B5CF6] to-[#FF2D8D]" : "bg-white/16"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
showBikeOption ? "translate-x-6" : "translate-x-1"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Permission status */}
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-white/10">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{statusLabel}</p>
|
||||
<div className="border-t border-white/10 pt-2">
|
||||
<p className="text-xs text-[#F4F1EA]/50">{statusLabel}</p>
|
||||
{permission !== "granted" && permission !== "denied" && (
|
||||
<button
|
||||
onClick={() => Notification.requestPermission()}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useDepartureTime } from "../useDepartureTime";
|
||||
import type { Journey } from "@timetoleave/core";
|
||||
import React from "react";
|
||||
|
||||
import { ReminderSettingsProvider } from "../useReminderSettings";
|
||||
|
||||
// Wrapper to provide the context
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
return React.createElement(ReminderSettingsProvider, null, children);
|
||||
}
|
||||
|
||||
describe("useDepartureTime", () => {
|
||||
const eventTime = new Date("2024-01-01T12:00:00Z");
|
||||
const eventTimeMs = eventTime.getTime();
|
||||
|
||||
// Helper to create mock journeys
|
||||
const createJourney = (sD: string, sA: string, cancelled = false): Journey => ({
|
||||
id: "test-journey",
|
||||
sD: new Date(sD),
|
||||
rD: new Date(sD),
|
||||
sA: new Date(sA),
|
||||
rA: new Date(sA),
|
||||
delay: 0,
|
||||
platform: "1",
|
||||
changes: 0,
|
||||
trains: ["IC123"],
|
||||
cancelled,
|
||||
});
|
||||
|
||||
it("should return null when no journeys are available", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDepartureTime(eventTime, null, null, "train")
|
||||
, { wrapper });
|
||||
|
||||
expect(result.current.departureTime).toBeNull();
|
||||
expect(result.current.arrivalTime).toBeNull();
|
||||
expect(result.current.mode).toBeNull();
|
||||
});
|
||||
|
||||
it("should calculate correct departure time for train mode", () => {
|
||||
const journeys: Journey[] = [
|
||||
createJourney("2024-01-01T10:30:00Z", "2024-01-01T11:45:00Z"), // arrives 15 min early
|
||||
createJourney("2024-01-01T10:45:00Z", "2024-01-01T11:55:00Z"), // arrives 5 min early
|
||||
createJourney("2024-01-01T11:00:00Z", "2024-01-01T12:10:00Z"), // arrives 10 min late (should be ignored)
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDepartureTime(eventTime, journeys, null, "train")
|
||||
, { wrapper });
|
||||
|
||||
expect(result.current.departureTime).not.toBeNull();
|
||||
expect(result.current.arrivalTime).not.toBeNull();
|
||||
expect(result.current.mode).toBe("train");
|
||||
|
||||
// Should pick the journey with the latest departure (10:45) that arrives on time (11:55)
|
||||
if (result.current.departureTime) {
|
||||
expect(result.current.departureTime.getTime()).toBe(
|
||||
new Date("2024-01-01T10:45:00Z").getTime()
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("should filter out cancelled journeys", () => {
|
||||
const journeys: Journey[] = [
|
||||
createJourney("2024-01-01T10:30:00Z", "2024-01-01T11:45:00Z", true), // cancelled
|
||||
createJourney("2024-01-01T10:45:00Z", "2024-01-01T11:55:00Z"), // valid
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDepartureTime(eventTime, journeys, null, "train")
|
||||
, { wrapper });
|
||||
|
||||
expect(result.current.departureTime).not.toBeNull();
|
||||
expect(result.current.departureTime?.getTime()).toBe(
|
||||
new Date("2024-01-01T10:45:00Z").getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it("should calculate correct departure time for bike mode", () => {
|
||||
const bikeRouteDuration = 1800; // 30 minutes in seconds
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useDepartureTime(eventTime, null, bikeRouteDuration, "bike")
|
||||
, { wrapper });
|
||||
|
||||
expect(result.current.departureTime).not.toBeNull();
|
||||
expect(result.current.arrivalTime).not.toBeNull();
|
||||
expect(result.current.mode).toBe("bike");
|
||||
|
||||
// Should account for bike duration + arrival buffer (default 5 min)
|
||||
// Total travel time: 30 min (bike) + 5 min (buffer) + 30 min (bike back) = 65 min
|
||||
const expectedDepartureTime = new Date(eventTimeMs - 65 * 60 * 1000);
|
||||
expect(result.current.departureTime?.getTime()).toBeCloseTo(
|
||||
expectedDepartureTime.getTime()
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null when bike route duration is invalid", () => {
|
||||
const { result } = renderHook(() =>
|
||||
useDepartureTime(eventTime, null, 0, "bike")
|
||||
, { wrapper });
|
||||
|
||||
expect(result.current.departureTime).toBeNull();
|
||||
expect(result.current.arrivalTime).toBeNull();
|
||||
expect(result.current.mode).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ interface ClockResult {
|
||||
status: "upcoming" | "now" | "past";
|
||||
}
|
||||
|
||||
export function useClock(targetDate: Date): ClockResult {
|
||||
export function useClock(targetDate: Date, departureTime?: Date | null): ClockResult {
|
||||
const [now, setNow] = useState<Date>(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,9 +19,11 @@ export function useClock(targetDate: Date): ClockResult {
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const effectiveTarget = departureTime ?? targetDate;
|
||||
|
||||
return useMemo(() => {
|
||||
const countdown = calculateCountdown(targetDate);
|
||||
const diffMs = targetDate.getTime() - now.getTime();
|
||||
const countdown = calculateCountdown(effectiveTarget);
|
||||
const diffMs = effectiveTarget.getTime() - now.getTime();
|
||||
|
||||
let status: "upcoming" | "now" | "past";
|
||||
if (diffMs <= 0) {
|
||||
@@ -33,5 +35,5 @@ export function useClock(targetDate: Date): ClockResult {
|
||||
}
|
||||
|
||||
return { countdown, status };
|
||||
}, [targetDate, now]);
|
||||
}, [effectiveTarget, now]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import type { Journey } from "@timetoleave/core";
|
||||
import { useReminderSettings } from "@/hooks/useReminderSettings";
|
||||
|
||||
interface DepartureTimeResult {
|
||||
departureTime: Date | null;
|
||||
arrivalTime: Date | null;
|
||||
mode: "train" | "bike" | null;
|
||||
}
|
||||
|
||||
export function useDepartureTime(
|
||||
eventTime: Date,
|
||||
journeys: Journey[] | null,
|
||||
bikeRoute: number | null, // duration in seconds
|
||||
activeMode: "train" | "bike" | null
|
||||
): DepartureTimeResult {
|
||||
const { arrivalBufferMinutes } = useReminderSettings();
|
||||
|
||||
return useMemo(() => {
|
||||
// Calculate target arrival time (event time minus buffer)
|
||||
const targetArrivalTime = new Date(eventTime);
|
||||
targetArrivalTime.setMinutes(targetArrivalTime.getMinutes() - arrivalBufferMinutes);
|
||||
|
||||
// Filter out cancelled journeys
|
||||
const validJourneys = journeys?.filter(journey => !journey.cancelled) || [];
|
||||
|
||||
let departureTime: Date | null = null;
|
||||
let arrivalTime: Date | null = null;
|
||||
let mode: "train" | "bike" | null = null;
|
||||
|
||||
// Handle train mode
|
||||
if (activeMode === "train" && validJourneys.length > 0) {
|
||||
// Find journeys that arrive before or at the target arrival time
|
||||
const onTimeJourneys = validJourneys.filter(journey =>
|
||||
journey.sA.getTime() <= targetArrivalTime.getTime()
|
||||
);
|
||||
|
||||
if (onTimeJourneys.length > 0) {
|
||||
// Pick the journey with the latest departure time
|
||||
const bestJourney = onTimeJourneys.reduce((latest, current) =>
|
||||
current.sD.getTime() > latest.sD.getTime() ? current : latest
|
||||
);
|
||||
|
||||
departureTime = new Date(bestJourney.sD);
|
||||
arrivalTime = new Date(bestJourney.sA);
|
||||
mode = "train";
|
||||
}
|
||||
}
|
||||
|
||||
// Handle bike mode
|
||||
if (activeMode === "bike" && bikeRoute !== null && bikeRoute > 0) {
|
||||
// Convert bike route duration from seconds to milliseconds
|
||||
const bikeDurationMs = bikeRoute * 1000;
|
||||
|
||||
// Calculate arrival time by subtracting bike duration from event time
|
||||
const calculatedArrivalTime = new Date(eventTime);
|
||||
calculatedArrivalTime.setTime(calculatedArrivalTime.getTime() - bikeDurationMs);
|
||||
|
||||
// Calculate departure time by subtracting bike duration from calculated arrival time
|
||||
const calculatedDepartureTime = new Date(calculatedArrivalTime);
|
||||
calculatedDepartureTime.setTime(calculatedArrivalTime.getTime() - bikeDurationMs);
|
||||
|
||||
// Calculate how much earlier we need to account for arrival buffer
|
||||
const totalBufferMs = arrivalBufferMinutes * 60 * 1000;
|
||||
const totalTravelTimeMs = bikeDurationMs * 2 + totalBufferMs;
|
||||
|
||||
departureTime = new Date(eventTime);
|
||||
departureTime.setTime(eventTime.getTime() - totalTravelTimeMs);
|
||||
|
||||
arrivalTime = new Date(calculatedArrivalTime);
|
||||
mode = "bike";
|
||||
}
|
||||
|
||||
return { departureTime, arrivalTime, mode };
|
||||
}, [eventTime, journeys, bikeRoute, activeMode, arrivalBufferMinutes]);
|
||||
}
|
||||
@@ -4,7 +4,13 @@ import { createContext, useContext, useState, useCallback, useEffect, ReactNode
|
||||
import { ReminderSettings } from "@timetoleave/core";
|
||||
|
||||
const STORAGE_KEY = "ttl_reminder_settings";
|
||||
const DEFAULTS: ReminderSettings = { bufferMinutes: 15, enabled: true };
|
||||
const DEFAULTS: ReminderSettings = {
|
||||
bufferMinutes: 15,
|
||||
enabled: true,
|
||||
arrivalBufferMinutes: 5,
|
||||
showWalkingOption: true,
|
||||
showBikeOption: true,
|
||||
};
|
||||
|
||||
function loadFromStorage(): ReminderSettings {
|
||||
if (typeof window === "undefined") return DEFAULTS;
|
||||
@@ -20,6 +26,9 @@ function loadFromStorage(): ReminderSettings {
|
||||
interface ReminderContextType extends ReminderSettings {
|
||||
setBufferMinutes: (minutes: number) => void;
|
||||
setEnabled: (enabled: boolean) => void;
|
||||
setArrivalBufferMinutes: (minutes: number) => void;
|
||||
setShowWalkingOption: (show: boolean) => void;
|
||||
setShowBikeOption: (show: boolean) => void;
|
||||
}
|
||||
|
||||
const ReminderContext = createContext<ReminderContextType | undefined>(undefined);
|
||||
@@ -39,8 +48,20 @@ export function ReminderSettingsProvider({ children }: { children: ReactNode })
|
||||
setSettings((prev) => ({ ...prev, enabled }));
|
||||
}, []);
|
||||
|
||||
const setArrivalBufferMinutes = useCallback((minutes: number) => {
|
||||
setSettings((prev) => ({ ...prev, arrivalBufferMinutes: Math.max(0, Math.min(30, minutes)) }));
|
||||
}, []);
|
||||
|
||||
const setShowWalkingOption = useCallback((show: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, showWalkingOption: show }));
|
||||
}, []);
|
||||
|
||||
const setShowBikeOption = useCallback((show: boolean) => {
|
||||
setSettings((prev) => ({ ...prev, showBikeOption: show }));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ReminderContext.Provider value={{ ...settings, setBufferMinutes, setEnabled }}>
|
||||
<ReminderContext.Provider value={{ ...settings, setBufferMinutes, setEnabled, setArrivalBufferMinutes, setShowWalkingOption, setShowBikeOption }}>
|
||||
{children}
|
||||
</ReminderContext.Provider>
|
||||
);
|
||||
|
||||
@@ -17,11 +17,10 @@ function getClientTheme(): "dark" | "light" {
|
||||
|
||||
export function useTheme() {
|
||||
const [dark, setDark] = useState(() => getClientTheme() === "dark");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [mounted] = useState(() => typeof window !== "undefined");
|
||||
const initialized = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
if (!initialized.current) {
|
||||
initialized.current = true;
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import type { WalkRoute } from "@timetoleave/core";
|
||||
import { ApiClient } from "@timetoleave/api-client";
|
||||
|
||||
const client = new ApiClient();
|
||||
|
||||
export function useWalkRoute(
|
||||
fromLat: number | undefined,
|
||||
fromLng: number | undefined,
|
||||
toLat: number | undefined,
|
||||
toLng: number | undefined,
|
||||
) {
|
||||
const [walkRoute, setWalkRoute] = useState<WalkRoute | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchRoute = async () => {
|
||||
if (fromLat == null || fromLng == null || toLat == null || toLng == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await client.getWalkRoute(fromLat, fromLng, toLat, toLng);
|
||||
|
||||
if (isMounted) {
|
||||
setWalkRoute(data);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isMounted) {
|
||||
const message = err instanceof Error ? err.message : "Failed to fetch walk route";
|
||||
setError(message);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchRoute();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [fromLat, fromLng, toLat, toLng]);
|
||||
|
||||
return { walkRoute, loading, error };
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { BikeRoute, BikeStep } from "@timetoleave/core";
|
||||
import { OSRM_URL } from "./constants";
|
||||
import { ApiClient } from "./api-service";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OSRM response types (internal, not exported)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface OsrmStep {
|
||||
name: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
maneuver: { instruction?: string; type: string; modifier?: string };
|
||||
}
|
||||
|
||||
interface OsrmRoute {
|
||||
distance: number;
|
||||
duration: number;
|
||||
legs: Array<{ steps: OsrmStep[] }>;
|
||||
}
|
||||
|
||||
interface OsrmResponse {
|
||||
code: string;
|
||||
routes: OsrmRoute[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Build human-readable instruction from OSRM step data
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function stepInstruction(step: OsrmStep): string {
|
||||
if (step.maneuver.instruction) return step.maneuver.instruction;
|
||||
const modifier = step.maneuver.modifier ? ` ${step.maneuver.modifier}` : "";
|
||||
return `${step.maneuver.type}${modifier}${step.name ? ` onto ${step.name}` : ""}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WalkRoutingClient — Wraps the OSRM foot routing API
|
||||
//
|
||||
// OSRM routes are cached for 5 minutes. Walk routes between the same
|
||||
// coordinates rarely change, and caching significantly reduces API load.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class WalkRoutingClient {
|
||||
private client: ApiClient;
|
||||
private readonly defaultTtlMs: number;
|
||||
|
||||
constructor(
|
||||
baseUrl: string = OSRM_URL,
|
||||
ttlMs: number = 5 * 60 * 1000, // 5 minutes — routes are very stable
|
||||
) {
|
||||
this.client = new ApiClient({
|
||||
baseUrl,
|
||||
defaultTimeoutMs: 10_000,
|
||||
defaultTtlMs: ttlMs,
|
||||
maxRetries: 3,
|
||||
});
|
||||
this.defaultTtlMs = ttlMs;
|
||||
}
|
||||
|
||||
async getWalkRoute(fromLat: number, fromLng: number, toLat: number, toLng: number): Promise<BikeRoute | null> {
|
||||
const coords = `${fromLng},${fromLat};${toLng},${toLat}`;
|
||||
const path = `/route/v1/foot/${coords}`;
|
||||
|
||||
const cacheKey = `osrm:walk:${coords}`;
|
||||
|
||||
const res = await this.client.get<OsrmResponse>(
|
||||
path,
|
||||
{ overview: "false", steps: "true" },
|
||||
{
|
||||
cacheKey,
|
||||
ttl: this.defaultTtlMs,
|
||||
},
|
||||
);
|
||||
|
||||
if (res.code !== "Ok" || !res.routes.length) return null;
|
||||
|
||||
const route = res.routes[0];
|
||||
const steps: BikeStep[] = (route.legs[0]?.steps ?? []).map((s) => ({
|
||||
name: s.name,
|
||||
distance: s.distance,
|
||||
duration: s.duration,
|
||||
instruction: stepInstruction(s),
|
||||
}));
|
||||
|
||||
return {
|
||||
distance: route.distance,
|
||||
duration: route.duration,
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose cache statistics for debugging/monitoring.
|
||||
*/
|
||||
public cacheStats() {
|
||||
return this.client.cacheStats();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the cache (useful for testing or forced refresh).
|
||||
*/
|
||||
public clearCache() {
|
||||
this.client.clearCache();
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,23 @@ export class ApiClient {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async getWalkRoute(
|
||||
fromLat: number,
|
||||
fromLng: number,
|
||||
toLat: number,
|
||||
toLng: number,
|
||||
): Promise<BikeRoute> {
|
||||
const url = buildUrl(this.baseUrl, "/api/walk-route", {
|
||||
fromLat: String(fromLat),
|
||||
fromLng: String(fromLng),
|
||||
toLat: String(toLat),
|
||||
toLng: String(toLng),
|
||||
});
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Walk route failed: ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async fetchCalendar(url: string, days?: number): Promise<CalendarEvent[]> {
|
||||
const params: Record<string, string> = { url };
|
||||
if (days) params.days = String(days);
|
||||
|
||||
@@ -49,6 +49,19 @@ export interface BikeRoute {
|
||||
steps: BikeStep[];
|
||||
}
|
||||
|
||||
export interface WalkRoute {
|
||||
distance: number;
|
||||
duration: number;
|
||||
steps: WalkStep[];
|
||||
}
|
||||
|
||||
export interface WalkStep {
|
||||
name: string;
|
||||
distance: number;
|
||||
duration: number;
|
||||
instruction: string;
|
||||
}
|
||||
|
||||
export interface CountdownInfo {
|
||||
label: string;
|
||||
color: string;
|
||||
@@ -63,6 +76,9 @@ export type CalStatus = null | "loading" | "ok" | "error";
|
||||
export interface ReminderSettings {
|
||||
bufferMinutes: number;
|
||||
enabled: boolean;
|
||||
arrivalBufferMinutes: number; // arrive X minutes before event (default 5)
|
||||
showWalkingOption: boolean; // show walk-from-station option (default true)
|
||||
showBikeOption: boolean; // show bike route section (default true)
|
||||
}
|
||||
|
||||
export type ServerStatus = boolean | null;
|
||||
|
||||
Reference in New Issue
Block a user