$artworks public artwork rows (ascending by published_at) */ public function rebuildForUser(User $user, Collection $artworks): void { $eras = $this->computeEras($user, $artworks); DB::transaction(function () use ($user, $eras): void { CreatorEra::query()->where('user_id', (int) $user->id)->delete(); if ($eras !== []) { DB::table('creator_eras')->insert($eras); } }); } /** * Return the public era payload for the journey API. * * @return list> */ public function publicErasForUser(int $userId): array { return CreatorEra::query() ->where('user_id', $userId) ->orderBy('starts_at') ->get() ->map(fn (CreatorEra $era): array => $this->formatEra($era)) ->values() ->all(); } /** * Compute milestone rows for era_started events. * * @param Collection $artworks * @return array> */ public function calculateEraMilestones( User $user, Collection $artworks, CarbonInterface $computedAt, callable $makeMilestoneRow, ): array { if ($artworks->isEmpty()) { return []; } $eras = $this->computeEras($user, $artworks); $milestones = []; foreach ($eras as $era) { if (in_array($era['era_type'], ['early_years', 'current'], true)) { continue; // Only notable era transitions get milestone rows } $occurredAt = Carbon::parse($era['starts_at']); $milestones[] = $makeMilestoneRow( (int) $user->id, CreatorMilestoneType::EraStarted, $occurredAt, [ 'title' => 'New era', 'headline' => $era['title'], 'summary' => $era['description'] ?? 'A new creative phase began.', 'value' => $era['title'], 'metadata' => ['era_type' => $era['era_type']], ], null, $computedAt, ); } return $milestones; } /** * @param Collection $artworks * @return array> */ private function computeEras(User $user, Collection $artworks): array { if ($artworks->isEmpty()) { return []; } $sorted = $artworks ->filter(fn (object $row): bool => ! empty($row->published_at)) ->sortBy([['published_at', 'asc'], ['id', 'asc']]) ->values(); if ($sorted->isEmpty()) { return []; } $now = Carbon::now(); $userId = (int) $user->id; $eras = []; $firstArtwork = $sorted->first(); $firstDate = Carbon::parse($firstArtwork->published_at); $lastArtwork = $sorted->last(); $lastDate = Carbon::parse($lastArtwork->published_at); // Detect featured date (breakthrough signal) $firstFeaturedAt = $this->firstFeaturedDate($userId); $firstMajorDownloadAt = $this->firstMajorDownloadDate($sorted); // Detect comeback gap $comebackDate = $this->firstComebackDate($sorted); // Phase boundaries $breakthroughAt = match (true) { $firstFeaturedAt !== null => $firstFeaturedAt, $firstMajorDownloadAt !== null => $firstMajorDownloadAt, default => null, }; // ── Early Years ──────────────────────────────────────────────────── $earlyYearsEnds = $breakthroughAt?->copy()->subSecond() ?? $comebackDate?->copy()->subSecond() ?? null; $eras[] = [ 'user_id' => $userId, 'era_type' => 'early_years', 'title' => 'Early Years', 'description' => 'The beginning of the creative journey on Skinbase.', 'starts_at' => $firstDate->toDateTimeString(), 'ends_at' => $earlyYearsEnds?->toDateTimeString(), 'is_current' => false, 'metadata' => json_encode($this->eraMetadata($sorted, $firstDate, $earlyYearsEnds ?? $lastDate)), 'created_at' => $now->toDateTimeString(), 'updated_at' => $now->toDateTimeString(), ]; // ── Breakthrough Era ─────────────────────────────────────────────── if ($breakthroughAt !== null) { $breakthroughEnds = $comebackDate?->copy()->subSecond() ?? null; $eras[] = [ 'user_id' => $userId, 'era_type' => 'breakthrough', 'title' => 'Breakthrough Era', 'description' => 'A period marked by first recognition — featured work, strong downloads, and growing visibility.', 'starts_at' => $breakthroughAt->toDateTimeString(), 'ends_at' => $breakthroughEnds?->toDateTimeString(), 'is_current' => false, 'metadata' => json_encode($this->eraMetadata($sorted, $breakthroughAt, $breakthroughEnds ?? $lastDate)), 'created_at' => $now->toDateTimeString(), 'updated_at' => $now->toDateTimeString(), ]; } // ── Comeback Era ─────────────────────────────────────────────────── if ($comebackDate !== null) { // Comeback era encompasses everything from the comeback to now (or next major event) $eras[] = [ 'user_id' => $userId, 'era_type' => 'comeback', 'title' => 'Comeback Era', 'description' => 'A return to creative work on Skinbase after a significant break.', 'starts_at' => $comebackDate->toDateTimeString(), 'ends_at' => null, 'is_current' => true, 'metadata' => json_encode($this->eraMetadata($sorted, $comebackDate, $lastDate)), 'created_at' => $now->toDateTimeString(), 'updated_at' => $now->toDateTimeString(), ]; } else { // ── Current Era ─────────────────────────────────────────────── // Only set if there's been activity in the last 2 years $twoYearsAgo = $now->copy()->subYears(2); if ($lastDate->greaterThanOrEqualTo($twoYearsAgo)) { $currentStart = $breakthroughAt ?? $firstDate; // Don't double-stamp if breakthrough era is already current if ($breakthroughAt === null || $currentStart->equalTo($firstDate)) { $eras[] = [ 'user_id' => $userId, 'era_type' => 'current', 'title' => 'Current Era', 'description' => 'The latest active creative phase on Skinbase.', 'starts_at' => $currentStart->toDateTimeString(), 'ends_at' => null, 'is_current' => true, 'metadata' => json_encode($this->eraMetadata($sorted, $currentStart, $lastDate)), 'created_at' => $now->toDateTimeString(), 'updated_at' => $now->toDateTimeString(), ]; } else { // Mark breakthrough as current $lastIdx = count($eras) - 1; $eras[$lastIdx]['is_current'] = true; $eras[$lastIdx]['ends_at'] = null; } } } // Deduplicate: ensure we don't have two is_current=true if an era was edited above $currentCount = count(array_filter($eras, fn ($e) => $e['is_current'])); if ($currentCount > 1) { // Only the last is_current one stays $found = false; for ($i = count($eras) - 1; $i >= 0; $i--) { if ($eras[$i]['is_current']) { if ($found) { $eras[$i]['is_current'] = false; } else { $found = true; } } } } return $eras; } /** * @param Collection $artworks */ private function eraMetadata(Collection $artworks, CarbonInterface $from, CarbonInterface $to): array { $inRange = $artworks->filter(function (object $artwork) use ($from, $to): bool { $date = empty($artwork->published_at) ? null : Carbon::parse($artwork->published_at); if ($date === null) { return false; } return $date->greaterThanOrEqualTo($from) && $date->lessThanOrEqualTo($to); }); $uploads = $inRange->count(); $downloads = $inRange->sum(fn ($a): int => (int) ($a->stat_downloads ?? 0)); $topArtwork = $inRange->sortByDesc(fn ($a): float => (float) ($a->stat_downloads ?? 0))->first(); $years = $inRange ->map(fn ($a): int => (int) Carbon::parse($a->published_at)->year) ->unique() ->sort() ->values() ->all(); return [ 'uploads_count' => $uploads, 'downloads' => $downloads, 'dominant_years' => $years, 'top_artwork_id' => $topArtwork ? (int) $topArtwork->id : null, ]; } private function firstFeaturedDate(int $userId): ?CarbonInterface { $row = DB::table('artwork_features as af') ->join('artworks as a', 'a.id', '=', 'af.artwork_id') ->where('a.user_id', $userId) ->whereNull('a.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) ->whereNull('af.deleted_at') ->where('af.is_active', true) ->orderBy('af.featured_at') ->first(['af.featured_at']); return $row ? Carbon::parse($row->featured_at) : null; } /** * @param Collection $sorted */ private function firstMajorDownloadDate(Collection $sorted): ?CarbonInterface { // Threshold: artwork with 500+ downloads is considered a "major" milestone $artwork = $sorted->first(fn ($a): bool => (int) ($a->stat_downloads ?? 0) >= 500); return $artwork ? Carbon::parse($artwork->published_at) : null; } /** * @param Collection $sorted */ private function firstComebackDate(Collection $sorted): ?CarbonInterface { $prevDate = null; foreach ($sorted as $artwork) { $currentDate = Carbon::parse($artwork->published_at); if ($prevDate !== null) { $gapDays = (int) $prevDate->diffInDays($currentDate); if ($gapDays >= self::COMEBACK_GAP_DAYS) { return $currentDate; } } $prevDate = $currentDate; } return null; } /** * @return array */ private function formatEra(CreatorEra $era): array { return [ 'type' => $era->era_type, 'title' => $era->title, 'description' => $era->description, 'starts_at' => $era->starts_at->toIso8601String(), 'ends_at' => $era->ends_at?->toIso8601String(), 'is_current' => $era->is_current, 'stats' => $era->metadata ?? [], ]; } }