Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
44da285
fix: Activity::eventType is a belongsTo, not a hasOne
tomasvanrijsse Jul 16, 2026
aa624e2
fix: absorb fully covered activities instead of saving negative durat…
tomasvanrijsse Jul 17, 2026
0f99b82
fix: attach fully covered events to the covering activity instead of …
tomasvanrijsse Jul 17, 2026
81ea82f
fix: only attach covered events to activities of the same customer an…
tomasvanrijsse Jul 17, 2026
0f75caf
fix: decide event coverage before trimming neighbouring activities
tomasvanrijsse Jul 17, 2026
6097477
feat: bundle same-ticket activities of a day into one suggestion
tomasvanrijsse Jul 16, 2026
2b2a7b6
fix: keep EntrySuggestion date attribute a plain string
tomasvanrijsse Jul 16, 2026
63cb245
refactor: delegate suggestion creation to SuggestionBundler with stri…
tomasvanrijsse Jul 16, 2026
ddde3e2
feat: add rebundle command to recompute open entry suggestions
tomasvanrijsse Jul 16, 2026
4d73ab7
fix: run suggestion rebundling inside a database transaction
tomasvanrijsse Jul 16, 2026
9b29f67
test: prove rebundle command rolls back on mid-replay failure
tomasvanrijsse Jul 16, 2026
f60a6e2
fix: snapshot rebundle targets inside the transaction with row locks
tomasvanrijsse Jul 16, 2026
4f003f5
fix: store suggestion date as the matched date string
tomasvanrijsse Jul 16, 2026
b91e08f
feat: add Period value object with interval subtraction
tomasvanrijsse Aug 19, 2026
abfe73d
feat: chain events into activity groups in ActivityProjector
tomasvanrijsse Aug 19, 2026
7d6c93a
feat: resolve overlapping activity groups by event type weight
tomasvanrijsse Aug 19, 2026
3437790
feat: trim projected activities around booked entry periods
tomasvanrijsse Aug 19, 2026
7c22c58
refactor: rename Period DTO to TimeSlot to avoid confusion with Budge…
tomasvanrijsse Aug 19, 2026
47beb17
feat: project activities into entry suggestions on the bundler key
tomasvanrijsse Aug 19, 2026
fc0b384
feat: add projection query objects for events, entries and dismissed …
tomasvanrijsse Aug 19, 2026
425306b
feat: rebuild a user's day of activities and suggestions from events
tomasvanrijsse Aug 19, 2026
07337c2
feat: dispatch day rebuilds from incoming events instead of increment…
tomasvanrijsse Aug 19, 2026
5028fe0
refactor: replace per-activity suggestion bundling with day projection
tomasvanrijsse Aug 19, 2026
53cc96d
chore: remove activity projection feature flags
tomasvanrijsse Aug 19, 2026
f2e6517
refactor: move EventGroup to DataTransferObjects namespace
tomasvanrijsse Aug 19, 2026
81d5543
refactor: simplify projection code for clarity
tomasvanrijsse Aug 19, 2026
dc60688
refactor: inline projection queries into RebuildUserDay
tomasvanrijsse Aug 19, 2026
6e7371e
refactor: reorder delete/save to match activity → suggestion dependency
tomasvanrijsse Aug 19, 2026
56050b7
rename to timeslots
tomasvanrijsse Aug 19, 2026
0cc05d7
fix: process activity groups by weight tier to correctly split overla…
tomasvanrijsse Aug 19, 2026
a0dd2f3
refactor: replace EventGroup and TimeSlot with Activity throughout pr…
tomasvanrijsse Aug 19, 2026
0523571
simplified activity projector
tomasvanrijsse Aug 19, 2026
a365e79
only chain events without any intermediate events in between.
tomasvanrijsse Aug 19, 2026
e108d38
remove superseded test
tomasvanrijsse Aug 19, 2026
049cc1d
fix: only bundle entry suggestions with matching or empty tickets
tomasvanrijsse Sep 2, 2026
b5d6ec7
fix: let entry suggestions chain across a missing budget
tomasvanrijsse Sep 2, 2026
ba61ffc
simplify chain logic for suggestions
tomasvanrijsse Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 35 additions & 0 deletions app/Console/Commands/RebundleSuggestionsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace App\Console\Commands;

use App\Jobs\RebuildUserDay;
use App\Models\EntrySuggestion;
use Illuminate\Console\Command;

class RebundleSuggestionsCommand extends Command
{
protected $signature = 'timatic:rebundle-suggestions
{--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 = 'Rebuild the activities and open suggestions of every user-day that has an open suggestion';

public function handle(): int
{
$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'])
->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;
}
}
29 changes: 0 additions & 29 deletions app/Events/ActivityCreated.php

This file was deleted.

98 changes: 98 additions & 0 deletions app/Jobs/RebuildUserDay.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

namespace App\Jobs;

use App\Models\Activity;
use App\Models\Entry;
use App\Models\EntrySuggestion;
use App\Models\Event;
use App\Services\ActivityProjector;
use App\Services\SuggestionProjector;
use Carbon\Carbon;
use Carbon\CarbonInterface;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\DatabaseManager;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Collection;

class RebuildUserDay implements ShouldBeUnique, ShouldQueue
{
use Queueable;

public function __construct(
public readonly int $userId,
public readonly string $date,
) {}

public function uniqueId(): string
{
return $this->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<int, Activity> $activities
* @param Collection<int, EntrySuggestion> $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]);
});
}
}
163 changes: 0 additions & 163 deletions app/Listeners/CreateActivity.php

This file was deleted.

Loading
Loading