Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ Sources: [go-live menu](resources/js/components/profile-actions-menu.tsx), [live

### Achievement badges and XP

Profiles have **14 achievement badges**. Earned badges appear in a single row, with **+N more** when more have been earned than fit. Hover over the row on desktop or tap it on mobile to toggle a popover with every badge, including locked badges and their progress. Profiles with no earned badges show **View achievements** instead. Select a badge in the popover, or the profile's rank or unlocked count, to open the detailed achievements dialog.
Profiles have **15 achievement badges**. Earned badges appear in a single row, with **+N more** when more have been earned than fit. Hover over the row on desktop or tap it on mobile to toggle a popover with every badge, including locked badges and their progress. Profiles with no earned badges show **View achievements** instead. Select a badge in the popover, or the profile's rank or unlocked count, to open the detailed achievements dialog.

| Badge | When it unlocks |
| --- | --- |
Expand All @@ -133,6 +133,7 @@ Profiles have **14 achievement badges**. Earned badges appear in a single row, w
| **sweet tooth** | Receive **100 pastéis across your account**, not on a single commit. Pastéis mode does not need to be enabled, and the total survives deletion of the awarded commits. |
| **luddite** | Stop following Tom while he still follows you. This deducts **100 XP**; following him again removes the badge and penalty. Accounts Tom has never befriended do not qualify. |
| **shell shocked** | “Survived the php2shell downtime of Oct '26. Earns 100 XP.” — accounts created before **October 21, 2026 at 14:00 UTC** receive this badge and **100 bonus XP**. Accounts created at or after that moment do not qualify. |
| **super-saiyan** | Have **9,000 followers or more** on /dev/null. “Y—your follower count. It... it’s over 9000!!!1!” |

Commit counts include replies and quotes. Commit-based badges and XP use non-deleted commits visible to the person viewing the profile, so an author may see progress from pending or rejected commits that other viewers cannot see. Streak badges use the **longest** streak, not the current streak: missing a day does not by itself remove them. Days follow the activity graph's app-timezone grouping, not the user's selected display timezone.

Expand Down
5 changes: 5 additions & 0 deletions app/Enums/Achievement.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ enum Achievement: string
case SweetTooth = 'sweet_tooth';
case Luddite = 'luddite';
case ShellShocked = 'shell_shocked';
case SuperSaiyan = 'super_saiyan';

public function label(): string
{
Expand All @@ -38,6 +39,7 @@ public function label(): string
self::SweetTooth => 'sweet tooth',
self::Luddite => 'luddite',
self::ShellShocked => 'shell shocked',
self::SuperSaiyan => 'super-saiyan',
};
}

Expand All @@ -58,6 +60,7 @@ public function description(): string
self::SweetTooth => 'Received 100 pastéis across your account.',
self::Luddite => 'Unfollowed Tom. Costs 100 XP until you follow him again.',
self::ShellShocked => "Survived the php2shell downtime of Oct '26. Earns 100 XP.",
self::SuperSaiyan => 'Y—your follower count. It... it’s over 9000!!!1!',
};
}

Expand All @@ -78,6 +81,7 @@ public function icon(): string
self::SweetTooth => 'cookie',
self::Luddite => 'bot-off',
self::ShellShocked => 'bomb',
self::SuperSaiyan => 'dragon-ball',
};
}

Expand All @@ -98,6 +102,7 @@ public function target(): int
self::SweetTooth => 100,
self::Luddite => 1,
self::ShellShocked => 1,
self::SuperSaiyan => 9000,
};
}
}
4 changes: 4 additions & 0 deletions app/Queries/UserAchievementsQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array
'progress' => $isShellShocked ? 1 : 0,
'target' => Achievement::ShellShocked->target(),
],
Achievement::SuperSaiyan->value => [
'progress' => min($user->followers()->count(), Achievement::SuperSaiyan->target()),
'target' => Achievement::SuperSaiyan->target(),
],
];

$badges = array_map(function (Achievement $achievement) use ($metrics): array {
Expand Down
2 changes: 2 additions & 0 deletions resources/js/components/achievements-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from 'lucide-react';
import type { CSSProperties } from 'react';
import { useState } from 'react';
import { DragonBallIcon } from '@/components/dragon-ball-icon';
import {
Dialog,
DialogContent,
Expand Down Expand Up @@ -44,6 +45,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
cookie: Cookie,
'bot-off': BotOff,
bomb: Bomb,
'dragon-ball': DragonBallIcon,
};

export function AchievementsDialog({
Expand Down
2 changes: 2 additions & 0 deletions resources/js/components/achievements-shelf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from 'lucide-react';
import type { CSSProperties } from 'react';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { DragonBallIcon } from '@/components/dragon-ball-icon';
import {
Popover,
PopoverContent,
Expand Down Expand Up @@ -49,6 +50,7 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
cookie: Cookie,
'bot-off': BotOff,
bomb: Bomb,
'dragon-ball': DragonBallIcon,
};

export function AchievementsShelf({
Expand Down
28 changes: 28 additions & 0 deletions resources/js/components/dragon-ball-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { SVGAttributes } from 'react';

export function DragonBallIcon(props: SVGAttributes<SVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
{...props}
>
<circle cx="12" cy="12" r="10" />
<path d="M5.2 8.5a8 8 0 0 1 3.3-3.3" strokeWidth="1.2" />
<path d="M4.5 11v.5" strokeWidth="1.2" />
<path
d="m12 9 1 2.1 2.3.3-1.65 1.6.4 2.3L12 14.2l-2.05 1.1.4-2.3-1.65-1.6 2.3-.3z"
fill="currentColor"
stroke="none"
/>
</svg>
);
}
25 changes: 24 additions & 1 deletion tests/Unit/Queries/UserAchievementsQueryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
->and($result['ranks'])->toHaveCount(6)
->and($result['xp_breakdown']['commits']['xp'])->toBe(0)
->and($result['xp_breakdown']['shell_shocked']['xp'])->toBe(0)
->and($result['badges'])->toHaveCount(14);
->and($result['badges'])->toHaveCount(15);

foreach ($result['badges'] as $badge) {
expect($badge['unlocked'])->toBeFalse()
Expand Down Expand Up @@ -275,6 +275,29 @@
'above threshold' => [125, 100, true],
]);

it('unlocks super-saiyan at 9000 current followers without adding xp', function (): void {
$user = User::factory()->create();
$followers = User::factory()->count(9001)->create();
$user->following()->attach($followers);
$query = resolve(UserAchievementsQuery::class);

foreach ([[0, 0, false], [8999, 8999, false], [9000, 9000, true], [9001, 9000, true], [8999, 8999, false]] as [$count, $progress, $unlocked]) {
$user->followers()->sync($followers->take($count)->modelKeys());

$result = $query->get($user, null);

expect(collect($result['badges'])->firstWhere('id', 'super_saiyan'))->toBe([
'id' => 'super_saiyan',
'name' => 'super-saiyan',
'description' => 'Y—your follower count. It... it’s over 9000!!!1!',
'icon' => 'dragon-ball',
'unlocked' => $unlocked,
'progress' => $progress,
'target' => 9000,
])->and($result['xp'])->toBe(0);
}
});

it('does not grant luddite to Tom or someone he has never befriended', function (): void {
$tom = User::factory()->create(['github_id' => CreateTomFriendship::GITHUB_ID]);
$user = User::factory()->create();
Expand Down
Loading