From c66fd753cf20869ab733191f0f6bd9c310b0db80 Mon Sep 17 00:00:00 2001 From: myrmidex Date: Sun, 28 Sep 2025 13:31:43 +0200 Subject: [PATCH] WIP --- .claude/11/plan-of-attack.md | 149 ++++++++++++++++++ .../Domain/Trip/Observers/TripObserver.php | 43 +++++ .../Trip/Providers/TripServiceProvider.php | 20 +++ .../Trip/Services/CalendarSlotService.php | 66 ++++++++ .../CalendarSlot/CalendarSlotController.php | 50 ++++++ .../PlannableItem/PlannableItemController.php | 64 ++++++++ .../API/PlannedItem/PlannedItemController.php | 53 +++++++ backend/app/Models/CalendarSlot.php | 46 ++++++ backend/app/Models/PlannableItem.php | 44 ++++++ backend/app/Models/PlannedItem.php | 28 ++++ backend/app/Models/Trip.php | 11 ++ backend/bootstrap/providers.php | 1 + ...28_064348_create_plannable_items_table.php | 33 ++++ ..._28_064407_create_calendar_slots_table.php | 36 +++++ ...9_28_064428_create_planned_items_table.php | 33 ++++ backend/routes/api.php | 20 +++ 16 files changed, 697 insertions(+) create mode 100644 .claude/11/plan-of-attack.md create mode 100644 backend/app/Domain/Trip/Observers/TripObserver.php create mode 100644 backend/app/Domain/Trip/Providers/TripServiceProvider.php create mode 100644 backend/app/Domain/Trip/Services/CalendarSlotService.php create mode 100644 backend/app/Infrastructure/Http/Controllers/API/CalendarSlot/CalendarSlotController.php create mode 100644 backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php create mode 100644 backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php create mode 100644 backend/app/Models/CalendarSlot.php create mode 100644 backend/app/Models/PlannableItem.php create mode 100644 backend/app/Models/PlannedItem.php create mode 100644 backend/database/migrations/2025_09_28_064348_create_plannable_items_table.php create mode 100644 backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php create mode 100644 backend/database/migrations/2025_09_28_064428_create_planned_items_table.php diff --git a/.claude/11/plan-of-attack.md b/.claude/11/plan-of-attack.md new file mode 100644 index 0000000..2e3ff8a --- /dev/null +++ b/.claude/11/plan-of-attack.md @@ -0,0 +1,149 @@ +# Plannable Items Feature - Plan of Attack + +## Overview +Implement a plannable items feature for trips, with a simplified 3-table structure that allows users to create items and organize them into calendar slots. + +## Database Structure (3 tables) + +### 1. `plannable_items` table +- `id` (primary key) +- `trip_id` (foreign key to trips) +- `name` (string) +- `type` (enum: hotel/restaurant/attraction/transport/activity) +- `address` (string, nullable) +- `notes` (text, nullable) +- `metadata` (JSON for type-specific fields) +- `created_at`, `updated_at` + +### 2. `calendar_slots` table +- `id` (primary key) +- `trip_id` (foreign key to trips) +- `name` (string, e.g., "Monday", "Day 1", "Morning of Day 2") +- `datetime_start`, `datetime_end` +- `slot_date` (date) +- `slot_order` (integer, for sorting) +- `created_at`, `updated_at` + +### 3. `planned_items` table (junction) +- `id` (primary key) +- `plannable_item_id` (foreign key) +- `calendar_slot_id` (foreign key) +- `created_at`, `updated_at` + +## Slot Management Strategy +- **Auto-create day-based slots** when trip is created or dates are updated +- Generate one slot per day between start_date and end_date +- Items can exist in "unplanned" state (no slot assignment) or be assigned to a slot + +## Backend Implementation + +### 1. Database Migrations +- Create migration for `plannable_items` table +- Create migration for `calendar_slots` table +- Create migration for `planned_items` junction table + +### 2. Models and Relationships +- `PlannableItem` model (belongs to Trip, belongs to many CalendarSlots through PlannedItems) +- `CalendarSlot` model (belongs to Trip, has many PlannableItems through PlannedItems) +- `PlannedItem` model (junction model with additional attributes) +- Update `Trip` model to include relationships + +### 3. Business Logic +- Auto-create/update slots when trip dates change +- Handle slot regeneration when dates are modified +- Preserve existing planned items when possible + +### 4. API Endpoints +``` +GET /api/trips/{id}/plannables - List all plannable items for a trip +POST /api/trips/{id}/plannables - Create new plannable item +PUT /api/plannables/{id} - Update plannable item +DELETE /api/plannables/{id} - Delete plannable item + +GET /api/trips/{id}/calendar-slots - Get all slots for a trip +PUT /api/calendar-slots/{id} - Update slot (rename) + +POST /api/planned-items - Assign item to slot +PUT /api/planned-items/{id} - Update assignment (change slot, time, order) +DELETE /api/planned-items/{id} - Unassign item from slot +PUT /api/calendar-slots/{id}/reorder - Reorder items within a slot +``` + +## Frontend Implementation + +### 1. Routing Setup +- Install `react-router-dom` package +- Configure routes in App.jsx +- Add route for trip detail page: `/trip/:id` + +### 2. Trip Detail Page (`TripDetail.jsx`) +- Header section: Display trip name, dates, description +- Two-column layout: + - Left: Plannable items sidebar + - Right: Calendar view (future implementation) + +### 3. Plannable Items Sidebar Components + +#### `PlannablesList.jsx` +- "+" button at top to add new items +- "Unplanned Items" section +- Day slot sections with assigned items +- Drag & drop functionality between sections + +#### `PlannableItem.jsx` +- Display item with type icon +- Show name and key details +- Edit/Delete actions +- Draggable wrapper + +#### `PlannableForm.jsx` +- Modal/slide-out form +- Dynamic fields based on item type +- Validation + +### 4. Update Existing Components +- Make `TripCard` clickable +- Add navigation to trip detail on click +- Update Dashboard to handle navigation + +### 5. State Management +- Consider using Context or simple state management for: + - Plannable items list + - Calendar slots + - Drag & drop state + +## UI/UX Considerations +- Type-specific icons (🏨 hotel, 🍽️ restaurant, 🎯 attraction, ✈️ transport, 🎭 activity) +- Color coding for different types +- Smooth drag & drop animations +- Loading states during API calls +- Confirmation dialogs for deletions +- Toast notifications for actions + +## Implementation Order +1. ✅ Backend migrations and models - COMPLETED + - Created migrations for plannable_items, calendar_slots, planned_items tables + - Created models with relationships +2. ✅ Backend API endpoints - COMPLETED + - Created controllers for PlannableItem, CalendarSlot, PlannedItem + - Added all routes to api.php + - Organized controllers in DDD structure (Infrastructure/Domain) +3. ✅ Auto-slot creation - COMPLETED + - Created CalendarSlotService + - Created TripObserver to auto-create slots on trip creation/update + - Registered service provider +4. ⏳ Frontend routing setup - TODO +5. ⏳ Trip detail page structure - TODO +6. ⏳ Plannable items CRUD - TODO +7. ⏳ Calendar slots display - TODO +8. ⏳ Drag & drop functionality - TODO +9. ⏳ Polish and edge cases - TODO + +## Future Enhancements (Not in MVP) +- Calendar view in right column +- Time-based slots (morning/afternoon/evening) +- Collaborative features (share with travel companions) +- Import from external sources (booking confirmations) +- Export to PDF/calendar formats +- Cost tracking and budgeting +- Map view of planned items \ No newline at end of file diff --git a/backend/app/Domain/Trip/Observers/TripObserver.php b/backend/app/Domain/Trip/Observers/TripObserver.php new file mode 100644 index 0000000..9c53a68 --- /dev/null +++ b/backend/app/Domain/Trip/Observers/TripObserver.php @@ -0,0 +1,43 @@ +calendarSlotService = $calendarSlotService; + } + + public function created(Trip $trip): void + { + $this->calendarSlotService->createOrUpdateSlotsForTrip($trip); + } + + public function updated(Trip $trip): void + { + if ($trip->isDirty(['start_date', 'end_date'])) { + $this->calendarSlotService->createOrUpdateSlotsForTrip($trip); + } + } + + public function deleted(Trip $trip): void + { + $this->calendarSlotService->deleteSlotsForTrip($trip); + } + + public function restored(Trip $trip): void + { + $this->calendarSlotService->createOrUpdateSlotsForTrip($trip); + } + + public function forceDeleted(Trip $trip): void + { + $this->calendarSlotService->deleteSlotsForTrip($trip); + } +} \ No newline at end of file diff --git a/backend/app/Domain/Trip/Providers/TripServiceProvider.php b/backend/app/Domain/Trip/Providers/TripServiceProvider.php new file mode 100644 index 0000000..929e0b1 --- /dev/null +++ b/backend/app/Domain/Trip/Providers/TripServiceProvider.php @@ -0,0 +1,20 @@ +start_date || !$trip->end_date) { + return collect(); + } + + $existingSlots = $trip->calendarSlots; + $existingSlotsMap = $existingSlots->keyBy('slot_date'); + + $startDate = Carbon::parse($trip->start_date); + $endDate = Carbon::parse($trip->end_date); + + $newSlots = collect(); + $currentDate = $startDate->copy(); + $dayNumber = 1; + + while ($currentDate->lte($endDate)) { + $slotDate = $currentDate->toDateString(); + + if (!$existingSlotsMap->has($slotDate)) { + $slot = CalendarSlot::create([ + 'trip_id' => $trip->id, + 'name' => 'Day ' . $dayNumber, + 'slot_date' => $slotDate, + 'datetime_start' => $currentDate->copy()->startOfDay(), + 'datetime_end' => $currentDate->copy()->endOfDay(), + 'slot_order' => $dayNumber, + ]); + $newSlots->push($slot); + } else { + $existingSlot = $existingSlotsMap->get($slotDate); + $existingSlot->update([ + 'slot_order' => $dayNumber, + 'datetime_start' => $currentDate->copy()->startOfDay(), + 'datetime_end' => $currentDate->copy()->endOfDay(), + ]); + $newSlots->push($existingSlot); + } + + $currentDate->addDay(); + $dayNumber++; + } + + $trip->calendarSlots() + ->whereNotIn('slot_date', $newSlots->pluck('slot_date')) + ->delete(); + + return $newSlots; + } + + public function deleteSlotsForTrip(Trip $trip): void + { + $trip->calendarSlots()->delete(); + } +} \ 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 new file mode 100644 index 0000000..724ff41 --- /dev/null +++ b/backend/app/Infrastructure/Http/Controllers/API/CalendarSlot/CalendarSlotController.php @@ -0,0 +1,50 @@ +calendarSlots() + ->with(['plannableItems']) + ->get(); + + return response()->json($calendarSlots); + } + + public function update(Request $request, CalendarSlot $calendarSlot): JsonResponse + { + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:255', + ]); + + $calendarSlot->update($validated); + + return response()->json($calendarSlot); + } + + public function reorder(Request $request, CalendarSlot $calendarSlot): JsonResponse + { + $validated = $request->validate([ + 'items' => 'required|array', + 'items.*.plannable_item_id' => 'required|exists:plannable_items,id', + 'items.*.sort_order' => 'required|integer', + ]); + + 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 new file mode 100644 index 0000000..e418a69 --- /dev/null +++ b/backend/app/Infrastructure/Http/Controllers/API/PlannableItem/PlannableItemController.php @@ -0,0 +1,64 @@ +plannableItems() + ->with(['calendarSlots']) + ->get(); + + return response()->json($plannableItems); + } + + public function store(Request $request, Trip $trip): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'type' => 'required|in:hotel,restaurant,attraction,transport,activity', + 'address' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + 'metadata' => 'nullable|array', + ]); + + $plannableItem = $trip->plannableItems()->create($validated); + + return response()->json($plannableItem, 201); + } + + public function show(PlannableItem $plannableItem): JsonResponse + { + $plannableItem->load(['calendarSlots', 'trip']); + return response()->json($plannableItem); + } + + public function update(Request $request, PlannableItem $plannableItem): JsonResponse + { + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:255', + 'type' => 'sometimes|required|in:hotel,restaurant,attraction,transport,activity', + 'address' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + 'metadata' => 'nullable|array', + ]); + + $plannableItem->update($validated); + + return response()->json($plannableItem); + } + + public function destroy(PlannableItem $plannableItem): JsonResponse + { + $plannableItem->delete(); + + return response()->json(null, 204); + } +} \ No newline at end of file diff --git a/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php b/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php new file mode 100644 index 0000000..0c93c77 --- /dev/null +++ b/backend/app/Infrastructure/Http/Controllers/API/PlannedItem/PlannedItemController.php @@ -0,0 +1,53 @@ +validate([ + 'plannable_item_id' => 'required|exists:plannable_items,id', + 'calendar_slot_id' => 'required|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, + ] + ); + + return response()->json($plannedItem->load(['plannableItem', 'calendarSlot']), 201); + } + + public function update(Request $request, PlannedItem $plannedItem): JsonResponse + { + $validated = $request->validate([ + 'calendar_slot_id' => 'sometimes|required|exists:calendar_slots,id', + 'sort_order' => 'nullable|integer', + ]); + + $plannedItem->update($validated); + + return response()->json($plannedItem->load(['plannableItem', 'calendarSlot'])); + } + + public function destroy(PlannedItem $plannedItem): JsonResponse + { + $plannedItem->delete(); + + return response()->json(null, 204); + } +} \ No newline at end of file diff --git a/backend/app/Models/CalendarSlot.php b/backend/app/Models/CalendarSlot.php new file mode 100644 index 0000000..523cebf --- /dev/null +++ b/backend/app/Models/CalendarSlot.php @@ -0,0 +1,46 @@ + 'datetime', + 'datetime_end' => 'datetime', + 'slot_date' => 'date', + ]; + + public function trip(): BelongsTo + { + return $this->belongsTo(Trip::class); + } + + public function plannableItems(): BelongsToMany + { + return $this->belongsToMany(PlannableItem::class, 'planned_items') + ->withPivot('sort_order') + ->withTimestamps() + ->orderBy('planned_items.sort_order'); + } + + public function plannedItems() + { + return $this->hasMany(PlannedItem::class); + } +} diff --git a/backend/app/Models/PlannableItem.php b/backend/app/Models/PlannableItem.php new file mode 100644 index 0000000..f6a1886 --- /dev/null +++ b/backend/app/Models/PlannableItem.php @@ -0,0 +1,44 @@ + 'array', + ]; + + public function trip(): BelongsTo + { + return $this->belongsTo(Trip::class); + } + + public function calendarSlots(): BelongsToMany + { + return $this->belongsToMany(CalendarSlot::class, 'planned_items') + ->withPivot('sort_order') + ->withTimestamps() + ->orderBy('planned_items.sort_order'); + } + + public function plannedItems() + { + return $this->hasMany(PlannedItem::class); + } +} diff --git a/backend/app/Models/PlannedItem.php b/backend/app/Models/PlannedItem.php new file mode 100644 index 0000000..52b83ab --- /dev/null +++ b/backend/app/Models/PlannedItem.php @@ -0,0 +1,28 @@ +belongsTo(PlannableItem::class); + } + + public function calendarSlot(): BelongsTo + { + return $this->belongsTo(CalendarSlot::class); + } +} diff --git a/backend/app/Models/Trip.php b/backend/app/Models/Trip.php index c2a7ed9..ea11dc8 100644 --- a/backend/app/Models/Trip.php +++ b/backend/app/Models/Trip.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class Trip extends Model { @@ -26,4 +27,14 @@ public function user(): BelongsTo { return $this->belongsTo(User::class, 'created_by_user_id'); } + + public function plannableItems(): HasMany + { + return $this->hasMany(PlannableItem::class); + } + + public function calendarSlots(): HasMany + { + return $this->hasMany(CalendarSlot::class)->orderBy('slot_date')->orderBy('slot_order'); + } } diff --git a/backend/bootstrap/providers.php b/backend/bootstrap/providers.php index 38b258d..1823eaf 100644 --- a/backend/bootstrap/providers.php +++ b/backend/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Domain\Trip\Providers\TripServiceProvider::class, ]; diff --git a/backend/database/migrations/2025_09_28_064348_create_plannable_items_table.php b/backend/database/migrations/2025_09_28_064348_create_plannable_items_table.php new file mode 100644 index 0000000..fd012c7 --- /dev/null +++ b/backend/database/migrations/2025_09_28_064348_create_plannable_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('trip_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->enum('type', ['hotel', 'restaurant', 'attraction', 'transport', 'activity']); + $table->string('address')->nullable(); + $table->text('notes')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plannable_items'); + } +}; 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 new file mode 100644 index 0000000..fa145c9 --- /dev/null +++ b/backend/database/migrations/2025_09_28_064407_create_calendar_slots_table.php @@ -0,0 +1,36 @@ +id(); + $table->foreignId('trip_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->dateTime('datetime_start')->nullable(); + $table->dateTime('datetime_end')->nullable(); + $table->date('slot_date'); + $table->integer('slot_order')->default(0); + $table->timestamps(); + + $table->index(['trip_id', 'slot_date']); + $table->index(['trip_id', 'slot_order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('calendar_slots'); + } +}; diff --git a/backend/database/migrations/2025_09_28_064428_create_planned_items_table.php b/backend/database/migrations/2025_09_28_064428_create_planned_items_table.php new file mode 100644 index 0000000..4c6d6fa --- /dev/null +++ b/backend/database/migrations/2025_09_28_064428_create_planned_items_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('plannable_item_id')->constrained()->cascadeOnDelete(); + $table->foreignId('calendar_slot_id')->constrained()->cascadeOnDelete(); + $table->integer('sort_order')->default(0); + $table->timestamps(); + + $table->unique(['plannable_item_id', 'calendar_slot_id']); + $table->index(['calendar_slot_id', 'sort_order']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('planned_items'); + } +}; diff --git a/backend/routes/api.php b/backend/routes/api.php index be7582e..72b8a0f 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -3,6 +3,9 @@ use App\Infrastructure\Http\Controllers\API\User\Auth\AuthController; use App\Infrastructure\Http\Controllers\API\Trip\TripController; use App\Infrastructure\Http\Controllers\API\E2e\TestSetupController; +use App\Infrastructure\Http\Controllers\API\PlannableItem\PlannableItemController; +use App\Infrastructure\Http\Controllers\API\CalendarSlot\CalendarSlotController; +use App\Infrastructure\Http\Controllers\API\PlannedItem\PlannedItemController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -31,4 +34,21 @@ // Trip routes Route::apiResource('trips', TripController::class); + + // Plannable items routes + Route::get('trips/{trip}/plannables', [PlannableItemController::class, 'index']); + Route::post('trips/{trip}/plannables', [PlannableItemController::class, 'store']); + Route::get('plannables/{plannableItem}', [PlannableItemController::class, 'show']); + Route::put('plannables/{plannableItem}', [PlannableItemController::class, 'update']); + Route::delete('plannables/{plannableItem}', [PlannableItemController::class, 'destroy']); + + // Calendar slots routes + Route::get('trips/{trip}/calendar-slots', [CalendarSlotController::class, 'index']); + Route::put('calendar-slots/{calendarSlot}', [CalendarSlotController::class, 'update']); + Route::put('calendar-slots/{calendarSlot}/reorder', [CalendarSlotController::class, 'reorder']); + + // Planned items routes + Route::post('planned-items', [PlannedItemController::class, 'store']); + Route::put('planned-items/{plannedItem}', [PlannedItemController::class, 'update']); + Route::delete('planned-items/{plannedItem}', [PlannedItemController::class, 'destroy']); });