$artworks rows from publicArtworkRows() * @param int $userId * @param CarbonInterface $computedAt * @param callable(int, CreatorMilestoneType, CarbonInterface, array, ?int, CarbonInterface): array $makeMilestoneRow * @return array> */ public function calculateComebacks( Collection $artworks, int $userId, CarbonInterface $computedAt, callable $makeMilestoneRow, ): array { if ($artworks->count() < 2) { return []; } $sorted = $artworks ->filter(fn (object $row): bool => ! empty($row->published_at)) ->sortBy([['published_at', 'asc'], ['id', 'asc']]) ->values(); $milestones = []; $prevDate = null; foreach ($sorted as $artwork) { $currentDate = $this->parseDate($artwork->published_at); if ($prevDate !== null && $currentDate !== null) { $gapDays = (int) $prevDate->diffInDays($currentDate); $type = $this->comebackTypeForGap($gapDays); if ($type !== null) { $milestones[] = $makeMilestoneRow( $userId, $type, $currentDate, $this->buildPayload($type, $gapDays, $prevDate, $artwork), (int) $artwork->id, $computedAt, ); // Only record one comeback per gap: if we match legendary, skip major/minor for same gap. // prevDate resets after each comeback so consecutive short-gap uploads won't double-count. } } // Only advance prevDate when the gap did NOT trigger a comeback. // After a comeback, the "chain" resets from the new return date. $prevDate = $currentDate; } return $milestones; } private function comebackTypeForGap(int $gapDays): ?CreatorMilestoneType { if ($gapDays >= self::LEGENDARY_DAYS) { return CreatorMilestoneType::ComebackLegendary; } if ($gapDays >= self::MAJOR_DAYS) { return CreatorMilestoneType::ComebackMajor; } if ($gapDays >= self::MINOR_DAYS) { return CreatorMilestoneType::ComebackMinor; } return null; } /** * @return array */ private function buildPayload( CreatorMilestoneType $type, int $gapDays, CarbonInterface $previousUploadAt, object $artwork, ): array { $years = (int) round($gapDays / 365); $months = (int) round($gapDays / 30); $durationLabel = match (true) { $years >= 3 => $years . ' years', $years >= 1 => $years === 1 ? 'a year' : $years . ' years', $months >= 2 => $months . ' months', default => 'several months', }; $summaryMap = [ CreatorMilestoneType::ComebackMinor->value => "Returned to Skinbase after {$durationLabel} away with a new public upload.", CreatorMilestoneType::ComebackMajor->value => "Major comeback after {$durationLabel} away — new work published again on Skinbase.", CreatorMilestoneType::ComebackLegendary->value => "Returned to Skinbase after {$durationLabel} away, picking up where the journey left off.", ]; $titleMap = [ CreatorMilestoneType::ComebackMinor->value => 'Comeback', CreatorMilestoneType::ComebackMajor->value => 'Major comeback', CreatorMilestoneType::ComebackLegendary->value => 'Legendary comeback', ]; return [ 'title' => $titleMap[$type->value] ?? 'Comeback', 'headline' => (string) $artwork->title, 'summary' => $summaryMap[$type->value] ?? "Returned after {$durationLabel}.", 'value' => "After {$durationLabel}", 'artwork' => $this->artworkSnapshot($artwork), 'metadata' => [ 'previous_upload_at' => $previousUploadAt->toIso8601String(), 'gap_days' => $gapDays, 'comeback_level' => $this->levelLabel($type), ], ]; } private function levelLabel(CreatorMilestoneType $type): string { return match ($type) { CreatorMilestoneType::ComebackMinor => 'minor', CreatorMilestoneType::ComebackMajor => 'major', CreatorMilestoneType::ComebackLegendary => 'legendary', default => 'minor', }; } /** * @return array */ private function artworkSnapshot(object $artwork): array { return [ 'id' => (int) $artwork->id, 'title' => (string) $artwork->title, 'slug' => (string) ($artwork->slug ?? $artwork->id), 'published_at' => $this->parseDate($artwork->published_at)?->toIso8601String(), ]; } private function parseDate(mixed $value): ?CarbonInterface { if ($value instanceof CarbonInterface) { return $value; } if (! is_string($value) || trim($value) === '') { return null; } try { return Carbon::parse($value); } catch (\Throwable) { return null; } } }