Fix timeline display with 24h format and timezone handling

This commit is contained in:
myrmidex 2025-10-07 22:51:10 +02:00
parent 935162ea70
commit cd3a29942f
14 changed files with 408 additions and 143 deletions

1
.gitignore vendored
View file

@ -2,3 +2,4 @@
/docker/data /docker/data
/.claude /.claude
CLAUDE.md CLAUDE.md
/coverage

View file

@ -41,4 +41,4 @@ RUN mkdir -p storage/app/public storage/framework/cache storage/framework/sessio
EXPOSE 8000 EXPOSE 8000
# Start Laravel development server with composer install # 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" CMD sh -c "composer install && php artisan key:generate --force && php artisan migrate --force && php artisan serve --host=0.0.0.0 --port=8000"

View file

@ -67,22 +67,26 @@
/* Content Layout */ /* Content Layout */
.trip-detail-content { .trip-detail-content {
flex: 1; flex: 1;
display: flex; display: grid;
grid-template-columns: 1fr var(--sidebar-width);
gap: 0; gap: 0;
height: calc(100vh - var(--header-height)); 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 { .trip-detail-sidebar {
width: var(--sidebar-width); width: var(--sidebar-width);
background: var(--color-bg-primary); background: var(--color-bg-primary);
border-right: 1px solid var(--color-border); border-left: 1px solid var(--color-border);
overflow-y: auto;
}
.trip-detail-main {
flex: 1;
padding: var(--spacing-xl);
overflow-y: auto; overflow-y: auto;
order: 2;
} }
/* Calendar Placeholder */ /* Calendar Placeholder */
@ -165,19 +169,23 @@
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 768px) {
.trip-detail-content { .trip-detail-content {
flex-direction: column; grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
height: auto; height: auto;
} }
.trip-detail-sidebar { .trip-detail-main {
width: 100%; order: 2;
border-right: none; padding: var(--spacing-md);
border-bottom: 1px solid var(--color-border);
min-height: 400px;
} }
.trip-detail-main { .trip-detail-sidebar {
padding: var(--spacing-md); order: 1;
width: 100%;
border-left: none;
border-bottom: 1px solid var(--color-border);
min-height: 300px;
max-height: 50vh;
} }
.trip-detail-header { .trip-detail-header {

View file

@ -211,40 +211,6 @@ const PlannablesList = ({ tripId, onItemsChange }) => {
)} )}
</div> </div>
</div> </div>
{/* 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 (
<div key={slot.id} className="plannables-section">
<h3 className="section-title">
📅 {slot.name}
<span className="section-date">{dayName}, {dateStr}</span>
{items.length > 0 && (
<span className="item-count">{items.length}</span>
)}
</h3>
<div className="section-items">
{items.length === 0 ? (
<p className="empty-message">No items planned for this day</p>
) : (
items.map(item => (
<PlannableItem
key={item.id}
item={item}
onEdit={handleEditItem}
onDelete={handleDeleteItem}
/>
))
)}
</div>
</div>
);
})}
</div> </div>
{showForm && ( {showForm && (

View file

@ -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;
}

View file

@ -1,5 +1,6 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import HourRow from './HourRow'; import HourRow from './HourRow';
import './DaySection.css';
const START_HOUR = 6; // 6 AM const START_HOUR = 6; // 6 AM
const END_HOUR = 23; // 11 PM const END_HOUR = 23; // 11 PM
@ -12,16 +13,16 @@ function DaySection({ date, dayNumber, slots, plannableItems, onScheduleItem })
year: 'numeric' year: 'numeric'
}); });
// Memoize slot-to-hour mapping to avoid recalculating on every render // Memoize slot-to-hour mapping to avoid recalculating on every render
const slotsByHour = useMemo(() => { const slotsByHour = useMemo(() => {
const mapping = {}; const mapping = {};
for (let hour = START_HOUR; hour <= END_HOUR; hour++) { for (let hour = START_HOUR; hour <= END_HOUR; hour++) {
// Pre-compute hour boundaries once per hour (not per slot) // Pre-compute hour boundaries once per hour (not per slot)
const hourStart = new Date(date); // Create hour boundaries explicitly to avoid DST issues
hourStart.setHours(hour, 0, 0, 0); const hourStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hour, 0, 0, 0);
const hourEnd = new Date(date); const hourEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hour, 59, 59, 999);
hourEnd.setHours(hour, 59, 59, 999);
mapping[hour] = slots.filter(slot => { mapping[hour] = slots.filter(slot => {
if (!slot.datetime_start) return false; if (!slot.datetime_start) return false;
@ -35,18 +36,18 @@ function DaySection({ date, dayNumber, slots, plannableItems, onScheduleItem })
} }
return mapping; return mapping;
}, [slots, date]); }, [slots, date.getTime()]);
return ( return (
<div className="day-section border border-gray-200 rounded-lg p-4 bg-white shadow-sm"> <div className="day-section">
<div className="day-header mb-4 pb-2 border-b border-gray-200"> <div className="day-header">
<h4 className="text-lg font-semibold text-gray-800"> <h4>
Day {dayNumber}: {dayName} Day {dayNumber}: {dayName}
</h4> </h4>
<p className="text-sm text-gray-500">{dateString}</p> <p>{dateString}</p>
</div> </div>
<div className="hours-grid space-y-1"> <div className="hours-grid">
{Object.keys(slotsByHour).map(hour => ( {Object.keys(slotsByHour).map(hour => (
<HourRow <HourRow
key={hour} key={hour}

View file

@ -0,0 +1,98 @@
.hour-row {
display: flex;
align-items: stretch;
border-left: 2px solid var(--primary-color);
min-height: 60px;
transition: background-color 0.2s ease;
position: relative;
}
.hour-row:hover {
background-color: rgba(228, 93, 4, 0.03);
}
.hour-label {
width: 100px;
flex-shrink: 0;
padding: 0.75rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
}
.hour-content {
flex: 1;
padding: 0.75rem 1rem;
position: relative;
}
.scheduled-slot {
background: rgba(59, 130, 246, 0.08);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: var(--radius-md);
padding: 0.75rem;
margin-bottom: 0.5rem;
}
.scheduled-slot-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.scheduled-slot-content {
flex: 1;
}
.scheduled-slot-title {
font-weight: 600;
color: #1e40af;
margin: 0 0 0.25rem 0;
font-size: 0.95rem;
}
.scheduled-slot-time {
font-size: 0.85rem;
color: #1d4ed8;
margin-bottom: 0.25rem;
}
.scheduled-slot-items {
margin-top: 0.5rem;
font-size: 0.8rem;
color: #2563eb;
}
.add-button {
position: absolute;
right: 0.5rem;
top: 0.5rem;
padding: 0.375rem;
background-color: #22c55e;
color: white;
border: none;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px rgba(34, 197, 94, 0.3);
transition: all 0.2s ease;
opacity: 0;
width: 28px;
height: 28px;
}
.hour-row:hover .add-button {
opacity: 1;
}
.add-button:hover {
background-color: #16a34a;
transform: scale(1.1);
}
.add-button svg {
width: 16px;
height: 16px;
}

View file

@ -1,14 +1,13 @@
import { useState } from 'react'; import { useState } from 'react';
import { PlusIcon } from '@heroicons/react/24/outline'; import { PlusIcon } from '@heroicons/react/24/outline';
import ScheduleItemModal from './ScheduleItemModal'; import ScheduleItemModal from './ScheduleItemModal';
import './HourRow.css';
function HourRow({ hour, date, slots, plannableItems, onScheduleItem }) { function HourRow({ hour, date, slots, plannableItems, onScheduleItem }) {
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const formatHour = (h) => { const formatHour = (h) => {
const period = h >= 12 ? 'PM' : 'AM'; return `${h.toString().padStart(2, '0')}:00`;
const displayHour = h > 12 ? h - 12 : h === 0 ? 12 : h;
return `${displayHour}:00 ${period}`;
}; };
const handleAddClick = () => { const handleAddClick = () => {
@ -16,47 +15,56 @@ function HourRow({ hour, date, slots, plannableItems, onScheduleItem }) {
}; };
const handleSchedule = async (plannableItemId, startDatetime, endDatetime) => { const handleSchedule = async (plannableItemId, startDatetime, endDatetime) => {
try {
await onScheduleItem(plannableItemId, startDatetime, endDatetime); await onScheduleItem(plannableItemId, startDatetime, endDatetime);
setShowModal(false); 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 // Check if there's a slot that starts at this hour
const slotStartsHere = slots.find(slot => { const slotStartsHere = slots.find(slot => {
// Parse the datetime string and get the local hours
const slotStart = new Date(slot.datetime_start); const slotStart = new Date(slot.datetime_start);
return slotStart.getHours() === hour; const startHour = slotStart.getHours();
return startHour === hour;
}); });
return ( return (
<> <>
<div className="hour-row flex items-stretch border-l-2 border-gray-300 hover:bg-gray-50 transition-colors min-h-[60px] group"> <div className="hour-row">
{/* Hour label */} {/* Hour label */}
<div className="hour-label w-24 flex-shrink-0 px-3 py-2 text-sm font-medium text-gray-600"> <div className="hour-label">
{formatHour(hour)} {formatHour(hour)}
</div> </div>
{/* Content area */} {/* Content area */}
<div className="hour-content flex-1 px-3 py-2 relative"> <div className="hour-content">
{/* Scheduled items that start at this hour */} {/* Scheduled items that start at this hour */}
{slotStartsHere && ( {slotStartsHere && (
<div className="scheduled-slot bg-blue-50 border border-blue-200 rounded p-2 mb-2"> <div className="scheduled-slot">
<div className="flex justify-between items-start"> <div className="scheduled-slot-header">
<div className="flex-1"> <div className="scheduled-slot-content">
<div className="font-medium text-blue-900"> <div className="scheduled-slot-title">
{slotStartsHere.name} {slotStartsHere.name}
</div> </div>
<div className="text-sm text-blue-700"> <div className="scheduled-slot-time">
{new Date(slotStartsHere.datetime_start).toLocaleTimeString('en-US', { {new Date(slotStartsHere.datetime_start).toLocaleTimeString('en-US', {
hour: 'numeric', hour: '2-digit',
minute: '2-digit' minute: '2-digit',
hour12: false
})} })}
{' - '} {' - '}
{new Date(slotStartsHere.datetime_end).toLocaleTimeString('en-US', { {new Date(slotStartsHere.datetime_end).toLocaleTimeString('en-US', {
hour: 'numeric', hour: '2-digit',
minute: '2-digit' minute: '2-digit',
hour12: false
})} })}
</div> </div>
{slotStartsHere.planned_items && slotStartsHere.planned_items.length > 0 && ( {slotStartsHere.planned_items && slotStartsHere.planned_items.length > 0 && (
<div className="mt-1 text-xs text-blue-600"> <div className="scheduled-slot-items">
{slotStartsHere.planned_items.map(pi => pi.plannable_item?.name).join(', ')} {slotStartsHere.planned_items.map(pi => pi.plannable_item?.name).join(', ')}
</div> </div>
)} )}
@ -68,10 +76,10 @@ function HourRow({ hour, date, slots, plannableItems, onScheduleItem }) {
{/* Add button - CSS-only hover */} {/* Add button - CSS-only hover */}
<button <button
onClick={handleAddClick} onClick={handleAddClick}
className="add-button absolute right-2 top-2 p-1 bg-green-500 text-white rounded-full hover:bg-green-600 transition-all shadow-md opacity-0 group-hover:opacity-100" className="add-button"
title="Schedule an item" title="Schedule an item"
> >
<PlusIcon className="w-4 h-4" /> <PlusIcon />
</button> </button>
</div> </div>
</div> </div>

View file

@ -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;
}

View file

@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import BaseModal from '../BaseModal'; import BaseModal from '../BaseModal';
import './ScheduleItemModal.css';
function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose }) { function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose }) {
const [selectedItemId, setSelectedItemId] = useState(''); const [selectedItemId, setSelectedItemId] = useState('');
@ -30,8 +31,11 @@ function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose })
return; return;
} }
// Construct full datetime strings // Construct full datetime strings using local date
const dateString = date.toISOString().split('T')[0]; 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 startDatetime = `${dateString} ${startTime}:00`;
const endDatetime = `${dateString} ${endTime}:00`; const endDatetime = `${dateString} ${endTime}:00`;
@ -44,9 +48,10 @@ function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose })
try { try {
setSubmitting(true); setSubmitting(true);
await onSchedule(selectedItemId, startDatetime, endDatetime); await onSchedule(selectedItemId, startDatetime, endDatetime);
// Modal will be closed by parent (HourRow) on success
} catch (err) { } catch (err) {
setError(err.response?.data?.message || 'Failed to schedule item'); console.error('Error in modal submit:', err);
} finally { setError(err.response?.data?.message || err.message || 'Failed to schedule item');
setSubmitting(false); setSubmitting(false);
} }
}; };
@ -64,22 +69,22 @@ function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose })
}, []); }, []);
return ( return (
<BaseModal onClose={onClose} title="Schedule Item"> <BaseModal isOpen={true} onClose={onClose} title="Schedule Item">
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="schedule-form">
{error && ( {error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded"> <div className="schedule-form-error">
{error} {error}
</div> </div>
)} )}
<div> <div className="schedule-form-group">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="schedule-form-label">
Select Item Select Item
</label> </label>
<select <select
value={selectedItemId} value={selectedItemId}
onChange={(e) => setSelectedItemId(e.target.value)} onChange={(e) => setSelectedItemId(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="schedule-form-select"
required required
> >
<option value="">-- Choose an item --</option> <option value="">-- Choose an item --</option>
@ -91,15 +96,15 @@ function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose })
</select> </select>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="schedule-time-group">
<div> <div className="schedule-form-group">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="schedule-form-label">
Start Time Start Time
</label> </label>
<select <select
value={startTime} value={startTime}
onChange={(e) => setStartTime(e.target.value)} onChange={(e) => setStartTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="schedule-form-select"
required required
> >
{timeOptions.map(time => ( {timeOptions.map(time => (
@ -110,14 +115,14 @@ function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose })
</select> </select>
</div> </div>
<div> <div className="schedule-form-group">
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="schedule-form-label">
End Time End Time
</label> </label>
<select <select
value={endTime} value={endTime}
onChange={(e) => setEndTime(e.target.value)} onChange={(e) => setEndTime(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" className="schedule-form-select"
required required
> >
{timeOptions.map(time => ( {timeOptions.map(time => (
@ -129,18 +134,18 @@ function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose })
</div> </div>
</div> </div>
<div className="flex justify-end gap-2 pt-4"> <div className="schedule-form-actions">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded hover:bg-gray-200 transition-colors" className="schedule-btn-cancel"
disabled={submitting} disabled={submitting}
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
className="px-4 py-2 text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors disabled:bg-blue-300" className="schedule-btn-submit"
disabled={submitting} disabled={submitting}
> >
{submitting ? 'Scheduling...' : 'Schedule'} {submitting ? 'Scheduling...' : 'Schedule'}

View file

@ -0,0 +1,24 @@
.trip-timeline {
width: 100%;
}
.trip-timeline h3 {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: 1.5rem;
color: var(--text-primary);
font-family: var(--font-secondary);
}
.timeline-days-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.timeline-loading {
padding: 2rem;
color: var(--text-muted);
text-align: center;
font-size: 1.1rem;
}

View file

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import DaySection from './DaySection'; import DaySection from './DaySection';
import axios from 'axios'; import axios from 'axios';
import './TripTimeline.css';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
@ -18,7 +19,8 @@ function TripTimeline({ trip, plannableItems, onScheduleSuccess }) {
const response = await axios.get(`${API_URL}/api/trips/${trip.id}/calendar-slots`, { const response = await axios.get(`${API_URL}/api/trips/${trip.id}/calendar-slots`, {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
}); });
setCalendarSlots(response.data.data || []); const slots = response.data.data || [];
setCalendarSlots(slots);
} catch (error) { } catch (error) {
console.error('Error fetching calendar slots:', error); console.error('Error fetching calendar slots:', error);
} finally { } finally {
@ -29,14 +31,16 @@ function TripTimeline({ trip, plannableItems, onScheduleSuccess }) {
const handleScheduleItem = async (plannableItemId, startDatetime, endDatetime) => { const handleScheduleItem = async (plannableItemId, startDatetime, endDatetime) => {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
await axios.post( const payload = {
`${API_URL}/api/planned-items`,
{
plannable_item_id: plannableItemId, plannable_item_id: plannableItemId,
trip_id: trip.id, trip_id: trip.id,
start_datetime: startDatetime, start_datetime: startDatetime,
end_datetime: endDatetime, end_datetime: endDatetime,
}, };
const response = await axios.post(
`${API_URL}/api/planned-items`,
payload,
{ {
headers: { Authorization: `Bearer ${token}` } headers: { Authorization: `Bearer ${token}` }
} }
@ -48,6 +52,7 @@ function TripTimeline({ trip, plannableItems, onScheduleSuccess }) {
} }
} catch (error) { } catch (error) {
console.error('Error scheduling item:', error); console.error('Error scheduling item:', error);
console.error('Error response:', error.response?.data);
throw error; throw error;
} }
}; };
@ -57,31 +62,42 @@ function TripTimeline({ trip, plannableItems, onScheduleSuccess }) {
const start = new Date(trip.start_date); const start = new Date(trip.start_date);
const end = new Date(trip.end_date); const end = new Date(trip.end_date);
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { for (let d = new Date(start); d <= end; ) {
days.push(new Date(d)); days.push(new Date(d));
d.setDate(d.getDate() + 1);
} }
return days; return days;
}; };
const getSlotsForDay = (date) => { const getSlotsForDay = (date) => {
const dateString = date.toISOString().split('T')[0]; // Format date as YYYY-MM-DD in local timezone, not UTC
return calendarSlots.filter(slot => slot.slot_date === dateString); 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 slots = calendarSlots.filter(slot => {
// slot_date comes as "2025-12-12T00:00:00.000000Z", extract just the date part
const slotDateString = slot.slot_date.split('T')[0];
return slotDateString === dateString;
});
return slots;
}; };
if (loading) { if (loading) {
return <div className="p-4 text-gray-500">Loading timeline...</div>; return <div className="timeline-loading">Loading timeline...</div>;
} }
const days = generateDays(); const days = generateDays();
return ( return (
<div className="trip-timeline"> <div className="trip-timeline">
<h3 className="text-xl font-semibold mb-4">Trip Timeline</h3> <h3>Trip Timeline</h3>
<div className="space-y-8"> <div className="timeline-days-container">
{days.map((day, index) => ( {days.map((day, index) => (
<DaySection <DaySection
key={day.toISOString()} key={`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`}
date={day} date={day}
dayNumber={index + 1} dayNumber={index + 1}
slots={getSlotsForDay(day)} slots={getSlotsForDay(day)}

View file

@ -2,5 +2,5 @@
"testEnvironment": "node", "testEnvironment": "node",
"testMatch": ["**/specs/**/*.test.js"], "testMatch": ["**/specs/**/*.test.js"],
"setupFilesAfterEnv": ["<rootDir>/support/config/jest.setup.local.js"], "setupFilesAfterEnv": ["<rootDir>/support/config/jest.setup.local.js"],
"testTimeout": 30000 "testTimeout": 60000
} }

View file

@ -110,9 +110,14 @@ describe('Timeline Scheduling Feature Test', () => {
// Helper function to create a plannable item // Helper function to create a plannable item
async function createPlannableItem(itemData) { async function createPlannableItem(itemData) {
// Click Add Item button in sidebar // Ensure no modal is already open
await driver.sleep(500);
// Click Add Item button in sidebar using JavaScript click
const addItemButton = await driver.findElement(By.xpath('//button[contains(text(), "Add Item")]')); const addItemButton = await driver.findElement(By.xpath('//button[contains(text(), "Add Item")]'));
await addItemButton.click(); await driver.executeScript("arguments[0].scrollIntoView(true);", addItemButton);
await driver.sleep(300);
await driver.executeScript("arguments[0].click();", addItemButton);
// Wait for form modal // Wait for form modal
await driver.wait(until.elementLocated(By.className('plannable-form-modal')), 5000); await driver.wait(until.elementLocated(By.className('plannable-form-modal')), 5000);
@ -142,7 +147,7 @@ describe('Timeline Scheduling Feature Test', () => {
// Wait for modal to close // Wait for modal to close
await driver.sleep(1500); await driver.sleep(1500);
await driver.wait(async () => { await driver.wait(async () => {
const overlays = await driver.findElements(By.css('.plannable-form-overlay')); const overlays = await driver.findElements(By.css('.plannable-form-overlay, .plannable-form-modal'));
for (const overlay of overlays) { for (const overlay of overlays) {
try { try {
if (await overlay.isDisplayed()) { if (await overlay.isDisplayed()) {
@ -154,6 +159,9 @@ describe('Timeline Scheduling Feature Test', () => {
} }
return true; return true;
}, 10000); }, 10000);
// Additional wait to ensure UI is stable
await driver.sleep(500);
} }
// Helper to wait for modal close // Helper to wait for modal close
@ -211,15 +219,15 @@ describe('Timeline Scheduling Feature Test', () => {
// Get first day section // Get first day section
const firstDaySection = await driver.findElement(By.className('day-section')); const firstDaySection = await driver.findElement(By.className('day-section'));
// Should have hour rows (6 AM to 11 PM = 18 hours) // Should have hour rows (06:00 to 23:00 = 18 hours)
const hourRows = await firstDaySection.findElements(By.className('hour-row')); const hourRows = await firstDaySection.findElements(By.className('hour-row'));
expect(hourRows.length).toBeGreaterThanOrEqual(18); expect(hourRows.length).toBeGreaterThanOrEqual(18);
// Verify specific hour labels exist // Verify specific hour labels exist
const hour6am = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "6:00 AM")]')); const hour6am = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "06:00")]'));
expect(await hour6am.isDisplayed()).toBe(true); expect(await hour6am.isDisplayed()).toBe(true);
const hour11pm = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "11:00 PM")]')); const hour11pm = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "23:00")]'));
expect(await hour11pm.isDisplayed()).toBe(true); expect(await hour11pm.isDisplayed()).toBe(true);
}); });
}); });
@ -250,8 +258,8 @@ describe('Timeline Scheduling Feature Test', () => {
notes: 'Morning coffee' notes: 'Morning coffee'
}); });
// Now find the 8 AM hour row // Now find the 08:00 hour row
const hour8am = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "8:00 AM")]/following-sibling::div[contains(@class, "hour-content")]')); const hour8am = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "08:00")]/following-sibling::div[contains(@class, "hour-content")]'));
// Hover to show + button // Hover to show + button
await driver.actions().move({ origin: hour8am }).perform(); await driver.actions().move({ origin: hour8am }).perform();
@ -285,11 +293,11 @@ describe('Timeline Scheduling Feature Test', () => {
await driver.sleep(1000); await driver.sleep(1000);
// Find the 10 AM hour row on Day 1 // Find the 10:00 hour row on Day 1
const daySections = await driver.findElements(By.className('day-section')); const daySections = await driver.findElements(By.className('day-section'));
const day1Section = daySections[0]; const day1Section = daySections[0];
const hour10am = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "10:00 AM")]/following-sibling::div[contains(@class, "hour-content")]')); const hour10am = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "10:00")]/following-sibling::div[contains(@class, "hour-content")]'));
// Hover and click + // Hover and click +
await driver.actions().move({ origin: hour10am }).perform(); await driver.actions().move({ origin: hour10am }).perform();
@ -321,12 +329,12 @@ describe('Timeline Scheduling Feature Test', () => {
// Wait for modal to close // Wait for modal to close
await waitForModalClose(); await waitForModalClose();
// Verify item appears in timeline at 10 AM // Verify item appears in timeline at 10:00
await driver.sleep(1000); await driver.sleep(1000);
const scheduledItem = await driver.findElement(By.xpath('//div[contains(@class, "scheduled-slot") and contains(., "Louvre Museum Visit")]')); const scheduledItem = await driver.findElement(By.xpath('//div[contains(@class, "scheduled-slot") and contains(., "Louvre Museum Visit")]'));
expect(await scheduledItem.isDisplayed()).toBe(true); expect(await scheduledItem.isDisplayed()).toBe(true);
// Verify time display shows 10:00 AM - 12:00 PM // Verify time display shows 10:00 - 12:00
const timeDisplay = await scheduledItem.getText(); const timeDisplay = await scheduledItem.getText();
expect(timeDisplay).toContain('10:00'); expect(timeDisplay).toContain('10:00');
expect(timeDisplay).toContain('12:00'); expect(timeDisplay).toContain('12:00');
@ -350,11 +358,11 @@ describe('Timeline Scheduling Feature Test', () => {
await driver.sleep(1000); await driver.sleep(1000);
// Schedule first item at 8 AM // Schedule first item at 08:00
const daySections = await driver.findElements(By.className('day-section')); const daySections = await driver.findElements(By.className('day-section'));
const day2Section = daySections[1]; // Day 2 const day2Section = daySections[1]; // Day 2
const hour8am = await day2Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "8:00 AM")]/following-sibling::div[contains(@class, "hour-content")]')); const hour8am = await day2Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "08:00")]/following-sibling::div[contains(@class, "hour-content")]'));
await driver.actions().move({ origin: hour8am }).perform(); await driver.actions().move({ origin: hour8am }).perform();
await driver.sleep(500); await driver.sleep(500);
@ -371,8 +379,8 @@ describe('Timeline Scheduling Feature Test', () => {
await driver.sleep(1000); await driver.sleep(1000);
// Schedule second item at 3 PM // Schedule second item at 15:00
const hour3pm = await day2Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "3:00 PM")]/following-sibling::div[contains(@class, "hour-content")]')); const hour3pm = await day2Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "15:00")]/following-sibling::div[contains(@class, "hour-content")]'));
await driver.actions().move({ origin: hour3pm }).perform(); await driver.actions().move({ origin: hour3pm }).perform();
await driver.sleep(500); await driver.sleep(500);
@ -411,11 +419,11 @@ describe('Timeline Scheduling Feature Test', () => {
await driver.sleep(1000); await driver.sleep(1000);
// Open schedule modal at 2 PM // Open schedule modal at 14:00
const daySections = await driver.findElements(By.className('day-section')); const daySections = await driver.findElements(By.className('day-section'));
const day3Section = daySections[2]; // Day 3 const day3Section = daySections[2]; // Day 3
const hour2pm = await day3Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "2:00 PM")]/following-sibling::div[contains(@class, "hour-content")]')); const hour2pm = await day3Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "14:00")]/following-sibling::div[contains(@class, "hour-content")]'));
await driver.actions().move({ origin: hour2pm }).perform(); await driver.actions().move({ origin: hour2pm }).perform();
await driver.sleep(500); await driver.sleep(500);
@ -463,7 +471,7 @@ describe('Timeline Scheduling Feature Test', () => {
const daySections = await driver.findElements(By.className('day-section')); const daySections = await driver.findElements(By.className('day-section'));
const day1Section = daySections[0]; const day1Section = daySections[0];
const hour11am = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "11:00 AM")]/following-sibling::div[contains(@class, "hour-content")]')); const hour11am = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "11:00")]/following-sibling::div[contains(@class, "hour-content")]'));
await driver.actions().move({ origin: hour11am }).perform(); await driver.actions().move({ origin: hour11am }).perform();
await driver.sleep(500); await driver.sleep(500);
@ -502,8 +510,8 @@ describe('Timeline Scheduling Feature Test', () => {
const daySections = await driver.findElements(By.className('day-section')); const daySections = await driver.findElements(By.className('day-section'));
const day1Section = daySections[0]; const day1Section = daySections[0];
// Schedule Dinner first (7 PM) // Schedule Dinner first (19:00)
const hour7pm = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "7:00 PM")]/following-sibling::div[contains(@class, "hour-content")]')); const hour7pm = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "19:00")]/following-sibling::div[contains(@class, "hour-content")]'));
await driver.actions().move({ origin: hour7pm }).perform(); await driver.actions().move({ origin: hour7pm }).perform();
await driver.sleep(500); await driver.sleep(500);
await hour7pm.findElement(By.css('button')).click(); await hour7pm.findElement(By.css('button')).click();
@ -515,8 +523,8 @@ describe('Timeline Scheduling Feature Test', () => {
await waitForModalClose(); await waitForModalClose();
await driver.sleep(1000); await driver.sleep(1000);
// Schedule Breakfast (7 AM) // Schedule Breakfast (07:00)
const hour7am = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "7:00 AM")]/following-sibling::div[contains(@class, "hour-content")]')); const hour7am = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "07:00")]/following-sibling::div[contains(@class, "hour-content")]'));
await driver.actions().move({ origin: hour7am }).perform(); await driver.actions().move({ origin: hour7am }).perform();
await driver.sleep(500); await driver.sleep(500);
await hour7am.findElement(By.css('button')).click(); await hour7am.findElement(By.css('button')).click();
@ -528,8 +536,8 @@ describe('Timeline Scheduling Feature Test', () => {
await waitForModalClose(); await waitForModalClose();
await driver.sleep(1000); await driver.sleep(1000);
// Schedule Lunch (12 PM) // Schedule Lunch (12:00)
const hour12pm = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "12:00 PM")]/following-sibling::div[contains(@class, "hour-content")]')); const hour12pm = await day1Section.findElement(By.xpath('.//div[contains(@class, "hour-label") and contains(text(), "12:00")]/following-sibling::div[contains(@class, "hour-content")]'));
await driver.actions().move({ origin: hour12pm }).perform(); await driver.actions().move({ origin: hour12pm }).perform();
await driver.sleep(500); await driver.sleep(500);
await hour12pm.findElement(By.css('button')).click(); await hour12pm.findElement(By.css('button')).click();