7-display-daily-timeline #34

Merged
myrmidex merged 3 commits from refs/pull/34/head into release/v0.1.0 2025-10-07 22:55:04 +02:00
32 changed files with 2468 additions and 108 deletions

1
.gitignore vendored
View file

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

View file

@ -0,0 +1,34 @@
<?php
namespace App\Domain\CalendarSlot\Policies;
use App\Models\User;
use App\Models\CalendarSlot;
use App\Models\Trip;
class CalendarSlotPolicy
{
/**
* Determine if the user can view calendar slots for the given trip
*/
public function viewAny(User $user, Trip $trip): bool
{
return $trip->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;
}
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Domain\PlannableItem\Policies;
use App\Models\User;
use App\Models\PlannableItem;
use App\Models\Trip;
class PlannableItemPolicy
{
/**
* Determine if the user can create a plannable item for the given trip
*/
public function create(User $user, Trip $trip): bool
{
return $trip->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;
}
}

View file

@ -0,0 +1,104 @@
<?php
namespace App\Domain\PlannedItem\Actions;
use App\Domain\Trip\Services\CalendarSlotService;
use App\Models\PlannableItem;
use App\Models\CalendarSlot;
use App\Models\PlannedItem;
use Carbon\Carbon;
class CreatePlannedItemAction
{
public function __construct(
private CalendarSlotService $calendarSlotService
) {}
/**
* Create a new planned item from datetime parameters
*
* @param array $data ['plannable_item_id', 'trip_id', 'start_datetime', 'end_datetime', 'sort_order'?]
* @return CalendarSlot The calendar slot with loaded planned items
* @throws \Exception
*/
public function execute(array $data): CalendarSlot
{
$startDatetime = Carbon::parse($data['start_datetime']);
$endDatetime = Carbon::parse($data['end_datetime']);
$slotDate = $startDatetime->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']);
}
}

View file

@ -0,0 +1,19 @@
<?php
namespace App\Domain\PlannedItem\Actions;
use App\Models\PlannedItem;
class DeletePlannedItemAction
{
/**
* Delete a planned item
*
* @param PlannedItem $plannedItem
* @return void
*/
public function execute(PlannedItem $plannedItem): void
{
$plannedItem->delete();
}
}

View file

@ -0,0 +1,22 @@
<?php
namespace App\Domain\PlannedItem\Actions;
use App\Models\PlannedItem;
class UpdatePlannedItemAction
{
/**
* Update a planned item's properties
*
* @param PlannedItem $plannedItem
* @param array $data ['calendar_slot_id'?, 'sort_order'?]
* @return PlannedItem
*/
public function execute(PlannedItem $plannedItem, array $data): PlannedItem
{
$plannedItem->update($data);
return $plannedItem->load(['plannableItem', 'calendarSlot']);
}
}

View file

@ -0,0 +1,54 @@
<?php
namespace App\Domain\PlannedItem\Policies;
use App\Models\User;
use App\Models\PlannedItem;
use App\Models\Trip;
class PlannedItemPolicy
{
/**
* Determine if the user can create a planned item for the given trip
*/
public function create(User $user, int $tripId): bool
{
return \DB::table('trips')
->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();
}
}

View file

@ -0,0 +1,33 @@
<?php
namespace App\Domain\Trip\Policies;
use App\Models\User;
use App\Models\Trip;
class TripPolicy
{
/**
* Determine if the user can view the trip
*/
public function view(User $user, Trip $trip): bool
{
return $trip->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;
}
}

View file

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

View file

@ -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,17 +47,34 @@ 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) {
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']);
}

View file

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

View file

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

View file

@ -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();

View file

@ -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']);
});
}

View file

@ -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']);
}
}

View file

