rewrite phase 6

This commit is contained in:
2026-05-09 16:13:29 +02:00
parent b1c3cb99bd
commit a8700cedc8
6 changed files with 204 additions and 280 deletions
+10 -10
View File
@@ -14,7 +14,7 @@
## Phase 1 — Scaffold Next.js Project (~30 min)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
|---|---|----|-|
| 1 | Initialize Next.js with App Router, TypeScript, Tailwind CSS | [x] | [x] |
| 2 | Set up `tsconfig.json` with strict mode | [x] | [x] |
| 3 | Create `.env.example` with `PORT`, `HAFAS_URL`, `NOMINATIM_URL`, `OSRM_URL` | [x] | [x] |
@@ -27,7 +27,7 @@
## Phase 2 — Types + Library Layer (~2 hours)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
|---|---|----|-|
| 7 | Define TypeScript types in `src/types/index.ts` | [x] | [x] |
| 8 | Port `lib/hafas-client.ts` — HAFAS API client functions with proper types | [x] | [x] |
| 9 | Port `lib/calendar-utils.ts``extractEvents` + `cleanLocation` | [x] | [x] |
@@ -40,7 +40,7 @@
## Phase 3 — API Routes (~45 min)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
|---|---|----|-|
| 13 | `src/app/api/hafas/route.ts` — POST handler, same logic as Express | [x] | [x] |
| 14 | `src/app/api/calendar/route.ts` — GET handler for remote ICS | [x] | [x] |
| 15 | `src/app/api/calendar/parse/route.ts` — POST handler for ICS body | [x] | [x] |
@@ -53,7 +53,7 @@
## Phase 4 — Custom Hooks (~2.5 hours)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
|---|---|----|-|
| 19 | `useServerHealth.ts` — polls `/api/health` every 30s | [x] | [x] |
| 20 | `useClock.ts` — interval that updates `now` every 10s | [x] | [x] |
| 21 | `useGeolocation.ts` — wraps `navigator.geolocation` | [x] | [x] |
@@ -68,10 +68,10 @@
## Phase 5 — UI Components (~3.5 hours)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
|---|---|----|-|
| 27 | `ui/Chip.tsx` — small badge component | [x] | [x] |
| 28 | `ui/Button.tsx` — styled button | [x] | [x] |
| 29 | `ui/LoadingSpinner.tsx` — loading indicator | [x] | [ ] |
| 29 | `ui/LoadingSpinner.tsx` — loading indicator | [x] | [x] |
| 30 | `event/LeaveByBadge.tsx` — countdown badge | [x] | [x] |
| 31 | `event/JourneyList.tsx` — departure rows | [x] | [x] |
| 32 | `event/TrainSection.tsx` — train data in event card | [x] | [x] |
@@ -89,7 +89,7 @@
## Phase 6 — Calendar Page (~1 hour)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
|---|---|----|-|
| 41 | `calendar/CalendarView.tsx` — month grid component (NEW) | [x] | [x] |
| 42 | `calendar/DayEvents.tsx` — events for a selected day (NEW) | [x] | [x] |
| 43 | `app/calendar/page.tsx` — calendar route (NEW) | [x] | [x] |
@@ -99,7 +99,7 @@
## Phase 7 — Tests (~2 hours)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
|---|---|----|-|
| 44 | Migrate `server/__tests__/*.test.js``src/app/api/__tests__/*.test.ts` | [ ] | [ ] |
| 45 | Add unit tests for `calendar-utils.ts`, `countdown-utils.ts` in `src/lib/__tests__/` | [ ] | [ ] |
| 46 | Add API tests for `geocode` and `bike-route` in `src/app/api/__tests__/` (NEW) | [ ] | [ ] |
@@ -110,7 +110,7 @@
## Phase 8 — Cleanup (~30 min)
| # | Item | ✅ | ✔️ |
|---|------|----|----|
|---|---|----|-|
| 48 | Delete old `server/` directory | [ ] | [ ] |
| 49 | Delete old `oebb-planner-app/` directory | [ ] | [ ] |
| 50 | Delete `oebb-planner.jsx` | [ ] | [ ] |
@@ -121,4 +121,4 @@
**✅ Implemented** — Code exists and is in the repo.
**✔️ Reviewed** — Code has been reviewed for correctness against the plan.
`[ ]` pending required · `[x]` done · `[~]` optional/deferred (never blocks phase advancement)
`[ ]` pending required · `[x]` done · `[~]` optional/deferred (never blocks phase advancement)
+11
View File
@@ -8,6 +8,7 @@
"name": "oebb_planner",
"version": "0.1.0",
"dependencies": {
"date-fns": "^4.1.0",
"next": "16.2.6",
"node-ical": "^0.18.0",
"react": "19.2.4",
@@ -3751,6 +3752,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+1
View File
@@ -12,6 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"date-fns": "^4.1.0",
"next": "16.2.6",
"node-ical": "^0.18.0",
"react": "19.2.4",
+112 -127
View File
@@ -1,149 +1,134 @@
"use client";
import React, { useState } from "react";
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday } from "date-fns";
import { Event } from "@/types";
interface CalendarViewProps {
type CalendarViewProps = {
events: Event[];
onDateSelect: (date: Date) => void;
selectedDate: Date;
onDateChange: (date: Date) => void;
}
className?: string;
};
const CalendarView: React.FC<CalendarViewProps> = ({ selectedDate, onDateChange }) => {
const [currentMonth, setCurrentMonth] = useState<Date>(
new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1),
);
const CalendarView: React.FC<CalendarViewProps> = ({ events, onDateSelect, selectedDate, className = "" }) => {
const [currentDate, setCurrentDate] = useState<Date>(selectedDate);
const goToPreviousMonth = () => {
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1));
};
const goToNextMonth = () => {
setCurrentMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1));
};
const getDaysInMonth = (year: number, month: number) => {
return new Date(year, month + 1, 0).getDate();
};
const getFirstDayOfMonth = (year: number, month: number) => {
return new Date(year, month, 1).getDay();
};
const renderCalendarDays = () => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDayOfMonth = getFirstDayOfMonth(year, month);
const days = [];
// Previous month's days
const prevMonthDays = getDaysInMonth(year, month - 1);
for (let i = firstDayOfMonth - 1; i >= 0; i--) {
const day = prevMonthDays - i;
const date = new Date(year, month - 1, day);
days.push(
<div key={`prev-${day}`} className="p-2 text-center text-gray-400 dark:text-gray-500">
{day}
</div>,
);
}
// Current month's days
for (let i = 1; i <= daysInMonth; i++) {
const date = new Date(year, month, i);
const isSelected =
date.getDate() === selectedDate.getDate() &&
date.getMonth() === selectedDate.getMonth() &&
date.getFullYear() === selectedDate.getFullYear();
days.push(
const renderHeader = () => {
return (
<div className="flex items-center justify-between mb-4">
<button
key={`current-${i}`}
onClick={() => onDateChange(date)}
className={`p-2 text-center rounded-lg transition-colors ${
isSelected
? "bg-blue-500 text-white"
: date.toDateString() === new Date().toDateString()
? "bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-200"
: "hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-900 dark:text-gray-100"
}`}
onClick={() => setCurrentDate(new Date(currentDate.getFullYear(), currentDate.getMonth() - 1, 1))}
className="p-2 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700"
>
{i}
</button>,
);
}
&larr;
</button>
<h2 className="text-xl font-semibold text-gray-900 dark: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-gray-700"
>
&rarr;
</button>
</div>
);
};
// Next month's days
const totalCells = 42; // 6 weeks * 7 days
const nextMonthDays = totalCells - days.length;
for (let i = 1; i <= nextMonthDays; i++) {
const date = new Date(year, month + 1, i);
days.push(
<div key={`next-${i}`} className="p-2 text-center text-gray-400 dark:text-gray-500">
{i}
const renderDays = () => {
const headers = [];
const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
for (let i = 0; i < 7; i++) {
headers.push(
<div key={i} className="text-center font-medium text-gray-600 dark:text-gray-300 py-2">
{daysOfWeek[i]}
</div>,
);
}
return days;
return <div className="grid grid-cols-7 gap-1 mb-1">{headers}</div>;
};
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
const renderCells = () => {
const monthStart = startOfMonth(currentDate);
const monthEnd = endOfMonth(monthStart);
const startDate = startOfMonth(monthStart);
const endDate = endOfMonth(monthEnd);
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const days = eachDayOfInterval({ start: startDate, end: endDate });
const cells: React.ReactNode[] = [];
days.forEach((day, index) => {
const dayEvents = events.filter((event) => {
const eventDate = new Date(event.eventTime);
return (
eventDate.getDate() === day.getDate() &&
eventDate.getMonth() === day.getMonth() &&
eventDate.getFullYear() === day.getFullYear()
);
});
const isCurrentMonth = isSameMonth(day, monthStart);
const isTodayDate = isToday(day);
cells.push(
<div
key={index}
onClick={() => onDateSelect(day)}
role="button"
tabIndex={0}
className={`
min-h-24 p-2 border border-gray-200 dark:border-gray-700 rounded-lg cursor-pointer
${!isCurrentMonth ? "bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500" : "bg-white dark:bg-gray-900"}
${isTodayDate ? "ring-2 ring-blue-500" : ""}
hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors
`}
>
<div className="text-right">
<span
className={`inline-flex items-center justify-center w-6 h-6 rounded-full text-sm ${
isTodayDate ? "bg-blue-500 text-white" : ""
}`}
>
{format(day, "d")}
</span>
</div>
<div className="mt-1 space-y-1 max-h-20 overflow-y-auto">
{dayEvents.slice(0, 3).map((event) => (
<div
key={event.id}
className="text-xs truncate bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 px-2 py-1 rounded"
>
{event.title}
</div>
))}
{dayEvents.length > 3 && (
<div className="text-xs text-gray-500 dark:text-gray-400">+{dayEvents.length - 3} more</div>
)}
</div>
</div>,
);
});
// Group cells into rows of 7
const rows = [];
for (let i = 0; i < cells.length; i += 7) {
rows.push(
<div key={i} className="grid grid-cols-7 gap-1 mb-1">
{cells.slice(i, i + 7)}
</div>,
);
}
return <div>{rows}</div>;
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
</h2>
<div className="flex space-x-2">
<button
onClick={goToPreviousMonth}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
aria-label="Previous month"
>
&lt;
</button>
<button
onClick={() => setCurrentMonth(new Date())}
className="px-3 py-1 text-sm rounded-lg bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600"
>
Today
</button>
<button
onClick={goToNextMonth}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
aria-label="Next month"
>
&gt;
</button>
</div>
</div>
<div className="grid grid-cols-7 gap-1 mb-2">
{dayNames.map((day) => (
<div key={day} className="p-2 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">{renderCalendarDays()}</div>
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 ${className}`}>
{renderHeader()}
{renderDays()}
{renderCells()}
</div>
);
};
+29 -78
View File
@@ -1,97 +1,48 @@
"use client";
import React from "react";
import { Event } from "../../types";
import { format } from "date-fns";
import { Event, TripDataEntry } from "@/types";
import EventCard from "@/app/event/EventCard";
interface DayEventsProps {
date: Date;
type DayEventsProps = {
events: Event[];
loading: boolean;
error: string | null;
}
date: Date;
tripData?: Record<string, TripDataEntry>; // Map of event id to trip data
className?: string;
};
const DayEvents: React.FC<DayEventsProps> = ({ date, events, loading, error }) => {
const formatDate = (date: Date) => {
return date.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
};
if (loading) {
const DayEvents: React.FC<DayEventsProps> = ({ events, date, tripData = {}, className = "" }) => {
const filteredEvents = events.filter((event) => {
// Direct Date comparison instead of parseISO
const eventDate = new Date(event.eventTime);
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{formatDate(date)}</h2>
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="animate-pulse flex space-x-4">
<div className="flex-1 space-y-2 py-1">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
</div>
))}
</div>
</div>
eventDate.getDate() === date.getDate() &&
eventDate.getMonth() === date.getMonth() &&
eventDate.getFullYear() === date.getFullYear()
);
}
});
if (error) {
if (filteredEvents.length === 0) {
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{formatDate(date)}</h2>
</div>
<div className="text-red-500 dark:text-red-400 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg">Error: {error}</div>
<div className={`text-center py-8 text-gray-500 dark:text-gray-400 ${className}`}>
<p>No events scheduled for {format(date, "MMMM d, yyyy")}</p>
</div>
);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{formatDate(date)}</h2>
<div className={`space-y-4 ${className}`}>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">Events for {format(date, "MMMM d, yyyy")}</h3>
<div className="space-y-3">
{filteredEvents.map((event) => (
<EventCard
key={event.id}
event={event}
tripData={tripData[event.id] || { journeys: [], destName: "", demo: false, loading: false }}
/>
))}
</div>
{events.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<p>No events scheduled for this day</p>
</div>
) : (
<div className="space-y-3">
{events.map((event) => {
// Format time for display
const eventTime = new Date(event.eventTime);
const timeString = eventTime.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
return (
<div
key={event.id}
className="p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:shadow-md transition-shadow"
>
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium text-gray-900 dark:text-white">{event.title}</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{timeString} {event.destination}
</p>
</div>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100">
{event.source === "calendar" ? "Calendar" : "Manual"}
</span>
</div>
</div>
);
})}
</div>
)}
</div>
);
};
+41 -65
View File
@@ -1,81 +1,57 @@
"use client";
import React, { useState, useEffect } from "react";
import React from "react";
import { Event } from "@/types";
import CalendarView from "./CalendarView";
import DayEvents from "./DayEvents";
import { Event } from "@/types";
const CalendarPage: React.FC = () => {
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
export default function CalendarPage() {
// Mock events for demonstration
const events: Event[] = [
{
id: "1",
title: "Meeting with team",
destination: "Berlin",
eventTime: new Date(new Date().setDate(new Date().getDate() + 1)), // Tomorrow
source: "manual",
},
{
id: "2",
title: "Train trip to Munich",
destination: "Munich",
eventTime: new Date(new Date().setDate(new Date().getDate() + 2)), // Day after tomorrow
source: "manual",
},
{
id: "3",
title: "Conference in Vienna",
destination: "Vienna",
eventTime: new Date(new Date().setDate(new Date().getDate() + 3)), // In 3 days
source: "calendar",
},
];
// In a real app, this would fetch from a database or API
useEffect(() => {
const fetchEvents = async () => {
setLoading(true);
try {
// Mock data for demonstration
const mockEvents: Event[] = [
{
id: "1",
title: "Meeting with team",
destination: "Berlin",
eventTime: new Date(Date.now() + 86400000), // Tomorrow
source: "manual",
},
{
id: "2",
title: "Train trip to Munich",
destination: "Munich",
eventTime: new Date(Date.now() + 172800000), // Day after tomorrow
source: "manual",
},
];
setEvents(mockEvents);
} catch (err) {
setError("Failed to load events");
console.error(err);
} finally {
setLoading(false);
}
};
const [selectedDate, setSelectedDate] = React.useState<Date>(new Date());
fetchEvents();
}, []);
const handleDateChange = (date: Date) => {
const handleDateSelect = (date: Date) => {
setSelectedDate(date);
};
// Filter events for the selected date
const eventsForSelectedDate = events.filter((event) => {
const eventDate = new Date(event.eventTime);
return (
eventDate.getDate() === selectedDate.getDate() &&
eventDate.getMonth() === selectedDate.getMonth() &&
eventDate.getFullYear() === selectedDate.getFullYear()
);
});
return (
<div className="flex flex-col min-h-screen p-4 md:p-8">
<div className="max-w-6xl mx-auto w-full">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-6">Calendar</h1>
<div className="max-w-6xl mx-auto p-4">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Calendar</h1>
<p className="text-gray-600 dark:text-gray-400">View and manage your events</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<CalendarView selectedDate={selectedDate} onDateChange={handleDateChange} />
</div>
<div>
<DayEvents date={selectedDate} events={eventsForSelectedDate} loading={loading} error={error} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<CalendarView events={events} onDateSelect={handleDateSelect} selectedDate={selectedDate} />
</div>
<div>
<DayEvents events={events} date={selectedDate} />
</div>
</div>
</div>
);
};
export default CalendarPage;
}