From a8700cedc8492ca86374a0171a42965dd355326a Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Sat, 9 May 2026 16:13:29 +0200 Subject: [PATCH] rewrite phase 6 --- CHECKLIST.md | 20 +-- package-lock.json | 11 ++ package.json | 1 + src/app/calendar/CalendarView.tsx | 239 ++++++++++++++---------------- src/app/calendar/DayEvents.tsx | 107 ++++--------- src/app/calendar/page.tsx | 106 +++++-------- 6 files changed, 204 insertions(+), 280 deletions(-) diff --git a/CHECKLIST.md b/CHECKLIST.md index d3c86b9..1269a24 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -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) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index a026727..6a00e22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index bbac638..8590e21 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/calendar/CalendarView.tsx b/src/app/calendar/CalendarView.tsx index 8728361..048f6b2 100644 --- a/src/app/calendar/CalendarView.tsx +++ b/src/app/calendar/CalendarView.tsx @@ -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 = ({ selectedDate, onDateChange }) => { - const [currentMonth, setCurrentMonth] = useState( - new Date(selectedDate.getFullYear(), selectedDate.getMonth(), 1), - ); +const CalendarView: React.FC = ({ events, onDateSelect, selectedDate, className = "" }) => { + const [currentDate, setCurrentDate] = useState(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( -
- {day} -
, - ); - } - - // 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 ( +
, - ); - } + ← + +

{format(currentDate, "MMMM yyyy")}

+ +
+ ); + }; - // 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( -
- {i} + const renderDays = () => { + const headers = []; + const daysOfWeek = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + for (let i = 0; i < 7; i++) { + headers.push( +
+ {daysOfWeek[i]}
, ); } - return days; + return
{headers}
; }; - 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( +
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 + `} + > +
+ + {format(day, "d")} + +
+
+ {dayEvents.slice(0, 3).map((event) => ( +
+ {event.title} +
+ ))} + {dayEvents.length > 3 && ( +
+{dayEvents.length - 3} more
+ )} +
+
, + ); + }); + + // Group cells into rows of 7 + const rows = []; + for (let i = 0; i < cells.length; i += 7) { + rows.push( +
+ {cells.slice(i, i + 7)} +
, + ); + } + + return
{rows}
; + }; return ( -
-
-

- {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()} -

-
- - - -
-
- -
- {dayNames.map((day) => ( -
- {day} -
- ))} -
- -
{renderCalendarDays()}
+
+ {renderHeader()} + {renderDays()} + {renderCells()}
); }; diff --git a/src/app/calendar/DayEvents.tsx b/src/app/calendar/DayEvents.tsx index a9ded68..eef8eec 100644 --- a/src/app/calendar/DayEvents.tsx +++ b/src/app/calendar/DayEvents.tsx @@ -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; // Map of event id to trip data + className?: string; +}; -const DayEvents: React.FC = ({ 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 = ({ events, date, tripData = {}, className = "" }) => { + const filteredEvents = events.filter((event) => { + // Direct Date comparison instead of parseISO + const eventDate = new Date(event.eventTime); return ( -
-
-

{formatDate(date)}

-
-
- {[1, 2, 3].map((i) => ( -
-
-
-
-
-
- ))} -
-
+ eventDate.getDate() === date.getDate() && + eventDate.getMonth() === date.getMonth() && + eventDate.getFullYear() === date.getFullYear() ); - } + }); - if (error) { + if (filteredEvents.length === 0) { return ( -
-
-

{formatDate(date)}

-
-
Error: {error}
+
+

No events scheduled for {format(date, "MMMM d, yyyy")}

); } return ( -
-
-

{formatDate(date)}

+
+

Events for {format(date, "MMMM d, yyyy")}

+
+ {filteredEvents.map((event) => ( + + ))}
- - {events.length === 0 ? ( -
-

No events scheduled for this day

-
- ) : ( -
- {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 ( -
-
-
-

{event.title}

-

- {timeString} • {event.destination} -

-
- - {event.source === "calendar" ? "Calendar" : "Manual"} - -
-
- ); - })} -
- )}
); }; diff --git a/src/app/calendar/page.tsx b/src/app/calendar/page.tsx index 563f1ec..f7c2406 100644 --- a/src/app/calendar/page.tsx +++ b/src/app/calendar/page.tsx @@ -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(new Date()); - const [events, setEvents] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(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(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 ( -
-
-

Calendar

+
+
+

Calendar

+

View and manage your events

+
-
-
- -
- -
- -
+
+
+ +
+
+
); -}; - -export default CalendarPage; +}