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/backend/app/Domain/CalendarSlot/Policies/CalendarSlotPolicy.php b/backend/app/Domain/CalendarSlot/Policies/CalendarSlotPolicy.php new file mode 100644 index 0000000..a23d08b --- /dev/null +++ b/backend/app/Domain/CalendarSlot/Policies/CalendarSlotPolicy.php @@ -0,0 +1,34 @@ +created_by_user_id === $user->id; + } + + /** + * Determine if the user can update the calendar slot + */ + public function update(User $user, CalendarSlot $calendarSlot): bool + { + return $calendarSlot->trip->created_by_user_id === $user->id; + } + + /** + * Determine if the user can reorder items in the calendar slot + */ + public function reorder(User $user, CalendarSlot $calendarSlot): bool + { + return $calendarSlot->trip->created_by_user_id === $user->id; + } +} diff --git a/backend/app/Domain/PlannableItem/Policies/PlannableItemPolicy.php b/backend/app/Domain/PlannableItem/Policies/PlannableItemPolicy.php new file mode 100644 index 0000000..0bb267b --- /dev/null +++ b/backend/app/Domain/PlannableItem/Policies/PlannableItemPolicy.php @@ -0,0 +1,42 @@ +created_by_user_id === $user->id; + } + + /** + * Determine if the user can view the plannable item + */ + public function view(User $user, PlannableItem $plannableItem): bool + { + return $plannableItem->trip->created_by_user_id === $user->id; + } + + /** + * Determine if the user can update the plannable item + */ + public function update(User $user, PlannableItem $plannableItem): bool + { + return $plannableItem->trip->created_by_user_id === $user->id; + } + + /** + * Determine if the user can delete the plannable item + */ + public function delete(User $user, PlannableItem $plannableItem): bool + { + return $plannableItem->trip->created_by_user_id === $user->id; + } +} diff --git a/backend/app/Domain/PlannedItem/Actions/CreatePlannedItemAction.php b/backend/app/Domain/PlannedItem/Actions/CreatePlannedItemAction.php new file mode 100644 index 0000000..ed2523a --- /dev/null +++ b/backend/app/Domain/PlannedItem/Actions/CreatePlannedItemAction.php @@ -0,0 +1,104 @@ +toDateString(); + + // Verify the plannable item belongs to the trip + $plannableItem = PlannableItem::findOrFail($data['plannable_item_id']); + if ($plannableItem->trip_id !== $data['trip_id']) { + throw new \Exception('Plannable item does not belong to this trip'); + } + + // Use database transaction to prevent race conditions + $calendarSlot = \DB::transaction(function () use ($data, $plannableItem, $startDatetime, $endDatetime, $slotDate) { + // Use firstOrCreate to prevent duplicate slots with same times + $calendarSlot = CalendarSlot::firstOrCreate( + [ + 'trip_id' => $data['trip_id'], + 'datetime_start' => $startDatetime, + 'datetime_end' => $endDatetime, + 'slot_date' => $slotDate, + ], + [ + 'name' => $plannableItem->name, + 'slot_order' => 0, // Will be recalculated + ] + ); + + // Recalculate slot orders to ensure consistency + $this->calendarSlotService->recalculateSlotOrdersForDate( + $data['trip_id'], + $slotDate + ); + + // Create PlannedItem link (use firstOrCreate to prevent race condition duplicates) + PlannedItem::firstOrCreate( + [ + 'plannable_item_id' => $data['plannable_item_id'], + 'calendar_slot_id' => $calendarSlot->id, + ], + [ + 'sort_order' => $data['sort_order'] ?? 0, + ] + ); + + return $calendarSlot; + }); + + return $calendarSlot->load(['plannedItems.plannableItem']); + } + + /** + * Create planned item from existing calendar slot (legacy flow) + * + * @param array $data ['plannable_item_id', 'calendar_slot_id', 'sort_order'?] + * @return PlannedItem + * @throws \Exception + */ + public function executeFromSlot(array $data): PlannedItem + { + // Validate that calendar slot and plannable item belong to the same trip + $plannableItem = PlannableItem::findOrFail($data['plannable_item_id']); + $calendarSlot = CalendarSlot::findOrFail($data['calendar_slot_id']); + + if ($plannableItem->trip_id !== $calendarSlot->trip_id) { + throw new \Exception('Calendar slot and plannable item must belong to the same trip'); + } + + $plannedItem = PlannedItem::updateOrCreate( + [ + 'plannable_item_id' => $data['plannable_item_id'], + 'calendar_slot_id' => $data['calendar_slot_id'], + ], + [ + 'sort_order' => $data['sort_order'] ?? 0, + ] + ); + + return $plannedItem->load(['plannableItem', 'calendarSlot']); + } +} diff --git a/backend/app/Domain/PlannedItem/Actions/DeletePlannedItemAction.php b/backend/app/Domain/PlannedItem/Actions/DeletePlannedItemAction.php new file mode 100644 index 0000000..cfc43bf --- /dev/null +++ b/backend/app/Domain/PlannedItem/Actions/DeletePlannedItemAction.php @@ -0,0 +1,19 @@ +delete(); + } +} diff --git a/backend/app/Domain/PlannedItem/Actions/UpdatePlannedItemAction.php b/backend/app/Domain/PlannedItem/Actions/UpdatePlannedItemAction.php new file mode 100644 index 0000000..88b89c8 --- /dev/null +++ b/backend/app/Domain/PlannedItem/Actions/UpdatePlannedItemAction.php @@ -0,0 +1,22 @@ +update($data); + + return $plannedItem->load(['plannableItem', 'calendarSlot']); + } +} diff --git a/backend/app/Domain/PlannedItem/Policies/PlannedItemPolicy.php b/backend/app/Domain/PlannedItem/Policies/PlannedItemPolicy.php new file mode 100644 index 0000000..fec6fc2 --- /dev/null +++ b/backend/app/Domain/PlannedItem/Policies/PlannedItemPolicy.php @@ -0,0 +1,54 @@ +where('id', $tripId) + ->where('created_by_user_id', $user->id) + ->exists(); + } + + /** + * Determine if the user can update the planned item + */ + public function update(User $user, PlannedItem $plannedItem): bool + { + return \DB::table('planned_items') + ->join('calendar_slots', 'planned_items.calendar_slot_id', '=', 'calendar_slots.id') + ->join('trips', 'calendar_slots.trip_id', '=', 'trips.id') + ->where('planned_items.id', $plannedItem->id) + ->where('trips.created_by_user_id', $user->id) + ->exists(); + } + + /** + * Determine if the user can delete the planned item + */ + public function delete(User $user, PlannedItem $plannedItem): bool + { + return $this->update($user, $plannedItem); + } + + /** + * Determine if the user can move a planned item to a new calendar slot + */ + public function moveToSlot(User $user, int $calendarSlotId): bool + { + return \DB::table('calendar_slots') + ->join('trips', 'calendar_slots.trip_id', '=', 'trips.id') + ->where('calendar_slots.id', $calendarSlotId) + ->where('trips.created_by_user_id', $user->id) + ->exists(); + } +} diff --git a/backend/app/Domain/Trip/Policies/TripPolicy.php b/backend/app/Domain/Trip/Policies/TripPolicy.php new file mode 100644 index 0000000..5b7ea87 --- /dev/null +++ b/backend/app/Domain/Trip/Policies/TripPolicy.php @@ -0,0 +1,33 @@ +created_by_user_id === $user->id; + } + + /** + * Determine if the user can update the trip + */ + public function update(User $user, Trip $trip): bool + { + return $trip->created_by_user_id === $user->id; + } + + /** + * Determine if the user can delete the trip + */ + public function delete(User $user, Trip $trip): bool + { + return $trip->created_by_user_id === $user->id; + } +} diff --git a/backend/app/Domain/Trip/Services/CalendarSlotService.php b/backend/app/Domain/Trip/Services/CalendarSlotService.php index 24fa3b1..a7d84df 100644 --- a/backend/app/Domain/Trip/Services/CalendarSlotService.php +++ b/backend/app/Domain/Trip/Services/CalendarSlotService.php @@ -69,4 +69,43 @@ public function deleteSlotsForTrip(Trip $trip): void { $trip->calendarSlots()->delete(); } + + /** + * Calculate the slot_order for a new slot based on datetime_start + * Orders chronologically by start time within the same day + */ + public function calculateSlotOrder(int $tripId, string $slotDate, Carbon $datetimeStart): int + { + $maxOrder = CalendarSlot::where('trip_id', $tripId) + ->where('slot_date', $slotDate) + ->where('datetime_start', '<', $datetimeStart) + ->max('slot_order'); + + return ($maxOrder ?? -1) + 1; + } + + /** + * Recalculate slot_order for all slots on a given date + * Orders by datetime_start ASC + * Uses database transaction with individual updates for safety + */ + public function recalculateSlotOrdersForDate(int $tripId, string $slotDate): void + { + $slots = CalendarSlot::where('trip_id', $tripId) + ->where('slot_date', $slotDate) + ->orderBy('datetime_start') + ->get(); + + if ($slots->isEmpty()) { + return; + } + + // Update each slot's order within a transaction + \DB::transaction(function () use ($slots) { + foreach ($slots as $index => $slot) { + $slot->slot_order = $index; + $slot->save(); + } + }); + } } \ No newline at end of file diff --git a/backend/app/Infrastructure/Http/Controllers/API/CalendarSlot/CalendarSlotController.php b/backend/app/Infrastructure/Http/Controllers/API/CalendarSlot/CalendarSlotController.php index 5051908..58b1d76 100644 --- a/backend/app/Infrastructure/Http/Controllers/API/CalendarSlot/CalendarSlotController.php +++ b/backend/app/Infrastructure/Http/Controllers/API/CalendarSlot/CalendarSlotController.php @@ -2,6 +2,7 @@ namespace App\Infrastructure\Http\Controllers\API\CalendarSlot; +use App\Domain\CalendarSlot\Policies\CalendarSlotPolicy; use App\Infrastructure\Http\Controllers\Controller; use App\Models\CalendarSlot; use App\Models\Trip; @@ -11,16 +12,19 @@ class CalendarSlotController extends Controller { + public function __construct( + private CalendarSlotPolicy $policy + ) {} public function index(Trip $trip): JsonResponse { - // Check if user owns the trip - if ($trip->created_by_user_id !== auth()->id()) { + if (!$this->policy->viewAny(auth()->user(), $trip)) { return response()->json(['message' => 'Forbidden'], 403); } $calendarSlots = $trip->calendarSlots() - ->with(['plannableItems']) - ->orderBy('slot_order') + ->with(['plannedItems.plannableItem']) + ->orderBy('slot_date') + ->orderBy('datetime_start') ->get(); return response()->json(['data' => $calendarSlots]); @@ -28,8 +32,7 @@ public function index(Trip $trip): JsonResponse public function update(Request $request, CalendarSlot $calendarSlot): JsonResponse { - // Check if user owns the trip - if ($calendarSlot->trip->created_by_user_id !== auth()->id()) { + if (!$this->policy->update(auth()->user(), $calendarSlot)) { return response()->json(['message' => 'Forbidden'], 403); } @@ -44,18 +47,35 @@ public function update(Request $request, CalendarSlot $calendarSlot): JsonRespon public function reorder(Request $request, CalendarSlot $calendarSlot): JsonResponse { + if (!$this->policy->reorder(auth()->user(), $calendarSlot)) { + return response()->json(['message' => 'Forbidden'], 403); + } + $validated = $request->validate([ 'items' => 'required|array', 'items.*.plannable_item_id' => 'required|exists:plannable_items,id', 'items.*.sort_order' => 'required|integer', ]); + // Validate all plannable items belong to the same trip + $trip = $calendarSlot->trip; + $validPlannableItemIds = $trip->plannableItems()->pluck('id')->toArray(); + foreach ($validated['items'] as $item) { - PlannedItem::where('calendar_slot_id', $calendarSlot->id) - ->where('plannable_item_id', $item['plannable_item_id']) - ->update(['sort_order' => $item['sort_order']]); + if (!in_array($item['plannable_item_id'], $validPlannableItemIds)) { + return response()->json(['message' => 'Invalid plannable item for this trip'], 422); + } } + // Update sort orders in transaction + \DB::transaction(function () use ($validated, $calendarSlot) { + foreach ($validated['items'] as $item) { + PlannedItem::where('calendar_slot_id', $calendarSlot->id) + ->where('plannable_item_id', $item['plannable_item_id']) + ->update(['sort_order' => $item['sort_order']]); + } + }); + return response()->json(['message' => 'Items reordered successfully']); } } \ No newline at end of file diff --git a/backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php b/backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php index 2d3bb0f..5ed4cb3 100644 --- a/backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php +++ b/backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php @@ -2,6 +2,7 @@ namespace App\Infrastructure\Http\Controllers\API\PlannableItem; +use App\Domain\PlannableItem\Policies\PlannableItemPolicy; use App\Infrastructure\Http\Controllers\Controller; use App\Models\PlannableItem; use App\Models\Trip; @@ -10,10 +11,12 @@ class PlannableItemController extends Controller { + public function __construct( + private PlannableItemPolicy $policy + ) {} public function index(Trip $trip): JsonResponse { - // Check if user owns the trip - if ($trip->created_by_user_id !== auth()->id()) { + if (!$this->policy->create(auth()->user(), $trip)) { return response()->json(['message' => 'Forbidden'], 403); } @@ -26,8 +29,7 @@ public function index(Trip $trip): JsonResponse public function store(Request $request, Trip $trip): JsonResponse { - // Check if user owns the trip - if ($trip->created_by_user_id !== auth()->id()) { + if (!$this->policy->create(auth()->user(), $trip)) { return response()->json(['message' => 'Forbidden'], 403); } @@ -46,14 +48,17 @@ public function store(Request $request, Trip $trip): JsonResponse public function show(PlannableItem $plannableItem): JsonResponse { + if (!$this->policy->view(auth()->user(), $plannableItem)) { + return response()->json(['message' => 'Forbidden'], 403); + } + $plannableItem->load(['calendarSlots', 'trip']); return response()->json($plannableItem); } public function update(Request $request, PlannableItem $plannableItem): JsonResponse { - // Check if user owns the trip - if ($plannableItem->trip->created_by_user_id !== auth()->id()) { + if (!$this->policy->update(auth()->user(), $plannableItem)) { return response()->json(['message' => 'Forbidden'], 403); } @@ -72,6 +77,10 @@ public function update(Request $request, PlannableItem $plannableItem): JsonResp public function destroy(PlannableItem $plannableItem): JsonResponse { + if (!$this->policy->delete(auth()->user(), $plannableItem)) { + return response()->json(['message' => 'Forbidden'], 403); + } + $plannableItem->delete(); return response()->json(null, 204); diff --git a/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php b/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php index 0c93c77..07b894a 100644 --- a/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php +++ b/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php @@ -2,51 +2,90 @@ namespace App\Infrastructure\Http\Controllers\API\PlannedItem; +use App\Domain\PlannedItem\Actions\CreatePlannedItemAction; +use App\Domain\PlannedItem\Actions\UpdatePlannedItemAction; +use App\Domain\PlannedItem\Actions\DeletePlannedItemAction; +use App\Domain\PlannedItem\Policies\PlannedItemPolicy; use App\Infrastructure\Http\Controllers\Controller; use App\Models\PlannedItem; -use App\Models\PlannableItem; -use App\Models\CalendarSlot; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\Gate; class PlannedItemController extends Controller { + public function __construct( + private CreatePlannedItemAction $createAction, + private UpdatePlannedItemAction $updateAction, + private DeletePlannedItemAction $deleteAction, + private PlannedItemPolicy $policy + ) {} + public function store(Request $request): JsonResponse { $validated = $request->validate([ 'plannable_item_id' => 'required|exists:plannable_items,id', - 'calendar_slot_id' => 'required|exists:calendar_slots,id', + 'trip_id' => 'required|exists:trips,id', + 'start_datetime' => 'required|date', + 'end_datetime' => 'required|date|after:start_datetime', + 'calendar_slot_id' => 'sometimes|exists:calendar_slots,id', 'sort_order' => 'nullable|integer', ]); - $plannedItem = PlannedItem::updateOrCreate( - [ - 'plannable_item_id' => $validated['plannable_item_id'], - 'calendar_slot_id' => $validated['calendar_slot_id'], - ], - [ - 'sort_order' => $validated['sort_order'] ?? 0, - ] - ); + // Check authorization using Policy + if (!$this->policy->create(auth()->user(), $validated['trip_id'])) { + return response()->json(['message' => 'Forbidden'], 403); + } - return response()->json($plannedItem->load(['plannableItem', 'calendarSlot']), 201); + // If calendar_slot_id is provided, use existing flow (backward compatibility) + if (isset($validated['calendar_slot_id'])) { + $plannedItem = $this->createAction->executeFromSlot($validated); + return response()->json($plannedItem, 201); + } + + // New flow: Create CalendarSlot from datetime using Action + try { + $calendarSlot = $this->createAction->execute($validated); + return response()->json($calendarSlot, 201); + } catch (\Exception $e) { + return response()->json(['message' => $e->getMessage()], 422); + } } public function update(Request $request, PlannedItem $plannedItem): JsonResponse { + // Check authorization using Policy + if (!$this->policy->update(auth()->user(), $plannedItem)) { + return response()->json(['message' => 'Forbidden'], 403); + } + $validated = $request->validate([ 'calendar_slot_id' => 'sometimes|required|exists:calendar_slots,id', 'sort_order' => 'nullable|integer', ]); - $plannedItem->update($validated); + // If changing calendar slot, verify user owns the new slot's trip + if (isset($validated['calendar_slot_id'])) { + if (!$this->policy->moveToSlot(auth()->user(), $validated['calendar_slot_id'])) { + return response()->json(['message' => 'Forbidden'], 403); + } + } - return response()->json($plannedItem->load(['plannableItem', 'calendarSlot'])); + // Execute update using Action + $updatedItem = $this->updateAction->execute($plannedItem, $validated); + + return response()->json($updatedItem); } public function destroy(PlannedItem $plannedItem): JsonResponse { - $plannedItem->delete(); + // Check authorization using Policy + if (!$this->policy->delete(auth()->user(), $plannedItem)) { + return response()->json(['message' => 'Forbidden'], 403); + } + + // Execute delete using Action + $this->deleteAction->execute($plannedItem); return response()->json(null, 204); } diff --git a/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php b/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php index d17bdc6..19bf89b 100644 --- a/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php +++ b/backend/app/Infrastructure/Http/Controllers/API/Trip/TripController.php @@ -2,14 +2,17 @@ namespace App\Infrastructure\Http\Controllers\API\Trip; +use App\Domain\Trip\Policies\TripPolicy; use App\Infrastructure\Http\Controllers\Controller; use App\Models\Trip; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; -use Illuminate\Validation\ValidationException; class TripController extends Controller { + public function __construct( + private TripPolicy $policy + ) {} /** * Display a listing of the resource. */ @@ -44,11 +47,11 @@ public function store(Request $request): JsonResponse /** * Display the specified resource. */ - public function show(Request $request, string $id): JsonResponse + public function show(Request $request, Trip $trip): JsonResponse { - $trip = Trip::where('id', $id) - ->where('created_by_user_id', $request->user()->id) - ->firstOrFail(); + if (!$this->policy->view($request->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } return response()->json(['data' => $trip]); } @@ -56,11 +59,11 @@ public function show(Request $request, string $id): JsonResponse /** * Update the specified resource in storage. */ - public function update(Request $request, string $id): JsonResponse + public function update(Request $request, Trip $trip): JsonResponse { - $trip = Trip::where('id', $id) - ->where('created_by_user_id', $request->user()->id) - ->firstOrFail(); + if (!$this->policy->update($request->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } $validated = $request->validate([ 'name' => 'required|string|max:255', @@ -77,11 +80,11 @@ public function update(Request $request, string $id): JsonResponse /** * Remove the specified resource from storage. */ - public function destroy(Request $request, string $id): JsonResponse + public function destroy(Request $request, Trip $trip): JsonResponse { - $trip = Trip::where('id', $id) - ->where('created_by_user_id', $request->user()->id) - ->firstOrFail(); + if (!$this->policy->delete($request->user(), $trip)) { + return response()->json(['message' => 'Forbidden'], 403); + } $trip->delete(); diff --git a/backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php b/backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php index fa145c9..940a299 100644 --- a/backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php +++ b/backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php @@ -21,8 +21,9 @@ public function up(): void $table->integer('slot_order')->default(0); $table->timestamps(); - $table->index(['trip_id', 'slot_date']); - $table->index(['trip_id', 'slot_order']); + // Composite indexes for performance + $table->index(['trip_id', 'slot_date', 'datetime_start']); + $table->index(['trip_id', 'slot_date', 'slot_order']); }); } diff --git a/backend/tests/Feature/CalendarSlotTest.php b/backend/tests/Feature/CalendarSlotTest.php index edf578e..4efb070 100644 --- a/backend/tests/Feature/CalendarSlotTest.php +++ b/backend/tests/Feature/CalendarSlotTest.php @@ -227,4 +227,110 @@ public function test_slots_created_when_dates_added_to_trip() $trip->refresh(); $this->assertCount(2, $trip->calendarSlots); } + + public function test_calendar_slots_include_planned_items_with_plannable_item() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-01' + ]); + + // Create a plannable item and schedule it + $plannableItem = \App\Models\PlannableItem::factory()->create([ + 'trip_id' => $trip->id, + 'name' => 'Eiffel Tower' + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->postJson('/api/planned-items', [ + 'plannable_item_id' => $plannableItem->id, + 'trip_id' => $trip->id, + 'start_datetime' => '2024-01-01 14:00:00', + 'end_datetime' => '2024-01-01 16:00:00', + ]); + + $response->assertStatus(201); + + // Fetch calendar slots and verify relationships are loaded + $slotsResponse = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$trip->id}/calendar-slots"); + + $slotsResponse->assertStatus(200); + + // Find the slot we just created + $slots = $slotsResponse->json('data'); + $scheduledSlot = collect($slots)->firstWhere('name', 'Eiffel Tower'); + + $this->assertNotNull($scheduledSlot); + $this->assertArrayHasKey('planned_items', $scheduledSlot); + $this->assertCount(1, $scheduledSlot['planned_items']); + $this->assertArrayHasKey('plannable_item', $scheduledSlot['planned_items'][0]); + $this->assertEquals('Eiffel Tower', $scheduledSlot['planned_items'][0]['plannable_item']['name']); + } + + public function test_calendar_slots_ordered_by_date_and_time() + { + $trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-02' + ]); + + // Create plannable items + $item1 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Breakfast']); + $item2 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Lunch']); + $item3 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Dinner Day 1']); + $item4 = \App\Models\PlannableItem::factory()->create(['trip_id' => $trip->id, 'name' => 'Breakfast Day 2']); + + // Schedule in non-chronological order + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $item3->id, + 'trip_id' => $trip->id, + 'start_datetime' => '2024-01-01 19:00:00', + 'end_datetime' => '2024-01-01 21:00:00', + ]); + + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $item1->id, + 'trip_id' => $trip->id, + 'start_datetime' => '2024-01-01 08:00:00', + 'end_datetime' => '2024-01-01 09:00:00', + ]); + + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $item4->id, + 'trip_id' => $trip->id, + 'start_datetime' => '2024-01-02 08:00:00', + 'end_datetime' => '2024-01-02 09:00:00', + ]); + + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $item2->id, + 'trip_id' => $trip->id, + 'start_datetime' => '2024-01-01 12:00:00', + 'end_datetime' => '2024-01-01 13:00:00', + ]); + + // Fetch slots and verify they're ordered correctly + $response = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $this->token, + 'Accept' => 'application/json' + ])->getJson("/api/trips/{$trip->id}/calendar-slots"); + + $response->assertStatus(200); + + $slots = $response->json('data'); + $scheduledSlots = collect($slots)->filter(fn($slot) => $slot['datetime_start'] !== null)->values(); + + // Should be ordered: Breakfast (Day 1, 8am), Lunch (Day 1, 12pm), Dinner (Day 1, 7pm), Breakfast (Day 2, 8am) + $this->assertEquals('Breakfast', $scheduledSlots[0]['name']); + $this->assertEquals('Lunch', $scheduledSlots[1]['name']); + $this->assertEquals('Dinner Day 1', $scheduledSlots[2]['name']); + $this->assertEquals('Breakfast Day 2', $scheduledSlots[3]['name']); + } } \ No newline at end of file diff --git a/backend/tests/Feature/PlannedItemTest.php b/backend/tests/Feature/PlannedItemTest.php new file mode 100644 index 0000000..b5820ef --- /dev/null +++ b/backend/tests/Feature/PlannedItemTest.php @@ -0,0 +1,321 @@ +user = User::factory()->create(); + $this->trip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + 'start_date' => '2025-01-15', + 'end_date' => '2025-01-17', + ]); + $this->plannableItem = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id, + 'name' => 'Eiffel Tower', + ]); + } + + /** @test */ + public function it_creates_calendar_slot_when_scheduling_item_with_datetime() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('calendar_slots', [ + 'trip_id' => $this->trip->id, + 'name' => 'Eiffel Tower', + 'slot_date' => '2025-01-15', + ]); + $this->assertDatabaseHas('planned_items', [ + 'plannable_item_id' => $this->plannableItem->id, + ]); + } + + /** @test */ + public function it_calculates_slot_order_correctly() + { + // Create first slot at 10:00 + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 10:00:00', + 'end_datetime' => '2025-01-15 11:00:00', + ]); + + // Create another plannable item + $secondItem = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id, + 'name' => 'Louvre', + ]); + + // Create second slot at 14:00 + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $secondItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + // Create third slot at 08:00 (should have slot_order 0 after recalculation) + $thirdItem = PlannableItem::factory()->create([ + 'trip_id' => $this->trip->id, + 'name' => 'Breakfast', + ]); + + $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $thirdItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 08:00:00', + 'end_datetime' => '2025-01-15 09:00:00', + ]); + + $slots = CalendarSlot::where('trip_id', $this->trip->id) + ->where('slot_date', '2025-01-15') + ->orderBy('slot_order') + ->get(); + + // After recalculation, order should be by datetime_start + $this->assertEquals('Breakfast', $slots[0]->name); + $this->assertEquals(0, $slots[0]->slot_order); + $this->assertEquals('Eiffel Tower', $slots[1]->name); + $this->assertEquals(1, $slots[1]->slot_order); + $this->assertEquals('Louvre', $slots[2]->name); + $this->assertEquals(2, $slots[2]->slot_order); + } + + /** @test */ + public function it_validates_end_time_is_after_start_time() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 16:00:00', + 'end_datetime' => '2025-01-15 14:00:00', + ]); + + $response->assertStatus(422); + } + + /** @test */ + public function it_requires_plannable_item_to_belong_to_trip() + { + $otherTrip = Trip::factory()->create([ + 'created_by_user_id' => $this->user->id, + ]); + $otherItem = PlannableItem::factory()->create([ + 'trip_id' => $otherTrip->id, + ]); + + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $otherItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(422); + $response->assertJson([ + 'error' => 'Plannable item does not belong to this trip' + ]); + } + + /** @test */ + public function it_supports_backward_compatible_calendar_slot_id_flow() + { + $calendarSlot = CalendarSlot::factory()->create([ + 'trip_id' => $this->trip->id, + ]); + + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot->id, + ]); + + $response->assertStatus(201); + $this->assertDatabaseHas('planned_items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot->id, + ]); + } + + /** @test */ + public function it_requires_authentication_to_create_planned_item() + { + $response = $this->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(401); + } + + /** @test */ + public function it_validates_required_fields() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', []); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['plannable_item_id', 'trip_id', 'start_datetime', 'end_datetime']); + } + + /** @test */ + public function it_validates_plannable_item_exists() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => 99999, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['plannable_item_id']); + } + + /** @test */ + public function it_validates_trip_exists() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => 99999, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(422); + $response->assertJsonValidationErrors(['trip_id']); + } + + /** @test */ + public function it_returns_calendar_slot_with_relationships() + { + $response = $this->actingAs($this->user)->postJson('/api/planned-items', [ + 'plannable_item_id' => $this->plannableItem->id, + 'trip_id' => $this->trip->id, + 'start_datetime' => '2025-01-15 14:00:00', + 'end_datetime' => '2025-01-15 16:00:00', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure([ + 'id', + 'trip_id', + 'name', + 'datetime_start', + 'datetime_end', + 'slot_date', + 'slot_order', + 'planned_items' => [ + '*' => [ + 'id', + 'plannable_item_id', + 'calendar_slot_id', + 'plannable_item' => [ + 'id', + 'name', + 'trip_id', + ] + ] + ] + ]); + } + + /** @test */ + public function it_can_update_planned_item_slot() + { + $calendarSlot1 = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]); + $calendarSlot2 = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]); + + $plannedItem = PlannedItem::create([ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot1->id, + 'sort_order' => 0, + ]); + + $response = $this->actingAs($this->user)->putJson("/api/planned-items/{$plannedItem->id}", [ + 'calendar_slot_id' => $calendarSlot2->id, + 'sort_order' => 5, + ]); + + $response->assertStatus(200); + $this->assertDatabaseHas('planned_items', [ + 'id' => $plannedItem->id, + 'calendar_slot_id' => $calendarSlot2->id, + 'sort_order' => 5, + ]); + } + + /** @test */ + public function it_can_delete_planned_item() + { + $calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]); + $plannedItem = PlannedItem::create([ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot->id, + 'sort_order' => 0, + ]); + + $response = $this->actingAs($this->user)->deleteJson("/api/planned-items/{$plannedItem->id}"); + + $response->assertStatus(204); + $this->assertDatabaseMissing('planned_items', [ + 'id' => $plannedItem->id, + ]); + } + + /** @test */ + public function it_requires_authentication_to_update_planned_item() + { + $calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]); + $plannedItem = PlannedItem::create([ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot->id, + ]); + + $response = $this->putJson("/api/planned-items/{$plannedItem->id}", [ + 'sort_order' => 1, + ]); + + $response->assertStatus(401); + } + + /** @test */ + public function it_requires_authentication_to_delete_planned_item() + { + $calendarSlot = CalendarSlot::factory()->create(['trip_id' => $this->trip->id]); + $plannedItem = PlannedItem::create([ + 'plannable_item_id' => $this->plannableItem->id, + 'calendar_slot_id' => $calendarSlot->id, + ]); + + $response = $this->deleteJson("/api/planned-items/{$plannedItem->id}"); + + $response->assertStatus(401); + } +} diff --git a/backend/tests/Unit/CalendarSlotServiceTest.php b/backend/tests/Unit/CalendarSlotServiceTest.php new file mode 100644 index 0000000..68b4ad3 --- /dev/null +++ b/backend/tests/Unit/CalendarSlotServiceTest.php @@ -0,0 +1,235 @@ +service = new CalendarSlotService(); + $this->trip = Trip::factory()->create([ + 'start_date' => '2024-01-01', + 'end_date' => '2024-01-03', + ]); + } + + /** @test */ + public function it_calculates_slot_order_for_first_slot_on_date() + { + $order = $this->service->calculateSlotOrder( + $this->trip->id, + '2024-01-01', + Carbon::parse('2024-01-01 10:00:00') + ); + + $this->assertEquals(0, $order); + } + + /** @test */ + public function it_calculates_slot_order_based_on_chronological_position() + { + // Create existing slots + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Morning', + 'datetime_start' => '2024-01-01 08:00:00', + 'datetime_end' => '2024-01-01 09:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 0, + ]); + + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Afternoon', + 'datetime_start' => '2024-01-01 14:00:00', + 'datetime_end' => '2024-01-01 15:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 1, + ]); + + // Calculate order for noon slot (should be between morning and afternoon) + $order = $this->service->calculateSlotOrder( + $this->trip->id, + '2024-01-01', + Carbon::parse('2024-01-01 12:00:00') + ); + + $this->assertEquals(1, $order); + } + + /** @test */ + public function it_calculates_slot_order_for_earliest_time() + { + // Create existing slot + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Late Morning', + 'datetime_start' => '2024-01-01 10:00:00', + 'datetime_end' => '2024-01-01 11:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 0, + ]); + + // Calculate order for earlier time + $order = $this->service->calculateSlotOrder( + $this->trip->id, + '2024-01-01', + Carbon::parse('2024-01-01 08:00:00') + ); + + $this->assertEquals(0, $order); + } + + /** @test */ + public function it_calculates_slot_order_for_latest_time() + { + // Create existing slots + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Morning', + 'datetime_start' => '2024-01-01 08:00:00', + 'datetime_end' => '2024-01-01 09:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 0, + ]); + + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Noon', + 'datetime_start' => '2024-01-01 12:00:00', + 'datetime_end' => '2024-01-01 13:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 1, + ]); + + // Calculate order for later time + $order = $this->service->calculateSlotOrder( + $this->trip->id, + '2024-01-01', + Carbon::parse('2024-01-01 20:00:00') + ); + + $this->assertEquals(2, $order); + } + + /** @test */ + public function it_only_considers_slots_from_same_date() + { + // Create slot on different date + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Other Day', + 'datetime_start' => '2024-01-02 08:00:00', + 'datetime_end' => '2024-01-02 09:00:00', + 'slot_date' => '2024-01-02', + 'slot_order' => 0, + ]); + + // Calculate order for Jan 1 (should start at 0, not affected by Jan 2) + $order = $this->service->calculateSlotOrder( + $this->trip->id, + '2024-01-01', + Carbon::parse('2024-01-01 10:00:00') + ); + + $this->assertEquals(0, $order); + } + + /** @test */ + public function it_recalculates_slot_orders_for_date() + { + // Create slots in non-chronological slot_order + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Evening', + 'datetime_start' => '2024-01-01 20:00:00', + 'datetime_end' => '2024-01-01 21:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 5, // Wrong order + ]); + + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Morning', + 'datetime_start' => '2024-01-01 08:00:00', + 'datetime_end' => '2024-01-01 09:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 3, // Wrong order + ]); + + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Afternoon', + 'datetime_start' => '2024-01-01 14:00:00', + 'datetime_end' => '2024-01-01 15:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 1, // Wrong order + ]); + + // Recalculate + $this->service->recalculateSlotOrdersForDate($this->trip->id, '2024-01-01'); + + // Verify correct chronological order + $slots = CalendarSlot::where('trip_id', $this->trip->id) + ->where('slot_date', '2024-01-01') + ->orderBy('slot_order') + ->get(); + + $this->assertEquals('Morning', $slots[0]->name); + $this->assertEquals(0, $slots[0]->slot_order); + + $this->assertEquals('Afternoon', $slots[1]->name); + $this->assertEquals(1, $slots[1]->slot_order); + + $this->assertEquals('Evening', $slots[2]->name); + $this->assertEquals(2, $slots[2]->slot_order); + } + + /** @test */ + public function it_only_recalculates_slots_for_specified_date() + { + // Create slots on Jan 1 + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Jan 1 Slot', + 'datetime_start' => '2024-01-01 10:00:00', + 'datetime_end' => '2024-01-01 11:00:00', + 'slot_date' => '2024-01-01', + 'slot_order' => 5, + ]); + + // Create slots on Jan 2 + CalendarSlot::create([ + 'trip_id' => $this->trip->id, + 'name' => 'Jan 2 Slot', + 'datetime_start' => '2024-01-02 10:00:00', + 'datetime_end' => '2024-01-02 11:00:00', + 'slot_date' => '2024-01-02', + 'slot_order' => 7, + ]); + + // Recalculate only Jan 1 + $this->service->recalculateSlotOrdersForDate($this->trip->id, '2024-01-01'); + + // Jan 1 should be recalculated to 0 + $jan1Slot = CalendarSlot::where('slot_date', '2024-01-01')->first(); + $this->assertEquals(0, $jan1Slot->slot_order); + + // Jan 2 should remain unchanged + $jan2Slot = CalendarSlot::where('slot_date', '2024-01-02')->first(); + $this->assertEquals(7, $jan2Slot->slot_order); + } +} 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/TripDetail.jsx b/frontend/src/components/TripDetail.jsx index 7183be1..85b376b 100644 --- a/frontend/src/components/TripDetail.jsx +++ b/frontend/src/components/TripDetail.jsx @@ -3,12 +3,17 @@ import { useParams, useNavigate, Link } from 'react-router-dom'; import { formatDate } from '../utils/dateFormatter'; import { useTrip } from '../hooks/useTrip'; import PlannablesList from './plannables/PlannablesList'; +import TripTimeline from './timeline/TripTimeline'; +import axios from 'axios'; import './TripDetail.css'; +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + const TripDetail = () => { const { id } = useParams(); const navigate = useNavigate(); const [trip, setTrip] = useState(null); + const [plannableItems, setPlannableItems] = useState([]); const { fetchTrip, loading, error } = useTrip(); useEffect(() => { @@ -24,6 +29,24 @@ const TripDetail = () => { loadTrip(); }, [id, fetchTrip]); + useEffect(() => { + const loadPlannableItems = async () => { + try { + const token = localStorage.getItem('token'); + const response = await axios.get(`${API_URL}/api/trips/${id}/plannables`, { + headers: { Authorization: `Bearer ${token}` } + }); + setPlannableItems(response.data.data || []); + } catch (err) { + console.error('Error loading plannable items:', err); + } + }; + + if (id) { + loadPlannableItems(); + } + }, [id]); + // Memoize trip dates display to prevent unnecessary re-renders const tripDatesDisplay = useMemo(() => { if (!trip) return null; @@ -79,13 +102,16 @@ const TripDetail = () => {
- + setPlannableItems(items)} />
-
-

