feat: ship creator journey v2 and profile updates
This commit is contained in:
303
app/Services/Profile/CreatorStreakService.php
Normal file
303
app/Services/Profile/CreatorStreakService.php
Normal file
@@ -0,0 +1,303 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Profile;
|
||||
|
||||
use App\Enums\CreatorMilestoneType;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Calculates upload streaks (consecutive calendar months with at least one public upload)
|
||||
* and active-year streaks (consecutive years with at least one public upload).
|
||||
*
|
||||
* Returns:
|
||||
* - milestone rows for notable streak achievements
|
||||
* - a streaks summary array for the API payload
|
||||
*/
|
||||
final class CreatorStreakService
|
||||
{
|
||||
/**
|
||||
* Compute streak milestones from a creator's public artwork collection.
|
||||
*
|
||||
* @param Collection<int, object> $artworks
|
||||
* @param int $userId
|
||||
* @param CarbonInterface $computedAt
|
||||
* @param callable $makeMilestoneRow
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function calculateStreakMilestones(
|
||||
Collection $artworks,
|
||||
int $userId,
|
||||
CarbonInterface $computedAt,
|
||||
callable $makeMilestoneRow,
|
||||
): array {
|
||||
if ($artworks->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$milestones = [];
|
||||
$stats = $this->computeStreakStats($artworks);
|
||||
|
||||
// Monthly upload streak milestones
|
||||
foreach ([12, 6, 3] as $months) {
|
||||
if ($stats['best_monthly_streak'] >= $months) {
|
||||
$type = match ($months) {
|
||||
12 => CreatorMilestoneType::UploadStreak12,
|
||||
6 => CreatorMilestoneType::UploadStreak6,
|
||||
3 => CreatorMilestoneType::UploadStreak3,
|
||||
};
|
||||
|
||||
$occurredAt = $stats['best_monthly_streak_end'] ?? $computedAt;
|
||||
|
||||
$milestones[] = $makeMilestoneRow(
|
||||
$userId,
|
||||
$type,
|
||||
$occurredAt,
|
||||
[
|
||||
'title' => $months . '-month upload streak',
|
||||
'headline' => "Published in {$months} consecutive months.",
|
||||
'summary' => "Maintained a public upload in every calendar month for {$months} consecutive months.",
|
||||
'value' => "{$months} months",
|
||||
'metrics' => [
|
||||
'months' => $months,
|
||||
'best_monthly_streak' => $stats['best_monthly_streak'],
|
||||
'current_monthly_streak' => $stats['current_monthly_streak'],
|
||||
],
|
||||
],
|
||||
null,
|
||||
$computedAt,
|
||||
);
|
||||
|
||||
break; // Only insert the best monthly streak milestone (e.g. if best=12, skip 6 and 3)
|
||||
}
|
||||
}
|
||||
|
||||
// Active-year streak milestones
|
||||
foreach ([5, 3] as $years) {
|
||||
if ($stats['best_year_streak'] >= $years) {
|
||||
$type = match ($years) {
|
||||
5 => CreatorMilestoneType::ActiveYearStreak5,
|
||||
3 => CreatorMilestoneType::ActiveYearStreak3,
|
||||
};
|
||||
|
||||
$occurredAt = $stats['best_year_streak_end'] ?? $computedAt;
|
||||
|
||||
$milestones[] = $makeMilestoneRow(
|
||||
$userId,
|
||||
$type,
|
||||
$occurredAt,
|
||||
[
|
||||
'title' => "{$years}-year active streak",
|
||||
'headline' => "Stayed active for {$years} consecutive years.",
|
||||
'summary' => "Published at least one public artwork every year for {$years} consecutive years.",
|
||||
'value' => "{$years} years",
|
||||
'metrics' => [
|
||||
'years' => $years,
|
||||
'best_year_streak' => $stats['best_year_streak'],
|
||||
'current_year_streak' => $stats['current_year_streak'],
|
||||
],
|
||||
],
|
||||
null,
|
||||
$computedAt,
|
||||
);
|
||||
|
||||
break; // Only insert the best year streak milestone
|
||||
}
|
||||
}
|
||||
|
||||
return $milestones;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute raw streak statistics for use in the API streaks payload.
|
||||
*
|
||||
* @param Collection<int, object> $artworks
|
||||
* @return array{
|
||||
* current_monthly_streak: int,
|
||||
* best_monthly_streak: int,
|
||||
* best_monthly_streak_end: ?CarbonInterface,
|
||||
* current_year_streak: int,
|
||||
* best_year_streak: int,
|
||||
* best_year_streak_end: ?CarbonInterface,
|
||||
* }
|
||||
*/
|
||||
public function computeStreakStats(Collection $artworks): array
|
||||
{
|
||||
if ($artworks->isEmpty()) {
|
||||
return $this->emptyStats();
|
||||
}
|
||||
|
||||
// Build sets of active months (YYYY-MM) and active years
|
||||
$activeMonths = [];
|
||||
$activeYears = [];
|
||||
|
||||
foreach ($artworks as $artwork) {
|
||||
$date = $this->parseDate($artwork->published_at);
|
||||
|
||||
if ($date === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$activeMonths[$date->format('Y-m')] = $date;
|
||||
$activeYears[(int) $date->format('Y')] = $date;
|
||||
}
|
||||
|
||||
if ($activeMonths === []) {
|
||||
return $this->emptyStats();
|
||||
}
|
||||
|
||||
ksort($activeMonths);
|
||||
ksort($activeYears);
|
||||
|
||||
return [
|
||||
...$this->computeMonthlyStreaks($activeMonths),
|
||||
...$this->computeYearlyStreaks($activeYears),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, CarbonInterface> $activeMonths sorted ascending by key (YYYY-MM)
|
||||
* @return array{current_monthly_streak: int, best_monthly_streak: int, best_monthly_streak_end: ?CarbonInterface}
|
||||
*/
|
||||
private function computeMonthlyStreaks(array $activeMonths): array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
$currentMonth = $now->format('Y-m');
|
||||
|
||||
$streak = 1;
|
||||
$best = 1;
|
||||
$bestEndDate = null;
|
||||
$prevKey = null;
|
||||
$lastKey = null;
|
||||
|
||||
foreach ($activeMonths as $key => $date) {
|
||||
if ($prevKey !== null) {
|
||||
$expected = Carbon::parse($prevKey . '-01')->addMonth()->format('Y-m');
|
||||
|
||||
if ($key === $expected) {
|
||||
$streak++;
|
||||
} else {
|
||||
$streak = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($streak > $best) {
|
||||
$best = $streak;
|
||||
$bestEndDate = $date;
|
||||
}
|
||||
|
||||
$prevKey = $key;
|
||||
$lastKey = $key;
|
||||
}
|
||||
|
||||
// Current streak: walk backwards from current/last month
|
||||
$currentStreak = 0;
|
||||
$checkMonth = $lastKey !== null ? Carbon::parse($lastKey . '-01') : $now->startOfMonth();
|
||||
|
||||
// If the last active month is current or previous month, count the streak
|
||||
$diff = $now->startOfMonth()->diffInMonths($checkMonth);
|
||||
|
||||
if ($diff <= 1) {
|
||||
$currentStreak = 1;
|
||||
$checkBack = $checkMonth->copy()->subMonth();
|
||||
|
||||
while (isset($activeMonths[$checkBack->format('Y-m')])) {
|
||||
$currentStreak++;
|
||||
$checkBack->subMonth();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'current_monthly_streak' => $currentStreak,
|
||||
'best_monthly_streak' => $best,
|
||||
'best_monthly_streak_end' => $bestEndDate,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, CarbonInterface> $activeYears sorted ascending by key (int year)
|
||||
* @return array{current_year_streak: int, best_year_streak: int, best_year_streak_end: ?CarbonInterface}
|
||||
*/
|
||||
private function computeYearlyStreaks(array $activeYears): array
|
||||
{
|
||||
$currentYear = (int) Carbon::now()->year;
|
||||
|
||||
$streak = 1;
|
||||
$best = 1;
|
||||
$bestEndDate = null;
|
||||
$prevYear = null;
|
||||
$lastYear = null;
|
||||
|
||||
foreach ($activeYears as $year => $date) {
|
||||
if ($prevYear !== null) {
|
||||
if ($year === $prevYear + 1) {
|
||||
$streak++;
|
||||
} else {
|
||||
$streak = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ($streak > $best) {
|
||||
$best = $streak;
|
||||
$bestEndDate = $date;
|
||||
}
|
||||
|
||||
$prevYear = $year;
|
||||
$lastYear = $year;
|
||||
}
|
||||
|
||||
// Current year streak
|
||||
$currentStreak = 0;
|
||||
|
||||
if ($lastYear !== null && ($lastYear === $currentYear || $lastYear === $currentYear - 1)) {
|
||||
$currentStreak = 1;
|
||||
$checkYear = $lastYear - 1;
|
||||
|
||||
while (isset($activeYears[$checkYear])) {
|
||||
$currentStreak++;
|
||||
$checkYear--;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'current_year_streak' => $currentStreak,
|
||||
'best_year_streak' => $best,
|
||||
'best_year_streak_end' => $bestEndDate,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{current_monthly_streak: int, best_monthly_streak: int, best_monthly_streak_end: null, current_year_streak: int, best_year_streak: int, best_year_streak_end: null}
|
||||
*/
|
||||
private function emptyStats(): array
|
||||
{
|
||||
return [
|
||||
'current_monthly_streak' => 0,
|
||||
'best_monthly_streak' => 0,
|
||||
'best_monthly_streak_end' => null,
|
||||
'current_year_streak' => 0,
|
||||
'best_year_streak' => 0,
|
||||
'best_year_streak_end' => null,
|
||||
];
|
||||
}
|
||||
|
||||
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