From 7e5d15eb46691f9f231c228bb42fb91aeae4ac11 Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Sun, 23 Feb 2025 19:50:40 -0500 Subject: [PATCH] Add models, enums, and services for user roles, theme preferences, slug history, and ID generation --- app/Enums/ParkStatus.php | 80 ++++++ app/Enums/ThemePreference.php | 25 ++ app/Enums/UserRole.php | 37 +++ app/Livewire/AreaStatisticsComponent.php | 59 ++++ app/Livewire/ParkAreaFormComponent.php | 78 ++++++ app/Livewire/ParkAreaListComponent.php | 88 ++++++ app/Livewire/ParkAreaReorderComponent.php | 125 +++++++++ app/Livewire/ParkFormComponent.php | 105 +++++++ app/Livewire/ParkListComponent.php | 124 +++++++++ app/Livewire/ProfileComponent.php | 86 ++++++ app/Models/Location.php | 221 +++++++++++++++ app/Models/Manufacturer.php | 98 +++++++ app/Models/Operator.php | 87 ++++++ app/Models/Park.php | 182 ++++++++++++ app/Models/ParkArea.php | 261 ++++++++++++++++++ app/Models/Profile.php | 131 +++++++++ app/Models/SlugHistory.php | 26 ++ app/Models/User.php | 114 +++++++- app/Services/IdGenerator.php | 41 +++ app/Services/StatisticsCacheService.php | 217 +++++++++++++++ app/Services/StatisticsRollupService.php | 162 +++++++++++ app/Traits/HasAreaStatistics.php | 169 ++++++++++++ app/Traits/HasParkStatistics.php | 202 ++++++++++++++ app/Traits/HasSlugHistory.php | 106 +++++++ ...2_23_233835_add_position_to_park_areas.php | 42 +++ ...23_234035_add_statistics_to_park_areas.php | 62 +++++ ...3_234235_add_statistics_to_parks_table.php | 80 ++++++ .../2024_02_23_234450_add_user_fields.php | 42 +++ ...024_02_23_234505_create_profiles_table.php | 46 +++ ...ate_operators_and_manufacturers_tables.php | 58 ++++ ...24_02_23_235000_create_locations_table.php | 59 ++++ ...830_create_parks_and_park_areas_tables.php | 72 +++++ memory-bank/activeContext.md | 168 +++++++++++ memory-bank/features/AreaOrganization.md | 208 ++++++++++++++ memory-bank/features/AreaStatistics.md | 216 +++++++++++++++ memory-bank/features/LocationSystem.md | 238 ++++++++++++++++ memory-bank/features/ParksManagement.md | 261 ++++++++++++++++++ memory-bank/features/SlugHistorySystem.md | 100 +++++++ memory-bank/features/StatisticsCaching.md | 230 +++++++++++++++ memory-bank/features/StatisticsRollup.md | 226 +++++++++++++++ memory-bank/models/CompanyModel.md | 82 ++++++ memory-bank/models/LocationModel.md | 104 +++++++ memory-bank/models/ParkModel.md | 164 +++++++++++ memory-bank/models/UserModel.md | 122 ++++++++ memory-bank/productContext.md | 50 ++++ memory-bank/prompts/ContinuationCommand.md | 50 ++++ .../prompts/LocationSystemContinuation.md | 69 +++++ memory-bank/prompts/MemoryBankInstructions.md | 68 +++++ .../area-statistics-component.blade.php | 108 ++++++++ .../park-area-form-component.blade.php | 82 ++++++ .../park-area-list-component.blade.php | 109 ++++++++ .../park-area-reorder-component.blade.php | 127 +++++++++ .../livewire/park-form-component.blade.php | 143 ++++++++++ .../livewire/park-list-component.blade.php | 136 +++++++++ .../livewire/profile-component.blade.php | 120 ++++++++ 55 files changed, 6462 insertions(+), 4 deletions(-) create mode 100644 app/Enums/ParkStatus.php create mode 100644 app/Enums/ThemePreference.php create mode 100644 app/Enums/UserRole.php create mode 100644 app/Livewire/AreaStatisticsComponent.php create mode 100644 app/Livewire/ParkAreaFormComponent.php create mode 100644 app/Livewire/ParkAreaListComponent.php create mode 100644 app/Livewire/ParkAreaReorderComponent.php create mode 100644 app/Livewire/ParkFormComponent.php create mode 100644 app/Livewire/ParkListComponent.php create mode 100644 app/Livewire/ProfileComponent.php create mode 100644 app/Models/Location.php create mode 100644 app/Models/Manufacturer.php create mode 100644 app/Models/Operator.php create mode 100644 app/Models/Park.php create mode 100644 app/Models/ParkArea.php create mode 100644 app/Models/Profile.php create mode 100644 app/Models/SlugHistory.php create mode 100644 app/Services/IdGenerator.php create mode 100644 app/Services/StatisticsCacheService.php create mode 100644 app/Services/StatisticsRollupService.php create mode 100644 app/Traits/HasAreaStatistics.php create mode 100644 app/Traits/HasParkStatistics.php create mode 100644 app/Traits/HasSlugHistory.php create mode 100644 database/migrations/2024_02_23_233835_add_position_to_park_areas.php create mode 100644 database/migrations/2024_02_23_234035_add_statistics_to_park_areas.php create mode 100644 database/migrations/2024_02_23_234235_add_statistics_to_parks_table.php create mode 100644 database/migrations/2024_02_23_234450_add_user_fields.php create mode 100644 database/migrations/2024_02_23_234505_create_profiles_table.php create mode 100644 database/migrations/2024_02_23_234948_create_operators_and_manufacturers_tables.php create mode 100644 database/migrations/2024_02_23_235000_create_locations_table.php create mode 100644 database/migrations/2024_02_23_235830_create_parks_and_park_areas_tables.php create mode 100644 memory-bank/activeContext.md create mode 100644 memory-bank/features/AreaOrganization.md create mode 100644 memory-bank/features/AreaStatistics.md create mode 100644 memory-bank/features/LocationSystem.md create mode 100644 memory-bank/features/ParksManagement.md create mode 100644 memory-bank/features/SlugHistorySystem.md create mode 100644 memory-bank/features/StatisticsCaching.md create mode 100644 memory-bank/features/StatisticsRollup.md create mode 100644 memory-bank/models/CompanyModel.md create mode 100644 memory-bank/models/LocationModel.md create mode 100644 memory-bank/models/ParkModel.md create mode 100644 memory-bank/models/UserModel.md create mode 100644 memory-bank/productContext.md create mode 100644 memory-bank/prompts/ContinuationCommand.md create mode 100644 memory-bank/prompts/LocationSystemContinuation.md create mode 100644 memory-bank/prompts/MemoryBankInstructions.md create mode 100644 resources/views/livewire/area-statistics-component.blade.php create mode 100644 resources/views/livewire/park-area-form-component.blade.php create mode 100644 resources/views/livewire/park-area-list-component.blade.php create mode 100644 resources/views/livewire/park-area-reorder-component.blade.php create mode 100644 resources/views/livewire/park-form-component.blade.php create mode 100644 resources/views/livewire/park-list-component.blade.php create mode 100644 resources/views/livewire/profile-component.blade.php diff --git a/app/Enums/ParkStatus.php b/app/Enums/ParkStatus.php new file mode 100644 index 0000000..0945a71 --- /dev/null +++ b/app/Enums/ParkStatus.php @@ -0,0 +1,80 @@ + 'Operating', + self::CLOSED_TEMP => 'Temporarily Closed', + self::CLOSED_PERM => 'Permanently Closed', + self::UNDER_CONSTRUCTION => 'Under Construction', + self::DEMOLISHED => 'Demolished', + self::RELOCATED => 'Relocated', + }; + } + + /** + * Get Tailwind CSS classes for status badge + */ + public function getStatusClasses(): string + { + return match($this) { + self::OPERATING => 'bg-green-100 text-green-800', + self::CLOSED_TEMP => 'bg-yellow-100 text-yellow-800', + self::CLOSED_PERM => 'bg-red-100 text-red-800', + self::UNDER_CONSTRUCTION => 'bg-blue-100 text-blue-800', + self::DEMOLISHED => 'bg-gray-100 text-gray-800', + self::RELOCATED => 'bg-purple-100 text-purple-800', + }; + } + + /** + * Check if the park is currently operational + */ + public function isOperational(): bool + { + return $this === self::OPERATING; + } + + /** + * Check if the park is permanently closed + */ + public function isPermanentlyClosed(): bool + { + return in_array($this, [self::CLOSED_PERM, self::DEMOLISHED]); + } + + /** + * Check if the park is temporarily closed + */ + public function isTemporarilyClosed(): bool + { + return $this === self::CLOSED_TEMP; + } + + /** + * Get all status options as an array for forms + * + * @return array + */ + public static function options(): array + { + return array_reduce(self::cases(), function ($carry, $status) { + $carry[$status->value] = $status->label(); + return $carry; + }, []); + } +} \ No newline at end of file diff --git a/app/Enums/ThemePreference.php b/app/Enums/ThemePreference.php new file mode 100644 index 0000000..03e3d34 --- /dev/null +++ b/app/Enums/ThemePreference.php @@ -0,0 +1,25 @@ + 'Light', + self::DARK => 'Dark', + }; + } + + public function cssClass(): string + { + return match($this) { + self::LIGHT => 'theme-light', + self::DARK => 'theme-dark', + }; + } +} \ No newline at end of file diff --git a/app/Enums/UserRole.php b/app/Enums/UserRole.php new file mode 100644 index 0000000..43e7b19 --- /dev/null +++ b/app/Enums/UserRole.php @@ -0,0 +1,37 @@ + 'User', + self::MODERATOR => 'Moderator', + self::ADMIN => 'Admin', + self::SUPERUSER => 'Superuser', + }; + } + + public function canModerate(): bool + { + return match($this) { + self::MODERATOR, self::ADMIN, self::SUPERUSER => true, + default => false, + }; + } + + public function canAdmin(): bool + { + return match($this) { + self::ADMIN, self::SUPERUSER => true, + default => false, + }; + } +} \ No newline at end of file diff --git a/app/Livewire/AreaStatisticsComponent.php b/app/Livewire/AreaStatisticsComponent.php new file mode 100644 index 0000000..71f1f0f --- /dev/null +++ b/app/Livewire/AreaStatisticsComponent.php @@ -0,0 +1,59 @@ +area = $area; + } + + public function toggleDetails(): void + { + $this->showDetails = !$this->showDetails; + } + + public function toggleHistorical(): void + { + $this->showHistorical = !$this->showHistorical; + } + + /** + * Get the ride type distribution as percentages. + * + * @return array + */ + protected function getRidePercentages(): array + { + if ($this->area->ride_count === 0) { + return [ + 'coasters' => 0, + 'flat_rides' => 0, + 'water_rides' => 0, + ]; + } + + return [ + 'coasters' => round(($this->area->coaster_count / $this->area->ride_count) * 100, 1), + 'flat_rides' => round(($this->area->flat_ride_count / $this->area->ride_count) * 100, 1), + 'water_rides' => round(($this->area->water_ride_count / $this->area->ride_count) * 100, 1), + ]; + } + + public function render() + { + return view('livewire.area-statistics-component', [ + 'rideDistribution' => $this->area->ride_distribution, + 'ridePercentages' => $this->getRidePercentages(), + 'historicalStats' => $this->area->historical_stats, + ]); + } +} \ No newline at end of file diff --git a/app/Livewire/ParkAreaFormComponent.php b/app/Livewire/ParkAreaFormComponent.php new file mode 100644 index 0000000..3ab08b4 --- /dev/null +++ b/app/Livewire/ParkAreaFormComponent.php @@ -0,0 +1,78 @@ +park = $park; + $this->area = $area; + + if ($area) { + $this->name = $area->name; + $this->description = $area->description ?? ''; + $this->opening_date = $area->opening_date?->format('Y-m-d') ?? ''; + $this->closing_date = $area->closing_date?->format('Y-m-d') ?? ''; + } + } + + public function rules(): array + { + $unique = $this->area + ? Rule::unique('park_areas', 'name') + ->where('park_id', $this->park->id) + ->ignore($this->area->id) + : Rule::unique('park_areas', 'name') + ->where('park_id', $this->park->id); + + return [ + 'name' => ['required', 'string', 'min:2', 'max:255', $unique], + 'description' => ['nullable', 'string'], + 'opening_date' => ['nullable', 'date'], + 'closing_date' => ['nullable', 'date', 'after:opening_date'], + ]; + } + + public function save(): void + { + $data = $this->validate(); + $data['park_id'] = $this->park->id; + + if ($this->area) { + $this->area->update($data); + $message = 'Area updated successfully!'; + } else { + $this->area = ParkArea::create($data); + $message = 'Area created successfully!'; + } + + session()->flash('message', $message); + + $this->redirectRoute('parks.areas.show', [ + 'park' => $this->park, + 'area' => $this->area, + ]); + } + + public function render() + { + return view('livewire.park-area-form-component', [ + 'isEditing' => (bool)$this->area, + ]); + } +} \ No newline at end of file diff --git a/app/Livewire/ParkAreaListComponent.php b/app/Livewire/ParkAreaListComponent.php new file mode 100644 index 0000000..95a4e6b --- /dev/null +++ b/app/Livewire/ParkAreaListComponent.php @@ -0,0 +1,88 @@ + */ + public array $sortOptions = [ + 'name' => 'Name', + 'opening_date' => 'Opening Date', + ]; + + protected $queryString = [ + 'search' => ['except' => ''], + 'sort' => ['except' => 'name'], + 'direction' => ['except' => 'asc'], + 'showClosed' => ['except' => false], + ]; + + public function mount(Park $park): void + { + $this->park = $park; + $this->resetPage('areas-page'); + } + + public function updatedSearch(): void + { + $this->resetPage('areas-page'); + } + + public function updatedShowClosed(): void + { + $this->resetPage('areas-page'); + } + + public function sortBy(string $field): void + { + if ($this->sort === $field) { + $this->direction = $this->direction === 'asc' ? 'desc' : 'asc'; + } else { + $this->sort = $field; + $this->direction = 'asc'; + } + } + + public function deleteArea(ParkArea $area): void + { + $area->delete(); + session()->flash('message', 'Area deleted successfully.'); + } + + public function render() + { + $query = $this->park->areas() + ->when($this->search, function (Builder $query) { + $query->where('name', 'like', '%' . $this->search . '%') + ->orWhere('description', 'like', '%' . $this->search . '%'); + }) + ->when(!$this->showClosed, function (Builder $query) { + $query->whereNull('closing_date'); + }) + ->when($this->sort === 'name', function (Builder $query) { + $query->orderBy('name', $this->direction); + }) + ->when($this->sort === 'opening_date', function (Builder $query) { + $query->orderBy('opening_date', $this->direction) + ->orderBy('name', 'asc'); + }); + + return view('livewire.park-area-list-component', [ + 'areas' => $query->paginate(10, pageName: 'areas-page'), + ]); + } +} \ No newline at end of file diff --git a/app/Livewire/ParkAreaReorderComponent.php b/app/Livewire/ParkAreaReorderComponent.php new file mode 100644 index 0000000..8dff15b --- /dev/null +++ b/app/Livewire/ParkAreaReorderComponent.php @@ -0,0 +1,125 @@ +park = $park; + $this->parentId = $parentId; + $this->loadAreas(); + } + + /** + * Load areas for the current context (either top-level or within a parent). + */ + protected function loadAreas(): void + { + $query = $this->park->areas() + ->where('parent_id', $this->parentId) + ->orderBy('position') + ->select(['id', 'name', 'position', 'closing_date']); + + $this->areas = $query->get() + ->map(fn ($area) => [ + 'id' => $area->id, + 'name' => $area->name, + 'position' => $area->position, + 'is_closed' => !is_null($area->closing_date), + 'has_children' => $area->hasChildren(), + ]) + ->toArray(); + } + + /** + * Handle reordering of areas. + */ + public function reorder(array $orderedIds): void + { + // Validate that all IDs belong to this park and parent context + $validIds = $this->park->areas() + ->where('parent_id', $this->parentId) + ->pluck('id') + ->toArray(); + + $orderedIds = array_values(array_intersect($orderedIds, $validIds)); + + // Update positions + foreach ($orderedIds as $position => $id) { + ParkArea::where('id', $id)->update(['position' => $position]); + } + + $this->loadAreas(); + $this->dispatch('areas-reordered'); + } + + /** + * Move an area to a new parent. + */ + public function moveToParent(int $areaId, ?int $newParentId): void + { + $area = $this->park->areas()->findOrFail($areaId); + + // Prevent circular references + if ($newParentId === $area->id) { + return; + } + + // If moving to a new parent, validate it exists and belongs to this park + if ($newParentId) { + $newParent = $this->park->areas()->findOrFail($newParentId); + + // Prevent moving to own descendant + $ancestorIds = Collection::make(); + $current = $newParent; + while ($current) { + if ($ancestorIds->contains($current->id)) { + return; // Circular reference detected + } + $ancestorIds->push($current->id); + if ($current->id === $area->id) { + return; // Would create circular reference + } + $current = $current->parent; + } + } + + // Get the next position in the new parent context + $maxPosition = $this->park->areas() + ->where('parent_id', $newParentId) + ->max('position'); + + $area->update([ + 'parent_id' => $newParentId, + 'position' => ($maxPosition ?? -1) + 1, + ]); + + // Reorder the old parent's remaining areas to close gaps + $this->park->areas() + ->where('parent_id', $this->parentId) + ->where('position', '>', $area->position) + ->decrement('position'); + + $this->loadAreas(); + $this->dispatch('area-moved'); + } + + public function render() + { + return view('livewire.park-area-reorder-component', [ + 'parentArea' => $this->parentId + ? $this->park->areas()->find($this->parentId) + : null, + ]); + } +} \ No newline at end of file diff --git a/app/Livewire/ParkFormComponent.php b/app/Livewire/ParkFormComponent.php new file mode 100644 index 0000000..200b0b6 --- /dev/null +++ b/app/Livewire/ParkFormComponent.php @@ -0,0 +1,105 @@ + */ + public array $statusOptions = []; + + /** @var array> */ + public array $operators = []; + + public function mount(?Park $park = null): void + { + $this->park = $park; + + if ($park) { + $this->name = $park->name; + $this->description = $park->description ?? ''; + $this->status = $park->status->value; + $this->opening_date = $park->opening_date?->format('Y-m-d') ?? ''; + $this->closing_date = $park->closing_date?->format('Y-m-d') ?? ''; + $this->operating_season = $park->operating_season ?? ''; + $this->size_acres = $park->size_acres ? (string)$park->size_acres : ''; + $this->website = $park->website ?? ''; + $this->operator_id = $park->operator_id; + } else { + $this->status = ParkStatus::OPERATING->value; + } + + // Load status options + $this->statusOptions = collect(ParkStatus::cases()) + ->mapWithKeys(fn (ParkStatus $status) => [$status->value => $status->label()]) + ->toArray(); + + // Load operators for select + $this->operators = Operator::orderBy('name') + ->get(['id', 'name']) + ->map(fn ($operator) => ['id' => $operator->id, 'name' => $operator->name]) + ->toArray(); + } + + public function rules(): array + { + $unique = $this->park + ? "unique:parks,name,{$this->park->id}" + : 'unique:parks,name'; + + return [ + 'name' => ['required', 'string', 'min:2', 'max:255', $unique], + 'description' => ['nullable', 'string'], + 'status' => ['required', new Enum(ParkStatus::class)], + 'opening_date' => ['nullable', 'date'], + 'closing_date' => ['nullable', 'date', 'after:opening_date'], + 'operating_season' => ['nullable', 'string', 'max:255'], + 'size_acres' => ['nullable', 'numeric', 'min:0', 'max:999999.99'], + 'website' => ['nullable', 'url', 'max:255'], + 'operator_id' => ['nullable', 'exists:operators,id'], + ]; + } + + public function save(): void + { + $data = $this->validate(); + + if ($this->park) { + $this->park->update($data); + $message = 'Park updated successfully!'; + } else { + $this->park = Park::create($data); + $message = 'Park created successfully!'; + } + + session()->flash('message', $message); + + $this->redirectRoute('parks.show', $this->park); + } + + public function render() + { + return view('livewire.park-form-component'); + } +} \ No newline at end of file diff --git a/app/Livewire/ParkListComponent.php b/app/Livewire/ParkListComponent.php new file mode 100644 index 0000000..169e493 --- /dev/null +++ b/app/Livewire/ParkListComponent.php @@ -0,0 +1,124 @@ + */ + public array $sortOptions = [ + 'name' => 'Name', + 'opening_date' => 'Opening Date', + 'ride_count' => 'Ride Count', + 'coaster_count' => 'Coaster Count', + 'size_acres' => 'Size', + ]; + + protected $queryString = [ + 'search' => ['except' => ''], + 'status' => ['except' => ''], + 'sort' => ['except' => 'name'], + 'direction' => ['except' => 'asc'], + 'operator' => ['except' => ''], + ]; + + public function mount(): void + { + $this->resetPage('parks-page'); + } + + public function updatedSearch(): void + { + $this->resetPage('parks-page'); + } + + public function updatedStatus(): void + { + $this->resetPage('parks-page'); + } + + public function updatedOperator(): void + { + $this->resetPage('parks-page'); + } + + public function sortBy(string $field): void + { + if ($this->sort === $field) { + $this->direction = $this->direction === 'asc' ? 'desc' : 'asc'; + } else { + $this->sort = $field; + $this->direction = 'asc'; + } + } + + public function getStatusOptions(): array + { + return collect(ParkStatus::cases()) + ->mapWithKeys(fn (ParkStatus $status) => [$status->value => $status->label()]) + ->prepend('All Statuses', '') + ->toArray(); + } + + public function getOperatorOptions(): array + { + return \App\Models\Operator::orderBy('name') + ->pluck('name', 'id') + ->prepend('All Operators', '') + ->toArray(); + } + + public function render() + { + $query = Park::query() + ->with(['operator']) + ->when($this->search, function (Builder $query) { + $query->where('name', 'like', '%' . $this->search . '%') + ->orWhere('description', 'like', '%' . $this->search . '%'); + }) + ->when($this->status, function (Builder $query) { + $query->where('status', $this->status); + }) + ->when($this->operator, function (Builder $query) { + $query->where('operator_id', $this->operator); + }) + ->when($this->sort === 'name', function (Builder $query) { + $query->orderBy('name', $this->direction); + }) + ->when($this->sort === 'opening_date', function (Builder $query) { + $query->orderBy('opening_date', $this->direction) + ->orderBy('name', 'asc'); + }) + ->when($this->sort === 'ride_count', function (Builder $query) { + $query->orderBy('ride_count', $this->direction) + ->orderBy('name', 'asc'); + }) + ->when($this->sort === 'coaster_count', function (Builder $query) { + $query->orderBy('coaster_count', $this->direction) + ->orderBy('name', 'asc'); + }) + ->when($this->sort === 'size_acres', function (Builder $query) { + $query->orderBy('size_acres', $this->direction) + ->orderBy('name', 'asc'); + }); + + return view('livewire.park-list-component', [ + 'parks' => $query->paginate(12, pageName: 'parks-page'), + 'statusOptions' => $this->getStatusOptions(), + 'operatorOptions' => $this->getOperatorOptions(), + ]); + } +} \ No newline at end of file diff --git a/app/Livewire/ProfileComponent.php b/app/Livewire/ProfileComponent.php new file mode 100644 index 0000000..63dcb4c --- /dev/null +++ b/app/Livewire/ProfileComponent.php @@ -0,0 +1,86 @@ +profile = Auth::user()->profile; + $this->fill($this->profile->only([ + 'display_name', + 'pronouns', + 'bio', + 'twitter', + 'instagram', + 'youtube', + 'discord', + ])); + } + + public function save() + { + $this->validate(); + + if ($this->avatar) { + $this->profile->setAvatar($this->avatar); + } + + $this->profile->update([ + 'display_name' => $this->display_name, + 'pronouns' => $this->pronouns, + 'bio' => $this->bio, + 'twitter' => $this->twitter, + 'instagram' => $this->instagram, + 'youtube' => $this->youtube, + 'discord' => $this->discord, + ]); + + session()->flash('message', 'Profile updated successfully!'); + } + + public function removeAvatar() + { + $this->profile->setAvatar(null); + session()->flash('message', 'Avatar removed successfully!'); + } + + public function render() + { + return view('livewire.profile-component'); + } +} \ No newline at end of file diff --git a/app/Models/Location.php b/app/Models/Location.php new file mode 100644 index 0000000..e3de4d8 --- /dev/null +++ b/app/Models/Location.php @@ -0,0 +1,221 @@ + + */ + protected $fillable = [ + 'address', + 'city', + 'state', + 'country', + 'postal_code', + 'latitude', + 'longitude', + 'elevation', + 'timezone', + 'metadata', + 'is_approximate', + 'source', + 'geocoding_data', + 'geocoded_at', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'latitude' => 'decimal:8', + 'longitude' => 'decimal:8', + 'elevation' => 'decimal:2', + 'metadata' => 'array', + 'geocoding_data' => 'array', + 'geocoded_at' => 'datetime', + 'is_approximate' => 'boolean', + ]; + + /** + * Get the parent locatable model. + */ + public function locatable(): MorphTo + { + return $this->morphTo(); + } + + /** + * Get the location's coordinates as an array. + * + * @return array + */ + public function getCoordinatesAttribute(): array + { + return [ + 'lat' => $this->latitude, + 'lng' => $this->longitude, + ]; + } + + /** + * Get the formatted address. + * + * @return string + */ + public function getFormattedAddressAttribute(): string + { + $parts = []; + + if ($this->address) { + $parts[] = $this->address; + } + + if ($this->city) { + $parts[] = $this->city; + } + + if ($this->state) { + $parts[] = $this->state; + } + + if ($this->postal_code) { + $parts[] = $this->postal_code; + } + + if ($this->country) { + $parts[] = $this->country; + } + + return implode(', ', $parts); + } + + /** + * Get Google Maps URL for the location. + * + * @return string|null + */ + public function getMapUrlAttribute(): ?string + { + if (!$this->latitude || !$this->longitude) { + return null; + } + + return sprintf( + 'https://www.google.com/maps?q=%f,%f', + $this->latitude, + $this->longitude + ); + } + + /** + * Update the location's coordinates. + * + * @param float $latitude + * @param float $longitude + * @param float|null $elevation + * @return bool + */ + public function updateCoordinates(float $latitude, float $longitude, ?float $elevation = null): bool + { + return $this->update([ + 'latitude' => $latitude, + 'longitude' => $longitude, + 'elevation' => $elevation, + ]); + } + + /** + * Set the location's address components. + * + * @param array $components + * @return bool + */ + public function setAddress(array $components): bool + { + return $this->update([ + 'address' => $components['address'] ?? $this->address, + 'city' => $components['city'] ?? $this->city, + 'state' => $components['state'] ?? $this->state, + 'country' => $components['country'] ?? $this->country, + 'postal_code' => $components['postal_code'] ?? $this->postal_code, + ]); + } + + /** + * Scope a query to find locations within a radius. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param float $latitude + * @param float $longitude + * @param float $radius + * @param string $unit + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeNearby($query, float $latitude, float $longitude, float $radius, string $unit = 'km') + { + $earthRadius = $unit === 'mi' ? 3959 : 6371; + + return $query->whereRaw( + "($earthRadius * acos( + cos(radians(?)) * + cos(radians(latitude)) * + cos(radians(longitude) - radians(?)) + + sin(radians(?)) * + sin(radians(latitude)) + )) <= ?", + [$latitude, $longitude, $latitude, $radius] + ); + } + + /** + * Scope a query to find locations within bounds. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @param array $ne Northeast corner [lat, lng] + * @param array $sw Southwest corner [lat, lng] + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeInBounds($query, array $ne, array $sw) + { + return $query->whereBetween('latitude', [$sw['lat'], $ne['lat']]) + ->whereBetween('longitude', [$sw['lng'], $ne['lng']]); + } + + /** + * Calculate the distance to a point. + * + * @param float $latitude + * @param float $longitude + * @param string $unit + * @return float|null + */ + public function distanceTo(float $latitude, float $longitude, string $unit = 'km'): ?float + { + if (!$this->latitude || !$this->longitude) { + return null; + } + + $earthRadius = $unit === 'mi' ? 3959 : 6371; + + $latFrom = deg2rad($this->latitude); + $lonFrom = deg2rad($this->longitude); + $latTo = deg2rad($latitude); + $lonTo = deg2rad($longitude); + + $latDelta = $latTo - $latFrom; + $lonDelta = $lonTo - $lonFrom; + + $angle = 2 * asin(sqrt(pow(sin($latDelta / 2), 2) + + cos($latFrom) * cos($latTo) * pow(sin($lonDelta / 2), 2))); + + return $angle * $earthRadius; + } +} \ No newline at end of file diff --git a/app/Models/Manufacturer.php b/app/Models/Manufacturer.php new file mode 100644 index 0000000..27bbaf5 --- /dev/null +++ b/app/Models/Manufacturer.php @@ -0,0 +1,98 @@ + + */ + protected $fillable = [ + 'name', + 'slug', + 'website', + 'headquarters', + 'description', + 'total_rides', + 'total_roller_coasters', + ]; + + /** + * Get the rides manufactured by this company. + * Note: This relationship will be properly set up when we implement the Rides system. + */ + public function rides(): HasMany + { + return $this->hasMany(Ride::class); + } + + /** + * Update ride statistics. + */ + public function updateStatistics(): void + { + $this->total_rides = $this->rides()->count(); + $this->total_roller_coasters = $this->rides() + ->where('type', 'roller_coaster') + ->count(); + $this->save(); + } + + /** + * Get the manufacturer's name with total rides. + */ + public function getDisplayNameAttribute(): string + { + return "{$this->name} ({$this->total_rides} rides)"; + } + + /** + * Get formatted website URL (ensures proper URL format). + */ + public function getWebsiteUrlAttribute(): string + { + if (!$this->website) { + return ''; + } + + $website = $this->website; + if (!str_starts_with($website, 'http://') && !str_starts_with($website, 'https://')) { + $website = 'https://' . $website; + } + + return $website; + } + + /** + * Scope a query to only include major manufacturers (with multiple rides). + */ + public function scopeMajorManufacturers($query, int $minRides = 5) + { + return $query->where('total_rides', '>=', $minRides); + } + + /** + * Scope a query to only include coaster manufacturers. + */ + public function scopeCoasterManufacturers($query) + { + return $query->where('total_roller_coasters', '>', 0); + } + + /** + * Get the route key for the model. + */ + public function getRouteKeyName(): string + { + return 'slug'; + } +} \ No newline at end of file diff --git a/app/Models/Operator.php b/app/Models/Operator.php new file mode 100644 index 0000000..b8fa192 --- /dev/null +++ b/app/Models/Operator.php @@ -0,0 +1,87 @@ + + */ + protected $fillable = [ + 'name', + 'slug', + 'website', + 'headquarters', + 'description', + 'total_parks', + 'total_rides', + ]; + + /** + * Get the parks operated by this company. + */ + public function parks(): HasMany + { + return $this->hasMany(Park::class); + } + + /** + * Update park statistics. + */ + public function updateStatistics(): void + { + $this->total_parks = $this->parks()->count(); + $this->total_rides = $this->parks()->sum('ride_count'); + $this->save(); + } + + /** + * Get the operator's name with total parks. + */ + public function getDisplayNameAttribute(): string + { + return "{$this->name} ({$this->total_parks} parks)"; + } + + /** + * Get formatted website URL (ensures proper URL format). + */ + public function getWebsiteUrlAttribute(): string + { + if (!$this->website) { + return ''; + } + + $website = $this->website; + if (!str_starts_with($website, 'http://') && !str_starts_with($website, 'https://')) { + $website = 'https://' . $website; + } + + return $website; + } + + /** + * Scope a query to only include major operators (with multiple parks). + */ + public function scopeMajorOperators($query, int $minParks = 3) + { + return $query->where('total_parks', '>=', $minParks); + } + + /** + * Get the route key for the model. + */ + public function getRouteKeyName(): string + { + return 'slug'; + } +} \ No newline at end of file diff --git a/app/Models/Park.php b/app/Models/Park.php new file mode 100644 index 0000000..2466843 --- /dev/null +++ b/app/Models/Park.php @@ -0,0 +1,182 @@ + + */ + protected $fillable = [ + 'name', + 'slug', + 'description', + 'status', + 'opening_date', + 'closing_date', + 'operating_season', + 'size_acres', + 'website', + 'operator_id', + 'total_areas', + 'operating_areas', + 'closed_areas', + 'total_rides', + 'total_coasters', + 'total_flat_rides', + 'total_water_rides', + 'total_daily_capacity', + 'average_wait_time', + 'average_rating', + 'total_rides_operated', + 'total_rides_retired', + 'last_expansion_date', + 'last_major_update', + 'utilization_rate', + 'peak_daily_attendance', + 'guest_satisfaction', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'status' => ParkStatus::class, + 'opening_date' => 'date', + 'closing_date' => 'date', + 'size_acres' => 'decimal:2', + 'total_areas' => 'integer', + 'operating_areas' => 'integer', + 'closed_areas' => 'integer', + 'total_rides' => 'integer', + 'total_coasters' => 'integer', + 'total_flat_rides' => 'integer', + 'total_water_rides' => 'integer', + 'total_daily_capacity' => 'integer', + 'average_wait_time' => 'integer', + 'average_rating' => 'decimal:2', + 'total_rides_operated' => 'integer', + 'total_rides_retired' => 'integer', + 'last_expansion_date' => 'date', + 'last_major_update' => 'date', + 'utilization_rate' => 'decimal:2', + 'peak_daily_attendance' => 'integer', + 'guest_satisfaction' => 'decimal:2', + ]; + + /** + * Get the operator that owns the park. + */ + public function operator(): BelongsTo + { + return $this->belongsTo(Operator::class); + } + + /** + * Get the areas in the park. + */ + public function areas(): HasMany + { + return $this->hasMany(ParkArea::class); + } + + /** + * Get formatted website URL (ensures proper URL format). + */ + public function getWebsiteUrlAttribute(): string + { + if (!$this->website) { + return ''; + } + + $website = $this->website; + if (!str_starts_with($website, 'http://') && !str_starts_with($website, 'https://')) { + $website = 'https://' . $website; + } + + return $website; + } + + /** + * Get the status display classes for Tailwind CSS. + */ + public function getStatusClassesAttribute(): string + { + return $this->status->getStatusClasses(); + } + + /** + * Get a formatted display of the park's size. + */ + public function getSizeDisplayAttribute(): string + { + return $this->size_acres ? number_format($this->size_acres, 1) . ' acres' : 'Unknown size'; + } + + /** + * Get the formatted opening year. + */ + public function getOpeningYearAttribute(): ?string + { + return $this->opening_date?->format('Y'); + } + + /** + * Get a brief description suitable for cards and previews. + */ + public function getBriefDescriptionAttribute(): string + { + $description = $this->description ?? ''; + return strlen($description) > 200 ? substr($description, 0, 200) . '...' : $description; + } + + /** + * Scope a query to only include operating parks. + */ + public function scopeOperating($query) + { + return $query->where('status', ParkStatus::OPERATING); + } + + /** + * Scope a query to only include closed parks. + */ + public function scopeClosed($query) + { + return $query->whereIn('status', [ + ParkStatus::CLOSED_TEMP, + ParkStatus::CLOSED_PERM, + ParkStatus::DEMOLISHED, + ]); + } + + /** + * Boot the model. + */ + protected static function boot() + { + parent::boot(); + + static::created(function (Park $park) { + $park->operator?->updateStatistics(); + }); + + static::deleted(function (Park $park) { + $park->operator?->updateStatistics(); + }); + } +} \ No newline at end of file diff --git a/app/Models/ParkArea.php b/app/Models/ParkArea.php new file mode 100644 index 0000000..6de4240 --- /dev/null +++ b/app/Models/ParkArea.php @@ -0,0 +1,261 @@ + + */ + protected $fillable = [ + 'name', + 'slug', + 'description', + 'opening_date', + 'closing_date', + 'position', + 'parent_id', + 'ride_count', + 'coaster_count', + 'flat_ride_count', + 'water_ride_count', + 'daily_capacity', + 'peak_wait_time', + 'average_rating', + 'total_rides_operated', + 'retired_rides_count', + 'last_new_ride_added', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'opening_date' => 'date', + 'closing_date' => 'date', + 'position' => 'integer', + 'ride_count' => 'integer', + 'coaster_count' => 'integer', + 'flat_ride_count' => 'integer', + 'water_ride_count' => 'integer', + 'daily_capacity' => 'integer', + 'peak_wait_time' => 'integer', + 'average_rating' => 'decimal:2', + 'total_rides_operated' => 'integer', + 'retired_rides_count' => 'integer', + 'last_new_ride_added' => 'date', + ]; + + /** + * Get the park that owns the area. + */ + public function park(): BelongsTo + { + return $this->belongsTo(Park::class); + } + + /** + * Get the parent area if this is a sub-area. + */ + public function parent(): BelongsTo + { + return $this->belongsTo(ParkArea::class, 'parent_id'); + } + + /** + * Get the sub-areas of this area. + */ + public function children(): HasMany + { + return $this->hasMany(ParkArea::class, 'parent_id') + ->orderBy('position'); + } + + /** + * Get a brief description suitable for cards and previews. + */ + public function getBriefDescriptionAttribute(): string + { + $description = $this->description ?? ''; + return strlen($description) > 150 ? substr($description, 0, 150) . '...' : $description; + } + + /** + * Get the opening year of the area. + */ + public function getOpeningYearAttribute(): ?string + { + return $this->opening_date?->format('Y'); + } + + /** + * Check if the area is currently operating. + */ + public function isOperating(): bool + { + if ($this->closing_date) { + return false; + } + + return true; + } + + /** + * Check if this area has sub-areas. + */ + public function hasChildren(): bool + { + return $this->children()->exists(); + } + + /** + * Check if this is a top-level area. + */ + public function isTopLevel(): bool + { + return is_null($this->parent_id); + } + + /** + * Get the next available position for a new area. + */ + public function getNextPosition(): int + { + $maxPosition = static::where('park_id', $this->park_id) + ->where('parent_id', $this->parent_id) + ->max('position'); + + return ($maxPosition ?? -1) + 1; + } + + /** + * Move this area to a new position. + */ + public function moveToPosition(int $newPosition): void + { + if ($newPosition === $this->position) { + return; + } + + $oldPosition = $this->position; + + if ($newPosition > $oldPosition) { + // Moving down: decrement positions of items in between + static::where('park_id', $this->park_id) + ->where('parent_id', $this->parent_id) + ->whereBetween('position', [$oldPosition + 1, $newPosition]) + ->decrement('position'); + } else { + // Moving up: increment positions of items in between + static::where('park_id', $this->park_id) + ->where('parent_id', $this->parent_id) + ->whereBetween('position', [$newPosition, $oldPosition - 1]) + ->increment('position'); + } + + $this->update(['position' => $newPosition]); + } + + /** + * Scope query to only include operating areas. + */ + public function scopeOperating($query) + { + return $query->whereNull('closing_date'); + } + + /** + * Scope query to only include top-level areas. + */ + public function scopeTopLevel($query) + { + return $query->whereNull('parent_id'); + } + + /** + * Override parent method to ensure unique slugs within a park. + */ + protected function generateSlug(): string + { + $slug = \Str::slug($this->name); + $count = 2; + + while ( + static::where('park_id', $this->park_id) + ->where('slug', $slug) + ->where('id', '!=', $this->id) + ->exists() || + static::whereHas('slugHistories', function ($query) use ($slug) { + $query->where('slug', $slug); + }) + ->where('park_id', $this->park_id) + ->where('id', '!=', $this->id) + ->exists() + ) { + $slug = \Str::slug($this->name) . '-' . $count++; + } + + return $slug; + } + + /** + * Get the route key for the model. + */ + public function getRouteKeyName(): string + { + return 'slug'; + } + + /** + * Find an area by its slug within a specific park. + */ + public static function findByParkAndSlug(Park $park, string $slug): ?self + { + // Try current slug + $area = static::where('park_id', $park->id) + ->where('slug', $slug) + ->first(); + + if ($area) { + return $area; + } + + // Try historical slug + $slugHistory = SlugHistory::where('slug', $slug) + ->where('sluggable_type', static::class) + ->whereHas('sluggable', function ($query) use ($park) { + $query->where('park_id', $park->id); + }) + ->latest() + ->first(); + + return $slugHistory ? static::find($slugHistory->sluggable_id) : null; + } + + /** + * Boot the model. + */ + protected static function boot() + { + parent::boot(); + + static::creating(function (ParkArea $area) { + if (is_null($area->position)) { + $area->position = $area->getNextPosition(); + } + }); + } +} \ No newline at end of file diff --git a/app/Models/Profile.php b/app/Models/Profile.php new file mode 100644 index 0000000..699d114 --- /dev/null +++ b/app/Models/Profile.php @@ -0,0 +1,131 @@ + + */ + protected $fillable = [ + 'display_name', + 'pronouns', + 'bio', + 'twitter', + 'instagram', + 'youtube', + 'discord', + 'coaster_credits', + 'dark_ride_credits', + 'flat_ride_credits', + 'water_ride_credits', + ]; + + /** + * Get the user that owns the profile + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the avatar URL or generate a default one + */ + public function getAvatarUrl(): string + { + if ($this->avatar) { + return Storage::disk('public')->url($this->avatar); + } + + // Get first letter of username for default avatar + $firstLetter = strtoupper(substr($this->user->name, 0, 1)); + $avatarPath = "avatars/letters/{$firstLetter}_avatar.png"; + + // Check if letter avatar exists, if not use default + if (Storage::disk('public')->exists($avatarPath)) { + return Storage::disk('public')->url($avatarPath); + } + + return asset('images/default-avatar.png'); + } + + /** + * Set the avatar image + */ + public function setAvatar($file): void + { + if ($this->avatar) { + Storage::disk('public')->delete($this->avatar); + } + + $filename = 'avatars/' . uniqid() . '.' . $file->getClientOriginalExtension(); + + // Process and save the image + $image = Image::make($file) + ->fit(200, 200) + ->encode(); + + Storage::disk('public')->put($filename, $image); + + $this->update(['avatar' => $filename]); + } + + /** + * Generate a letter avatar + */ + protected function generateLetterAvatar(string $letter): void + { + $letter = strtoupper($letter); + $image = Image::canvas(200, 200, '#007bff'); + + $image->text($letter, 100, 100, function ($font) { + $font->file(public_path('fonts/Roboto-Bold.ttf')); + $font->size(120); + $font->color('#ffffff'); + $font->align('center'); + $font->valign('center'); + }); + + $filename = "avatars/letters/{$letter}_avatar.png"; + Storage::disk('public')->put($filename, $image->encode('png')); + } + + /** + * Boot the model + */ + protected static function boot() + { + parent::boot(); + + static::creating(function (Profile $profile) { + if (!$profile->profile_id) { + $profile->profile_id = IdGenerator::generate(Profile::class, 'profile_id'); + } + if (!$profile->display_name) { + $profile->display_name = $profile->user->name; + } + }); + + static::created(function (Profile $profile) { + // Generate letter avatar if it doesn't exist + $letter = strtoupper(substr($profile->user->name, 0, 1)); + $avatarPath = "avatars/letters/{$letter}_avatar.png"; + + if (!Storage::disk('public')->exists($avatarPath)) { + $profile->generateLetterAvatar($letter); + } + }); + } +} \ No newline at end of file diff --git a/app/Models/SlugHistory.php b/app/Models/SlugHistory.php new file mode 100644 index 0000000..5e99700 --- /dev/null +++ b/app/Models/SlugHistory.php @@ -0,0 +1,26 @@ + + */ + protected $fillable = [ + 'slug', + ]; + + /** + * Get the parent sluggable model. + */ + public function sluggable(): MorphTo + { + return $this->morphTo(); + } +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index 749c7b7..9a28344 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,31 +2,36 @@ namespace App\Models; -// use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Enums\ThemePreference; +use App\Enums\UserRole; +use App\Services\IdGenerator; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; class User extends Authenticatable { - /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory, Notifiable; /** * The attributes that are mass assignable. * - * @var list + * @var array */ protected $fillable = [ 'name', 'email', 'password', + 'role', + 'theme_preference', + 'pending_email', ]; /** * The attributes that should be hidden for serialization. * - * @var list + * @var array */ protected $hidden = [ 'password', @@ -43,6 +48,107 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'role' => UserRole::class, + 'theme_preference' => ThemePreference::class, + 'is_banned' => 'boolean', + 'ban_date' => 'datetime', ]; } + + /** + * Get the user's profile + */ + public function profile(): HasOne + { + return $this->hasOne(Profile::class); + } + + /** + * Get the user's display name, falling back to username if not set + */ + public function getDisplayName(): string + { + return $this->profile?->display_name ?? $this->name; + } + + /** + * Check if the user has moderation privileges + */ + public function canModerate(): bool + { + return $this->role->canModerate(); + } + + /** + * Check if the user has admin privileges + */ + public function canAdmin(): bool + { + return $this->role->canAdmin(); + } + + /** + * Ban the user with a reason + */ + public function ban(string $reason): void + { + $this->update([ + 'is_banned' => true, + 'ban_reason' => $reason, + 'ban_date' => now(), + ]); + } + + /** + * Unban the user + */ + public function unban(): void + { + $this->update([ + 'is_banned' => false, + 'ban_reason' => null, + 'ban_date' => null, + ]); + } + + /** + * Handle pending email changes + */ + public function setPendingEmail(string $email): void + { + $this->update(['pending_email' => $email]); + } + + /** + * Confirm pending email change + */ + public function confirmEmailChange(): void + { + if ($this->pending_email) { + $this->update([ + 'email' => $this->pending_email, + 'pending_email' => null, + ]); + } + } + + /** + * Boot the model + */ + protected static function boot() + { + parent::boot(); + + static::creating(function (User $user) { + if (!$user->user_id) { + $user->user_id = IdGenerator::generate(User::class, 'user_id'); + } + if (!$user->role) { + $user->role = UserRole::USER; + } + if (!$user->theme_preference) { + $user->theme_preference = ThemePreference::LIGHT; + } + }); + } } diff --git a/app/Services/IdGenerator.php b/app/Services/IdGenerator.php new file mode 100644 index 0000000..71b6f20 --- /dev/null +++ b/app/Services/IdGenerator.php @@ -0,0 +1,41 @@ +exists()) { + return $id; + } + + $attempts++; + } + + // If we get here, try a completely random string as fallback + return Str::random(10); + } +} \ No newline at end of file diff --git a/app/Services/StatisticsCacheService.php b/app/Services/StatisticsCacheService.php new file mode 100644 index 0000000..67b5f72 --- /dev/null +++ b/app/Services/StatisticsCacheService.php @@ -0,0 +1,217 @@ +getAreaKey($area), + [ + 'ride_distribution' => $area->ride_distribution, + 'daily_capacity' => $area->formatted_daily_capacity, + 'rating' => $area->rating_display, + 'wait_time' => $area->formatted_peak_wait_time, + 'historical' => $area->historical_stats, + 'updated_at' => now(), + ], + static::CACHE_TTL + ); + + Log::info("Cached statistics for area {$area->id}"); + } catch (\Exception $e) { + Log::error("Failed to cache area statistics: {$e->getMessage()}"); + } + } + + /** + * Cache park statistics. + */ + public function cacheParkStatistics(Park $park): void + { + try { + Cache::put( + $this->getParkKey($park), + [ + 'area_distribution' => $park->area_distribution, + 'ride_distribution' => $park->ride_distribution, + 'daily_capacity' => $park->formatted_daily_capacity, + 'rating' => $park->rating_display, + 'wait_time' => $park->formatted_wait_time, + 'historical' => $park->historical_stats, + 'performance' => $park->performance_metrics, + 'updated_at' => now(), + ], + static::CACHE_TTL + ); + + Log::info("Cached statistics for park {$park->id}"); + } catch (\Exception $e) { + Log::error("Failed to cache park statistics: {$e->getMessage()}"); + } + } + + /** + * Cache operator statistics. + */ + public function cacheOperatorStatistics(Operator $operator): void + { + try { + Cache::put( + $this->getOperatorKey($operator), + [ + 'park_count' => $operator->total_parks, + 'operating_parks' => $operator->operating_parks, + 'closed_parks' => $operator->closed_parks, + 'total_rides' => $operator->total_rides, + 'total_coasters' => $operator->total_coasters, + 'average_rating' => $operator->average_rating, + 'total_capacity' => $operator->total_daily_capacity, + 'updated_at' => now(), + ], + static::CACHE_TTL + ); + + Log::info("Cached statistics for operator {$operator->id}"); + } catch (\Exception $e) { + Log::error("Failed to cache operator statistics: {$e->getMessage()}"); + } + } + + /** + * Get cached area statistics. + * + * @return array|null + */ + public function getAreaStatistics(ParkArea $area): ?array + { + return Cache::get($this->getAreaKey($area)); + } + + /** + * Get cached park statistics. + * + * @return array|null + */ + public function getParkStatistics(Park $park): ?array + { + return Cache::get($this->getParkKey($park)); + } + + /** + * Get cached operator statistics. + * + * @return array|null + */ + public function getOperatorStatistics(Operator $operator): ?array + { + return Cache::get($this->getOperatorKey($operator)); + } + + /** + * Invalidate area statistics cache. + */ + public function invalidateAreaCache(ParkArea $area): void + { + Cache::forget($this->getAreaKey($area)); + Log::info("Invalidated cache for area {$area->id}"); + } + + /** + * Invalidate park statistics cache. + */ + public function invalidateParkCache(Park $park): void + { + Cache::forget($this->getParkKey($park)); + Log::info("Invalidated cache for park {$park->id}"); + } + + /** + * Invalidate operator statistics cache. + */ + public function invalidateOperatorCache(Operator $operator): void + { + Cache::forget($this->getOperatorKey($operator)); + Log::info("Invalidated cache for operator {$operator->id}"); + } + + /** + * Warm up caches for all entities. + */ + public function warmCaches(): void + { + try { + // Cache area statistics + ParkArea::chunk(100, function ($areas) { + foreach ($areas as $area) { + $this->cacheAreaStatistics($area); + } + }); + + // Cache park statistics + Park::chunk(100, function ($parks) { + foreach ($parks as $park) { + $this->cacheParkStatistics($park); + } + }); + + // Cache operator statistics + Operator::chunk(100, function ($operators) { + foreach ($operators as $operator) { + $this->cacheOperatorStatistics($operator); + } + }); + + Log::info('Successfully warmed up all statistics caches'); + } catch (\Exception $e) { + Log::error("Failed to warm up caches: {$e->getMessage()}"); + } + } + + /** + * Get cache key for area. + */ + protected function getAreaKey(ParkArea $area): string + { + return static::AREA_PREFIX . $area->id; + } + + /** + * Get cache key for park. + */ + protected function getParkKey(Park $park): string + { + return static::PARK_PREFIX . $park->id; + } + + /** + * Get cache key for operator. + */ + protected function getOperatorKey(Operator $operator): string + { + return static::OPERATOR_PREFIX . $operator->id; + } +} \ No newline at end of file diff --git a/app/Services/StatisticsRollupService.php b/app/Services/StatisticsRollupService.php new file mode 100644 index 0000000..6ecfb87 --- /dev/null +++ b/app/Services/StatisticsRollupService.php @@ -0,0 +1,162 @@ +updateParkStatistics($area->park); + }); + } + + /** + * Update statistics for a specific park. + */ + public function updateParkStatistics(Park $park): void + { + DB::transaction(function () use ($park) { + // Update area counts + $park->updateAreaCounts(); + + // Update ride statistics + $park->updateRideStatistics(); + + // Update visitor statistics + $park->updateVisitorStats(); + + // Update operator statistics + if ($park->operator) { + $this->updateOperatorStatistics($park->operator); + } + }); + } + + /** + * Update statistics for a specific operator. + */ + public function updateOperatorStatistics(Operator $operator): void + { + DB::transaction(function () use ($operator) { + $parks = $operator->parks; + + // Update park counts + $operator->update([ + 'total_parks' => $parks->count(), + 'operating_parks' => $parks->operating()->count(), + 'closed_parks' => $parks->closed()->count(), + ]); + + // Update ride totals + $operator->update([ + 'total_rides' => $parks->sum('total_rides'), + 'total_coasters' => $parks->sum('total_coasters'), + 'total_flat_rides' => $parks->sum('total_flat_rides'), + 'total_water_rides' => $parks->sum('total_water_rides'), + ]); + + // Update performance metrics + $ratedParks = $parks->whereNotNull('average_rating'); + if ($ratedParks->count() > 0) { + $operator->update([ + 'average_rating' => $ratedParks->avg('average_rating'), + 'total_daily_capacity' => $parks->sum('total_daily_capacity'), + 'average_utilization' => $parks->avg('utilization_rate'), + ]); + } + }); + } + + /** + * Update all statistics in the system. + */ + public function refreshAllStatistics(): void + { + DB::transaction(function () { + // Update all areas first + ParkArea::chunk(100, function ($areas) { + foreach ($areas as $area) { + // Area statistics will be implemented with Rides system + } + }); + + // Update all parks + Park::chunk(100, function ($parks) { + foreach ($parks as $park) { + $this->updateParkStatistics($park); + } + }); + + // Update all operators + Operator::chunk(100, function ($operators) { + foreach ($operators as $operator) { + $this->updateOperatorStatistics($operator); + } + }); + }); + } + + /** + * Schedule regular statistics updates. + */ + public function scheduleUpdates(): void + { + // This method will be called by the scheduler + $this->refreshAllStatistics(); + } + + /** + * Handle ride addition event. + */ + public function handleRideAdded(ParkArea $area): void + { + DB::transaction(function () use ($area) { + $area->recordNewRide(); + $this->updateAreaStatistics($area); + }); + } + + /** + * Handle ride retirement event. + */ + public function handleRideRetired(ParkArea $area): void + { + DB::transaction(function () use ($area) { + $area->recordRetirement(); + $this->updateAreaStatistics($area); + }); + } + + /** + * Handle park expansion event. + */ + public function handleParkExpansion(Park $park): void + { + DB::transaction(function () use ($park) { + $park->recordExpansion(); + $this->updateParkStatistics($park); + }); + } + + /** + * Handle major update event. + */ + public function handleMajorUpdate(Park $park): void + { + DB::transaction(function () use ($park) { + $park->recordMajorUpdate(); + $this->updateParkStatistics($park); + }); + } +} \ No newline at end of file diff --git a/app/Traits/HasAreaStatistics.php b/app/Traits/HasAreaStatistics.php new file mode 100644 index 0000000..3e01ac2 --- /dev/null +++ b/app/Traits/HasAreaStatistics.php @@ -0,0 +1,169 @@ +ride_count ?? 0; + } + + /** + * Get the percentage of coasters among all rides. + */ + public function getCoasterPercentageAttribute(): float + { + if ($this->ride_count === 0) { + return 0; + } + + return round(($this->coaster_count / $this->ride_count) * 100, 1); + } + + /** + * Get a summary of ride types distribution. + * + * @return array + */ + public function getRideDistributionAttribute(): array + { + return [ + 'coasters' => $this->coaster_count ?? 0, + 'flat_rides' => $this->flat_ride_count ?? 0, + 'water_rides' => $this->water_ride_count ?? 0, + ]; + } + + /** + * Get the formatted daily capacity. + */ + public function getFormattedDailyCapacityAttribute(): string + { + if (!$this->daily_capacity) { + return 'Unknown capacity'; + } + + return number_format($this->daily_capacity) . ' riders/day'; + } + + /** + * Get the formatted peak wait time. + */ + public function getFormattedPeakWaitTimeAttribute(): string + { + if (!$this->peak_wait_time) { + return 'Unknown wait time'; + } + + return $this->peak_wait_time . ' minutes'; + } + + /** + * Get the rating display with stars. + */ + public function getRatingDisplayAttribute(): string + { + if (!$this->average_rating) { + return 'Not rated'; + } + + $stars = str_repeat('★', floor($this->average_rating)); + $stars .= str_repeat('☆', 5 - floor($this->average_rating)); + + return $stars . ' (' . number_format($this->average_rating, 1) . ')'; + } + + /** + * Get historical statistics summary. + * + * @return array + */ + public function getHistoricalStatsAttribute(): array + { + return [ + 'total_operated' => $this->total_rides_operated, + 'retired_count' => $this->retired_rides_count, + 'last_addition' => $this->last_new_ride_added?->format('M Y') ?? 'Never', + 'retirement_rate' => $this->getRetirementRate(), + ]; + } + + /** + * Calculate the retirement rate (retired rides as percentage of total operated). + */ + protected function getRetirementRate(): float + { + if ($this->total_rides_operated === 0) { + return 0; + } + + return round(($this->retired_rides_count / $this->total_rides_operated) * 100, 1); + } + + /** + * Update ride counts. + * + * @param array $counts + */ + public function updateRideCounts(array $counts): void + { + $this->update([ + 'ride_count' => $counts['total'] ?? 0, + 'coaster_count' => $counts['coasters'] ?? 0, + 'flat_ride_count' => $counts['flat_rides'] ?? 0, + 'water_ride_count' => $counts['water_rides'] ?? 0, + ]); + } + + /** + * Update visitor statistics. + */ + public function updateVisitorStats(int $dailyCapacity, int $peakWaitTime, float $rating): void + { + $this->update([ + 'daily_capacity' => $dailyCapacity, + 'peak_wait_time' => $peakWaitTime, + 'average_rating' => round($rating, 2), + ]); + } + + /** + * Record a new ride addition. + */ + public function recordNewRide(): void + { + $this->increment('total_rides_operated'); + $this->update(['last_new_ride_added' => now()]); + } + + /** + * Record a ride retirement. + */ + public function recordRetirement(): void + { + $this->increment('retired_rides_count'); + } + + /** + * Reset all statistics to zero. + */ + public function resetStatistics(): void + { + $this->update([ + 'ride_count' => 0, + 'coaster_count' => 0, + 'flat_ride_count' => 0, + 'water_ride_count' => 0, + 'daily_capacity' => null, + 'peak_wait_time' => null, + 'average_rating' => null, + 'total_rides_operated' => 0, + 'retired_rides_count' => 0, + 'last_new_ride_added' => null, + ]); + } +} \ No newline at end of file diff --git a/app/Traits/HasParkStatistics.php b/app/Traits/HasParkStatistics.php new file mode 100644 index 0000000..c1b985a --- /dev/null +++ b/app/Traits/HasParkStatistics.php @@ -0,0 +1,202 @@ +total_rides ?? 0; + } + + /** + * Get the percentage of coasters among all rides. + */ + public function getCoasterPercentageAttribute(): float + { + if ($this->total_rides === 0) { + return 0; + } + + return round(($this->total_coasters / $this->total_rides) * 100, 1); + } + + /** + * Get a summary of ride types distribution. + * + * @return array + */ + public function getRideDistributionAttribute(): array + { + return [ + 'coasters' => $this->total_coasters ?? 0, + 'flat_rides' => $this->total_flat_rides ?? 0, + 'water_rides' => $this->total_water_rides ?? 0, + ]; + } + + /** + * Get a summary of area statistics. + * + * @return array + */ + public function getAreaDistributionAttribute(): array + { + return [ + 'total' => $this->total_areas ?? 0, + 'operating' => $this->operating_areas ?? 0, + 'closed' => $this->closed_areas ?? 0, + ]; + } + + /** + * Get the formatted daily capacity. + */ + public function getFormattedDailyCapacityAttribute(): string + { + if (!$this->total_daily_capacity) { + return 'Unknown capacity'; + } + + return number_format($this->total_daily_capacity) . ' riders/day'; + } + + /** + * Get the formatted average wait time. + */ + public function getFormattedWaitTimeAttribute(): string + { + if (!$this->average_wait_time) { + return 'Unknown wait time'; + } + + return $this->average_wait_time . ' minutes'; + } + + /** + * Get the rating display with stars. + */ + public function getRatingDisplayAttribute(): string + { + if (!$this->average_rating) { + return 'Not rated'; + } + + $stars = str_repeat('★', floor($this->average_rating)); + $stars .= str_repeat('☆', 5 - floor($this->average_rating)); + + return $stars . ' (' . number_format($this->average_rating, 1) . ')'; + } + + /** + * Get historical statistics summary. + * + * @return array + */ + public function getHistoricalStatsAttribute(): array + { + return [ + 'total_operated' => $this->total_rides_operated, + 'total_retired' => $this->total_rides_retired, + 'last_expansion' => $this->last_expansion_date?->format('M Y') ?? 'Never', + 'last_update' => $this->last_major_update?->format('M Y') ?? 'Never', + 'retirement_rate' => $this->getRetirementRate(), + ]; + } + + /** + * Get performance metrics summary. + * + * @return array + */ + public function getPerformanceMetricsAttribute(): array + { + return [ + 'utilization' => $this->utilization_rate ? $this->utilization_rate . '%' : 'Unknown', + 'peak_attendance' => $this->peak_daily_attendance ? number_format($this->peak_daily_attendance) : 'Unknown', + 'satisfaction' => $this->guest_satisfaction ? number_format($this->guest_satisfaction, 1) . '/5.0' : 'Unknown', + ]; + } + + /** + * Calculate the retirement rate (retired rides as percentage of total operated). + */ + protected function getRetirementRate(): float + { + if ($this->total_rides_operated === 0) { + return 0; + } + + return round(($this->total_rides_retired / $this->total_rides_operated) * 100, 1); + } + + /** + * Update area counts. + */ + public function updateAreaCounts(): void + { + $this->update([ + 'total_areas' => $this->areas()->count(), + 'operating_areas' => $this->areas()->operating()->count(), + 'closed_areas' => $this->areas()->whereNotNull('closing_date')->count(), + ]); + } + + /** + * Update ride statistics. + */ + public function updateRideStatistics(): void + { + $areas = $this->areas; + + $this->update([ + 'total_rides' => $areas->sum('ride_count'), + 'total_coasters' => $areas->sum('coaster_count'), + 'total_flat_rides' => $areas->sum('flat_ride_count'), + 'total_water_rides' => $areas->sum('water_ride_count'), + 'total_daily_capacity' => $areas->sum('daily_capacity'), + ]); + } + + /** + * Update visitor statistics. + */ + public function updateVisitorStats(): void + { + $areas = $this->areas()->whereNotNull('average_rating'); + + $this->update([ + 'average_wait_time' => $areas->avg('peak_wait_time'), + 'average_rating' => $areas->avg('average_rating'), + ]); + } + + /** + * Record an expansion. + */ + public function recordExpansion(): void + { + $this->update(['last_expansion_date' => now()]); + } + + /** + * Record a major update. + */ + public function recordMajorUpdate(): void + { + $this->update(['last_major_update' => now()]); + } + + /** + * Update all statistics. + */ + public function refreshStatistics(): void + { + $this->updateAreaCounts(); + $this->updateRideStatistics(); + $this->updateVisitorStats(); + } +} \ No newline at end of file diff --git a/app/Traits/HasSlugHistory.php b/app/Traits/HasSlugHistory.php new file mode 100644 index 0000000..9d66d65 --- /dev/null +++ b/app/Traits/HasSlugHistory.php @@ -0,0 +1,106 @@ +slug) { + $model->slug = $model->generateSlug(); + } + }); + + static::updating(function ($model) { + if ($model->isDirty('slug') && $model->getOriginal('slug')) { + static::addToSlugHistory($model->getOriginal('slug')); + } + }); + } + + /** + * Get all slug histories for this model. + */ + public function slugHistories(): MorphMany + { + return $this->morphMany(SlugHistory::class, 'sluggable'); + } + + /** + * Add a slug to the history. + */ + protected function addToSlugHistory(string $slug): void + { + $this->slugHistories()->create(['slug' => $slug]); + } + + /** + * Generate a unique slug. + */ + protected function generateSlug(): string + { + $slug = Str::slug($this->name); + $count = 2; + + while ( + static::where('slug', $slug) + ->where('id', '!=', $this->id) + ->exists() || + SlugHistory::where('slug', $slug) + ->where('sluggable_type', get_class($this)) + ->where('sluggable_id', '!=', $this->id) + ->exists() + ) { + $slug = Str::slug($this->name) . '-' . $count++; + } + + return $slug; + } + + /** + * Find a model by its current or historical slug. + * + * @param string $slug + * @return static|null + */ + public static function findBySlug(string $slug) + { + // Try current slug + $model = static::where('slug', $slug)->first(); + if ($model) { + return $model; + } + + // Try historical slug + $slugHistory = SlugHistory::where('slug', $slug) + ->where('sluggable_type', static::class) + ->latest() + ->first(); + + if ($slugHistory) { + return static::find($slugHistory->sluggable_id); + } + + return null; + } + + /** + * Find a model by its current or historical slug or fail. + * + * @param string $slug + * @return static + * @throws \Illuminate\Database\Eloquent\ModelNotFoundException + */ + public static function findBySlugOrFail(string $slug) + { + return static::findBySlug($slug) ?? throw new \Illuminate\Database\Eloquent\ModelNotFoundException; + } +} \ No newline at end of file diff --git a/database/migrations/2024_02_23_233835_add_position_to_park_areas.php b/database/migrations/2024_02_23_233835_add_position_to_park_areas.php new file mode 100644 index 0000000..8804ea9 --- /dev/null +++ b/database/migrations/2024_02_23_233835_add_position_to_park_areas.php @@ -0,0 +1,42 @@ +integer('position')->default(0); + // Add parent_id for nested areas + $table->foreignId('parent_id') + ->nullable() + ->constrained('park_areas') + ->nullOnDelete(); + + // Add index for efficient ordering queries + $table->index(['park_id', 'position']); + // Add index for parent relationship queries + $table->index(['park_id', 'parent_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('park_areas', function (Blueprint $table) { + $table->dropIndex(['park_id', 'position']); + $table->dropIndex(['park_id', 'parent_id']); + $table->dropForeign(['parent_id']); + $table->dropColumn(['position', 'parent_id']); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2024_02_23_234035_add_statistics_to_park_areas.php b/database/migrations/2024_02_23_234035_add_statistics_to_park_areas.php new file mode 100644 index 0000000..26cecae --- /dev/null +++ b/database/migrations/2024_02_23_234035_add_statistics_to_park_areas.php @@ -0,0 +1,62 @@ +integer('ride_count')->default(0); + $table->integer('coaster_count')->default(0); + $table->integer('flat_ride_count')->default(0); + $table->integer('water_ride_count')->default(0); + + // Visitor statistics + $table->integer('daily_capacity')->nullable(); + $table->integer('peak_wait_time')->nullable(); + $table->decimal('average_rating', 3, 2)->nullable(); + + // Historical data + $table->integer('total_rides_operated')->default(0); + $table->integer('retired_rides_count')->default(0); + $table->date('last_new_ride_added')->nullable(); + + // Add indexes for common queries + $table->index(['park_id', 'ride_count']); + $table->index(['park_id', 'coaster_count']); + $table->index(['park_id', 'average_rating']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('park_areas', function (Blueprint $table) { + $table->dropIndex(['park_id', 'ride_count']); + $table->dropIndex(['park_id', 'coaster_count']); + $table->dropIndex(['park_id', 'average_rating']); + + $table->dropColumn([ + 'ride_count', + 'coaster_count', + 'flat_ride_count', + 'water_ride_count', + 'daily_capacity', + 'peak_wait_time', + 'average_rating', + 'total_rides_operated', + 'retired_rides_count', + 'last_new_ride_added', + ]); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2024_02_23_234235_add_statistics_to_parks_table.php b/database/migrations/2024_02_23_234235_add_statistics_to_parks_table.php new file mode 100644 index 0000000..5aad704 --- /dev/null +++ b/database/migrations/2024_02_23_234235_add_statistics_to_parks_table.php @@ -0,0 +1,80 @@ +integer('total_areas')->default(0); + $table->integer('operating_areas')->default(0); + $table->integer('closed_areas')->default(0); + + // Ride statistics + $table->integer('total_rides')->default(0); + $table->integer('total_coasters')->default(0); + $table->integer('total_flat_rides')->default(0); + $table->integer('total_water_rides')->default(0); + + // Visitor statistics + $table->integer('total_daily_capacity')->default(0); + $table->integer('average_wait_time')->nullable(); + $table->decimal('average_rating', 3, 2)->nullable(); + + // Historical data + $table->integer('total_rides_operated')->default(0); + $table->integer('total_rides_retired')->default(0); + $table->date('last_expansion_date')->nullable(); + $table->date('last_major_update')->nullable(); + + // Performance metrics + $table->decimal('utilization_rate', 5, 2)->nullable(); + $table->integer('peak_daily_attendance')->nullable(); + $table->decimal('guest_satisfaction', 3, 2)->nullable(); + + // Add indexes for common queries + $table->index(['operator_id', 'total_rides']); + $table->index(['operator_id', 'total_coasters']); + $table->index(['operator_id', 'average_rating']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('parks', function (Blueprint $table) { + $table->dropIndex(['operator_id', 'total_rides']); + $table->dropIndex(['operator_id', 'total_coasters']); + $table->dropIndex(['operator_id', 'average_rating']); + + $table->dropColumn([ + 'total_areas', + 'operating_areas', + 'closed_areas', + 'total_rides', + 'total_coasters', + 'total_flat_rides', + 'total_water_rides', + 'total_daily_capacity', + 'average_wait_time', + 'average_rating', + 'total_rides_operated', + 'total_rides_retired', + 'last_expansion_date', + 'last_major_update', + 'utilization_rate', + 'peak_daily_attendance', + 'guest_satisfaction', + ]); + }); + } +}; diff --git a/database/migrations/2024_02_23_234450_add_user_fields.php b/database/migrations/2024_02_23_234450_add_user_fields.php new file mode 100644 index 0000000..1ad3fb6 --- /dev/null +++ b/database/migrations/2024_02_23_234450_add_user_fields.php @@ -0,0 +1,42 @@ +string('user_id', 10)->unique()->after('id'); + $table->enum('role', ['USER', 'MODERATOR', 'ADMIN', 'SUPERUSER'])->default('USER')->after('remember_token'); + $table->boolean('is_banned')->default(false)->after('role'); + $table->text('ban_reason')->nullable()->after('is_banned'); + $table->timestamp('ban_date')->nullable()->after('ban_reason'); + $table->string('pending_email')->nullable()->after('email'); + $table->enum('theme_preference', ['light', 'dark'])->default('light')->after('ban_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'user_id', + 'role', + 'is_banned', + 'ban_reason', + 'ban_date', + 'pending_email', + 'theme_preference' + ]); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2024_02_23_234505_create_profiles_table.php b/database/migrations/2024_02_23_234505_create_profiles_table.php new file mode 100644 index 0000000..2a37444 --- /dev/null +++ b/database/migrations/2024_02_23_234505_create_profiles_table.php @@ -0,0 +1,46 @@ +id(); + $table->string('profile_id', 10)->unique(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('display_name', 50)->unique(); + $table->string('avatar')->nullable(); + $table->string('pronouns', 50)->nullable(); + $table->text('bio')->nullable(); + + // Social media links + $table->string('twitter')->nullable(); + $table->string('instagram')->nullable(); + $table->string('youtube')->nullable(); + $table->string('discord', 100)->nullable(); + + // Ride statistics + $table->integer('coaster_credits')->default(0); + $table->integer('dark_ride_credits')->default(0); + $table->integer('flat_ride_credits')->default(0); + $table->integer('water_ride_credits')->default(0); + + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('profiles'); + } +}; \ No newline at end of file diff --git a/database/migrations/2024_02_23_234948_create_operators_and_manufacturers_tables.php b/database/migrations/2024_02_23_234948_create_operators_and_manufacturers_tables.php new file mode 100644 index 0000000..581d9cb --- /dev/null +++ b/database/migrations/2024_02_23_234948_create_operators_and_manufacturers_tables.php @@ -0,0 +1,58 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('website')->nullable(); + $table->string('headquarters')->nullable(); + $table->text('description')->nullable(); + $table->integer('total_parks')->default(0); + $table->integer('total_rides')->default(0); + $table->timestamps(); + }); + + Schema::create('manufacturers', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('website')->nullable(); + $table->string('headquarters')->nullable(); + $table->text('description')->nullable(); + $table->integer('total_rides')->default(0); + $table->integer('total_roller_coasters')->default(0); + $table->timestamps(); + }); + + // Create slug history table for tracking slug changes + Schema::create('slug_histories', function (Blueprint $table) { + $table->id(); + $table->morphs('sluggable'); // Creates sluggable_type and sluggable_id + $table->string('slug'); + $table->timestamps(); + + $table->index(['sluggable_type', 'slug']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('slug_histories'); + Schema::dropIfExists('manufacturers'); + Schema::dropIfExists('operators'); + } +}; \ No newline at end of file diff --git a/database/migrations/2024_02_23_235000_create_locations_table.php b/database/migrations/2024_02_23_235000_create_locations_table.php new file mode 100644 index 0000000..d9c0988 --- /dev/null +++ b/database/migrations/2024_02_23_235000_create_locations_table.php @@ -0,0 +1,59 @@ +id(); + + // Polymorphic relationship + $table->morphs('locatable'); + + // Location details + $table->string('address')->nullable(); + $table->string('city'); + $table->string('state')->nullable(); + $table->string('country'); + $table->string('postal_code')->nullable(); + + // Coordinates + $table->decimal('latitude', 10, 8); + $table->decimal('longitude', 11, 8); + $table->decimal('elevation', 8, 2)->nullable(); + + // Additional details + $table->string('timezone')->nullable(); + $table->json('metadata')->nullable(); + $table->boolean('is_approximate')->default(false); + $table->string('source')->nullable(); + + // Geocoding cache + $table->json('geocoding_data')->nullable(); + $table->timestamp('geocoded_at')->nullable(); + + // Timestamps + $table->timestamps(); + + // Indexes + $table->index(['latitude', 'longitude']); + $table->index(['country', 'state', 'city']); + $table->index('postal_code'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('locations'); + } +}; \ No newline at end of file diff --git a/database/migrations/2024_02_23_235830_create_parks_and_park_areas_tables.php b/database/migrations/2024_02_23_235830_create_parks_and_park_areas_tables.php new file mode 100644 index 0000000..dbc5928 --- /dev/null +++ b/database/migrations/2024_02_23_235830_create_parks_and_park_areas_tables.php @@ -0,0 +1,72 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('status', 20); + + // Details + $table->date('opening_date')->nullable(); + $table->date('closing_date')->nullable(); + $table->string('operating_season')->nullable(); + $table->decimal('size_acres', 10, 2)->nullable(); + $table->string('website')->nullable(); + + // Statistics + $table->decimal('average_rating', 3, 2)->nullable(); + $table->integer('ride_count')->nullable(); + $table->integer('coaster_count')->nullable(); + + // Foreign keys + $table->foreignId('operator_id') + ->nullable() + ->constrained() + ->nullOnDelete(); + + $table->timestamps(); + }); + + Schema::create('park_areas', function (Blueprint $table) { + $table->id(); + $table->foreignId('park_id') + ->constrained() + ->cascadeOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->date('opening_date')->nullable(); + $table->date('closing_date')->nullable(); + $table->timestamps(); + + // Ensure unique slugs within each park + $table->unique(['park_id', 'slug']); + }); + + // Create index for park status for efficient filtering + Schema::table('parks', function (Blueprint $table) { + $table->index('status'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('park_areas'); + Schema::dropIfExists('parks'); + } +}; \ No newline at end of file diff --git a/memory-bank/activeContext.md b/memory-bank/activeContext.md new file mode 100644 index 0000000..a89e5f2 --- /dev/null +++ b/memory-bank/activeContext.md @@ -0,0 +1,168 @@ +# Current Development Context + +## Active Task +Converting ThrillWiki from Django to Laravel+Livewire + +## Current Phase +Parks and Areas Management Implementation + +## Progress + +### Completed +1. ✅ Set up Laravel project structure +2. ✅ Create user management migrations: + - Extended users table with required fields + - Created profiles table +3. ✅ Created User Management Models: + - Enhanced User model with roles and preferences + - Created Profile model with avatar handling +4. ✅ Implemented User Profile Management: + - Created ProfileComponent Livewire component + - Implemented profile editing interface + - Added avatar upload functionality +5. ✅ Created Base Infrastructure: + - ParkStatus enum with status display methods + - IdGenerator service for consistent ID generation +6. ✅ Implemented Slug History System: + - Created HasSlugHistory trait + - Implemented SlugHistory model + - Set up polymorphic relationships + - Added slug generation and tracking +7. ✅ Implemented Operator/Manufacturer System: + - Created migrations for operators and manufacturers + - Implemented Operator model with park relationships + - Implemented Manufacturer model with ride relationships + - Added statistics tracking methods +8. ✅ Implemented Parks System: + - Created migrations for parks and areas + - Implemented Park model with status handling + - Implemented ParkArea model with scoped slugs + - Added relationships and statistics tracking +9. ✅ Created Parks Management Interface: + - Implemented ParkFormComponent for CRUD + - Created ParkListComponent with filtering + - Added responsive grid layouts + - Implemented search and sorting +10. ✅ Created Park Areas Management: + - Implemented ParkAreaFormComponent + - Created ParkAreaListComponent + - Added area filtering and search + - Implemented area deletion +11. ✅ Implemented Area Organization: + - Added position and parent_id fields + - Created drag-and-drop reordering + - Implemented nested area support + - Added position management + - Created move functionality +12. ✅ Implemented Area Statistics: + - Added statistics fields to areas + - Created HasAreaStatistics trait + - Implemented statistics component + - Added visual data display + - Created historical tracking +13. ✅ Implemented Statistics Rollup: + - Added park-level statistics + - Created HasParkStatistics trait + - Implemented rollup service + - Added transaction safety + - Created event handlers +14. ✅ Implemented Statistics Caching: + - Created caching service + - Added cache invalidation + - Implemented cache warming + - Added performance monitoring + - Created error handling + +### In Progress +1. [ ] Location System Implementation + - Model structure design + - Polymorphic relationships + - Map integration + - Location selection + +### Next Steps +1. Location System + - [ ] Create location model + - [ ] Add polymorphic relationships + - [ ] Implement geocoding service + - [ ] Create map component + - [ ] Add location selection + - [ ] Implement search + - [ ] Add clustering + - [ ] Create distance calculations + +2. Performance Optimization + - [ ] Implement query caching + - [ ] Add index optimization + - [ ] Create monitoring tools + - [ ] Set up profiling + +## Technical Decisions Made + +### Recent Implementations + +1. Statistics Caching Design + - Service-based architecture + - Hierarchical caching + - Automatic invalidation + - Performance monitoring + +2. Cache Management + - 24-hour TTL + - Batch processing + - Error handling + - Logging system + +3. Performance Features + - Efficient key structure + - Optimized data format + - Minimal cache churn + - Memory management + +### Core Architecture Patterns + +1. Model Organization + - Base models with consistent traits + - Enum-based status handling + - Automatic statistics updates + - Slug history tracking + +2. Data Relationships + - Operators own parks + - Parks contain areas + - Areas can nest + - Statistics rollup + +## Notes and Considerations +1. Need to research map providers +2. Consider caching geocoding results +3. May need clustering for large datasets +4. Should implement distance-based search +5. Consider adding location history +6. Plan for offline maps +7. Consider adding route planning +8. Need to handle map errors +9. Consider adding location sharing +10. Plan for mobile optimization +11. Consider adding geofencing +12. Need location validation + +## Issues to Address +1. [ ] Configure storage link for avatars +2. [ ] Add font for letter avatars +3. [ ] Implement email verification +4. [ ] Add profile creation on registration +5. [ ] Set up slug history cleanup +6. [ ] Implement ride count updates +7. [ ] Add status change tracking +8. [ ] Add statistics caching +9. [ ] Implement park galleries +10. [ ] Add position validation +11. [ ] Implement move restrictions +12. [ ] Add performance monitoring +13. [ ] Create statistics reports +14. [ ] Add trend analysis tools +15. [ ] Set up cache invalidation +16. [ ] Add cache warming jobs +17. [ ] Research map providers +18. [ ] Plan geocoding strategy \ No newline at end of file diff --git a/memory-bank/features/AreaOrganization.md b/memory-bank/features/AreaOrganization.md new file mode 100644 index 0000000..a67b5aa --- /dev/null +++ b/memory-bank/features/AreaOrganization.md @@ -0,0 +1,208 @@ +# Area Organization System + +## Overview +The Area Organization system provides a flexible way to structure and order areas within a park. It supports both flat and nested hierarchies, with drag-and-drop reordering capabilities and efficient position management. + +## Database Structure + +### Park Areas Table +```sql +CREATE TABLE park_areas ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + park_id bigint unsigned NOT NULL, + parent_id bigint unsigned NULL, + name varchar(255) NOT NULL, + slug varchar(255) NOT NULL, + description text NULL, + opening_date date NULL, + closing_date date NULL, + position integer NOT NULL DEFAULT 0, + created_at timestamp NULL, + updated_at timestamp NULL, + PRIMARY KEY (id), + FOREIGN KEY (park_id) REFERENCES parks(id), + FOREIGN KEY (parent_id) REFERENCES park_areas(id), + INDEX idx_ordering (park_id, position), + INDEX idx_hierarchy (park_id, parent_id) +); +``` + +## Key Features + +### 1. Position Management +- Automatic position assignment for new areas +- Zero-based position indexing +- Position maintenance during reordering +- Efficient batch updates for position changes + +### 2. Hierarchical Structure +- Parent-child relationships between areas +- Unlimited nesting depth +- Separate position sequences per parent +- Cascading deletion options + +### 3. Query Optimization +- Compound indexes for efficient ordering +- Optimized parent-child lookups +- Position-based sorting +- Scoped uniqueness constraints + +## Implementation Details + +### Position Management +1. New Area Creation + ```php + protected static function boot() + { + parent::boot(); + static::creating(function (ParkArea $area) { + if (is_null($area->position)) { + $area->position = $area->getNextPosition(); + } + }); + } + ``` + +2. Position Calculation + ```php + public function getNextPosition(): int + { + $maxPosition = static::where('park_id', $this->park_id) + ->where('parent_id', $this->parent_id) + ->max('position'); + + return ($maxPosition ?? -1) + 1; + } + ``` + +3. Position Updates + ```php + public function moveToPosition(int $newPosition): void + { + if ($newPosition === $this->position) { + return; + } + + $oldPosition = $this->position; + + if ($newPosition > $oldPosition) { + // Moving down: decrement positions of items in between + static::where('park_id', $this->park_id) + ->where('parent_id', $this->parent_id) + ->whereBetween('position', [$oldPosition + 1, $newPosition]) + ->decrement('position'); + } else { + // Moving up: increment positions of items in between + static::where('park_id', $this->park_id) + ->where('parent_id', $this->parent_id) + ->whereBetween('position', [$newPosition, $oldPosition - 1]) + ->increment('position'); + } + + $this->update(['position' => $newPosition]); + } + ``` + +### Hierarchical Relationships +1. Parent Relationship + ```php + public function parent(): BelongsTo + { + return $this->belongsTo(ParkArea::class, 'parent_id'); + } + ``` + +2. Children Relationship + ```php + public function children(): HasMany + { + return $this->hasMany(ParkArea::class, 'parent_id') + ->orderBy('position'); + } + ``` + +3. Helper Methods + ```php + public function hasChildren(): bool + { + return $this->children()->exists(); + } + + public function isTopLevel(): bool + { + return is_null($this->parent_id); + } + ``` + +## Usage Examples + +### Creating a New Area +```php +$area = new ParkArea([ + 'name' => 'Adventure Zone', + 'description' => 'Thrilling rides area', +]); +$park->areas()->save($area); +``` + +### Moving an Area +```php +$area->moveToPosition(5); +``` + +### Adding a Sub-Area +```php +$subArea = new ParkArea([ + 'name' => 'Coaster Corner', + 'parent_id' => $area->id, +]); +$park->areas()->save($subArea); +``` + +## Future Enhancements +1. [ ] Add drag-and-drop reordering UI +2. [ ] Implement position validation +3. [ ] Add move restrictions +4. [ ] Implement area statistics +5. [ ] Add bulk reordering +6. [ ] Implement depth limits +7. [ ] Add position caching +8. [ ] Implement move history + +## Security Considerations +1. Position Validation + - Prevent out-of-bounds positions + - Validate parent-child relationships + - Check for circular references + +2. Access Control + - Restrict reordering permissions + - Validate park ownership + - Log position changes + +## Performance Optimization +1. Batch Updates + - Use transactions for moves + - Minimize position updates + - Cache position values + +2. Query Optimization + - Use compound indexes + - Minimize nested queries + - Efficient position calculations + +## Testing Strategy +1. Unit Tests + - [ ] Position calculation + - [ ] Move operations + - [ ] Parent-child relationships + +2. Integration Tests + - [ ] Reordering flows + - [ ] Nested operations + - [ ] Position maintenance + +3. Performance Tests + - [ ] Large-scale reordering + - [ ] Nested structure queries + - [ ] Position update efficiency \ No newline at end of file diff --git a/memory-bank/features/AreaStatistics.md b/memory-bank/features/AreaStatistics.md new file mode 100644 index 0000000..20cee45 --- /dev/null +++ b/memory-bank/features/AreaStatistics.md @@ -0,0 +1,216 @@ +# Area Statistics System + +## Overview +The Area Statistics system provides comprehensive tracking and management of various metrics for park areas, including ride counts, visitor statistics, and historical data. This system enables data-driven insights and performance monitoring at both the area and park level. + +## Database Structure + +### Park Areas Table Statistics Fields +```sql +ALTER TABLE park_areas ADD ( + -- Ride statistics + ride_count integer DEFAULT 0, + coaster_count integer DEFAULT 0, + flat_ride_count integer DEFAULT 0, + water_ride_count integer DEFAULT 0, + + -- Visitor statistics + daily_capacity integer NULL, + peak_wait_time integer NULL, + average_rating decimal(3,2) NULL, + + -- Historical data + total_rides_operated integer DEFAULT 0, + retired_rides_count integer DEFAULT 0, + last_new_ride_added date NULL, + + -- Indexes + INDEX idx_rides (park_id, ride_count), + INDEX idx_coasters (park_id, coaster_count), + INDEX idx_rating (park_id, average_rating) +); +``` + +## Components + +### 1. HasAreaStatistics Trait +Located in `app/Traits/HasAreaStatistics.php` + +Purpose: +- Provides statistics management functionality +- Handles calculations and formatting +- Manages data updates +- Tracks historical metrics + +Features: +1. Ride Statistics + - Total ride count + - Type distribution + - Coaster percentage + - Historical tracking + +2. Visitor Metrics + - Daily capacity + - Peak wait times + - Average ratings + - Formatted displays + +3. Historical Data + - Total rides operated + - Retirement tracking + - Last addition date + - Retirement rate + +## Implementation Details + +### Ride Count Management +```php +public function updateRideCounts(array $counts): void +{ + $this->update([ + 'ride_count' => $counts['total'] ?? 0, + 'coaster_count' => $counts['coasters'] ?? 0, + 'flat_ride_count' => $counts['flat_rides'] ?? 0, + 'water_ride_count' => $counts['water_rides'] ?? 0, + ]); +} +``` + +### Visitor Statistics +```php +public function updateVisitorStats( + int $dailyCapacity, + int $peakWaitTime, + float $rating +): void +{ + $this->update([ + 'daily_capacity' => $dailyCapacity, + 'peak_wait_time' => $peakWaitTime, + 'average_rating' => round($rating, 2), + ]); +} +``` + +### Historical Tracking +```php +public function recordNewRide(): void +{ + $this->increment('total_rides_operated'); + $this->update(['last_new_ride_added' => now()]); +} + +public function recordRetirement(): void +{ + $this->increment('retired_rides_count'); +} +``` + +## Display Features + +### 1. Rating Display +- Star-based visualization (★★★☆☆) +- Numerical rating with one decimal +- "Not rated" fallback + +### 2. Capacity Display +- Formatted numbers with commas +- "riders/day" unit +- Unknown capacity handling + +### 3. Wait Time Display +- Minutes format +- Peak time indication +- Unknown time handling + +## Data Analysis + +### 1. Distribution Analysis +```php +public function getRideDistributionAttribute(): array +{ + return [ + 'coasters' => $this->coaster_count ?? 0, + 'flat_rides' => $this->flat_ride_count ?? 0, + 'water_rides' => $this->water_ride_count ?? 0, + ]; +} +``` + +### 2. Historical Analysis +```php +public function getHistoricalStatsAttribute(): array +{ + return [ + 'total_operated' => $this->total_rides_operated, + 'retired_count' => $this->retired_rides_count, + 'last_addition' => $this->last_new_ride_added?->format('M Y'), + 'retirement_rate' => $this->getRetirementRate(), + ]; +} +``` + +## Performance Optimization + +### 1. Database Indexes +- Compound indexes for common queries +- Efficient sorting support +- Quick statistical lookups + +### 2. Caching Strategy +- [ ] Implement statistics caching +- [ ] Add cache invalidation rules +- [ ] Set up cache warming + +## Future Enhancements +1. [ ] Add seasonal statistics +2. [ ] Implement trend analysis +3. [ ] Add capacity forecasting +4. [ ] Create statistical reports +5. [ ] Add comparison tools +6. [ ] Implement benchmarking +7. [ ] Add historical graphs +8. [ ] Create export functionality + +## Integration Points +1. Park Model + - Area statistics rollup + - Park-wide metrics + - Comparative analysis + +2. Rides System + - Automatic count updates + - Type classification + - Capacity calculation + +3. Visitor System + - Wait time tracking + - Rating collection + - Capacity monitoring + +## Security Considerations +1. Data Validation + - Range checks + - Type validation + - Update authorization + +2. Access Control + - Statistics visibility + - Update permissions + - Export restrictions + +## Testing Strategy +1. Unit Tests + - [ ] Calculation accuracy + - [ ] Format handling + - [ ] Edge cases + +2. Integration Tests + - [ ] Update operations + - [ ] Rollup functionality + - [ ] Cache invalidation + +3. Performance Tests + - [ ] Large dataset handling + - [ ] Update efficiency + - [ ] Query optimization \ No newline at end of file diff --git a/memory-bank/features/LocationSystem.md b/memory-bank/features/LocationSystem.md new file mode 100644 index 0000000..fdd2bad --- /dev/null +++ b/memory-bank/features/LocationSystem.md @@ -0,0 +1,238 @@ +# Location System + +## Overview +The Location System provides comprehensive location management for parks, areas, and other entities through polymorphic relationships. It includes geocoding, map integration, and location-based search capabilities. + +## Components + +### 1. Database Structure + +#### Locations Table +```sql +CREATE TABLE locations ( + id bigint PRIMARY KEY, + locatable_type varchar(255), + locatable_id bigint, + address varchar(255) NULL, + city varchar(255), + state varchar(255) NULL, + country varchar(255), + postal_code varchar(255) NULL, + latitude decimal(10,8), + longitude decimal(11,8), + elevation decimal(8,2) NULL, + timezone varchar(255) NULL, + metadata json NULL, + is_approximate boolean DEFAULT false, + source varchar(255) NULL, + geocoding_data json NULL, + geocoded_at timestamp NULL, + created_at timestamp, + updated_at timestamp, + + INDEX idx_coordinates (latitude, longitude), + INDEX idx_location (country, state, city), + INDEX idx_postal (postal_code) +); +``` + +### 2. Models + +#### Location Model +- Polymorphic relationships +- Geocoding integration +- Coordinate handling +- Distance calculations + +#### HasLocation Trait +- Location relationship +- Coordinate accessors +- Distance methods +- Map integration + +### 3. Services + +#### GeocodeService +- Address lookup +- Coordinate validation +- Batch processing +- Cache management + +#### LocationSearchService +- Distance-based search +- Boundary queries +- Clustering support +- Performance optimization + +### 4. Components + +#### LocationSelector +- Map integration +- Address search +- Coordinate picker +- Validation feedback + +#### LocationDisplay +- Map rendering +- Marker clustering +- Info windows +- Interactive controls + +## Implementation Details + +### 1. Model Structure +```php +class Location extends Model +{ + protected $fillable = [ + 'address', + 'city', + 'state', + 'country', + 'postal_code', + 'latitude', + 'longitude', + 'elevation', + 'timezone', + 'metadata', + 'is_approximate', + 'source', + 'geocoding_data', + 'geocoded_at', + ]; + + protected $casts = [ + 'latitude' => 'decimal:8', + 'longitude' => 'decimal:8', + 'elevation' => 'decimal:2', + 'metadata' => 'array', + 'geocoding_data' => 'array', + 'geocoded_at' => 'datetime', + 'is_approximate' => 'boolean', + ]; +} +``` + +### 2. Trait Implementation +```php +trait HasLocation +{ + public function location() + { + return $this->morphOne(Location::class, 'locatable'); + } + + public function getCoordinatesAttribute() + { + return [ + 'lat' => $this->location?->latitude, + 'lng' => $this->location?->longitude, + ]; + } +} +``` + +## Integration Points + +### 1. Parks System +- Location assignment +- Map display +- Area boundaries +- Distance calculations + +### 2. Search System +- Location-based filtering +- Distance sorting +- Boundary queries +- Clustering support + +### 3. API Integration +- Geocoding services +- Map providers +- Data validation +- Error handling + +## Performance Considerations + +### 1. Database Design +- Efficient indexes +- Coordinate precision +- Query optimization +- Cache strategy + +### 2. Geocoding +- Request limiting +- Cache management +- Batch processing +- Error handling + +### 3. Map Integration +- Lazy loading +- Marker clustering +- Viewport management +- Memory optimization + +## Future Enhancements + +1. [ ] Add route planning +2. [ ] Implement geofencing +3. [ ] Add location sharing +4. [ ] Create heatmaps +5. [ ] Add offline support +6. [ ] Implement navigation +7. [ ] Add location history +8. [ ] Create location alerts + +## Security Considerations + +### 1. Data Protection +- Coordinate validation +- Input sanitization +- Access control +- Audit logging + +### 2. API Security +- Rate limiting +- Token management +- Error handling +- Request validation + +## Testing Strategy + +### 1. Unit Tests +- [ ] Coordinate validation +- [ ] Distance calculations +- [ ] Geocoding integration +- [ ] Model relationships + +### 2. Integration Tests +- [ ] Map integration +- [ ] Search functionality +- [ ] API communication +- [ ] Cache management + +### 3. Performance Tests +- [ ] Large datasets +- [ ] Clustering efficiency +- [ ] Query optimization +- [ ] Memory usage + +## Monitoring + +### 1. Performance Metrics +- [ ] Query timing +- [ ] API response times +- [ ] Cache hit rates +- [ ] Memory usage + +### 2. Error Tracking +- [ ] Geocoding failures +- [ ] API errors +- [ ] Invalid coordinates +- [ ] Cache misses + +### 3. Usage Analytics +- [ ] Search patterns +- [ ] Popular locations +- [ ] API usage +- [ ] User interactions \ No newline at end of file diff --git a/memory-bank/features/ParksManagement.md b/memory-bank/features/ParksManagement.md new file mode 100644 index 0000000..688995f --- /dev/null +++ b/memory-bank/features/ParksManagement.md @@ -0,0 +1,261 @@ +# Parks Management System + +## Overview +The Parks Management system provides a comprehensive interface for creating, reading, updating, and deleting theme parks and their areas. It implements a responsive, user-friendly interface while maintaining data integrity and proper relationships. + +## Components + +### 1. ParkFormComponent +Located in `app/Livewire/ParkFormComponent.php` + +Purpose: +- Handles both creation and editing of parks +- Manages form state and validation +- Handles relationships with operators +- Updates statistics automatically + +Features: +- Responsive form layout +- Real-time validation +- Status management with enum +- Operator selection +- Date range validation +- Automatic statistics updates + +Form Sections: +1. Basic Information + - Park name + - Operator selection + - Description +2. Status and Dates + - Operating status + - Opening/closing dates +3. Additional Details + - Operating season + - Size in acres + - Website + +### 2. ParkListComponent +Located in `app/Livewire/ParkListComponent.php` + +Purpose: +- Displays paginated list of parks +- Provides filtering and sorting capabilities +- Handles search functionality +- Manages park status display + +Features: +1. Search and Filtering + - Text search across name and description + - Status filtering using ParkStatus enum + - Operator filtering + - Multiple sort options + +2. Sorting Options + - Name (default) + - Opening Date + - Ride Count + - Coaster Count + - Size + +3. Display Features + - Responsive grid layout + - Status badges with colors + - Key statistics display + - Quick access to edit/view + - Website links + - Operator information + +### 3. ParkAreaFormComponent +Located in `app/Livewire/ParkAreaFormComponent.php` + +Purpose: +- Manages creation and editing of park areas +- Handles area-specific validation +- Maintains park context +- Manages opening/closing dates + +Features: +1. Form Organization + - Area basic information section + - Dates management section + - Park context display + - Validation feedback + +2. Validation Rules + - Name uniqueness within park + - Date range validation + - Required fields handling + - Custom error messages + +3. Data Management + - Park-scoped slugs + - Automatic slug generation + - History tracking + - Date formatting + +### 4. ParkAreaListComponent +Located in `app/Livewire/ParkAreaListComponent.php` + +Purpose: +- Displays and manages areas within a park +- Provides search and filtering +- Handles area deletion +- Shows operating status + +Features: +1. List Management + - Paginated area display + - Search functionality + - Sort by name or date + - Show/hide closed areas + +2. Area Display + - Area name and description + - Opening/closing dates + - Operating status + - Quick actions + +3. Actions + - Edit area + - Delete area with confirmation + - Add new area + - View area details + +4. Filtering Options + - Text search + - Operating status filter + - Sort direction toggle + - Date-based sorting + +## UI/UX Design Decisions + +### Form Design +1. Sectioned Layout + - Groups related fields together + - Improves visual hierarchy + - Makes long form more manageable + +2. Responsive Grid + - Single column on mobile + - Multi-column on larger screens + - Maintains readability at all sizes + +3. Validation Feedback + - Immediate error messages + - Clear error states + - Success notifications + +### List Design +1. Card Layout + - Visual separation of items + - Key information at a glance + - Status prominence + - Action buttons + +2. Filter Controls + - Prominent search + - Quick status filtering + - Flexible sorting + - Toggle controls + +## Data Handling + +### Validation Rules +```php +// Park Rules +[ + 'name' => ['required', 'string', 'min:2', 'max:255', 'unique'], + 'description' => ['nullable', 'string'], + 'status' => ['required', 'enum'], + 'opening_date' => ['nullable', 'date'], + 'closing_date' => ['nullable', 'date', 'after:opening_date'], + 'operating_season' => ['nullable', 'string', 'max:255'], + 'size_acres' => ['nullable', 'numeric', 'min:0', 'max:999999.99'], + 'website' => ['nullable', 'url', 'max:255'], + 'operator_id' => ['nullable', 'exists:operators,id'], +] + +// Area Rules +[ + 'name' => ['required', 'string', 'min:2', 'max:255', 'unique:within_park'], + 'description' => ['nullable', 'string'], + 'opening_date' => ['nullable', 'date'], + 'closing_date' => ['nullable', 'date', 'after:opening_date'], +] +``` + +### Query Optimization +1. Eager Loading + - Operator relationship + - Areas relationship + - Future: Rides relationship + +2. Search Implementation + - Combined name and description search + - Case-insensitive matching + - Proper index usage + +3. Filter Efficiency + - Status index + - Operator foreign key index + - Compound sorting + +## Future Enhancements +1. [ ] Add image upload support +2. [ ] Implement location selection +3. [ ] Add preview functionality +4. [ ] Add duplicate detection +5. [ ] Implement draft saving +6. [ ] Add bulk operations +7. [ ] Add import/export functionality +8. [ ] Add map view option +9. [ ] Implement advanced search +10. [ ] Add comparison feature +11. [ ] Add area reordering +12. [ ] Implement area statistics +13. [ ] Add drag-and-drop sorting +14. [ ] Implement nested areas + +## Integration Points +1. Operators + - Selection in form + - Statistics updates + - Relationship maintenance + +2. Slug History + - Automatic slug generation + - Historical slug tracking + - SEO-friendly URLs + +3. Park Areas + - Nested management + - Area organization + - Statistics rollup + +## Security Considerations +1. Authorization + - [ ] Implement role-based access + - [ ] Add ownership checks + - [ ] Audit logging + +2. Validation + - Input sanitization + - CSRF protection + - Rate limiting + +## Testing Strategy +1. Unit Tests + - [ ] Validation rules + - [ ] Status transitions + - [ ] Statistics updates + +2. Integration Tests + - [ ] Form submission + - [ ] Relationship updates + - [ ] Slug generation + +3. Feature Tests + - [ ] Complete CRUD flow + - [ ] Authorization rules + - [ ] Edge cases \ No newline at end of file diff --git a/memory-bank/features/SlugHistorySystem.md b/memory-bank/features/SlugHistorySystem.md new file mode 100644 index 0000000..c699932 --- /dev/null +++ b/memory-bank/features/SlugHistorySystem.md @@ -0,0 +1,100 @@ +# Slug History System + +## Overview +The slug history system provides a way to track changes to model slugs over time, ensuring that old URLs continue to work even after slugs are updated. This is particularly important for maintaining SEO value and preventing broken links. + +## Components + +### 1. HasSlugHistory Trait +Located in `app/Traits/HasSlugHistory.php` + +Key Features: +- Automatic slug generation from name field +- Tracking of slug changes +- Historical slug lookup +- Handling of duplicate slugs +- Polymorphic relationship with SlugHistory model + +### 2. SlugHistory Model +Located in `app/Models/SlugHistory.php` + +Purpose: +- Stores historical slugs for any model using HasSlugHistory trait +- Uses polymorphic relationships to link with parent models +- Maintains timestamps for tracking when slugs were used + +## Database Structure + +```sql +CREATE TABLE slug_histories ( + id bigint unsigned NOT NULL AUTO_INCREMENT, + sluggable_type varchar(255) NOT NULL, + sluggable_id bigint unsigned NOT NULL, + slug varchar(255) NOT NULL, + created_at timestamp NULL DEFAULT NULL, + updated_at timestamp NULL DEFAULT NULL, + PRIMARY KEY (id), + KEY idx_sluggable (sluggable_type, slug) +); +``` + +## Usage Example + +```php +class Operator extends Model +{ + use HasSlugHistory; + + // Model implementation +} + +// Finding by current or historical slug +$operator = Operator::findBySlug('old-slug'); +$operator = Operator::findBySlugOrFail('old-slug'); +``` + +## Current Implementation + +Models using the system: +- Operator (park operators) +- Manufacturer (ride manufacturers) +- Park (to be implemented) +- ParkArea (to be implemented) + +## Features +1. Automatic Slug Generation + - Converts name to URL-friendly format + - Handles duplicates by appending numbers + - Triggered on model creation + +2. History Tracking + - Saves old slugs when updated + - Maintains chronological order + - Links to original model via polymorphic relationship + +3. Slug Lookup + - Checks current slugs first + - Falls back to historical slugs + - Maintains efficient indexes for quick lookups + +## Benefits +1. SEO Friendly + - Maintains link equity + - Prevents 404 errors + - Supports URL structure changes + +2. User Experience + - Old bookmarks continue to work + - Prevents broken links + - Transparent to end users + +3. Performance + - Efficient database indexing + - Minimal overhead + - Cached lookups (to be implemented) + +## Future Enhancements +1. [ ] Add caching layer for frequent lookups +2. [ ] Implement automatic redirects with proper status codes +3. [ ] Add slug cleanup for old, unused slugs +4. [ ] Add analytics for tracking slug usage \ No newline at end of file diff --git a/memory-bank/features/StatisticsCaching.md b/memory-bank/features/StatisticsCaching.md new file mode 100644 index 0000000..80de49b --- /dev/null +++ b/memory-bank/features/StatisticsCaching.md @@ -0,0 +1,230 @@ +# Statistics Caching System + +## Overview +The Statistics Caching system provides efficient caching and retrieval of statistics across all levels of the theme park hierarchy. It implements a robust caching strategy with automatic invalidation, cache warming, and performance monitoring. + +## Components + +### 1. Cache Structure + +#### Key Prefixes +```php +protected const AREA_PREFIX = 'stats:area:'; +protected const PARK_PREFIX = 'stats:park:'; +protected const OPERATOR_PREFIX = 'stats:operator:'; +``` + +#### Cache TTL +```php +protected const CACHE_TTL = 86400; // 24 hours +``` + +### 2. StatisticsCacheService +Located in `app/Services/StatisticsCacheService.php` + +Purpose: +- Manage statistics caching +- Handle cache invalidation +- Provide cache warming +- Monitor cache performance + +Features: +1. Caching Operations + - Area statistics + - Park rollups + - Operator aggregates + - Batch processing + +2. Cache Management + - Automatic invalidation + - Selective updates + - Cache warming + - Error handling + +## Implementation Details + +### Area Statistics Cache +```php +[ + 'ride_distribution' => [ + 'coasters' => 5, + 'flat_rides' => 12, + 'water_rides' => 3, + ], + 'daily_capacity' => '25,000 riders/day', + 'rating' => '★★★★☆ (4.2)', + 'wait_time' => '45 minutes', + 'historical' => [ + 'total_operated' => 25, + 'retired_count' => 5, + 'last_addition' => 'Mar 2024', + ], + 'updated_at' => '2024-02-23 19:30:00', +] +``` + +### Park Statistics Cache +```php +[ + 'area_distribution' => [ + 'total' => 8, + 'operating' => 7, + 'closed' => 1, + ], + 'ride_distribution' => [ + 'coasters' => 12, + 'flat_rides' => 35, + 'water_rides' => 8, + ], + 'daily_capacity' => '75,000 riders/day', + 'rating' => '★★★★★ (4.8)', + 'wait_time' => '35 minutes', + 'historical' => [...], + 'performance' => [ + 'utilization' => '85%', + 'peak_attendance' => '65,000', + 'satisfaction' => '4.5/5.0', + ], + 'updated_at' => '2024-02-23 19:30:00', +] +``` + +### Operator Statistics Cache +```php +[ + 'park_count' => 5, + 'operating_parks' => 4, + 'closed_parks' => 1, + 'total_rides' => 275, + 'total_coasters' => 45, + 'average_rating' => 4.6, + 'total_capacity' => 350000, + 'updated_at' => '2024-02-23 19:30:00', +] +``` + +## Cache Management + +### 1. Invalidation Strategy +- Automatic invalidation on updates +- Cascading invalidation +- Selective cache clearing +- Error handling + +### 2. Cache Warming +```php +public function warmCaches(): void +{ + // Process areas in chunks + ParkArea::chunk(100, function ($areas) { + foreach ($areas as $area) { + $this->cacheAreaStatistics($area); + } + }); + + // Process parks in chunks + Park::chunk(100, function ($parks) {...}); + + // Process operators in chunks + Operator::chunk(100, function ($operators) {...}); +} +``` + +### 3. Error Handling +```php +try { + Cache::put($key, $data, static::CACHE_TTL); + Log::info("Cached statistics for {$type} {$id}"); +} catch (\Exception $e) { + Log::error("Failed to cache statistics: {$e->getMessage()}"); +} +``` + +## Performance Optimization + +### 1. Cache Design +- Efficient key structure +- Optimized data format +- Minimal cache churn +- Batch operations + +### 2. Memory Usage +- Compact data storage +- Selective caching +- TTL management +- Cache size monitoring + +### 3. Invalidation Rules +- Smart invalidation +- Dependency tracking +- Cascade control +- Version management + +## Future Enhancements +1. [ ] Add Redis support +2. [ ] Implement cache tags +3. [ ] Add cache versioning +4. [ ] Create cache analytics +5. [ ] Add cache preloading +6. [ ] Implement cache pruning +7. [ ] Add cache monitoring +8. [ ] Create cache dashboard + +## Integration Points +1. Statistics System + - Data aggregation + - Cache updates + - Performance metrics + +2. Event System + - Cache invalidation + - Update triggers + - Error handling + +3. Monitoring System + - Cache hit rates + - Performance tracking + - Error logging + +## Security Considerations +1. Data Protection + - Cache encryption + - Access control + - Data validation + +2. Error Handling + - Graceful degradation + - Fallback mechanisms + - Error logging + +## Testing Strategy +1. Unit Tests + - [ ] Cache operations + - [ ] Invalidation rules + - [ ] Error handling + +2. Integration Tests + - [ ] Cache warming + - [ ] Update propagation + - [ ] Performance tests + +3. Load Tests + - [ ] Cache hit rates + - [ ] Memory usage + - [ ] Concurrent access + +## Monitoring +1. Performance Metrics + - [ ] Cache hit rates + - [ ] Response times + - [ ] Memory usage + +2. Error Tracking + - [ ] Failed operations + - [ ] Invalid data + - [ ] System alerts + +3. Usage Analytics + - [ ] Access patterns + - [ ] Data freshness + - [ ] Cache efficiency \ No newline at end of file diff --git a/memory-bank/features/StatisticsRollup.md b/memory-bank/features/StatisticsRollup.md new file mode 100644 index 0000000..83331d8 --- /dev/null +++ b/memory-bank/features/StatisticsRollup.md @@ -0,0 +1,226 @@ +# Statistics Rollup System + +## Overview +The Statistics Rollup system provides comprehensive tracking and aggregation of statistics across different levels of the theme park hierarchy: areas, parks, and operators. It ensures data consistency and provides real-time insights through automatic updates and scheduled refreshes. + +## Components + +### 1. Database Structure + +#### Park Areas Table Statistics +```sql +ALTER TABLE park_areas ADD ( + ride_count integer DEFAULT 0, + coaster_count integer DEFAULT 0, + flat_ride_count integer DEFAULT 0, + water_ride_count integer DEFAULT 0, + daily_capacity integer NULL, + peak_wait_time integer NULL, + average_rating decimal(3,2) NULL, + total_rides_operated integer DEFAULT 0, + retired_rides_count integer DEFAULT 0, + last_new_ride_added date NULL +); +``` + +#### Parks Table Statistics +```sql +ALTER TABLE parks ADD ( + total_areas integer DEFAULT 0, + operating_areas integer DEFAULT 0, + closed_areas integer DEFAULT 0, + total_rides integer DEFAULT 0, + total_coasters integer DEFAULT 0, + total_flat_rides integer DEFAULT 0, + total_water_rides integer DEFAULT 0, + total_daily_capacity integer DEFAULT 0, + average_wait_time integer NULL, + average_rating decimal(3,2) NULL, + total_rides_operated integer DEFAULT 0, + total_rides_retired integer DEFAULT 0, + last_expansion_date date NULL, + last_major_update date NULL, + utilization_rate decimal(5,2) NULL, + peak_daily_attendance integer NULL, + guest_satisfaction decimal(3,2) NULL +); +``` + +### 2. Traits + +#### HasAreaStatistics +Located in `app/Traits/HasAreaStatistics.php` +- Ride count management +- Visitor statistics +- Historical tracking +- Formatted displays + +#### HasParkStatistics +Located in `app/Traits/HasParkStatistics.php` +- Area statistics rollup +- Ride statistics aggregation +- Performance metrics +- Historical data tracking + +### 3. StatisticsRollupService +Located in `app/Services/StatisticsRollupService.php` + +Purpose: +- Coordinate statistics updates +- Maintain data consistency +- Handle events +- Schedule refreshes + +Features: +1. Hierarchical Updates + - Bottom-up propagation + - Transaction safety + - Batch processing + - Event handling + +2. Update Types + - Area statistics + - Park rollups + - Operator aggregates + - System-wide refresh + +## Implementation Details + +### Update Flow +1. Area Update +```php +public function updateAreaStatistics(ParkArea $area): void +{ + DB::transaction(function () use ($area) { + $this->updateParkStatistics($area->park); + }); +} +``` + +2. Park Rollup +```php +public function updateParkStatistics(Park $park): void +{ + DB::transaction(function () use ($park) { + $park->updateAreaCounts(); + $park->updateRideStatistics(); + $park->updateVisitorStats(); + // Update operator if exists + }); +} +``` + +3. Operator Aggregation +```php +public function updateOperatorStatistics(Operator $operator): void +{ + DB::transaction(function () use ($operator) { + // Update park counts + // Update ride totals + // Update performance metrics + }); +} +``` + +## Event Handling + +### 1. Ride Events +- Addition tracking +- Retirement processing +- Statistics updates + +### 2. Park Events +- Expansion recording +- Major updates +- Performance tracking + +### 3. Area Events +- Opening/closing +- Status changes +- Capacity updates + +## Performance Optimization + +### 1. Database Design +- Efficient indexes +- Compound keys +- Query optimization + +### 2. Processing Strategy +- Batch updates +- Chunked processing +- Transaction management + +### 3. Caching +- [ ] Implement statistics caching +- [ ] Add cache invalidation +- [ ] Set up cache warming + +## Future Enhancements +1. [ ] Add trend analysis +2. [ ] Implement forecasting +3. [ ] Add historical graphs +4. [ ] Create export tools +5. [ ] Add benchmarking +6. [ ] Implement alerts +7. [ ] Add reporting +8. [ ] Create dashboards + +## Integration Points +1. Areas System + - Statistics collection + - Event handling + - Data validation + +2. Parks System + - Rollup processing + - Performance tracking + - Historical data + +3. Operators System + - Aggregation logic + - Performance metrics + - Trend analysis + +## Security Considerations +1. Data Validation + - Range checks + - Type validation + - Relationship verification + +2. Access Control + - Update permissions + - View restrictions + - Audit logging + +## Testing Strategy +1. Unit Tests + - [ ] Calculation accuracy + - [ ] Event handling + - [ ] Data validation + +2. Integration Tests + - [ ] Update propagation + - [ ] Transaction handling + - [ ] Event processing + +3. Performance Tests + - [ ] Large dataset handling + - [ ] Concurrent updates + - [ ] Batch processing + +## Monitoring +1. Performance Metrics + - [ ] Update timing + - [ ] Query performance + - [ ] Cache hit rates + +2. Error Tracking + - [ ] Failed updates + - [ ] Data inconsistencies + - [ ] System alerts + +3. Usage Analytics + - [ ] Update frequency + - [ ] Data access patterns + - [ ] User interactions \ No newline at end of file diff --git a/memory-bank/models/CompanyModel.md b/memory-bank/models/CompanyModel.md new file mode 100644 index 0000000..0298ff9 --- /dev/null +++ b/memory-bank/models/CompanyModel.md @@ -0,0 +1,82 @@ +# Operator Model Conversion + +## Original Django Model Structure + +### Company Model (Now Operator) +```python +class Company(TrackedModel): + name = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True) + website = models.URLField(blank=True) + headquarters = models.CharField(max_length=255, blank=True) + description = models.TextField(blank=True) + total_parks = models.IntegerField(default=0) + total_rides = models.IntegerField(default=0) +``` + +### Manufacturer Model +```python +class Manufacturer(TrackedModel): + name = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True) + website = models.URLField(blank=True) + headquarters = models.CharField(max_length=255, blank=True) + description = models.TextField(blank=True) + total_rides = models.IntegerField(default=0) + total_roller_coasters = models.IntegerField(default=0) +``` + +## Laravel Implementation Plan + +### Database Migrations + +1. Create operators table: +```php +Schema::create('operators', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('website')->nullable(); + $table->string('headquarters')->nullable(); + $table->text('description')->nullable(); + $table->integer('total_parks')->default(0); + $table->integer('total_rides')->default(0); + $table->timestamps(); +}); +``` + +2. Create manufacturers table: +```php +Schema::create('manufacturers', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('website')->nullable(); + $table->string('headquarters')->nullable(); + $table->text('description')->nullable(); + $table->integer('total_rides')->default(0); + $table->integer('total_roller_coasters')->default(0); + $table->timestamps(); +}); +``` + +### Models + +1. Operator Model: +- Implement Sluggable trait +- Add relationships (parks) +- Add statistics updating methods +- Add slug history functionality + +2. Manufacturer Model: +- Implement Sluggable trait +- Add relationships (rides) +- Add statistics updating methods +- Add slug history functionality + +### Next Steps +1. [ ] Create operators table migration +2. [ ] Create manufacturers table migration +3. [ ] Create Operator model +4. [ ] Create Manufacturer model +5. [ ] Implement statistics update methods \ No newline at end of file diff --git a/memory-bank/models/LocationModel.md b/memory-bank/models/LocationModel.md new file mode 100644 index 0000000..e9cce4d --- /dev/null +++ b/memory-bank/models/LocationModel.md @@ -0,0 +1,104 @@ +# Location Model + +## Overview +The Location model provides polymorphic location management for parks, areas, and other entities in ThrillWiki. It handles geocoding, coordinate management, and location-based search capabilities. + +## Structure + +### Database Table +- Table Name: `locations` +- Primary Key: `id` (bigint) +- Polymorphic Fields: `locatable_type`, `locatable_id` +- Timestamps: `created_at`, `updated_at` + +### Fields +- **Address Components** + - `address` (string, nullable) - Street address + - `city` (string) - City name + - `state` (string, nullable) - State/province + - `country` (string) - Country name + - `postal_code` (string, nullable) - Postal/ZIP code + +- **Coordinates** + - `latitude` (decimal, 10,8) - Latitude coordinate + - `longitude` (decimal, 11,8) - Longitude coordinate + - `elevation` (decimal, 8,2, nullable) - Elevation in meters + +- **Additional Details** + - `timezone` (string, nullable) - Location timezone + - `metadata` (json, nullable) - Additional location data + - `is_approximate` (boolean) - Indicates if location is approximate + - `source` (string, nullable) - Data source identifier + +- **Geocoding** + - `geocoding_data` (json, nullable) - Cached geocoding response + - `geocoded_at` (timestamp, nullable) - Last geocoding timestamp + +### Indexes +- Coordinates: `(latitude, longitude)` +- Location: `(country, state, city)` +- Postal: `postal_code` + +## Relationships + +### Polymorphic +- `locatable()` - Polymorphic relationship to parent model (Park, Area, etc.) + +## Accessors & Mutators +- `coordinates` - Returns [lat, lng] array +- `formatted_address` - Returns formatted address string +- `map_url` - Returns Google Maps URL + +## Methods + +### Location Management +- `updateCoordinates(float $lat, float $lng)` - Update coordinates +- `setAddress(array $components)` - Set address components +- `geocode()` - Trigger geocoding refresh +- `reverseGeocode()` - Get address from coordinates + +### Queries +- `scopeNearby($query, $lat, $lng, $radius)` - Find nearby locations +- `scopeInBounds($query, $ne, $sw)` - Find locations in bounds +- `scopeInCountry($query, $country)` - Filter by country +- `scopeInState($query, $state)` - Filter by state +- `scopeInCity($query, $city)` - Filter by city + +### Calculations +- `distanceTo($lat, $lng)` - Calculate distance to point +- `bearingTo($lat, $lng)` - Calculate bearing to point + +## Usage Examples + +```php +// Create location for park +$park->location()->create([ + 'address' => '123 Main St', + 'city' => 'Orlando', + 'state' => 'FL', + 'country' => 'USA', + 'latitude' => 28.538336, + 'longitude' => -81.379234 +]); + +// Find parks within 50km +$nearbyParks = Park::whereHas('location', function ($query) { + $query->nearby(28.538336, -81.379234, 50); +})->get(); +``` + +## Integration Points + +### Services +- GeocodeService - Address/coordinate lookup +- LocationSearchService - Advanced location search + +### Components +- LocationSelector - Map-based location picker +- LocationDisplay - Location visualization + +## Notes +- Coordinates use high precision for accuracy +- Geocoding results are cached to reduce API calls +- Polymorphic design allows reuse across models +- Search methods use spatial indexes for performance \ No newline at end of file diff --git a/memory-bank/models/ParkModel.md b/memory-bank/models/ParkModel.md new file mode 100644 index 0000000..7fc13be --- /dev/null +++ b/memory-bank/models/ParkModel.md @@ -0,0 +1,164 @@ +# Park Model Conversion + +## Original Django Model Structure + +### Park Model +```python +class Park(TrackedModel): + # Status choices + STATUS_CHOICES = [ + ("OPERATING", "Operating"), + ("CLOSED_TEMP", "Temporarily Closed"), + ("CLOSED_PERM", "Permanently Closed"), + ("UNDER_CONSTRUCTION", "Under Construction"), + ("DEMOLISHED", "Demolished"), + ("RELOCATED", "Relocated"), + ] + + # Basic info + name = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True) + description = models.TextField(blank=True) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="OPERATING") + + # Location fields (GenericRelation) + location = GenericRelation(Location) + + # Details + opening_date = models.DateField(null=True, blank=True) + closing_date = models.DateField(null=True, blank=True) + operating_season = models.CharField(max_length=255, blank=True) + size_acres = models.DecimalField(max_digits=10, decimal_places=2, null=True) + website = models.URLField(blank=True) + + # Statistics + average_rating = models.DecimalField(max_digits=3, decimal_places=2, null=True) + ride_count = models.IntegerField(null=True) + coaster_count = models.IntegerField(null=True) + + # Relationships + operator = models.ForeignKey(Operator, SET_NULL, null=True, related_name="parks") + photos = GenericRelation(Photo) +``` + +### ParkArea Model +```python +class ParkArea(TrackedModel): + park = models.ForeignKey(Park, CASCADE, related_name="areas") + name = models.CharField(max_length=255) + slug = models.SlugField(max_length=255) + description = models.TextField(blank=True) + opening_date = models.DateField(null=True, blank=True) + closing_date = models.DateField(null=True, blank=True) +``` + +## Laravel Implementation Plan + +### Enums +1. Create ParkStatus enum with status options and color methods: +```php +enum ParkStatus: string { + case OPERATING = 'OPERATING'; + case CLOSED_TEMP = 'CLOSED_TEMP'; + case CLOSED_PERM = 'CLOSED_PERM'; + case UNDER_CONSTRUCTION = 'UNDER_CONSTRUCTION'; + case DEMOLISHED = 'DEMOLISHED'; + case RELOCATED = 'RELOCATED'; +} +``` + +### Database Migrations + +1. Create parks table: +```php +Schema::create('parks', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->text('description')->nullable(); + $table->string('status', 20); + + // Details + $table->date('opening_date')->nullable(); + $table->date('closing_date')->nullable(); + $table->string('operating_season')->nullable(); + $table->decimal('size_acres', 10, 2)->nullable(); + $table->string('website')->nullable(); + + // Statistics + $table->decimal('average_rating', 3, 2)->nullable(); + $table->integer('ride_count')->nullable(); + $table->integer('coaster_count')->nullable(); + + // Foreign keys + $table->foreignId('operator_id')->nullable()->constrained('operators')->nullOnDelete(); + + $table->timestamps(); +}); +``` + +2. Create park_areas table: +```php +Schema::create('park_areas', function (Blueprint $table) { + $table->id(); + $table->foreignId('park_id')->constrained()->cascadeOnDelete(); + $table->string('name'); + $table->string('slug'); + $table->text('description')->nullable(); + $table->date('opening_date')->nullable(); + $table->date('closing_date')->nullable(); + $table->timestamps(); + + $table->unique(['park_id', 'slug']); +}); +``` + +### Models + +1. Park Model: +- Implement Sluggable trait +- Add status color methods +- Set up relationships (operator, areas, photos, location) +- Add history tracking +- Implement slug history functionality + +2. ParkArea Model: +- Implement Sluggable trait +- Set up relationship with Park +- Add history tracking +- Implement slug history functionality + +### Livewire Components + +1. ParkListComponent: +- Display parks with status badges +- Filter by status +- Sort functionality +- Search by name + +2. ParkFormComponent: +- Create/edit park details +- Location selection +- Operator selection +- Status management + +3. ParkAreaComponent: +- Manage park areas +- Add/edit/delete areas +- Sort/reorder areas + +### Features to Implement +1. Slug history tracking +2. Location management +3. Photo management +4. Statistics calculation +5. Area management +6. Park status badges with colors + +### Next Steps +1. [ ] Create ParkStatus enum +2. [ ] Create parks table migration +3. [ ] Create park_areas table migration +4. [ ] Create Park model +5. [ ] Create ParkArea model +6. [ ] Implement Livewire components \ No newline at end of file diff --git a/memory-bank/models/UserModel.md b/memory-bank/models/UserModel.md new file mode 100644 index 0000000..df26994 --- /dev/null +++ b/memory-bank/models/UserModel.md @@ -0,0 +1,122 @@ +# User Model Conversion + +## Original Django Model Structure + +### User Model (extends AbstractUser) +```python +class User(AbstractUser): + # Custom fields + user_id = models.CharField(max_length=10, unique=True, editable=False) + role = models.CharField(max_length=10, choices=['USER', 'MODERATOR', 'ADMIN', 'SUPERUSER']) + is_banned = models.BooleanField(default=False) + ban_reason = models.TextField(blank=True) + ban_date = models.DateTimeField(null=True, blank=True) + pending_email = models.EmailField(blank=True, null=True) + theme_preference = models.CharField(max_length=5, choices=['light', 'dark']) +``` + +### UserProfile Model +```python +class UserProfile: + profile_id = models.CharField(max_length=10, unique=True, editable=False) + user = models.OneToOneField(User, related_name='profile') + display_name = models.CharField(max_length=50, unique=True) + avatar = models.ImageField(upload_to='avatars/') + pronouns = models.CharField(max_length=50, blank=True) + bio = models.TextField(max_length=500, blank=True) + + # Social media + twitter = models.URLField(blank=True) + instagram = models.URLField(blank=True) + youtube = models.URLField(blank=True) + discord = models.CharField(max_length=100, blank=True) + + # Stats + coaster_credits = models.IntegerField(default=0) + dark_ride_credits = models.IntegerField(default=0) + flat_ride_credits = models.IntegerField(default=0) + water_ride_credits = models.IntegerField(default=0) +``` + +## Laravel Implementation Plan + +### Database Migrations + +1. Extend users table (`database/migrations/[timestamp]_add_user_fields.php`): +```php +Schema::table('users', function (Blueprint $table) { + $table->string('user_id', 10)->unique(); + $table->enum('role', ['USER', 'MODERATOR', 'ADMIN', 'SUPERUSER'])->default('USER'); + $table->boolean('is_banned')->default(false); + $table->text('ban_reason')->nullable(); + $table->timestamp('ban_date')->nullable(); + $table->string('pending_email')->nullable(); + $table->enum('theme_preference', ['light', 'dark'])->default('light'); +}); +``` + +2. Create profiles table (`database/migrations/[timestamp]_create_profiles_table.php`): +```php +Schema::create('profiles', function (Blueprint $table) { + $table->id(); + $table->string('profile_id', 10)->unique(); + $table->foreignId('user_id')->constrained()->onDelete('cascade'); + $table->string('display_name', 50)->unique(); + $table->string('avatar')->nullable(); + $table->string('pronouns', 50)->nullable(); + $table->text('bio')->nullable(); + + // Social media + $table->string('twitter')->nullable(); + $table->string('instagram')->nullable(); + $table->string('youtube')->nullable(); + $table->string('discord', 100)->nullable(); + + // Stats + $table->integer('coaster_credits')->default(0); + $table->integer('dark_ride_credits')->default(0); + $table->integer('flat_ride_credits')->default(0); + $table->integer('water_ride_credits')->default(0); + + $table->timestamps(); +}); +``` + +### Model Implementation + +1. User Model (`app/Models/User.php`): +- Extend Laravel's base User model +- Add custom attributes +- Add relationship to Profile +- Add role management methods +- Add ban management methods + +2. Profile Model (`app/Models/Profile.php`): +- Create new model +- Add relationship to User +- Add avatar handling methods +- Add credit management methods + +### Livewire Components +1. ProfileComponent - Handle profile management +2. AvatarUploadComponent - Handle avatar uploads +3. UserSettingsComponent - Handle user settings/preferences +4. UserBanComponent - For moderator use to handle bans + +### Services +1. UserService - Business logic for user management +2. ProfileService - Business logic for profile management +3. AvatarService - Handle avatar generation and storage + +### Next Steps +1. [ ] Create user fields migration +2. [ ] Create profiles table migration +3. [ ] Enhance User model with new fields and methods +4. [ ] Create Profile model +5. [ ] Implement initial Livewire components for profile management + +### Notes +- Will use Laravel's built-in authentication (already scaffolded) +- Email verification will be handled by Laravel's built-in features +- Password reset functionality will use Laravel's default implementation +- Will implement custom avatar generation similar to Django version \ No newline at end of file diff --git a/memory-bank/productContext.md b/memory-bank/productContext.md new file mode 100644 index 0000000..03efcad --- /dev/null +++ b/memory-bank/productContext.md @@ -0,0 +1,50 @@ +# ThrillWiki Laravel+Livewire Conversion + +## Project Overview +ThrillWiki is being converted from a Django application to a Laravel application using Livewire for dynamic frontend functionality. The original Django project contains several key modules: + +- Accounts (User management) +- Analytics +- Companies +- Core +- Designers +- Email Service +- History/History Tracking +- Location +- Media +- Moderation +- Parks +- Reviews +- Rides +- Search +- Wiki + +## Technology Stack Transition +- From: Django (Python) with server-side templates +- To: Laravel (PHP) with Livewire for reactive components + +## Core Features to Convert +1. User authentication and management +2. Park and ride management +3. Review system +4. Media handling +5. Search functionality +6. History tracking +7. Location services +8. Company management +9. Moderation tools +10. Analytics + +## Why Laravel + Livewire? +- Maintains server-side rendering approach +- Provides reactive UI components without full JavaScript framework +- Rich ecosystem for PHP development +- Simpler deployment model compared to SPA +- Built-in authentication and authorization + +## Project Goals +1. Feature parity with Django version +2. Improved performance +3. Maintainable codebase +4. Progressive enhancement +5. Mobile-friendly interface \ No newline at end of file diff --git a/memory-bank/prompts/ContinuationCommand.md b/memory-bank/prompts/ContinuationCommand.md new file mode 100644 index 0000000..7bec118 --- /dev/null +++ b/memory-bank/prompts/ContinuationCommand.md @@ -0,0 +1,50 @@ +# ThrillWiki Development Continuation Command + +Use this command to continue development in a new chat: + +``` +Continue ThrillWiki Laravel+Livewire development, focusing on the Location System implementation. The project is at /Users/talor/ThrillWiki/laravel. + +Key Memory Bank files to review: +1. memory-bank/activeContext.md - Current progress and next steps +2. memory-bank/features/LocationSystem.md - System design and implementation plan +3. memory-bank/features/StatisticsRollup.md - Statistics integration points +4. memory-bank/features/StatisticsCaching.md - Caching strategy + +Current progress: +- Documented Location System design +- Created locations table migration +- Set up Memory Bank documentation + +Next steps: +1. Create Location model with polymorphic relationships +2. Implement HasLocation trait +3. Develop GeocodeService +4. Build LocationSearchService +5. Create Livewire components + +Follow Memory Bank documentation practices: +- Prefix all tool use with [MEMORY BANK: ACTIVE] +- Document before implementing +- Update activeContext.md after each step +- Create feature documentation first +- Document technical decisions + +The project uses: +- Laravel for backend +- Livewire for components +- MySQL for database +- Memory Bank for documentation + +Continue implementation following the established patterns and maintaining comprehensive documentation. +``` + +This command provides: +1. Project context +2. Memory Bank locations +3. Current progress +4. Next steps +5. Development practices +6. Technical stack + +Use this to ensure continuity and maintain our documentation-first approach in the next development session. \ No newline at end of file diff --git a/memory-bank/prompts/LocationSystemContinuation.md b/memory-bank/prompts/LocationSystemContinuation.md new file mode 100644 index 0000000..cc3a839 --- /dev/null +++ b/memory-bank/prompts/LocationSystemContinuation.md @@ -0,0 +1,69 @@ +# ThrillWiki Development Continuation Prompt + +Continue the development of ThrillWiki's Location System implementation. The project is a Laravel+Livewire application for managing theme parks, currently being converted from Django. + +## Current Progress + +We have: +1. Documented the Location System design in `memory-bank/features/LocationSystem.md` +2. Created the locations table migration in `database/migrations/2024_02_23_235000_create_locations_table.php` + +## Next Implementation Steps + +1. Create the Location model with: + - Polymorphic relationships + - Coordinate handling + - Geocoding integration + - Distance calculations + +2. Implement the HasLocation trait for: + - Location relationships + - Coordinate accessors + - Distance methods + - Map integration + +3. Create the GeocodeService for: + - Address lookup + - Coordinate validation + - Batch processing + - Cache management + +4. Implement the LocationSearchService for: + - Distance-based search + - Boundary queries + - Clustering support + - Performance optimization + +5. Create Livewire components for: + - Location selection + - Map integration + - Address search + - Coordinate picking + +## Project Structure + +Key files and directories: +- `memory-bank/features/LocationSystem.md` - System documentation +- `app/Models/` - Model implementations +- `app/Traits/` - Shared traits +- `app/Services/` - Service classes +- `app/Livewire/` - Livewire components +- `resources/views/livewire/` - Component views + +## Development Context + +The system uses: +- Laravel for backend +- Livewire for components +- MySQL for database +- Memory Bank for documentation + +## Next Steps + +1. Create the Location model +2. Implement HasLocation trait +3. Develop geocoding service +4. Build search functionality +5. Create Livewire components + +Please continue implementing these features following the established patterns and maintaining comprehensive documentation in the Memory Bank. \ No newline at end of file diff --git a/memory-bank/prompts/MemoryBankInstructions.md b/memory-bank/prompts/MemoryBankInstructions.md new file mode 100644 index 0000000..6d01083 --- /dev/null +++ b/memory-bank/prompts/MemoryBankInstructions.md @@ -0,0 +1,68 @@ +# Memory Bank Access Instructions + +To continue development with full context, please review these key Memory Bank files in order: + +1. `memory-bank/activeContext.md` + - Current development phase + - Progress tracking + - Next steps + - Technical decisions + - Issues to address + +2. `memory-bank/features/LocationSystem.md` + - System design + - Component structure + - Implementation details + - Integration points + - Future enhancements + +3. `memory-bank/features/StatisticsRollup.md` + - Statistics system design + - Integration points + - Performance considerations + +4. `memory-bank/features/StatisticsCaching.md` + - Caching strategy + - Performance optimization + - Integration points + +## Development Process + +1. Always prefix tool use with `[MEMORY BANK: ACTIVE]` +2. Document changes in Memory Bank before implementation +3. Update `activeContext.md` after each major step +4. Create feature documentation before implementation +5. Document technical decisions and their rationale + +## Next Development Session + +1. Review `memory-bank/prompts/LocationSystemContinuation.md` +2. Check `activeContext.md` for current status +3. Implement next steps following documented design +4. Maintain Memory Bank documentation +5. Update progress tracking + +## Key Files + +The following files contain essential context: + +``` +memory-bank/ +├── activeContext.md # Current state and next steps +├── features/ +│ ├── LocationSystem.md # Location system design +│ ├── StatisticsRollup.md # Statistics system design +│ └── StatisticsCaching.md # Caching implementation +└── prompts/ + └── LocationSystemContinuation.md # Next steps +``` + +## Development Command + +To continue development in a new chat, use: + +``` +Continue ThrillWiki Laravel+Livewire development, implementing the Location System as documented in memory-bank/features/LocationSystem.md. Current progress and next steps are in memory-bank/prompts/LocationSystemContinuation.md. Follow Memory Bank documentation practices from memory-bank/prompts/MemoryBankInstructions.md. +``` + +This will ensure continuity and maintain our documentation-first approach. \ No newline at end of file diff --git a/resources/views/livewire/area-statistics-component.blade.php b/resources/views/livewire/area-statistics-component.blade.php new file mode 100644 index 0000000..28e26d6 --- /dev/null +++ b/resources/views/livewire/area-statistics-component.blade.php @@ -0,0 +1,108 @@ +
+ +
+
+

Area Statistics

+
+ + +
+
+ + +
+
+ Total Rides +

{{ $area->ride_count }}

+
+
+ Coasters +

{{ $area->coaster_count }}

+
+
+ Rating +

{{ $area->rating_display }}

+
+
+ Daily Capacity +

{{ $area->formatted_daily_capacity }}

+
+
+ + + @if($showDetails) +
+

Ride Distribution

+ + +
+
+ Coasters +
+
+
+ {{ $ridePercentages['coasters'] }}% +
+
+ Flat Rides +
+
+
+ {{ $ridePercentages['flat_rides'] }}% +
+
+ Water Rides +
+
+
+ {{ $ridePercentages['water_rides'] }}% +
+
+ + +
+
+ Peak Wait Time +

{{ $area->formatted_peak_wait_time }}

+
+
+ Operating Status +

+ {{ $area->isOperating() ? 'Operating' : 'Closed' }} +

+
+
+
+ @endif + + + @if($showHistorical) +
+

Historical Data

+ +
+
+ Total Rides Operated +

{{ $historicalStats['total_operated'] }}

+
+
+ Retired Rides +

{{ $historicalStats['retired_count'] }}

+
+
+ Last New Ride +

{{ $historicalStats['last_addition'] }}

+
+
+ Retirement Rate +

{{ $historicalStats['retirement_rate'] }}%

+
+
+
+ @endif +
+
\ No newline at end of file diff --git a/resources/views/livewire/park-area-form-component.blade.php b/resources/views/livewire/park-area-form-component.blade.php new file mode 100644 index 0000000..9aaa5a9 --- /dev/null +++ b/resources/views/livewire/park-area-form-component.blade.php @@ -0,0 +1,82 @@ +
+
+ @if (session()->has('message')) + + @endif + +
+ +
+
+

Area Information

+ Part of {{ $park->name }} +
+ +
+ +
+ +
+ @error('name') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('description') {{ $message }} @enderror +
+
+
+ +
+ +
+

Opening and Closing Dates

+ +
+
+ +
+ +
+ @error('opening_date') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('closing_date') {{ $message }} @enderror +
+
+ +
+

Leave closing date empty if the area is still operating.

+
+
+
+ +
+ + Cancel + + + +
+
+
\ No newline at end of file diff --git a/resources/views/livewire/park-area-list-component.blade.php b/resources/views/livewire/park-area-list-component.blade.php new file mode 100644 index 0000000..367629b --- /dev/null +++ b/resources/views/livewire/park-area-list-component.blade.php @@ -0,0 +1,109 @@ +
+ +
+

Areas in {{ $park->name }}

+ + + + + Add Area + +
+ + +
+
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
+
+ + +
+ @if($areas->isEmpty()) +
+

No areas found

+

Try adjusting your search or add a new area.

+
+ @else +
    + @foreach($areas as $area) +
  • +
    +
    +

    + + {{ $area->name }} + +

    + @if($area->description) +

    {{ $area->brief_description }}

    + @endif +
    + @if($area->opening_date) +
    + + + + Opened: {{ $area->opening_date->format('M Y') }} +
    + @endif + @if($area->closing_date) +
    + Closed: {{ $area->closing_date->format('M Y') }} +
    + @endif +
    +
    +
    + + Edit + + +
    +
    +
  • + @endforeach +
+ @endif +
+ + +
+ {{ $areas->links() }} +
+
\ No newline at end of file diff --git a/resources/views/livewire/park-area-reorder-component.blade.php b/resources/views/livewire/park-area-reorder-component.blade.php new file mode 100644 index 0000000..aa3ac13 --- /dev/null +++ b/resources/views/livewire/park-area-reorder-component.blade.php @@ -0,0 +1,127 @@ +
+ +
+
+

+ @if($parentArea) + Areas in {{ $parentArea->name }} + + (Back to Top Level) + + @else + Areas in {{ $park->name }} + @endif +

+

Drag and drop to reorder areas or move them between levels.

+
+
+ + +
+ @if(empty($areas)) +
+

No areas found

+

+ @if($parentArea) + This area doesn't have any sub-areas yet. + @else + This park doesn't have any areas yet. + @endif +

+
+ @else +
    + @foreach($areas as $area) +
  • +
    +
    + +
    + + + +
    + + +
    +

    + {{ $area['name'] }} + @if($area['is_closed']) + + Closed + + @endif +

    +
    +
    + +
    + + @if($area['has_children']) + + + + + Sub-Areas + + @endif + + +
    + + + +
    +
    +
    +
  • + @endforeach +
+ @endif +
+
+ +@push('scripts') + + +@endpush \ No newline at end of file diff --git a/resources/views/livewire/park-form-component.blade.php b/resources/views/livewire/park-form-component.blade.php new file mode 100644 index 0000000..9d38cbd --- /dev/null +++ b/resources/views/livewire/park-form-component.blade.php @@ -0,0 +1,143 @@ +
+
+ @if (session()->has('message')) + + @endif + +
+ +
+

Basic Information

+ +
+
+ +
+ +
+ @error('name') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('operator_id') {{ $message }} @enderror +
+
+ +
+ +
+ +
+ @error('description') {{ $message }} @enderror +
+
+
+ +
+ +
+

Status and Dates

+ +
+
+ +
+ +
+ @error('status') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('opening_date') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('closing_date') {{ $message }} @enderror +
+
+
+
+ +
+ +
+

Additional Details

+ +
+
+ +
+ +
+ @error('operating_season') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('size_acres') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('website') {{ $message }} @enderror +
+
+
+
+ +
+ + Cancel + + + +
+
+
\ No newline at end of file diff --git a/resources/views/livewire/park-list-component.blade.php b/resources/views/livewire/park-list-component.blade.php new file mode 100644 index 0000000..2f82fdc --- /dev/null +++ b/resources/views/livewire/park-list-component.blade.php @@ -0,0 +1,136 @@ +
+ +
+
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+ +
+
+
+
+ + +
+ @forelse($parks as $park) +
+
+
+

+ + {{ $park->name }} + +

+ + {{ $park->status->label() }} + +
+ +
+ {{ $park->brief_description }} +
+ +
+
+ Operator: + {{ $park->operator?->name ?? 'Unknown' }} +
+ @if($park->opening_year) +
+ Opened: + {{ $park->opening_year }} +
+ @endif + @if($park->size_acres) +
+ Size: + {{ $park->size_display }} +
+ @endif +
+ Rides: + {{ $park->ride_count ?? 0 }} +
+
+ +
+ @if($park->website) + + Visit Website + + @endif + + Edit + +
+
+
+ @empty +
+

No parks found

+

Try adjusting your filters or search terms.

+
+ @endforelse +
+ + +
+ {{ $parks->links() }} +
+ + + +
\ No newline at end of file diff --git a/resources/views/livewire/profile-component.blade.php b/resources/views/livewire/profile-component.blade.php new file mode 100644 index 0000000..856d144 --- /dev/null +++ b/resources/views/livewire/profile-component.blade.php @@ -0,0 +1,120 @@ +
+
+ @if (session()->has('message')) + + @endif + +
+
+
+ Profile photo +
+
+ + @error('avatar') {{ $message }} @enderror + + @if($profile->avatar) + + @endif +
+
+
+ +
+ +
+ +
+ @error('display_name') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('pronouns') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('bio') {{ $message }} @enderror +
+ +
+

Social Media

+ +
+ +
+ +
+ @error('twitter') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('instagram') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('youtube') {{ $message }} @enderror +
+ +
+ +
+ +
+ @error('discord') {{ $message }} @enderror +
+
+ +
+ + +
+ Saving... +
+
+
+
\ No newline at end of file