Calendar View

-

Calendar view will be implemented here in the future

-
+ { + // Optional: refresh plannable items if needed + }} + />
diff --git a/frontend/src/components/plannables/PlannablesList.jsx b/frontend/src/components/plannables/PlannablesList.jsx index 3c6833a..cf14b92 100644 --- a/frontend/src/components/plannables/PlannablesList.jsx +++ b/frontend/src/components/plannables/PlannablesList.jsx @@ -6,7 +6,7 @@ import PlannableForm from './PlannableForm'; import ConfirmDialog from '../common/ConfirmDialog'; import './PlannablesList.css'; -const PlannablesList = ({ tripId }) => { +const PlannablesList = ({ tripId, onItemsChange }) => { const { showSuccess, showError } = useToast(); const { fetchBothData, @@ -46,6 +46,11 @@ const PlannablesList = ({ tripId }) => { const limitedCalendarSlots = calendarSlots.slice(0, 365); // Max 1 year of slots setCalendarSlots(limitedCalendarSlots); + // Notify parent component of items change + if (onItemsChange) { + onItemsChange(plannables); + } + if (errors.plannables) { console.error('Failed to fetch plannables:', errors.plannables); showError('Failed to load plannable items'); @@ -90,7 +95,11 @@ const PlannablesList = ({ tripId }) => { const performDelete = async (itemId) => { try { await deletePlannable(itemId); - setPlannables(plannables.filter(item => item.id !== itemId)); + const updatedPlannables = plannables.filter(item => item.id !== itemId); + setPlannables(updatedPlannables); + if (onItemsChange) { + onItemsChange(updatedPlannables); + } showSuccess('Item deleted successfully'); } catch (err) { console.error('Error deleting item:', err); @@ -104,19 +113,26 @@ const PlannablesList = ({ tripId }) => { try { const isEditing = !!editingItem; let savedItem; + let updatedPlannables; if (isEditing) { savedItem = await updatePlannable(editingItem.id, formData); - setPlannables(plannables.map(item => + updatedPlannables = plannables.map(item => item.id === editingItem.id ? savedItem : item - )); + ); + setPlannables(updatedPlannables); showSuccess('Item updated successfully'); } else { savedItem = await createPlannable(tripId, formData); - setPlannables([...plannables, savedItem]); + updatedPlannables = [...plannables, savedItem]; + setPlannables(updatedPlannables); showSuccess('Item added successfully'); } + if (onItemsChange) { + onItemsChange(updatedPlannables); + } + setShowForm(false); setEditingItem(null); } catch (err) { @@ -195,40 +211,6 @@ const PlannablesList = ({ tripId }) => { )} - - {/* 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 new file mode 100644 index 0000000..162d863 --- /dev/null +++ b/frontend/src/components/timeline/DaySection.jsx @@ -0,0 +1,66 @@ +import { useMemo } from 'react'; +import HourRow from './HourRow'; +import './DaySection.css'; + +const START_HOUR = 6; // 6 AM +const END_HOUR = 23; // 11 PM + +function DaySection({ date, dayNumber, slots, plannableItems, onScheduleItem }) { + const dayName = date.toLocaleDateString('en-US', { weekday: 'long' }); + const dateString = date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + 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) + // 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; + + const slotStart = new Date(slot.datetime_start); + const slotEnd = new Date(slot.datetime_end); + + // Check if slot overlaps with this hour (strict comparison) + return slotStart < hourEnd && slotEnd > hourStart; + }); + } + + return mapping; + }, [slots, date.getTime()]); + + return ( +
+
+

+ Day {dayNumber}: {dayName} +

+

{dateString}

+
+ +
+ {Object.keys(slotsByHour).map(hour => ( + + ))} +
+
+ ); +} + +export default DaySection; diff --git a/frontend/src/components/timeline/HourRow.css b/frontend/src/components/timeline/HourRow.css new file mode 100644 index 0000000..7c9f780 --- /dev/null +++ b/frontend/src/components/timeline/HourRow.css @@ -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; +} diff --git a/frontend/src/components/timeline/HourRow.jsx b/frontend/src/components/timeline/HourRow.jsx new file mode 100644 index 0000000..2c5edf4 --- /dev/null +++ b/frontend/src/components/timeline/HourRow.jsx @@ -0,0 +1,100 @@ +import { useState } from 'react'; +import { PlusIcon } from '@heroicons/react/24/outline'; +import ScheduleItemModal from './ScheduleItemModal'; +import './HourRow.css'; + +function HourRow({ hour, date, slots, plannableItems, onScheduleItem }) { + const [showModal, setShowModal] = useState(false); + + const formatHour = (h) => { + return `${h.toString().padStart(2, '0')}:00`; + }; + + const handleAddClick = () => { + setShowModal(true); + }; + + const handleSchedule = async (plannableItemId, startDatetime, endDatetime) => { + 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); + 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: '2-digit', + minute: '2-digit', + hour12: false + })} + {' - '} + {new Date(slotStartsHere.datetime_end).toLocaleTimeString('en-US', { + 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(', ')} +
+ )} +
+
+
+ )} + + {/* Add button - CSS-only hover */} + +
+
+ + {showModal && ( + setShowModal(false)} + /> + )} + + ); +} + +export default HourRow; 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 new file mode 100644 index 0000000..15d2baf --- /dev/null +++ b/frontend/src/components/timeline/ScheduleItemModal.jsx @@ -0,0 +1,159 @@ +import { useState, useEffect, useMemo } from 'react'; +import BaseModal from '../BaseModal'; +import './ScheduleItemModal.css'; + +function ScheduleItemModal({ date, hour, plannableItems, onSchedule, onClose }) { + const [selectedItemId, setSelectedItemId] = useState(''); + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [error, setError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + // Set default times based on the clicked hour + const startHour = hour.toString().padStart(2, '0'); + const endHour = (hour + 1).toString().padStart(2, '0'); + setStartTime(`${startHour}:00`); + setEndTime(`${endHour}:00`); + }, [hour]); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + if (!selectedItemId) { + setError('Please select an item to schedule'); + return; + } + + if (!startTime || !endTime) { + setError('Please select start and end times'); + return; + } + + // 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`; + + // Validate end time is after start time + if (endTime <= startTime) { + setError('End time must be after start time'); + return; + } + + try { + setSubmitting(true); + await onSchedule(selectedItemId, startDatetime, endDatetime); + // Modal will be closed by parent (HourRow) on success + } catch (err) { + console.error('Error in modal submit:', err); + setError(err.response?.data?.message || err.message || 'Failed to schedule item'); + setSubmitting(false); + } + }; + + // Generate time options (every 30 minutes) - memoized to avoid recreation + const timeOptions = useMemo(() => { + const options = []; + for (let h = 0; h < 24; h++) { + for (let m = 0; m < 60; m += 30) { + const timeStr = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`; + options.push(timeStr); + } + } + return options; + }, []); + + return ( + +
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+
+ ); +} + +export default ScheduleItemModal; diff --git a/frontend/src/components/timeline/TripTimeline.css b/frontend/src/components/timeline/TripTimeline.css new file mode 100644 index 0000000..ea6f659 --- /dev/null +++ b/frontend/src/components/timeline/TripTimeline.css @@ -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; +} diff --git a/frontend/src/components/timeline/TripTimeline.jsx b/frontend/src/components/timeline/TripTimeline.jsx new file mode 100644 index 0000000..17f3e6a --- /dev/null +++ b/frontend/src/components/timeline/TripTimeline.jsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from 'react'; +import DaySection from './DaySection'; +import axios from 'axios'; +import './TripTimeline.css'; + +const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'; + +function TripTimeline({ trip, plannableItems, onScheduleSuccess }) { + const [calendarSlots, setCalendarSlots] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchCalendarSlots(); + }, [trip.id]); + + const fetchCalendarSlots = async () => { + try { + const token = localStorage.getItem('token'); + const response = await axios.get(`${API_URL}/api/trips/${trip.id}/calendar-slots`, { + headers: { Authorization: `Bearer ${token}` } + }); + const slots = response.data.data || []; + setCalendarSlots(slots); + } catch (error) { + console.error('Error fetching calendar slots:', error); + } finally { + setLoading(false); + } + }; + + const handleScheduleItem = async (plannableItemId, startDatetime, endDatetime) => { + try { + const token = localStorage.getItem('token'); + const payload = { + plannable_item_id: plannableItemId, + trip_id: trip.id, + start_datetime: startDatetime, + end_datetime: endDatetime, + }; + + const response = await axios.post( + `${API_URL}/api/planned-items`, + payload, + { + headers: { Authorization: `Bearer ${token}` } + } + ); + + await fetchCalendarSlots(); + if (onScheduleSuccess) { + onScheduleSuccess(); + } + } catch (error) { + console.error('Error scheduling item:', error); + console.error('Error response:', error.response?.data); + throw error; + } + }; + + const generateDays = () => { + const days = []; + const start = new Date(trip.start_date); + const end = new Date(trip.end_date); + + for (let d = new Date(start); d <= end; ) { + days.push(new Date(d)); + d.setDate(d.getDate() + 1); + } + + return days; + }; + + const getSlotsForDay = (date) => { + // Format date as YYYY-MM-DD in local timezone, not UTC + 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) { + return
Loading timeline...
; + } + + const days = generateDays(); + + return ( +
+

Trip Timeline

+
+ {days.map((day, index) => ( + + ))} +
+
+ ); +} + +export default TripTimeline; diff --git a/tests/.gitignore b/tests/.gitignore index 88ad741..fe5002e 100644 --- a/tests/.gitignore +++ b/tests/.gitignore @@ -5,3 +5,4 @@ screenshots/ .env.local coverage/ test-results/ +/.trees diff --git a/tests/jest-local.json b/tests/jest-local.json index fb834c5..c637b80 100644 --- a/tests/jest-local.json +++ b/tests/jest-local.json @@ -2,5 +2,5 @@ "testEnvironment": "node", "testMatch": ["**/specs/**/*.test.js"], "setupFilesAfterEnv": ["/support/config/jest.setup.local.js"], - "testTimeout": 30000 + "testTimeout": 60000 } \ No newline at end of file diff --git a/tests/specs/integration/timeline-scheduling.test.js b/tests/specs/integration/timeline-scheduling.test.js new file mode 100644 index 0000000..f65d4be --- /dev/null +++ b/tests/specs/integration/timeline-scheduling.test.js @@ -0,0 +1,571 @@ +const { By, until } = require('selenium-webdriver'); +const RegistrationPage = require('../../support/pages/RegistrationPage'); +const LoginPage = require('../../support/pages/LoginPage'); +const DashboardPage = require('../../support/pages/DashboardPage'); +const TripPage = require('../../support/pages/TripPage'); + +describe('Timeline Scheduling Feature Test', () => { + let driver; + let registrationPage; + let loginPage; + let dashboardPage; + let tripPage; + let testUser; + let testTrip; + + beforeAll(async () => { + driver = await global.createDriver(); + registrationPage = new RegistrationPage(driver); + loginPage = new LoginPage(driver); + dashboardPage = new DashboardPage(driver); + tripPage = new TripPage(driver); + + // Create unique test data + const timestamp = Date.now(); + testUser = { + name: `Timeline Test User ${timestamp}`, + email: `timeline.test.${timestamp}@example.com`, + password: 'TimelineTest123!' + }; + + testTrip = { + name: `Timeline Test Trip ${timestamp}`, + description: 'Testing timeline scheduling feature', + startDate: '2025-03-01', + endDate: '2025-03-03' + }; + }); + + afterAll(async () => { + global.timelineTestsInitialized = false; + await global.quitDriver(driver); + }); + + beforeEach(async () => { + // For individual test runs or when starting fresh, ensure we have proper setup + if (!global.timelineTestsInitialized) { + // Clear storage and cookies + await Promise.all([ + driver.manage().deleteAllCookies().catch(() => {}), + driver.executeScript('try { localStorage.clear(); sessionStorage.clear(); } catch(e) {}') + ]); + + // Navigate to base URL + await driver.get(process.env.APP_URL || 'http://localhost:5173'); + await driver.wait(until.urlContains('/'), 5000); + + // Register new user + try { + await driver.wait(until.elementLocated(By.css('.auth-toggle button:last-child')), 2000); + await driver.findElement(By.css('.auth-toggle button:last-child')).click(); + + await registrationPage.register(testUser.name, testUser.email, testUser.password); + + // Wait for dashboard to load + await driver.wait(until.elementLocated(By.className('dashboard')), 10000); + await driver.wait(until.elementLocated(By.css('.add-trip-card')), 10000); + + // Create a trip + const addTripCard = await driver.findElement(By.css('.add-trip-card')); + await addTripCard.click(); + await driver.wait(until.elementLocated(By.css('.trip-modal')), 10000); + + await tripPage.fillTripForm(testTrip); + await tripPage.submitTripForm(); + + // Wait for trip to be created + await driver.wait(until.elementLocated(By.xpath(`//h3[contains(text(), "${testTrip.name}")]`)), 10000); + + global.timelineTestsInitialized = true; + } catch (e) { + console.log('Failed to initialize timeline test environment:', e.message); + } + } + }); + + // Helper function to navigate to trip detail page + async function navigateToTripDetail() { + const currentUrl = await driver.getCurrentUrl(); + + if (global.timelineTripDetailUrl && currentUrl === global.timelineTripDetailUrl) { + await driver.wait(until.elementLocated(By.className('trip-timeline')), 10000); + await driver.sleep(500); + return; + } + + if (global.timelineTripDetailUrl) { + await driver.get(global.timelineTripDetailUrl); + await driver.sleep(1000); + } else { + await driver.wait(until.elementLocated(By.className('dashboard')), 10000); + const tripCard = await driver.findElement(By.xpath(`//h3[contains(text(), "${testTrip.name}")]/ancestor::div[contains(@class, 'trip-card')]`)); + await tripCard.click(); + await driver.wait(until.urlContains('/trip/'), 10000); + global.timelineTripDetailUrl = await driver.getCurrentUrl(); + } + + await driver.wait(until.elementLocated(By.className('trip-timeline')), 10000); + await driver.sleep(500); + } + + // Helper function to create a plannable item + async function createPlannableItem(itemData) { + // 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")]')); + await driver.executeScript("arguments[0].scrollIntoView(true);", addItemButton); + await driver.sleep(300); + await driver.executeScript("arguments[0].click();", addItemButton); + + // Wait for form modal + await driver.wait(until.elementLocated(By.className('plannable-form-modal')), 5000); + + // Fill in the form + await driver.findElement(By.name('name')).sendKeys(itemData.name); + + if (itemData.type) { + const typeSelect = await driver.findElement(By.name('type')); + await typeSelect.findElement(By.xpath(`//option[@value="${itemData.type}"]`)).click(); + } + + if (itemData.address) { + await driver.findElement(By.name('address')).sendKeys(itemData.address); + } + + if (itemData.notes) { + await driver.findElement(By.name('notes')).sendKeys(itemData.notes); + } + + // Submit without assigning to a slot (leave in "Unplanned Items") + const submitButton = await driver.findElement(By.xpath('//button[contains(text(), "Add Item")]')); + await driver.executeScript("arguments[0].scrollIntoView(true);", submitButton); + await driver.sleep(500); + await driver.executeScript("arguments[0].click();", submitButton); + + // Wait for modal to close + await driver.sleep(1500); + await driver.wait(async () => { + const overlays = await driver.findElements(By.css('.plannable-form-overlay, .plannable-form-modal')); + for (const overlay of overlays) { + try { + if (await overlay.isDisplayed()) { + return false; + } + } catch (e) { + // Stale element + } + } + return true; + }, 10000); + + // Additional wait to ensure UI is stable + await driver.sleep(500); + } + + // Helper to wait for modal close + async function waitForModalClose() { + await driver.sleep(1000); + await driver.wait(async () => { + const modals = await driver.findElements(By.css('.modal, .base-modal')); + for (const modal of modals) { + try { + if (await modal.isDisplayed()) { + return false; + } + } catch (e) { + // Stale element + } + } + return true; + }, 10000); + } + + describe('Timeline Display', () => { + it('should display timeline with correct structure', async () => { + await navigateToTripDetail(); + + // Verify timeline container exists + const timeline = await driver.findElement(By.className('trip-timeline')); + expect(await timeline.isDisplayed()).toBe(true); + + // Verify timeline has header + const timelineHeader = await driver.findElement(By.xpath('//h3[contains(text(), "Trip Timeline")]')); + expect(await timelineHeader.isDisplayed()).toBe(true); + }); + + it('should display all days of the trip', async () => { + await navigateToTripDetail(); + + // Trip is from March 1-3, so we should have 3 day sections + const daySections = await driver.findElements(By.className('day-section')); + expect(daySections.length).toBe(3); + + // Verify day headers + const day1Header = await driver.findElement(By.xpath('//h4[contains(text(), "Day 1")]')); + expect(await day1Header.isDisplayed()).toBe(true); + + const day2Header = await driver.findElement(By.xpath('//h4[contains(text(), "Day 2")]')); + expect(await day2Header.isDisplayed()).toBe(true); + + const day3Header = await driver.findElement(By.xpath('//h4[contains(text(), "Day 3")]')); + expect(await day3Header.isDisplayed()).toBe(true); + }); + + it('should display hour rows for each day', async () => { + await navigateToTripDetail(); + + // Get first day section + const firstDaySection = await driver.findElement(By.className('day-section')); + + // Should have hour rows (06:00 to 23:00 = 18 hours) + const hourRows = await firstDaySection.findElements(By.className('hour-row')); + expect(hourRows.length).toBeGreaterThanOrEqual(18); + + // Verify specific hour labels exist + const hour6am = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "06:00")]')); + expect(await hour6am.isDisplayed()).toBe(true); + + const hour11pm = await driver.findElement(By.xpath('//div[contains(@class, "hour-label") and contains(text(), "23:00")]')); + expect(await hour11pm.isDisplayed()).toBe(true); + }); + }); + + describe('Scheduling Items via Timeline', () => { + it('should show + button on hover over hour row', async () => { + await navigateToTripDetail(); + + // Find an hour row + const hourRow = await driver.findElement(By.className('hour-row')); + + // Hover over it + await driver.actions().move({ origin: hourRow }).perform(); + await driver.sleep(500); + + // Should see a + button + const addButton = await hourRow.findElement(By.css('button')); + expect(await addButton.isDisplayed()).toBe(true); + }); + + it('should open schedule modal when clicking + button', async () => { + await navigateToTripDetail(); + + // First create a plannable item to schedule + await createPlannableItem({ + name: 'Breakfast at Cafe', + type: 'restaurant', + notes: 'Morning coffee' + }); + + // Now find the 08:00 hour row + 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 + await driver.actions().move({ origin: hour8am }).perform(); + await driver.sleep(500); + + // Click the + button + const addButton = await hour8am.findElement(By.css('button')); + await addButton.click(); + + // Wait for schedule modal to appear + await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000); + + // Verify modal elements + const itemSelect = await driver.findElement(By.css('select')); + expect(await itemSelect.isDisplayed()).toBe(true); + + // Should have time pickers + const timeInputs = await driver.findElements(By.css('select')); + expect(timeInputs.length).toBeGreaterThanOrEqual(2); // Start time and end time + }); + + it('should schedule an item at specific time', async () => { + await navigateToTripDetail(); + + // Create plannable item + await createPlannableItem({ + name: 'Louvre Museum Visit', + type: 'attraction', + notes: 'Morning visit to see Mona Lisa' + }); + + await driver.sleep(1000); + + // Find the 10:00 hour row on Day 1 + const daySections = await driver.findElements(By.className('day-section')); + const day1Section = daySections[0]; + + 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 + + await driver.actions().move({ origin: hour10am }).perform(); + await driver.sleep(500); + + const addButton = await hour10am.findElement(By.css('button')); + await addButton.click(); + + // Wait for modal + await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000); + + // Select the item + const itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select')); + await itemSelect.findElement(By.xpath('//option[contains(text(), "Louvre Museum Visit")]')).click(); + + // Start time should default to 10:00 + const startTimeSelect = await driver.findElement(By.xpath('//label[contains(text(), "Start Time")]/following-sibling::select')); + const startTimeValue = await startTimeSelect.getAttribute('value'); + expect(startTimeValue).toBe('10:00'); + + // Set end time to 12:00 + const endTimeSelect = await driver.findElement(By.xpath('//label[contains(text(), "End Time")]/following-sibling::select')); + await endTimeSelect.findElement(By.xpath('//option[@value="12:00"]')).click(); + + // Submit + const scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]')); + await scheduleButton.click(); + + // Wait for modal to close + await waitForModalClose(); + + // Verify item appears in timeline at 10:00 + await driver.sleep(1000); + const scheduledItem = await driver.findElement(By.xpath('//div[contains(@class, "scheduled-slot") and contains(., "Louvre Museum Visit")]')); + expect(await scheduledItem.isDisplayed()).toBe(true); + + // Verify time display shows 10:00 - 12:00 + const timeDisplay = await scheduledItem.getText(); + expect(timeDisplay).toContain('10:00'); + expect(timeDisplay).toContain('12:00'); + }); + + it('should schedule multiple items on the same day', async () => { + await navigateToTripDetail(); + + // Create multiple plannable items + await createPlannableItem({ + name: 'Morning Croissant', + type: 'restaurant' + }); + + await driver.sleep(500); + + await createPlannableItem({ + name: 'Afternoon Tea', + type: 'restaurant' + }); + + await driver.sleep(1000); + + // Schedule first item at 08:00 + const daySections = await driver.findElements(By.className('day-section')); + const day2Section = daySections[1]; // Day 2 + + 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.sleep(500); + await hour8am.findElement(By.css('button')).click(); + + await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000); + + const itemSelect1 = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select')); + await itemSelect1.findElement(By.xpath('//option[contains(text(), "Morning Croissant")]')).click(); + + const scheduleButton1 = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]')); + await scheduleButton1.click(); + await waitForModalClose(); + + await driver.sleep(1000); + + // Schedule second item at 15:00 + 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.sleep(500); + await hour3pm.findElement(By.css('button')).click(); + + await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000); + + const itemSelect2 = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select')); + await itemSelect2.findElement(By.xpath('//option[contains(text(), "Afternoon Tea")]')).click(); + + const endTimeSelect = await driver.findElement(By.xpath('//label[contains(text(), "End Time")]/following-sibling::select')); + await endTimeSelect.findElement(By.xpath('//option[@value="16:00"]')).click(); + + const scheduleButton2 = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]')); + await scheduleButton2.click(); + await waitForModalClose(); + + await driver.sleep(1000); + + // Verify both items appear in Day 2 section + const croissantItem = await day2Section.findElement(By.xpath('.//div[contains(., "Morning Croissant")]')); + expect(await croissantItem.isDisplayed()).toBe(true); + + const teaItem = await day2Section.findElement(By.xpath('.//div[contains(., "Afternoon Tea")]')); + expect(await teaItem.isDisplayed()).toBe(true); + }); + + it('should validate end time is after start time', async () => { + await navigateToTripDetail(); + + // Create plannable item + await createPlannableItem({ + name: 'Invalid Time Test', + type: 'other' + }); + + await driver.sleep(1000); + + // Open schedule modal at 14:00 + const daySections = await driver.findElements(By.className('day-section')); + const day3Section = daySections[2]; // Day 3 + + 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.sleep(500); + await hour2pm.findElement(By.css('button')).click(); + + await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000); + + // Select item + const itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select')); + await itemSelect.findElement(By.xpath('//option[contains(text(), "Invalid Time Test")]')).click(); + + // Try to set end time before start time (e.g., start 14:00, end 13:00) + const endTimeSelect = await driver.findElement(By.xpath('//label[contains(text(), "End Time")]/following-sibling::select')); + await endTimeSelect.findElement(By.xpath('//option[@value="13:00"]')).click(); + + // Submit + const scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]')); + await scheduleButton.click(); + + await driver.sleep(1000); + + // Should show error message + const errorMessage = await driver.findElement(By.xpath('//*[contains(text(), "End time must be after start time")]')); + expect(await errorMessage.isDisplayed()).toBe(true); + }); + }); + + describe('Timeline Integration', () => { + it('should display item in both sidebar and timeline after scheduling', async () => { + await navigateToTripDetail(); + + // Create plannable item + await createPlannableItem({ + name: 'Arc de Triomphe', + type: 'attraction' + }); + + await driver.sleep(1000); + + // Verify it appears in sidebar under "Unplanned Items" + const sidebarItem = await driver.findElement(By.xpath('//h4[contains(text(), "Arc de Triomphe")]')); + expect(await sidebarItem.isDisplayed()).toBe(true); + + // Schedule it via timeline + const daySections = await driver.findElements(By.className('day-section')); + const day1Section = daySections[0]; + + 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.sleep(500); + await hour11am.findElement(By.css('button')).click(); + + await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000); + + const itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select')); + await itemSelect.findElement(By.xpath('//option[contains(text(), "Arc de Triomphe")]')).click(); + + const scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]')); + await scheduleButton.click(); + await waitForModalClose(); + + await driver.sleep(1000); + + // Verify it now appears in timeline + const timelineItem = await driver.findElement(By.xpath('//div[contains(@class, "scheduled-slot") and contains(., "Arc de Triomphe")]')); + expect(await timelineItem.isDisplayed()).toBe(true); + + // Note: The sidebar behavior might change - item could stay in unplanned or move to a day section + // For now, we just verify it's in the timeline + }); + + it('should maintain chronological order when items are scheduled out of order', async () => { + await navigateToTripDetail(); + + // Create items + await createPlannableItem({ name: 'Dinner', type: 'restaurant' }); + await driver.sleep(500); + await createPlannableItem({ name: 'Breakfast', type: 'restaurant' }); + await driver.sleep(500); + await createPlannableItem({ name: 'Lunch', type: 'restaurant' }); + await driver.sleep(1000); + + const daySections = await driver.findElements(By.className('day-section')); + const day1Section = daySections[0]; + + // Schedule Dinner first (19:00) + 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.sleep(500); + await hour7pm.findElement(By.css('button')).click(); + await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000); + let itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select')); + await itemSelect.findElement(By.xpath('//option[contains(text(), "Dinner")]')).click(); + let scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]')); + await scheduleButton.click(); + await waitForModalClose(); + await driver.sleep(1000); + + // Schedule Breakfast (07:00) + 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.sleep(500); + await hour7am.findElement(By.css('button')).click(); + await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000); + itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select')); + await itemSelect.findElement(By.xpath('//option[contains(text(), "Breakfast")]')).click(); + scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]')); + await scheduleButton.click(); + await waitForModalClose(); + await driver.sleep(1000); + + // Schedule Lunch (12:00) + 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.sleep(500); + await hour12pm.findElement(By.css('button')).click(); + await driver.wait(until.elementLocated(By.xpath('//*[contains(text(), "Schedule Item")]')), 5000); + itemSelect = await driver.findElement(By.xpath('//label[contains(text(), "Select Item")]/following-sibling::select')); + await itemSelect.findElement(By.xpath('//option[contains(text(), "Lunch")]')).click(); + scheduleButton = await driver.findElement(By.xpath('//button[contains(text(), "Schedule")]')); + await scheduleButton.click(); + await waitForModalClose(); + await driver.sleep(1000); + + // Verify items appear in chronological order in Day 1 section + const scheduledSlots = await day1Section.findElements(By.className('scheduled-slot')); + + // Get text of all scheduled items to verify order + const itemTexts = []; + for (const slot of scheduledSlots) { + const text = await slot.getText(); + itemTexts.push(text); + } + + // Breakfast should appear before Lunch, and Lunch before Dinner + const breakfastIndex = itemTexts.findIndex(text => text.includes('Breakfast')); + const lunchIndex = itemTexts.findIndex(text => text.includes('Lunch')); + const dinnerIndex = itemTexts.findIndex(text => text.includes('Dinner')); + + expect(breakfastIndex).toBeLessThan(lunchIndex); + expect(lunchIndex).toBeLessThan(dinnerIndex); + }); + }); +});