From cd3a29942f1a199aa0369bcb4d6bc7d7eb9ce3ea Mon Sep 17 00:00:00 2001 From: myrmidex Date: Tue, 7 Oct 2025 22:51:10 +0200 Subject: [PATCH] Fix timeline display with 24h format and timezone handling --- .gitignore | 1 + docker/backend/Dockerfile.dev | 2 +- frontend/src/components/TripDetail.css | 40 +++++--- .../components/plannables/PlannablesList.jsx | 34 ------- .../src/components/timeline/DaySection.css | 32 ++++++ .../src/components/timeline/DaySection.jsx | 21 ++-- frontend/src/components/timeline/HourRow.css | 98 +++++++++++++++++++ frontend/src/components/timeline/HourRow.jsx | 50 ++++++---- .../components/timeline/ScheduleItemModal.css | 98 +++++++++++++++++++ .../components/timeline/ScheduleItemModal.jsx | 45 +++++---- .../src/components/timeline/TripTimeline.css | 24 +++++ .../src/components/timeline/TripTimeline.jsx | 46 ++++++--- tests/jest-local.json | 2 +- .../integration/timeline-scheduling.test.js | 58 ++++++----- 14 files changed, 408 insertions(+), 143 deletions(-) create mode 100644 frontend/src/components/timeline/DaySection.css create mode 100644 frontend/src/components/timeline/HourRow.css create mode 100644 frontend/src/components/timeline/ScheduleItemModal.css create mode 100644 frontend/src/components/timeline/TripTimeline.css diff --git a/.gitignore b/.gitignore index 446e4e1..c09ab17 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /docker/data /.claude CLAUDE.md +/coverage \ No newline at end of file diff --git a/docker/backend/Dockerfile.dev b/docker/backend/Dockerfile.dev index 3bc831e..0974091 100644 --- a/docker/backend/Dockerfile.dev +++ b/docker/backend/Dockerfile.dev @@ -41,4 +41,4 @@ RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessio EXPOSE 8000 # Start Laravel development server with composer install -CMD sh -c "composer install && php artisan key:generate --force && php artisan serve --host=0.0.0.0 --port=8000" \ No newline at end of file +CMD sh -c "composer install && php artisan key:generate --force && php artisan migrate --force && php artisan serve --host=0.0.0.0 --port=8000" \ No newline at end of file diff --git a/frontend/src/components/TripDetail.css b/frontend/src/components/TripDetail.css index df4cbea..27ef97c 100644 --- a/frontend/src/components/TripDetail.css +++ b/frontend/src/components/TripDetail.css @@ -67,22 +67,26 @@ /* Content Layout */ .trip-detail-content { flex: 1; - display: flex; + display: grid; + grid-template-columns: 1fr var(--sidebar-width); gap: 0; height: calc(100vh - var(--header-height)); + min-height: 0; +} + +.trip-detail-main { + padding: var(--spacing-xl); + overflow-y: auto; + background: var(--color-bg-secondary); + order: 1; } .trip-detail-sidebar { width: var(--sidebar-width); background: var(--color-bg-primary); - border-right: 1px solid var(--color-border); - overflow-y: auto; -} - -.trip-detail-main { - flex: 1; - padding: var(--spacing-xl); + border-left: 1px solid var(--color-border); overflow-y: auto; + order: 2; } /* Calendar Placeholder */ @@ -165,19 +169,23 @@ /* Responsive */ @media (max-width: 768px) { .trip-detail-content { - flex-direction: column; + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; height: auto; } - .trip-detail-sidebar { - width: 100%; - border-right: none; - border-bottom: 1px solid var(--color-border); - min-height: 400px; + .trip-detail-main { + order: 2; + padding: var(--spacing-md); } - .trip-detail-main { - padding: var(--spacing-md); + .trip-detail-sidebar { + order: 1; + width: 100%; + border-left: none; + border-bottom: 1px solid var(--color-border); + min-height: 300px; + max-height: 50vh; } .trip-detail-header { diff --git a/frontend/src/components/plannables/PlannablesList.jsx b/frontend/src/components/plannables/PlannablesList.jsx index 8a2bc61..cf14b92 100644 --- a/frontend/src/components/plannables/PlannablesList.jsx +++ b/frontend/src/components/plannables/PlannablesList.jsx @@ -211,40 +211,6 @@ const PlannablesList = ({ tripId, onItemsChange }) => { )} - - {/* Calendar Slots Sections */} - {calendarSlots.map(slot => { - const items = plannedItemsBySlot[slot.id] || []; - const slotDate = new Date(slot.slot_date); - const dayName = slotDate.toLocaleDateString('en-US', { weekday: 'long' }); - const dateStr = slotDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); - - return ( -
-

