feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View 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: 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;
}
}
}