feat: ship creator journey v2 and profile updates
This commit is contained in:
187
app/Services/Profile/CreatorComebackService.php
Normal file
187
app/Services/Profile/CreatorComebackService.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Profile;
|
||||
|
||||
use App\Enums\CreatorMilestoneType;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Detects inactivity gaps in a creator's public artwork history and
|
||||
* returns milestone rows for any comeback events.
|
||||
*
|
||||
* Thresholds:
|
||||
* Minor: 180–364 days gap
|
||||
* Major: 365–1094 days gap (1–3 years)
|
||||
* Legendary: 1095+ days gap (3+ years)
|
||||
*/
|
||||
final class CreatorComebackService
|
||||
{
|
||||
private const MINOR_DAYS = 180;
|
||||
private const MAJOR_DAYS = 365;
|
||||
private const LEGENDARY_DAYS = 1095;
|
||||
|
||||
/**
|
||||
* Given the ordered collection of public artwork rows (ascending by published_at),
|
||||
* detect all comeback events and return milestone row arrays.
|
||||
*
|
||||
* @param Collection<int, object> $artworks rows from publicArtworkRows()
|
||||
* @param int $userId
|
||||
* @param CarbonInterface $computedAt
|
||||
* @param callable(int, CreatorMilestoneType, CarbonInterface, array, ?int, CarbonInterface): array $makeMilestoneRow
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function calculateComebacks(
|
||||
Collection $artworks,
|
||||
int $userId,
|
||||
CarbonInterface $computedAt,
|
||||
callable $makeMilestoneRow,
|
||||
): array {
|
||||
if ($artworks->count() < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$sorted = $artworks
|
||||
->filter(fn (object $row): bool => ! empty($row->published_at))
|
||||
->sortBy([['published_at', 'asc'], ['id', 'asc']])
|
||||
->values();
|
||||
|
||||
$milestones = [];
|
||||
$prevDate = null;
|
||||
|
||||
foreach ($sorted as $artwork) {
|
||||
$currentDate = $this->parseDate($artwork->published_at);
|
||||
|
||||
if ($prevDate !== null && $currentDate !== null) {
|
||||
$gapDays = (int) $prevDate->diffInDays($currentDate);
|
||||
|
||||
$type = $this->comebackTypeForGap($gapDays);
|
||||
|
||||
if ($type !== null) {
|
||||
$milestones[] = $makeMilestoneRow(
|
||||
$userId,
|
||||
$type,
|
||||
$currentDate,
|
||||
$this->buildPayload($type, $gapDays, $prevDate, $artwork),
|
||||
(int) $artwork->id,
|
||||
$computedAt,
|
||||
);
|
||||
|
||||
// Only record one comeback per gap: if we match legendary, skip major/minor for same gap.
|
||||
// prevDate resets after each comeback so consecutive short-gap uploads won't double-count.
|
||||
}
|
||||
}
|
||||
|
||||
// Only advance prevDate when the gap did NOT trigger a comeback.
|
||||
// After a comeback, the "chain" resets from the new return date.
|
||||
$prevDate = $currentDate;
|
||||
}
|
||||
|
||||
return $milestones;
|
||||
}
|
||||
|
||||
private function comebackTypeForGap(int $gapDays): ?CreatorMilestoneType
|
||||
{
|
||||
if ($gapDays >= self::LEGENDARY_DAYS) {
|
||||
return CreatorMilestoneType::ComebackLegendary;
|
||||
}
|
||||
|
||||
if ($gapDays >= self::MAJOR_DAYS) {
|
||||
return CreatorMilestoneType::ComebackMajor;
|
||||
}
|
||||
|
||||
if ($gapDays >= self::MINOR_DAYS) {
|
||||
return CreatorMilestoneType::ComebackMinor;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPayload(
|
||||
CreatorMilestoneType $type,
|
||||
int $gapDays,
|
||||
CarbonInterface $previousUploadAt,
|
||||
object $artwork,
|
||||
): array {
|
||||
$years = (int) round($gapDays / 365);
|
||||
$months = (int) round($gapDays / 30);
|
||||
|
||||
$durationLabel = match (true) {
|
||||
$years >= 3 => $years . ' years',
|
||||
$years >= 1 => $years === 1 ? 'a year' : $years . ' years',
|
||||
$months >= 2 => $months . ' months',
|
||||
default => 'several months',
|
||||
};
|
||||
|
||||
$summaryMap = [
|
||||
CreatorMilestoneType::ComebackMinor->value => "Returned to Skinbase after {$durationLabel} away with a new public upload.",
|
||||
CreatorMilestoneType::ComebackMajor->value => "Major comeback after {$durationLabel} away — new work published again on Skinbase.",
|
||||
CreatorMilestoneType::ComebackLegendary->value => "Returned to Skinbase after {$durationLabel} away, picking up where the journey left off.",
|
||||
];
|
||||
|
||||
$titleMap = [
|
||||
CreatorMilestoneType::ComebackMinor->value => 'Comeback',
|
||||
CreatorMilestoneType::ComebackMajor->value => 'Major comeback',
|
||||
CreatorMilestoneType::ComebackLegendary->value => 'Legendary comeback',
|
||||
];
|
||||
|
||||
return [
|
||||
'title' => $titleMap[$type->value] ?? 'Comeback',
|
||||
'headline' => (string) $artwork->title,
|
||||
'summary' => $summaryMap[$type->value] ?? "Returned after {$durationLabel}.",
|
||||
'value' => "After {$durationLabel}",
|
||||
'artwork' => $this->artworkSnapshot($artwork),
|
||||
'metadata' => [
|
||||
'previous_upload_at' => $previousUploadAt->toIso8601String(),
|
||||
'gap_days' => $gapDays,
|
||||
'comeback_level' => $this->levelLabel($type),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function levelLabel(CreatorMilestoneType $type): string
|
||||
{
|
||||
return match ($type) {
|
||||
CreatorMilestoneType::ComebackMinor => 'minor',
|
||||
CreatorMilestoneType::ComebackMajor => 'major',
|
||||
CreatorMilestoneType::ComebackLegendary => 'legendary',
|
||||
default => 'minor',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function artworkSnapshot(object $artwork): array
|
||||
{
|
||||
return [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) $artwork->title,
|
||||
'slug' => (string) ($artwork->slug ?? $artwork->id),
|
||||
'published_at' => $this->parseDate($artwork->published_at)?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
359
app/Services/Profile/CreatorEraService.php
Normal file
359
app/Services/Profile/CreatorEraService.php
Normal file
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Profile;
|
||||
|
||||
use App\Enums\CreatorMilestoneType;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\CreatorEra;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Generates deterministic creator eras from a creator's public artwork history.
|
||||
*
|
||||
* Era types (assigned in order):
|
||||
* early_years – from first upload until a breakthrough signal
|
||||
* breakthrough – starts at first featured artwork or first major download milestone
|
||||
* experimental – detected when a creator shows high category/tag diversity with lower volume
|
||||
* comeback – starts after a significant inactivity gap (180+ days) followed by new publishing
|
||||
* current – the latest ongoing active phase (always set for active creators)
|
||||
*
|
||||
* Rules:
|
||||
* - Only public artworks are considered.
|
||||
* - Era boundaries are determined by key events (features, comebacks).
|
||||
* - At most one era of each non-current type is created per rebuild.
|
||||
* - The "current" era is always the last active phase.
|
||||
*/
|
||||
final class CreatorEraService
|
||||
{
|
||||
private const COMEBACK_GAP_DAYS = 180;
|
||||
|
||||
/**
|
||||
* Rebuild all eras for a user: delete existing rows and reinsert computed ones.
|
||||
*
|
||||
* @param Collection<int, object> $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<array<string, mixed>>
|
||||
*/
|
||||
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<int, object> $artworks
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, object> $artworks
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, object> $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<int, object> $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<int, object> $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<string, mixed>
|
||||
*/
|
||||
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 ?? [],
|
||||
];
|
||||
}
|
||||
}
|
||||
986
app/Services/Profile/CreatorJourneyService.php
Normal file
986
app/Services/Profile/CreatorJourneyService.php
Normal file
@@ -0,0 +1,986 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Profile;
|
||||
|
||||
use App\Enums\CreatorMilestoneType;
|
||||
use App\Jobs\RebuildCreatorJourneyJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkRelation;
|
||||
use App\Models\CreatorMilestone;
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupRelease;
|
||||
use App\Models\User;
|
||||
use App\Services\Profile\CreatorComebackService;
|
||||
use App\Services\Profile\CreatorEraService;
|
||||
use App\Services\Profile\CreatorStreakService;
|
||||
use App\Services\Ranking\ArtworkRankingService;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonInterface;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class CreatorJourneyService
|
||||
{
|
||||
private const PUBLIC_CACHE_TTL_SECONDS = 900;
|
||||
private const REBUILD_DEBOUNCE_SECONDS = 300;
|
||||
|
||||
public function __construct(
|
||||
private readonly ArtworkRankingService $ranking,
|
||||
private readonly CreatorComebackService $comebacks,
|
||||
private readonly CreatorStreakService $streaks,
|
||||
private readonly CreatorEraService $eras,
|
||||
) {
|
||||
}
|
||||
|
||||
public function publicPayloadForUser(User|int $user): array
|
||||
{
|
||||
$resolvedUser = $this->resolveUser($user);
|
||||
$userId = (int) $resolvedUser->id;
|
||||
$version = $this->cacheVersion($userId);
|
||||
|
||||
return Cache::remember(
|
||||
sprintf('creator_journey:public:%d:v%d', $userId, $version),
|
||||
now()->addSeconds(self::PUBLIC_CACHE_TTL_SECONDS),
|
||||
function () use ($resolvedUser, $userId): array {
|
||||
$rows = CreatorMilestone::query()
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->orderByDesc('occurred_at')
|
||||
->orderByDesc('priority')
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
$this->rebuildForUser($resolvedUser);
|
||||
|
||||
$rows = CreatorMilestone::query()
|
||||
->where('user_id', $userId)
|
||||
->where('is_public', true)
|
||||
->orderByDesc('occurred_at')
|
||||
->orderByDesc('priority')
|
||||
->orderByDesc('id')
|
||||
->get();
|
||||
}
|
||||
|
||||
// v2: gather eras, evolution, and streak stats
|
||||
$eraData = Schema::hasTable('creator_eras') ? $this->eras->publicErasForUser($userId) : [];
|
||||
$evolutionData = Schema::hasTable('artwork_relations') ? $this->evolutionPayloadForUser($userId) : [];
|
||||
$artworks = $this->publicArtworkRows($userId);
|
||||
$streakStats = $this->streaks->computeStreakStats($artworks);
|
||||
|
||||
return $this->formatPublicPayload($resolvedUser, $rows, $eraData, $evolutionData, $streakStats);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{milestones_saved:int}
|
||||
*/
|
||||
public function rebuildForUser(User|int $user): array
|
||||
{
|
||||
$resolvedUser = $this->resolveUser($user);
|
||||
$userId = (int) $resolvedUser->id;
|
||||
$computedAt = now();
|
||||
$rows = $this->calculateMilestones($resolvedUser, $computedAt);
|
||||
|
||||
DB::transaction(function () use ($userId, $rows): void {
|
||||
CreatorMilestone::query()->where('user_id', $userId)->delete();
|
||||
|
||||
if ($rows !== []) {
|
||||
DB::table('creator_milestones')->insert($rows);
|
||||
}
|
||||
});
|
||||
|
||||
// Rebuild eras in the same pass (separate table, transactional independently)
|
||||
$artworks = $this->publicArtworkRows($userId);
|
||||
$this->eras->rebuildForUser($resolvedUser, $artworks);
|
||||
|
||||
Cache::forget($this->rebuildDebounceKey($userId));
|
||||
$this->bumpCacheVersion($userId);
|
||||
|
||||
return ['milestones_saved' => count($rows)];
|
||||
}
|
||||
|
||||
public function requestRebuild(int $userId, bool $force = false): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $force && ! Cache::add($this->rebuildDebounceKey($userId), true, now()->addSeconds(self::REBUILD_DEBOUNCE_SECONDS))) {
|
||||
return;
|
||||
}
|
||||
|
||||
RebuildCreatorJourneyJob::dispatch([$userId]);
|
||||
}
|
||||
|
||||
public function invalidateUser(int $userId): void
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->bumpCacheVersion($userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function calculateMilestones(User $user, CarbonInterface $computedAt): array
|
||||
{
|
||||
$artworks = $this->publicArtworkRows((int) $user->id);
|
||||
$milestones = [];
|
||||
|
||||
if ($firstUpload = $artworks->sortBy([['published_at', 'asc'], ['id', 'asc']])->first()) {
|
||||
$occurredAt = $this->parseDate($firstUpload->published_at);
|
||||
$milestones[] = $this->makeMilestoneRow(
|
||||
(int) $user->id,
|
||||
CreatorMilestoneType::FirstUpload,
|
||||
$occurredAt,
|
||||
[
|
||||
'title' => 'First upload',
|
||||
'headline' => (string) $firstUpload->title,
|
||||
'summary' => 'Started the public journey with the first published work on Skinbase.',
|
||||
'value' => $this->displayDate($occurredAt),
|
||||
'artwork' => $this->artworkSnapshot($firstUpload),
|
||||
],
|
||||
(int) $firstUpload->id,
|
||||
$computedAt,
|
||||
);
|
||||
}
|
||||
|
||||
if ($firstFeatured = $this->firstFeaturedArtwork((int) $user->id)) {
|
||||
$occurredAt = $this->parseDate($firstFeatured->featured_at);
|
||||
$milestones[] = $this->makeMilestoneRow(
|
||||
(int) $user->id,
|
||||
CreatorMilestoneType::FirstFeaturedArtwork,
|
||||
$occurredAt,
|
||||
[
|
||||
'title' => 'First featured artwork',
|
||||
'headline' => (string) $firstFeatured->title,
|
||||
'summary' => 'Earned a first featured slot on the public artwork lineup.',
|
||||
'value' => $this->displayDate($occurredAt),
|
||||
'artwork' => $this->artworkSnapshot($firstFeatured),
|
||||
],
|
||||
(int) $firstFeatured->id,
|
||||
$computedAt,
|
||||
);
|
||||
}
|
||||
|
||||
if ($firstGroupRelease = $this->firstGroupRelease((int) $user->id)) {
|
||||
$occurredAt = $this->parseDate($firstGroupRelease->released_on);
|
||||
$milestones[] = $this->makeMilestoneRow(
|
||||
(int) $user->id,
|
||||
CreatorMilestoneType::FirstGroupRelease,
|
||||
$occurredAt,
|
||||
[
|
||||
'title' => 'First group release',
|
||||
'headline' => (string) $firstGroupRelease->release_title,
|
||||
'summary' => 'Joined the first public group release as a credited contributor.',
|
||||
'value' => (string) $firstGroupRelease->group_name,
|
||||
'release' => [
|
||||
'id' => (int) $firstGroupRelease->release_id,
|
||||
'title' => (string) $firstGroupRelease->release_title,
|
||||
'group_name' => (string) $firstGroupRelease->group_name,
|
||||
'url' => url('/groups/' . $firstGroupRelease->group_slug . '/releases/' . $firstGroupRelease->release_slug),
|
||||
],
|
||||
],
|
||||
null,
|
||||
$computedAt,
|
||||
);
|
||||
}
|
||||
|
||||
if ($bestSpike = $this->biggestDownloadSpike($artworks)) {
|
||||
$occurredAt = $this->parseDate($bestSpike['occurred_at']);
|
||||
$milestones[] = $this->makeMilestoneRow(
|
||||
(int) $user->id,
|
||||
CreatorMilestoneType::BiggestDownloadSpike,
|
||||
$occurredAt,
|
||||
[
|
||||
'title' => 'Biggest download spike',
|
||||
'headline' => (string) $bestSpike['artwork']->title,
|
||||
'summary' => 'Captured the strongest one-hour download burst recorded for a public artwork.',
|
||||
'value' => (int) $bestSpike['downloads_in_hour'] . ' downloads in 1 hour',
|
||||
'artwork' => $this->artworkSnapshot($bestSpike['artwork']),
|
||||
'metrics' => [
|
||||
'downloads_in_hour' => (int) $bestSpike['downloads_in_hour'],
|
||||
],
|
||||
],
|
||||
(int) $bestSpike['artwork']->id,
|
||||
$computedAt,
|
||||
);
|
||||
}
|
||||
|
||||
if ($bestPerforming = $this->bestPerformingArtwork($artworks)) {
|
||||
$occurredAt = $this->parseDate($bestPerforming->published_at);
|
||||
$score = $this->basePerformanceScore($bestPerforming);
|
||||
$milestones[] = $this->makeMilestoneRow(
|
||||
(int) $user->id,
|
||||
CreatorMilestoneType::BestPerformingWork,
|
||||
$occurredAt,
|
||||
[
|
||||
'title' => 'Best-performing work',
|
||||
'headline' => (string) $bestPerforming->title,
|
||||
'summary' => 'Leads the public catalog on total engagement across views, downloads, favourites, comments, and shares.',
|
||||
'value' => number_format($score, 1) . ' performance points',
|
||||
'artwork' => $this->artworkSnapshot($bestPerforming),
|
||||
'metrics' => $this->artworkMetricSnapshot($bestPerforming) + ['performance_score' => round($score, 2)],
|
||||
],
|
||||
(int) $bestPerforming->id,
|
||||
$computedAt,
|
||||
);
|
||||
}
|
||||
|
||||
if ($mostProductiveYear = $this->mostProductiveYear($artworks)) {
|
||||
$occurredAt = $this->parseDate($mostProductiveYear['last_published_at']);
|
||||
$milestones[] = $this->makeMilestoneRow(
|
||||
(int) $user->id,
|
||||
CreatorMilestoneType::MostProductiveYear,
|
||||
$occurredAt,
|
||||
[
|
||||
'title' => 'Most productive year',
|
||||
'headline' => (string) $mostProductiveYear['year'],
|
||||
'summary' => 'Published the highest number of public artworks in a single year.',
|
||||
'value' => (int) $mostProductiveYear['uploads_count'] . ' public uploads',
|
||||
'metrics' => [
|
||||
'year' => (int) $mostProductiveYear['year'],
|
||||
'uploads_count' => (int) $mostProductiveYear['uploads_count'],
|
||||
],
|
||||
],
|
||||
null,
|
||||
$computedAt,
|
||||
);
|
||||
}
|
||||
|
||||
// ── v2: Comeback milestones ────────────────────────────────────────
|
||||
foreach ($this->comebacks->calculateComebacks($artworks, (int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) {
|
||||
$milestones[] = $row;
|
||||
}
|
||||
|
||||
// ── v2: Streak milestones ─────────────────────────────────────────
|
||||
foreach ($this->streaks->calculateStreakMilestones($artworks, (int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) {
|
||||
$milestones[] = $row;
|
||||
}
|
||||
|
||||
// ── v2: Era milestones ────────────────────────────────────────────
|
||||
foreach ($this->eras->calculateEraMilestones($user, $artworks, $computedAt, $this->makeMilestoneRow(...)) as $row) {
|
||||
$milestones[] = $row;
|
||||
}
|
||||
|
||||
// ── v2: Evolution / Before-Now milestones ─────────────────────────
|
||||
foreach ($this->evolutionMilestonesForUser((int) $user->id, $computedAt, $this->makeMilestoneRow(...)) as $row) {
|
||||
$milestones[] = $row;
|
||||
}
|
||||
|
||||
foreach ($this->yearlyRecaps($artworks) as $recap) {
|
||||
$occurredAt = $this->parseDate($recap['last_published_at']);
|
||||
$milestones[] = $this->makeMilestoneRow(
|
||||
(int) $user->id,
|
||||
CreatorMilestoneType::YearlyRecap,
|
||||
$occurredAt,
|
||||
[
|
||||
'title' => $recap['year'] . ' recap',
|
||||
'headline' => $recap['uploads_count'] . ' public uploads',
|
||||
'summary' => $recap['downloads'] . ' downloads, ' . number_format((int) $recap['views']) . ' views, and ' . $recap['favorites'] . ' favourites across the year.',
|
||||
'value' => (string) $recap['year'],
|
||||
'artwork' => $recap['top_artwork'] !== null ? $this->artworkSnapshot($recap['top_artwork']) : null,
|
||||
'metrics' => [
|
||||
'year' => (int) $recap['year'],
|
||||
'uploads_count' => (int) $recap['uploads_count'],
|
||||
'views' => (int) $recap['views'],
|
||||
'downloads' => (int) $recap['downloads'],
|
||||
'favorites' => (int) $recap['favorites'],
|
||||
'comments_count' => (int) $recap['comments_count'],
|
||||
'shares_count' => (int) $recap['shares_count'],
|
||||
'featured_count' => (int) $recap['featured_count'],
|
||||
'performance_score' => round((float) $recap['performance_score'], 2),
|
||||
'top_category' => $recap['top_category'] ?? null,
|
||||
'best_month' => $recap['best_month'] ?? null,
|
||||
'year_status' => $recap['year_status'] ?? 'steady',
|
||||
],
|
||||
'shareable_recap' => [
|
||||
'type' => 'yearly_recap',
|
||||
'year' => (int) $recap['year'],
|
||||
'title' => 'My ' . $recap['year'] . ' on Skinbase',
|
||||
'stats' => [
|
||||
'uploads' => (int) $recap['uploads_count'],
|
||||
'downloads' => (int) $recap['downloads'],
|
||||
'featured' => (int) $recap['featured_count'],
|
||||
],
|
||||
'top_artwork' => $recap['top_artwork'] !== null ? [
|
||||
'id' => (int) $recap['top_artwork']->id,
|
||||
'title' => (string) $recap['top_artwork']->title,
|
||||
] : null,
|
||||
],
|
||||
],
|
||||
$recap['top_artwork'] !== null ? (int) $recap['top_artwork']->id : null,
|
||||
$computedAt,
|
||||
);
|
||||
}
|
||||
|
||||
return collect($milestones)
|
||||
->sortBy([
|
||||
['occurred_at', 'desc'],
|
||||
['priority', 'desc'],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function publicArtworkRows(int $userId): Collection
|
||||
{
|
||||
return DB::table('artworks as a')
|
||||
->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'a.id')
|
||||
->where('a.user_id', $userId)
|
||||
->whereNull('a.deleted_at')
|
||||
->where('a.is_public', true)
|
||||
->where('a.is_approved', true)
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('a.visibility')
|
||||
->orWhere('a.visibility', Artwork::VISIBILITY_PUBLIC);
|
||||
})
|
||||
->whereNotNull('a.published_at')
|
||||
->where('a.published_at', '<=', now())
|
||||
->orderBy('a.published_at')
|
||||
->orderBy('a.id')
|
||||
->get([
|
||||
'a.id',
|
||||
'a.title',
|
||||
'a.slug',
|
||||
'a.published_at',
|
||||
'a.created_at',
|
||||
's.views as stat_views',
|
||||
's.downloads as stat_downloads',
|
||||
's.favorites as stat_favorites',
|
||||
's.comments_count as stat_comments_count',
|
||||
's.shares_count as stat_shares_count',
|
||||
's.downloads_1h as stat_downloads_1h',
|
||||
's.heat_score_updated_at as stat_heat_score_updated_at',
|
||||
]);
|
||||
}
|
||||
|
||||
private function firstFeaturedArtwork(int $userId): ?object
|
||||
{
|
||||
return 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)
|
||||
->where(function ($query): void {
|
||||
$query->whereNull('a.visibility')
|
||||
->orWhere('a.visibility', Artwork::VISIBILITY_PUBLIC);
|
||||
})
|
||||
->whereNotNull('a.published_at')
|
||||
->whereNull('af.deleted_at')
|
||||
->where('af.is_active', true)
|
||||
->orderBy('af.featured_at')
|
||||
->orderBy('a.id')
|
||||
->first([
|
||||
'a.id',
|
||||
'a.title',
|
||||
'a.slug',
|
||||
'a.published_at',
|
||||
'af.featured_at',
|
||||
]);
|
||||
}
|
||||
|
||||
private function firstGroupRelease(int $userId): ?object
|
||||
{
|
||||
return DB::table('group_release_contributors as grc')
|
||||
->join('group_releases as gr', 'gr.id', '=', 'grc.group_release_id')
|
||||
->join('groups as g', 'g.id', '=', 'gr.group_id')
|
||||
->where('grc.user_id', $userId)
|
||||
->whereNull('gr.deleted_at')
|
||||
->where('gr.visibility', GroupRelease::VISIBILITY_PUBLIC)
|
||||
->where('gr.status', GroupRelease::STATUS_RELEASED)
|
||||
->where('g.visibility', Group::VISIBILITY_PUBLIC)
|
||||
->where('g.status', Group::LIFECYCLE_ACTIVE)
|
||||
->whereNotNull('gr.released_at')
|
||||
->where('gr.released_at', '<=', now())
|
||||
->orderBy('gr.released_at')
|
||||
->orderBy('gr.id')
|
||||
->first([
|
||||
'gr.id as release_id',
|
||||
'gr.title as release_title',
|
||||
'gr.slug as release_slug',
|
||||
'gr.released_at as released_on',
|
||||
'g.name as group_name',
|
||||
'g.slug as group_slug',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $artworks
|
||||
* @return array{artwork:object,downloads_in_hour:int,occurred_at:string}|null
|
||||
*/
|
||||
private function biggestDownloadSpike(Collection $artworks): ?array
|
||||
{
|
||||
$best = null;
|
||||
$publicArtworkIds = $artworks->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||
|
||||
if ($publicArtworkIds !== [] && DB::getSchemaBuilder()->hasTable('artwork_metric_snapshots_hourly')) {
|
||||
$snapshots = DB::table('artwork_metric_snapshots_hourly as ms')
|
||||
->whereIn('ms.artwork_id', $publicArtworkIds)
|
||||
->orderBy('ms.artwork_id')
|
||||
->orderBy('ms.bucket_hour')
|
||||
->get([
|
||||
'ms.artwork_id',
|
||||
'ms.bucket_hour',
|
||||
'ms.downloads_count',
|
||||
]);
|
||||
|
||||
$byArtwork = $artworks->keyBy('id');
|
||||
$previous = [];
|
||||
|
||||
foreach ($snapshots as $snapshot) {
|
||||
$artwork = $byArtwork->get((int) $snapshot->artwork_id);
|
||||
|
||||
if (! $artwork) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$priorCount = $previous[(int) $snapshot->artwork_id] ?? null;
|
||||
|
||||
if ($priorCount !== null) {
|
||||
$delta = max(0, (int) $snapshot->downloads_count - $priorCount['downloads_count']);
|
||||
|
||||
if ($delta > 0 && ($best === null || $delta > $best['downloads_in_hour'] || ($delta === $best['downloads_in_hour'] && $snapshot->bucket_hour > $best['occurred_at']))) {
|
||||
$best = [
|
||||
'artwork' => $artwork,
|
||||
'downloads_in_hour' => $delta,
|
||||
'occurred_at' => (string) $snapshot->bucket_hour,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$previous[(int) $snapshot->artwork_id] = [
|
||||
'downloads_count' => (int) $snapshot->downloads_count,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if ($best !== null) {
|
||||
return $best;
|
||||
}
|
||||
|
||||
$fallback = $artworks
|
||||
->filter(fn ($artwork): bool => (int) ($artwork->stat_downloads_1h ?? 0) > 0)
|
||||
->sortBy([
|
||||
fn ($artwork): int => -1 * (int) ($artwork->stat_downloads_1h ?? 0),
|
||||
fn ($artwork): string => (string) ($artwork->stat_heat_score_updated_at ?? $artwork->published_at ?? ''),
|
||||
])
|
||||
->first();
|
||||
|
||||
if (! $fallback) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'artwork' => $fallback,
|
||||
'downloads_in_hour' => (int) ($fallback->stat_downloads_1h ?? 0),
|
||||
'occurred_at' => (string) ($fallback->stat_heat_score_updated_at ?? $fallback->published_at),
|
||||
];
|
||||
}
|
||||
|
||||
private function bestPerformingArtwork(Collection $artworks): ?object
|
||||
{
|
||||
return $artworks
|
||||
->filter(fn ($artwork): bool => $this->basePerformanceScore($artwork) > 0)
|
||||
->sortBy([
|
||||
fn ($artwork): float => -1 * $this->basePerformanceScore($artwork),
|
||||
fn ($artwork): string => (string) ($artwork->published_at ?? ''),
|
||||
])
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $artworks
|
||||
* @return array{year:int,uploads_count:int,last_published_at:string}|null
|
||||
*/
|
||||
private function mostProductiveYear(Collection $artworks): ?array
|
||||
{
|
||||
return $artworks
|
||||
->groupBy(fn ($artwork): int => (int) date('Y', strtotime((string) $artwork->published_at)))
|
||||
->map(function (Collection $items, int $year): array {
|
||||
$lastPublishedAt = $items
|
||||
->sortByDesc('published_at')
|
||||
->first()?->published_at;
|
||||
|
||||
return [
|
||||
'year' => $year,
|
||||
'uploads_count' => $items->count(),
|
||||
'last_published_at' => (string) $lastPublishedAt,
|
||||
];
|
||||
})
|
||||
->sortBy([
|
||||
fn (array $row): int => -1 * (int) $row['uploads_count'],
|
||||
fn (array $row): int => -1 * (int) $row['year'],
|
||||
])
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $artworks
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private function yearlyRecaps(Collection $artworks): array
|
||||
{
|
||||
// Fetch featured counts per year once (keyed by year)
|
||||
$featuredByYear = $this->featuredCountsByYear($artworks);
|
||||
|
||||
return $artworks
|
||||
->groupBy(fn ($artwork): int => (int) date('Y', strtotime((string) $artwork->published_at)))
|
||||
->map(function (Collection $items, int $year) use ($featuredByYear): array {
|
||||
$topArtwork = $items
|
||||
->sortByDesc(fn ($artwork): float => $this->basePerformanceScore($artwork))
|
||||
->first();
|
||||
|
||||
$downloads = $items->sum(fn ($a): int => (int) ($a->stat_downloads ?? 0));
|
||||
$uploads = $items->count();
|
||||
$featured = (int) ($featuredByYear[$year] ?? 0);
|
||||
$perfScore = $items->sum(fn ($a): float => $this->basePerformanceScore($a));
|
||||
|
||||
// Best month: which calendar month had the most uploads
|
||||
$bestMonth = $items
|
||||
->groupBy(fn ($a): string => date('Y-m', strtotime((string) $a->published_at)))
|
||||
->map(fn (Collection $g): int => $g->count())
|
||||
->sortDesc()
|
||||
->keys()
|
||||
->first();
|
||||
|
||||
// Top category from artwork pivot (best effort — requires subquery or separate call)
|
||||
$topCategory = $this->topCategoryForYear($items);
|
||||
|
||||
// Year status label
|
||||
$yearStatus = $this->classifyYear($uploads, $featured, $perfScore);
|
||||
|
||||
return [
|
||||
'year' => $year,
|
||||
'uploads_count' => $uploads,
|
||||
'views' => $items->sum(fn ($a): int => (int) ($a->stat_views ?? 0)),
|
||||
'downloads' => $downloads,
|
||||
'favorites' => $items->sum(fn ($a): int => (int) ($a->stat_favorites ?? 0)),
|
||||
'comments_count' => $items->sum(fn ($a): int => (int) ($a->stat_comments_count ?? 0)),
|
||||
'shares_count' => $items->sum(fn ($a): int => (int) ($a->stat_shares_count ?? 0)),
|
||||
'featured_count' => $featured,
|
||||
'performance_score' => $perfScore,
|
||||
'last_published_at' => (string) $items->sortByDesc('published_at')->first()?->published_at,
|
||||
'top_artwork' => $topArtwork,
|
||||
'best_month' => $bestMonth,
|
||||
'top_category' => $topCategory,
|
||||
'year_status' => $yearStatus,
|
||||
];
|
||||
})
|
||||
->sortByDesc('year')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $artworks
|
||||
* @return array<int, int> year => featured_count
|
||||
*/
|
||||
private function featuredCountsByYear(Collection $artworks): array
|
||||
{
|
||||
$artworkIds = $artworks->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||
|
||||
if ($artworkIds === [] || ! Schema::hasTable('artwork_features')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$yearExpr = DB::connection()->getDriverName() === 'sqlite'
|
||||
? "CAST(strftime('%Y', af.featured_at) AS INTEGER)"
|
||||
: 'YEAR(af.featured_at)';
|
||||
|
||||
return DB::table('artwork_features as af')
|
||||
->join('artworks as a', 'a.id', '=', 'af.artwork_id')
|
||||
->whereIn('af.artwork_id', $artworkIds)
|
||||
->whereNull('af.deleted_at')
|
||||
->where('af.is_active', true)
|
||||
->selectRaw("{$yearExpr} as yr, COUNT(*) as cnt")
|
||||
->groupBy('yr')
|
||||
->pluck('cnt', 'yr')
|
||||
->map(fn ($cnt): int => (int) $cnt)
|
||||
->toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, object> $items
|
||||
*/
|
||||
private function topCategoryForYear(Collection $items): ?string
|
||||
{
|
||||
$artworkIds = $items->pluck('id')->map(fn ($id): int => (int) $id)->all();
|
||||
|
||||
if ($artworkIds === [] || ! Schema::hasTable('artwork_category')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$row = DB::table('artwork_category as ac')
|
||||
->join('categories as c', 'c.id', '=', 'ac.category_id')
|
||||
->whereIn('ac.artwork_id', $artworkIds)
|
||||
->selectRaw('c.name, COUNT(*) as cnt')
|
||||
->groupBy('c.id', 'c.name')
|
||||
->orderByDesc('cnt')
|
||||
->first(['c.name']);
|
||||
|
||||
return $row ? (string) $row->name : null;
|
||||
}
|
||||
|
||||
private function classifyYear(int $uploads, int $featured, float $perfScore): string
|
||||
{
|
||||
if ($uploads >= 10 && $featured >= 2) {
|
||||
return 'breakout';
|
||||
}
|
||||
|
||||
if ($featured >= 1 && $uploads >= 5) {
|
||||
return 'steady';
|
||||
}
|
||||
|
||||
if ($uploads >= 6 && $featured === 0) {
|
||||
return 'experimental';
|
||||
}
|
||||
|
||||
if ($uploads <= 2) {
|
||||
return 'quiet';
|
||||
}
|
||||
|
||||
return 'steady';
|
||||
}
|
||||
|
||||
private function formatPublicPayload(
|
||||
User $user,
|
||||
Collection $rows,
|
||||
array $eras = [],
|
||||
array $evolution = [],
|
||||
array $streakStats = [],
|
||||
): array {
|
||||
$items = $rows->map(function (CreatorMilestone $milestone): array {
|
||||
$payload = $milestone->payload_json ?? [];
|
||||
|
||||
return [
|
||||
'id' => (int) $milestone->id,
|
||||
'type' => (string) $milestone->type,
|
||||
'occurred_at' => $milestone->occurred_at?->toIso8601String(),
|
||||
'occurred_year' => $milestone->occurred_year,
|
||||
'priority' => (int) $milestone->priority,
|
||||
'title' => (string) ($payload['title'] ?? Str::headline((string) $milestone->type)),
|
||||
'headline' => $payload['headline'] ?? null,
|
||||
'summary' => $payload['summary'] ?? null,
|
||||
'value' => $payload['value'] ?? null,
|
||||
'artwork' => $payload['artwork'] ?? null,
|
||||
'release' => $payload['release'] ?? null,
|
||||
'metrics' => $payload['metrics'] ?? [],
|
||||
'metadata' => $payload['metadata'] ?? null,
|
||||
'shareable_recap' => $payload['shareable_recap'] ?? null,
|
||||
];
|
||||
})->values();
|
||||
|
||||
$timeline = $items
|
||||
->reject(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$yearlyRecaps = $items
|
||||
->filter(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value)
|
||||
->sortByDesc('occurred_year')
|
||||
->values()
|
||||
->all();
|
||||
|
||||
// Build shareable recap payloads from yearly recap milestone payloads
|
||||
$shareableRecaps = $items
|
||||
->filter(fn (array $item): bool => $item['type'] === CreatorMilestoneType::YearlyRecap->value)
|
||||
->sortByDesc('occurred_year')
|
||||
->map(fn (array $item): ?array => $item['shareable_recap'])
|
||||
->filter()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$highlightTypes = [
|
||||
CreatorMilestoneType::BestPerformingWork->value,
|
||||
CreatorMilestoneType::BiggestDownloadSpike->value,
|
||||
CreatorMilestoneType::MostProductiveYear->value,
|
||||
CreatorMilestoneType::FirstFeaturedArtwork->value,
|
||||
CreatorMilestoneType::ComebackLegendary->value,
|
||||
CreatorMilestoneType::UploadStreak12->value,
|
||||
CreatorMilestoneType::ActiveYearStreak5->value,
|
||||
];
|
||||
|
||||
$highlights = $items
|
||||
->filter(fn (array $item): bool => in_array($item['type'], $highlightTypes, true))
|
||||
->sortByDesc('priority')
|
||||
->values()
|
||||
->take(4)
|
||||
->all();
|
||||
|
||||
$latestMilestone = collect($timeline)->first();
|
||||
|
||||
// Streak summary for API
|
||||
$streakSummary = [
|
||||
'current_monthly_upload_streak' => (int) ($streakStats['current_monthly_streak'] ?? 0),
|
||||
'best_monthly_upload_streak' => (int) ($streakStats['best_monthly_streak'] ?? 0),
|
||||
'current_active_year_streak' => (int) ($streakStats['current_year_streak'] ?? 0),
|
||||
'best_active_year_streak' => (int) ($streakStats['best_year_streak'] ?? 0),
|
||||
];
|
||||
|
||||
return [
|
||||
'summary' => [
|
||||
'available' => $items->isNotEmpty(),
|
||||
'member_since_year' => $user->created_at?->year,
|
||||
'years_on_skinbase' => $user->created_at?->diffInYears(now()),
|
||||
'milestone_count' => $items->count(),
|
||||
'latest_milestone' => $latestMilestone,
|
||||
'latest_yearly_recap' => $yearlyRecaps[0] ?? null,
|
||||
'generated_at' => $rows->max(fn (CreatorMilestone $milestone) => $milestone->computed_at?->toIso8601String()),
|
||||
],
|
||||
'highlights' => $highlights,
|
||||
'timeline' => $timeline,
|
||||
'yearly_recaps' => $yearlyRecaps,
|
||||
// ── v2 sections ────────────────────────────────────────────────
|
||||
'eras' => $eras,
|
||||
'evolution' => $evolution,
|
||||
'streaks' => $streakSummary,
|
||||
'shareable_recaps' => $shareableRecaps,
|
||||
];
|
||||
}
|
||||
|
||||
private function evolutionPayloadForUser(int $userId): array
|
||||
{
|
||||
// Fetch public artwork_relations where the source artwork belongs to this creator.
|
||||
// Both source and target must be public for public display.
|
||||
$rows = DB::table('artwork_relations as ar')
|
||||
->join('artworks as src', 'src.id', '=', 'ar.source_artwork_id')
|
||||
->join('artworks as tgt', 'tgt.id', '=', 'ar.target_artwork_id')
|
||||
->leftJoin('artwork_stats as ss', 'ss.artwork_id', '=', 'ar.source_artwork_id')
|
||||
->leftJoin('artwork_stats as ts', 'ts.artwork_id', '=', 'ar.target_artwork_id')
|
||||
->where('src.user_id', $userId)
|
||||
->whereNull('src.deleted_at')
|
||||
->whereNull('tgt.deleted_at')
|
||||
->where('src.is_public', true)
|
||||
->where('src.is_approved', true)
|
||||
->where('tgt.is_public', true)
|
||||
->where('tgt.is_approved', true)
|
||||
->whereNotNull('src.published_at')
|
||||
->whereNotNull('tgt.published_at')
|
||||
->orderBy('ar.sort_order')
|
||||
->orderBy('ar.id')
|
||||
->get([
|
||||
'ar.id',
|
||||
'ar.relation_type',
|
||||
'ar.note',
|
||||
'src.id as src_id',
|
||||
'src.title as src_title',
|
||||
'src.slug as src_slug',
|
||||
'src.published_at as src_published_at',
|
||||
'tgt.id as tgt_id',
|
||||
'tgt.title as tgt_title',
|
||||
'tgt.slug as tgt_slug',
|
||||
'tgt.published_at as tgt_published_at',
|
||||
]);
|
||||
|
||||
return $rows->map(function (object $row): array {
|
||||
$srcDate = Carbon::parse($row->src_published_at);
|
||||
$tgtDate = Carbon::parse($row->tgt_published_at);
|
||||
$yearsBetween = (int) abs($tgtDate->diffInYears($srcDate));
|
||||
|
||||
return [
|
||||
'id' => (int) $row->id,
|
||||
'relation_type' => (string) $row->relation_type,
|
||||
'years_between' => $yearsBetween,
|
||||
'note' => $row->note,
|
||||
'source_artwork' => [
|
||||
'id' => (int) $row->src_id,
|
||||
'title' => (string) $row->src_title,
|
||||
'slug' => (string) $row->src_slug,
|
||||
'url' => route('art.show', ['id' => (int) $row->src_id, 'slug' => $row->src_slug]),
|
||||
'published_at' => $srcDate->toIso8601String(),
|
||||
],
|
||||
'target_artwork' => [
|
||||
'id' => (int) $row->tgt_id,
|
||||
'title' => (string) $row->tgt_title,
|
||||
'slug' => (string) $row->tgt_slug,
|
||||
'url' => route('art.show', ['id' => (int) $row->tgt_id, 'slug' => $row->tgt_slug]),
|
||||
'published_at' => $tgtDate->toIso8601String(),
|
||||
],
|
||||
];
|
||||
})->values()->all();
|
||||
}
|
||||
|
||||
private function evolutionMilestonesForUser(int $userId, CarbonInterface $computedAt, callable $makeMilestoneRow): array
|
||||
{
|
||||
if (! Schema::hasTable('artwork_relations')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = DB::table('artwork_relations as ar')
|
||||
->join('artworks as src', 'src.id', '=', 'ar.source_artwork_id')
|
||||
->join('artworks as tgt', 'tgt.id', '=', 'ar.target_artwork_id')
|
||||
->where('src.user_id', $userId)
|
||||
->whereNull('src.deleted_at')
|
||||
->whereNull('tgt.deleted_at')
|
||||
->where('src.is_public', true)
|
||||
->where('src.is_approved', true)
|
||||
->where('tgt.is_public', true)
|
||||
->where('tgt.is_approved', true)
|
||||
->whereNotNull('src.published_at')
|
||||
->whereNotNull('tgt.published_at')
|
||||
->get(['ar.id', 'ar.relation_type', 'ar.note', 'src.id as src_id', 'src.title as src_title', 'src.slug as src_slug', 'src.published_at as src_pub', 'tgt.id as tgt_id', 'tgt.title as tgt_title', 'tgt.published_at as tgt_pub']);
|
||||
|
||||
$milestones = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$srcDate = Carbon::parse($row->src_pub);
|
||||
$tgtDate = Carbon::parse($row->tgt_pub);
|
||||
$years = max(0, (int) abs($tgtDate->diffInYears($srcDate)));
|
||||
$yearStr = $years >= 1 ? "{$years} " . ($years === 1 ? 'year' : 'years') . ' later' : 'recently';
|
||||
|
||||
$milestones[] = $makeMilestoneRow(
|
||||
$userId,
|
||||
CreatorMilestoneType::BeforeNow,
|
||||
$srcDate->max($tgtDate), // milestone at the newer artwork
|
||||
[
|
||||
'title' => 'Then & Now',
|
||||
'headline' => (string) $row->src_title,
|
||||
'summary' => "Revisited and {$row->relation_type} \"{$row->tgt_title}\" — {$yearStr}.",
|
||||
'value' => $yearStr,
|
||||
'artwork' => [
|
||||
'id' => (int) $row->src_id,
|
||||
'title' => (string) $row->src_title,
|
||||
'slug' => (string) $row->src_slug,
|
||||
'url' => route('art.show', ['id' => (int) $row->src_id, 'slug' => $row->src_slug]),
|
||||
],
|
||||
'metadata' => [
|
||||
'relation_type' => $row->relation_type,
|
||||
'years_between' => $years,
|
||||
'source_artwork_id' => (int) $row->src_id,
|
||||
'target_artwork_id' => (int) $row->tgt_id,
|
||||
],
|
||||
],
|
||||
(int) $row->src_id,
|
||||
$computedAt,
|
||||
);
|
||||
}
|
||||
|
||||
return $milestones;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function makeMilestoneRow(
|
||||
int $userId,
|
||||
CreatorMilestoneType $type,
|
||||
?CarbonInterface $occurredAt,
|
||||
array $payload,
|
||||
?int $relatedArtworkId,
|
||||
CarbonInterface $computedAt,
|
||||
): array {
|
||||
$occurredAt = $occurredAt ?? $computedAt;
|
||||
|
||||
return [
|
||||
'user_id' => $userId,
|
||||
'type' => $type->value,
|
||||
'occurred_at' => $occurredAt->toDateTimeString(),
|
||||
'occurred_year' => (int) $occurredAt->year,
|
||||
'related_artwork_id' => $relatedArtworkId,
|
||||
'is_public' => true,
|
||||
'priority' => $type->priority(),
|
||||
'payload_json' => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
'computed_at' => $computedAt->toDateTimeString(),
|
||||
'created_at' => $computedAt->toDateTimeString(),
|
||||
'updated_at' => $computedAt->toDateTimeString(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function artworkSnapshot(object $artwork): array
|
||||
{
|
||||
$slug = Str::slug((string) ($artwork->slug ?: $artwork->title)) ?: (string) $artwork->id;
|
||||
|
||||
return [
|
||||
'id' => (int) $artwork->id,
|
||||
'title' => (string) $artwork->title,
|
||||
'slug' => (string) $slug,
|
||||
'url' => route('art.show', ['id' => (int) $artwork->id, 'slug' => $slug]),
|
||||
'published_at' => $this->parseDate($artwork->published_at)?->toIso8601String(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function artworkMetricSnapshot(object $artwork): array
|
||||
{
|
||||
return [
|
||||
'views' => (int) ($artwork->stat_views ?? 0),
|
||||
'downloads' => (int) ($artwork->stat_downloads ?? 0),
|
||||
'favorites' => (int) ($artwork->stat_favorites ?? 0),
|
||||
'comments_count' => (int) ($artwork->stat_comments_count ?? 0),
|
||||
'shares_count' => (int) ($artwork->stat_shares_count ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
private function basePerformanceScore(object $artwork): float
|
||||
{
|
||||
return $this->ranking->calculateBaseScore((object) [
|
||||
'views_all' => (float) ($artwork->stat_views ?? 0),
|
||||
'downloads_all' => (float) ($artwork->stat_downloads ?? 0),
|
||||
'favourites_all' => (float) ($artwork->stat_favorites ?? 0),
|
||||
'comments_count' => (float) ($artwork->stat_comments_count ?? 0),
|
||||
'shares_count' => (float) ($artwork->stat_shares_count ?? 0),
|
||||
]);
|
||||
}
|
||||
|
||||
private function displayDate(?CarbonInterface $date): ?string
|
||||
{
|
||||
return $date?->format('M j, Y');
|
||||
}
|
||||
|
||||
private function parseDate(mixed $value): ?CarbonInterface
|
||||
{
|
||||
if ($value instanceof CarbonInterface) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! is_string($value) || trim($value) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Carbon::parse($value);
|
||||
}
|
||||
|
||||
private function resolveUser(User|int $user): User
|
||||
{
|
||||
return $user instanceof User
|
||||
? $user
|
||||
: User::query()->findOrFail($user);
|
||||
}
|
||||
|
||||
private function cacheVersion(int $userId): int
|
||||
{
|
||||
return (int) Cache::get($this->cacheVersionKey($userId), 1);
|
||||
}
|
||||
|
||||
private function bumpCacheVersion(int $userId): void
|
||||
{
|
||||
Cache::forever($this->cacheVersionKey($userId), $this->cacheVersion($userId) + 1);
|
||||
}
|
||||
|
||||
private function cacheVersionKey(int $userId): string
|
||||
{
|
||||
return 'creator_journey:version:' . $userId;
|
||||
}
|
||||
|
||||
private function rebuildDebounceKey(int $userId): string
|
||||
{
|
||||
return 'creator_journey:rebuild:debounce:' . $userId;
|
||||
}
|
||||
}
|
||||
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