feat: ship creator journey v2 and profile updates
This commit is contained in:
187
app/Services/Profile/CreatorComebackService.php
Normal file
187
app/Services/Profile/CreatorComebackService.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user