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