188 lines
6.3 KiB
PHP
188 lines
6.3 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Services\Profile;
|
||
|
||
use App\Enums\CreatorMilestoneType;
|
||
use Carbon\Carbon;
|
||
use Carbon\CarbonInterface;
|
||
use Illuminate\Support\Collection;
|
||
|
||
/**
|
||
* Detects inactivity gaps in a creator's public artwork history and
|
||
* returns milestone rows for any comeback events.
|
||
*
|
||
* Thresholds:
|
||
* Minor: 180–364 days gap
|
||
* Major: 365–1094 days gap (1–3 years)
|
||
* Legendary: 1095+ days gap (3+ years)
|
||
*/
|
||
final class CreatorComebackService
|
||
{
|
||
private const MINOR_DAYS = 180;
|
||
private const MAJOR_DAYS = 365;
|
||
private const LEGENDARY_DAYS = 1095;
|
||
|
||
/**
|
||
* Given the ordered collection of public artwork rows (ascending by published_at),
|
||
* detect all comeback events and return milestone row arrays.
|
||
*
|
||
* @param Collection<int, object> $artworks rows from publicArtworkRows()
|
||
* @param int $userId
|
||
* @param CarbonInterface $computedAt
|
||
* @param callable(int, CreatorMilestoneType, CarbonInterface, array, ?int, CarbonInterface): array $makeMilestoneRow
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
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<string, mixed>
|
||
*/
|
||
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<string, mixed>
|
||
*/
|
||
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;
|
||
}
|
||
}
|
||
}
|