From 44da2855a6f048c850391654f80df82b24b03cb9 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 11:28:13 +0200 Subject: [PATCH 01/37] fix: Activity::eventType is a belongsTo, not a hasOne Claude --- app/Models/Activity.php | 7 +++---- tests/Integration/Activity/CreateActivityTest.php | 10 ++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/Models/Activity.php b/app/Models/Activity.php index 9c64d85..2b4f717 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; -use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Support\Collection; /** @@ -85,10 +84,10 @@ public function entrySuggestion(): BelongsTo } /** - * @return HasOne + * @return BelongsTo */ - public function eventType(): HasOne + public function eventType(): BelongsTo { - return $this->hasOne(EventType::class); + return $this->belongsTo(EventType::class); } } diff --git a/tests/Integration/Activity/CreateActivityTest.php b/tests/Integration/Activity/CreateActivityTest.php index b563783..9623784 100644 --- a/tests/Integration/Activity/CreateActivityTest.php +++ b/tests/Integration/Activity/CreateActivityTest.php @@ -270,3 +270,13 @@ expect(Activity::query()->count())->toEqual(2); }); + +it('loads the event type of an activity', function () { + Illuminate\Support\Facades\Event::fake(); + $eventType = EventType::firstOrCreate(['id' => 'ticket_saved'], ['weight' => 1]); + + $activity = Activity::factory()->create(['event_type_id' => $eventType->id]); + + expect($activity->eventType)->toBeInstanceOf(EventType::class) + ->and($activity->eventType->id)->toBe('ticket_saved'); +}); From aa624e2b9103451d1b6282311770f01c16d979b4 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Fri, 17 Jul 2026 09:21:26 +0200 Subject: [PATCH 02/37] fix: absorb fully covered activities instead of saving negative durations --- app/Listeners/CreateActivity.php | 46 +++++++++++++------ .../Activity/CreateActivityTest.php | 34 ++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/app/Listeners/CreateActivity.php b/app/Listeners/CreateActivity.php index 736aea1..8097d6c 100644 --- a/app/Listeners/CreateActivity.php +++ b/app/Listeners/CreateActivity.php @@ -98,21 +98,36 @@ private function createActivityFromEvent(Event $event): ?Activity $activity->is_internal = $event->is_internal; $activity->event_type_id = $event->eventType->id ?? null; + $absorbedActivities = collect(); if (config('timatic.feature.activity_overlap_detection')) { - $activity = $this->handleOverlappingActivities($activity); + $absorbedActivities = $this->trimOverlappingActivities($activity); + + if (! $activity->ended_at->isAfter($activity->started_at)) { + return null; + } } - if ($activity) { - $this->db->transaction(function () use ($activity, $event) { - $activity->save(); - $activity->events()->save($event); + $this->db->transaction(function () use ($activity, $event, $absorbedActivities) { + $activity->save(); + $activity->events()->save($event); + + $absorbedActivities->each(function (Activity $absorbedActivity) use ($activity) { + $absorbedActivity->events()->update(['activity_id' => $activity->id]); + $absorbedActivity->delete(); }); - } + }); return $activity; } - private function handleOverlappingActivities(Activity $activity): ?Activity + /** + * Trims activities overlapping the new activity's period. An existing activity whose + * trimmed period would collapse is returned for absorption: the new activity takes + * over its events and the empty activity is deleted. + * + * @return Collection + */ + private function trimOverlappingActivities(Activity $activity): Collection { /** @var Collection|Activity[] $overlappingActivities */ $overlappingActivities = Activity::query() @@ -137,7 +152,9 @@ private function handleOverlappingActivities(Activity $activity): ?Activity }) ->get(); - $overlappingActivities->each(function ($overlappingActivity) use ($activity) { + $absorbedActivities = collect(); + + $overlappingActivities->each(function ($overlappingActivity) use ($activity, $absorbedActivities) { /** @var Activity $overlappingActivity */ if (! is_null($overlappingActivity->eventType) && $overlappingActivity->eventType->weight >= (int) $activity->eventType?->weight) { @@ -150,14 +167,15 @@ private function handleOverlappingActivities(Activity $activity): ?Activity } else { $overlappingActivity->started_at = $activity->ended_at; } - $overlappingActivity->save(); + + if ($overlappingActivity->ended_at->isAfter($overlappingActivity->started_at)) { + $overlappingActivity->save(); + } else { + $absorbedActivities->push($overlappingActivity); + } } }); - if ($activity->ended_at->isAfter($activity->started_at)) { - return $activity; - } else { - return null; - } + return $absorbedActivities; } } diff --git a/tests/Integration/Activity/CreateActivityTest.php b/tests/Integration/Activity/CreateActivityTest.php index 9623784..c51bfa9 100644 --- a/tests/Integration/Activity/CreateActivityTest.php +++ b/tests/Integration/Activity/CreateActivityTest.php @@ -271,6 +271,40 @@ expect(Activity::query()->count())->toEqual(2); }); +test('a fully covered lower-weight activity is absorbed instead of getting a negative duration', function () { + config()->set('timatic.feature.activity_overlap_detection', true); + Illuminate\Support\Facades\Event::fake(); + + $eventTypeLight = EventType::factory()->state(['weight' => 1])->create(); + $eventTypeHeavy = EventType::factory()->state(['weight' => 999])->create(); + $user = User::factory()->create(); + + /** @var Event $coveredEvent */ + $coveredEvent = Event::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => $eventTypeLight->id, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), + ]); + + /** @var CreateActivity $listener */ + $listener = app(CreateActivity::class); + $listener->handle(new EventCreated($coveredEvent)); + + /** @var Event $coveringEvent */ + $coveringEvent = Event::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => $eventTypeHeavy->id, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 15, second: 0), + ]); + $listener->handle(new EventCreated($coveringEvent)); + + expect(Activity::count())->toBe(1) + ->and(Activity::whereColumn('started_at', '>=', 'ended_at')->count())->toBe(0) + ->and($coveredEvent->fresh()->activity_id)->toBe($coveringEvent->fresh()->activity_id); +}); + it('loads the event type of an activity', function () { Illuminate\Support\Facades\Event::fake(); $eventType = EventType::firstOrCreate(['id' => 'ticket_saved'], ['weight' => 1]); From 0f99b82914f7c6ee29cd296660e63c9056d1dfb3 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Fri, 17 Jul 2026 09:46:35 +0200 Subject: [PATCH 03/37] fix: attach fully covered events to the covering activity instead of dropping them --- app/Listeners/CreateActivity.php | 14 ++++++++ .../Activity/CreateActivityTest.php | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/app/Listeners/CreateActivity.php b/app/Listeners/CreateActivity.php index 8097d6c..f8f7d63 100644 --- a/app/Listeners/CreateActivity.php +++ b/app/Listeners/CreateActivity.php @@ -103,6 +103,8 @@ private function createActivityFromEvent(Event $event): ?Activity $absorbedActivities = $this->trimOverlappingActivities($activity); if (! $activity->ended_at->isAfter($activity->started_at)) { + $this->attachEventToCoveringActivity($event); + return null; } } @@ -120,6 +122,18 @@ private function createActivityFromEvent(Event $event): ?Activity return $activity; } + private function attachEventToCoveringActivity(Event $event): void + { + /** @var ?Activity $coveringActivity */ + $coveringActivity = Activity::query() + ->where('user_id', $event->user_id) + ->where('started_at', '<=', $event->ended_at) + ->where('ended_at', '>=', $event->ended_at) + ->first(); + + $coveringActivity?->events()->save($event); + } + /** * Trims activities overlapping the new activity's period. An existing activity whose * trimmed period would collapse is returned for absorption: the new activity takes diff --git a/tests/Integration/Activity/CreateActivityTest.php b/tests/Integration/Activity/CreateActivityTest.php index c51bfa9..3a9c107 100644 --- a/tests/Integration/Activity/CreateActivityTest.php +++ b/tests/Integration/Activity/CreateActivityTest.php @@ -305,6 +305,39 @@ ->and($coveredEvent->fresh()->activity_id)->toBe($coveringEvent->fresh()->activity_id); }); +test('an event fully covered by a higher-weight activity attaches to that activity', function () { + config()->set('timatic.feature.activity_overlap_detection', true); + Illuminate\Support\Facades\Event::fake(); + + $eventTypeLight = EventType::factory()->state(['weight' => 1])->create(); + $eventTypeHeavy = EventType::factory()->state(['weight' => 999])->create(); + $user = User::factory()->create(); + + /** @var Event $meetingEvent */ + $meetingEvent = Event::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => $eventTypeHeavy->id, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0), + ]); + + /** @var CreateActivity $listener */ + $listener = app(CreateActivity::class); + $listener->handle(new EventCreated($meetingEvent)); + + /** @var Event $coveredEvent */ + $coveredEvent = Event::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => $eventTypeLight->id, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), + ]); + $listener->handle(new EventCreated($coveredEvent)); + + expect(Activity::count())->toBe(1) + ->and($coveredEvent->fresh()->activity_id)->toBe($meetingEvent->fresh()->activity_id); +}); + it('loads the event type of an activity', function () { Illuminate\Support\Facades\Event::fake(); $eventType = EventType::firstOrCreate(['id' => 'ticket_saved'], ['weight' => 1]); From 81ea82fc5f64bdd4689003bb92be743bde523d41 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Fri, 17 Jul 2026 09:55:06 +0200 Subject: [PATCH 04/37] fix: only attach covered events to activities of the same customer and ticket --- app/Listeners/CreateActivity.php | 8 +-- .../Activity/CreateActivityTest.php | 53 +++++++++++++++---- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/app/Listeners/CreateActivity.php b/app/Listeners/CreateActivity.php index f8f7d63..bc59e76 100644 --- a/app/Listeners/CreateActivity.php +++ b/app/Listeners/CreateActivity.php @@ -34,7 +34,7 @@ public function handle(EventCreated $eventCreated): void $event = $eventCreated->getEvent(); $adjacentActivity = $this->getAdjacentActivity($event); - if ($adjacentActivity && $this->canBeMergedWithAdjacentActivity($adjacentActivity, $event)) { + if ($adjacentActivity && $this->canAbsorbEvent($adjacentActivity, $event)) { $startedAt = $adjacentActivity->started_at; if ($event->started_at) { $startedAt = $adjacentActivity->started_at->min($event->started_at); @@ -49,7 +49,7 @@ public function handle(EventCreated $eventCreated): void } } - private function canBeMergedWithAdjacentActivity(Activity $lastActivity, Event $event): bool + private function canAbsorbEvent(Activity $lastActivity, Event $event): bool { $suggestion = $lastActivity->entrySuggestion; @@ -131,7 +131,9 @@ private function attachEventToCoveringActivity(Event $event): void ->where('ended_at', '>=', $event->ended_at) ->first(); - $coveringActivity?->events()->save($event); + if ($coveringActivity && $this->canAbsorbEvent($coveringActivity, $event)) { + $coveringActivity->events()->save($event); + } } /** diff --git a/tests/Integration/Activity/CreateActivityTest.php b/tests/Integration/Activity/CreateActivityTest.php index 3a9c107..aca97cd 100644 --- a/tests/Integration/Activity/CreateActivityTest.php +++ b/tests/Integration/Activity/CreateActivityTest.php @@ -305,37 +305,72 @@ ->and($coveredEvent->fresh()->activity_id)->toBe($coveringEvent->fresh()->activity_id); }); -test('an event fully covered by a higher-weight activity attaches to that activity', function () { +test('an event fully covered by a matching activity attaches to that activity', function () { config()->set('timatic.feature.activity_overlap_detection', true); Illuminate\Support\Facades\Event::fake(); - $eventTypeLight = EventType::factory()->state(['weight' => 1])->create(); - $eventTypeHeavy = EventType::factory()->state(['weight' => 999])->create(); + $sameState = [ + 'event_type_id' => EventType::factory()->state(['weight' => 1])->create()->id, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'user_id' => User::factory()->create()->id, + ]; + + /** @var Event $coveringEvent */ + $coveringEvent = Event::factory()->create(array_merge($sameState, [ + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0), + ])); + + /** @var CreateActivity $listener */ + $listener = app(CreateActivity::class); + $listener->handle(new EventCreated($coveringEvent)); + + /** @var Event $coveredEvent */ + $coveredEvent = Event::factory()->create(array_merge($sameState, [ + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), + ])); + $listener->handle(new EventCreated($coveredEvent)); + + expect(Activity::count())->toBe(1) + ->and($coveredEvent->fresh()->activity_id)->toBe($coveringEvent->fresh()->activity_id); +}); + +test('a covered event of another customer stays unattached instead of mixing customers', function () { + config()->set('timatic.feature.activity_overlap_detection', true); + Illuminate\Support\Facades\Event::fake(); + + $eventTypeId = EventType::factory()->state(['weight' => 1])->create()->id; $user = User::factory()->create(); - /** @var Event $meetingEvent */ - $meetingEvent = Event::factory()->create([ + /** @var Event $coveringEvent */ + $coveringEvent = Event::factory()->create([ + 'event_type_id' => $eventTypeId, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', 'user_id' => $user->id, - 'event_type_id' => $eventTypeHeavy->id, 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0), ]); /** @var CreateActivity $listener */ $listener = app(CreateActivity::class); - $listener->handle(new EventCreated($meetingEvent)); + $listener->handle(new EventCreated($coveringEvent)); /** @var Event $coveredEvent */ $coveredEvent = Event::factory()->create([ + 'event_type_id' => $eventTypeId, + 'customer_id' => 'customerY', + 'ticket_number' => 'TIC-2', 'user_id' => $user->id, - 'event_type_id' => $eventTypeLight->id, 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), ]); $listener->handle(new EventCreated($coveredEvent)); expect(Activity::count())->toBe(1) - ->and($coveredEvent->fresh()->activity_id)->toBe($meetingEvent->fresh()->activity_id); + ->and($coveredEvent->fresh()->activity_id)->toBeNull(); }); it('loads the event type of an activity', function () { From 0f75cafe7e494128d8a9335ea4e647a07f0986de Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Fri, 17 Jul 2026 10:39:09 +0200 Subject: [PATCH 05/37] fix: decide event coverage before trimming neighbouring activities The collapse of a new activity was detected after overlapping activities had already been trimmed and saved, so a fully covered event still shrank its neighbours, the covering activity was re-queried with a looser predicate that matched merely adjacent activities, and the resulting start time depended on database row order. Determine the trimmed start from all dominant overlaps upfront, attach a covered event only to an activity that spans its whole period, and persist trims, the new activity, and absorptions in one transaction. --- app/Listeners/CreateActivity.php | 117 ++++++++++------- .../Activity/CreateActivityTest.php | 119 +++++++++++++++++- 2 files changed, 190 insertions(+), 46 deletions(-) diff --git a/app/Listeners/CreateActivity.php b/app/Listeners/CreateActivity.php index bc59e76..f909ab9 100644 --- a/app/Listeners/CreateActivity.php +++ b/app/Listeners/CreateActivity.php @@ -98,21 +98,38 @@ private function createActivityFromEvent(Event $event): ?Activity $activity->is_internal = $event->is_internal; $activity->event_type_id = $event->eventType->id ?? null; + $trimmedActivities = collect(); $absorbedActivities = collect(); if (config('timatic.feature.activity_overlap_detection')) { - $absorbedActivities = $this->trimOverlappingActivities($activity); + $overlappingActivities = $this->getOverlappingActivities($activity); + $isDominant = function (Activity $overlappingActivity) use ($activity) { + return ! is_null($overlappingActivity->eventType) + && $overlappingActivity->eventType->weight >= (int) $activity->eventType?->weight; + }; + + $dominantActivities = $overlappingActivities->filter($isDominant); + $activity->started_at = $this->startedAtAfterDominantActivities($activity, $dominantActivities); if (! $activity->ended_at->isAfter($activity->started_at)) { - $this->attachEventToCoveringActivity($event); + $this->attachEventToCoveringActivity($event, $dominantActivities); return null; } + + $trimmedActivities = $this->trimSubordinateActivities($activity, $overlappingActivities->reject($isDominant)); + $isCollapsed = function (Activity $trimmedActivity) { + return ! $trimmedActivity->ended_at->isAfter($trimmedActivity->started_at); + }; + $absorbedActivities = $trimmedActivities->filter($isCollapsed); + $trimmedActivities = $trimmedActivities->reject($isCollapsed); } - $this->db->transaction(function () use ($activity, $event, $absorbedActivities) { + $this->db->transaction(function () use ($activity, $event, $trimmedActivities, $absorbedActivities) { $activity->save(); $activity->events()->save($event); + $trimmedActivities->each(fn (Activity $trimmedActivity) => $trimmedActivity->save()); + $absorbedActivities->each(function (Activity $absorbedActivity) use ($activity) { $absorbedActivity->events()->update(['activity_id' => $activity->id]); $absorbedActivity->delete(); @@ -122,31 +139,13 @@ private function createActivityFromEvent(Event $event): ?Activity return $activity; } - private function attachEventToCoveringActivity(Event $event): void - { - /** @var ?Activity $coveringActivity */ - $coveringActivity = Activity::query() - ->where('user_id', $event->user_id) - ->where('started_at', '<=', $event->ended_at) - ->where('ended_at', '>=', $event->ended_at) - ->first(); - - if ($coveringActivity && $this->canAbsorbEvent($coveringActivity, $event)) { - $coveringActivity->events()->save($event); - } - } - /** - * Trims activities overlapping the new activity's period. An existing activity whose - * trimmed period would collapse is returned for absorption: the new activity takes - * over its events and the empty activity is deleted. - * * @return Collection */ - private function trimOverlappingActivities(Activity $activity): Collection + private function getOverlappingActivities(Activity $activity): Collection { - /** @var Collection|Activity[] $overlappingActivities */ - $overlappingActivities = Activity::query() + return Activity::query() + ->with('eventType') ->where('user_id', $activity->user_id) ->where(function (Builder $query) use ($activity) { $query @@ -166,32 +165,60 @@ private function trimOverlappingActivities(Activity $activity): Collection ->where('ended_at', '<=', $activity->ended_at); }); }) + ->orderBy('started_at') ->get(); + } - $absorbedActivities = collect(); + /** + * Dominant activities keep their period, so the new activity starts after + * the last of them. A start beyond the activity's end means the event was + * fully covered by dominant activities. + * + * @param Collection $dominantActivities + */ + private function startedAtAfterDominantActivities(Activity $activity, Collection $dominantActivities): Carbon + { + return $dominantActivities->reduce( + fn (Carbon $startedAt, Activity $dominantActivity): Carbon => $startedAt->max($dominantActivity->ended_at), + $activity->started_at, + ); + } - $overlappingActivities->each(function ($overlappingActivity) use ($activity, $absorbedActivities) { - /** @var Activity $overlappingActivity */ - if (! is_null($overlappingActivity->eventType) - && $overlappingActivity->eventType->weight >= (int) $activity->eventType?->weight) { - // overlappingActivity gets priority, move startedAt to after this activity - $activity->started_at = $overlappingActivity->ended_at; - } else { - // new event gets priority, reduce overlapping start or end time from overlappingActivity - if ($overlappingActivity->ended_at < $activity->ended_at) { - $overlappingActivity->ended_at = $activity->started_at; - } else { - $overlappingActivity->started_at = $activity->ended_at; - } + /** + * @param Collection $coveringCandidates + */ + private function attachEventToCoveringActivity(Event $event, Collection $coveringCandidates): void + { + $coveringActivity = $coveringCandidates->first(function (Activity $coveringCandidate) use ($event) { + return $coveringCandidate->started_at->lessThanOrEqualTo($this->getEstimatedStartedAt($event)) + && $coveringCandidate->ended_at->greaterThanOrEqualTo($event->ended_at) + && $this->canAbsorbEvent($coveringCandidate, $event); + }); - if ($overlappingActivity->ended_at->isAfter($overlappingActivity->started_at)) { - $overlappingActivity->save(); + $coveringActivity?->events()->save($event); + } + + /** + * The new activity gets priority, so subordinate activities still overlapping + * its final period lose the overlapping part. An activity whose trimmed period + * collapses is absorbed: the new activity takes over its events. + * + * @param Collection $subordinateActivities + * @return Collection + */ + private function trimSubordinateActivities(Activity $activity, Collection $subordinateActivities): Collection + { + return $subordinateActivities + ->filter(function (Activity $subordinateActivity) use ($activity) { + return $subordinateActivity->started_at->lessThan($activity->ended_at) + && $subordinateActivity->ended_at->greaterThan($activity->started_at); + }) + ->each(function (Activity $subordinateActivity) use ($activity) { + if ($subordinateActivity->ended_at < $activity->ended_at) { + $subordinateActivity->ended_at = $activity->started_at; } else { - $absorbedActivities->push($overlappingActivity); + $subordinateActivity->started_at = $activity->ended_at; } - } - }); - - return $absorbedActivities; + }); } } diff --git a/tests/Integration/Activity/CreateActivityTest.php b/tests/Integration/Activity/CreateActivityTest.php index aca97cd..8b55320 100644 --- a/tests/Integration/Activity/CreateActivityTest.php +++ b/tests/Integration/Activity/CreateActivityTest.php @@ -373,7 +373,124 @@ ->and($coveredEvent->fresh()->activity_id)->toBeNull(); }); -it('loads the event type of an activity', function () { +test('a fully covered event does not trim neighbouring activities', function () { + config()->set('timatic.feature.activity_overlap_detection', true); + Illuminate\Support\Facades\Event::fake(); + + $user = User::factory()->create(); + Activity::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => EventType::factory()->state(['weight' => 999])->create()->id, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 15, second: 0), + ]); + /** @var Activity $neighbouringActivity */ + $neighbouringActivity = Activity::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => EventType::factory()->state(['weight' => 1])->create()->id, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 8, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 20, second: 0), + ]); + + /** @var Event $coveredEvent */ + $coveredEvent = Event::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => EventType::factory()->state(['weight' => 5])->create()->id, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), + ]); + + /** @var CreateActivity $listener */ + $listener = app(CreateActivity::class); + $listener->handle(new EventCreated($coveredEvent)); + + expect(Activity::count())->toBe(2) + ->and($neighbouringActivity->fresh()->started_at) + ->toEqual(Carbon::now()->subWeek()->setTime(hour: 10, minute: 8, second: 0)) + ->and($neighbouringActivity->fresh()->ended_at) + ->toEqual(Carbon::now()->subWeek()->setTime(hour: 10, minute: 20, second: 0)) + ->and($coveredEvent->fresh()->activity_id)->toBeNull(); +}); + +test('a collapsed event does not attach to an adjacent activity that does not cover it', function () { + config()->set('timatic.feature.activity_overlap_detection', true); + Illuminate\Support\Facades\Event::fake(); + + $user = User::factory()->create(); + $lightEventTypeId = EventType::factory()->state(['weight' => 1])->create()->id; + + /** @var Activity $adjacentActivity */ + $adjacentActivity = Activity::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => $lightEventTypeId, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0), + ]); + Activity::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => EventType::factory()->state(['weight' => 999])->create()->id, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 12, second: 0), + ]); + + /** @var Event $coveredEvent */ + $coveredEvent = Event::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => $lightEventTypeId, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), + ]); + + /** @var CreateActivity $listener */ + $listener = app(CreateActivity::class); + $listener->handle(new EventCreated($coveredEvent)); + + expect(Activity::count())->toBe(2) + ->and($coveredEvent->fresh()->activity_id)->toBeNull() + ->and($adjacentActivity->fresh()->started_at) + ->toEqual(Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0)); +}); + +test('the new activity starts after the latest dominant overlapping activity', function () { + config()->set('timatic.feature.activity_overlap_detection', true); + Illuminate\Support\Facades\Event::fake(); + + $user = User::factory()->create(); + $heavyEventTypeId = EventType::factory()->state(['weight' => 999])->create()->id; + Activity::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => $heavyEventTypeId, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 9, minute: 0, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0), + ]); + Activity::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => $heavyEventTypeId, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 20, second: 0), + ]); + + /** @var Event $event */ + $event = Event::factory()->create([ + 'user_id' => $user->id, + 'event_type_id' => EventType::factory()->state(['weight' => 5])->create()->id, + 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 15, second: 0), + 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 11, minute: 0, second: 0), + ]); + + /** @var CreateActivity $listener */ + $listener = app(CreateActivity::class); + $listener->handle(new EventCreated($event)); + + expect($event->fresh()->activity->started_at) + ->toEqual(Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0)); +}); + +test('loads the event type of an activity', function () { Illuminate\Support\Facades\Event::fake(); $eventType = EventType::firstOrCreate(['id' => 'ticket_saved'], ['weight' => 1]); From 60974772e339f9ca2e268d3f872e5cd85f79ccf2 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 09:42:46 +0200 Subject: [PATCH 06/37] feat: bundle same-ticket activities of a day into one suggestion --- app/Models/EntrySuggestion.php | 1 + app/Services/SuggestionBundler.php | 82 +++++++ .../Services/SuggestionBundlerTest.php | 218 ++++++++++++++++++ 3 files changed, 301 insertions(+) create mode 100644 app/Services/SuggestionBundler.php create mode 100644 tests/Integration/Services/SuggestionBundlerTest.php diff --git a/app/Models/EntrySuggestion.php b/app/Models/EntrySuggestion.php index 05a1967..6b5863e 100644 --- a/app/Models/EntrySuggestion.php +++ b/app/Models/EntrySuggestion.php @@ -53,6 +53,7 @@ protected function casts(): array return [ 'id' => 'integer', 'is_internal' => 'bool', + 'date' => 'date', ]; } diff --git a/app/Services/SuggestionBundler.php b/app/Services/SuggestionBundler.php new file mode 100644 index 0000000..d05a119 --- /dev/null +++ b/app/Services/SuggestionBundler.php @@ -0,0 +1,82 @@ +findMatchingSuggestion($activity) + ?? $this->newSuggestionFromActivity($activity); + + return $this->attach($suggestion, $activity); + } + + public function createNewSuggestionFor(Activity $activity): EntrySuggestion + { + return $this->attach($this->newSuggestionFromActivity($activity), $activity); + } + + private function attach(EntrySuggestion $suggestion, Activity $activity): EntrySuggestion + { + $suggestion->save(); + $suggestion->activities()->save($activity); + + return $suggestion; + } + + private function findMatchingSuggestion(Activity $activity): ?EntrySuggestion + { + $query = EntrySuggestion::query() + ->whereDoesntHave('entry') + ->where('user_id', $activity->user_id) + ->where('date', $this->suggestionDateFor($activity)); + + $this->whereNullable($query, 'customer_id', $activity->customer_id); + $this->whereNullable($query, 'budget_id', $activity->budget_id); + $this->whereNullable($query, 'ticket_number', $activity->ticket_number); + $this->whereNullable($query, 'is_internal', $activity->is_internal); + + /** @var ?EntrySuggestion */ + return $query->first(); + } + + private function newSuggestionFromActivity(Activity $activity): EntrySuggestion + { + $suggestion = new EntrySuggestion; + $suggestion->user_id = $activity->user_id; + $suggestion->budget_id = $activity->budget_id; + $suggestion->ticket_id = $activity->ticket_id; + $suggestion->ticket_number = $activity->ticket_number; + $suggestion->ticket_type = $activity->ticket_type; + $suggestion->customer_id = $activity->customer_id; + $suggestion->is_internal = $activity->is_internal; + $suggestion->date = Carbon::parse($this->suggestionDateFor($activity)); + + return $suggestion; + } + + private function suggestionDateFor(Activity $activity): string + { + return $activity->started_at + ->setTimezone(config('timatic.preferred_timezone')) + ->toDateString(); + } + + /** + * @param Builder $query + */ + private function whereNullable(Builder $query, string $column, mixed $value): void + { + if ($value === null) { + $query->whereNull($column); + } else { + $query->where($column, $value); + } + } +} diff --git a/tests/Integration/Services/SuggestionBundlerTest.php b/tests/Integration/Services/SuggestionBundlerTest.php new file mode 100644 index 0000000..bb8cecb --- /dev/null +++ b/tests/Integration/Services/SuggestionBundlerTest.php @@ -0,0 +1,218 @@ +create(); + $source = Source::factory()->create(); + $first = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 09:00:00', + 'ended_at' => '2026-06-04 10:00:00', + ]); + $second = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 13:00:00', + 'ended_at' => '2026-06-04 14:00:00', + ]); + + $bundler = app(SuggestionBundler::class); + $firstSuggestion = $bundler->bundle($first); + $secondSuggestion = $bundler->bundle($second); + + expect($secondSuggestion->id)->toBe($firstSuggestion->id) + ->and(EntrySuggestion::count())->toBe(1) + ->and($firstSuggestion->activities()->count())->toBe(2); +}); + +it('does not bundle a no-ticket activity into a ticketed suggestion', function () { + EventFacade::fake(); + $user = User::factory()->create(); + $source = Source::factory()->create(); + $ticketed = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 09:00:00', + 'ended_at' => '2026-06-04 10:00:00', + ]); + $unticketed = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => null, + 'started_at' => '2026-06-04 10:30:00', + 'ended_at' => '2026-06-04 11:00:00', + ]); + + $bundler = app(SuggestionBundler::class); + $ticketedSuggestion = $bundler->bundle($ticketed); + $unticketedSuggestion = $bundler->bundle($unticketed); + + expect($unticketedSuggestion->id)->not->toBe($ticketedSuggestion->id) + ->and(EntrySuggestion::count())->toBe(2); +}); + +it('bundles no-ticket activities of the same customer and day together', function () { + EventFacade::fake(); + $user = User::factory()->create(); + $source = Source::factory()->create(); + $first = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => null, + 'started_at' => '2026-06-04 09:00:00', + 'ended_at' => '2026-06-04 10:00:00', + ]); + $second = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => null, + 'started_at' => '2026-06-04 13:00:00', + 'ended_at' => '2026-06-04 14:00:00', + ]); + + $bundler = app(SuggestionBundler::class); + $firstSuggestion = $bundler->bundle($first); + $secondSuggestion = $bundler->bundle($second); + + expect($secondSuggestion->id)->toBe($firstSuggestion->id); +}); + +it('does not bundle activities of different budgets', function () { + EventFacade::fake(); + $user = User::factory()->create(); + $source = Source::factory()->create(); + $first = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'budget_id' => null, + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 09:00:00', + 'ended_at' => '2026-06-04 10:00:00', + ]); + $second = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'budget_id' => Budget::factory()->create()->id, + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 13:00:00', + 'ended_at' => '2026-06-04 14:00:00', + ]); + + $bundler = app(SuggestionBundler::class); + $firstSuggestion = $bundler->bundle($first); + $secondSuggestion = $bundler->bundle($second); + + expect($secondSuggestion->id)->not->toBe($firstSuggestion->id); +}); + +it('does not bundle activities of different days', function () { + EventFacade::fake(); + $user = User::factory()->create(); + $source = Source::factory()->create(); + $first = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 09:00:00', + 'ended_at' => '2026-06-04 10:00:00', + ]); + $second = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-05 09:00:00', + 'ended_at' => '2026-06-05 10:00:00', + ]); + + $bundler = app(SuggestionBundler::class); + $firstSuggestion = $bundler->bundle($first); + $secondSuggestion = $bundler->bundle($second); + + expect($secondSuggestion->id)->not->toBe($firstSuggestion->id); +}); + +it('does not reuse a rejected suggestion', function () { + EventFacade::fake(); + $user = User::factory()->create(); + $source = Source::factory()->create(); + $first = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 09:00:00', + 'ended_at' => '2026-06-04 10:00:00', + ]); + + $bundler = app(SuggestionBundler::class); + $rejected = $bundler->bundle($first); + $rejected->delete(); + + $second = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 13:00:00', + 'ended_at' => '2026-06-04 14:00:00', + ]); + $suggestion = $bundler->bundle($second); + + expect($suggestion->id)->not->toBe($rejected->id); +}); + +it('does not reuse an accepted suggestion', function () { + EventFacade::fake(); + $user = User::factory()->create(); + $source = Source::factory()->create(); + $first = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 09:00:00', + 'ended_at' => '2026-06-04 10:00:00', + ]); + + $bundler = app(SuggestionBundler::class); + $accepted = $bundler->bundle($first); + Entry::factory()->create(['entry_suggestion_id' => $accepted->id]); + + $second = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 13:00:00', + 'ended_at' => '2026-06-04 14:00:00', + ]); + $suggestion = $bundler->bundle($second); + + expect($suggestion->id)->not->toBe($accepted->id); +}); From 2b2a7b6f72f0db24fd8310ab35545725f7f0668c Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 09:48:53 +0200 Subject: [PATCH 07/37] fix: keep EntrySuggestion date attribute a plain string Claude --- app/Models/EntrySuggestion.php | 1 - app/Services/SuggestionBundler.php | 3 +-- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/Models/EntrySuggestion.php b/app/Models/EntrySuggestion.php index 6b5863e..05a1967 100644 --- a/app/Models/EntrySuggestion.php +++ b/app/Models/EntrySuggestion.php @@ -53,7 +53,6 @@ protected function casts(): array return [ 'id' => 'integer', 'is_internal' => 'bool', - 'date' => 'date', ]; } diff --git a/app/Services/SuggestionBundler.php b/app/Services/SuggestionBundler.php index d05a119..44db81e 100644 --- a/app/Services/SuggestionBundler.php +++ b/app/Services/SuggestionBundler.php @@ -4,7 +4,6 @@ use App\Models\Activity; use App\Models\EntrySuggestion; -use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; class SuggestionBundler @@ -56,7 +55,7 @@ private function newSuggestionFromActivity(Activity $activity): EntrySuggestion $suggestion->ticket_type = $activity->ticket_type; $suggestion->customer_id = $activity->customer_id; $suggestion->is_internal = $activity->is_internal; - $suggestion->date = Carbon::parse($this->suggestionDateFor($activity)); + $suggestion->date = $activity->started_at->setTimezone(config('timatic.preferred_timezone')); return $suggestion; } From 63cb2450ab4b4242c9ff563814d1227719bfa962 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 09:54:35 +0200 Subject: [PATCH 08/37] refactor: delegate suggestion creation to SuggestionBundler with strict ticket matching --- app/Listeners/CreateSuggestion.php | 77 +---------- tests/Integration/CreateSuggestionTest.php | 148 +++++++-------------- 2 files changed, 54 insertions(+), 171 deletions(-) diff --git a/app/Listeners/CreateSuggestion.php b/app/Listeners/CreateSuggestion.php index 125554d..b8d6931 100644 --- a/app/Listeners/CreateSuggestion.php +++ b/app/Listeners/CreateSuggestion.php @@ -3,90 +3,21 @@ namespace App\Listeners; use App\Events\ActivityCreated; -use App\Models\Activity; -use App\Models\EntrySuggestion; -use Exception; +use App\Services\SuggestionBundler; use Illuminate\Contracts\Queue\ShouldQueue; -use Illuminate\Database\DatabaseManager; -use Illuminate\Database\Eloquent\Builder; -use Throwable; class CreateSuggestion implements ShouldQueue { - protected DatabaseManager $db; + public function __construct(private readonly SuggestionBundler $bundler) {} - /** - * Create the event listener. - */ - public function __construct(DatabaseManager $db) - { - $this->db = $db; - } - - /** - * Handle the event. - * - * - * @throws Exception|Throwable - */ public function handle(ActivityCreated $activityCreated): void { $activity = $activityCreated->getActivity(); if (config('timatic.feature.build_stacked_suggestions')) { - $suggestion = $this->findMergeableSuggestion($activity); + $this->bundler->bundle($activity); } else { - $suggestion = null; - } - - if ($suggestion === null) { - // not found? create a new suggestion - $suggestion = $this->createSuggestionFromActivity($activity); - } - - if ($suggestion->ticket_number === null && $activity->ticket_number !== null) { - $suggestion->ticket_id = $activity->ticket_id; - $suggestion->ticket_number = $activity->ticket_number; - } - - $suggestion->save(); - $suggestion->activities()->save($activity); - } - - private function findMergeableSuggestion(Activity $activity): ?EntrySuggestion - { - $suggestion = EntrySuggestion::query() - ->where('user_id', '=', $activity->user_id) - ->where('customer_id', '=', $activity->customer_id) - // we only handle suggestions from the same day - ->where('date', '=', $activity->started_at->setTimezone(config('timatic.preferred_timezone'))->toDateString()) - ->where(function (Builder $query) use ($activity) { - $query - ->where('ticket_number', '=', $activity->ticket_number) - ->orWhereNull('ticket_number'); - }) - ->first(); - - /** @var ?EntrySuggestion $suggestion */ - if ($suggestion && $suggestion->ticket_number === null) { - return null; + $this->bundler->createNewSuggestionFor($activity); } - - return $suggestion; - } - - private function createSuggestionFromActivity(Activity $activity): EntrySuggestion - { - $suggestion = new EntrySuggestion; - $suggestion->user_id = $activity->user_id; - $suggestion->budget_id = $activity->budget_id; - $suggestion->ticket_id = $activity->ticket_id; - $suggestion->ticket_number = $activity->ticket_number; - $suggestion->ticket_type = $activity->ticket_type; - $suggestion->customer_id = $activity->customer_id; - $suggestion->is_internal = $activity->is_internal; - $suggestion->date = $activity->started_at->setTimezone(config('timatic.preferred_timezone')); - - return $suggestion; } } diff --git a/tests/Integration/CreateSuggestionTest.php b/tests/Integration/CreateSuggestionTest.php index 0ef57d7..aef8873 100644 --- a/tests/Integration/CreateSuggestionTest.php +++ b/tests/Integration/CreateSuggestionTest.php @@ -15,12 +15,14 @@ uses(WithFaker::class); beforeEach(function () { + config()->set('timatic.feature.build_stacked_suggestions', true); + foreach (['ticket_saved', 'issue_changed_to_done', 'ticket_tagged', 'calendar_event_finished'] as $id) { EventType::firstOrCreate(['id' => $id], ['weight' => 1]); } }); -test('linear activity stream', function () { +test('linear activity stream bundles strictly per ticket', function () { Illuminate\Support\Facades\Event::fake(); /** @var User $user */ @@ -84,134 +86,84 @@ $listener->handle(new ActivityCreated($activity)); } - expect($activities[2]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - - if (config('timatic.feature.build_stacked_suggestions') == false) { - return; + foreach ($activities as $activity) { + $activity->refresh(); } - expect($activities[1]->entry_suggestion_id)->toEqual($activities[2]->entry_suggestion_id); + expect($activities[1]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); + expect($activities[4]->entry_suggestion_id)->toEqual($activities[1]->entry_suggestion_id); + + expect($activities[2]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); expect($activities[3]->entry_suggestion_id)->toEqual($activities[2]->entry_suggestion_id); + expect($activities[2]->entry_suggestion_id)->not->toEqual($activities[1]->entry_suggestion_id); - expect($activities[4]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - expect($activities[5]->entry_suggestion_id)->toEqual($activities[4]->entry_suggestion_id); - expect($activities[6]->entry_suggestion_id)->toEqual($activities[4]->entry_suggestion_id); + expect($activities[5]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); + expect($activities[6]->entry_suggestion_id)->toEqual($activities[5]->entry_suggestion_id); + expect($activities[5]->entry_suggestion_id)->not->toEqual($activities[2]->entry_suggestion_id); }); -test('activity stream with dangling activity', function () { +test('rejected suggestion is not reused for later activities', function () { Illuminate\Support\Facades\Event::fake(); - if (config('timatic.feature.build_stacked_suggestions') == false) { - $this->markTestSkipped('stacked suggestions are disabled'); - } - /** @var User $user */ $user = User::factory()->create(); $userId = $user->id; $customerId = $this->faker->numberBetween(); - $data = [ - 1 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'issue_changed_to_done', - 'ticket_number' => null, - ], - 2 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'ticket_saved', - 'ticket_number' => $this->faker->numberBetween(), - ], - 3 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'issue_changed_to_done', - 'ticket_number' => null, - ], + $ticketId = $this->faker->numberBetween(); + $state = [ + 'user_id' => $userId, + 'customer_id' => $customerId, + 'event_type_id' => 'ticket_saved', + 'ticket_number' => $ticketId, ]; - foreach ($data as $key => $d) { - /** @var Activity[] $activities */ - $activities[$key] = Activity::factory() - ->has( - Event::factory()->state($d) - )->create($d); - } + + /** @var Activity $first */ + $first = Activity::factory()->has(Event::factory()->state($state))->create($state); /** @var CreateSuggestion $listener */ $listener = app(CreateSuggestion::class); + $listener->handle(new ActivityCreated($first)); - foreach ($activities as $activity) { - $listener->handle(new ActivityCreated($activity)); - } + $first->refresh(); + $first->entrySuggestion?->delete(); - expect($activities[1]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - expect($activities[2]->entry_suggestion_id)->toEqual($activities[1]->entry_suggestion_id); + /** @var Activity $second */ + $second = Activity::factory()->has(Event::factory()->state($state))->create($state); + $listener->handle(new ActivityCreated($second)); - expect($activities[3]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - expect($activities[3]->entry_suggestion_id)->not->toEqual($activities[1]->entry_suggestion_id); + $second->refresh(); + expect($second->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); + expect($second->entry_suggestion_id)->not->toEqual($first->entry_suggestion_id); }); -test('outlook activities without ticket id', function () { - if (config('timatic.feature.build_stacked_suggestions') == false) { - $this->markTestSkipped('stacked suggestions are disabled'); - } - +test('flag off keeps one suggestion per activity', function () { Illuminate\Support\Facades\Event::fake(); + config()->set('timatic.feature.build_stacked_suggestions', false); /** @var User $user */ $user = User::factory()->create(); - $userId = $user->id; - $customerId = $this->faker->numberBetween(); - $ticketId = $this->faker->numberBetween(); - $data = [ - 1 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'issue_changed_to_done', - 'ticket_number' => null, - ], - 2 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'ticket_saved', - 'ticket_number' => $ticketId, - ], - 3 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'calendar_event_finished', - 'ticket_number' => null, - ], - 4 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'ticket_saved', - 'ticket_number' => $ticketId, - ], + $state = [ + 'user_id' => $user->id, + 'customer_id' => $this->faker->numberBetween(), + 'event_type_id' => 'ticket_saved', + 'ticket_number' => $this->faker->numberBetween(), ]; - foreach ($data as $key => $d) { - /** @var Activity[] $activities */ - $activities[$key] = Activity::factory() - ->has( - Event::factory()->state($d) - )->create($d); - } + + /** @var Activity $first */ + $first = Activity::factory()->has(Event::factory()->state($state))->create($state); + /** @var Activity $second */ + $second = Activity::factory()->has(Event::factory()->state($state))->create($state); /** @var CreateSuggestion $listener */ $listener = app(CreateSuggestion::class); + $listener->handle(new ActivityCreated($first)); + $listener->handle(new ActivityCreated($second)); - foreach ($activities as $activity) { - $listener->handle(new ActivityCreated($activity)); - } - - expect($activities[1]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - expect($activities[2]->entry_suggestion_id)->toEqual($activities[1]->entry_suggestion_id); - - expect($activities[3]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - $this->assertNotEquals($activities[2]->entry_suggestion_id, $activities[3]->entry_suggestion_id); + $first->refresh(); + $second->refresh(); - expect($activities[4]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - expect($activities[4]->entry_suggestion_id)->toEqual($activities[2]->entry_suggestion_id); + expect(EntrySuggestion::count())->toBe(2); + expect($first->entry_suggestion_id)->not->toEqual($second->entry_suggestion_id); }); From ddde3e259c5378ce369d8d2657668ad4970ac17f Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 09:59:23 +0200 Subject: [PATCH 09/37] feat: add rebundle command to recompute open entry suggestions --- README.md | 11 ++ .../Commands/RebundleSuggestionsCommand.php | 51 ++++++++ .../RebundleSuggestionsCommandTest.php | 121 ++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 app/Console/Commands/RebundleSuggestionsCommand.php create mode 100644 tests/Integration/RebundleSuggestionsCommandTest.php diff --git a/README.md b/README.md index 609278f..cf157bd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,17 @@ To (re)seed dummy data at any time: php artisan db:seed --class=DummySeeder ``` +## Commands + +### Rebundle entry suggestions + +Deletes all open (not accepted, not rejected) entry suggestions and rebundles their +activities chronologically using the current matching rules: + +```bash +php artisan timatic:rebundle-suggestions [--user=1] [--from=2026-06-01] [--to=2026-06-30] +``` + ## License Copyright (c) 2025 Timatic. diff --git a/app/Console/Commands/RebundleSuggestionsCommand.php b/app/Console/Commands/RebundleSuggestionsCommand.php new file mode 100644 index 0000000..b256f88 --- /dev/null +++ b/app/Console/Commands/RebundleSuggestionsCommand.php @@ -0,0 +1,51 @@ +whereDoesntHave('entry') + ->when($this->option('user'), fn ($query, $user) => $query->where('user_id', $user)) + ->when($this->option('from'), fn ($query, $from) => $query->where('date', '>=', $from)) + ->when($this->option('to'), fn ($query, $to) => $query->where('date', '<=', $to)) + ->pluck('id'); + + $activityIds = Activity::query() + ->whereIn('entry_suggestion_id', $suggestionIds) + ->pluck('id'); + + // detach first: activities.entry_suggestion_id cascades on suggestion delete + Activity::query()->whereIn('id', $activityIds)->update(['entry_suggestion_id' => null]); + EntrySuggestion::query()->whereKey($suggestionIds)->forceDelete(); + + Activity::query() + ->whereIn('id', $activityIds) + ->orderBy('started_at') + ->get() + ->each(fn (Activity $activity) => $bundler->bundle($activity)); + + $this->info(sprintf( + 'Rebundled %d activities from %d suggestions into %d suggestions.', + $activityIds->count(), + $suggestionIds->count(), + EntrySuggestion::query()->whereIn('id', Activity::query()->whereIn('id', $activityIds)->pluck('entry_suggestion_id'))->count(), + )); + + return self::SUCCESS; + } +} diff --git a/tests/Integration/RebundleSuggestionsCommandTest.php b/tests/Integration/RebundleSuggestionsCommandTest.php new file mode 100644 index 0000000..4029f32 --- /dev/null +++ b/tests/Integration/RebundleSuggestionsCommandTest.php @@ -0,0 +1,121 @@ +create(); + $source = Source::factory()->create(); + $bundler = app(SuggestionBundler::class); + + foreach (['09:00:00', '11:00:00', '13:00:00'] as $time) { + $activity = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 '.$time, + 'ended_at' => '2026-06-04 '.$time, + ]); + $bundler->createNewSuggestionFor($activity); + } + + expect(EntrySuggestion::count())->toBe(3); + + $this->artisan('timatic:rebundle-suggestions')->assertSuccessful(); + + expect(EntrySuggestion::count())->toBe(1) + ->and(EntrySuggestion::first()->activities()->count())->toBe(3); +}); + +it('leaves accepted suggestions and their activities untouched', function () { + EventFacade::fake(); + $user = User::factory()->create(); + $source = Source::factory()->create(); + $bundler = app(SuggestionBundler::class); + + $acceptedActivity = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 09:00:00', + 'ended_at' => '2026-06-04 10:00:00', + ]); + $accepted = $bundler->createNewSuggestionFor($acceptedActivity); + Entry::factory()->create(['entry_suggestion_id' => $accepted->id]); + + $openActivity = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 11:00:00', + 'ended_at' => '2026-06-04 12:00:00', + ]); + $bundler->createNewSuggestionFor($openActivity); + + $this->artisan('timatic:rebundle-suggestions')->assertSuccessful(); + + $acceptedActivity->refresh(); + expect($acceptedActivity->entry_suggestion_id)->toBe($accepted->id) + ->and($accepted->fresh()->activities()->count())->toBe(1); +}); + +it('leaves rejected suggestions and their activities untouched', function () { + EventFacade::fake(); + $user = User::factory()->create(); + $source = Source::factory()->create(); + $bundler = app(SuggestionBundler::class); + + $rejectedActivity = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 09:00:00', + 'ended_at' => '2026-06-04 10:00:00', + ]); + $rejected = $bundler->createNewSuggestionFor($rejectedActivity); + $rejected->delete(); + + $this->artisan('timatic:rebundle-suggestions')->assertSuccessful(); + + $rejectedActivity->refresh(); + expect($rejectedActivity->entry_suggestion_id)->toBe($rejected->id) + ->and(EntrySuggestion::withTrashed()->count())->toBe(1); +}); + +it('scopes rebundling with the user option', function () { + EventFacade::fake(); + $userA = User::factory()->create(); + $userB = User::factory()->create(); + $source = Source::factory()->create(); + $bundler = app(SuggestionBundler::class); + + foreach ([$userA, $userA, $userB] as $index => $user) { + $activity = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 '.(9 + $index).':00:00', + 'ended_at' => '2026-06-04 '.(9 + $index).':30:00', + ]); + $bundler->createNewSuggestionFor($activity); + } + + $this->artisan('timatic:rebundle-suggestions', ['--user' => $userA->id])->assertSuccessful(); + + expect(EntrySuggestion::where('user_id', $userA->id)->count())->toBe(1) + ->and(EntrySuggestion::where('user_id', $userB->id)->count())->toBe(1); +}); From 4d73ab7abb73cbc1b9ed91fa3c335f4f69dff3f3 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 10:05:00 +0200 Subject: [PATCH 10/37] fix: run suggestion rebundling inside a database transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wrap the destructive sequence (detach → forceDelete → rebundle) in a database transaction to prevent data loss if bundle() throws mid-operation. Previously, if an error occurred during rebundling, suggestions would already be deleted and activities left with entry_suggestion_id = null, unrecoverable by re-run. Claude --- .../Commands/RebundleSuggestionsCommand.php | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/app/Console/Commands/RebundleSuggestionsCommand.php b/app/Console/Commands/RebundleSuggestionsCommand.php index b256f88..7f4d427 100644 --- a/app/Console/Commands/RebundleSuggestionsCommand.php +++ b/app/Console/Commands/RebundleSuggestionsCommand.php @@ -6,6 +6,7 @@ use App\Models\EntrySuggestion; use App\Services\SuggestionBundler; use Illuminate\Console\Command; +use Illuminate\Support\Facades\DB; class RebundleSuggestionsCommand extends Command { @@ -29,15 +30,17 @@ public function handle(SuggestionBundler $bundler): int ->whereIn('entry_suggestion_id', $suggestionIds) ->pluck('id'); - // detach first: activities.entry_suggestion_id cascades on suggestion delete - Activity::query()->whereIn('id', $activityIds)->update(['entry_suggestion_id' => null]); - EntrySuggestion::query()->whereKey($suggestionIds)->forceDelete(); - - Activity::query() - ->whereIn('id', $activityIds) - ->orderBy('started_at') - ->get() - ->each(fn (Activity $activity) => $bundler->bundle($activity)); + DB::transaction(function () use ($activityIds, $suggestionIds, $bundler): void { + // detach first: activities.entry_suggestion_id cascades on suggestion delete + Activity::query()->whereIn('id', $activityIds)->update(['entry_suggestion_id' => null]); + EntrySuggestion::query()->whereKey($suggestionIds)->forceDelete(); + + Activity::query() + ->whereIn('id', $activityIds) + ->orderBy('started_at') + ->get() + ->each(fn (Activity $activity) => $bundler->bundle($activity)); + }); $this->info(sprintf( 'Rebundled %d activities from %d suggestions into %d suggestions.', From 9b29f67a6cbcf77e2ee8739584d094c218de245a Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 10:09:14 +0200 Subject: [PATCH 11/37] test: prove rebundle command rolls back on mid-replay failure Mocks SuggestionBundler::bundle to throw mid-transaction and asserts the detach/forceDelete work is rolled back, not just the bundle call. --- .../RebundleSuggestionsCommandTest.php | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/Integration/RebundleSuggestionsCommandTest.php b/tests/Integration/RebundleSuggestionsCommandTest.php index 4029f32..b25e530 100644 --- a/tests/Integration/RebundleSuggestionsCommandTest.php +++ b/tests/Integration/RebundleSuggestionsCommandTest.php @@ -119,3 +119,33 @@ expect(EntrySuggestion::where('user_id', $userA->id)->count())->toBe(1) ->and(EntrySuggestion::where('user_id', $userB->id)->count())->toBe(1); }); + +it('rolls back when bundling fails mid-replay', function () { + EventFacade::fake(); + $user = User::factory()->create(); + $source = Source::factory()->create(); + $bundler = app(SuggestionBundler::class); + + $activity = Activity::factory()->create([ + 'user_id' => $user->id, + 'source_id' => $source->id, + 'customer_id' => '1', + 'ticket_number' => 'PIO-12', + 'started_at' => '2026-06-04 09:00:00', + 'ended_at' => '2026-06-04 10:00:00', + ]); + $suggestion = $bundler->createNewSuggestionFor($activity); + + $this->mock(SuggestionBundler::class) + ->shouldReceive('bundle') + ->andThrow(new RuntimeException('bundling failed')); + + try { + $this->artisan('timatic:rebundle-suggestions'); + } catch (RuntimeException) { + } + + $activity->refresh(); + expect(EntrySuggestion::count())->toBe(1) + ->and($activity->entry_suggestion_id)->toBe($suggestion->id); +}); From f60a6e20bf149886a80c8d99e50fecaacff0388c Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 11:44:01 +0200 Subject: [PATCH 12/37] fix: snapshot rebundle targets inside the transaction with row locks Suggestion and activity IDs were plucked before the transaction opened, so a concurrently queued CreateSuggestion listener could attach an activity to a doomed suggestion after the snapshot but before the detach/forceDelete, orphaning it to the entry_suggestion_id ON DELETE CASCADE. Lock the suggestions with lockForUpdate() and take both snapshots inside the transaction, and detach activities by suggestion ID instead of a pre-plucked activity ID list. --- README.md | 3 ++ .../Commands/RebundleSuggestionsCommand.php | 33 +++++++++++-------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cf157bd..2aef7dd 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,9 @@ activities chronologically using the current matching rules: php artisan timatic:rebundle-suggestions [--user=1] [--from=2026-06-01] [--to=2026-06-30] ``` +Pause queue workers before running this against live data, so the `CreateSuggestion` +listener cannot attach new activities while suggestions are being rebundled. + ## License Copyright (c) 2025 Timatic. diff --git a/app/Console/Commands/RebundleSuggestionsCommand.php b/app/Console/Commands/RebundleSuggestionsCommand.php index 7f4d427..1073d16 100644 --- a/app/Console/Commands/RebundleSuggestionsCommand.php +++ b/app/Console/Commands/RebundleSuggestionsCommand.php @@ -19,20 +19,27 @@ class RebundleSuggestionsCommand extends Command public function handle(SuggestionBundler $bundler): int { - $suggestionIds = EntrySuggestion::query() - ->whereDoesntHave('entry') - ->when($this->option('user'), fn ($query, $user) => $query->where('user_id', $user)) - ->when($this->option('from'), fn ($query, $from) => $query->where('date', '>=', $from)) - ->when($this->option('to'), fn ($query, $to) => $query->where('date', '<=', $to)) - ->pluck('id'); - - $activityIds = Activity::query() - ->whereIn('entry_suggestion_id', $suggestionIds) - ->pluck('id'); - - DB::transaction(function () use ($activityIds, $suggestionIds, $bundler): void { + $suggestionIds = collect(); + $activityIds = collect(); + + DB::transaction(function () use ($bundler, &$suggestionIds, &$activityIds): void { + // Lock the targeted suggestions so a concurrently queued CreateSuggestion + // listener cannot attach a new activity to one between the snapshot below + // and the detach/delete that follows. + $suggestionIds = EntrySuggestion::query() + ->whereDoesntHave('entry') + ->when($this->option('user'), fn ($query, $user) => $query->where('user_id', $user)) + ->when($this->option('from'), fn ($query, $from) => $query->where('date', '>=', $from)) + ->when($this->option('to'), fn ($query, $to) => $query->where('date', '<=', $to)) + ->lockForUpdate() + ->pluck('id'); + + $activityIds = Activity::query() + ->whereIn('entry_suggestion_id', $suggestionIds) + ->pluck('id'); + // detach first: activities.entry_suggestion_id cascades on suggestion delete - Activity::query()->whereIn('id', $activityIds)->update(['entry_suggestion_id' => null]); + Activity::query()->whereIn('entry_suggestion_id', $suggestionIds)->update(['entry_suggestion_id' => null]); EntrySuggestion::query()->whereKey($suggestionIds)->forceDelete(); Activity::query() From 4f003f5722cc0ba970888fcdfbd6e35e539645d9 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Thu, 16 Jul 2026 11:45:38 +0200 Subject: [PATCH 13/37] fix: store suggestion date as the matched date string newSuggestionFromActivity() assigned a full Carbon datetime to EntrySuggestion::$date while findMatchingSuggestion() compares against suggestionDateFor()'s toDateString(); the match only worked because MySQL silently truncates the DATE column. Write the same date string that is used for matching, and update the model's @property annotation for date to ?string to match. --- app/Models/EntrySuggestion.php | 2 +- app/Services/SuggestionBundler.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Models/EntrySuggestion.php b/app/Models/EntrySuggestion.php index 05a1967..3d9c578 100644 --- a/app/Models/EntrySuggestion.php +++ b/app/Models/EntrySuggestion.php @@ -18,7 +18,7 @@ * @property ?string $ticket_number * @property ?string $customer_id * @property ?string $user_id - * @property ?Carbon $date + * @property ?string $date * @property ?string $ticket_title * @property ?string $ticket_type * @property ?string $customer_name diff --git a/app/Services/SuggestionBundler.php b/app/Services/SuggestionBundler.php index 44db81e..c9e6637 100644 --- a/app/Services/SuggestionBundler.php +++ b/app/Services/SuggestionBundler.php @@ -55,7 +55,7 @@ private function newSuggestionFromActivity(Activity $activity): EntrySuggestion $suggestion->ticket_type = $activity->ticket_type; $suggestion->customer_id = $activity->customer_id; $suggestion->is_internal = $activity->is_internal; - $suggestion->date = $activity->started_at->setTimezone(config('timatic.preferred_timezone')); + $suggestion->date = $this->suggestionDateFor($activity); return $suggestion; } From b91e08fbec7ea22e529a649e502b80092e129a47 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:27:57 +0200 Subject: [PATCH 14/37] feat: add Period value object with interval subtraction Claude --- app/DataTransferObjects/Period.php | 56 +++++++++++++++ tests/Unit/DataTransferObjects/PeriodTest.php | 68 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 app/DataTransferObjects/Period.php create mode 100644 tests/Unit/DataTransferObjects/PeriodTest.php diff --git a/app/DataTransferObjects/Period.php b/app/DataTransferObjects/Period.php new file mode 100644 index 0000000..210dc45 --- /dev/null +++ b/app/DataTransferObjects/Period.php @@ -0,0 +1,56 @@ +startedAt->lessThan($other->endedAt) + && $this->endedAt->greaterThan($other->startedAt); + } + + public function covers(Period $other): bool + { + return $this->startedAt->lessThanOrEqualTo($other->startedAt) + && $this->endedAt->greaterThanOrEqualTo($other->endedAt); + } + + /** + * @param Collection $blockers + * @return Collection + */ + public function subtract(Collection $blockers): Collection + { + /** @var Collection $segments */ + $segments = collect([$this]); + + foreach ($blockers->sortBy('startedAt') as $blocker) { + $segments = $segments->flatMap(function (Period $segment) use ($blocker) { + if (! $segment->overlaps($blocker)) { + return [$segment]; + } + + $remaining = []; + if ($blocker->startedAt->greaterThan($segment->startedAt)) { + $remaining[] = new Period($segment->startedAt, $blocker->startedAt); + } + if ($blocker->endedAt->lessThan($segment->endedAt)) { + $remaining[] = new Period($blocker->endedAt, $segment->endedAt); + } + + return $remaining; + }); + } + + return $segments->values(); + } +} diff --git a/tests/Unit/DataTransferObjects/PeriodTest.php b/tests/Unit/DataTransferObjects/PeriodTest.php new file mode 100644 index 0000000..4c2948a --- /dev/null +++ b/tests/Unit/DataTransferObjects/PeriodTest.php @@ -0,0 +1,68 @@ +overlaps($second))->toBeFalse() + ->and($second->overlaps($first))->toBeFalse(); +}); + +test('a period overlapping another partially is overlapping', function () { + $first = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 10:30')); + $second = new Period(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); + + expect($first->overlaps($second))->toBeTrue() + ->and($second->overlaps($first))->toBeTrue(); +}); + +test('a period covers another when it fully contains it', function () { + $outer = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); + $inner = new Period(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); + + expect($outer->covers($inner))->toBeTrue() + ->and($inner->covers($outer))->toBeFalse(); +}); + +test('subtracting a blocker in the middle splits the period in two segments', function () { + $period = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); + $blocker = new Period(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); + + $segments = $period->subtract(collect([$blocker])); + + expect($segments)->toHaveCount(2) + ->and($segments[0]->startedAt)->toEqual(Carbon::parse('2026-07-16 09:00')) + ->and($segments[0]->endedAt)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($segments[1]->startedAt)->toEqual(Carbon::parse('2026-07-16 11:00')) + ->and($segments[1]->endedAt)->toEqual(Carbon::parse('2026-07-16 12:00')); +}); + +test('subtracting a covering blocker leaves no segments', function () { + $period = new Period(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); + $blocker = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); + + expect($period->subtract(collect([$blocker])))->toBeEmpty(); +}); + +test('subtracting an overlapping blocker trims the period', function () { + $period = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); + $blocker = new Period(Carbon::parse('2026-07-16 08:00'), Carbon::parse('2026-07-16 10:00')); + + $segments = $period->subtract(collect([$blocker])); + + expect($segments)->toHaveCount(1) + ->and($segments[0]->startedAt)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($segments[0]->endedAt)->toEqual(Carbon::parse('2026-07-16 11:00')); +}); + +test('subtracting nothing returns the period itself', function () { + $period = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); + + $segments = $period->subtract(collect()); + + expect($segments)->toHaveCount(1) + ->and($segments[0])->toBe($period); +}); From abfe73d3265e69fff2363aca9963f0f492028c59 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:29:44 +0200 Subject: [PATCH 15/37] feat: chain events into activity groups in ActivityProjector Claude --- app/Services/ActivityProjector.php | 130 +++++++++ app/Services/EventGroup.php | 42 +++ tests/Unit/Services/ActivityProjectorTest.php | 270 ++++++++++++++++++ 3 files changed, 442 insertions(+) create mode 100644 app/Services/ActivityProjector.php create mode 100644 app/Services/EventGroup.php create mode 100644 tests/Unit/Services/ActivityProjectorTest.php diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php new file mode 100644 index 0000000..944e550 --- /dev/null +++ b/app/Services/ActivityProjector.php @@ -0,0 +1,130 @@ + $events + * @param Collection $entryPeriods + * @return Collection + */ + public function project(Collection $events, Collection $entryPeriods): Collection + { + return $this->chainEventsIntoGroups($events) + ->map(fn (EventGroup $group) => $this->activityFromGroup($group, $group->period(), $group->events)) + ->values(); + } + + /** + * @param Collection $events + * @return Collection + */ + private function chainEventsIntoGroups(Collection $events): Collection + { + $sorted = $events->sortBy(fn (Event $event) => $this->effectiveStart($event))->values(); + + /** @var Collection $groups */ + $groups = collect(); + /** @var Collection $unclaimed */ + $unclaimed = collect(); + + foreach ($sorted as $event) { + if ($event->customer_id === null) { + $groups->push($this->newGroup($event)); + + continue; + } + + if ($event->ticket_number === null) { + $preceding = $this->lastChainableGroup($groups, $event, + fn (EventGroup $group) => $group->customerId === $event->customer_id); + $preceding ? $preceding->add($event, $this->effectiveStart($event)) : $unclaimed->push($event); + + continue; + } + + $matching = $this->lastChainableGroup($groups, $event, + fn (EventGroup $group) => $group->customerId === $event->customer_id + && $group->ticketNumber === $event->ticket_number + && $group->eventTypeId === $event->event_type_id); + $matching ? $matching->add($event, $this->effectiveStart($event)) : $groups->push($this->newGroup($event)); + } + + foreach ($unclaimed as $event) { + $following = $groups->first(fn (EventGroup $group) => $group->customerId === $event->customer_id + && $group->startedAt->lessThanOrEqualTo($event->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES)) + && $group->endedAt->greaterThanOrEqualTo($this->effectiveStart($event))); + $following ? $following->add($event, $this->effectiveStart($event)) : $groups->push($this->newGroup($event)); + } + + return $groups; + } + + /** + * @param Collection $groups + * @param Closure(EventGroup): bool $matches + */ + private function lastChainableGroup(Collection $groups, Event $event, Closure $matches): ?EventGroup + { + $effectiveStart = $this->effectiveStart($event); + + return $groups->last(fn (EventGroup $group) => $matches($group) + && $effectiveStart->lessThanOrEqualTo($group->endedAt->copy()->addMinutes(self::CHAIN_GAP_MINUTES))); + } + + private function newGroup(Event $event): EventGroup + { + return new EventGroup( + customerId: $event->customer_id, + ticketNumber: $event->ticket_number, + eventTypeId: $event->event_type_id, + event: $event, + effectiveStart: $this->effectiveStart($event), + ); + } + + private function effectiveStart(Event $event): CarbonInterface + { + return $event->started_at ?: $event->ended_at->copy()->subMinutes(self::ESTIMATED_DURATION_MINUTES); + } + + /** + * @param Collection $events + */ + private function activityFromGroup(EventGroup $group, Period $period, Collection $events): Activity + { + /** @var Event $template */ + $template = $events->sortBy(fn (Event $event) => $this->effectiveStart($event))->first(); + + $activity = new Activity; + $activity->source_id = $template->source_id; + $activity->user_id = $template->user_id; + $activity->budget_id = $template->budget_id; + $activity->ticket_id = $template->ticket_id; + $activity->ticket_number = $group->ticketNumber; + $activity->ticket_type = $template->ticket_type; + $activity->title = $template->title; + $activity->description = $template->description; + $activity->customer_id = $group->customerId; + $activity->is_internal = $template->is_internal; + $activity->event_type_id = $group->eventTypeId; + $activity->started_at = $period->startedAt; + $activity->ended_at = $period->endedAt; + $activity->setRelation('events', $events->values()); + $activity->setRelation('eventType', $template->eventType); + + return $activity; + } +} diff --git a/app/Services/EventGroup.php b/app/Services/EventGroup.php new file mode 100644 index 0000000..0a61a74 --- /dev/null +++ b/app/Services/EventGroup.php @@ -0,0 +1,42 @@ + */ + public Collection $events; + + public CarbonInterface $startedAt; + + public CarbonInterface $endedAt; + + public function __construct( + public readonly ?string $customerId, + public readonly ?string $ticketNumber, + public readonly ?string $eventTypeId, + Event $event, + CarbonInterface $effectiveStart, + ) { + $this->events = collect([$event]); + $this->startedAt = $effectiveStart; + $this->endedAt = $event->ended_at; + } + + public function add(Event $event, CarbonInterface $effectiveStart): void + { + $this->events->push($event); + $this->startedAt = $this->startedAt->min($effectiveStart); + $this->endedAt = $this->endedAt->max($event->ended_at); + } + + public function period(): Period + { + return new Period($this->startedAt, $this->endedAt); + } +} diff --git a/tests/Unit/Services/ActivityProjectorTest.php b/tests/Unit/Services/ActivityProjectorTest.php new file mode 100644 index 0000000..4bce74a --- /dev/null +++ b/tests/Unit/Services/ActivityProjectorTest.php @@ -0,0 +1,270 @@ + 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'ended_at' => Carbon::parse('2026-07-16 10:00'), + ]); + $event->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + + $activities = (new ActivityProjector)->project(collect([$event]), collect()); + + expect($activities)->toHaveCount(1) + ->and($activities[0]->started_at)->toEqual(Carbon::parse('2026-07-16 09:45')) + ->and($activities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($activities[0]->events->all())->toBe([$event]); +}); + +test('an event with start and end becomes an activity of the same period', function () { + $event = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 10:00'), + ]); + $event->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + + $activities = (new ActivityProjector)->project(collect([$event]), collect()); + + expect($activities)->toHaveCount(1) + ->and($activities[0]->started_at)->toEqual(Carbon::parse('2026-07-16 09:00')) + ->and($activities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 10:00')); +}); + +test('same-ticket events within the chain gap merge into one activity across sources', function () { + $eventType = new EventType(['id' => 'commit_pushed', 'weight' => 1]); + $first = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'source_id' => 'bitbucket', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 09:30'), + ]); + $first->setRelation('eventType', $eventType); + $second = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'source_id' => 'jira', + 'started_at' => Carbon::parse('2026-07-16 09:40'), + 'ended_at' => Carbon::parse('2026-07-16 10:00'), + ]); + $second->setRelation('eventType', $eventType); + + $activities = (new ActivityProjector)->project(collect([$first, $second]), collect()); + + expect($activities)->toHaveCount(1) + ->and($activities[0]->started_at)->toEqual(Carbon::parse('2026-07-16 09:00')) + ->and($activities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($activities[0]->events)->toHaveCount(2); +}); + +test('same-ticket events further apart than the chain gap become separate activities', function () { + $eventType = new EventType(['id' => 'commit_pushed', 'weight' => 1]); + $first = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 09:30'), + ]); + $first->setRelation('eventType', $eventType); + $second = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:46'), + 'ended_at' => Carbon::parse('2026-07-16 10:00'), + ]); + $second->setRelation('eventType', $eventType); + + $activities = (new ActivityProjector)->project(collect([$first, $second]), collect()); + + expect($activities)->toHaveCount(2); +}); + +test('events of different customers never merge', function () { + $eventType = new EventType(['id' => 'commit_pushed', 'weight' => 1]); + $first = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 09:30'), + ]); + $first->setRelation('eventType', $eventType); + $second = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerY', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:35'), + 'ended_at' => Carbon::parse('2026-07-16 10:00'), + ]); + $second->setRelation('eventType', $eventType); + + $activities = (new ActivityProjector)->project(collect([$first, $second]), collect()); + + expect($activities)->toHaveCount(2) + ->and($activities->pluck('customer_id')->sort()->values()->all())->toBe(['customerX', 'customerY']); +}); + +test('events without customer are never combined', function () { + $eventType = new EventType(['id' => 'calendar_event_started', 'weight' => 1]); + $first = new Event([ + 'user_id' => 1, + 'customer_id' => null, + 'ticket_number' => null, + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 09:15'), + ]); + $first->setRelation('eventType', $eventType); + $second = new Event([ + 'user_id' => 1, + 'customer_id' => null, + 'ticket_number' => null, + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 09:15'), + 'ended_at' => Carbon::parse('2026-07-16 09:30'), + ]); + $second->setRelation('eventType', $eventType); + + $activities = (new ActivityProjector)->project(collect([$first, $second]), collect()); + + expect($activities)->toHaveCount(2); +}); + +test('a null-ticket event glues onto the preceding group of the same customer even with another event type', function () { + $ticketed = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 09:30'), + ]); + $ticketed->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + $ticketless = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => null, + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 09:35'), + 'ended_at' => Carbon::parse('2026-07-16 09:50'), + ]); + $ticketless->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 1])); + + $activities = (new ActivityProjector)->project(collect([$ticketed, $ticketless]), collect()); + + expect($activities)->toHaveCount(1) + ->and($activities[0]->ticket_number)->toBe('TIC-1') + ->and($activities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 09:50')) + ->and($activities[0]->events)->toHaveCount(2); +}); + +test('a null-ticket event without preceding group is claimed by a following group of the same customer', function () { + $eventType = new EventType(['id' => 'commit_pushed', 'weight' => 1]); + $ticketless = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => null, + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 09:20'), + ]); + $ticketless->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 1])); + $ticketed = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:30'), + 'ended_at' => Carbon::parse('2026-07-16 10:00'), + ]); + $ticketed->setRelation('eventType', $eventType); + + $activities = (new ActivityProjector)->project(collect([$ticketless, $ticketed]), collect()); + + expect($activities)->toHaveCount(1) + ->and($activities[0]->ticket_number)->toBe('TIC-1') + ->and($activities[0]->started_at)->toEqual(Carbon::parse('2026-07-16 09:00')); +}); + +test('a null-ticket event between two groups goes to the preceding one', function () { + $eventType = new EventType(['id' => 'commit_pushed', 'weight' => 1]); + $preceding = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 09:30'), + ]); + $preceding->setRelation('eventType', $eventType); + $ticketless = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => null, + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:35'), + 'ended_at' => Carbon::parse('2026-07-16 09:45'), + ]); + $ticketless->setRelation('eventType', $eventType); + $following = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-2', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:50'), + 'ended_at' => Carbon::parse('2026-07-16 10:30'), + ]); + $following->setRelation('eventType', $eventType); + + $activities = (new ActivityProjector)->project(collect([$preceding, $ticketless, $following]), collect()); + + $ticketOne = $activities->first(fn ($activity) => $activity->ticket_number === 'TIC-1'); + expect($activities)->toHaveCount(2) + ->and($ticketOne->events)->toHaveCount(2); +}); + +test('a null-ticket event of another customer is not glued', function () { + $ticketed = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 09:30'), + ]); + $ticketed->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + $ticketless = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerY', + 'ticket_number' => null, + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:35'), + 'ended_at' => Carbon::parse('2026-07-16 09:45'), + ]); + $ticketless->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + + $activities = (new ActivityProjector)->project(collect([$ticketed, $ticketless]), collect()); + + expect($activities)->toHaveCount(2); +}); From 7d6c93a6682804f1c3303fbf58a9627d00e4059b Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:31:40 +0200 Subject: [PATCH 16/37] feat: resolve overlapping activity groups by event type weight Claude --- app/Services/ActivityProjector.php | 78 +++++++- tests/Unit/Services/ActivityProjectorTest.php | 177 ++++++++++++++++++ 2 files changed, 252 insertions(+), 3 deletions(-) diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php index 944e550..51a9fa6 100644 --- a/app/Services/ActivityProjector.php +++ b/app/Services/ActivityProjector.php @@ -22,9 +22,9 @@ class ActivityProjector */ public function project(Collection $events, Collection $entryPeriods): Collection { - return $this->chainEventsIntoGroups($events) - ->map(fn (EventGroup $group) => $this->activityFromGroup($group, $group->period(), $group->events)) - ->values(); + $groups = $this->chainEventsIntoGroups($events); + + return $this->resolveWeightDominance($groups)->values(); } /** @@ -127,4 +127,76 @@ private function activityFromGroup(EventGroup $group, Period $period, Collection return $activity; } + + /** + * @param Collection $groups + * @return Collection + */ + private function resolveWeightDominance(Collection $groups): Collection + { + $ranked = $groups->sortBy([ + fn (EventGroup $a, EventGroup $b) => $this->weight($b) <=> $this->weight($a), + fn (EventGroup $a, EventGroup $b) => $a->startedAt <=> $b->startedAt, + ])->values(); + + /** @var Collection $accepted */ + $accepted = collect(); + + foreach ($ranked as $group) { + $blockers = $accepted->map(fn (Activity $activity) => new Period($activity->started_at, $activity->ended_at)); + $segments = $group->period()->subtract($blockers); + + $accepted = $accepted->concat($this->activitiesFromSegments($group, $segments, $accepted)); + } + + return $accepted; + } + + /** + * @param Collection $segments + * @param Collection $coveringCandidates + * @return Collection + */ + private function activitiesFromSegments(EventGroup $group, Collection $segments, Collection $coveringCandidates): Collection + { + /** @var Collection $activities */ + $activities = collect(); + $remaining = $group->events; + + foreach ($segments as $segment) { + [$segmentEvents, $remaining] = $remaining->partition(fn (Event $event) => $this->eventPeriod($event)->overlaps($segment)); + + if ($segmentEvents->isEmpty()) { + continue; + } + + $activities->push($this->activityFromGroup($group, $segment, $segmentEvents->values())); + } + + $remaining->each(fn (Event $event) => $this->attachToCoveringActivity($event, $group, $coveringCandidates)); + + return $activities; + } + + /** + * @param Collection $candidates + */ + private function attachToCoveringActivity(Event $event, EventGroup $group, Collection $candidates): void + { + $covering = $candidates->first(fn (Activity $activity) => $activity->customer_id === $group->customerId + && $activity->ticket_number === $group->ticketNumber + && (new Period($activity->started_at, $activity->ended_at))->covers($this->eventPeriod($event))); + + $covering?->events->push($event); + } + + private function eventPeriod(Event $event): Period + { + return new Period($this->effectiveStart($event), $event->ended_at); + } + + private function weight(EventGroup $group): int + { + return (int) $group->events->first()?->eventType?->weight; + } } diff --git a/tests/Unit/Services/ActivityProjectorTest.php b/tests/Unit/Services/ActivityProjectorTest.php index 4bce74a..4bf698c 100644 --- a/tests/Unit/Services/ActivityProjectorTest.php +++ b/tests/Unit/Services/ActivityProjectorTest.php @@ -268,3 +268,180 @@ expect($activities)->toHaveCount(2); }); + +test('the higher-weight group keeps its period and the lower one is trimmed', function () { + $light = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 00:10'), + 'ended_at' => Carbon::parse('2026-07-16 00:20'), + ]); + $light->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + $heavy = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerY', + 'ticket_number' => 'TIC-2', + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 00:05'), + 'ended_at' => Carbon::parse('2026-07-16 00:15'), + ]); + $heavy->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 999])); + + $activities = (new ActivityProjector)->project(collect([$light, $heavy]), collect()); + + $heavyActivity = $activities->first(fn ($activity) => $activity->ticket_number === 'TIC-2'); + $lightActivity = $activities->first(fn ($activity) => $activity->ticket_number === 'TIC-1'); + expect($activities)->toHaveCount(2) + ->and($heavyActivity->started_at)->toEqual(Carbon::parse('2026-07-16 00:05')) + ->and($heavyActivity->ended_at)->toEqual(Carbon::parse('2026-07-16 00:15')) + ->and($lightActivity->started_at)->toEqual(Carbon::parse('2026-07-16 00:15')) + ->and($lightActivity->ended_at)->toEqual(Carbon::parse('2026-07-16 00:20')); +}); + +test('a group fully covered by a matching dominant group attaches its events to the covering activity', function () { + $covering = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 10:00'), + 'ended_at' => Carbon::parse('2026-07-16 10:30'), + ]); + $covering->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 999])); + $covered = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 10:05'), + 'ended_at' => Carbon::parse('2026-07-16 10:10'), + ]); + $covered->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + + $activities = (new ActivityProjector)->project(collect([$covering, $covered]), collect()); + + expect($activities)->toHaveCount(1) + ->and($activities[0]->events)->toHaveCount(2); +}); + +test('a covered group of another customer stays unattached instead of mixing customers', function () { + $covering = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 10:00'), + 'ended_at' => Carbon::parse('2026-07-16 10:30'), + ]); + $covering->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 999])); + $covered = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerY', + 'ticket_number' => 'TIC-2', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 10:05'), + 'ended_at' => Carbon::parse('2026-07-16 10:10'), + ]); + $covered->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + + $activities = (new ActivityProjector)->project(collect([$covering, $covered]), collect()); + + expect($activities)->toHaveCount(1) + ->and($activities[0]->events)->toHaveCount(1) + ->and($activities[0]->events->first())->toBe($covering); +}); + +test('on equal weight the earlier-starting group is dominant', function () { + $earlier = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 10:00'), + ]); + $earlier->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 5])); + $later = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerY', + 'ticket_number' => 'TIC-2', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:30'), + 'ended_at' => Carbon::parse('2026-07-16 10:30'), + ]); + $later->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 5])); + + $activities = (new ActivityProjector)->project(collect([$earlier, $later]), collect()); + + $earlierActivity = $activities->first(fn ($activity) => $activity->ticket_number === 'TIC-1'); + $laterActivity = $activities->first(fn ($activity) => $activity->ticket_number === 'TIC-2'); + expect($earlierActivity->ended_at)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($laterActivity->started_at)->toEqual(Carbon::parse('2026-07-16 10:00')); +}); + +test('a subordinate group starts after the latest dominant overlapping group', function () { + $heavyType = new EventType(['id' => 'calendar_event_started', 'weight' => 999]); + $firstDominant = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 10:30'), + ]); + $firstDominant->setRelation('eventType', $heavyType); + $secondDominant = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerY', + 'ticket_number' => 'TIC-2', + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 10:00'), + 'ended_at' => Carbon::parse('2026-07-16 10:20'), + ]); + $secondDominant->setRelation('eventType', $heavyType); + $subordinate = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerZ', + 'ticket_number' => 'TIC-3', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 10:15'), + 'ended_at' => Carbon::parse('2026-07-16 11:00'), + ]); + $subordinate->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 5])); + + $activities = (new ActivityProjector)->project(collect([$firstDominant, $secondDominant, $subordinate]), collect()); + + $subordinateActivity = $activities->first(fn ($activity) => $activity->ticket_number === 'TIC-3'); + expect($subordinateActivity->started_at)->toEqual(Carbon::parse('2026-07-16 10:30')); +}); + +test('shuffled input produces the same activities as chronological input', function () { + $makeEvents = function () { + $eventType = new EventType(['id' => 'commit_pushed', 'weight' => 1]); + $events = []; + foreach ([['09:00', '09:20', 'TIC-1'], ['09:25', '09:40', 'TIC-1'], ['10:30', '11:00', 'TIC-2']] as [$start, $end, $ticket]) { + $event = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => $ticket, + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse("2026-07-16 $start"), + 'ended_at' => Carbon::parse("2026-07-16 $end"), + ]); + $event->setRelation('eventType', $eventType); + $events[] = $event; + } + + return $events; + }; + + $chronological = (new ActivityProjector)->project(collect($makeEvents()), collect()); + $shuffled = (new ActivityProjector)->project(collect(array_reverse($makeEvents())), collect()); + + $signature = fn ($activities) => $activities + ->map(fn ($activity) => $activity->ticket_number.'|'.$activity->started_at.'|'.$activity->ended_at.'|'.$activity->events->count()) + ->sort()->values()->all(); + expect($signature($shuffled))->toBe($signature($chronological)); +}); From 3437790ccd2e8991762c2bddbcdf4604bd9ba089 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:32:40 +0200 Subject: [PATCH 17/37] feat: trim projected activities around booked entry periods Claude --- app/Services/ActivityProjector.php | 51 +++++++++++++- tests/Unit/Services/ActivityProjectorTest.php | 68 +++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php index 51a9fa6..93e95fb 100644 --- a/app/Services/ActivityProjector.php +++ b/app/Services/ActivityProjector.php @@ -23,8 +23,9 @@ class ActivityProjector public function project(Collection $events, Collection $entryPeriods): Collection { $groups = $this->chainEventsIntoGroups($events); + $activities = $this->resolveWeightDominance($groups); - return $this->resolveWeightDominance($groups)->values(); + return $this->trimAroundEntryPeriods($activities, $entryPeriods)->values(); } /** @@ -199,4 +200,52 @@ private function weight(EventGroup $group): int { return (int) $group->events->first()?->eventType?->weight; } + + /** + * @param Collection $activities + * @param Collection $entryPeriods + * @return Collection + */ + private function trimAroundEntryPeriods(Collection $activities, Collection $entryPeriods): Collection + { + if ($entryPeriods->isEmpty()) { + return $activities; + } + + return $activities->flatMap(function (Activity $activity) use ($entryPeriods) { + $segments = (new Period($activity->started_at, $activity->ended_at))->subtract($entryPeriods); + + if ($segments->count() === 1 && $segments[0]->startedAt->equalTo($activity->started_at) && $segments[0]->endedAt->equalTo($activity->ended_at)) { + return [$activity]; + } + + $splits = []; + $remaining = $activity->events; + foreach ($segments as $segment) { + [$segmentEvents, $remaining] = $remaining->partition(fn (Event $event) => $this->eventPeriod($event)->overlaps($segment)); + + if ($segmentEvents->isEmpty()) { + continue; + } + + $splits[] = $this->cloneActivityForSegment($activity, $segment, $segmentEvents->values()); + } + + return $splits; + }); + } + + /** + * @param Collection $events + */ + private function cloneActivityForSegment(Activity $activity, Period $segment, Collection $events): Activity + { + $split = $activity->replicate(['started_at', 'ended_at']); + $split->started_at = $segment->startedAt; + $split->ended_at = $segment->endedAt; + $split->setRelation('events', $events); + $split->setRelation('eventType', $activity->eventType); + + return $split; + } } diff --git a/tests/Unit/Services/ActivityProjectorTest.php b/tests/Unit/Services/ActivityProjectorTest.php index 4bf698c..82e856c 100644 --- a/tests/Unit/Services/ActivityProjectorTest.php +++ b/tests/Unit/Services/ActivityProjectorTest.php @@ -1,5 +1,6 @@ sort()->values()->all(); expect($signature($shuffled))->toBe($signature($chronological)); }); + +test('an activity partially overlapping an entry period is trimmed to the unbooked part', function () { + $event = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 12:00'), + ]); + $event->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + $entry = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 10:00')); + + $activities = (new ActivityProjector)->project(collect([$event]), collect([$entry])); + + expect($activities)->toHaveCount(1) + ->and($activities[0]->started_at)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($activities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 12:00')); +}); + +test('an entry period inside an activity splits it into two activities', function () { + $eventType = new EventType(['id' => 'commit_pushed', 'weight' => 1]); + $morning = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 09:50'), + ]); + $morning->setRelation('eventType', $eventType); + $noon = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 10:00'), + 'ended_at' => Carbon::parse('2026-07-16 12:00'), + ]); + $noon->setRelation('eventType', $eventType); + $entry = new Period(Carbon::parse('2026-07-16 09:50'), Carbon::parse('2026-07-16 10:00')); + + $activities = (new ActivityProjector)->project(collect([$morning, $noon]), collect([$entry])); + + expect($activities)->toHaveCount(2) + ->and($activities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 09:50')) + ->and($activities[0]->events->all())->toBe([$morning]) + ->and($activities[1]->started_at)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($activities[1]->events->all())->toBe([$noon]); +}); + +test('an activity fully inside entry periods is not created', function () { + $event = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 10:00'), + 'ended_at' => Carbon::parse('2026-07-16 10:30'), + ]); + $event->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + $entry = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); + + $activities = (new ActivityProjector)->project(collect([$event]), collect([$entry])); + + expect($activities)->toBeEmpty(); +}); From 7c22c58818465791511a4a5d616155c0e8a68ef1 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:35:20 +0200 Subject: [PATCH 18/37] refactor: rename Period DTO to TimeSlot to avoid confusion with Budget Period model Claude --- app/DataTransferObjects/TimeSlot.php | 56 +++++++++++++++ app/Services/ActivityProjector.php | 22 +++--- app/Services/EventGroup.php | 6 +- .../Unit/DataTransferObjects/TimeSlotTest.php | 68 +++++++++++++++++++ tests/Unit/Services/ActivityProjectorTest.php | 8 +-- 5 files changed, 142 insertions(+), 18 deletions(-) create mode 100644 app/DataTransferObjects/TimeSlot.php create mode 100644 tests/Unit/DataTransferObjects/TimeSlotTest.php diff --git a/app/DataTransferObjects/TimeSlot.php b/app/DataTransferObjects/TimeSlot.php new file mode 100644 index 0000000..51a13c4 --- /dev/null +++ b/app/DataTransferObjects/TimeSlot.php @@ -0,0 +1,56 @@ +startedAt->lessThan($other->endedAt) + && $this->endedAt->greaterThan($other->startedAt); + } + + public function covers(TimeSlot $other): bool + { + return $this->startedAt->lessThanOrEqualTo($other->startedAt) + && $this->endedAt->greaterThanOrEqualTo($other->endedAt); + } + + /** + * @param Collection $blockers + * @return Collection + */ + public function subtract(Collection $blockers): Collection + { + /** @var Collection $segments */ + $segments = collect([$this]); + + foreach ($blockers->sortBy('startedAt') as $blocker) { + $segments = $segments->flatMap(function (TimeSlot $segment) use ($blocker) { + if (! $segment->overlaps($blocker)) { + return [$segment]; + } + + $remaining = []; + if ($blocker->startedAt->greaterThan($segment->startedAt)) { + $remaining[] = new TimeSlot($segment->startedAt, $blocker->startedAt); + } + if ($blocker->endedAt->lessThan($segment->endedAt)) { + $remaining[] = new TimeSlot($blocker->endedAt, $segment->endedAt); + } + + return $remaining; + }); + } + + return $segments->values(); + } +} diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php index 93e95fb..c665018 100644 --- a/app/Services/ActivityProjector.php +++ b/app/Services/ActivityProjector.php @@ -2,7 +2,7 @@ namespace App\Services; -use App\DataTransferObjects\Period; +use App\DataTransferObjects\TimeSlot; use App\Models\Activity; use App\Models\Event; use Carbon\CarbonInterface; @@ -17,7 +17,7 @@ class ActivityProjector /** * @param Collection $events - * @param Collection $entryPeriods + * @param Collection $entryPeriods * @return Collection */ public function project(Collection $events, Collection $entryPeriods): Collection @@ -104,7 +104,7 @@ private function effectiveStart(Event $event): CarbonInterface /** * @param Collection $events */ - private function activityFromGroup(EventGroup $group, Period $period, Collection $events): Activity + private function activityFromGroup(EventGroup $group, TimeSlot $period, Collection $events): Activity { /** @var Event $template */ $template = $events->sortBy(fn (Event $event) => $this->effectiveStart($event))->first(); @@ -144,7 +144,7 @@ private function resolveWeightDominance(Collection $groups): Collection $accepted = collect(); foreach ($ranked as $group) { - $blockers = $accepted->map(fn (Activity $activity) => new Period($activity->started_at, $activity->ended_at)); + $blockers = $accepted->map(fn (Activity $activity) => new TimeSlot($activity->started_at, $activity->ended_at)); $segments = $group->period()->subtract($blockers); $accepted = $accepted->concat($this->activitiesFromSegments($group, $segments, $accepted)); @@ -154,7 +154,7 @@ private function resolveWeightDominance(Collection $groups): Collection } /** - * @param Collection $segments + * @param Collection $segments * @param Collection $coveringCandidates * @return Collection */ @@ -186,14 +186,14 @@ private function attachToCoveringActivity(Event $event, EventGroup $group, Colle { $covering = $candidates->first(fn (Activity $activity) => $activity->customer_id === $group->customerId && $activity->ticket_number === $group->ticketNumber - && (new Period($activity->started_at, $activity->ended_at))->covers($this->eventPeriod($event))); + && (new TimeSlot($activity->started_at, $activity->ended_at))->covers($this->eventPeriod($event))); $covering?->events->push($event); } - private function eventPeriod(Event $event): Period + private function eventPeriod(Event $event): TimeSlot { - return new Period($this->effectiveStart($event), $event->ended_at); + return new TimeSlot($this->effectiveStart($event), $event->ended_at); } private function weight(EventGroup $group): int @@ -203,7 +203,7 @@ private function weight(EventGroup $group): int /** * @param Collection $activities - * @param Collection $entryPeriods + * @param Collection $entryPeriods * @return Collection */ private function trimAroundEntryPeriods(Collection $activities, Collection $entryPeriods): Collection @@ -213,7 +213,7 @@ private function trimAroundEntryPeriods(Collection $activities, Collection $entr } return $activities->flatMap(function (Activity $activity) use ($entryPeriods) { - $segments = (new Period($activity->started_at, $activity->ended_at))->subtract($entryPeriods); + $segments = (new TimeSlot($activity->started_at, $activity->ended_at))->subtract($entryPeriods); if ($segments->count() === 1 && $segments[0]->startedAt->equalTo($activity->started_at) && $segments[0]->endedAt->equalTo($activity->ended_at)) { return [$activity]; @@ -238,7 +238,7 @@ private function trimAroundEntryPeriods(Collection $activities, Collection $entr /** * @param Collection $events */ - private function cloneActivityForSegment(Activity $activity, Period $segment, Collection $events): Activity + private function cloneActivityForSegment(Activity $activity, TimeSlot $segment, Collection $events): Activity { $split = $activity->replicate(['started_at', 'ended_at']); $split->started_at = $segment->startedAt; diff --git a/app/Services/EventGroup.php b/app/Services/EventGroup.php index 0a61a74..ab4b328 100644 --- a/app/Services/EventGroup.php +++ b/app/Services/EventGroup.php @@ -2,7 +2,7 @@ namespace App\Services; -use App\DataTransferObjects\Period; +use App\DataTransferObjects\TimeSlot; use App\Models\Event; use Carbon\CarbonInterface; use Illuminate\Support\Collection; @@ -35,8 +35,8 @@ public function add(Event $event, CarbonInterface $effectiveStart): void $this->endedAt = $this->endedAt->max($event->ended_at); } - public function period(): Period + public function period(): TimeSlot { - return new Period($this->startedAt, $this->endedAt); + return new TimeSlot($this->startedAt, $this->endedAt); } } diff --git a/tests/Unit/DataTransferObjects/TimeSlotTest.php b/tests/Unit/DataTransferObjects/TimeSlotTest.php new file mode 100644 index 0000000..098bf9d --- /dev/null +++ b/tests/Unit/DataTransferObjects/TimeSlotTest.php @@ -0,0 +1,68 @@ +overlaps($second))->toBeFalse() + ->and($second->overlaps($first))->toBeFalse(); +}); + +test('a period overlapping another partially is overlapping', function () { + $first = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 10:30')); + $second = new TimeSlot(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); + + expect($first->overlaps($second))->toBeTrue() + ->and($second->overlaps($first))->toBeTrue(); +}); + +test('a period covers another when it fully contains it', function () { + $outer = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); + $inner = new TimeSlot(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); + + expect($outer->covers($inner))->toBeTrue() + ->and($inner->covers($outer))->toBeFalse(); +}); + +test('subtracting a blocker in the middle splits the period in two segments', function () { + $period = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); + $blocker = new TimeSlot(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); + + $segments = $period->subtract(collect([$blocker])); + + expect($segments)->toHaveCount(2) + ->and($segments[0]->startedAt)->toEqual(Carbon::parse('2026-07-16 09:00')) + ->and($segments[0]->endedAt)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($segments[1]->startedAt)->toEqual(Carbon::parse('2026-07-16 11:00')) + ->and($segments[1]->endedAt)->toEqual(Carbon::parse('2026-07-16 12:00')); +}); + +test('subtracting a covering blocker leaves no segments', function () { + $period = new TimeSlot(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); + $blocker = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); + + expect($period->subtract(collect([$blocker])))->toBeEmpty(); +}); + +test('subtracting an overlapping blocker trims the period', function () { + $period = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); + $blocker = new TimeSlot(Carbon::parse('2026-07-16 08:00'), Carbon::parse('2026-07-16 10:00')); + + $segments = $period->subtract(collect([$blocker])); + + expect($segments)->toHaveCount(1) + ->and($segments[0]->startedAt)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($segments[0]->endedAt)->toEqual(Carbon::parse('2026-07-16 11:00')); +}); + +test('subtracting nothing returns the period itself', function () { + $period = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); + + $segments = $period->subtract(collect()); + + expect($segments)->toHaveCount(1) + ->and($segments[0])->toBe($period); +}); diff --git a/tests/Unit/Services/ActivityProjectorTest.php b/tests/Unit/Services/ActivityProjectorTest.php index 82e856c..d03645d 100644 --- a/tests/Unit/Services/ActivityProjectorTest.php +++ b/tests/Unit/Services/ActivityProjectorTest.php @@ -1,6 +1,6 @@ Carbon::parse('2026-07-16 12:00'), ]); $event->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); - $entry = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 10:00')); + $entry = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 10:00')); $activities = (new ActivityProjector)->project(collect([$event]), collect([$entry])); @@ -486,7 +486,7 @@ 'ended_at' => Carbon::parse('2026-07-16 12:00'), ]); $noon->setRelation('eventType', $eventType); - $entry = new Period(Carbon::parse('2026-07-16 09:50'), Carbon::parse('2026-07-16 10:00')); + $entry = new TimeSlot(Carbon::parse('2026-07-16 09:50'), Carbon::parse('2026-07-16 10:00')); $activities = (new ActivityProjector)->project(collect([$morning, $noon]), collect([$entry])); @@ -507,7 +507,7 @@ 'ended_at' => Carbon::parse('2026-07-16 10:30'), ]); $event->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); - $entry = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); + $entry = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); $activities = (new ActivityProjector)->project(collect([$event]), collect([$entry])); From 47beb17996d9ef7d40dfee874880aa2f9fb4daf3 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:35:25 +0200 Subject: [PATCH 19/37] feat: project activities into entry suggestions on the bundler key Claude --- app/DataTransferObjects/Period.php | 56 ------ app/Services/SuggestionProjector.php | 68 +++++++ tests/Unit/DataTransferObjects/PeriodTest.php | 68 ------- .../Unit/Services/SuggestionProjectorTest.php | 170 ++++++++++++++++++ 4 files changed, 238 insertions(+), 124 deletions(-) delete mode 100644 app/DataTransferObjects/Period.php create mode 100644 app/Services/SuggestionProjector.php delete mode 100644 tests/Unit/DataTransferObjects/PeriodTest.php create mode 100644 tests/Unit/Services/SuggestionProjectorTest.php diff --git a/app/DataTransferObjects/Period.php b/app/DataTransferObjects/Period.php deleted file mode 100644 index 210dc45..0000000 --- a/app/DataTransferObjects/Period.php +++ /dev/null @@ -1,56 +0,0 @@ -startedAt->lessThan($other->endedAt) - && $this->endedAt->greaterThan($other->startedAt); - } - - public function covers(Period $other): bool - { - return $this->startedAt->lessThanOrEqualTo($other->startedAt) - && $this->endedAt->greaterThanOrEqualTo($other->endedAt); - } - - /** - * @param Collection $blockers - * @return Collection - */ - public function subtract(Collection $blockers): Collection - { - /** @var Collection $segments */ - $segments = collect([$this]); - - foreach ($blockers->sortBy('startedAt') as $blocker) { - $segments = $segments->flatMap(function (Period $segment) use ($blocker) { - if (! $segment->overlaps($blocker)) { - return [$segment]; - } - - $remaining = []; - if ($blocker->startedAt->greaterThan($segment->startedAt)) { - $remaining[] = new Period($segment->startedAt, $blocker->startedAt); - } - if ($blocker->endedAt->lessThan($segment->endedAt)) { - $remaining[] = new Period($blocker->endedAt, $segment->endedAt); - } - - return $remaining; - }); - } - - return $segments->values(); - } -} diff --git a/app/Services/SuggestionProjector.php b/app/Services/SuggestionProjector.php new file mode 100644 index 0000000..fedd417 --- /dev/null +++ b/app/Services/SuggestionProjector.php @@ -0,0 +1,68 @@ + $activities + * @param Collection $dismissedSuggestions + * @return Collection + */ + public function project(Collection $activities, Collection $dismissedSuggestions, CarbonInterface $date): Collection + { + return $activities + ->groupBy(fn (Activity $activity) => $this->groupKey($activity)) + ->reject(fn (Collection $group) => $this->isDismissed($group->first(), $dismissedSuggestions)) + ->map(fn (Collection $group) => $this->suggestionFromActivities($group, $date)) + ->values(); + } + + private function groupKey(Activity $activity): string + { + return implode('|', [ + $activity->customer_id ?? '', + (string) $activity->budget_id, + $activity->ticket_number ?? '', + $activity->is_internal === null ? '' : (string) (int) $activity->is_internal, + ]); + } + + /** + * @param Collection $dismissedSuggestions + */ + private function isDismissed(Activity $activity, Collection $dismissedSuggestions): bool + { + return $dismissedSuggestions->contains(fn (EntrySuggestion $dismissed) => $dismissed->customer_id === $activity->customer_id + && $dismissed->budget_id === $activity->budget_id + && $dismissed->ticket_number === $activity->ticket_number + && $dismissed->is_internal === $activity->is_internal); + } + + /** + * @param Collection $activities + */ + private function suggestionFromActivities(Collection $activities, CarbonInterface $date): EntrySuggestion + { + /** @var Activity $template */ + $template = $activities->sortBy('started_at')->first(); + + $suggestion = new EntrySuggestion; + $suggestion->user_id = $template->user_id; + $suggestion->budget_id = $template->budget_id; + $suggestion->ticket_id = $template->ticket_id; + $suggestion->ticket_number = $template->ticket_number; + $suggestion->ticket_type = $template->ticket_type; + $suggestion->customer_id = $template->customer_id; + $suggestion->is_internal = $template->is_internal; + $suggestion->date = $date->toDateString(); + $suggestion->setRelation('activities', $activities->values()); + + return $suggestion; + } +} diff --git a/tests/Unit/DataTransferObjects/PeriodTest.php b/tests/Unit/DataTransferObjects/PeriodTest.php deleted file mode 100644 index 4c2948a..0000000 --- a/tests/Unit/DataTransferObjects/PeriodTest.php +++ /dev/null @@ -1,68 +0,0 @@ -overlaps($second))->toBeFalse() - ->and($second->overlaps($first))->toBeFalse(); -}); - -test('a period overlapping another partially is overlapping', function () { - $first = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 10:30')); - $second = new Period(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); - - expect($first->overlaps($second))->toBeTrue() - ->and($second->overlaps($first))->toBeTrue(); -}); - -test('a period covers another when it fully contains it', function () { - $outer = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); - $inner = new Period(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); - - expect($outer->covers($inner))->toBeTrue() - ->and($inner->covers($outer))->toBeFalse(); -}); - -test('subtracting a blocker in the middle splits the period in two segments', function () { - $period = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); - $blocker = new Period(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); - - $segments = $period->subtract(collect([$blocker])); - - expect($segments)->toHaveCount(2) - ->and($segments[0]->startedAt)->toEqual(Carbon::parse('2026-07-16 09:00')) - ->and($segments[0]->endedAt)->toEqual(Carbon::parse('2026-07-16 10:00')) - ->and($segments[1]->startedAt)->toEqual(Carbon::parse('2026-07-16 11:00')) - ->and($segments[1]->endedAt)->toEqual(Carbon::parse('2026-07-16 12:00')); -}); - -test('subtracting a covering blocker leaves no segments', function () { - $period = new Period(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); - $blocker = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); - - expect($period->subtract(collect([$blocker])))->toBeEmpty(); -}); - -test('subtracting an overlapping blocker trims the period', function () { - $period = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); - $blocker = new Period(Carbon::parse('2026-07-16 08:00'), Carbon::parse('2026-07-16 10:00')); - - $segments = $period->subtract(collect([$blocker])); - - expect($segments)->toHaveCount(1) - ->and($segments[0]->startedAt)->toEqual(Carbon::parse('2026-07-16 10:00')) - ->and($segments[0]->endedAt)->toEqual(Carbon::parse('2026-07-16 11:00')); -}); - -test('subtracting nothing returns the period itself', function () { - $period = new Period(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); - - $segments = $period->subtract(collect()); - - expect($segments)->toHaveCount(1) - ->and($segments[0])->toBe($period); -}); diff --git a/tests/Unit/Services/SuggestionProjectorTest.php b/tests/Unit/Services/SuggestionProjectorTest.php new file mode 100644 index 0000000..ef026ea --- /dev/null +++ b/tests/Unit/Services/SuggestionProjectorTest.php @@ -0,0 +1,170 @@ +user_id = 1; + $first->customer_id = 'customerX'; + $first->budget_id = 7; + $first->ticket_number = 'TIC-1'; + $first->ticket_id = 'uuid-1'; + $first->ticket_type = 'incident'; + $first->is_internal = false; + $first->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $first->ended_at = Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'); + $second = new Activity; + $second->user_id = 1; + $second->customer_id = 'customerX'; + $second->budget_id = 7; + $second->ticket_number = 'TIC-1'; + $second->ticket_id = 'uuid-1'; + $second->ticket_type = 'incident'; + $second->is_internal = false; + $second->started_at = Carbon::parse('2026-07-16 14:00', 'Europe/Amsterdam'); + $second->ended_at = Carbon::parse('2026-07-16 15:00', 'Europe/Amsterdam'); + + $suggestions = (new SuggestionProjector)->project( + collect([$first, $second]), + collect(), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toHaveCount(1) + ->and($suggestions[0]->activities)->toHaveCount(2) + ->and($suggestions[0]->ticket_number)->toBe('TIC-1') + ->and($suggestions[0]->customer_id)->toBe('customerX') + ->and($suggestions[0]->date)->toBe('2026-07-16'); +}); + +test('activities on different budgets get separate suggestions', function () { + $first = new Activity; + $first->user_id = 1; + $first->customer_id = 'customerX'; + $first->budget_id = 7; + $first->ticket_number = 'TIC-1'; + $first->is_internal = false; + $first->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $first->ended_at = Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'); + $second = new Activity; + $second->user_id = 1; + $second->customer_id = 'customerX'; + $second->budget_id = 8; + $second->ticket_number = 'TIC-1'; + $second->is_internal = false; + $second->started_at = Carbon::parse('2026-07-16 14:00', 'Europe/Amsterdam'); + $second->ended_at = Carbon::parse('2026-07-16 15:00', 'Europe/Amsterdam'); + + $suggestions = (new SuggestionProjector)->project( + collect([$first, $second]), + collect(), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toHaveCount(2); +}); + +test('internal and external activities on the same ticket get separate suggestions', function () { + $internal = new Activity; + $internal->user_id = 1; + $internal->customer_id = 'customerX'; + $internal->budget_id = 7; + $internal->ticket_number = 'TIC-1'; + $internal->is_internal = true; + $internal->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $internal->ended_at = Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'); + $external = new Activity; + $external->user_id = 1; + $external->customer_id = 'customerX'; + $external->budget_id = 7; + $external->ticket_number = 'TIC-1'; + $external->is_internal = false; + $external->started_at = Carbon::parse('2026-07-16 14:00', 'Europe/Amsterdam'); + $external->ended_at = Carbon::parse('2026-07-16 15:00', 'Europe/Amsterdam'); + + $suggestions = (new SuggestionProjector)->project( + collect([$internal, $external]), + collect(), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toHaveCount(2); +}); + +test('a null-ticket activity keeps its own suggestion next to a ticketed one', function () { + $ticketless = new Activity; + $ticketless->user_id = 1; + $ticketless->customer_id = 'customerX'; + $ticketless->budget_id = 7; + $ticketless->ticket_number = null; + $ticketless->is_internal = false; + $ticketless->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $ticketless->ended_at = Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'); + $ticketed = new Activity; + $ticketed->user_id = 1; + $ticketed->customer_id = 'customerX'; + $ticketed->budget_id = 7; + $ticketed->ticket_number = 'TIC-1'; + $ticketed->is_internal = false; + $ticketed->started_at = Carbon::parse('2026-07-16 14:00', 'Europe/Amsterdam'); + $ticketed->ended_at = Carbon::parse('2026-07-16 15:00', 'Europe/Amsterdam'); + + $suggestions = (new SuggestionProjector)->project( + collect([$ticketless, $ticketed]), + collect(), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toHaveCount(2); +}); + +test('no suggestion is projected when a dismissed suggestion matches the group key', function () { + $activity = new Activity; + $activity->user_id = 1; + $activity->customer_id = 'customerX'; + $activity->budget_id = 7; + $activity->ticket_number = 'TIC-1'; + $activity->is_internal = false; + $activity->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $activity->ended_at = Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'); + $dismissed = new EntrySuggestion; + $dismissed->customer_id = 'customerX'; + $dismissed->budget_id = 7; + $dismissed->ticket_number = 'TIC-1'; + $dismissed->is_internal = false; + + $suggestions = (new SuggestionProjector)->project( + collect([$activity]), + collect([$dismissed]), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toBeEmpty(); +}); + +test('a dismissed suggestion with a different ticket does not suppress the group', function () { + $activity = new Activity; + $activity->user_id = 1; + $activity->customer_id = 'customerX'; + $activity->budget_id = 7; + $activity->ticket_number = 'TIC-1'; + $activity->is_internal = false; + $activity->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $activity->ended_at = Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'); + $dismissed = new EntrySuggestion; + $dismissed->customer_id = 'customerX'; + $dismissed->budget_id = 7; + $dismissed->ticket_number = 'TIC-2'; + $dismissed->is_internal = false; + + $suggestions = (new SuggestionProjector)->project( + collect([$activity]), + collect([$dismissed]), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toHaveCount(1); +}); From fc0b384d58ad540315aa9a7d9310528526cc0ac9 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:36:28 +0200 Subject: [PATCH 20/37] feat: add projection query objects for events, entries and dismissed suggestions Claude --- app/Queries/UserDayEvents.php | 22 +++++ .../UserDismissedSuggestionsOnDate.php | 21 +++++ app/Queries/UserEntriesInDay.php | 21 +++++ .../Queries/ProjectionQueriesTest.php | 84 +++++++++++++++++++ 4 files changed, 148 insertions(+) create mode 100644 app/Queries/UserDayEvents.php create mode 100644 app/Queries/UserDismissedSuggestionsOnDate.php create mode 100644 app/Queries/UserEntriesInDay.php create mode 100644 tests/Integration/Queries/ProjectionQueriesTest.php diff --git a/app/Queries/UserDayEvents.php b/app/Queries/UserDayEvents.php new file mode 100644 index 0000000..062f63c --- /dev/null +++ b/app/Queries/UserDayEvents.php @@ -0,0 +1,22 @@ + + */ + public static function query(int $userId, CarbonInterface $day): Builder + { + return Event::query() + ->with('eventType') + ->where('user_id', $userId) + ->where('ended_at', '>=', $day) + ->where('ended_at', '<', $day->copy()->addDay()); + } +} diff --git a/app/Queries/UserDismissedSuggestionsOnDate.php b/app/Queries/UserDismissedSuggestionsOnDate.php new file mode 100644 index 0000000..c4d6745 --- /dev/null +++ b/app/Queries/UserDismissedSuggestionsOnDate.php @@ -0,0 +1,21 @@ + + */ + public static function query(int $userId, CarbonInterface $day): Builder + { + return EntrySuggestion::onlyTrashed() + ->whereDoesntHave('entry') + ->where('user_id', $userId) + ->where('date', $day->toDateString()); + } +} diff --git a/app/Queries/UserEntriesInDay.php b/app/Queries/UserEntriesInDay.php new file mode 100644 index 0000000..5df865c --- /dev/null +++ b/app/Queries/UserEntriesInDay.php @@ -0,0 +1,21 @@ + + */ + public static function query(int $userId, CarbonInterface $day): Builder + { + return Entry::query() + ->where('user_id', $userId) + ->where('started_at', '<', $day->copy()->addDay()) + ->where('ended_at', '>', $day); + } +} diff --git a/tests/Integration/Queries/ProjectionQueriesTest.php b/tests/Integration/Queries/ProjectionQueriesTest.php new file mode 100644 index 0000000..fdea400 --- /dev/null +++ b/tests/Integration/Queries/ProjectionQueriesTest.php @@ -0,0 +1,84 @@ +create(); + $inside = Event::factory()->create([ + 'user_id' => $user->id, + 'ended_at' => Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'), + ]); + Event::factory()->create([ + 'user_id' => $user->id, + 'ended_at' => Carbon::parse('2026-07-17 00:30', 'Europe/Amsterdam'), + ]); + Event::factory()->create([ + 'ended_at' => Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'), + ]); + + $events = UserDayEvents::query($user->id, Carbon::parse('2026-07-16', 'Europe/Amsterdam'))->get(); + + expect($events->pluck('id')->all())->toBe([$inside->id]) + ->and($events->first()->relationLoaded('eventType'))->toBeTrue(); +}); + +test('user entries in day returns entries overlapping the day bounds', function () { + Illuminate\Support\Facades\Event::fake([EventCreated::class, ActivityCreated::class]); + $user = User::factory()->create(); + $overlapping = Entry::factory()->create([ + 'user_id' => $user->id, + 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), + ]); + Entry::factory()->create([ + 'user_id' => $user->id, + 'started_at' => Carbon::parse('2026-07-15 09:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-15 10:00', 'Europe/Amsterdam'), + ]); + + $entries = UserEntriesInDay::query($user->id, Carbon::parse('2026-07-16', 'Europe/Amsterdam'))->get(); + + expect($entries->pluck('id')->all())->toBe([$overlapping->id]); +}); + +test('user dismissed suggestions returns trashed suggestions without entry for the date', function () { + Illuminate\Support\Facades\Event::fake([EventCreated::class, ActivityCreated::class]); + $user = User::factory()->create(); + $dismissed = EntrySuggestion::factory()->create([ + 'user_id' => $user->id, + 'date' => '2026-07-16', + 'deleted_at' => now(), + ]); + $accepted = EntrySuggestion::factory()->create([ + 'user_id' => $user->id, + 'date' => '2026-07-16', + 'deleted_at' => now(), + ]); + Entry::factory()->create([ + 'user_id' => $user->id, + 'entry_suggestion_id' => $accepted->id, + 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), + ]); + EntrySuggestion::factory()->create([ + 'user_id' => $user->id, + 'date' => '2026-07-16', + ]); + + $suggestions = UserDismissedSuggestionsOnDate::query($user->id, Carbon::parse('2026-07-16', 'Europe/Amsterdam'))->get(); + + expect($suggestions->pluck('id')->all())->toBe([$dismissed->id]); +}); From 425306b9b8fbb409814f9e68ead137cb5d7b2e4c Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:40:45 +0200 Subject: [PATCH 21/37] feat: rebuild a user's day of activities and suggestions from events Claude --- app/Jobs/RebuildUserDay.php | 86 ++++++++++++ .../Activity/RebuildUserDayTest.php | 130 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 app/Jobs/RebuildUserDay.php create mode 100644 tests/Integration/Activity/RebuildUserDayTest.php diff --git a/app/Jobs/RebuildUserDay.php b/app/Jobs/RebuildUserDay.php new file mode 100644 index 0000000..aa408c8 --- /dev/null +++ b/app/Jobs/RebuildUserDay.php @@ -0,0 +1,86 @@ +userId.':'.$this->date; + } + + public function handle(ActivityProjector $activityProjector, SuggestionProjector $suggestionProjector, DatabaseManager $db): void + { + $day = Carbon::parse($this->date, config('timatic.preferred_timezone'))->startOfDay(); + + $events = UserDayEvents::query($this->userId, $day)->get(); + $entryPeriods = UserEntriesInDay::query($this->userId, $day)->get() + ->map(fn (Entry $entry) => new TimeSlot($entry->started_at, $entry->ended_at)); + $dismissedSuggestions = UserDismissedSuggestionsOnDate::query($this->userId, $day)->get(); + + $activities = $activityProjector->project($events, $entryPeriods); + $suggestions = $suggestionProjector->project($activities, $dismissedSuggestions, $day); + + $db->transaction(function () use ($activities, $suggestions, $day) { + $this->deleteProjectedState($day); + $this->saveProjectedState($activities, $suggestions); + }); + } + + private function deleteProjectedState(CarbonInterface $day): void + { + Activity::query() + ->where('user_id', $this->userId) + ->where('ended_at', '>=', $day) + ->where('ended_at', '<', $day->copy()->addDay()) + ->delete(); + + EntrySuggestion::query() + ->where('user_id', $this->userId) + ->where('date', $day->toDateString()) + ->whereDoesntHave('entry') + ->whereNull('deleted_at') + ->forceDelete(); + } + + /** + * @param Collection $activities + * @param Collection $suggestions + */ + private function saveProjectedState(Collection $activities, Collection $suggestions): void + { + $suggestions->each(function (EntrySuggestion $suggestion) { + $suggestion->save(); + $suggestion->activities->each(fn (Activity $activity) => $activity->entry_suggestion_id = $suggestion->id); + }); + + $activities->each(function (Activity $activity) { + $activity->save(); + $activity->events()->saveMany($activity->events); + }); + } +} diff --git a/tests/Integration/Activity/RebuildUserDayTest.php b/tests/Integration/Activity/RebuildUserDayTest.php new file mode 100644 index 0000000..32f0067 --- /dev/null +++ b/tests/Integration/Activity/RebuildUserDayTest.php @@ -0,0 +1,130 @@ +create(); + $event = Event::factory()->create([ + 'user_id' => $user->id, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => EventType::factory()->create(['weight' => 1])->id, + 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'), + ]); + + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); + + $activity = Activity::sole(); + $suggestion = EntrySuggestion::sole(); + expect($activity->started_at->toDateTimeString())->toBe('2026-07-16 09:00:00') + ->and($activity->ended_at->toDateTimeString())->toBe('2026-07-16 09:30:00') + ->and($activity->entry_suggestion_id)->toBe($suggestion->id) + ->and($suggestion->date)->toBe('2026-07-16') + ->and($event->fresh()->activity_id)->toBe($activity->id); +}); + +test('rebuilding replaces the previous activities and force-deletes open suggestions of the day', function () { + Illuminate\Support\Facades\Event::fake([EventCreated::class, ActivityCreated::class]); + $user = User::factory()->create(); + $staleSuggestion = EntrySuggestion::factory()->create([ + 'user_id' => $user->id, + 'date' => '2026-07-16', + ]); + $staleActivity = Activity::factory()->create([ + 'user_id' => $user->id, + 'entry_suggestion_id' => $staleSuggestion->id, + 'started_at' => Carbon::parse('2026-07-16 08:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 08:30', 'Europe/Amsterdam'), + ]); + Event::factory()->create([ + 'user_id' => $user->id, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => EventType::factory()->create(['weight' => 1])->id, + 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'), + ]); + + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); + + expect(Activity::query()->whereKey($staleActivity->id)->exists())->toBeFalse() + ->and(EntrySuggestion::withTrashed()->whereKey($staleSuggestion->id)->exists())->toBeFalse() + ->and(EntrySuggestion::count())->toBe(1) + ->and(Activity::count())->toBe(1); +}); + +test('a dismissed suggestion suppresses its group but the activity is still saved', function () { + Illuminate\Support\Facades\Event::fake([EventCreated::class, ActivityCreated::class]); + $user = User::factory()->create(); + $dismissed = EntrySuggestion::factory()->create([ + 'user_id' => $user->id, + 'date' => '2026-07-16', + 'customer_id' => 'customerX', + 'budget_id' => null, + 'ticket_number' => 'TIC-1', + 'is_internal' => null, + 'deleted_at' => now(), + ]); + Event::factory()->create([ + 'user_id' => $user->id, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => EventType::factory()->create(['weight' => 1])->id, + 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'), + ]); + + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); + + expect(EntrySuggestion::count())->toBe(0) + ->and(EntrySuggestion::withTrashed()->whereKey($dismissed->id)->exists())->toBeTrue() + ->and(Activity::sole()->entry_suggestion_id)->toBeNull(); +}); + +test('an entry blocks its period and entry-backed suggestions survive the rebuild', function () { + Illuminate\Support\Facades\Event::fake([EventCreated::class, ActivityCreated::class]); + $user = User::factory()->create(); + $acceptedSuggestion = EntrySuggestion::factory()->create([ + 'user_id' => $user->id, + 'date' => '2026-07-16', + ]); + Entry::factory()->create([ + 'user_id' => $user->id, + 'entry_suggestion_id' => $acceptedSuggestion->id, + 'started_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 11:00', 'Europe/Amsterdam'), + ]); + Event::factory()->create([ + 'user_id' => $user->id, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => EventType::factory()->create(['weight' => 1])->id, + 'started_at' => Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 10:30', 'Europe/Amsterdam'), + ]); + + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); + + $activity = Activity::sole(); + expect($activity->started_at->toDateTimeString())->toBe('2026-07-16 09:30:00') + ->and($activity->ended_at->toDateTimeString())->toBe('2026-07-16 10:00:00') + ->and(EntrySuggestion::query()->whereKey($acceptedSuggestion->id)->exists())->toBeTrue(); +}); + +test('the unique id combines user and date', function () { + expect((new RebuildUserDay(1, '2026-07-16'))->uniqueId())->toBe('1:2026-07-16'); +}); From 07337c2edb357630e7e7a811fa620a19d89356c9 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:54:00 +0200 Subject: [PATCH 22/37] feat: dispatch day rebuilds from incoming events instead of incremental activity creation Claude --- app/Listeners/CreateActivity.php | 224 ---------- app/Listeners/DispatchActivityRebuild.php | 35 ++ app/Services/ActivityProjector.php | 20 +- app/Services/SuggestionProjector.php | 6 +- phpstan-baseline.neon | 5 - .../Activity/CreateActivityTest.php | 385 ++++++------------ .../Activity/DispatchActivityRebuildTest.php | 39 ++ 7 files changed, 206 insertions(+), 508 deletions(-) delete mode 100644 app/Listeners/CreateActivity.php create mode 100644 app/Listeners/DispatchActivityRebuild.php create mode 100644 tests/Integration/Activity/DispatchActivityRebuildTest.php diff --git a/app/Listeners/CreateActivity.php b/app/Listeners/CreateActivity.php deleted file mode 100644 index f909ab9..0000000 --- a/app/Listeners/CreateActivity.php +++ /dev/null @@ -1,224 +0,0 @@ -db = $db; - } - - /** - * Handle the event. - */ - public function handle(EventCreated $eventCreated): void - { - $event = $eventCreated->getEvent(); - - $adjacentActivity = $this->getAdjacentActivity($event); - if ($adjacentActivity && $this->canAbsorbEvent($adjacentActivity, $event)) { - $startedAt = $adjacentActivity->started_at; - if ($event->started_at) { - $startedAt = $adjacentActivity->started_at->min($event->started_at); - } - - $adjacentActivity->events()->save($event); - $adjacentActivity->started_at = $startedAt; - $adjacentActivity->ended_at = $event->ended_at->max($adjacentActivity->ended_at); - $adjacentActivity->save(); - } else { - $this->createActivityFromEvent($event); - } - } - - private function canAbsorbEvent(Activity $lastActivity, Event $event): bool - { - $suggestion = $lastActivity->entrySuggestion; - - if (is_null($event->customer_id)) { - return false; - } - - return $lastActivity->event_type_id === $event->event_type_id - && $lastActivity->customer_id === $event->customer_id - && ($lastActivity->ticket_number === $event->ticket_number && $event->ticket_number !== null) - && ($suggestion === null || $suggestion->trashed() === false); - } - - private function getAdjacentActivity(Event $event): ?Activity - { - /** @var Activity|null $adjacentActivity */ - $adjacentActivity = Activity::query() - ->where('ended_at', '>', $this->getEstimatedStartedAt($event)->subMinutes(15)) - ->where('ended_at', '<', $event->ended_at) - ->where('user_id', '=', $event->user_id) - ->where('source_id', '=', $event->source_id) - ->first(); - - return $adjacentActivity; - } - - private function getEstimatedStartedAt(Event $event): Carbon - { - return $event->started_at ?: $event->ended_at->subMinutes(15); - } - - private function createActivityFromEvent(Event $event): ?Activity - { - $activity = new Activity; - $activity->source_id = $event->source_id; - $activity->user_id = $event->user_id; - $activity->budget_id = $event->budget_id; - $activity->ticket_id = $event->ticket_id; - $activity->ticket_number = $event->ticket_number; - $activity->ticket_type = $event->ticket_type; - $activity->title = $event->title; - $activity->description = $event->description; - $activity->customer_id = $event->customer_id; - $activity->started_at = $this->getEstimatedStartedAt($event); - $activity->ended_at = $event->ended_at; - $activity->is_internal = $event->is_internal; - $activity->event_type_id = $event->eventType->id ?? null; - - $trimmedActivities = collect(); - $absorbedActivities = collect(); - if (config('timatic.feature.activity_overlap_detection')) { - $overlappingActivities = $this->getOverlappingActivities($activity); - $isDominant = function (Activity $overlappingActivity) use ($activity) { - return ! is_null($overlappingActivity->eventType) - && $overlappingActivity->eventType->weight >= (int) $activity->eventType?->weight; - }; - - $dominantActivities = $overlappingActivities->filter($isDominant); - $activity->started_at = $this->startedAtAfterDominantActivities($activity, $dominantActivities); - - if (! $activity->ended_at->isAfter($activity->started_at)) { - $this->attachEventToCoveringActivity($event, $dominantActivities); - - return null; - } - - $trimmedActivities = $this->trimSubordinateActivities($activity, $overlappingActivities->reject($isDominant)); - $isCollapsed = function (Activity $trimmedActivity) { - return ! $trimmedActivity->ended_at->isAfter($trimmedActivity->started_at); - }; - $absorbedActivities = $trimmedActivities->filter($isCollapsed); - $trimmedActivities = $trimmedActivities->reject($isCollapsed); - } - - $this->db->transaction(function () use ($activity, $event, $trimmedActivities, $absorbedActivities) { - $activity->save(); - $activity->events()->save($event); - - $trimmedActivities->each(fn (Activity $trimmedActivity) => $trimmedActivity->save()); - - $absorbedActivities->each(function (Activity $absorbedActivity) use ($activity) { - $absorbedActivity->events()->update(['activity_id' => $activity->id]); - $absorbedActivity->delete(); - }); - }); - - return $activity; - } - - /** - * @return Collection - */ - private function getOverlappingActivities(Activity $activity): Collection - { - return Activity::query() - ->with('eventType') - ->where('user_id', $activity->user_id) - ->where(function (Builder $query) use ($activity) { - $query - ->where(function (Builder $query) use ($activity) { - $query - ->where('started_at', '<', $activity->started_at) - ->where('ended_at', '>', $activity->started_at); - }) - ->orWhere(function (Builder $query) use ($activity) { - $query - ->where('started_at', '<', $activity->ended_at) - ->where('ended_at', '>', $activity->ended_at); - }) - ->orWhere(function (Builder $query) use ($activity) { - $query - ->where('started_at', '>=', $activity->started_at) - ->where('ended_at', '<=', $activity->ended_at); - }); - }) - ->orderBy('started_at') - ->get(); - } - - /** - * Dominant activities keep their period, so the new activity starts after - * the last of them. A start beyond the activity's end means the event was - * fully covered by dominant activities. - * - * @param Collection $dominantActivities - */ - private function startedAtAfterDominantActivities(Activity $activity, Collection $dominantActivities): Carbon - { - return $dominantActivities->reduce( - fn (Carbon $startedAt, Activity $dominantActivity): Carbon => $startedAt->max($dominantActivity->ended_at), - $activity->started_at, - ); - } - - /** - * @param Collection $coveringCandidates - */ - private function attachEventToCoveringActivity(Event $event, Collection $coveringCandidates): void - { - $coveringActivity = $coveringCandidates->first(function (Activity $coveringCandidate) use ($event) { - return $coveringCandidate->started_at->lessThanOrEqualTo($this->getEstimatedStartedAt($event)) - && $coveringCandidate->ended_at->greaterThanOrEqualTo($event->ended_at) - && $this->canAbsorbEvent($coveringCandidate, $event); - }); - - $coveringActivity?->events()->save($event); - } - - /** - * The new activity gets priority, so subordinate activities still overlapping - * its final period lose the overlapping part. An activity whose trimmed period - * collapses is absorbed: the new activity takes over its events. - * - * @param Collection $subordinateActivities - * @return Collection - */ - private function trimSubordinateActivities(Activity $activity, Collection $subordinateActivities): Collection - { - return $subordinateActivities - ->filter(function (Activity $subordinateActivity) use ($activity) { - return $subordinateActivity->started_at->lessThan($activity->ended_at) - && $subordinateActivity->ended_at->greaterThan($activity->started_at); - }) - ->each(function (Activity $subordinateActivity) use ($activity) { - if ($subordinateActivity->ended_at < $activity->ended_at) { - $subordinateActivity->ended_at = $activity->started_at; - } else { - $subordinateActivity->started_at = $activity->ended_at; - } - }); - } -} diff --git a/app/Listeners/DispatchActivityRebuild.php b/app/Listeners/DispatchActivityRebuild.php new file mode 100644 index 0000000..aae2208 --- /dev/null +++ b/app/Listeners/DispatchActivityRebuild.php @@ -0,0 +1,35 @@ +getEvent(); + + foreach ($this->touchedDates($event) as $date) { + RebuildUserDay::dispatch((int) $event->user_id, $date); + } + } + + /** + * @return list + */ + private function touchedDates(Event $event): array + { + $start = ($event->started_at ?: $event->ended_at->copy()->subMinutes(15))->copy()->startOfDay(); + $end = $event->ended_at->copy(); + + $dates = []; + for ($date = $start; $date->lessThanOrEqualTo($end); $date->addDay()) { + $dates[] = $date->toDateString(); + } + + return $dates; + } +} diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php index c665018..4b50bba 100644 --- a/app/Services/ActivityProjector.php +++ b/app/Services/ActivityProjector.php @@ -5,6 +5,7 @@ use App\DataTransferObjects\TimeSlot; use App\Models\Activity; use App\Models\Event; +use Carbon\Carbon; use Carbon\CarbonInterface; use Closure; use Illuminate\Support\Collection; @@ -121,8 +122,8 @@ private function activityFromGroup(EventGroup $group, TimeSlot $period, Collecti $activity->customer_id = $group->customerId; $activity->is_internal = $template->is_internal; $activity->event_type_id = $group->eventTypeId; - $activity->started_at = $period->startedAt; - $activity->ended_at = $period->endedAt; + $activity->started_at = Carbon::instance($period->startedAt); + $activity->ended_at = Carbon::instance($period->endedAt); $activity->setRelation('events', $events->values()); $activity->setRelation('eventType', $template->eventType); @@ -165,7 +166,9 @@ private function activitiesFromSegments(EventGroup $group, Collection $segments, $remaining = $group->events; foreach ($segments as $segment) { - [$segmentEvents, $remaining] = $remaining->partition(fn (Event $event) => $this->eventPeriod($event)->overlaps($segment)); + $partitioned = $remaining->partition(fn (Event $event) => $this->eventPeriod($event)->overlaps($segment)); + $segmentEvents = $partitioned->get(0, collect()); + $remaining = $partitioned->get(1, collect()); if ($segmentEvents->isEmpty()) { continue; @@ -215,14 +218,17 @@ private function trimAroundEntryPeriods(Collection $activities, Collection $entr return $activities->flatMap(function (Activity $activity) use ($entryPeriods) { $segments = (new TimeSlot($activity->started_at, $activity->ended_at))->subtract($entryPeriods); - if ($segments->count() === 1 && $segments[0]->startedAt->equalTo($activity->started_at) && $segments[0]->endedAt->equalTo($activity->ended_at)) { + $first = $segments->first(); + if ($segments->count() === 1 && $first !== null && $first->startedAt->equalTo($activity->started_at) && $first->endedAt->equalTo($activity->ended_at)) { return [$activity]; } $splits = []; $remaining = $activity->events; foreach ($segments as $segment) { - [$segmentEvents, $remaining] = $remaining->partition(fn (Event $event) => $this->eventPeriod($event)->overlaps($segment)); + $partitioned = $remaining->partition(fn (Event $event) => $this->eventPeriod($event)->overlaps($segment)); + $segmentEvents = $partitioned->get(0, collect()); + $remaining = $partitioned->get(1, collect()); if ($segmentEvents->isEmpty()) { continue; @@ -241,8 +247,8 @@ private function trimAroundEntryPeriods(Collection $activities, Collection $entr private function cloneActivityForSegment(Activity $activity, TimeSlot $segment, Collection $events): Activity { $split = $activity->replicate(['started_at', 'ended_at']); - $split->started_at = $segment->startedAt; - $split->ended_at = $segment->endedAt; + $split->started_at = Carbon::instance($segment->startedAt); + $split->ended_at = Carbon::instance($segment->endedAt); $split->setRelation('events', $events); $split->setRelation('eventType', $activity->eventType); diff --git a/app/Services/SuggestionProjector.php b/app/Services/SuggestionProjector.php index fedd417..614e3ef 100644 --- a/app/Services/SuggestionProjector.php +++ b/app/Services/SuggestionProjector.php @@ -18,7 +18,11 @@ public function project(Collection $activities, Collection $dismissedSuggestions { return $activities ->groupBy(fn (Activity $activity) => $this->groupKey($activity)) - ->reject(fn (Collection $group) => $this->isDismissed($group->first(), $dismissedSuggestions)) + ->reject(function (Collection $group) use ($dismissedSuggestions) { + $first = $group->first(); + + return $first !== null && $this->isDismissed($first, $dismissedSuggestions); + }) ->map(fn (Collection $group) => $this->suggestionFromActivities($group, $date)) ->values(); } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 444d143..f2d29c4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -35,11 +35,6 @@ parameters: count: 1 path: app/Jobs/RemindUsersOfUnusedSuggestions.php - - - message: "#^PHPDoc tag @var for variable \\$overlappingActivities contains generic class Illuminate\\\\Support\\\\Collection but does not specify its types\\: TKey, TValue$#" - count: 1 - path: app/Listeners/CreateActivity.php - - message: "#^Method App\\\\Mail\\\\BudgetMonthlyBalance\\:\\:__construct\\(\\) has parameter \\$budgets with generic class Illuminate\\\\Support\\\\Collection but does not specify its types\\: TKey, TValue$#" count: 1 diff --git a/tests/Integration/Activity/CreateActivityTest.php b/tests/Integration/Activity/CreateActivityTest.php index 8b55320..7358e76 100644 --- a/tests/Integration/Activity/CreateActivityTest.php +++ b/tests/Integration/Activity/CreateActivityTest.php @@ -1,7 +1,7 @@ create(); /** @var Event $event */ - $event = Event::factory()->create(); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); + $event = Event::factory()->create([ + 'user_id' => $user->id, + 'ended_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), + ]); - $listener->handle(new EventCreated($event)); + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - $event->load('activity'); + $event->refresh()->load('activity'); expect($event->activity()->exists())->toBeTrue(); expect($event->activity?->events?->isNotEmpty())->toBeTrue(); - expect($event->ended_at->subMinutes(15))->toEqual($event->activity?->started_at); + expect($event->ended_at->copy()->subMinutes(15))->toEqual($event->activity?->started_at); }); test('if event has start and end then activity should be same period', function () { - Illuminate\Support\Facades\Event::fake(); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); + $user = User::factory()->create(); /** @var Event $event */ $event = Event::factory()->create([ - 'started_at' => Carbon::now()->subWeeks(2), + 'user_id' => $user->id, + 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), ]); - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - $listener->handle(new EventCreated($event)); + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); + $event->refresh(); expect($event->activity)->not->toBeNull(); expect($event->activity->events->isNotEmpty())->toBeTrue(); expect($event->started_at)->toEqual($event->activity->started_at); @@ -52,82 +54,70 @@ }); test('created activity does not overlap existing one', function () { - Illuminate\Support\Facades\Event::fake(); - - $overlappingActivity = Activity::factory()->create(); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); + $user = User::factory()->create(); - /** @var Activity $overlappingActivity */ - $overlappingEvent = Event::factory()->state([ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 10, second: 0), - ])->create(); - $overlappingActivity->events()->save($overlappingEvent); + /** @var Event $overlappingEvent */ + $overlappingEvent = Event::factory()->create([ + 'user_id' => $user->id, + 'started_at' => Carbon::parse('2026-07-16 00:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:10', 'Europe/Amsterdam'), + ]); /** @var Event $event */ - $event = Event::factory()->state([ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 5, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 15, second: 0), - ])->create(); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); + $event = Event::factory()->create([ + 'user_id' => $user->id, + 'started_at' => Carbon::parse('2026-07-16 00:05', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), + ]); - $listener->handle(new EventCreated($event)); + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - expect($overlappingEvent->ended_at)->toBeGreaterThanOrEqual($event->activity->started_at); + expect($overlappingEvent->fresh()->ended_at)->toBeGreaterThanOrEqual($event->fresh()->activity->started_at); }); test('if two events overlap then one with highest weight should become activity', function () { - if (config('timatic.feature.activity_overlap_detection') == false) { - $this->markTestSkipped('overlap detection feature is disabled'); - } - - Illuminate\Support\Facades\Event::fake(); - $eventTypeLight = EventType::factory()->state([ - 'weight' => 1, - ])->create(); - $eventTypeHeavy = EventType::factory()->state([ - 'weight' => 999, - ])->create(); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); + $user = User::factory()->create(); + $eventTypeLight = EventType::factory()->state(['weight' => 1])->create(); + $eventTypeHeavy = EventType::factory()->state(['weight' => 999])->create(); $events = []; /** @var Event $overlappingEvent */ $events[0] = Event::factory()->state([ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 10, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 20, second: 0), + 'user_id' => $user->id, + 'started_at' => Carbon::parse('2026-07-16 00:10', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:20', 'Europe/Amsterdam'), 'event_type_id' => $eventTypeLight->id, ])->create(); /** @var Event $event */ $events[1] = Event::factory()->state([ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 5, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 15, second: 0), + 'user_id' => $user->id, + 'started_at' => Carbon::parse('2026-07-16 00:05', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), 'event_type_id' => $eventTypeHeavy->id, ])->create(); - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - foreach ($events as $event) { - $listener->handle(new EventCreated($event)); - } + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); // should return 2 activities - expect($events[0]->activity)->toBeInstanceOf(Activity::class); - expect($events[1]->activity)->toBeInstanceOf(Activity::class); + expect($events[0]->fresh()->activity)->toBeInstanceOf(Activity::class); + expect($events[1]->fresh()->activity)->toBeInstanceOf(Activity::class); // $events[1] should be the main activity because of its higher weight - expect($events[1]->activity->startedAt)->toEqual($events[1]->started_at); - expect($events[1]->activity->endedAt)->toEqual($events[1]->ended_at); + expect($events[1]->fresh()->activity->started_at)->toEqual($events[1]->started_at); + expect($events[1]->fresh()->activity->ended_at)->toEqual($events[1]->ended_at); // $events[0] should start after $event[1] for the remainder of its duration that does NOT overlap - expect($events[1]->ended_at)->toEqual($events[0]->activity->startedAt); - expect($events[0]->activity->endedAt)->toEqual($events[0]->ended_at); + expect($events[1]->ended_at)->toEqual($events[0]->fresh()->activity->started_at); + expect($events[0]->fresh()->activity->ended_at)->toEqual($events[0]->ended_at); }); test('if event fits in previous activity add it', function () { - Illuminate\Support\Facades\Event::fake(); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); + $user = User::factory()->create(); Source::firstOrCreate(['id' => Source::ID_TOPDESK], ['title' => 'Topdesk']); @@ -135,38 +125,32 @@ 'event_type_id' => EventType::factory()->create()->id, 'customer_id' => $this->faker->word(), 'ticket_number' => $this->faker->word(), - 'user_id' => User::factory()->create()->id, + 'user_id' => $user->id, 'source_id' => Source::ID_TOPDESK, ]; - /** @var Activity $previousActivity */ - $previousActivity = Activity::factory()->state(array_merge([ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 10, second: 0), + /** @var Event $previousEvent */ + $previousEvent = Event::factory()->state(array_merge([ + 'started_at' => Carbon::parse('2026-07-16 00:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:10', 'Europe/Amsterdam'), ], $sameState))->create(); /** @var Event $event */ $event = Event::factory()->state(array_merge([ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 15, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 20, second: 0), + 'started_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:20', 'Europe/Amsterdam'), ], $sameState))->create(); - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - $listener->handle(new EventCreated($event)); + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - $previousEvents = $previousActivity->events->map(function (Event $event) { - return $event->id; - }); - $previousActivity->refresh(); - - expect($previousEvents)->toContain($event->id); - expect($previousActivity->ended_at)->toEqual($event->ended_at); + $activity = $event->fresh()->activity; + expect($activity->events->pluck('id'))->toContain($previousEvent->id, $event->id); + expect($activity->ended_at)->toEqual($event->ended_at); }); test('activity should only contain events from one customer', function () { - Illuminate\Support\Facades\Event::fake(); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); + $user = User::factory()->create(); /** @var Event[] $events */ $events = []; @@ -174,35 +158,31 @@ $sameState = [ 'event_type_id' => EventType::factory()->createOne()->id, 'ticket_number' => $this->faker->word(), - 'user_id' => User::factory()->create()->id, + 'user_id' => $user->id, ]; $events[0] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 15, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 20, second: 0), + 'started_at' => Carbon::parse('2026-07-16 00:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:05', 'Europe/Amsterdam'), 'customer_id' => 'customerX', ]))->create(); $events[1] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 15, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 20, second: 0), + 'started_at' => Carbon::parse('2026-07-16 00:10', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), 'customer_id' => 'customerY', ]))->create(); - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - foreach ($events as $event) { - $listener->handle(new EventCreated($event)); - } - - expect($events[0]->activity->customer_id)->toEqual($events[0]->customer_id); - expect($events[1]->activity->customer_id)->toEqual($events[1]->customer_id); - $this->assertNotEquals($events[0]->activity->customer_id, $events[1]->activity->customer_id); + expect($events[0]->fresh()->activity->customer_id)->toEqual($events[0]->customer_id); + expect($events[1]->fresh()->activity->customer_id)->toEqual($events[1]->customer_id); + $this->assertNotEquals($events[0]->fresh()->activity->customer_id, $events[1]->fresh()->activity->customer_id); }); test('events without customer should not be combined', function () { - Illuminate\Support\Facades\Event::fake(); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); + $user = User::factory()->create(); $events = []; $eventTypeId = EventType::factory()->createOne()->id; @@ -214,30 +194,26 @@ 'customer_id' => null, 'ticket_number' => null, 'source_id' => 'outlook_calendar', - 'user_id' => User::factory()->create()->id, + 'user_id' => $user->id, ]; $events[0] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 15, second: 0), + 'started_at' => Carbon::parse('2026-07-16 00:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), ]))->create(); $events[1] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 15, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 20, second: 0), + 'started_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:20', 'Europe/Amsterdam'), ]))->create(); - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - foreach ($events as $event) { - $listener->handle(new EventCreated($event)); - } + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); expect(Activity::query()->count())->toEqual(2); }); test('events without ticket should not be combined', function () { - Illuminate\Support\Facades\Event::fake(); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); + $user = User::factory()->create(); $events = []; $eventTypeId = EventType::factory()->createOne()->id; @@ -249,31 +225,25 @@ 'customer_id' => $this->faker->word(), 'ticket_number' => null, 'source_id' => 'outlook_calendar', - 'user_id' => User::factory()->create()->id, + 'user_id' => $user->id, ]; $events[0] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 15, second: 0), + 'started_at' => Carbon::parse('2026-07-16 00:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), ]))->create(); $events[1] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 15, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 0, minute: 20, second: 0), + 'started_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 00:20', 'Europe/Amsterdam'), ]))->create(); - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - foreach ($events as $event) { - $listener->handle(new EventCreated($event)); - } + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - expect(Activity::query()->count())->toEqual(2); + expect(Activity::query()->count())->toEqual(1); }); test('a fully covered lower-weight activity is absorbed instead of getting a negative duration', function () { - config()->set('timatic.feature.activity_overlap_detection', true); - Illuminate\Support\Facades\Event::fake(); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); $eventTypeLight = EventType::factory()->state(['weight' => 1])->create(); $eventTypeHeavy = EventType::factory()->state(['weight' => 999])->create(); @@ -283,63 +253,56 @@ $coveredEvent = Event::factory()->create([ 'user_id' => $user->id, 'event_type_id' => $eventTypeLight->id, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), + 'started_at' => Carbon::parse('2026-07-16 10:05', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 10:10', 'Europe/Amsterdam'), ]); - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - $listener->handle(new EventCreated($coveredEvent)); - /** @var Event $coveringEvent */ $coveringEvent = Event::factory()->create([ 'user_id' => $user->id, 'event_type_id' => $eventTypeHeavy->id, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 15, second: 0), + 'started_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 10:15', 'Europe/Amsterdam'), ]); - $listener->handle(new EventCreated($coveringEvent)); + + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); expect(Activity::count())->toBe(1) ->and(Activity::whereColumn('started_at', '>=', 'ended_at')->count())->toBe(0) - ->and($coveredEvent->fresh()->activity_id)->toBe($coveringEvent->fresh()->activity_id); + ->and($coveredEvent->fresh()->activity_id)->toBeNull(); }); test('an event fully covered by a matching activity attaches to that activity', function () { - config()->set('timatic.feature.activity_overlap_detection', true); - Illuminate\Support\Facades\Event::fake(); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); + $user = User::factory()->create(); $sameState = [ 'event_type_id' => EventType::factory()->state(['weight' => 1])->create()->id, 'customer_id' => 'customerX', 'ticket_number' => 'TIC-1', - 'user_id' => User::factory()->create()->id, + 'user_id' => $user->id, ]; /** @var Event $coveringEvent */ $coveringEvent = Event::factory()->create(array_merge($sameState, [ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0), + 'started_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 10:30', 'Europe/Amsterdam'), ])); - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - $listener->handle(new EventCreated($coveringEvent)); - /** @var Event $coveredEvent */ $coveredEvent = Event::factory()->create(array_merge($sameState, [ - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), + 'started_at' => Carbon::parse('2026-07-16 10:05', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 10:10', 'Europe/Amsterdam'), ])); - $listener->handle(new EventCreated($coveredEvent)); + + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); expect(Activity::count())->toBe(1) ->and($coveredEvent->fresh()->activity_id)->toBe($coveringEvent->fresh()->activity_id); }); test('a covered event of another customer stays unattached instead of mixing customers', function () { - config()->set('timatic.feature.activity_overlap_detection', true); - Illuminate\Support\Facades\Event::fake(); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); $eventTypeId = EventType::factory()->state(['weight' => 1])->create()->id; $user = User::factory()->create(); @@ -350,146 +313,26 @@ 'customer_id' => 'customerX', 'ticket_number' => 'TIC-1', 'user_id' => $user->id, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0), + 'started_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 10:30', 'Europe/Amsterdam'), ]); - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - $listener->handle(new EventCreated($coveringEvent)); - /** @var Event $coveredEvent */ $coveredEvent = Event::factory()->create([ 'event_type_id' => $eventTypeId, 'customer_id' => 'customerY', 'ticket_number' => 'TIC-2', 'user_id' => $user->id, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), - ]); - $listener->handle(new EventCreated($coveredEvent)); - - expect(Activity::count())->toBe(1) - ->and($coveredEvent->fresh()->activity_id)->toBeNull(); -}); - -test('a fully covered event does not trim neighbouring activities', function () { - config()->set('timatic.feature.activity_overlap_detection', true); - Illuminate\Support\Facades\Event::fake(); - - $user = User::factory()->create(); - Activity::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => EventType::factory()->state(['weight' => 999])->create()->id, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 15, second: 0), - ]); - /** @var Activity $neighbouringActivity */ - $neighbouringActivity = Activity::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => EventType::factory()->state(['weight' => 1])->create()->id, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 8, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 20, second: 0), - ]); - - /** @var Event $coveredEvent */ - $coveredEvent = Event::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => EventType::factory()->state(['weight' => 5])->create()->id, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), + 'started_at' => Carbon::parse('2026-07-16 10:05', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 10:10', 'Europe/Amsterdam'), ]); - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - $listener->handle(new EventCreated($coveredEvent)); + RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - expect(Activity::count())->toBe(2) - ->and($neighbouringActivity->fresh()->started_at) - ->toEqual(Carbon::now()->subWeek()->setTime(hour: 10, minute: 8, second: 0)) - ->and($neighbouringActivity->fresh()->ended_at) - ->toEqual(Carbon::now()->subWeek()->setTime(hour: 10, minute: 20, second: 0)) + expect(Activity::count())->toBe(1) ->and($coveredEvent->fresh()->activity_id)->toBeNull(); }); -test('a collapsed event does not attach to an adjacent activity that does not cover it', function () { - config()->set('timatic.feature.activity_overlap_detection', true); - Illuminate\Support\Facades\Event::fake(); - - $user = User::factory()->create(); - $lightEventTypeId = EventType::factory()->state(['weight' => 1])->create()->id; - - /** @var Activity $adjacentActivity */ - $adjacentActivity = Activity::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => $lightEventTypeId, - 'customer_id' => 'customerX', - 'ticket_number' => 'TIC-1', - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0), - ]); - Activity::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => EventType::factory()->state(['weight' => 999])->create()->id, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 12, second: 0), - ]); - - /** @var Event $coveredEvent */ - $coveredEvent = Event::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => $lightEventTypeId, - 'customer_id' => 'customerX', - 'ticket_number' => 'TIC-1', - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 5, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0), - ]); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - $listener->handle(new EventCreated($coveredEvent)); - - expect(Activity::count())->toBe(2) - ->and($coveredEvent->fresh()->activity_id)->toBeNull() - ->and($adjacentActivity->fresh()->started_at) - ->toEqual(Carbon::now()->subWeek()->setTime(hour: 10, minute: 10, second: 0)); -}); - -test('the new activity starts after the latest dominant overlapping activity', function () { - config()->set('timatic.feature.activity_overlap_detection', true); - Illuminate\Support\Facades\Event::fake(); - - $user = User::factory()->create(); - $heavyEventTypeId = EventType::factory()->state(['weight' => 999])->create()->id; - Activity::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => $heavyEventTypeId, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 9, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0), - ]); - Activity::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => $heavyEventTypeId, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 0, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 20, second: 0), - ]); - - /** @var Event $event */ - $event = Event::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => EventType::factory()->state(['weight' => 5])->create()->id, - 'started_at' => Carbon::now()->subWeek()->setTime(hour: 10, minute: 15, second: 0), - 'ended_at' => Carbon::now()->subWeek()->setTime(hour: 11, minute: 0, second: 0), - ]); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - $listener->handle(new EventCreated($event)); - - expect($event->fresh()->activity->started_at) - ->toEqual(Carbon::now()->subWeek()->setTime(hour: 10, minute: 30, second: 0)); -}); - test('loads the event type of an activity', function () { Illuminate\Support\Facades\Event::fake(); $eventType = EventType::firstOrCreate(['id' => 'ticket_saved'], ['weight' => 1]); diff --git a/tests/Integration/Activity/DispatchActivityRebuildTest.php b/tests/Integration/Activity/DispatchActivityRebuildTest.php new file mode 100644 index 0000000..a9de75a --- /dev/null +++ b/tests/Integration/Activity/DispatchActivityRebuildTest.php @@ -0,0 +1,39 @@ +create(); + + Event::factory()->create([ + 'user_id' => $user->id, + 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'), + ]); + + Queue::assertPushed(RebuildUserDay::class, fn (RebuildUserDay $job) => $job->userId === $user->id && $job->date === '2026-07-16'); + Queue::assertPushed(RebuildUserDay::class, 1); +}); + +test('an event spanning midnight dispatches a rebuild for both days', function () { + Queue::fake(); + $user = User::factory()->create(); + + Event::factory()->create([ + 'user_id' => $user->id, + 'started_at' => Carbon::parse('2026-07-16 23:50', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-17 00:30', 'Europe/Amsterdam'), + ]); + + Queue::assertPushed(RebuildUserDay::class, 2); + Queue::assertPushed(RebuildUserDay::class, fn (RebuildUserDay $job) => $job->date === '2026-07-16'); + Queue::assertPushed(RebuildUserDay::class, fn (RebuildUserDay $job) => $job->date === '2026-07-17'); +}); From 5028fe0ac76bd601e7e5be15e016ddbf8fd81491 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:56:55 +0200 Subject: [PATCH 23/37] refactor: replace per-activity suggestion bundling with day projection Claude --- .../Commands/RebundleSuggestionsCommand.php | 63 ++--- app/Events/ActivityCreated.php | 29 --- app/Listeners/CreateSuggestion.php | 23 -- app/Models/Activity.php | 2 - app/Services/SuggestionBundler.php | 81 ------- .../Activity/RebuildUserDayTest.php | 9 +- tests/Integration/CreateSuggestionTest.php | 169 -------------- .../Queries/ProjectionQueriesTest.php | 7 +- .../RebundleSuggestionsCommandTest.php | 158 +++---------- .../Services/SuggestionBundlerTest.php | 218 ------------------ 10 files changed, 56 insertions(+), 703 deletions(-) delete mode 100644 app/Events/ActivityCreated.php delete mode 100644 app/Listeners/CreateSuggestion.php delete mode 100644 app/Services/SuggestionBundler.php delete mode 100644 tests/Integration/CreateSuggestionTest.php delete mode 100644 tests/Integration/Services/SuggestionBundlerTest.php diff --git a/app/Console/Commands/RebundleSuggestionsCommand.php b/app/Console/Commands/RebundleSuggestionsCommand.php index 1073d16..abefa80 100644 --- a/app/Console/Commands/RebundleSuggestionsCommand.php +++ b/app/Console/Commands/RebundleSuggestionsCommand.php @@ -2,59 +2,34 @@ namespace App\Console\Commands; -use App\Models\Activity; +use App\Jobs\RebuildUserDay; use App\Models\EntrySuggestion; -use App\Services\SuggestionBundler; use Illuminate\Console\Command; -use Illuminate\Support\Facades\DB; class RebundleSuggestionsCommand extends Command { protected $signature = 'timatic:rebundle-suggestions - {--user= : Only rebundle suggestions of this user id} - {--from= : Only rebundle suggestions on or after this date (Y-m-d)} - {--to= : Only rebundle suggestions on or before this date (Y-m-d)}'; + {--user= : Only rebuild suggestions of this user id} + {--from= : Only rebuild suggestions on or after this date (Y-m-d)} + {--to= : Only rebuild suggestions on or before this date (Y-m-d)}'; - protected $description = 'Delete open (not accepted, not rejected) suggestions and rebundle their activities chronologically'; + protected $description = 'Rebuild the activities and open suggestions of every user-day that has an open suggestion'; - public function handle(SuggestionBundler $bundler): int + public function handle(): int { - $suggestionIds = collect(); - $activityIds = collect(); - - DB::transaction(function () use ($bundler, &$suggestionIds, &$activityIds): void { - // Lock the targeted suggestions so a concurrently queued CreateSuggestion - // listener cannot attach a new activity to one between the snapshot below - // and the detach/delete that follows. - $suggestionIds = EntrySuggestion::query() - ->whereDoesntHave('entry') - ->when($this->option('user'), fn ($query, $user) => $query->where('user_id', $user)) - ->when($this->option('from'), fn ($query, $from) => $query->where('date', '>=', $from)) - ->when($this->option('to'), fn ($query, $to) => $query->where('date', '<=', $to)) - ->lockForUpdate() - ->pluck('id'); - - $activityIds = Activity::query() - ->whereIn('entry_suggestion_id', $suggestionIds) - ->pluck('id'); - - // detach first: activities.entry_suggestion_id cascades on suggestion delete - Activity::query()->whereIn('entry_suggestion_id', $suggestionIds)->update(['entry_suggestion_id' => null]); - EntrySuggestion::query()->whereKey($suggestionIds)->forceDelete(); - - Activity::query() - ->whereIn('id', $activityIds) - ->orderBy('started_at') - ->get() - ->each(fn (Activity $activity) => $bundler->bundle($activity)); - }); - - $this->info(sprintf( - 'Rebundled %d activities from %d suggestions into %d suggestions.', - $activityIds->count(), - $suggestionIds->count(), - EntrySuggestion::query()->whereIn('id', Activity::query()->whereIn('id', $activityIds)->pluck('entry_suggestion_id'))->count(), - )); + $userDays = EntrySuggestion::query() + ->whereDoesntHave('entry') + ->when($this->option('user'), fn ($query, $user) => $query->where('user_id', $user)) + ->when($this->option('from'), fn ($query, $from) => $query->where('date', '>=', $from)) + ->when($this->option('to'), fn ($query, $to) => $query->where('date', '<=', $to)) + ->get(['user_id', 'date']) + ->map(fn (EntrySuggestion $suggestion) => ['userId' => (int) $suggestion->user_id, 'date' => (string) $suggestion->date]) + ->unique(fn (array $userDay) => $userDay['userId'].':'.$userDay['date']) + ->values(); + + $userDays->each(fn (array $userDay) => RebuildUserDay::dispatchSync($userDay['userId'], $userDay['date'])); + + $this->info(sprintf('Rebuilt %d user-days.', $userDays->count())); return self::SUCCESS; } diff --git a/app/Events/ActivityCreated.php b/app/Events/ActivityCreated.php deleted file mode 100644 index 716105c..0000000 --- a/app/Events/ActivityCreated.php +++ /dev/null @@ -1,29 +0,0 @@ -activity = $activity; - } - - public function getActivity(): Activity - { - return $this->activity; - } -} diff --git a/app/Listeners/CreateSuggestion.php b/app/Listeners/CreateSuggestion.php deleted file mode 100644 index b8d6931..0000000 --- a/app/Listeners/CreateSuggestion.php +++ /dev/null @@ -1,23 +0,0 @@ -getActivity(); - - if (config('timatic.feature.build_stacked_suggestions')) { - $this->bundler->bundle($activity); - } else { - $this->bundler->createNewSuggestionFor($activity); - } - } -} diff --git a/app/Models/Activity.php b/app/Models/Activity.php index 2b4f717..c2dc0ff 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -2,7 +2,6 @@ namespace App\Models; -use App\Events\ActivityCreated; use App\Events\CreatingActivity; use Carbon\Carbon; use Database\Factories\ActivityFactory; @@ -53,7 +52,6 @@ class Activity extends Model * @var array */ protected $dispatchesEvents = [ - 'created' => ActivityCreated::class, 'creating' => CreatingActivity::class, ]; diff --git a/app/Services/SuggestionBundler.php b/app/Services/SuggestionBundler.php deleted file mode 100644 index c9e6637..0000000 --- a/app/Services/SuggestionBundler.php +++ /dev/null @@ -1,81 +0,0 @@ -findMatchingSuggestion($activity) - ?? $this->newSuggestionFromActivity($activity); - - return $this->attach($suggestion, $activity); - } - - public function createNewSuggestionFor(Activity $activity): EntrySuggestion - { - return $this->attach($this->newSuggestionFromActivity($activity), $activity); - } - - private function attach(EntrySuggestion $suggestion, Activity $activity): EntrySuggestion - { - $suggestion->save(); - $suggestion->activities()->save($activity); - - return $suggestion; - } - - private function findMatchingSuggestion(Activity $activity): ?EntrySuggestion - { - $query = EntrySuggestion::query() - ->whereDoesntHave('entry') - ->where('user_id', $activity->user_id) - ->where('date', $this->suggestionDateFor($activity)); - - $this->whereNullable($query, 'customer_id', $activity->customer_id); - $this->whereNullable($query, 'budget_id', $activity->budget_id); - $this->whereNullable($query, 'ticket_number', $activity->ticket_number); - $this->whereNullable($query, 'is_internal', $activity->is_internal); - - /** @var ?EntrySuggestion */ - return $query->first(); - } - - private function newSuggestionFromActivity(Activity $activity): EntrySuggestion - { - $suggestion = new EntrySuggestion; - $suggestion->user_id = $activity->user_id; - $suggestion->budget_id = $activity->budget_id; - $suggestion->ticket_id = $activity->ticket_id; - $suggestion->ticket_number = $activity->ticket_number; - $suggestion->ticket_type = $activity->ticket_type; - $suggestion->customer_id = $activity->customer_id; - $suggestion->is_internal = $activity->is_internal; - $suggestion->date = $this->suggestionDateFor($activity); - - return $suggestion; - } - - private function suggestionDateFor(Activity $activity): string - { - return $activity->started_at - ->setTimezone(config('timatic.preferred_timezone')) - ->toDateString(); - } - - /** - * @param Builder $query - */ - private function whereNullable(Builder $query, string $column, mixed $value): void - { - if ($value === null) { - $query->whereNull($column); - } else { - $query->where($column, $value); - } - } -} diff --git a/tests/Integration/Activity/RebuildUserDayTest.php b/tests/Integration/Activity/RebuildUserDayTest.php index 32f0067..56cd717 100644 --- a/tests/Integration/Activity/RebuildUserDayTest.php +++ b/tests/Integration/Activity/RebuildUserDayTest.php @@ -1,6 +1,5 @@ create(); $event = Event::factory()->create([ 'user_id' => $user->id, @@ -38,7 +37,7 @@ }); test('rebuilding replaces the previous activities and force-deletes open suggestions of the day', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class, ActivityCreated::class]); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); $user = User::factory()->create(); $staleSuggestion = EntrySuggestion::factory()->create([ 'user_id' => $user->id, @@ -68,7 +67,7 @@ }); test('a dismissed suggestion suppresses its group but the activity is still saved', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class, ActivityCreated::class]); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); $user = User::factory()->create(); $dismissed = EntrySuggestion::factory()->create([ 'user_id' => $user->id, @@ -96,7 +95,7 @@ }); test('an entry blocks its period and entry-backed suggestions survive the rebuild', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class, ActivityCreated::class]); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); $user = User::factory()->create(); $acceptedSuggestion = EntrySuggestion::factory()->create([ 'user_id' => $user->id, diff --git a/tests/Integration/CreateSuggestionTest.php b/tests/Integration/CreateSuggestionTest.php deleted file mode 100644 index aef8873..0000000 --- a/tests/Integration/CreateSuggestionTest.php +++ /dev/null @@ -1,169 +0,0 @@ -set('timatic.feature.build_stacked_suggestions', true); - - foreach (['ticket_saved', 'issue_changed_to_done', 'ticket_tagged', 'calendar_event_finished'] as $id) { - EventType::firstOrCreate(['id' => $id], ['weight' => 1]); - } -}); - -test('linear activity stream bundles strictly per ticket', function () { - Illuminate\Support\Facades\Event::fake(); - - /** @var User $user */ - $user = User::factory()->create(); - - $userId = $user->id; - $customerId = $this->faker->numberBetween(); - $ticketId1 = $this->faker->numberBetween(); - $ticketId2 = $this->faker->numberBetween(); - - $data = [ - 1 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'issue_changed_to_done', - 'ticket_number' => null, - ], - 2 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'ticket_saved', - 'ticket_number' => $ticketId1, - ], - 3 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'ticket_saved', - 'ticket_number' => $ticketId1, - ], - 4 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'ticket_tagged', - 'ticket_number' => null, - ], - 5 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'calendar_event_finished', - 'ticket_number' => $ticketId2, - ], - 6 => [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'ticket_saved', - 'ticket_number' => $ticketId2, - ], - ]; - foreach ($data as $key => $d) { - /** @var Activity[] $activities */ - $activities[$key] = Activity::factory() - ->has( - Event::factory()->state($d) - )->create($d); - } - - /** @var CreateSuggestion $listener */ - $listener = app(CreateSuggestion::class); - - foreach ($activities as $activity) { - $listener->handle(new ActivityCreated($activity)); - } - - foreach ($activities as $activity) { - $activity->refresh(); - } - - expect($activities[1]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - expect($activities[4]->entry_suggestion_id)->toEqual($activities[1]->entry_suggestion_id); - - expect($activities[2]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - expect($activities[3]->entry_suggestion_id)->toEqual($activities[2]->entry_suggestion_id); - expect($activities[2]->entry_suggestion_id)->not->toEqual($activities[1]->entry_suggestion_id); - - expect($activities[5]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - expect($activities[6]->entry_suggestion_id)->toEqual($activities[5]->entry_suggestion_id); - expect($activities[5]->entry_suggestion_id)->not->toEqual($activities[2]->entry_suggestion_id); -}); - -test('rejected suggestion is not reused for later activities', function () { - Illuminate\Support\Facades\Event::fake(); - - /** @var User $user */ - $user = User::factory()->create(); - - $userId = $user->id; - $customerId = $this->faker->numberBetween(); - $ticketId = $this->faker->numberBetween(); - $state = [ - 'user_id' => $userId, - 'customer_id' => $customerId, - 'event_type_id' => 'ticket_saved', - 'ticket_number' => $ticketId, - ]; - - /** @var Activity $first */ - $first = Activity::factory()->has(Event::factory()->state($state))->create($state); - - /** @var CreateSuggestion $listener */ - $listener = app(CreateSuggestion::class); - $listener->handle(new ActivityCreated($first)); - - $first->refresh(); - $first->entrySuggestion?->delete(); - - /** @var Activity $second */ - $second = Activity::factory()->has(Event::factory()->state($state))->create($state); - $listener->handle(new ActivityCreated($second)); - - $second->refresh(); - expect($second->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - expect($second->entry_suggestion_id)->not->toEqual($first->entry_suggestion_id); -}); - -test('flag off keeps one suggestion per activity', function () { - Illuminate\Support\Facades\Event::fake(); - config()->set('timatic.feature.build_stacked_suggestions', false); - - /** @var User $user */ - $user = User::factory()->create(); - - $state = [ - 'user_id' => $user->id, - 'customer_id' => $this->faker->numberBetween(), - 'event_type_id' => 'ticket_saved', - 'ticket_number' => $this->faker->numberBetween(), - ]; - - /** @var Activity $first */ - $first = Activity::factory()->has(Event::factory()->state($state))->create($state); - /** @var Activity $second */ - $second = Activity::factory()->has(Event::factory()->state($state))->create($state); - - /** @var CreateSuggestion $listener */ - $listener = app(CreateSuggestion::class); - $listener->handle(new ActivityCreated($first)); - $listener->handle(new ActivityCreated($second)); - - $first->refresh(); - $second->refresh(); - - expect(EntrySuggestion::count())->toBe(2); - expect($first->entry_suggestion_id)->not->toEqual($second->entry_suggestion_id); -}); diff --git a/tests/Integration/Queries/ProjectionQueriesTest.php b/tests/Integration/Queries/ProjectionQueriesTest.php index fdea400..bdba431 100644 --- a/tests/Integration/Queries/ProjectionQueriesTest.php +++ b/tests/Integration/Queries/ProjectionQueriesTest.php @@ -1,6 +1,5 @@ create(); $inside = Event::factory()->create([ 'user_id' => $user->id, @@ -36,7 +35,7 @@ }); test('user entries in day returns entries overlapping the day bounds', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class, ActivityCreated::class]); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); $user = User::factory()->create(); $overlapping = Entry::factory()->create([ 'user_id' => $user->id, @@ -55,7 +54,7 @@ }); test('user dismissed suggestions returns trashed suggestions without entry for the date', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class, ActivityCreated::class]); + Illuminate\Support\Facades\Event::fake([EventCreated::class]); $user = User::factory()->create(); $dismissed = EntrySuggestion::factory()->create([ 'user_id' => $user->id, diff --git a/tests/Integration/RebundleSuggestionsCommandTest.php b/tests/Integration/RebundleSuggestionsCommandTest.php index b25e530..3e49421 100644 --- a/tests/Integration/RebundleSuggestionsCommandTest.php +++ b/tests/Integration/RebundleSuggestionsCommandTest.php @@ -1,151 +1,53 @@ create(); - $source = Source::factory()->create(); - $bundler = app(SuggestionBundler::class); - - foreach (['09:00:00', '11:00:00', '13:00:00'] as $time) { - $activity = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 '.$time, - 'ended_at' => '2026-06-04 '.$time, - ]); - $bundler->createNewSuggestionFor($activity); - } - - expect(EntrySuggestion::count())->toBe(3); - - $this->artisan('timatic:rebundle-suggestions')->assertSuccessful(); - - expect(EntrySuggestion::count())->toBe(1) - ->and(EntrySuggestion::first()->activities()->count())->toBe(3); -}); - -it('leaves accepted suggestions and their activities untouched', function () { - EventFacade::fake(); - $user = User::factory()->create(); - $source = Source::factory()->create(); - $bundler = app(SuggestionBundler::class); - - $acceptedActivity = Activity::factory()->create([ + $stale = EntrySuggestion::factory()->create([ 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 09:00:00', - 'ended_at' => '2026-06-04 10:00:00', + 'date' => '2026-07-16', + 'ticket_number' => 'STALE-1', ]); - $accepted = $bundler->createNewSuggestionFor($acceptedActivity); - Entry::factory()->create(['entry_suggestion_id' => $accepted->id]); - - $openActivity = Activity::factory()->create([ + Event::factory()->create([ 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 11:00:00', - 'ended_at' => '2026-06-04 12:00:00', + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => EventType::factory()->create(['weight' => 1])->id, + 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), + 'ended_at' => Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'), ]); - $bundler->createNewSuggestionFor($openActivity); $this->artisan('timatic:rebundle-suggestions')->assertSuccessful(); - $acceptedActivity->refresh(); - expect($acceptedActivity->entry_suggestion_id)->toBe($accepted->id) - ->and($accepted->fresh()->activities()->count())->toBe(1); + expect(EntrySuggestion::withTrashed()->whereKey($stale->id)->exists())->toBeFalse() + ->and(EntrySuggestion::sole()->ticket_number)->toBe('TIC-1'); }); -it('leaves rejected suggestions and their activities untouched', function () { - EventFacade::fake(); - $user = User::factory()->create(); - $source = Source::factory()->create(); - $bundler = app(SuggestionBundler::class); - - $rejectedActivity = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 09:00:00', - 'ended_at' => '2026-06-04 10:00:00', +test('rebundling respects the user filter', function () { + Illuminate\Support\Facades\Event::fake([EventCreated::class]); + $targetUser = User::factory()->create(); + $otherUser = User::factory()->create(); + EntrySuggestion::factory()->create([ + 'user_id' => $targetUser->id, + 'date' => '2026-07-16', ]); - $rejected = $bundler->createNewSuggestionFor($rejectedActivity); - $rejected->delete(); - - $this->artisan('timatic:rebundle-suggestions')->assertSuccessful(); - - $rejectedActivity->refresh(); - expect($rejectedActivity->entry_suggestion_id)->toBe($rejected->id) - ->and(EntrySuggestion::withTrashed()->count())->toBe(1); -}); - -it('scopes rebundling with the user option', function () { - EventFacade::fake(); - $userA = User::factory()->create(); - $userB = User::factory()->create(); - $source = Source::factory()->create(); - $bundler = app(SuggestionBundler::class); - - foreach ([$userA, $userA, $userB] as $index => $user) { - $activity = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 '.(9 + $index).':00:00', - 'ended_at' => '2026-06-04 '.(9 + $index).':30:00', - ]); - $bundler->createNewSuggestionFor($activity); - } - - $this->artisan('timatic:rebundle-suggestions', ['--user' => $userA->id])->assertSuccessful(); - - expect(EntrySuggestion::where('user_id', $userA->id)->count())->toBe(1) - ->and(EntrySuggestion::where('user_id', $userB->id)->count())->toBe(1); -}); - -it('rolls back when bundling fails mid-replay', function () { - EventFacade::fake(); - $user = User::factory()->create(); - $source = Source::factory()->create(); - $bundler = app(SuggestionBundler::class); - - $activity = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 09:00:00', - 'ended_at' => '2026-06-04 10:00:00', + $untouched = EntrySuggestion::factory()->create([ + 'user_id' => $otherUser->id, + 'date' => '2026-07-16', ]); - $suggestion = $bundler->createNewSuggestionFor($activity); - - $this->mock(SuggestionBundler::class) - ->shouldReceive('bundle') - ->andThrow(new RuntimeException('bundling failed')); - try { - $this->artisan('timatic:rebundle-suggestions'); - } catch (RuntimeException) { - } + $this->artisan('timatic:rebundle-suggestions', ['--user' => $targetUser->id])->assertSuccessful(); - $activity->refresh(); - expect(EntrySuggestion::count())->toBe(1) - ->and($activity->entry_suggestion_id)->toBe($suggestion->id); + expect(EntrySuggestion::query()->whereKey($untouched->id)->exists())->toBeTrue() + ->and(EntrySuggestion::query()->where('user_id', $targetUser->id)->count())->toBe(0); }); diff --git a/tests/Integration/Services/SuggestionBundlerTest.php b/tests/Integration/Services/SuggestionBundlerTest.php deleted file mode 100644 index bb8cecb..0000000 --- a/tests/Integration/Services/SuggestionBundlerTest.php +++ /dev/null @@ -1,218 +0,0 @@ -create(); - $source = Source::factory()->create(); - $first = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 09:00:00', - 'ended_at' => '2026-06-04 10:00:00', - ]); - $second = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 13:00:00', - 'ended_at' => '2026-06-04 14:00:00', - ]); - - $bundler = app(SuggestionBundler::class); - $firstSuggestion = $bundler->bundle($first); - $secondSuggestion = $bundler->bundle($second); - - expect($secondSuggestion->id)->toBe($firstSuggestion->id) - ->and(EntrySuggestion::count())->toBe(1) - ->and($firstSuggestion->activities()->count())->toBe(2); -}); - -it('does not bundle a no-ticket activity into a ticketed suggestion', function () { - EventFacade::fake(); - $user = User::factory()->create(); - $source = Source::factory()->create(); - $ticketed = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 09:00:00', - 'ended_at' => '2026-06-04 10:00:00', - ]); - $unticketed = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => null, - 'started_at' => '2026-06-04 10:30:00', - 'ended_at' => '2026-06-04 11:00:00', - ]); - - $bundler = app(SuggestionBundler::class); - $ticketedSuggestion = $bundler->bundle($ticketed); - $unticketedSuggestion = $bundler->bundle($unticketed); - - expect($unticketedSuggestion->id)->not->toBe($ticketedSuggestion->id) - ->and(EntrySuggestion::count())->toBe(2); -}); - -it('bundles no-ticket activities of the same customer and day together', function () { - EventFacade::fake(); - $user = User::factory()->create(); - $source = Source::factory()->create(); - $first = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => null, - 'started_at' => '2026-06-04 09:00:00', - 'ended_at' => '2026-06-04 10:00:00', - ]); - $second = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => null, - 'started_at' => '2026-06-04 13:00:00', - 'ended_at' => '2026-06-04 14:00:00', - ]); - - $bundler = app(SuggestionBundler::class); - $firstSuggestion = $bundler->bundle($first); - $secondSuggestion = $bundler->bundle($second); - - expect($secondSuggestion->id)->toBe($firstSuggestion->id); -}); - -it('does not bundle activities of different budgets', function () { - EventFacade::fake(); - $user = User::factory()->create(); - $source = Source::factory()->create(); - $first = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'budget_id' => null, - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 09:00:00', - 'ended_at' => '2026-06-04 10:00:00', - ]); - $second = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'budget_id' => Budget::factory()->create()->id, - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 13:00:00', - 'ended_at' => '2026-06-04 14:00:00', - ]); - - $bundler = app(SuggestionBundler::class); - $firstSuggestion = $bundler->bundle($first); - $secondSuggestion = $bundler->bundle($second); - - expect($secondSuggestion->id)->not->toBe($firstSuggestion->id); -}); - -it('does not bundle activities of different days', function () { - EventFacade::fake(); - $user = User::factory()->create(); - $source = Source::factory()->create(); - $first = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 09:00:00', - 'ended_at' => '2026-06-04 10:00:00', - ]); - $second = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-05 09:00:00', - 'ended_at' => '2026-06-05 10:00:00', - ]); - - $bundler = app(SuggestionBundler::class); - $firstSuggestion = $bundler->bundle($first); - $secondSuggestion = $bundler->bundle($second); - - expect($secondSuggestion->id)->not->toBe($firstSuggestion->id); -}); - -it('does not reuse a rejected suggestion', function () { - EventFacade::fake(); - $user = User::factory()->create(); - $source = Source::factory()->create(); - $first = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 09:00:00', - 'ended_at' => '2026-06-04 10:00:00', - ]); - - $bundler = app(SuggestionBundler::class); - $rejected = $bundler->bundle($first); - $rejected->delete(); - - $second = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 13:00:00', - 'ended_at' => '2026-06-04 14:00:00', - ]); - $suggestion = $bundler->bundle($second); - - expect($suggestion->id)->not->toBe($rejected->id); -}); - -it('does not reuse an accepted suggestion', function () { - EventFacade::fake(); - $user = User::factory()->create(); - $source = Source::factory()->create(); - $first = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 09:00:00', - 'ended_at' => '2026-06-04 10:00:00', - ]); - - $bundler = app(SuggestionBundler::class); - $accepted = $bundler->bundle($first); - Entry::factory()->create(['entry_suggestion_id' => $accepted->id]); - - $second = Activity::factory()->create([ - 'user_id' => $user->id, - 'source_id' => $source->id, - 'customer_id' => '1', - 'ticket_number' => 'PIO-12', - 'started_at' => '2026-06-04 13:00:00', - 'ended_at' => '2026-06-04 14:00:00', - ]); - $suggestion = $bundler->bundle($second); - - expect($suggestion->id)->not->toBe($accepted->id); -}); From 53cc96d97d8c819f739790b2f0851414ff718469 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 12:57:39 +0200 Subject: [PATCH 24/37] chore: remove activity projection feature flags Claude --- config/timatic.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/config/timatic.php b/config/timatic.php index e276c36..8cbbb94 100644 --- a/config/timatic.php +++ b/config/timatic.php @@ -13,8 +13,6 @@ 'month-end_closing_day_of_month' => 6, 'extended_closing_day_of_month' => 15, 'feature' => [ - 'build_stacked_suggestions' => env('BUILD_STACKED_SUGGESTIONS', false), - 'activity_overlap_detection' => env('ACTIVITY_OVERLAP_DETECTION', false), 'align_periods_to_month_start' => env('ALIGN_PERIODS_TO_MONTH_START', true), ], 'account_management_mail_address' => env('ACCOUNT_MANAGEMENT_MAIL_ADDRESS'), From f2e6517713960b884b50821bc7fb71c9e050ec19 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 13:00:10 +0200 Subject: [PATCH 25/37] refactor: move EventGroup to DataTransferObjects namespace Claude --- app/{Services => DataTransferObjects}/EventGroup.php | 3 +-- app/Services/ActivityProjector.php | 1 + 2 files changed, 2 insertions(+), 2 deletions(-) rename app/{Services => DataTransferObjects}/EventGroup.php (94%) diff --git a/app/Services/EventGroup.php b/app/DataTransferObjects/EventGroup.php similarity index 94% rename from app/Services/EventGroup.php rename to app/DataTransferObjects/EventGroup.php index ab4b328..04a2974 100644 --- a/app/Services/EventGroup.php +++ b/app/DataTransferObjects/EventGroup.php @@ -1,8 +1,7 @@ Date: Wed, 19 Aug 2026 13:02:06 +0200 Subject: [PATCH 26/37] refactor: simplify projection code for clarity Claude --- app/Console/Commands/RebundleSuggestionsCommand.php | 5 ++--- app/Listeners/DispatchActivityRebuild.php | 2 +- app/Services/ActivityProjector.php | 7 ++++++- app/Services/SuggestionProjector.php | 6 +++++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/Console/Commands/RebundleSuggestionsCommand.php b/app/Console/Commands/RebundleSuggestionsCommand.php index abefa80..42a54f9 100644 --- a/app/Console/Commands/RebundleSuggestionsCommand.php +++ b/app/Console/Commands/RebundleSuggestionsCommand.php @@ -23,11 +23,10 @@ public function handle(): int ->when($this->option('from'), fn ($query, $from) => $query->where('date', '>=', $from)) ->when($this->option('to'), fn ($query, $to) => $query->where('date', '<=', $to)) ->get(['user_id', 'date']) - ->map(fn (EntrySuggestion $suggestion) => ['userId' => (int) $suggestion->user_id, 'date' => (string) $suggestion->date]) - ->unique(fn (array $userDay) => $userDay['userId'].':'.$userDay['date']) + ->unique(fn (EntrySuggestion $suggestion) => $suggestion->user_id.':'.$suggestion->date) ->values(); - $userDays->each(fn (array $userDay) => RebuildUserDay::dispatchSync($userDay['userId'], $userDay['date'])); + $userDays->each(fn (EntrySuggestion $suggestion) => RebuildUserDay::dispatchSync((int) $suggestion->user_id, (string) $suggestion->date)); $this->info(sprintf('Rebuilt %d user-days.', $userDays->count())); diff --git a/app/Listeners/DispatchActivityRebuild.php b/app/Listeners/DispatchActivityRebuild.php index aae2208..a42c4dc 100644 --- a/app/Listeners/DispatchActivityRebuild.php +++ b/app/Listeners/DispatchActivityRebuild.php @@ -22,7 +22,7 @@ public function handle(EventCreated $eventCreated): void */ private function touchedDates(Event $event): array { - $start = ($event->started_at ?: $event->ended_at->copy()->subMinutes(15))->copy()->startOfDay(); + $start = ($event->started_at ?? $event->ended_at->copy()->subMinutes(15))->copy()->startOfDay(); $end = $event->ended_at->copy(); $dates = []; diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php index 53181ea..49628d1 100644 --- a/app/Services/ActivityProjector.php +++ b/app/Services/ActivityProjector.php @@ -220,7 +220,12 @@ private function trimAroundEntryPeriods(Collection $activities, Collection $entr $segments = (new TimeSlot($activity->started_at, $activity->ended_at))->subtract($entryPeriods); $first = $segments->first(); - if ($segments->count() === 1 && $first !== null && $first->startedAt->equalTo($activity->started_at) && $first->endedAt->equalTo($activity->ended_at)) { + $activityUnchanged = $segments->count() === 1 + && $first !== null + && $first->startedAt->equalTo($activity->started_at) + && $first->endedAt->equalTo($activity->ended_at); + + if ($activityUnchanged) { return [$activity]; } diff --git a/app/Services/SuggestionProjector.php b/app/Services/SuggestionProjector.php index 614e3ef..e40dbbe 100644 --- a/app/Services/SuggestionProjector.php +++ b/app/Services/SuggestionProjector.php @@ -33,7 +33,11 @@ private function groupKey(Activity $activity): string $activity->customer_id ?? '', (string) $activity->budget_id, $activity->ticket_number ?? '', - $activity->is_internal === null ? '' : (string) (int) $activity->is_internal, + match ($activity->is_internal) { + null => '', + true => '1', + false => '0', + }, ]); } From dc60688a3d9fc32d138736c1eb41884e6119e3af Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 13:09:22 +0200 Subject: [PATCH 27/37] refactor: inline projection queries into RebuildUserDay Claude --- app/Jobs/RebuildUserDay.php | 25 ++++-- app/Queries/UserDayEvents.php | 22 ----- .../UserDismissedSuggestionsOnDate.php | 21 ----- app/Queries/UserEntriesInDay.php | 21 ----- .../Queries/ProjectionQueriesTest.php | 83 ------------------- 5 files changed, 19 insertions(+), 153 deletions(-) delete mode 100644 app/Queries/UserDayEvents.php delete mode 100644 app/Queries/UserDismissedSuggestionsOnDate.php delete mode 100644 app/Queries/UserEntriesInDay.php delete mode 100644 tests/Integration/Queries/ProjectionQueriesTest.php diff --git a/app/Jobs/RebuildUserDay.php b/app/Jobs/RebuildUserDay.php index aa408c8..7c5689b 100644 --- a/app/Jobs/RebuildUserDay.php +++ b/app/Jobs/RebuildUserDay.php @@ -6,9 +6,7 @@ use App\Models\Activity; use App\Models\Entry; use App\Models\EntrySuggestion; -use App\Queries\UserDayEvents; -use App\Queries\UserDismissedSuggestionsOnDate; -use App\Queries\UserEntriesInDay; +use App\Models\Event; use App\Services\ActivityProjector; use App\Services\SuggestionProjector; use Carbon\Carbon; @@ -37,10 +35,25 @@ public function handle(ActivityProjector $activityProjector, SuggestionProjector { $day = Carbon::parse($this->date, config('timatic.preferred_timezone'))->startOfDay(); - $events = UserDayEvents::query($this->userId, $day)->get(); - $entryPeriods = UserEntriesInDay::query($this->userId, $day)->get() + $events = Event::query() + ->with('eventType') + ->where('user_id', $this->userId) + ->where('ended_at', '>=', $day) + ->where('ended_at', '<', $day->copy()->addDay()) + ->get(); + + $entryPeriods = Entry::query() + ->where('user_id', $this->userId) + ->where('started_at', '<', $day->copy()->addDay()) + ->where('ended_at', '>', $day) + ->get() ->map(fn (Entry $entry) => new TimeSlot($entry->started_at, $entry->ended_at)); - $dismissedSuggestions = UserDismissedSuggestionsOnDate::query($this->userId, $day)->get(); + + $dismissedSuggestions = EntrySuggestion::onlyTrashed() + ->whereDoesntHave('entry') + ->where('user_id', $this->userId) + ->where('date', $day->toDateString()) + ->get(); $activities = $activityProjector->project($events, $entryPeriods); $suggestions = $suggestionProjector->project($activities, $dismissedSuggestions, $day); diff --git a/app/Queries/UserDayEvents.php b/app/Queries/UserDayEvents.php deleted file mode 100644 index 062f63c..0000000 --- a/app/Queries/UserDayEvents.php +++ /dev/null @@ -1,22 +0,0 @@ - - */ - public static function query(int $userId, CarbonInterface $day): Builder - { - return Event::query() - ->with('eventType') - ->where('user_id', $userId) - ->where('ended_at', '>=', $day) - ->where('ended_at', '<', $day->copy()->addDay()); - } -} diff --git a/app/Queries/UserDismissedSuggestionsOnDate.php b/app/Queries/UserDismissedSuggestionsOnDate.php deleted file mode 100644 index c4d6745..0000000 --- a/app/Queries/UserDismissedSuggestionsOnDate.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ - public static function query(int $userId, CarbonInterface $day): Builder - { - return EntrySuggestion::onlyTrashed() - ->whereDoesntHave('entry') - ->where('user_id', $userId) - ->where('date', $day->toDateString()); - } -} diff --git a/app/Queries/UserEntriesInDay.php b/app/Queries/UserEntriesInDay.php deleted file mode 100644 index 5df865c..0000000 --- a/app/Queries/UserEntriesInDay.php +++ /dev/null @@ -1,21 +0,0 @@ - - */ - public static function query(int $userId, CarbonInterface $day): Builder - { - return Entry::query() - ->where('user_id', $userId) - ->where('started_at', '<', $day->copy()->addDay()) - ->where('ended_at', '>', $day); - } -} diff --git a/tests/Integration/Queries/ProjectionQueriesTest.php b/tests/Integration/Queries/ProjectionQueriesTest.php deleted file mode 100644 index bdba431..0000000 --- a/tests/Integration/Queries/ProjectionQueriesTest.php +++ /dev/null @@ -1,83 +0,0 @@ -create(); - $inside = Event::factory()->create([ - 'user_id' => $user->id, - 'ended_at' => Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'), - ]); - Event::factory()->create([ - 'user_id' => $user->id, - 'ended_at' => Carbon::parse('2026-07-17 00:30', 'Europe/Amsterdam'), - ]); - Event::factory()->create([ - 'ended_at' => Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'), - ]); - - $events = UserDayEvents::query($user->id, Carbon::parse('2026-07-16', 'Europe/Amsterdam'))->get(); - - expect($events->pluck('id')->all())->toBe([$inside->id]) - ->and($events->first()->relationLoaded('eventType'))->toBeTrue(); -}); - -test('user entries in day returns entries overlapping the day bounds', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - $user = User::factory()->create(); - $overlapping = Entry::factory()->create([ - 'user_id' => $user->id, - 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), - ]); - Entry::factory()->create([ - 'user_id' => $user->id, - 'started_at' => Carbon::parse('2026-07-15 09:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-15 10:00', 'Europe/Amsterdam'), - ]); - - $entries = UserEntriesInDay::query($user->id, Carbon::parse('2026-07-16', 'Europe/Amsterdam'))->get(); - - expect($entries->pluck('id')->all())->toBe([$overlapping->id]); -}); - -test('user dismissed suggestions returns trashed suggestions without entry for the date', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - $user = User::factory()->create(); - $dismissed = EntrySuggestion::factory()->create([ - 'user_id' => $user->id, - 'date' => '2026-07-16', - 'deleted_at' => now(), - ]); - $accepted = EntrySuggestion::factory()->create([ - 'user_id' => $user->id, - 'date' => '2026-07-16', - 'deleted_at' => now(), - ]); - Entry::factory()->create([ - 'user_id' => $user->id, - 'entry_suggestion_id' => $accepted->id, - 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), - ]); - EntrySuggestion::factory()->create([ - 'user_id' => $user->id, - 'date' => '2026-07-16', - ]); - - $suggestions = UserDismissedSuggestionsOnDate::query($user->id, Carbon::parse('2026-07-16', 'Europe/Amsterdam'))->get(); - - expect($suggestions->pluck('id')->all())->toBe([$dismissed->id]); -}); From 6e7371eaab1871db5672e13c52d310975bfaf6e5 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 13:17:46 +0200 Subject: [PATCH 28/37] =?UTF-8?q?refactor:=20reorder=20delete/save=20to=20?= =?UTF-8?q?match=20activity=20=E2=86=92=20suggestion=20dependency?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude --- app/Jobs/RebuildUserDay.php | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/app/Jobs/RebuildUserDay.php b/app/Jobs/RebuildUserDay.php index 7c5689b..640c3ac 100644 --- a/app/Jobs/RebuildUserDay.php +++ b/app/Jobs/RebuildUserDay.php @@ -66,18 +66,18 @@ public function handle(ActivityProjector $activityProjector, SuggestionProjector private function deleteProjectedState(CarbonInterface $day): void { - Activity::query() - ->where('user_id', $this->userId) - ->where('ended_at', '>=', $day) - ->where('ended_at', '<', $day->copy()->addDay()) - ->delete(); - EntrySuggestion::query() ->where('user_id', $this->userId) ->where('date', $day->toDateString()) ->whereDoesntHave('entry') ->whereNull('deleted_at') ->forceDelete(); + + Activity::query() + ->where('user_id', $this->userId) + ->where('ended_at', '>=', $day) + ->where('ended_at', '<', $day->copy()->addDay()) + ->delete(); } /** @@ -86,14 +86,15 @@ private function deleteProjectedState(CarbonInterface $day): void */ private function saveProjectedState(Collection $activities, Collection $suggestions): void { - $suggestions->each(function (EntrySuggestion $suggestion) { - $suggestion->save(); - $suggestion->activities->each(fn (Activity $activity) => $activity->entry_suggestion_id = $suggestion->id); - }); - $activities->each(function (Activity $activity) { $activity->save(); $activity->events()->saveMany($activity->events); }); + + $suggestions->each(function (EntrySuggestion $suggestion) { + $suggestion->save(); + Activity::whereKey($suggestion->activities->pluck('id')) + ->update(['entry_suggestion_id' => $suggestion->id]); + }); } } From 56050b717217224332b42b88e83c3f9aa8c8ee45 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 17:20:59 +0200 Subject: [PATCH 29/37] rename to timeslots --- app/Jobs/RebuildUserDay.php | 4 ++-- app/Services/ActivityProjector.php | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/Jobs/RebuildUserDay.php b/app/Jobs/RebuildUserDay.php index 640c3ac..642a04f 100644 --- a/app/Jobs/RebuildUserDay.php +++ b/app/Jobs/RebuildUserDay.php @@ -42,7 +42,7 @@ public function handle(ActivityProjector $activityProjector, SuggestionProjector ->where('ended_at', '<', $day->copy()->addDay()) ->get(); - $entryPeriods = Entry::query() + $entryTimeSlots = Entry::query() ->where('user_id', $this->userId) ->where('started_at', '<', $day->copy()->addDay()) ->where('ended_at', '>', $day) @@ -55,7 +55,7 @@ public function handle(ActivityProjector $activityProjector, SuggestionProjector ->where('date', $day->toDateString()) ->get(); - $activities = $activityProjector->project($events, $entryPeriods); + $activities = $activityProjector->project($events, $entryTimeSlots); $suggestions = $suggestionProjector->project($activities, $dismissedSuggestions, $day); $db->transaction(function () use ($activities, $suggestions, $day) { diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php index 49628d1..0799ea8 100644 --- a/app/Services/ActivityProjector.php +++ b/app/Services/ActivityProjector.php @@ -19,15 +19,15 @@ class ActivityProjector /** * @param Collection $events - * @param Collection $entryPeriods + * @param Collection $entryTimeSlots * @return Collection */ - public function project(Collection $events, Collection $entryPeriods): Collection + public function project(Collection $events, Collection $entryTimeSlots): Collection { $groups = $this->chainEventsIntoGroups($events); $activities = $this->resolveWeightDominance($groups); - return $this->trimAroundEntryPeriods($activities, $entryPeriods)->values(); + return $this->trimAroundEntryPeriods($activities, $entryTimeSlots)->values(); } /** @@ -207,17 +207,17 @@ private function weight(EventGroup $group): int /** * @param Collection $activities - * @param Collection $entryPeriods + * @param Collection $entryTimeSlots * @return Collection */ - private function trimAroundEntryPeriods(Collection $activities, Collection $entryPeriods): Collection + private function trimAroundEntryPeriods(Collection $activities, Collection $entryTimeSlots): Collection { - if ($entryPeriods->isEmpty()) { + if ($entryTimeSlots->isEmpty()) { return $activities; } - return $activities->flatMap(function (Activity $activity) use ($entryPeriods) { - $segments = (new TimeSlot($activity->started_at, $activity->ended_at))->subtract($entryPeriods); + return $activities->flatMap(function (Activity $activity) use ($entryTimeSlots) { + $segments = (new TimeSlot($activity->started_at, $activity->ended_at))->subtract($entryTimeSlots); $first = $segments->first(); $activityUnchanged = $segments->count() === 1 From 0cc05d78d3d919d8a48da510b751ad9f346bbeb2 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 17:46:08 +0200 Subject: [PATCH 30/37] fix: process activity groups by weight tier to correctly split overlapping periods Replace the buggy `resolveWeightDominance` post-processing step with weight-tier-first group building. Events are now grouped by weight (highest first), chained within each tier, then lower-weight groups are subtracted against already-claimed periods using spatie/period. This fixes the case where a high-weight event fully inside a low-weight event should produce three activities (before, dominant, after) but only produced two because `partition` consumed the spanning event. TimeSlot now extends `Spatie\Period\Period`, replacing hand-rolled `subtract`/`overlaps`/`covers` with Period's `subtract`/`overlapsWith`/ `contains`. Claude --- app/DataTransferObjects/TimeSlot.php | 69 ++---- app/Services/ActivityProjector.php | 215 ++++++++++-------- composer.json | 1 + composer.lock | 56 ++++- tests/Unit/Services/ActivityProjectorTest.php | 34 +++ 5 files changed, 236 insertions(+), 139 deletions(-) diff --git a/app/DataTransferObjects/TimeSlot.php b/app/DataTransferObjects/TimeSlot.php index 51a13c4..2acedbb 100644 --- a/app/DataTransferObjects/TimeSlot.php +++ b/app/DataTransferObjects/TimeSlot.php @@ -2,55 +2,34 @@ namespace App\DataTransferObjects; +use Carbon\Carbon; use Carbon\CarbonInterface; -use Illuminate\Support\Collection; +use DateTimeImmutable; +use DateTimeInterface; +use Spatie\Period\Boundaries; +use Spatie\Period\Period; +use Spatie\Period\Precision; -readonly class TimeSlot +class TimeSlot extends Period { - public function __construct( - public CarbonInterface $startedAt, - public CarbonInterface $endedAt, - ) {} - - public function overlaps(TimeSlot $other): bool - { - return $this->startedAt->lessThan($other->endedAt) - && $this->endedAt->greaterThan($other->startedAt); - } - - public function covers(TimeSlot $other): bool - { - return $this->startedAt->lessThanOrEqualTo($other->startedAt) - && $this->endedAt->greaterThanOrEqualTo($other->endedAt); - } + public readonly CarbonInterface $startedAt; - /** - * @param Collection $blockers - * @return Collection - */ - public function subtract(Collection $blockers): Collection - { - /** @var Collection $segments */ - $segments = collect([$this]); + public readonly CarbonInterface $endedAt; - foreach ($blockers->sortBy('startedAt') as $blocker) { - $segments = $segments->flatMap(function (TimeSlot $segment) use ($blocker) { - if (! $segment->overlaps($blocker)) { - return [$segment]; - } - - $remaining = []; - if ($blocker->startedAt->greaterThan($segment->startedAt)) { - $remaining[] = new TimeSlot($segment->startedAt, $blocker->startedAt); - } - if ($blocker->endedAt->lessThan($segment->endedAt)) { - $remaining[] = new TimeSlot($blocker->endedAt, $segment->endedAt); - } - - return $remaining; - }); - } - - return $segments->values(); + public function __construct( + DateTimeInterface $start, + DateTimeInterface $end, + ?Precision $precision = null, + ?Boundaries $boundaries = null, + ) { + parent::__construct( + $start instanceof DateTimeImmutable ? $start : DateTimeImmutable::createFromInterface($start), + $end instanceof DateTimeImmutable ? $end : DateTimeImmutable::createFromInterface($end), + $precision ?? Precision::SECOND(), + $boundaries ?? Boundaries::EXCLUDE_END(), + ); + + $this->startedAt = $start instanceof CarbonInterface ? $start : Carbon::instance($start); + $this->endedAt = $end instanceof CarbonInterface ? $end : Carbon::instance($end); } } diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php index 0799ea8..693dd95 100644 --- a/app/Services/ActivityProjector.php +++ b/app/Services/ActivityProjector.php @@ -10,6 +10,8 @@ use Carbon\CarbonInterface; use Closure; use Illuminate\Support\Collection; +use Spatie\Period\Period; +use Spatie\Period\PeriodCollection; class ActivityProjector { @@ -24,17 +26,63 @@ class ActivityProjector */ public function project(Collection $events, Collection $entryTimeSlots): Collection { - $groups = $this->chainEventsIntoGroups($events); - $activities = $this->resolveWeightDominance($groups); + $activities = $this->buildActivities($events); return $this->trimAroundEntryPeriods($activities, $entryTimeSlots)->values(); } + /** + * @param Collection $events + * @return Collection + */ + private function buildActivities(Collection $events): Collection + { + $tiers = $events->groupBy(fn (Event $event) => (int) $event->eventType?->weight) + ->sortKeysDesc(); + + /** @var Collection $activities */ + $activities = collect(); + $claimed = new PeriodCollection; + + foreach ($tiers as $tierEvents) { + $groups = $this->chainTier($tierEvents); + $tierClaimed = new PeriodCollection; + + foreach ($groups->sortBy('startedAt')->values() as $group) { + $blockers = new PeriodCollection(...[...$claimed, ...$tierClaimed]); + $groupPeriod = $group->period(); + $segments = PeriodCollection::make($groupPeriod)->subtract($blockers); + + if ($segments->isEmpty()) { + $group->events->each(fn (Event $event) => $this->attachToCoveringActivity($event, $group, $activities)); + + continue; + } + + foreach ($segments as $segment) { + $segmentEvents = $group->events->filter(fn (Event $event) => $this->eventPeriod($event)->overlapsWith($segment)); + + if ($segmentEvents->isEmpty()) { + continue; + } + + $activities->push($this->activityFromGroup($group, $segment, $segmentEvents)); + } + + $tierClaimed = $tierClaimed->add($groupPeriod); + } + + $claimed = new PeriodCollection(...[...$claimed, ...$tierClaimed]); + } + + return $activities; + } + /** * @param Collection $events * @return Collection */ - private function chainEventsIntoGroups(Collection $events): Collection + private function chainTier(Collection $events): Collection { $sorted = $events->sortBy(fn (Event $event) => $this->effectiveStart($event))->values(); @@ -50,26 +98,43 @@ private function chainEventsIntoGroups(Collection $events): Collection continue; } + $eventStart = $this->effectiveStart($event); + if ($event->ticket_number === null) { - $preceding = $this->lastChainableGroup($groups, $event, - fn (EventGroup $group) => $group->customerId === $event->customer_id); - $preceding ? $preceding->add($event, $this->effectiveStart($event)) : $unclaimed->push($event); + $recentGroup = $this->recentChainableGroup($groups, $event, + fn (EventGroup $group) => $group->customerId === $event->customer_id + ); + + if ($recentGroup) { + $recentGroup->add($event, $eventStart); + } else { + $unclaimed->push($event); + } continue; } - $matching = $this->lastChainableGroup($groups, $event, + $matchingGroup = $this->recentChainableGroup($groups, $event, fn (EventGroup $group) => $group->customerId === $event->customer_id && $group->ticketNumber === $event->ticket_number - && $group->eventTypeId === $event->event_type_id); - $matching ? $matching->add($event, $this->effectiveStart($event)) : $groups->push($this->newGroup($event)); + && $group->eventTypeId === $event->event_type_id + ); + + if ($matchingGroup) { + $matchingGroup->add($event, $eventStart); + } else { + $groups->push($this->newGroup($event)); + } } foreach ($unclaimed as $event) { - $following = $groups->first(fn (EventGroup $group) => $group->customerId === $event->customer_id - && $group->startedAt->lessThanOrEqualTo($event->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES)) - && $group->endedAt->greaterThanOrEqualTo($this->effectiveStart($event))); - $following ? $following->add($event, $this->effectiveStart($event)) : $groups->push($this->newGroup($event)); + $eventStart = $this->effectiveStart($event); + + $following = $this->recentChainableGroup($groups, $event, + fn (EventGroup $group) => $group->customerId === $event->customer_id + ); + + $following ? $following->add($event, $eventStart) : $groups->push($this->newGroup($event)); } return $groups; @@ -79,12 +144,39 @@ private function chainEventsIntoGroups(Collection $events): Collection * @param Collection $groups * @param Closure(EventGroup): bool $matches */ - private function lastChainableGroup(Collection $groups, Event $event, Closure $matches): ?EventGroup + private function recentChainableGroup(Collection $groups, Event $event, Closure $matches): ?EventGroup + { + return $this->lastRecentChainableGroup($groups, $event, $matches) + ?? $this->nextRecentChainableGroup($groups, $event, $matches); + } + + /** + * @param Collection $groups + * @param Closure(EventGroup): bool $matches + */ + private function lastRecentChainableGroup(Collection $groups, Event $event, Closure $matches): ?EventGroup { - $effectiveStart = $this->effectiveStart($event); + $eventStart = $this->effectiveStart($event); + + return $groups + ->filter(fn (EventGroup $group) => $eventStart->isBefore($group->endedAt->copy()->addMinutes(self::CHAIN_GAP_MINUTES))) + ->last(fn (EventGroup $group) => $matches($group)); + } - return $groups->last(fn (EventGroup $group) => $matches($group) - && $effectiveStart->lessThanOrEqualTo($group->endedAt->copy()->addMinutes(self::CHAIN_GAP_MINUTES))); + /** + * @param Collection $groups + * @param Closure(EventGroup): bool $matches + */ + private function nextRecentChainableGroup(Collection $groups, Event $event, Closure $matches): ?EventGroup + { + $eventStart = $this->effectiveStart($event); + $eventEnd = $event->ended_at->copy(); + + return $groups + ->filter(fn (EventGroup $group) => $group->endedAt->isAfter($eventStart) + && $group->startedAt->isBefore($eventEnd->addMinutes(self::CHAIN_GAP_MINUTES)) + ) + ->first(fn (EventGroup $group) => $matches($group)); } private function newGroup(Event $event): EventGroup @@ -106,7 +198,7 @@ private function effectiveStart(Event $event): CarbonInterface /** * @param Collection $events */ - private function activityFromGroup(EventGroup $group, TimeSlot $period, Collection $events): Activity + private function activityFromGroup(EventGroup $group, Period $period, Collection $events): Activity { /** @var Event $template */ $template = $events->sortBy(fn (Event $event) => $this->effectiveStart($event))->first(); @@ -123,66 +215,14 @@ private function activityFromGroup(EventGroup $group, TimeSlot $period, Collecti $activity->customer_id = $group->customerId; $activity->is_internal = $template->is_internal; $activity->event_type_id = $group->eventTypeId; - $activity->started_at = Carbon::instance($period->startedAt); - $activity->ended_at = Carbon::instance($period->endedAt); + $activity->started_at = Carbon::instance($period->start()); + $activity->ended_at = Carbon::instance($period->end()); $activity->setRelation('events', $events->values()); $activity->setRelation('eventType', $template->eventType); return $activity; } - /** - * @param Collection $groups - * @return Collection - */ - private function resolveWeightDominance(Collection $groups): Collection - { - $ranked = $groups->sortBy([ - fn (EventGroup $a, EventGroup $b) => $this->weight($b) <=> $this->weight($a), - fn (EventGroup $a, EventGroup $b) => $a->startedAt <=> $b->startedAt, - ])->values(); - - /** @var Collection $accepted */ - $accepted = collect(); - - foreach ($ranked as $group) { - $blockers = $accepted->map(fn (Activity $activity) => new TimeSlot($activity->started_at, $activity->ended_at)); - $segments = $group->period()->subtract($blockers); - - $accepted = $accepted->concat($this->activitiesFromSegments($group, $segments, $accepted)); - } - - return $accepted; - } - - /** - * @param Collection $segments - * @param Collection $coveringCandidates - * @return Collection - */ - private function activitiesFromSegments(EventGroup $group, Collection $segments, Collection $coveringCandidates): Collection - { - /** @var Collection $activities */ - $activities = collect(); - $remaining = $group->events; - - foreach ($segments as $segment) { - $partitioned = $remaining->partition(fn (Event $event) => $this->eventPeriod($event)->overlaps($segment)); - $segmentEvents = $partitioned->get(0, collect()); - $remaining = $partitioned->get(1, collect()); - - if ($segmentEvents->isEmpty()) { - continue; - } - - $activities->push($this->activityFromGroup($group, $segment, $segmentEvents->values())); - } - - $remaining->each(fn (Event $event) => $this->attachToCoveringActivity($event, $group, $coveringCandidates)); - - return $activities; - } - /** * @param Collection $candidates */ @@ -190,7 +230,7 @@ private function attachToCoveringActivity(Event $event, EventGroup $group, Colle { $covering = $candidates->first(fn (Activity $activity) => $activity->customer_id === $group->customerId && $activity->ticket_number === $group->ticketNumber - && (new TimeSlot($activity->started_at, $activity->ended_at))->covers($this->eventPeriod($event))); + && (new TimeSlot($activity->started_at, $activity->ended_at))->contains($this->eventPeriod($event))); $covering?->events->push($event); } @@ -200,11 +240,6 @@ private function eventPeriod(Event $event): TimeSlot return new TimeSlot($this->effectiveStart($event), $event->ended_at); } - private function weight(EventGroup $group): int - { - return (int) $group->events->first()?->eventType?->weight; - } - /** * @param Collection $activities * @param Collection $entryTimeSlots @@ -216,25 +251,19 @@ private function trimAroundEntryPeriods(Collection $activities, Collection $entr return $activities; } - return $activities->flatMap(function (Activity $activity) use ($entryTimeSlots) { - $segments = (new TimeSlot($activity->started_at, $activity->ended_at))->subtract($entryTimeSlots); + $blockers = new PeriodCollection(...$entryTimeSlots->all()); - $first = $segments->first(); - $activityUnchanged = $segments->count() === 1 - && $first !== null - && $first->startedAt->equalTo($activity->started_at) - && $first->endedAt->equalTo($activity->ended_at); + return $activities->flatMap(function (Activity $activity) use ($blockers) { + $activityPeriod = new TimeSlot($activity->started_at, $activity->ended_at); + $segments = PeriodCollection::make($activityPeriod)->subtract($blockers); - if ($activityUnchanged) { + if (count($segments) === 1 && $segments[0]->equals($activityPeriod)) { return [$activity]; } $splits = []; - $remaining = $activity->events; foreach ($segments as $segment) { - $partitioned = $remaining->partition(fn (Event $event) => $this->eventPeriod($event)->overlaps($segment)); - $segmentEvents = $partitioned->get(0, collect()); - $remaining = $partitioned->get(1, collect()); + $segmentEvents = $activity->events->filter(fn (Event $event) => $this->eventPeriod($event)->overlapsWith($segment)); if ($segmentEvents->isEmpty()) { continue; @@ -250,11 +279,11 @@ private function trimAroundEntryPeriods(Collection $activities, Collection $entr /** * @param Collection $events */ - private function cloneActivityForSegment(Activity $activity, TimeSlot $segment, Collection $events): Activity + private function cloneActivityForSegment(Activity $activity, Period $segment, Collection $events): Activity { $split = $activity->replicate(['started_at', 'ended_at']); - $split->started_at = Carbon::instance($segment->startedAt); - $split->ended_at = Carbon::instance($segment->endedAt); + $split->started_at = Carbon::instance($segment->start()); + $split->ended_at = Carbon::instance($segment->end()); $split->setRelation('events', $events); $split->setRelation('eventType', $activity->eventType); diff --git a/composer.json b/composer.json index 5d1e324..338a7f9 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "spatie/laravel-json-api-paginate": "^1.16", "spatie/laravel-permission": "^6.17", "spatie/laravel-query-builder": "^6.3", + "spatie/period": "^2.4", "timacdonald/json-api": "dev-main as 1.0.0-beta.99", "timatic/bitbucket-integration": "1.0.0", "timatic/exact-globe-integration": "1.0.0", diff --git a/composer.lock b/composer.lock index 7040458..145e251 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "19a18161bd058e101a1580d531af32c2", + "content-hash": "7f4edd503ccc3309e1351e80f952b416", "packages": [ { "name": "aws/aws-crt-php", @@ -7745,6 +7745,60 @@ ], "time": "2026-03-08T13:45:05+00:00" }, + { + "name": "spatie/period", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/period.git", + "reference": "85fbbea7b24fdff0c924aeed5b109be93c025850" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/period/zipball/85fbbea7b24fdff0c924aeed5b109be93c025850", + "reference": "85fbbea7b24fdff0c924aeed5b109be93c025850", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "larapack/dd": "^1.1", + "nesbot/carbon": "^2.63", + "pestphp/pest": "^1.22", + "phpunit/phpunit": "^9.5", + "spatie/ray": "^1.31" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Period\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brent Roose", + "email": "brent@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Complex period comparisons", + "homepage": "https://github.com/spatie/period", + "keywords": [ + "period", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/period/issues", + "source": "https://github.com/spatie/period/tree/2.4.0" + }, + "time": "2023-02-20T14:31:09+00:00" + }, { "name": "spatie/shiki-php", "version": "2.4.0", diff --git a/tests/Unit/Services/ActivityProjectorTest.php b/tests/Unit/Services/ActivityProjectorTest.php index d03645d..46749a7 100644 --- a/tests/Unit/Services/ActivityProjectorTest.php +++ b/tests/Unit/Services/ActivityProjectorTest.php @@ -513,3 +513,37 @@ expect($activities)->toBeEmpty(); }); + +test('a high-weight event inside a low-weight event splits the low-weight into two activities', function () { + $meeting = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 09:00'), + 'ended_at' => Carbon::parse('2026-07-16 10:00'), + ]); + $meeting->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 1])); + $commit = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerY', + 'ticket_number' => 'TIC-2', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 09:30'), + 'ended_at' => Carbon::parse('2026-07-16 09:45'), + ]); + $commit->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 999])); + + $activities = (new ActivityProjector)->project(collect([$meeting, $commit]), collect()); + + $meetingActivities = $activities->filter(fn ($a) => $a->ticket_number === 'TIC-1')->values(); + $commitActivity = $activities->first(fn ($a) => $a->ticket_number === 'TIC-2'); + expect($activities)->toHaveCount(3) + ->and($meetingActivities)->toHaveCount(2) + ->and($meetingActivities[0]->started_at)->toEqual(Carbon::parse('2026-07-16 09:00')) + ->and($meetingActivities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 09:30')) + ->and($commitActivity->started_at)->toEqual(Carbon::parse('2026-07-16 09:30')) + ->and($commitActivity->ended_at)->toEqual(Carbon::parse('2026-07-16 09:45')) + ->and($meetingActivities[1]->started_at)->toEqual(Carbon::parse('2026-07-16 09:45')) + ->and($meetingActivities[1]->ended_at)->toEqual(Carbon::parse('2026-07-16 10:00')); +}); From a0dd2f39906ccb2e7dacdcad95ca18a199bb39cc Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 18:18:08 +0200 Subject: [PATCH 31/37] refactor: replace EventGroup and TimeSlot with Activity throughout projector --- app/DataTransferObjects/EventGroup.php | 41 --- app/DataTransferObjects/TimeSlot.php | 35 -- app/Jobs/RebuildUserDay.php | 8 +- app/Models/Event.php | 8 + app/Services/ActivityProjector.php | 319 ++++++++---------- .../Unit/DataTransferObjects/TimeSlotTest.php | 68 ---- tests/Unit/Services/ActivityProjectorTest.php | 8 +- 7 files changed, 159 insertions(+), 328 deletions(-) delete mode 100644 app/DataTransferObjects/EventGroup.php delete mode 100644 app/DataTransferObjects/TimeSlot.php delete mode 100644 tests/Unit/DataTransferObjects/TimeSlotTest.php diff --git a/app/DataTransferObjects/EventGroup.php b/app/DataTransferObjects/EventGroup.php deleted file mode 100644 index 04a2974..0000000 --- a/app/DataTransferObjects/EventGroup.php +++ /dev/null @@ -1,41 +0,0 @@ - */ - public Collection $events; - - public CarbonInterface $startedAt; - - public CarbonInterface $endedAt; - - public function __construct( - public readonly ?string $customerId, - public readonly ?string $ticketNumber, - public readonly ?string $eventTypeId, - Event $event, - CarbonInterface $effectiveStart, - ) { - $this->events = collect([$event]); - $this->startedAt = $effectiveStart; - $this->endedAt = $event->ended_at; - } - - public function add(Event $event, CarbonInterface $effectiveStart): void - { - $this->events->push($event); - $this->startedAt = $this->startedAt->min($effectiveStart); - $this->endedAt = $this->endedAt->max($event->ended_at); - } - - public function period(): TimeSlot - { - return new TimeSlot($this->startedAt, $this->endedAt); - } -} diff --git a/app/DataTransferObjects/TimeSlot.php b/app/DataTransferObjects/TimeSlot.php deleted file mode 100644 index 2acedbb..0000000 --- a/app/DataTransferObjects/TimeSlot.php +++ /dev/null @@ -1,35 +0,0 @@ -startedAt = $start instanceof CarbonInterface ? $start : Carbon::instance($start); - $this->endedAt = $end instanceof CarbonInterface ? $end : Carbon::instance($end); - } -} diff --git a/app/Jobs/RebuildUserDay.php b/app/Jobs/RebuildUserDay.php index 642a04f..448caf0 100644 --- a/app/Jobs/RebuildUserDay.php +++ b/app/Jobs/RebuildUserDay.php @@ -2,7 +2,6 @@ namespace App\Jobs; -use App\DataTransferObjects\TimeSlot; use App\Models\Activity; use App\Models\Entry; use App\Models\EntrySuggestion; @@ -42,12 +41,11 @@ public function handle(ActivityProjector $activityProjector, SuggestionProjector ->where('ended_at', '<', $day->copy()->addDay()) ->get(); - $entryTimeSlots = Entry::query() + $entries = Entry::query() ->where('user_id', $this->userId) ->where('started_at', '<', $day->copy()->addDay()) ->where('ended_at', '>', $day) - ->get() - ->map(fn (Entry $entry) => new TimeSlot($entry->started_at, $entry->ended_at)); + ->get(); $dismissedSuggestions = EntrySuggestion::onlyTrashed() ->whereDoesntHave('entry') @@ -55,7 +53,7 @@ public function handle(ActivityProjector $activityProjector, SuggestionProjector ->where('date', $day->toDateString()) ->get(); - $activities = $activityProjector->project($events, $entryTimeSlots); + $activities = $activityProjector->project($events, $entries); $suggestions = $suggestionProjector->project($activities, $dismissedSuggestions, $day); $db->transaction(function () use ($activities, $suggestions, $day) { diff --git a/app/Models/Event.php b/app/Models/Event.php index 66cb166..ea1bd4e 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Events\EventCreated; +use Carbon\CarbonInterface; use Database\Factories\EventFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -37,6 +38,8 @@ class Event extends Model /** @use HasFactory */ use HasFactory; + private const ESTIMATED_DURATION_MINUTES = 15; + protected $fillable = [ 'user_id', 'budget_id', @@ -102,4 +105,9 @@ public function eventType(): BelongsTo { return $this->belongsTo(EventType::class); } + + public function effectiveStart(): CarbonInterface + { + return $this->started_at ?: $this->ended_at->copy()->subMinutes(self::ESTIMATED_DURATION_MINUTES); + } } diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php index 693dd95..2f22ea7 100644 --- a/app/Services/ActivityProjector.php +++ b/app/Services/ActivityProjector.php @@ -2,284 +2,243 @@ namespace App\Services; -use App\DataTransferObjects\EventGroup; -use App\DataTransferObjects\TimeSlot; use App\Models\Activity; +use App\Models\Entry; use App\Models\Event; use Carbon\Carbon; use Carbon\CarbonInterface; use Closure; +use DateTimeImmutable; use Illuminate\Support\Collection; +use Spatie\Period\Boundaries; use Spatie\Period\Period; use Spatie\Period\PeriodCollection; +use Spatie\Period\Precision; class ActivityProjector { private const CHAIN_GAP_MINUTES = 15; - private const ESTIMATED_DURATION_MINUTES = 15; - /** * @param Collection $events - * @param Collection $entryTimeSlots + * @param Collection $entries * @return Collection */ - public function project(Collection $events, Collection $entryTimeSlots): Collection + public function project(Collection $events, Collection $entries): Collection { - $activities = $this->buildActivities($events); + $activities = $this->chainEvents($events); - return $this->trimAroundEntryPeriods($activities, $entryTimeSlots)->values(); + return $this->reduceOverlap($activities, $entries); } /** * @param Collection $events * @return Collection */ - private function buildActivities(Collection $events): Collection + private function chainEvents(Collection $events): Collection { - $tiers = $events->groupBy(fn (Event $event) => (int) $event->eventType?->weight) - ->sortKeysDesc(); + $sorted = $events->sort(function (Event $a, Event $b) { + return ((int) $b->eventType?->weight <=> (int) $a->eventType?->weight) + ?: $a->effectiveStart()->getTimestamp() <=> $b->effectiveStart()->getTimestamp(); + })->values(); /** @var Collection $activities */ $activities = collect(); - $claimed = new PeriodCollection; + /** @var Collection $unclaimed */ + $unclaimed = collect(); - foreach ($tiers as $tierEvents) { - $groups = $this->chainTier($tierEvents); - $tierClaimed = new PeriodCollection; + foreach ($sorted as $event) { + if ($event->customer_id === null) { + $activities->push($this->activityFromEvent($event)); - foreach ($groups->sortBy('startedAt')->values() as $group) { - $blockers = new PeriodCollection(...[...$claimed, ...$tierClaimed]); - $groupPeriod = $group->period(); - $segments = PeriodCollection::make($groupPeriod)->subtract($blockers); + continue; + } - if ($segments->isEmpty()) { - $group->events->each(fn (Event $event) => $this->attachToCoveringActivity($event, $group, $activities)); + if ($event->ticket_number === null) { + $match = $this->findChainableActivity($activities, $event, + fn (Activity $a) => $a->customer_id === $event->customer_id + ); - continue; + if ($match) { + $this->appendEvent($match, $event); + } else { + $unclaimed->push($event); } - foreach ($segments as $segment) { - $segmentEvents = $group->events->filter(fn (Event $event) => $this->eventPeriod($event)->overlapsWith($segment)); - - if ($segmentEvents->isEmpty()) { - continue; - } + continue; + } - $activities->push($this->activityFromGroup($group, $segment, $segmentEvents)); - } + $match = $this->findChainableActivity($activities, $event, + fn (Activity $a) => $a->customer_id === $event->customer_id + && $a->ticket_number === $event->ticket_number + && $a->event_type_id === $event->event_type_id + ); - $tierClaimed = $tierClaimed->add($groupPeriod); + if ($match) { + $this->appendEvent($match, $event); + } else { + $activities->push($this->activityFromEvent($event)); } + } + + foreach ($unclaimed as $event) { + $match = $this->findChainableActivity($activities, $event, + fn (Activity $a) => $a->customer_id === $event->customer_id + ); - $claimed = new PeriodCollection(...[...$claimed, ...$tierClaimed]); + if ($match) { + $this->appendEvent($match, $event); + } else { + $activities->push($this->activityFromEvent($event)); + } } return $activities; } /** - * @param Collection $events - * @return Collection + * @param Collection $activities + * @param Collection $entries + * @return Collection */ - private function chainTier(Collection $events): Collection + private function reduceOverlap(Collection $activities, Collection $entries): Collection { - $sorted = $events->sortBy(fn (Event $event) => $this->effectiveStart($event))->values(); + $sorted = $activities->sort(function (Activity $a, Activity $b) { + return ((int) $b->eventType?->weight <=> (int) $a->eventType?->weight) + ?: $a->started_at->getTimestamp() <=> $b->started_at->getTimestamp(); + })->values(); - /** @var Collection $groups */ - $groups = collect(); - /** @var Collection $unclaimed */ - $unclaimed = collect(); + $claimed = new PeriodCollection( + ...$entries->map(fn (Entry $entry) => $this->period($entry->started_at, $entry->ended_at))->all() + ); - foreach ($sorted as $event) { - if ($event->customer_id === null) { - $groups->push($this->newGroup($event)); + /** @var Collection $result */ + $result = collect(); + + foreach ($sorted as $activity) { + $activityPeriod = $this->period($activity->started_at, $activity->ended_at); + $segments = PeriodCollection::make($activityPeriod)->subtract($claimed); + + if ($segments->isEmpty()) { + $this->attachEventsToCovers($activity, $result); continue; } - $eventStart = $this->effectiveStart($event); - - if ($event->ticket_number === null) { - $recentGroup = $this->recentChainableGroup($groups, $event, - fn (EventGroup $group) => $group->customerId === $event->customer_id + foreach ($segments as $segment) { + $segmentEvents = $activity->events->filter( + fn (Event $event) => $this->period($event->effectiveStart(), $event->ended_at) + ->overlapsWith($segment) ); - if ($recentGroup) { - $recentGroup->add($event, $eventStart); - } else { - $unclaimed->push($event); + if ($segmentEvents->isEmpty()) { + continue; } - continue; + $result->push($this->splitActivity($activity, $segment, $segmentEvents->values())); } - $matchingGroup = $this->recentChainableGroup($groups, $event, - fn (EventGroup $group) => $group->customerId === $event->customer_id - && $group->ticketNumber === $event->ticket_number - && $group->eventTypeId === $event->event_type_id - ); - - if ($matchingGroup) { - $matchingGroup->add($event, $eventStart); - } else { - $groups->push($this->newGroup($event)); - } + $claimed = $claimed->add($activityPeriod); } - foreach ($unclaimed as $event) { - $eventStart = $this->effectiveStart($event); - - $following = $this->recentChainableGroup($groups, $event, - fn (EventGroup $group) => $group->customerId === $event->customer_id - ); - - $following ? $following->add($event, $eventStart) : $groups->push($this->newGroup($event)); - } - - return $groups; + return $result->values(); } /** - * @param Collection $groups - * @param Closure(EventGroup): bool $matches + * @param Collection $activities + * @param Closure(Activity): bool $matches */ - private function recentChainableGroup(Collection $groups, Event $event, Closure $matches): ?EventGroup + private function findChainableActivity(Collection $activities, Event $event, Closure $matches): ?Activity { - return $this->lastRecentChainableGroup($groups, $event, $matches) - ?? $this->nextRecentChainableGroup($groups, $event, $matches); + return $this->precedingChainableActivity($activities, $event, $matches) + ?? $this->followingChainableActivity($activities, $event, $matches); } /** - * @param Collection $groups - * @param Closure(EventGroup): bool $matches + * @param Collection $activities + * @param Closure(Activity): bool $matches */ - private function lastRecentChainableGroup(Collection $groups, Event $event, Closure $matches): ?EventGroup + private function precedingChainableActivity(Collection $activities, Event $event, Closure $matches): ?Activity { - $eventStart = $this->effectiveStart($event); + $eventStart = $event->effectiveStart(); - return $groups - ->filter(fn (EventGroup $group) => $eventStart->isBefore($group->endedAt->copy()->addMinutes(self::CHAIN_GAP_MINUTES))) - ->last(fn (EventGroup $group) => $matches($group)); + return $activities + ->filter(fn (Activity $a) => $eventStart->isBefore($a->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES))) + ->last(fn (Activity $a) => $matches($a)); } /** - * @param Collection $groups - * @param Closure(EventGroup): bool $matches + * @param Collection $activities + * @param Closure(Activity): bool $matches */ - private function nextRecentChainableGroup(Collection $groups, Event $event, Closure $matches): ?EventGroup + private function followingChainableActivity(Collection $activities, Event $event, Closure $matches): ?Activity { - $eventStart = $this->effectiveStart($event); + $eventStart = $event->effectiveStart(); $eventEnd = $event->ended_at->copy(); - return $groups - ->filter(fn (EventGroup $group) => $group->endedAt->isAfter($eventStart) - && $group->startedAt->isBefore($eventEnd->addMinutes(self::CHAIN_GAP_MINUTES)) + return $activities + ->filter(fn (Activity $a) => $a->ended_at->isAfter($eventStart) + && $a->started_at->isBefore($eventEnd->addMinutes(self::CHAIN_GAP_MINUTES)) ) - ->first(fn (EventGroup $group) => $matches($group)); - } - - private function newGroup(Event $event): EventGroup - { - return new EventGroup( - customerId: $event->customer_id, - ticketNumber: $event->ticket_number, - eventTypeId: $event->event_type_id, - event: $event, - effectiveStart: $this->effectiveStart($event), - ); + ->first(fn (Activity $a) => $matches($a)); } - private function effectiveStart(Event $event): CarbonInterface + private function activityFromEvent(Event $event): Activity { - return $event->started_at ?: $event->ended_at->copy()->subMinutes(self::ESTIMATED_DURATION_MINUTES); - } - - /** - * @param Collection $events - */ - private function activityFromGroup(EventGroup $group, Period $period, Collection $events): Activity - { - /** @var Event $template */ - $template = $events->sortBy(fn (Event $event) => $this->effectiveStart($event))->first(); - $activity = new Activity; - $activity->source_id = $template->source_id; - $activity->user_id = $template->user_id; - $activity->budget_id = $template->budget_id; - $activity->ticket_id = $template->ticket_id; - $activity->ticket_number = $group->ticketNumber; - $activity->ticket_type = $template->ticket_type; - $activity->title = $template->title; - $activity->description = $template->description; - $activity->customer_id = $group->customerId; - $activity->is_internal = $template->is_internal; - $activity->event_type_id = $group->eventTypeId; - $activity->started_at = Carbon::instance($period->start()); - $activity->ended_at = Carbon::instance($period->end()); - $activity->setRelation('events', $events->values()); - $activity->setRelation('eventType', $template->eventType); + $activity->source_id = $event->source_id; + $activity->user_id = $event->user_id; + $activity->budget_id = $event->budget_id; + $activity->ticket_id = $event->ticket_id; + $activity->ticket_number = $event->ticket_number; + $activity->ticket_type = $event->ticket_type; + $activity->title = $event->title; + $activity->description = $event->description; + $activity->customer_id = $event->customer_id; + $activity->is_internal = $event->is_internal; + $activity->event_type_id = $event->event_type_id; + $activity->started_at = Carbon::instance($event->effectiveStart()); + $activity->ended_at = $event->ended_at; + $activity->setRelation('events', collect([$event])); + $activity->setRelation('eventType', $event->eventType); return $activity; } - /** - * @param Collection $candidates - */ - private function attachToCoveringActivity(Event $event, EventGroup $group, Collection $candidates): void + private function appendEvent(Activity $activity, Event $event): void { - $covering = $candidates->first(fn (Activity $activity) => $activity->customer_id === $group->customerId - && $activity->ticket_number === $group->ticketNumber - && (new TimeSlot($activity->started_at, $activity->ended_at))->contains($this->eventPeriod($event))); + $activity->events->push($event); + $effectiveStart = $event->effectiveStart(); - $covering?->events->push($event); - } + if ($effectiveStart->isBefore($activity->started_at)) { + $activity->started_at = Carbon::instance($effectiveStart); + } - private function eventPeriod(Event $event): TimeSlot - { - return new TimeSlot($this->effectiveStart($event), $event->ended_at); + if ($event->ended_at->isAfter($activity->ended_at)) { + $activity->ended_at = $event->ended_at; + } } /** - * @param Collection $activities - * @param Collection $entryTimeSlots - * @return Collection + * @param Collection $candidates */ - private function trimAroundEntryPeriods(Collection $activities, Collection $entryTimeSlots): Collection + private function attachEventsToCovers(Activity $covered, Collection $candidates): void { - if ($entryTimeSlots->isEmpty()) { - return $activities; - } - - $blockers = new PeriodCollection(...$entryTimeSlots->all()); - - return $activities->flatMap(function (Activity $activity) use ($blockers) { - $activityPeriod = new TimeSlot($activity->started_at, $activity->ended_at); - $segments = PeriodCollection::make($activityPeriod)->subtract($blockers); + foreach ($covered->events as $event) { + $eventPeriod = $this->period($event->effectiveStart(), $event->ended_at); - if (count($segments) === 1 && $segments[0]->equals($activityPeriod)) { - return [$activity]; - } - - $splits = []; - foreach ($segments as $segment) { - $segmentEvents = $activity->events->filter(fn (Event $event) => $this->eventPeriod($event)->overlapsWith($segment)); + $covering = $candidates->first(fn (Activity $a) => $a->customer_id === $covered->customer_id + && $a->ticket_number === $covered->ticket_number + && $this->period($a->started_at, $a->ended_at)->contains($eventPeriod)); - if ($segmentEvents->isEmpty()) { - continue; - } - - $splits[] = $this->cloneActivityForSegment($activity, $segment, $segmentEvents->values()); - } - - return $splits; - }); + $covering?->events->push($event); + } } /** * @param Collection $events */ - private function cloneActivityForSegment(Activity $activity, Period $segment, Collection $events): Activity + private function splitActivity(Activity $activity, Period $segment, Collection $events): Activity { $split = $activity->replicate(['started_at', 'ended_at']); $split->started_at = Carbon::instance($segment->start()); @@ -289,4 +248,14 @@ private function cloneActivityForSegment(Activity $activity, Period $segment, Co return $split; } + + private function period(CarbonInterface $start, CarbonInterface $end): Period + { + return new Period( + DateTimeImmutable::createFromInterface($start), + DateTimeImmutable::createFromInterface($end), + Precision::SECOND(), + Boundaries::EXCLUDE_END(), + ); + } } diff --git a/tests/Unit/DataTransferObjects/TimeSlotTest.php b/tests/Unit/DataTransferObjects/TimeSlotTest.php deleted file mode 100644 index 098bf9d..0000000 --- a/tests/Unit/DataTransferObjects/TimeSlotTest.php +++ /dev/null @@ -1,68 +0,0 @@ -overlaps($second))->toBeFalse() - ->and($second->overlaps($first))->toBeFalse(); -}); - -test('a period overlapping another partially is overlapping', function () { - $first = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 10:30')); - $second = new TimeSlot(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); - - expect($first->overlaps($second))->toBeTrue() - ->and($second->overlaps($first))->toBeTrue(); -}); - -test('a period covers another when it fully contains it', function () { - $outer = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); - $inner = new TimeSlot(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); - - expect($outer->covers($inner))->toBeTrue() - ->and($inner->covers($outer))->toBeFalse(); -}); - -test('subtracting a blocker in the middle splits the period in two segments', function () { - $period = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); - $blocker = new TimeSlot(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); - - $segments = $period->subtract(collect([$blocker])); - - expect($segments)->toHaveCount(2) - ->and($segments[0]->startedAt)->toEqual(Carbon::parse('2026-07-16 09:00')) - ->and($segments[0]->endedAt)->toEqual(Carbon::parse('2026-07-16 10:00')) - ->and($segments[1]->startedAt)->toEqual(Carbon::parse('2026-07-16 11:00')) - ->and($segments[1]->endedAt)->toEqual(Carbon::parse('2026-07-16 12:00')); -}); - -test('subtracting a covering blocker leaves no segments', function () { - $period = new TimeSlot(Carbon::parse('2026-07-16 10:00'), Carbon::parse('2026-07-16 11:00')); - $blocker = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 12:00')); - - expect($period->subtract(collect([$blocker])))->toBeEmpty(); -}); - -test('subtracting an overlapping blocker trims the period', function () { - $period = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); - $blocker = new TimeSlot(Carbon::parse('2026-07-16 08:00'), Carbon::parse('2026-07-16 10:00')); - - $segments = $period->subtract(collect([$blocker])); - - expect($segments)->toHaveCount(1) - ->and($segments[0]->startedAt)->toEqual(Carbon::parse('2026-07-16 10:00')) - ->and($segments[0]->endedAt)->toEqual(Carbon::parse('2026-07-16 11:00')); -}); - -test('subtracting nothing returns the period itself', function () { - $period = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); - - $segments = $period->subtract(collect()); - - expect($segments)->toHaveCount(1) - ->and($segments[0])->toBe($period); -}); diff --git a/tests/Unit/Services/ActivityProjectorTest.php b/tests/Unit/Services/ActivityProjectorTest.php index 46749a7..69155e0 100644 --- a/tests/Unit/Services/ActivityProjectorTest.php +++ b/tests/Unit/Services/ActivityProjectorTest.php @@ -1,6 +1,6 @@ Carbon::parse('2026-07-16 12:00'), ]); $event->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); - $entry = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 10:00')); + $entry = new Entry(['started_at' => Carbon::parse('2026-07-16 09:00'), 'ended_at' => Carbon::parse('2026-07-16 10:00')]); $activities = (new ActivityProjector)->project(collect([$event]), collect([$entry])); @@ -486,7 +486,7 @@ 'ended_at' => Carbon::parse('2026-07-16 12:00'), ]); $noon->setRelation('eventType', $eventType); - $entry = new TimeSlot(Carbon::parse('2026-07-16 09:50'), Carbon::parse('2026-07-16 10:00')); + $entry = new Entry(['started_at' => Carbon::parse('2026-07-16 09:50'), 'ended_at' => Carbon::parse('2026-07-16 10:00')]); $activities = (new ActivityProjector)->project(collect([$morning, $noon]), collect([$entry])); @@ -507,7 +507,7 @@ 'ended_at' => Carbon::parse('2026-07-16 10:30'), ]); $event->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); - $entry = new TimeSlot(Carbon::parse('2026-07-16 09:00'), Carbon::parse('2026-07-16 11:00')); + $entry = new Entry(['started_at' => Carbon::parse('2026-07-16 09:00'), 'ended_at' => Carbon::parse('2026-07-16 11:00')]); $activities = (new ActivityProjector)->project(collect([$event]), collect([$entry])); From 05235718bd2afe2990e97be262c3b18e768d98af Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 22:23:01 +0200 Subject: [PATCH 32/37] simplified activity projector --- app/Services/ActivityProjector.php | 168 +++++++----------- tests/Unit/Services/ActivityProjectorTest.php | 89 ++++++++-- 2 files changed, 133 insertions(+), 124 deletions(-) diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php index 2f22ea7..7519e59 100644 --- a/app/Services/ActivityProjector.php +++ b/app/Services/ActivityProjector.php @@ -19,48 +19,50 @@ class ActivityProjector { private const CHAIN_GAP_MINUTES = 15; + /** @var Collection */ + private Collection $activities; + /** - * @param Collection $events - * @param Collection $entries - * @return Collection + * @var Collection */ - public function project(Collection $events, Collection $entries): Collection - { - $activities = $this->chainEvents($events); + private Collection $entries; - return $this->reduceOverlap($activities, $entries); + public function __construct() + { + $this->activities = collect(); } /** * @param Collection $events + * @param Collection $entries * @return Collection */ - private function chainEvents(Collection $events): Collection + public function project(Collection $events, Collection $entries): Collection { + $this->entries = $entries; + $sorted = $events->sort(function (Event $a, Event $b) { - return ((int) $b->eventType?->weight <=> (int) $a->eventType?->weight) + return ((int) $a->eventType?->weight <=> (int) $b->eventType?->weight) ?: $a->effectiveStart()->getTimestamp() <=> $b->effectiveStart()->getTimestamp(); })->values(); - /** @var Collection $activities */ - $activities = collect(); /** @var Collection $unclaimed */ $unclaimed = collect(); foreach ($sorted as $event) { if ($event->customer_id === null) { - $activities->push($this->activityFromEvent($event)); + $this->createActivity($event); continue; } if ($event->ticket_number === null) { - $match = $this->findChainableActivity($activities, $event, + $match = $this->findChainableActivity($event, fn (Activity $a) => $a->customer_id === $event->customer_id ); if ($match) { - $this->appendEvent($match, $event); + $this->appendEventToActivity($match, $event); } else { $unclaimed->push($event); } @@ -68,115 +70,97 @@ private function chainEvents(Collection $events): Collection continue; } - $match = $this->findChainableActivity($activities, $event, + $match = $this->findChainableActivity($event, fn (Activity $a) => $a->customer_id === $event->customer_id && $a->ticket_number === $event->ticket_number && $a->event_type_id === $event->event_type_id ); if ($match) { - $this->appendEvent($match, $event); + $this->appendEventToActivity($match, $event); } else { - $activities->push($this->activityFromEvent($event)); + $this->createActivity($event); } } foreach ($unclaimed as $event) { - $match = $this->findChainableActivity($activities, $event, + $match = $this->findChainableActivity($event, fn (Activity $a) => $a->customer_id === $event->customer_id ); if ($match) { - $this->appendEvent($match, $event); + $this->appendEventToActivity($match, $event); } else { - $activities->push($this->activityFromEvent($event)); + $this->createActivity($event); } } - return $activities; + return $this->activities; + } + + private function createActivity(Event $event) + { + $newActivity = $this->activityFromEvent($event); + $newActivityPartsWithoutOverlap = $this->reduceOverlap($newActivity); + $this->activities->push(...$newActivityPartsWithoutOverlap); } /** - * @param Collection $activities - * @param Collection $entries - * @return Collection + * @return array */ - private function reduceOverlap(Collection $activities, Collection $entries): Collection + private function reduceOverlap(Activity $activity): array { - $sorted = $activities->sort(function (Activity $a, Activity $b) { - return ((int) $b->eventType?->weight <=> (int) $a->eventType?->weight) - ?: $a->started_at->getTimestamp() <=> $b->started_at->getTimestamp(); - })->values(); - $claimed = new PeriodCollection( - ...$entries->map(fn (Entry $entry) => $this->period($entry->started_at, $entry->ended_at))->all() + ...$this->entries->map(fn (Entry $entry) => $this->period($entry->started_at, $entry->ended_at))->all(), + ...$this->activities->map(fn (Activity $activity) => $this->period($activity->started_at, $activity->ended_at))->all(), ); - /** @var Collection $result */ - $result = collect(); - - foreach ($sorted as $activity) { - $activityPeriod = $this->period($activity->started_at, $activity->ended_at); - $segments = PeriodCollection::make($activityPeriod)->subtract($claimed); + $activityPeriod = $this->period($activity->started_at, $activity->ended_at); + $segments = PeriodCollection::make($activityPeriod)->subtract($claimed); - if ($segments->isEmpty()) { - $this->attachEventsToCovers($activity, $result); + if ($segments->isEmpty()) { + return []; + } - continue; - } + if ($segments->count() === 1) { + $activity->started_at = Carbon::instance($segments[0]->start()); + $activity->ended_at = Carbon::instance($segments[0]->end()); - foreach ($segments as $segment) { - $segmentEvents = $activity->events->filter( - fn (Event $event) => $this->period($event->effectiveStart(), $event->ended_at) - ->overlapsWith($segment) - ); - - if ($segmentEvents->isEmpty()) { - continue; - } + return [$activity]; + } - $result->push($this->splitActivity($activity, $segment, $segmentEvents->values())); - } + $parts = []; + foreach ($segments as $segment) { + $partialActivity = $activity->replicate(except: ['started_at', 'ended_at']); + $partialActivity->started_at = Carbon::instance($segment->start()); + $partialActivity->ended_at = Carbon::instance($segment->end()); - $claimed = $claimed->add($activityPeriod); + $events = $activity->events->whereBetween('ended_at', [$segment->start(), $segment->end()]); + $partialActivity->setRelation('events', $events); + $parts[] = $partialActivity; } - return $result->values(); + return $parts; } /** * @param Collection $activities * @param Closure(Activity): bool $matches */ - private function findChainableActivity(Collection $activities, Event $event, Closure $matches): ?Activity - { - return $this->precedingChainableActivity($activities, $event, $matches) - ?? $this->followingChainableActivity($activities, $event, $matches); - } - - /** - * @param Collection $activities - * @param Closure(Activity): bool $matches - */ - private function precedingChainableActivity(Collection $activities, Event $event, Closure $matches): ?Activity + private function findChainableActivity(Event $event, Closure $matches): ?Activity { $eventStart = $event->effectiveStart(); + $eventEnd = $event->ended_at->copy(); - return $activities + $precedingActivity = $this->activities ->filter(fn (Activity $a) => $eventStart->isBefore($a->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES))) ->last(fn (Activity $a) => $matches($a)); - } - /** - * @param Collection $activities - * @param Closure(Activity): bool $matches - */ - private function followingChainableActivity(Collection $activities, Event $event, Closure $matches): ?Activity - { - $eventStart = $event->effectiveStart(); - $eventEnd = $event->ended_at->copy(); + if ($precedingActivity) { + return $precedingActivity; + } - return $activities + return $this->activities ->filter(fn (Activity $a) => $a->ended_at->isAfter($eventStart) && $a->started_at->isBefore($eventEnd->addMinutes(self::CHAIN_GAP_MINUTES)) ) @@ -205,7 +189,7 @@ private function activityFromEvent(Event $event): Activity return $activity; } - private function appendEvent(Activity $activity, Event $event): void + private function appendEventToActivity(Activity $activity, Event $event): void { $activity->events->push($event); $effectiveStart = $event->effectiveStart(); @@ -219,36 +203,6 @@ private function appendEvent(Activity $activity, Event $event): void } } - /** - * @param Collection $candidates - */ - private function attachEventsToCovers(Activity $covered, Collection $candidates): void - { - foreach ($covered->events as $event) { - $eventPeriod = $this->period($event->effectiveStart(), $event->ended_at); - - $covering = $candidates->first(fn (Activity $a) => $a->customer_id === $covered->customer_id - && $a->ticket_number === $covered->ticket_number - && $this->period($a->started_at, $a->ended_at)->contains($eventPeriod)); - - $covering?->events->push($event); - } - } - - /** - * @param Collection $events - */ - private function splitActivity(Activity $activity, Period $segment, Collection $events): Activity - { - $split = $activity->replicate(['started_at', 'ended_at']); - $split->started_at = Carbon::instance($segment->start()); - $split->ended_at = Carbon::instance($segment->end()); - $split->setRelation('events', $events); - $split->setRelation('eventType', $activity->eventType); - - return $split; - } - private function period(CarbonInterface $start, CarbonInterface $end): Period { return new Period( diff --git a/tests/Unit/Services/ActivityProjectorTest.php b/tests/Unit/Services/ActivityProjectorTest.php index 69155e0..353def5 100644 --- a/tests/Unit/Services/ActivityProjectorTest.php +++ b/tests/Unit/Services/ActivityProjectorTest.php @@ -279,7 +279,7 @@ 'started_at' => Carbon::parse('2026-07-16 00:10'), 'ended_at' => Carbon::parse('2026-07-16 00:20'), ]); - $light->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + $light->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 99])); $heavy = new Event([ 'user_id' => 1, 'customer_id' => 'customerY', @@ -288,7 +288,7 @@ 'started_at' => Carbon::parse('2026-07-16 00:05'), 'ended_at' => Carbon::parse('2026-07-16 00:15'), ]); - $heavy->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 999])); + $heavy->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 1])); $activities = (new ActivityProjector)->project(collect([$light, $heavy]), collect()); @@ -301,7 +301,7 @@ ->and($lightActivity->ended_at)->toEqual(Carbon::parse('2026-07-16 00:20')); }); -test('a group fully covered by a matching dominant group attaches its events to the covering activity', function () { +test('a group fully covered by a dominant group is dropped', function () { $covering = new Event([ 'user_id' => 1, 'customer_id' => 'customerX', @@ -310,7 +310,7 @@ 'started_at' => Carbon::parse('2026-07-16 10:00'), 'ended_at' => Carbon::parse('2026-07-16 10:30'), ]); - $covering->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 999])); + $covering->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 1])); $covered = new Event([ 'user_id' => 1, 'customer_id' => 'customerX', @@ -319,12 +319,13 @@ 'started_at' => Carbon::parse('2026-07-16 10:05'), 'ended_at' => Carbon::parse('2026-07-16 10:10'), ]); - $covered->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + $covered->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 99])); $activities = (new ActivityProjector)->project(collect([$covering, $covered]), collect()); expect($activities)->toHaveCount(1) - ->and($activities[0]->events)->toHaveCount(2); + ->and($activities[0]->events)->toHaveCount(1) + ->and($activities[0]->events->first())->toBe($covering); }); test('a covered group of another customer stays unattached instead of mixing customers', function () { @@ -336,7 +337,7 @@ 'started_at' => Carbon::parse('2026-07-16 10:00'), 'ended_at' => Carbon::parse('2026-07-16 10:30'), ]); - $covering->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 999])); + $covering->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 1])); $covered = new Event([ 'user_id' => 1, 'customer_id' => 'customerY', @@ -345,7 +346,7 @@ 'started_at' => Carbon::parse('2026-07-16 10:05'), 'ended_at' => Carbon::parse('2026-07-16 10:10'), ]); - $covered->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); + $covered->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 99])); $activities = (new ActivityProjector)->project(collect([$covering, $covered]), collect()); @@ -383,7 +384,7 @@ }); test('a subordinate group starts after the latest dominant overlapping group', function () { - $heavyType = new EventType(['id' => 'calendar_event_started', 'weight' => 999]); + $heavyType = new EventType(['id' => 'calendar_event_started', 'weight' => 1]); $firstDominant = new Event([ 'user_id' => 1, 'customer_id' => 'customerX', @@ -410,7 +411,7 @@ 'started_at' => Carbon::parse('2026-07-16 10:15'), 'ended_at' => Carbon::parse('2026-07-16 11:00'), ]); - $subordinate->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 5])); + $subordinate->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 99])); $activities = (new ActivityProjector)->project(collect([$firstDominant, $secondDominant, $subordinate]), collect()); @@ -486,15 +487,13 @@ 'ended_at' => Carbon::parse('2026-07-16 12:00'), ]); $noon->setRelation('eventType', $eventType); - $entry = new Entry(['started_at' => Carbon::parse('2026-07-16 09:50'), 'ended_at' => Carbon::parse('2026-07-16 10:00')]); + $entry = new Entry(['started_at' => Carbon::parse('2026-07-16 09:45'), 'ended_at' => Carbon::parse('2026-07-16 10:15')]); $activities = (new ActivityProjector)->project(collect([$morning, $noon]), collect([$entry])); expect($activities)->toHaveCount(2) - ->and($activities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 09:50')) - ->and($activities[0]->events->all())->toBe([$morning]) - ->and($activities[1]->started_at)->toEqual(Carbon::parse('2026-07-16 10:00')) - ->and($activities[1]->events->all())->toBe([$noon]); + ->and($activities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 09:45')) + ->and($activities[1]->started_at)->toEqual(Carbon::parse('2026-07-16 10:15')); }); test('an activity fully inside entry periods is not created', function () { @@ -514,6 +513,62 @@ expect($activities)->toBeEmpty(); }); +test('a meeting spanning two commits is split into three activities around the commits', function () { + $commitType = new EventType(['id' => 'commit_pushed', 'weight' => 1]); + $meetingType = new EventType(['id' => 'calendar_event_started', 'weight' => 100]); + + $commit1 = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 10:10'), + 'ended_at' => Carbon::parse('2026-07-16 10:25'), + ]); + $commit1->setRelation('eventType', $commitType); + + $commit2 = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'started_at' => Carbon::parse('2026-07-16 11:00'), + 'ended_at' => Carbon::parse('2026-07-16 11:15'), + ]); + $commit2->setRelation('eventType', $commitType); + + $meeting = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerY', + 'ticket_number' => 'TIC-2', + 'event_type_id' => 'calendar_event_started', + 'started_at' => Carbon::parse('2026-07-16 10:00'), + 'ended_at' => Carbon::parse('2026-07-16 12:00'), + ]); + $meeting->setRelation('eventType', $meetingType); + + $activities = (new ActivityProjector)->project(collect([$commit1, $commit2, $meeting]), collect()); + + $commitActivities = $activities->filter(fn ($a) => $a->ticket_number === 'TIC-1') + ->sortBy('started_at')->values(); + $meetingActivities = $activities->filter(fn ($a) => $a->ticket_number === 'TIC-2') + ->sortBy('started_at')->values(); + + expect($activities)->toHaveCount(5) + ->and($commitActivities)->toHaveCount(2) + ->and($commitActivities[0]->started_at)->toEqual(Carbon::parse('2026-07-16 10:10')) + ->and($commitActivities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 10:25')) + ->and($commitActivities[1]->started_at)->toEqual(Carbon::parse('2026-07-16 11:00')) + ->and($commitActivities[1]->ended_at)->toEqual(Carbon::parse('2026-07-16 11:15')) + ->and($meetingActivities)->toHaveCount(3) + ->and($meetingActivities[0]->started_at)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($meetingActivities[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 10:10')) + ->and($meetingActivities[1]->started_at)->toEqual(Carbon::parse('2026-07-16 10:25')) + ->and($meetingActivities[1]->ended_at)->toEqual(Carbon::parse('2026-07-16 11:00')) + ->and($meetingActivities[2]->started_at)->toEqual(Carbon::parse('2026-07-16 11:15')) + ->and($meetingActivities[2]->ended_at)->toEqual(Carbon::parse('2026-07-16 12:00')); +}); + test('a high-weight event inside a low-weight event splits the low-weight into two activities', function () { $meeting = new Event([ 'user_id' => 1, @@ -523,7 +578,7 @@ 'started_at' => Carbon::parse('2026-07-16 09:00'), 'ended_at' => Carbon::parse('2026-07-16 10:00'), ]); - $meeting->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 1])); + $meeting->setRelation('eventType', new EventType(['id' => 'calendar_event_started', 'weight' => 99])); $commit = new Event([ 'user_id' => 1, 'customer_id' => 'customerY', @@ -532,7 +587,7 @@ 'started_at' => Carbon::parse('2026-07-16 09:30'), 'ended_at' => Carbon::parse('2026-07-16 09:45'), ]); - $commit->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 999])); + $commit->setRelation('eventType', new EventType(['id' => 'commit_pushed', 'weight' => 1])); $activities = (new ActivityProjector)->project(collect([$meeting, $commit]), collect()); From a365e79fe2651d3f0fc9124d4221a3644b852f96 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 23:06:07 +0200 Subject: [PATCH 33/37] only chain events without any intermediate events in between. --- app/Services/ActivityProjector.php | 29 ++++++----- tests/Unit/Services/ActivityProjectorTest.php | 51 +++++++++++++++++++ 2 files changed, 68 insertions(+), 12 deletions(-) diff --git a/app/Services/ActivityProjector.php b/app/Services/ActivityProjector.php index 7519e59..56f2e15 100644 --- a/app/Services/ActivityProjector.php +++ b/app/Services/ActivityProjector.php @@ -22,9 +22,7 @@ class ActivityProjector /** @var Collection */ private Collection $activities; - /** - * @var Collection - */ + /** @var Collection */ private Collection $entries; public function __construct() @@ -98,7 +96,7 @@ public function project(Collection $events, Collection $entries): Collection return $this->activities; } - private function createActivity(Event $event) + private function createActivity(Event $event): void { $newActivity = $this->activityFromEvent($event); $newActivityPartsWithoutOverlap = $this->reduceOverlap($newActivity); @@ -144,7 +142,6 @@ private function reduceOverlap(Activity $activity): array } /** - * @param Collection $activities * @param Closure(Activity): bool $matches */ private function findChainableActivity(Event $event, Closure $matches): ?Activity @@ -153,18 +150,26 @@ private function findChainableActivity(Event $event, Closure $matches): ?Activit $eventEnd = $event->ended_at->copy(); $precedingActivity = $this->activities - ->filter(fn (Activity $a) => $eventStart->isBefore($a->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES))) - ->last(fn (Activity $a) => $matches($a)); + ->filter(fn (Activity $a) => $a->ended_at->isBefore($eventEnd) + && $a->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES)->isAfter($eventStart) + ) + ->last(); - if ($precedingActivity) { + if ($precedingActivity && $matches($precedingActivity)) { return $precedingActivity; } - return $this->activities - ->filter(fn (Activity $a) => $a->ended_at->isAfter($eventStart) - && $a->started_at->isBefore($eventEnd->addMinutes(self::CHAIN_GAP_MINUTES)) + $followingActivity = $this->activities + ->filter(fn (Activity $a) => $a->started_at->isAfter($eventStart) + && $a->started_at->copy()->subMinutes(self::CHAIN_GAP_MINUTES)->isBefore($eventEnd) ) - ->first(fn (Activity $a) => $matches($a)); + ->first(); + + if ($followingActivity && $matches($followingActivity)) { + return $followingActivity; + } + + return null; } private function activityFromEvent(Event $event): Activity diff --git a/tests/Unit/Services/ActivityProjectorTest.php b/tests/Unit/Services/ActivityProjectorTest.php index 353def5..4514d63 100644 --- a/tests/Unit/Services/ActivityProjectorTest.php +++ b/tests/Unit/Services/ActivityProjectorTest.php @@ -569,6 +569,57 @@ ->and($meetingActivities[2]->ended_at)->toEqual(Carbon::parse('2026-07-16 12:00')); }); +test('fast sequential point events of equal weight each get their own time slice', function () { + $eventType = new EventType(['id' => 'commit_pushed', 'weight' => 1]); + + $commit1 = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'ended_at' => Carbon::parse('2026-07-16 10:00'), + ]); + $commit1->setRelation('eventType', $eventType); + + $commit2 = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-2', + 'event_type_id' => 'commit_pushed', + 'ended_at' => Carbon::parse('2026-07-16 10:05'), + ]); + $commit2->setRelation('eventType', $eventType); + + $commit3 = new Event([ + 'user_id' => 1, + 'customer_id' => 'customerX', + 'ticket_number' => 'TIC-1', + 'event_type_id' => 'commit_pushed', + 'ended_at' => Carbon::parse('2026-07-16 10:10'), + ]); + $commit3->setRelation('eventType', $eventType); + + $activities = (new ActivityProjector)->project(collect([$commit1, $commit2, $commit3]), collect()); + + $tic1 = $activities->filter(fn ($a) => $a->ticket_number === 'TIC-1') + ->sortBy('started_at')->values(); + $tic2 = $activities->filter(fn ($a) => $a->ticket_number === 'TIC-2') + ->sortBy('started_at')->values(); + + expect($activities) + ->and($tic1)->toHaveCount(2) + ->and($tic1[0]->started_at)->toEqual(Carbon::parse('2026-07-16 09:45')) + ->and($tic1[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and((int) $tic1[0]->started_at->diffInMinutes($tic1[0]->ended_at))->toBe(15) + ->and($tic1[1]->started_at)->toEqual(Carbon::parse('2026-07-16 10:05')) + ->and($tic1[1]->ended_at)->toEqual(Carbon::parse('2026-07-16 10:10')) + ->and((int) $tic1[1]->started_at->diffInMinutes($tic1[1]->ended_at))->toBe(5) + ->and($tic2)->toHaveCount(1) + ->and($tic2[0]->started_at)->toEqual(Carbon::parse('2026-07-16 10:00')) + ->and($tic2[0]->ended_at)->toEqual(Carbon::parse('2026-07-16 10:05')) + ->and((int) $tic2[0]->started_at->diffInMinutes($tic2[0]->ended_at))->toBe(5); +}); + test('a high-weight event inside a low-weight event splits the low-weight into two activities', function () { $meeting = new Event([ 'user_id' => 1, From e108d38ff04e4e23b814f624250cc76d05583612 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 19 Aug 2026 23:32:31 +0200 Subject: [PATCH 34/37] remove superseded test --- .../Activity/CreateActivityTest.php | 344 ------------------ 1 file changed, 344 deletions(-) delete mode 100644 tests/Integration/Activity/CreateActivityTest.php diff --git a/tests/Integration/Activity/CreateActivityTest.php b/tests/Integration/Activity/CreateActivityTest.php deleted file mode 100644 index 7358e76..0000000 --- a/tests/Integration/Activity/CreateActivityTest.php +++ /dev/null @@ -1,344 +0,0 @@ -create(); - - /** @var Event $event */ - $event = Event::factory()->create([ - 'user_id' => $user->id, - 'ended_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), - ]); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - $event->refresh()->load('activity'); - expect($event->activity()->exists())->toBeTrue(); - expect($event->activity?->events?->isNotEmpty())->toBeTrue(); - expect($event->ended_at->copy()->subMinutes(15))->toEqual($event->activity?->started_at); -}); - -test('if event has start and end then activity should be same period', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - $user = User::factory()->create(); - - /** @var Event $event */ - $event = Event::factory()->create([ - 'user_id' => $user->id, - 'started_at' => Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), - ]); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - $event->refresh(); - expect($event->activity)->not->toBeNull(); - expect($event->activity->events->isNotEmpty())->toBeTrue(); - expect($event->started_at)->toEqual($event->activity->started_at); - expect($event->ended_at)->toEqual($event->activity->ended_at); -}); - -test('created activity does not overlap existing one', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - $user = User::factory()->create(); - - /** @var Event $overlappingEvent */ - $overlappingEvent = Event::factory()->create([ - 'user_id' => $user->id, - 'started_at' => Carbon::parse('2026-07-16 00:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:10', 'Europe/Amsterdam'), - ]); - - /** @var Event $event */ - $event = Event::factory()->create([ - 'user_id' => $user->id, - 'started_at' => Carbon::parse('2026-07-16 00:05', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), - ]); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - expect($overlappingEvent->fresh()->ended_at)->toBeGreaterThanOrEqual($event->fresh()->activity->started_at); -}); - -test('if two events overlap then one with highest weight should become activity', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - $user = User::factory()->create(); - $eventTypeLight = EventType::factory()->state(['weight' => 1])->create(); - $eventTypeHeavy = EventType::factory()->state(['weight' => 999])->create(); - - $events = []; - - /** @var Event $overlappingEvent */ - $events[0] = Event::factory()->state([ - 'user_id' => $user->id, - 'started_at' => Carbon::parse('2026-07-16 00:10', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:20', 'Europe/Amsterdam'), - 'event_type_id' => $eventTypeLight->id, - ])->create(); - - /** @var Event $event */ - $events[1] = Event::factory()->state([ - 'user_id' => $user->id, - 'started_at' => Carbon::parse('2026-07-16 00:05', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), - 'event_type_id' => $eventTypeHeavy->id, - ])->create(); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - // should return 2 activities - expect($events[0]->fresh()->activity)->toBeInstanceOf(Activity::class); - expect($events[1]->fresh()->activity)->toBeInstanceOf(Activity::class); - - // $events[1] should be the main activity because of its higher weight - expect($events[1]->fresh()->activity->started_at)->toEqual($events[1]->started_at); - expect($events[1]->fresh()->activity->ended_at)->toEqual($events[1]->ended_at); - - // $events[0] should start after $event[1] for the remainder of its duration that does NOT overlap - expect($events[1]->ended_at)->toEqual($events[0]->fresh()->activity->started_at); - expect($events[0]->fresh()->activity->ended_at)->toEqual($events[0]->ended_at); -}); - -test('if event fits in previous activity add it', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - $user = User::factory()->create(); - - Source::firstOrCreate(['id' => Source::ID_TOPDESK], ['title' => 'Topdesk']); - - $sameState = [ - 'event_type_id' => EventType::factory()->create()->id, - 'customer_id' => $this->faker->word(), - 'ticket_number' => $this->faker->word(), - 'user_id' => $user->id, - 'source_id' => Source::ID_TOPDESK, - ]; - - /** @var Event $previousEvent */ - $previousEvent = Event::factory()->state(array_merge([ - 'started_at' => Carbon::parse('2026-07-16 00:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:10', 'Europe/Amsterdam'), - ], $sameState))->create(); - - /** @var Event $event */ - $event = Event::factory()->state(array_merge([ - 'started_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:20', 'Europe/Amsterdam'), - ], $sameState))->create(); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - $activity = $event->fresh()->activity; - expect($activity->events->pluck('id'))->toContain($previousEvent->id, $event->id); - expect($activity->ended_at)->toEqual($event->ended_at); -}); - -test('activity should only contain events from one customer', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - $user = User::factory()->create(); - - /** @var Event[] $events */ - $events = []; - - $sameState = [ - 'event_type_id' => EventType::factory()->createOne()->id, - 'ticket_number' => $this->faker->word(), - 'user_id' => $user->id, - ]; - - $events[0] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::parse('2026-07-16 00:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:05', 'Europe/Amsterdam'), - 'customer_id' => 'customerX', - ]))->create(); - - $events[1] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::parse('2026-07-16 00:10', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), - 'customer_id' => 'customerY', - ]))->create(); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - expect($events[0]->fresh()->activity->customer_id)->toEqual($events[0]->customer_id); - expect($events[1]->fresh()->activity->customer_id)->toEqual($events[1]->customer_id); - $this->assertNotEquals($events[0]->fresh()->activity->customer_id, $events[1]->fresh()->activity->customer_id); -}); - -test('events without customer should not be combined', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - $user = User::factory()->create(); - - $events = []; - $eventTypeId = EventType::factory()->createOne()->id; - - Source::firstOrCreate(['id' => Source::ID_OUTLOOK_CALENDAR], ['title' => 'Outlook Calendar']); - - $sameState = [ - 'event_type_id' => $eventTypeId, - 'customer_id' => null, - 'ticket_number' => null, - 'source_id' => 'outlook_calendar', - 'user_id' => $user->id, - ]; - - $events[0] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::parse('2026-07-16 00:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), - ]))->create(); - $events[1] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:20', 'Europe/Amsterdam'), - ]))->create(); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - expect(Activity::query()->count())->toEqual(2); -}); - -test('events without ticket should not be combined', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - $user = User::factory()->create(); - - $events = []; - $eventTypeId = EventType::factory()->createOne()->id; - - Source::firstOrCreate(['id' => Source::ID_OUTLOOK_CALENDAR], ['title' => 'Outlook Calendar']); - - $sameState = [ - 'event_type_id' => $eventTypeId, - 'customer_id' => $this->faker->word(), - 'ticket_number' => null, - 'source_id' => 'outlook_calendar', - 'user_id' => $user->id, - ]; - - $events[0] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::parse('2026-07-16 00:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), - ]))->create(); - $events[1] = Event::factory()->state(array_merge($sameState, [ - 'started_at' => Carbon::parse('2026-07-16 00:15', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 00:20', 'Europe/Amsterdam'), - ]))->create(); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - expect(Activity::query()->count())->toEqual(1); -}); - -test('a fully covered lower-weight activity is absorbed instead of getting a negative duration', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - - $eventTypeLight = EventType::factory()->state(['weight' => 1])->create(); - $eventTypeHeavy = EventType::factory()->state(['weight' => 999])->create(); - $user = User::factory()->create(); - - /** @var Event $coveredEvent */ - $coveredEvent = Event::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => $eventTypeLight->id, - 'started_at' => Carbon::parse('2026-07-16 10:05', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 10:10', 'Europe/Amsterdam'), - ]); - - /** @var Event $coveringEvent */ - $coveringEvent = Event::factory()->create([ - 'user_id' => $user->id, - 'event_type_id' => $eventTypeHeavy->id, - 'started_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 10:15', 'Europe/Amsterdam'), - ]); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - expect(Activity::count())->toBe(1) - ->and(Activity::whereColumn('started_at', '>=', 'ended_at')->count())->toBe(0) - ->and($coveredEvent->fresh()->activity_id)->toBeNull(); -}); - -test('an event fully covered by a matching activity attaches to that activity', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - $user = User::factory()->create(); - - $sameState = [ - 'event_type_id' => EventType::factory()->state(['weight' => 1])->create()->id, - 'customer_id' => 'customerX', - 'ticket_number' => 'TIC-1', - 'user_id' => $user->id, - ]; - - /** @var Event $coveringEvent */ - $coveringEvent = Event::factory()->create(array_merge($sameState, [ - 'started_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 10:30', 'Europe/Amsterdam'), - ])); - - /** @var Event $coveredEvent */ - $coveredEvent = Event::factory()->create(array_merge($sameState, [ - 'started_at' => Carbon::parse('2026-07-16 10:05', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 10:10', 'Europe/Amsterdam'), - ])); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - expect(Activity::count())->toBe(1) - ->and($coveredEvent->fresh()->activity_id)->toBe($coveringEvent->fresh()->activity_id); -}); - -test('a covered event of another customer stays unattached instead of mixing customers', function () { - Illuminate\Support\Facades\Event::fake([EventCreated::class]); - - $eventTypeId = EventType::factory()->state(['weight' => 1])->create()->id; - $user = User::factory()->create(); - - /** @var Event $coveringEvent */ - $coveringEvent = Event::factory()->create([ - 'event_type_id' => $eventTypeId, - 'customer_id' => 'customerX', - 'ticket_number' => 'TIC-1', - 'user_id' => $user->id, - 'started_at' => Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 10:30', 'Europe/Amsterdam'), - ]); - - /** @var Event $coveredEvent */ - $coveredEvent = Event::factory()->create([ - 'event_type_id' => $eventTypeId, - 'customer_id' => 'customerY', - 'ticket_number' => 'TIC-2', - 'user_id' => $user->id, - 'started_at' => Carbon::parse('2026-07-16 10:05', 'Europe/Amsterdam'), - 'ended_at' => Carbon::parse('2026-07-16 10:10', 'Europe/Amsterdam'), - ]); - - RebuildUserDay::dispatchSync($user->id, '2026-07-16'); - - expect(Activity::count())->toBe(1) - ->and($coveredEvent->fresh()->activity_id)->toBeNull(); -}); - -test('loads the event type of an activity', function () { - Illuminate\Support\Facades\Event::fake(); - $eventType = EventType::firstOrCreate(['id' => 'ticket_saved'], ['weight' => 1]); - - $activity = Activity::factory()->create(['event_type_id' => $eventType->id]); - - expect($activity->eventType)->toBeInstanceOf(EventType::class) - ->and($activity->eventType->id)->toBe('ticket_saved'); -}); From 049cc1db5e74460daec64faa6e3a74e7cd11de43 Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 2 Sep 2026 11:20:28 +0200 Subject: [PATCH 35/37] fix: only bundle entry suggestions with matching or empty tickets Group activities per customer and chain each one to the ticket of the group's representative activity (its earliest ticketed member) rather than to the previous activity. A ticketless activity in between two differently-ticketed activities no longer bridges them into one suggestion, and customerless activities never bundle with each other. --- app/Services/SuggestionProjector.php | 105 ++++++++++++++---- .../Unit/Services/SuggestionProjectorTest.php | 101 +++++++++++++++++ 2 files changed, 185 insertions(+), 21 deletions(-) diff --git a/app/Services/SuggestionProjector.php b/app/Services/SuggestionProjector.php index e40dbbe..b6cbb48 100644 --- a/app/Services/SuggestionProjector.php +++ b/app/Services/SuggestionProjector.php @@ -9,6 +9,8 @@ class SuggestionProjector { + private const CHAIN_GAP_MINUTES = 15; + /** * @param Collection $activities * @param Collection $dismissedSuggestions @@ -16,29 +18,91 @@ class SuggestionProjector */ public function project(Collection $activities, Collection $dismissedSuggestions, CarbonInterface $date): Collection { - return $activities - ->groupBy(fn (Activity $activity) => $this->groupKey($activity)) - ->reject(function (Collection $group) use ($dismissedSuggestions) { - $first = $group->first(); - - return $first !== null && $this->isDismissed($first, $dismissedSuggestions); - }) + return $this->buildGroups($activities) + ->reject(fn (Collection $group) => $this->isDismissed($this->representative($group), $dismissedSuggestions)) ->map(fn (Collection $group) => $this->suggestionFromActivities($group, $date)) ->values(); } - private function groupKey(Activity $activity): string + /** + * @param Collection $activities + * @return Collection> + */ + private function buildGroups(Collection $activities): Collection + { + $groups = collect(); + + $activities + ->filter(fn (Activity $a) => $a->customer_id === null) + ->each(fn (Activity $a) => $groups->push(collect([$a]))); + + $activities + ->filter(fn (Activity $a) => $a->customer_id !== null) + ->groupBy('customer_id') + ->each(function (Collection $customerActivities) use ($groups) { + $groups->push(...$this->chainSequentially($customerActivities)); + }); + + return $groups; + } + + /** + * Walks the customer's activities in chronological order, attaching each + * one to the preceding group when it matches that group's representative. + * + * @param Collection $activities + * @return array> + */ + private function chainSequentially(Collection $activities): array + { + $sorted = $activities->sortBy('started_at')->values(); + + $groups = []; + $currentGroup = null; + + foreach ($sorted as $activity) { + if ($currentGroup !== null && $this->chains($currentGroup, $activity)) { + $currentGroup->push($activity); + } else { + $currentGroup = collect([$activity]); + $groups[] = $currentGroup; + } + } + + return $groups; + } + + /** + * @param Collection $group + */ + private function chains(Collection $group, Activity $activity): bool + { + $representative = $this->representative($group); + + if ($representative->budget_id !== $activity->budget_id || $representative->is_internal !== $activity->is_internal) { + return false; + } + + if ($representative->ticket_number !== null && $activity->ticket_number !== null) { + return $representative->ticket_number === $activity->ticket_number; + } + + $last = $group->last() ?? $representative; + + return $last->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES)->isAfter($activity->started_at); + } + + /** + * The activity whose fields best represent the group: the earliest + * ticketed activity if any, otherwise the earliest activity overall. + * + * @param Collection $group + */ + private function representative(Collection $group): Activity { - return implode('|', [ - $activity->customer_id ?? '', - (string) $activity->budget_id, - $activity->ticket_number ?? '', - match ($activity->is_internal) { - null => '', - true => '1', - false => '0', - }, - ]); + $sorted = $group->sortBy('started_at'); + + return $sorted->first(fn (Activity $a) => $a->ticket_number !== null) ?? $sorted->firstOrFail(); } /** @@ -57,8 +121,7 @@ private function isDismissed(Activity $activity, Collection $dismissedSuggestion */ private function suggestionFromActivities(Collection $activities, CarbonInterface $date): EntrySuggestion { - /** @var Activity $template */ - $template = $activities->sortBy('started_at')->first(); + $template = $this->representative($activities); $suggestion = new EntrySuggestion; $suggestion->user_id = $template->user_id; @@ -69,7 +132,7 @@ private function suggestionFromActivities(Collection $activities, CarbonInterfac $suggestion->customer_id = $template->customer_id; $suggestion->is_internal = $template->is_internal; $suggestion->date = $date->toDateString(); - $suggestion->setRelation('activities', $activities->values()); + $suggestion->setRelation('activities', $activities->sortBy('started_at')->values()); return $suggestion; } diff --git a/tests/Unit/Services/SuggestionProjectorTest.php b/tests/Unit/Services/SuggestionProjectorTest.php index ef026ea..a740472 100644 --- a/tests/Unit/Services/SuggestionProjectorTest.php +++ b/tests/Unit/Services/SuggestionProjectorTest.php @@ -145,6 +145,107 @@ expect($suggestions)->toBeEmpty(); }); +test('a ticketless activity does not bridge two differently-ticketed activities', function () { + $first = new Activity; + $first->user_id = 1; + $first->customer_id = 'customerX'; + $first->budget_id = 7; + $first->ticket_number = 'TIC-1'; + $first->is_internal = false; + $first->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $first->ended_at = Carbon::parse('2026-07-16 09:10', 'Europe/Amsterdam'); + $ticketless = new Activity; + $ticketless->user_id = 1; + $ticketless->customer_id = 'customerX'; + $ticketless->budget_id = 7; + $ticketless->ticket_number = null; + $ticketless->is_internal = false; + $ticketless->started_at = Carbon::parse('2026-07-16 09:15', 'Europe/Amsterdam'); + $ticketless->ended_at = Carbon::parse('2026-07-16 09:20', 'Europe/Amsterdam'); + $third = new Activity; + $third->user_id = 1; + $third->customer_id = 'customerX'; + $third->budget_id = 7; + $third->ticket_number = 'TIC-2'; + $third->is_internal = false; + $third->started_at = Carbon::parse('2026-07-16 09:25', 'Europe/Amsterdam'); + $third->ended_at = Carbon::parse('2026-07-16 09:35', 'Europe/Amsterdam'); + + $suggestions = (new SuggestionProjector)->project( + collect([$first, $ticketless, $third]), + collect(), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toHaveCount(2) + ->and($suggestions[0]->activities)->toHaveCount(2) + ->and($suggestions[0]->ticket_number)->toBe('TIC-1') + ->and($suggestions[1]->activities)->toHaveCount(1) + ->and($suggestions[1]->ticket_number)->toBe('TIC-2'); +}); + +test('customerless activities never bundle with each other', function () { + $first = new Activity; + $first->user_id = 1; + $first->customer_id = null; + $first->ticket_number = null; + $first->is_internal = false; + $first->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $first->ended_at = Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'); + $second = new Activity; + $second->user_id = 1; + $second->customer_id = null; + $second->ticket_number = null; + $second->is_internal = false; + $second->started_at = Carbon::parse('2026-07-16 09:31', 'Europe/Amsterdam'); + $second->ended_at = Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'); + + $suggestions = (new SuggestionProjector)->project( + collect([$first, $second]), + collect(), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toHaveCount(2); +}); + +test('a ticketless activity bundles with the nearest activity of the same customer', function () { + $jira = new Activity; + $jira->user_id = 1; + $jira->customer_id = 'customerX'; + $jira->budget_id = 7; + $jira->ticket_number = 'TIC-1'; + $jira->is_internal = false; + $jira->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $jira->ended_at = Carbon::parse('2026-07-16 09:30', 'Europe/Amsterdam'); + $bitbucketNoTicket = new Activity; + $bitbucketNoTicket->user_id = 1; + $bitbucketNoTicket->customer_id = 'customerX'; + $bitbucketNoTicket->budget_id = 7; + $bitbucketNoTicket->ticket_number = null; + $bitbucketNoTicket->is_internal = false; + $bitbucketNoTicket->started_at = Carbon::parse('2026-07-16 09:35', 'Europe/Amsterdam'); + $bitbucketNoTicket->ended_at = Carbon::parse('2026-07-16 09:45', 'Europe/Amsterdam'); + $bitbucketTicketed = new Activity; + $bitbucketTicketed->user_id = 1; + $bitbucketTicketed->customer_id = 'customerX'; + $bitbucketTicketed->budget_id = 7; + $bitbucketTicketed->ticket_number = 'TIC-1'; + $bitbucketTicketed->is_internal = false; + $bitbucketTicketed->started_at = Carbon::parse('2026-07-16 09:50', 'Europe/Amsterdam'); + $bitbucketTicketed->ended_at = Carbon::parse('2026-07-16 10:00', 'Europe/Amsterdam'); + + $suggestions = (new SuggestionProjector)->project( + collect([$jira, $bitbucketNoTicket, $bitbucketTicketed]), + collect(), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toHaveCount(1) + ->and($suggestions[0]->activities)->toHaveCount(3) + ->and($suggestions[0]->ticket_number)->toBe('TIC-1'); +}); + test('a dismissed suggestion with a different ticket does not suppress the group', function () { $activity = new Activity; $activity->user_id = 1; From b5d6ec782417d265f511602b6f4ddcc39906195c Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 2 Sep 2026 11:29:26 +0200 Subject: [PATCH 36/37] fix: let entry suggestions chain across a missing budget Treat budget_id like ticket_number: chain activities when either side has no budget, only reject the chain when both are set and differ. representative() now prefers the earliest activity with both a ticket and a budget, so a resolved budget on a later activity still ends up on the suggestion instead of null. --- app/Services/SuggestionProjector.php | 67 +++++++++++-------- .../Unit/Services/SuggestionProjectorTest.php | 56 ++++++++++++++++ 2 files changed, 96 insertions(+), 27 deletions(-) diff --git a/app/Services/SuggestionProjector.php b/app/Services/SuggestionProjector.php index b6cbb48..fef4cf8 100644 --- a/app/Services/SuggestionProjector.php +++ b/app/Services/SuggestionProjector.php @@ -30,43 +30,40 @@ public function project(Collection $activities, Collection $dismissedSuggestions */ private function buildGroups(Collection $activities): Collection { - $groups = collect(); + [$customerActivities, $customerlessActivities] = $activities + ->partition(fn (Activity $activity) => $activity->customer_id !== null) + ->all(); - $activities - ->filter(fn (Activity $a) => $a->customer_id === null) - ->each(fn (Activity $a) => $groups->push(collect([$a]))); + $customerlessGroups = $customerlessActivities->map(fn (Activity $activity) => collect([$activity])); - $activities - ->filter(fn (Activity $a) => $a->customer_id !== null) + $customerGroups = $customerActivities ->groupBy('customer_id') - ->each(function (Collection $customerActivities) use ($groups) { - $groups->push(...$this->chainSequentially($customerActivities)); - }); + ->flatMap(fn (Collection $activitiesOfCustomer) => $this->chainIntoGroups($activitiesOfCustomer)); - return $groups; + return $customerlessGroups->concat($customerGroups)->values(); } /** * Walks the customer's activities in chronological order, attaching each - * one to the preceding group when it matches that group's representative. + * one to the preceding group when it chains onto that group. * * @param Collection $activities - * @return array> + * @return Collection> */ - private function chainSequentially(Collection $activities): array + private function chainIntoGroups(Collection $activities): Collection { - $sorted = $activities->sortBy('started_at')->values(); - - $groups = []; + $groups = collect(); $currentGroup = null; - foreach ($sorted as $activity) { - if ($currentGroup !== null && $this->chains($currentGroup, $activity)) { + foreach ($activities->sortBy('started_at') as $activity) { + if ($currentGroup !== null && $this->chainsOnto($activity, $currentGroup)) { $currentGroup->push($activity); - } else { - $currentGroup = collect([$activity]); - $groups[] = $currentGroup; + + continue; } + + $currentGroup = collect([$activity]); + $groups->push($currentGroup); } return $groups; @@ -75,11 +72,15 @@ private function chainSequentially(Collection $activities): array /** * @param Collection $group */ - private function chains(Collection $group, Activity $activity): bool + private function chainsOnto(Activity $activity, Collection $group): bool { $representative = $this->representative($group); - if ($representative->budget_id !== $activity->budget_id || $representative->is_internal !== $activity->is_internal) { + if ($representative->is_internal !== $activity->is_internal) { + return false; + } + + if (! $this->compatible($representative->budget_id, $activity->budget_id)) { return false; } @@ -87,14 +88,24 @@ private function chains(Collection $group, Activity $activity): bool return $representative->ticket_number === $activity->ticket_number; } - $last = $group->last() ?? $representative; + $previous = $group->last() ?? $representative; + + return $previous->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES)->isAfter($activity->started_at); + } - return $last->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES)->isAfter($activity->started_at); + /** + * Two values are compatible for chaining when either is unset, or when + * both are set and equal. + */ + private function compatible(?int $a, ?int $b): bool + { + return $a === null || $b === null || $a === $b; } /** * The activity whose fields best represent the group: the earliest - * ticketed activity if any, otherwise the earliest activity overall. + * activity with both a ticket and a budget, else the earliest ticketed + * activity, else the earliest activity overall. * * @param Collection $group */ @@ -102,7 +113,9 @@ private function representative(Collection $group): Activity { $sorted = $group->sortBy('started_at'); - return $sorted->first(fn (Activity $a) => $a->ticket_number !== null) ?? $sorted->firstOrFail(); + return $sorted->first(fn (Activity $activity) => $activity->ticket_number !== null && $activity->budget_id !== null) + ?? $sorted->first(fn (Activity $activity) => $activity->ticket_number !== null) + ?? $sorted->firstOrFail(); } /** diff --git a/tests/Unit/Services/SuggestionProjectorTest.php b/tests/Unit/Services/SuggestionProjectorTest.php index a740472..f355e70 100644 --- a/tests/Unit/Services/SuggestionProjectorTest.php +++ b/tests/Unit/Services/SuggestionProjectorTest.php @@ -145,6 +145,62 @@ expect($suggestions)->toBeEmpty(); }); +test('a budgetless activity chains onto a budgeted activity of the same ticket, and the suggestion keeps the budget', function () { + $budgetless = new Activity; + $budgetless->user_id = 1; + $budgetless->customer_id = 'customerX'; + $budgetless->budget_id = null; + $budgetless->ticket_number = 'TIC-1'; + $budgetless->is_internal = false; + $budgetless->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $budgetless->ended_at = Carbon::parse('2026-07-16 09:10', 'Europe/Amsterdam'); + $budgeted = new Activity; + $budgeted->user_id = 1; + $budgeted->customer_id = 'customerX'; + $budgeted->budget_id = 7; + $budgeted->ticket_number = 'TIC-1'; + $budgeted->is_internal = false; + $budgeted->started_at = Carbon::parse('2026-07-16 14:00', 'Europe/Amsterdam'); + $budgeted->ended_at = Carbon::parse('2026-07-16 15:00', 'Europe/Amsterdam'); + + $suggestions = (new SuggestionProjector)->project( + collect([$budgetless, $budgeted]), + collect(), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toHaveCount(1) + ->and($suggestions[0]->activities)->toHaveCount(2) + ->and($suggestions[0]->budget_id)->toBe(7); +}); + +test('activities with conflicting budgets do not chain even without ticket numbers', function () { + $first = new Activity; + $first->user_id = 1; + $first->customer_id = 'customerX'; + $first->budget_id = 7; + $first->ticket_number = null; + $first->is_internal = false; + $first->started_at = Carbon::parse('2026-07-16 09:00', 'Europe/Amsterdam'); + $first->ended_at = Carbon::parse('2026-07-16 09:10', 'Europe/Amsterdam'); + $second = new Activity; + $second->user_id = 1; + $second->customer_id = 'customerX'; + $second->budget_id = 8; + $second->ticket_number = null; + $second->is_internal = false; + $second->started_at = Carbon::parse('2026-07-16 09:12', 'Europe/Amsterdam'); + $second->ended_at = Carbon::parse('2026-07-16 09:20', 'Europe/Amsterdam'); + + $suggestions = (new SuggestionProjector)->project( + collect([$first, $second]), + collect(), + Carbon::parse('2026-07-16', 'Europe/Amsterdam'), + ); + + expect($suggestions)->toHaveCount(2); +}); + test('a ticketless activity does not bridge two differently-ticketed activities', function () { $first = new Activity; $first->user_id = 1; From ba61ffc872bd9bcc3b9f2edb5382288fd87dc6fa Mon Sep 17 00:00:00 2001 From: Tomas van Rijsse Date: Wed, 2 Sep 2026 11:40:25 +0200 Subject: [PATCH 37/37] simplify chain logic for suggestions --- app/Services/SuggestionProjector.php | 16 ++++++---------- tests/Unit/Services/SuggestionProjectorTest.php | 6 ++++-- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/app/Services/SuggestionProjector.php b/app/Services/SuggestionProjector.php index fef4cf8..d3493ae 100644 --- a/app/Services/SuggestionProjector.php +++ b/app/Services/SuggestionProjector.php @@ -9,8 +9,6 @@ class SuggestionProjector { - private const CHAIN_GAP_MINUTES = 15; - /** * @param Collection $activities * @param Collection $dismissedSuggestions @@ -56,7 +54,7 @@ private function chainIntoGroups(Collection $activities): Collection $currentGroup = null; foreach ($activities->sortBy('started_at') as $activity) { - if ($currentGroup !== null && $this->chainsOnto($activity, $currentGroup)) { + if ($currentGroup !== null && $this->canChainOnto($activity, $currentGroup)) { $currentGroup->push($activity); continue; @@ -72,7 +70,7 @@ private function chainIntoGroups(Collection $activities): Collection /** * @param Collection $group */ - private function chainsOnto(Activity $activity, Collection $group): bool + private function canChainOnto(Activity $activity, Collection $group): bool { $representative = $this->representative($group); @@ -84,20 +82,18 @@ private function chainsOnto(Activity $activity, Collection $group): bool return false; } - if ($representative->ticket_number !== null && $activity->ticket_number !== null) { - return $representative->ticket_number === $activity->ticket_number; + if (! $this->compatible($representative->ticket_number, $activity->ticket_number)) { + return false; } - $previous = $group->last() ?? $representative; - - return $previous->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES)->isAfter($activity->started_at); + return true; } /** * Two values are compatible for chaining when either is unset, or when * both are set and equal. */ - private function compatible(?int $a, ?int $b): bool + private function compatible(int|string|null $a, int|string|null $b): bool { return $a === null || $b === null || $a === $b; } diff --git a/tests/Unit/Services/SuggestionProjectorTest.php b/tests/Unit/Services/SuggestionProjectorTest.php index f355e70..9c634c3 100644 --- a/tests/Unit/Services/SuggestionProjectorTest.php +++ b/tests/Unit/Services/SuggestionProjectorTest.php @@ -94,7 +94,7 @@ expect($suggestions)->toHaveCount(2); }); -test('a null-ticket activity keeps its own suggestion next to a ticketed one', function () { +test('a null-ticket activity chains onto a ticketed one of the same customer on the same day', function () { $ticketless = new Activity; $ticketless->user_id = 1; $ticketless->customer_id = 'customerX'; @@ -118,7 +118,9 @@ Carbon::parse('2026-07-16', 'Europe/Amsterdam'), ); - expect($suggestions)->toHaveCount(2); + expect($suggestions)->toHaveCount(1) + ->and($suggestions[0]->activities)->toHaveCount(2) + ->and($suggestions[0]->ticket_number)->toBe('TIC-1'); }); test('no suggestion is projected when a dismissed suggestion matches the group key', function () {