release/v0.1.0 #24

Open
myrmidex wants to merge 14 commits from release/v0.1.0 into main
16 changed files with 697 additions and 0 deletions
Showing only changes of commit c66fd753cf - Show all commits

View file

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

View file

@ -0,0 +1,43 @@
<?php
namespace App\Domain\Trip\Observers;
use App\Models\Trip;
use App\Domain\Trip\Services\CalendarSlotService;
class TripObserver
{
protected CalendarSlotService $calendarSlotService;
public function __construct(CalendarSlotService $calendarSlotService)
{
$this->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);
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace App\Domain\Trip\Providers;
use App\Models\Trip;
use App\Domain\Trip\Observers\TripObserver;
use Illuminate\Support\ServiceProvider;
class TripServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Trip::observe(TripObserver::class);
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Domain\Trip\Services;
use App\Models\Trip;
use App\Models\CalendarSlot;
use Carbon\Carbon;
use Illuminate\Support\Collection;
class CalendarSlotService
{
public function createOrUpdateSlotsForTrip(Trip $trip): Collection
{
if (!$trip->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();
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Infrastructure\Http\Controllers\API\CalendarSlot;
use App\Infrastructure\Http\Controllers\Controller;
use App\Models\CalendarSlot;
use App\Models\Trip;
use App\Models\PlannedItem;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class CalendarSlotController extends Controller
{
public function index(Trip $trip): JsonResponse
{
$calendarSlots = $trip->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']);
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace App\Infrastructure\Http\Controllers\API\PlannableItem;
use App\Infrastructure\Http\Controllers\Controller;
use App\Models\PlannableItem;
use App\Models\Trip;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class PlannableItemController extends Controller
{
public function index(Trip $trip): JsonResponse
{
$plannableItems = $trip->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);
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace App\Infrastructure\Http\Controllers\API\PlannedItem;
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;
class PlannedItemController extends Controller
{
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',
'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);
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class CalendarSlot extends Model
{
use HasFactory;
protected $fillable = [
'trip_id',
'name',
'datetime_start',
'datetime_end',
'slot_date',
'slot_order',
];
protected $casts = [
'datetime_start' => '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);
}
}

View file

@ -0,0 +1,44 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class PlannableItem extends Model
{
use HasFactory;
protected $fillable = [
'trip_id',
'name',
'type',
'address',
'notes',
'metadata',
];
protected $casts = [
'metadata' => '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);
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PlannedItem extends Model
{
use HasFactory;
protected $fillable = [
'plannable_item_id',
'calendar_slot_id',
'sort_order',
];
public function plannableItem(): BelongsTo
{
return $this->belongsTo(PlannableItem::class);
}
public function calendarSlot(): BelongsTo
{
return $this->belongsTo(CalendarSlot::class);
}
}

View file

@ -5,6 +5,7 @@
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Trip extends Model class Trip extends Model
{ {
@ -26,4 +27,14 @@ public function user(): BelongsTo
{ {
return $this->belongsTo(User::class, 'created_by_user_id'); 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');
}
} }

View file

@ -2,4 +2,5 @@
return [ return [
App\Providers\AppServiceProvider::class, App\Providers\AppServiceProvider::class,
App\Domain\Trip\Providers\TripServiceProvider::class,
]; ];

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('plannable_items', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('calendar_slots', function (Blueprint $table) {
$table->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');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('planned_items', function (Blueprint $table) {
$table->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');
}
};

View file

@ -3,6 +3,9 @@
use App\Infrastructure\Http\Controllers\API\User\Auth\AuthController; use App\Infrastructure\Http\Controllers\API\User\Auth\AuthController;
use App\Infrastructure\Http\Controllers\API\Trip\TripController; use App\Infrastructure\Http\Controllers\API\Trip\TripController;
use App\Infrastructure\Http\Controllers\API\E2e\TestSetupController; 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\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
@ -31,4 +34,21 @@
// Trip routes // Trip routes
Route::apiResource('trips', TripController::class); 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']);
}); });