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

304 lines
9.6 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;
/**
* 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;
}
}
}