$artworks * @param int $userId * @param CarbonInterface $computedAt * @param callable $makeMilestoneRow * @return array> */ 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 $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 $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 $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; } } }