- 📅 {slot.name} - {dayName}, {dateStr} - {items.length > 0 && ( - {items.length} - )} -

-
- {items.length === 0 ? ( -

No items planned for this day

- ) : ( - items.map(item => ( - - )) - )} -
-
- ); - })} {showForm && ( diff --git a/frontend/src/components/timeline/DaySection.css b/frontend/src/components/timeline/DaySection.css new file mode 100644 index 0000000..5f06c94 --- /dev/null +++ b/frontend/src/components/timeline/DaySection.css @@ -0,0 +1,32 @@ +.day-section { + background: var(--bg-card); + border: 1px solid var(--primary-color); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); +} + +.day-header { + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 2px solid rgba(228, 93, 4, 0.2); +} + +.day-header h4 { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 0.25rem 0; +} + +.day-header p { + font-size: 0.9rem; + color: var(--text-secondary); + margin: 0; +} + +.hours-grid { + display: flex; + flex-direction: column; + gap: 0.25rem; +} diff --git a/frontend/src/components/timeline/DaySection.jsx b/frontend/src/components/timeline/DaySection.jsx index 56f9194..162d863 100644 --- a/frontend/src/components/timeline/DaySection.jsx +++ b/frontend/src/components/timeline/DaySection.jsx @@ -1,5 +1,6 @@ import { useMemo } from 'react'; import HourRow from './HourRow'; +import './DaySection.css'; const START_HOUR = 6; // 6 AM const END_HOUR = 23; // 11 PM @@ -12,16 +13,16 @@ function DaySection({ date, dayNumber, slots, plannableItems, onScheduleItem }) year: 'numeric' }); + // Memoize slot-to-hour mapping to avoid recalculating on every render const slotsByHour = useMemo(() => { const mapping = {}; for (let hour = START_HOUR; hour <= END_HOUR; hour++) { // Pre-compute hour boundaries once per hour (not per slot) - const hourStart = new Date(date); - hourStart.setHours(hour, 0, 0, 0); - const hourEnd = new Date(date); - hourEnd.setHours(hour, 59, 59, 999); + // Create hour boundaries explicitly to avoid DST issues + const hourStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hour, 0, 0, 0); + const hourEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hour, 59, 59, 999); mapping[hour] = slots.filter(slot => { if (!slot.datetime_start) return false; @@ -35,18 +36,18 @@ function DaySection({ date, dayNumber, slots, plannableItems, onScheduleItem }) } return mapping; - }, [slots, date]); + }, [slots, date.getTime()]); return ( -
-
-

+
+
+

Day {dayNumber}: {dayName}

-

{dateString}

+

{dateString}

-
+
{Object.keys(slotsByHour).map(hour => ( { - const period = h >= 12 ? 'PM' : 'AM'; - const displayHour = h > 12 ? h - 12 : h === 0 ? 12 : h; - return `${displayHour}:00 ${period}`; + return `${h.toString().padStart(2, '0')}:00`; }; const handleAddClick = () => { @@ -16,47 +15,56 @@ function HourRow({ hour, date, slots, plannableItems, onScheduleItem }) { }; const handleSchedule = async (plannableItemId, startDatetime, endDatetime) => { - await onScheduleItem(plannableItemId, startDatetime, endDatetime); - setShowModal(false); + try { + await onScheduleItem(plannableItemId, startDatetime, endDatetime); + setShowModal(false); + } catch (error) { + // Error is already logged in parent, just rethrow to let modal handle it + throw error; + } }; // Check if there's a slot that starts at this hour const slotStartsHere = slots.find(slot => { + // Parse the datetime string and get the local hours const slotStart = new Date(slot.datetime_start); - return slotStart.getHours() === hour; + const startHour = slotStart.getHours(); + return startHour === hour; }); return ( <> -
+
{/* Hour label */} -
+
{formatHour(hour)}
{/* Content area */} -
+
{/* Scheduled items that start at this hour */} {slotStartsHere && ( -
-
-
-
+
+
+
+
{slotStartsHere.name}
-
+
{new Date(slotStartsHere.datetime_start).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit' + hour: '2-digit', + minute: '2-digit', + hour12: false })} {' - '} {new Date(slotStartsHere.datetime_end).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit' + hour: '2-digit', + minute: '2-digit', + hour12: false })}
{slotStartsHere.planned_items && slotStartsHere.planned_items.length > 0 && ( -
+
{slotStartsHere.planned_items.map(pi => pi.plannable_item?.name).join(', ')}
)} @@ -68,10 +76,10 @@ function HourRow({ hour, date, slots, plannableItems, onScheduleItem }) { {/* Add button - CSS-only hover */}
diff --git a/frontend/src/components/timeline/ScheduleItemModal.css b/frontend/src/components/timeline/ScheduleItemModal.css new file mode 100644 index 0000000..594eca0 --- /dev/null +++ b/frontend/src/components/timeline/ScheduleItemModal.css @@ -0,0 +1,98 @@ +.schedule-form { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.schedule-form-error { + background-color: #fee; + border: 1px solid #fcc; + color: #c33; + padding: 0.75rem 1rem; + border-radius: var(--border-radius-md); + font-size: 0.9rem; +} + +.schedule-form-group { + display: flex; + flex-direction: column; +} + +.schedule-form-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text-primary); + margin-bottom: 0.5rem; +} + +.schedule-form-select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--border-radius-md); + font-size: 1rem; + background-color: var(--color-bg-primary); + color: var(--color-text-primary); + transition: border-color var(--transition-normal); +} + +.schedule-form-select:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(228, 93, 4, 0.1); +} + +.schedule-time-group { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.schedule-form-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding-top: 1rem; + border-top: 1px solid var(--color-border-light); +} + +.schedule-btn-cancel { + padding: 0.75rem 1.5rem; + color: var(--color-text-primary); + background-color: var(--color-bg-secondary); + border: none; + border-radius: var(--border-radius-md); + cursor: pointer; + font-weight: 500; + transition: background-color var(--transition-normal); +} + +.schedule-btn-cancel:hover:not(:disabled) { + background-color: var(--color-border); +} + +.schedule-btn-cancel:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.schedule-btn-submit { + padding: 0.75rem 1.5rem; + color: white; + background-color: var(--primary-color); + border: none; + border-radius: var(--border-radius-md); + cursor: pointer; + font-weight: 500; + transition: background-color var(--transition-normal); +} + +.schedule-btn-submit:hover:not(:disabled) { + background-color: var(--primary-hover); +} + +.schedule-btn-submit:disabled { + background-color: var(--color-border); + cursor: not-allowed; +} diff --git a/frontend/src/components/timeline/ScheduleItemModal.jsx b/frontend/src/components/timeline/ScheduleItemModal.jsx index 5f6f91a..15d2baf 100644 --- a/frontend/src/components/timeline/ScheduleItemModal.jsx +++ b/frontend/src/components/timeline/ScheduleItemModal.jsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo } from 'react'; import BaseModal from '../BaseModal'; +import './ScheduleItemModal.css'; function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose }) { const [selectedItemId, setSelectedItemId] = useState(''); @@ -30,8 +31,11 @@ function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose }) return; } - // Construct full datetime strings - const dateString = date.toISOString().split('T')[0]; + // Construct full datetime strings using local date + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const dateString = `${year}-${month}-${day}`; const startDatetime = `${dateString} ${startTime}:00`; const endDatetime = `${dateString} ${endTime}:00`; @@ -44,9 +48,10 @@ function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose }) try { setSubmitting(true); await onSchedule(selectedItemId, startDatetime, endDatetime); + // Modal will be closed by parent (HourRow) on success } catch (err) { - setError(err.response?.data?.message || 'Failed to schedule item'); - } finally { + console.error('Error in modal submit:', err); + setError(err.response?.data?.message || err.message || 'Failed to schedule item'); setSubmitting(false); } }; @@ -64,22 +69,22 @@ function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose }) }, []); return ( - -
+ + {error && ( -
+
{error}
)} -
-