diff --git a/app/Console/Commands/Database/UpdateMeetupActivity.php b/app/Console/Commands/Database/UpdateMeetupActivity.php new file mode 100644 index 0000000..5723e57 --- /dev/null +++ b/app/Console/Commands/Database/UpdateMeetupActivity.php @@ -0,0 +1,64 @@ +subYear(); + + Meetup::query()->chunkById(200, function ($meetups) use ($threshold) { + foreach ($meetups as $meetup) { + $this->updateMeetup($meetup, $threshold); + } + }); + + $this->info('Meetup activity flags updated.'); + + return Command::SUCCESS; + } + + private function updateMeetup(Meetup $meetup, CarbonInterface $threshold): void + { + $lastEventAt = MeetupEvent::query() + ->where('meetup_id', $meetup->id) + ->where('start', '<=', now()) + ->max('start'); + + $lastEventAt = $lastEventAt ? Date::parse($lastEventAt) : null; + + $hasFutureEvent = MeetupEvent::query() + ->where('meetup_id', $meetup->id) + ->where('start', '>', now()) + ->exists(); + + $hasActiveRecurrence = MeetupEvent::query() + ->where('meetup_id', $meetup->id) + ->whereNotNull('recurrence_type') + ->where(function ($query) { + $query->whereNull('recurrence_end_date') + ->orWhere('recurrence_end_date', '>=', now()); + }) + ->exists(); + + $isActive = ($lastEventAt && $lastEventAt->greaterThanOrEqualTo($threshold)) + || $hasFutureEvent + || $hasActiveRecurrence; + + $meetup->forceFill([ + 'is_active' => $isActive, + 'last_event_at' => $lastEventAt, + ])->saveQuietly(); + } +} diff --git a/app/Models/Meetup.php b/app/Models/Meetup.php index 9fec58c..00dbbd2 100644 --- a/app/Models/Meetup.php +++ b/app/Models/Meetup.php @@ -41,6 +41,8 @@ class Meetup extends Model implements HasMedia 'community', 'github_data', 'visible_on_map', + 'is_active', + 'last_event_at', ]; /** @@ -53,6 +55,8 @@ class Meetup extends Model implements HasMedia 'city_id' => 'integer', 'github_data' => 'json', 'simplified_geojson' => 'array', + 'is_active' => 'boolean', + 'last_event_at' => 'datetime', ]; protected static function booted() diff --git a/database/factories/MeetupFactory.php b/database/factories/MeetupFactory.php index b07f682..ad8c9a5 100644 --- a/database/factories/MeetupFactory.php +++ b/database/factories/MeetupFactory.php @@ -38,6 +38,24 @@ class MeetupFactory extends Factory 'nostr' => NostrHelper::randomNpub(), 'nostr_status' => NostrHelper::fakeNostrEventStatus(), 'created_by' => User::factory(), + 'is_active' => false, + 'last_event_at' => null, ]; } + + public function active(): static + { + return $this->state(fn (array $attrs) => [ + 'is_active' => true, + 'last_event_at' => now()->subDays(30), + ]); + } + + public function inactive(): static + { + return $this->state(fn (array $attrs) => [ + 'is_active' => false, + 'last_event_at' => now()->subYears(2), + ]); + } } diff --git a/database/migrations/2026_05_17_153816_add_activity_fields_to_meetups_table.php b/database/migrations/2026_05_17_153816_add_activity_fields_to_meetups_table.php new file mode 100644 index 0000000..8575c89 --- /dev/null +++ b/database/migrations/2026_05_17_153816_add_activity_fields_to_meetups_table.php @@ -0,0 +1,23 @@ +boolean('is_active')->default(false)->after('visible_on_map'); + $table->timestamp('last_event_at')->nullable()->after('is_active'); + }); + } + + public function down(): void + { + Schema::table('meetups', function (Blueprint $table) { + $table->dropColumn(['is_active', 'last_event_at']); + }); + } +}; diff --git a/resources/views/components/meetup-popup.blade.php b/resources/views/components/meetup-popup.blade.php index d83eb29..6a6cd69 100644 --- a/resources/views/components/meetup-popup.blade.php +++ b/resources/views/components/meetup-popup.blade.php @@ -1,7 +1,20 @@ @props(['meetup', 'url', 'eventUrl' => null])
- {{ $meetup->name }} +
+ {{ $meetup->name }} + @if($meetup->is_active) + {{ __('Aktiv') }} + @else + {{ __('Inaktiv') }} + @endif +
+ + @if($meetup->last_event_at) + + {{ __('Letztes Event') }}: {{ $meetup->last_event_at->asDate() }} + + @endif @if($meetup->intro) diff --git a/resources/views/livewire/cities/create.blade.php b/resources/views/livewire/cities/create.blade.php index d9cffa3..0d12c52 100644 --- a/resources/views/livewire/cities/create.blade.php +++ b/resources/views/livewire/cities/create.blade.php @@ -36,8 +36,17 @@ class extends Component { 'longitude' => ['required', 'numeric', 'between:-180,180'], 'population' => ['nullable', 'integer', 'min:0'], 'population_date' => ['nullable', 'string', 'max:255'], + ], [], [ + 'latitude' => __('Breitengrad'), + 'longitude' => __('Längengrad'), ]); + if ((float) $validated['latitude'] === 0.0 && (float) $validated['longitude'] === 0.0) { + $this->addError('latitude', __('Breiten- und Längengrad dürfen nicht beide 0 sein.')); + + return; + } + $validated['slug'] = str($validated['name'])->slug(); $validated['created_by'] = auth()->id(); diff --git a/resources/views/livewire/cities/edit.blade.php b/resources/views/livewire/cities/edit.blade.php index 4319296..b612223 100644 --- a/resources/views/livewire/cities/edit.blade.php +++ b/resources/views/livewire/cities/edit.blade.php @@ -39,8 +39,17 @@ class extends Component { 'longitude' => ['required', 'numeric', 'between:-180,180'], 'population' => ['nullable', 'integer', 'min:0'], 'population_date' => ['nullable', 'string', 'max:255'], + ], [], [ + 'latitude' => __('Breitengrad'), + 'longitude' => __('Längengrad'), ]); + if ((float) $validated['latitude'] === 0.0 && (float) $validated['longitude'] === 0.0) { + $this->addError('latitude', __('Breiten- und Längengrad dürfen nicht beide 0 sein.')); + + return; + } + $validated['slug'] = str($validated['name'])->slug(); $this->city->update($validated); diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index 0ddc3f4..0a5f0ff 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -152,11 +152,12 @@ class extends Component {
@foreach($myMeetups as $meetup) -
+
@@ -167,6 +168,9 @@ class extends Component { src="{{ asset('vendor/blade-flags/country-'.strtolower($meetup->city->country->code).'.svg') }}" width="24" height="12" /> + @unless($meetup->is_active) + {{ __('Inaktiv') }} + @endunless
{{ $meetup->city->name }} diff --git a/resources/views/livewire/dashboard/activities.blade.php b/resources/views/livewire/dashboard/activities.blade.php index d9db829..0789ed5 100644 --- a/resources/views/livewire/dashboard/activities.blade.php +++ b/resources/views/livewire/dashboard/activities.blade.php @@ -64,14 +64,18 @@ class extends Component { @if($activity['type'] === 'meetup') @php $meetup = $activity['data']; @endphp + class="block p-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-colors {{ $meetup->is_active ? '' : 'opacity-60' }}">
{{ __('Neues Meetup') }} + @unless($meetup->is_active) + {{ __('Inaktiv') }} + @endunless {{ strtolower($meetup->city->country->code) }} @foreach($topMeetups as $meetup) + class="flex items-center justify-between gap-3 p-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-lg transition-colors block {{ $meetup->is_active ? '' : 'opacity-60' }}">
-
{{ $meetup->name }}
+
+ {{ $meetup->name }} + @unless($meetup->is_active) + {{ __('Inaktiv') }} + @endunless +
{{ $meetup->users_count }} {{ __('User') }}
validate([ 'newCityName' => ['required', 'string', 'max:255', 'unique:cities,name'], 'newCityCountryId' => ['required', 'exists:countries,id'], - 'newCityLatitude' => ['required', 'numeric'], - 'newCityLongitude' => ['required', 'numeric'], + 'newCityLatitude' => ['required', 'numeric', 'between:-90,90'], + 'newCityLongitude' => ['required', 'numeric', 'between:-180,180'], + ], [], [ + 'newCityLatitude' => __('Breitengrad'), + 'newCityLongitude' => __('Längengrad'), ]); + if ((float) $validated['newCityLatitude'] === 0.0 && (float) $validated['newCityLongitude'] === 0.0) { + $this->addError('newCityLatitude', __('Breiten- und Längengrad dürfen nicht beide 0 sein.')); + + return; + } + $city = City::create([ 'name' => $validated['newCityName'], 'country_id' => $validated['newCityCountryId'], diff --git a/resources/views/livewire/meetups/index.blade.php b/resources/views/livewire/meetups/index.blade.php index b6faa9d..401d567 100644 --- a/resources/views/livewire/meetups/index.blade.php +++ b/resources/views/livewire/meetups/index.blade.php @@ -73,6 +73,7 @@ class extends Component { {{ __('Name') }} + {{ __('Aktivität') }} {{ __('Nächster Termin') }} {{ __('Links') }} {{ __('Aktionen') }} @@ -80,10 +81,10 @@ class extends Component { @foreach ($meetups as $meetup) - +
@@ -102,6 +103,23 @@ class extends Component {
+ +
+ @if($meetup->is_active) + {{ __('Aktiv') }} + @else + {{ __('Inaktiv') }} + @endif + @if($meetup->last_event_at) + + {{ __('Letztes Event') }}: {{ $meetup->last_event_at->asDate() }} + + @else + {{ __('Noch kein Event') }} + @endif +
+
+ @if($meetup->nextEvent && $meetup->nextEvent['start']->isFuture())
diff --git a/resources/views/livewire/meetups/map.blade.php b/resources/views/livewire/meetups/map.blade.php index 502f4ed..5c042e3 100644 --- a/resources/views/livewire/meetups/map.blade.php +++ b/resources/views/livewire/meetups/map.blade.php @@ -49,6 +49,8 @@ class extends Component { 'meetups.nostr', 'meetups.simplex', 'meetups.signal', + 'meetups.is_active', + 'meetups.last_event_at', ]) ->with(['city:id,country_id,longitude,latitude', 'city.country']) ->when( @@ -81,6 +83,7 @@ class extends Component { 'name' => $meetup->name, 'slug' => $meetup->slug, 'city' => $meetup->city, + 'is_active' => (bool) $meetup->is_active, 'popupHtml' => view('components.meetup-popup', [ 'meetup' => $meetup, 'url' => route('meetups.landingpage', [ @@ -105,6 +108,18 @@ class extends Component { #map:focus { outline: none; } + + .meetup-marker-inactive { + background: transparent; + border: none; + } + + .meetup-marker-inactive img { + width: 100%; + height: 100%; + filter: grayscale(100%); + opacity: 0.55; + } @php $attribution = '© OpenStreetMap contributors'; @@ -127,7 +142,7 @@ class extends Component { attribution: '{{ $attribution }}' }).addTo(map); - // Custom BTC icon + // Custom BTC icon (active meetups) const btcIcon = L.icon({ iconUrl: '/img/btc_marker.png', iconSize: [32, 32], // Full size of the image @@ -136,9 +151,18 @@ class extends Component { shadowUrl: null // No shadow for simplicity }); + // Inactive meetups: smaller, grayscaled, semi-transparent + const btcIconInactive = L.divIcon({ + className: 'meetup-marker-inactive', + html: ``, + iconSize: [20, 20], + iconAnchor: [10, 20], + popupAnchor: [0, -20], + }); + this.markers.forEach(marker => { L.marker([marker.city.latitude, marker.city.longitude], { - icon: btcIcon + icon: marker.is_active ? btcIcon : btcIconInactive }) .bindPopup(marker.popupHtml) .addTo(map); diff --git a/routes/console.php b/routes/console.php index 50e7617..c3e7d5b 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,6 +1,7 @@ everyFifteenMinutes(); @@ -12,3 +13,5 @@ Schedule::command(PublishUnpublishedItems::class, [ Schedule::command(PublishUnpublishedItems::class, [ '--model' => 'Meetup', ])->dailyAt('18:00'); + +Schedule::command(UpdateMeetupActivity::class)->dailyAt('03:30'); diff --git a/tests/Feature/Cities/CityCrudTest.php b/tests/Feature/Cities/CityCrudTest.php index c45f245..7e49fd6 100644 --- a/tests/Feature/Cities/CityCrudTest.php +++ b/tests/Feature/Cities/CityCrudTest.php @@ -86,6 +86,40 @@ it('rejects city creation with non-existent country', function () { ->assertHasErrors(['country_id' => 'exists']); }); +it('rejects city creation when latitude and longitude are both zero', function () { + actingAsUser(); + + Livewire::test('cities.create') + ->set('name', 'Null Island') + ->set('country_id', $this->country->id) + ->set('latitude', 0) + ->set('longitude', 0) + ->call('createCity') + ->assertHasErrors(['latitude']); + + expect(City::query()->where('name', 'Null Island')->exists())->toBeFalse(); +}); + +it('rejects city update when latitude and longitude are both zero', function () { + $city = City::factory()->create([ + 'name' => 'Berlin Test', + 'country_id' => $this->country->id, + 'latitude' => 52.52, + 'longitude' => 13.405, + ]); + actingAsUser(); + + Livewire::test('cities.edit', ['city' => $city]) + ->set('name', 'Berlin Test') + ->set('country_id', $this->country->id) + ->set('latitude', 0) + ->set('longitude', 0) + ->call('updateCity') + ->assertHasErrors(['latitude']); + + expect($city->refresh()->latitude)->toEqual(52.52); +}); + it('updates an existing city', function () { $city = City::factory()->create(['name' => 'Old Name', 'country_id' => $this->country->id]); actingAsUser(); diff --git a/tests/Feature/Database/UpdateMeetupActivityTest.php b/tests/Feature/Database/UpdateMeetupActivityTest.php new file mode 100644 index 0000000..752bfa4 --- /dev/null +++ b/tests/Feature/Database/UpdateMeetupActivityTest.php @@ -0,0 +1,90 @@ +create(['is_active' => false, 'last_event_at' => null]); + MeetupEvent::factory()->create([ + 'meetup_id' => $meetup->id, + 'start' => now()->subMonths(3), + 'recurrence_type' => null, + ]); + + $this->artisan('meetups:update-activity')->assertSuccessful(); + + $meetup->refresh(); + expect($meetup->is_active)->toBeTrue() + ->and($meetup->last_event_at)->not->toBeNull(); +}); + +it('marks a meetup as inactive when the last event is older than a year and no future event exists', function () { + $meetup = Meetup::factory()->create(['is_active' => true, 'last_event_at' => now()]); + MeetupEvent::factory()->create([ + 'meetup_id' => $meetup->id, + 'start' => now()->subYears(2), + 'recurrence_type' => null, + ]); + + $this->artisan('meetups:update-activity')->assertSuccessful(); + + $meetup->refresh(); + expect($meetup->is_active)->toBeFalse() + ->and($meetup->last_event_at)->not->toBeNull(); +}); + +it('keeps a meetup active when it only has a future event', function () { + $meetup = Meetup::factory()->create(['is_active' => false, 'last_event_at' => null]); + MeetupEvent::factory()->create([ + 'meetup_id' => $meetup->id, + 'start' => now()->addDays(14), + 'recurrence_type' => null, + ]); + + $this->artisan('meetups:update-activity')->assertSuccessful(); + + $meetup->refresh(); + expect($meetup->is_active)->toBeTrue() + ->and($meetup->last_event_at)->toBeNull(); +}); + +it('keeps a meetup active when a recurring event has no end date', function () { + $meetup = Meetup::factory()->create(['is_active' => false, 'last_event_at' => null]); + MeetupEvent::factory()->create([ + 'meetup_id' => $meetup->id, + 'start' => now()->subYears(3), + 'recurrence_type' => RecurrenceType::Monthly, + 'recurrence_end_date' => null, + ]); + + $this->artisan('meetups:update-activity')->assertSuccessful(); + + $meetup->refresh(); + expect($meetup->is_active)->toBeTrue(); +}); + +it('marks a meetup as inactive when no events exist at all', function () { + $meetup = Meetup::factory()->create(['is_active' => true, 'last_event_at' => now()]); + + $this->artisan('meetups:update-activity')->assertSuccessful(); + + $meetup->refresh(); + expect($meetup->is_active)->toBeFalse() + ->and($meetup->last_event_at)->toBeNull(); +}); + +it('marks a meetup as inactive when a recurring event has ended more than a year ago', function () { + $meetup = Meetup::factory()->create(['is_active' => true, 'last_event_at' => now()]); + MeetupEvent::factory()->create([ + 'meetup_id' => $meetup->id, + 'start' => now()->subYears(3), + 'recurrence_type' => RecurrenceType::Monthly, + 'recurrence_end_date' => now()->subYears(2), + ]); + + $this->artisan('meetups:update-activity')->assertSuccessful(); + + $meetup->refresh(); + expect($meetup->is_active)->toBeFalse(); +});