diff --git a/README.md b/README.md index 609278f..2aef7dd 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,20 @@ 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] +``` + +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 new file mode 100644 index 0000000..42a54f9 --- /dev/null +++ b/app/Console/Commands/RebundleSuggestionsCommand.php @@ -0,0 +1,35 @@ +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']) + ->unique(fn (EntrySuggestion $suggestion) => $suggestion->user_id.':'.$suggestion->date) + ->values(); + + $userDays->each(fn (EntrySuggestion $suggestion) => RebuildUserDay::dispatchSync((int) $suggestion->user_id, (string) $suggestion->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/Jobs/RebuildUserDay.php b/app/Jobs/RebuildUserDay.php new file mode 100644 index 0000000..448caf0 --- /dev/null +++ b/app/Jobs/RebuildUserDay.php @@ -0,0 +1,98 @@ +userId.':'.$this->date; + } + + public function handle(ActivityProjector $activityProjector, SuggestionProjector $suggestionProjector, DatabaseManager $db): void + { + $day = Carbon::parse($this->date, config('timatic.preferred_timezone'))->startOfDay(); + + $events = Event::query() + ->with('eventType') + ->where('user_id', $this->userId) + ->where('ended_at', '>=', $day) + ->where('ended_at', '<', $day->copy()->addDay()) + ->get(); + + $entries = Entry::query() + ->where('user_id', $this->userId) + ->where('started_at', '<', $day->copy()->addDay()) + ->where('ended_at', '>', $day) + ->get(); + + $dismissedSuggestions = EntrySuggestion::onlyTrashed() + ->whereDoesntHave('entry') + ->where('user_id', $this->userId) + ->where('date', $day->toDateString()) + ->get(); + + $activities = $activityProjector->project($events, $entries); + $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 + { + 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(); + } + + /** + * @param Collection $activities + * @param Collection $suggestions + */ + private function saveProjectedState(Collection $activities, Collection $suggestions): void + { + $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]); + }); + } +} diff --git a/app/Listeners/CreateActivity.php b/app/Listeners/CreateActivity.php deleted file mode 100644 index 736aea1..0000000 --- a/app/Listeners/CreateActivity.php +++ /dev/null @@ -1,163 +0,0 @@ -db = $db; - } - - /** - * Handle the event. - */ - public function handle(EventCreated $eventCreated): void - { - $event = $eventCreated->getEvent(); - - $adjacentActivity = $this->getAdjacentActivity($event); - if ($adjacentActivity && $this->canBeMergedWithAdjacentActivity($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 canBeMergedWithAdjacentActivity(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; - - if (config('timatic.feature.activity_overlap_detection')) { - $activity = $this->handleOverlappingActivities($activity); - } - - if ($activity) { - $this->db->transaction(function () use ($activity, $event) { - $activity->save(); - $activity->events()->save($event); - }); - } - - return $activity; - } - - private function handleOverlappingActivities(Activity $activity): ?Activity - { - /** @var Collection|Activity[] $overlappingActivities */ - $overlappingActivities = Activity::query() - ->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); - }); - }) - ->get(); - - $overlappingActivities->each(function ($overlappingActivity) use ($activity) { - /** @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; - } - $overlappingActivity->save(); - } - }); - - if ($activity->ended_at->isAfter($activity->started_at)) { - return $activity; - } else { - return null; - } - } -} diff --git a/app/Listeners/CreateSuggestion.php b/app/Listeners/CreateSuggestion.php deleted file mode 100644 index 125554d..0000000 --- a/app/Listeners/CreateSuggestion.php +++ /dev/null @@ -1,92 +0,0 @@ -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); - } 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; - } - - 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/app/Listeners/DispatchActivityRebuild.php b/app/Listeners/DispatchActivityRebuild.php new file mode 100644 index 0000000..a42c4dc --- /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/Models/Activity.php b/app/Models/Activity.php index 9c64d85..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; @@ -10,7 +9,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; /** @@ -54,7 +52,6 @@ class Activity extends Model * @var array */ protected $dispatchesEvents = [ - 'created' => ActivityCreated::class, 'creating' => CreatingActivity::class, ]; @@ -85,10 +82,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/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/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 new file mode 100644 index 0000000..56f2e15 --- /dev/null +++ b/app/Services/ActivityProjector.php @@ -0,0 +1,220 @@ + */ + private Collection $activities; + + /** @var Collection */ + private Collection $entries; + + public function __construct() + { + $this->activities = collect(); + } + + /** + * @param Collection $events + * @param Collection $entries + * @return Collection + */ + public function project(Collection $events, Collection $entries): Collection + { + $this->entries = $entries; + + $sorted = $events->sort(function (Event $a, Event $b) { + return ((int) $a->eventType?->weight <=> (int) $b->eventType?->weight) + ?: $a->effectiveStart()->getTimestamp() <=> $b->effectiveStart()->getTimestamp(); + })->values(); + + /** @var Collection $unclaimed */ + $unclaimed = collect(); + + foreach ($sorted as $event) { + if ($event->customer_id === null) { + $this->createActivity($event); + + continue; + } + + if ($event->ticket_number === null) { + $match = $this->findChainableActivity($event, + fn (Activity $a) => $a->customer_id === $event->customer_id + ); + + if ($match) { + $this->appendEventToActivity($match, $event); + } else { + $unclaimed->push($event); + } + + continue; + } + + $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->appendEventToActivity($match, $event); + } else { + $this->createActivity($event); + } + } + + foreach ($unclaimed as $event) { + $match = $this->findChainableActivity($event, + fn (Activity $a) => $a->customer_id === $event->customer_id + ); + + if ($match) { + $this->appendEventToActivity($match, $event); + } else { + $this->createActivity($event); + } + } + + return $this->activities; + } + + private function createActivity(Event $event): void + { + $newActivity = $this->activityFromEvent($event); + $newActivityPartsWithoutOverlap = $this->reduceOverlap($newActivity); + $this->activities->push(...$newActivityPartsWithoutOverlap); + } + + /** + * @return array + */ + private function reduceOverlap(Activity $activity): array + { + $claimed = new PeriodCollection( + ...$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(), + ); + + $activityPeriod = $this->period($activity->started_at, $activity->ended_at); + $segments = PeriodCollection::make($activityPeriod)->subtract($claimed); + + if ($segments->isEmpty()) { + return []; + } + + if ($segments->count() === 1) { + $activity->started_at = Carbon::instance($segments[0]->start()); + $activity->ended_at = Carbon::instance($segments[0]->end()); + + return [$activity]; + } + + $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()); + + $events = $activity->events->whereBetween('ended_at', [$segment->start(), $segment->end()]); + $partialActivity->setRelation('events', $events); + $parts[] = $partialActivity; + } + + return $parts; + } + + /** + * @param Closure(Activity): bool $matches + */ + private function findChainableActivity(Event $event, Closure $matches): ?Activity + { + $eventStart = $event->effectiveStart(); + $eventEnd = $event->ended_at->copy(); + + $precedingActivity = $this->activities + ->filter(fn (Activity $a) => $a->ended_at->isBefore($eventEnd) + && $a->ended_at->copy()->addMinutes(self::CHAIN_GAP_MINUTES)->isAfter($eventStart) + ) + ->last(); + + if ($precedingActivity && $matches($precedingActivity)) { + return $precedingActivity; + } + + $followingActivity = $this->activities + ->filter(fn (Activity $a) => $a->started_at->isAfter($eventStart) + && $a->started_at->copy()->subMinutes(self::CHAIN_GAP_MINUTES)->isBefore($eventEnd) + ) + ->first(); + + if ($followingActivity && $matches($followingActivity)) { + return $followingActivity; + } + + return null; + } + + private function activityFromEvent(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->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; + } + + private function appendEventToActivity(Activity $activity, Event $event): void + { + $activity->events->push($event); + $effectiveStart = $event->effectiveStart(); + + if ($effectiveStart->isBefore($activity->started_at)) { + $activity->started_at = Carbon::instance($effectiveStart); + } + + if ($event->ended_at->isAfter($activity->ended_at)) { + $activity->ended_at = $event->ended_at; + } + } + + 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/app/Services/SuggestionProjector.php b/app/Services/SuggestionProjector.php new file mode 100644 index 0000000..d3493ae --- /dev/null +++ b/app/Services/SuggestionProjector.php @@ -0,0 +1,148 @@ + $activities + * @param Collection $dismissedSuggestions + * @return Collection + */ + public function project(Collection $activities, Collection $dismissedSuggestions, CarbonInterface $date): Collection + { + return $this->buildGroups($activities) + ->reject(fn (Collection $group) => $this->isDismissed($this->representative($group), $dismissedSuggestions)) + ->map(fn (Collection $group) => $this->suggestionFromActivities($group, $date)) + ->values(); + } + + /** + * @param Collection $activities + * @return Collection> + */ + private function buildGroups(Collection $activities): Collection + { + [$customerActivities, $customerlessActivities] = $activities + ->partition(fn (Activity $activity) => $activity->customer_id !== null) + ->all(); + + $customerlessGroups = $customerlessActivities->map(fn (Activity $activity) => collect([$activity])); + + $customerGroups = $customerActivities + ->groupBy('customer_id') + ->flatMap(fn (Collection $activitiesOfCustomer) => $this->chainIntoGroups($activitiesOfCustomer)); + + return $customerlessGroups->concat($customerGroups)->values(); + } + + /** + * Walks the customer's activities in chronological order, attaching each + * one to the preceding group when it chains onto that group. + * + * @param Collection $activities + * @return Collection> + */ + private function chainIntoGroups(Collection $activities): Collection + { + $groups = collect(); + $currentGroup = null; + + foreach ($activities->sortBy('started_at') as $activity) { + if ($currentGroup !== null && $this->canChainOnto($activity, $currentGroup)) { + $currentGroup->push($activity); + + continue; + } + + $currentGroup = collect([$activity]); + $groups->push($currentGroup); + } + + return $groups; + } + + /** + * @param Collection $group + */ + private function canChainOnto(Activity $activity, Collection $group): bool + { + $representative = $this->representative($group); + + if ($representative->is_internal !== $activity->is_internal) { + return false; + } + + if (! $this->compatible($representative->budget_id, $activity->budget_id)) { + return false; + } + + if (! $this->compatible($representative->ticket_number, $activity->ticket_number)) { + return false; + } + + return true; + } + + /** + * Two values are compatible for chaining when either is unset, or when + * both are set and equal. + */ + private function compatible(int|string|null $a, int|string|null $b): bool + { + return $a === null || $b === null || $a === $b; + } + + /** + * The activity whose fields best represent the group: the earliest + * activity with both a ticket and a budget, else the earliest ticketed + * activity, else the earliest activity overall. + * + * @param Collection $group + */ + private function representative(Collection $group): Activity + { + $sorted = $group->sortBy('started_at'); + + 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(); + } + + /** + * @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 + { + $template = $this->representative($activities); + + $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->sortBy('started_at')->values()); + + return $suggestion; + } +} 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/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'), 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 deleted file mode 100644 index b563783..0000000 --- a/tests/Integration/Activity/CreateActivityTest.php +++ /dev/null @@ -1,272 +0,0 @@ -create(); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - $listener->handle(new EventCreated($event)); - - $event->load('activity'); - expect($event->activity()->exists())->toBeTrue(); - expect($event->activity?->events?->isNotEmpty())->toBeTrue(); - expect($event->ended_at->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(); - - /** @var Event $event */ - $event = Event::factory()->create([ - 'started_at' => Carbon::now()->subWeeks(2), - ]); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - $listener->handle(new EventCreated($event)); - - 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(); - - $overlappingActivity = Activity::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 $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); - - $listener->handle(new EventCreated($event)); - - expect($overlappingEvent->ended_at)->toBeGreaterThanOrEqual($event->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(); - - $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), - '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), - 'event_type_id' => $eventTypeHeavy->id, - ])->create(); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - foreach ($events as $event) { - $listener->handle(new EventCreated($event)); - } - - // should return 2 activities - expect($events[0]->activity)->toBeInstanceOf(Activity::class); - expect($events[1]->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); - - // $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); -}); - -test('if event fits in previous activity add it', function () { - Illuminate\Support\Facades\Event::fake(); - - 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::factory()->create()->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), - ], $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), - ], $sameState))->create(); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - $listener->handle(new EventCreated($event)); - - $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); -}); - -test('activity should only contain events from one customer', function () { - Illuminate\Support\Facades\Event::fake(); - - /** @var Event[] $events */ - $events = []; - - $sameState = [ - 'event_type_id' => EventType::factory()->createOne()->id, - 'ticket_number' => $this->faker->word(), - 'user_id' => User::factory()->create()->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), - '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), - 'customer_id' => 'customerY', - ]))->create(); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - 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); -}); - -test('events without customer should not be combined', function () { - Illuminate\Support\Facades\Event::fake(); - - $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::factory()->create()->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), - ]))->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), - ]))->create(); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - foreach ($events as $event) { - $listener->handle(new EventCreated($event)); - } - - expect(Activity::query()->count())->toEqual(2); -}); - -test('events without ticket should not be combined', function () { - Illuminate\Support\Facades\Event::fake(); - - $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::factory()->create()->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), - ]))->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), - ]))->create(); - - /** @var CreateActivity $listener */ - $listener = app(CreateActivity::class); - - foreach ($events as $event) { - $listener->handle(new EventCreated($event)); - } - - expect(Activity::query()->count())->toEqual(2); -}); 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'); +}); diff --git a/tests/Integration/Activity/RebuildUserDayTest.php b/tests/Integration/Activity/RebuildUserDayTest.php new file mode 100644 index 0000000..56cd717 --- /dev/null +++ b/tests/Integration/Activity/RebuildUserDayTest.php @@ -0,0 +1,129 @@ +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]); + $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]); + $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]); + $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'); +}); diff --git a/tests/Integration/CreateSuggestionTest.php b/tests/Integration/CreateSuggestionTest.php deleted file mode 100644 index 0ef57d7..0000000 --- a/tests/Integration/CreateSuggestionTest.php +++ /dev/null @@ -1,217 +0,0 @@ - $id], ['weight' => 1]); - } -}); - -test('linear activity stream', 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)); - } - - expect($activities[2]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - - if (config('timatic.feature.build_stacked_suggestions') == false) { - return; - } - - expect($activities[1]->entry_suggestion_id)->toEqual($activities[2]->entry_suggestion_id); - expect($activities[3]->entry_suggestion_id)->toEqual($activities[2]->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); -}); - -test('activity stream with dangling activity', 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, - ], - ]; - 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)); - } - - 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); - expect($activities[3]->entry_suggestion_id)->not->toEqual($activities[1]->entry_suggestion_id); -}); - -test('outlook activities without ticket id', function () { - if (config('timatic.feature.build_stacked_suggestions') == false) { - $this->markTestSkipped('stacked suggestions are disabled'); - } - - Illuminate\Support\Facades\Event::fake(); - - /** @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, - ], - ]; - 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)); - } - - 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); - - expect($activities[4]->entrySuggestion)->toBeInstanceOf(EntrySuggestion::class); - expect($activities[4]->entry_suggestion_id)->toEqual($activities[2]->entry_suggestion_id); -}); diff --git a/tests/Integration/RebundleSuggestionsCommandTest.php b/tests/Integration/RebundleSuggestionsCommandTest.php new file mode 100644 index 0000000..3e49421 --- /dev/null +++ b/tests/Integration/RebundleSuggestionsCommandTest.php @@ -0,0 +1,53 @@ +create(); + $stale = EntrySuggestion::factory()->create([ + 'user_id' => $user->id, + 'date' => '2026-07-16', + 'ticket_number' => 'STALE-1', + ]); + 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'), + ]); + + $this->artisan('timatic:rebundle-suggestions')->assertSuccessful(); + + expect(EntrySuggestion::withTrashed()->whereKey($stale->id)->exists())->toBeFalse() + ->and(EntrySuggestion::sole()->ticket_number)->toBe('TIC-1'); +}); + +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', + ]); + $untouched = EntrySuggestion::factory()->create([ + 'user_id' => $otherUser->id, + 'date' => '2026-07-16', + ]); + + $this->artisan('timatic:rebundle-suggestions', ['--user' => $targetUser->id])->assertSuccessful(); + + expect(EntrySuggestion::query()->whereKey($untouched->id)->exists())->toBeTrue() + ->and(EntrySuggestion::query()->where('user_id', $targetUser->id)->count())->toBe(0); +}); diff --git a/tests/Unit/Services/ActivityProjectorTest.php b/tests/Unit/Services/ActivityProjectorTest.php new file mode 100644 index 0000000..4514d63 --- /dev/null +++ b/tests/Unit/Services/ActivityProjectorTest.php @@ -0,0 +1,655 @@ + 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); +}); + +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' => 99])); + $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' => 1])); + + $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 dominant group is dropped', 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' => 1])); + $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' => 99])); + + $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('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' => 1])); + $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' => 99])); + + $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' => 1]); + $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' => 99])); + + $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)); +}); + +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 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])); + + 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 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: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 () { + $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 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])); + + 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('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, + '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' => 99])); + $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' => 1])); + + $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')); +}); diff --git a/tests/Unit/Services/SuggestionProjectorTest.php b/tests/Unit/Services/SuggestionProjectorTest.php new file mode 100644 index 0000000..9c634c3 --- /dev/null +++ b/tests/Unit/Services/SuggestionProjectorTest.php @@ -0,0 +1,329 @@ +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 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'; + $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(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 () { + $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 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; + $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; + $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); +});