@ -0,0 +1,321 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Trip;
use App\Models\PlannableItem;
use App\Models\CalendarSlot;
use App\Models\PlannedItem;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PlannedItemTest extends TestCase
{
use RefreshDatabase;
private User $user;
private Trip $trip;
private PlannableItem $plannableItem;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View file

@ -0,0 +1,235 @@
<?php
namespace Tests\Unit;
use App\Domain\Trip\Services\CalendarSlotService;
use App\Models\Trip;
use App\Models\CalendarSlot;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Carbon\Carbon;
class CalendarSlotServiceTest extends TestCase
{
use RefreshDatabase;
private CalendarSlotService $service;
private Trip $trip;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}

View file

@ -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"
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 */
.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 {

View file

@ -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 = () => {
<div className="trip-detail-content">
<div className="trip-detail-sidebar">
<PlannablesList tripId={trip.id} />
<PlannablesList tripId={trip.id} onItemsChange={(items) => setPlannableItems(items)} />
</div>
<div className="trip-detail-main">
<div className="calendar-placeholder">
<h2>Calendar View</h2>
<p>Calendar view will be implemented here in the future</p>
</div>
<TripTimeline
trip={trip}
plannableItems={plannableItems}
onScheduleSuccess={() => {
// Optional: refresh plannable items if needed
}}
/>
</div>
</div>
</div>

View file

@ -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 }) => {
)}
</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>
{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

@ -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 (
<div className="day-section">
<div className="day-header">
<h4>
Day {dayNumber}: {dayName}
</h4>
<p>{dateString}</p>
</div>
<div className="hours-grid">
{Object.keys(slotsByHour).map(hour => (
<HourRow
key={hour}
hour={parseInt(hour)}
date={date}
slots={slotsByHour[hour]}
plannableItems={plannableItems}
onScheduleItem={onScheduleItem}
/>
))}
</div>
</div>
);
}
export default DaySection;

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

@ -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 (
<>
<div className="hour-row">
{/* Hour label */}
<div className="hour-label">
{formatHour(hour)}
</div>
{/* Content area */}
<div className="hour-content">
{/* Scheduled items that start at this hour */}
{slotStartsHere && (
<div className="scheduled-slot">
<div className="scheduled-slot-header">
<div className="scheduled-slot-content">
<div className="scheduled-slot-title">
{slotStartsHere.name}
</div>
<div className="scheduled-slot-time">
{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
})}
</div>
{slotStartsHere.planned_items && slotStartsHere.planned_items.length > 0 && (
<div className="scheduled-slot-items">
{slotStartsHere.planned_items.map(pi => pi.plannable_item?.name).join(', ')}
</div>
)}
</div>
</div>
</div>
)}
{/* Add button - CSS-only hover */}
<button
onClick={handleAddClick}
className="add-button"
title="Schedule an item"
>
<PlusIcon />
</button>
</div>
</div>
{showModal && (
<ScheduleItemModal
date={date}
hour={hour}
plannableItems={plannableItems}
onSchedule={handleSchedule}
onClose={() => setShowModal(false)}
/>
)}
</>
);
}
export default HourRow;

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

@ -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 (
<BaseModal isOpen={true} onClose={onClose} title="Schedule Item">
<form onSubmit={handleSubmit} className="schedule-form">
{error && (
<div className="schedule-form-error">
{error}
</div>
)}
<div className="schedule-form-group">
<label className="schedule-form-label">
Select Item
</label>
<select
value={selectedItemId}
onChange={(e) => setSelectedItemId(e.target.value)}
className="schedule-form-select"
required
>
<option value="">-- Choose an item --</option>
{plannableItems.map(item => (
<option key={item.id} value={item.id}>
{item.name} {item.type ? `(${item.type})` : ''}
</option>
))}
</select>
</div>
<div className="schedule-time-group">
<div className="schedule-form-group">
<label className="schedule-form-label">
Start Time
</label>
<select
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="schedule-form-select"
required
>
{timeOptions.map(time => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
</div>
<div className="schedule-form-group">
<label className="schedule-form-label">
End Time
</label>
<select
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="schedule-form-select"
required
>
{timeOptions.map(time => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
</div>
</div>
<div className="schedule-form-actions">
<button
type="button"
onClick={onClose}
className="schedule-btn-cancel"
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="schedule-btn-submit"
disabled={submitting}
>
{submitting ? 'Scheduling...' : 'Schedule'}
</button>
</div>
</form>
</BaseModal>
);
}
export default ScheduleItemModal;

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

@ -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 <div className="timeline-loading">Loading timeline...</div>;
}
const days = generateDays();
return (
<div className="trip-timeline">
<h3>Trip Timeline</h3>
<div className="timeline-days-container">
{days.map((day, index) => (
<DaySection
key={`${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`}
date={day}
dayNumber={index + 1}
slots={getSlotsForDay(day)}
plannableItems={plannableItems}
onScheduleItem={handleScheduleItem}
/>
))}
</div>
</div>
);
}
export default TripTimeline;

1
tests/.gitignore vendored
View file

@ -5,3 +5,4 @@ screenshots/
.env.local
coverage/
test-results/
/.trees

View file

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

View file

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