From ebe598524402712618f40604cd2e5ba13376f505 Mon Sep 17 00:00:00 2001 From: Pauline Vos Date: Wed, 4 Nov 2026 12:45:19 +0000 Subject: [PATCH] feat: add whoopsie achievement badge --- KNOWLEDGE_BASE.md | 3 ++- app/Enums/Achievement.php | 5 ++++ app/Queries/UserAchievementsQuery.php | 4 +++ .../js/components/achievements-dialog.tsx | 2 ++ .../js/components/achievements-shelf.tsx | 2 ++ .../js/components/mushroom-cloud-icon.tsx | 24 +++++++++++++++++ .../Queries/UserAchievementsQueryTest.php | 26 ++++++++++++++++++- 7 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 resources/js/components/mushroom-cloud-icon.tsx diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 90e4128ee4..dc159419a8 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -152,7 +152,7 @@ Sources: [go-live button](resources/js/components/profile-live-button.tsx), [liv ### Achievement badges and XP -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. +Profiles have **16 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 | | --- | --- | @@ -171,6 +171,7 @@ Profiles have **15 achievement badges**. Earned badges appear in a single row, w | **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!” | +| **whoopsie** | “Nuked production at least once.” — unlocked for **@paulinevos**, **@nunomaduro**, **@brendt**, and **@mnapoli**, regardless of handle capitalization. No bonus XP. | 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. diff --git a/app/Enums/Achievement.php b/app/Enums/Achievement.php index 4926f96bce..e089fadc75 100644 --- a/app/Enums/Achievement.php +++ b/app/Enums/Achievement.php @@ -21,6 +21,7 @@ enum Achievement: string case Luddite = 'luddite'; case ShellShocked = 'shell_shocked'; case SuperSaiyan = 'super_saiyan'; + case Whoopsie = 'whoopsie'; public function label(): string { @@ -40,6 +41,7 @@ public function label(): string self::Luddite => 'luddite', self::ShellShocked => 'shell shocked', self::SuperSaiyan => 'super-saiyan', + self::Whoopsie => 'whoopsie', }; } @@ -61,6 +63,7 @@ public function description(): string 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!', + self::Whoopsie => 'Nuked production at least once.', }; } @@ -82,6 +85,7 @@ public function icon(): string self::Luddite => 'bot-off', self::ShellShocked => 'bomb', self::SuperSaiyan => 'dragon-ball', + self::Whoopsie => 'mushroom-cloud', }; } @@ -103,6 +107,7 @@ public function target(): int self::Luddite => 1, self::ShellShocked => 1, self::SuperSaiyan => 9000, + self::Whoopsie => 1, }; } } diff --git a/app/Queries/UserAchievementsQuery.php b/app/Queries/UserAchievementsQuery.php index f5b41c0ded..6c0174ef95 100644 --- a/app/Queries/UserAchievementsQuery.php +++ b/app/Queries/UserAchievementsQuery.php @@ -188,6 +188,10 @@ public function get(User $user, ?User $viewer, ?array $streaks = null): array 'progress' => min($user->followers()->count(), Achievement::SuperSaiyan->target()), 'target' => Achievement::SuperSaiyan->target(), ], + Achievement::Whoopsie->value => [ + 'progress' => in_array(mb_strtolower($user->handle), ['paulinevos', 'nunomaduro', 'brendt', 'mnapoli'], true) ? 1 : 0, + 'target' => Achievement::Whoopsie->target(), + ], ]; $badges = array_map(function (Achievement $achievement) use ($metrics): array { diff --git a/resources/js/components/achievements-dialog.tsx b/resources/js/components/achievements-dialog.tsx index 6d83ded28c..7a67e7f223 100644 --- a/resources/js/components/achievements-dialog.tsx +++ b/resources/js/components/achievements-dialog.tsx @@ -16,6 +16,7 @@ import { import type { CSSProperties } from 'react'; import { useState } from 'react'; import { DragonBallIcon } from '@/components/dragon-ball-icon'; +import { MushroomCloudIcon } from '@/components/mushroom-cloud-icon'; import { PastelDeNataIcon } from '@/components/pastel-de-nata-icon'; import { Dialog, @@ -46,6 +47,7 @@ const iconMap: Record> = { 'bot-off': BotOff, bomb: Bomb, 'dragon-ball': DragonBallIcon, + 'mushroom-cloud': MushroomCloudIcon, }; export function AchievementsDialog({ diff --git a/resources/js/components/achievements-shelf.tsx b/resources/js/components/achievements-shelf.tsx index ac16e4a1c6..62839a8712 100644 --- a/resources/js/components/achievements-shelf.tsx +++ b/resources/js/components/achievements-shelf.tsx @@ -18,6 +18,7 @@ import { import type { CSSProperties } from 'react'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { DragonBallIcon } from '@/components/dragon-ball-icon'; +import { MushroomCloudIcon } from '@/components/mushroom-cloud-icon'; import { PastelDeNataIcon } from '@/components/pastel-de-nata-icon'; import { Popover, @@ -51,6 +52,7 @@ const iconMap: Record> = { 'bot-off': BotOff, bomb: Bomb, 'dragon-ball': DragonBallIcon, + 'mushroom-cloud': MushroomCloudIcon, }; export function AchievementsShelf({ diff --git a/resources/js/components/mushroom-cloud-icon.tsx b/resources/js/components/mushroom-cloud-icon.tsx new file mode 100644 index 0000000000..ec040f5734 --- /dev/null +++ b/resources/js/components/mushroom-cloud-icon.tsx @@ -0,0 +1,24 @@ +import type { SVGAttributes } from 'react'; + +export function MushroomCloudIcon(props: SVGAttributes) { + return ( + + + + + + ); +} diff --git a/tests/Unit/Queries/UserAchievementsQueryTest.php b/tests/Unit/Queries/UserAchievementsQueryTest.php index 5200495fdd..2db2618247 100644 --- a/tests/Unit/Queries/UserAchievementsQueryTest.php +++ b/tests/Unit/Queries/UserAchievementsQueryTest.php @@ -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(15); + ->and($result['badges'])->toHaveCount(16); foreach ($result['badges'] as $badge) { expect($badge['unlocked'])->toBeFalse() @@ -320,6 +320,30 @@ expect(collect($result['badges'])->firstWhere('id', 'luddite')['unlocked'])->toBeFalse(); }); +it('unlocks whoopsie only for the listed handles without adding xp', function (string $handle, bool $unlocked, int $progress): void { + $user = User::factory()->create(['handle' => $handle]); + + $result = resolve(UserAchievementsQuery::class)->get($user, null); + + expect(collect($result['badges'])->firstWhere('id', 'whoopsie'))->toBe([ + 'id' => 'whoopsie', + 'name' => 'whoopsie', + 'description' => 'Nuked production at least once.', + 'icon' => 'mushroom-cloud', + 'unlocked' => $unlocked, + 'progress' => $progress, + 'target' => 1, + ])->and($result['xp'])->toBe(0); +})->with([ + 'paulinevos' => ['paulinevos', true, 1], + 'nunomaduro' => ['nunomaduro', true, 1], + 'brendt' => ['brendt', true, 1], + 'mnapoli' => ['mnapoli', true, 1], + 'mixed case' => ['PaulineVos', true, 1], + 'unlisted' => ['local-dev', false, 0], + 'partial match' => ['notpaulinevos', false, 0], +]); + it('adds the shell shocked reward to activity xp and includes the luddite penalty', function (bool $unfollowedTom, int $xp): void { $user = User::factory()->create(['created_at' => '2026-10-21 13:59:59']); Commit::factory()->for($user)->create();