Files
SkinbaseNova/app/Services/Profile/CreatorComebackService.php

188 lines
6.3 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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: 180364 days gap
* Major: 3651094 days gap (13 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;
}
}
}