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])
`,
+ 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();
+});