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
7 changes: 7 additions & 0 deletions apps/web/src/app/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,18 @@ describe('App Chip setup wiring', () => {
expect(content).toContain('dynastyIndicator={{ seasonYear: chipDockSeason, coachName: chipCoachName }}');
});

<<<<<<< HEAD
it('mounts Sprint 46 atmosphere emitters beside the app shell controllers', () => {
expect(content).toContain("import { EraTransitionEmitter } from '../features/dynasty-era/EraTransitionEmitter'");
expect(content).toContain("import { ChampionshipParadeEmitter } from '../features/playoffs/ChampionshipParadeEmitter'");
expect(content).toContain('<AudioController />');
expect(content).toContain('<EraTransitionEmitter />');
expect(content).toContain('<ChampionshipParadeEmitter />');
=======
it('registers the Sprint 46 achievements gallery route additively', () => {
expect(content).toContain("const LazyAchievementsGallery = lazy(async () => ({ default: (await import('../features/franchise/AchievementsGallery')).AchievementsGallery }));");
expect(content).toContain("path: '/franchise/achievements'");
expect(content).toContain('routeTree.addChildren([achievementsRoute]);');
>>>>>>> 9ca31c9 (Sprint 46: Add achievements gallery)
});
});
13 changes: 13 additions & 0 deletions apps/web/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ const LazyHallOfFameDirectory = lazy(async () => ({ default: (await import('../f
const LazyTrophyRoom = lazy(async () => ({ default: (await import('../features/franchise/TrophyRoom')).TrophyRoom }));
const LazyEraHall = lazy(async () => ({ default: (await import('../features/franchise/EraHall')).EraHall }));
const LazyMvpPlaqueWall = lazy(async () => ({ default: (await import('../features/franchise/MvpPlaqueWall')).MvpPlaqueWall }));
const LazyAchievementsGallery = lazy(async () => ({ default: (await import('../features/franchise/AchievementsGallery')).AchievementsGallery }));
const LazyPlayoffLoreDirectory = lazy(async () => ({ default: (await import('../features/playoffs/PlayoffLoreDirectory')).PlayoffLoreDirectory }));
const LazyDynastyChronicle = lazy(async () => ({ default: (await import('../features/franchise/DynastyChronicle')).DynastyChronicle }));
const LazyLockerRoom = lazy(async () => ({ default: (await import('../features/locker-room/LockerRoom')).LockerRoom }));
Expand Down Expand Up @@ -1795,6 +1796,16 @@ const settingsRoute = createRoute({
component: SettingsScreen,
});

const achievementsRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/franchise/achievements',
component: () => (
<LazyRouteFrame label="achievements">
<LazyAchievementsGallery />
</LazyRouteFrame>
),
});

const routeTree = rootRoute.addChildren([
indexRoute,
rosterRoute, lockerRoomRoute, contractsRoute, capLabRoute, frontOfficeRoute, endorsementsRoute, tradesRoute, tradeBlockRoute, watchListRoute,
Expand All @@ -1809,6 +1820,8 @@ routeTree.addChildren([...(routeTree.children ?? []), trophyRoomRoute, eraHallRo

routeTree.addChildren([...(routeTree.children ?? []), mvpPlaqueWallRoute]);

routeTree.addChildren([...(routeTree.children ?? []), achievementsRoute]);

const hashHistory = createHashHistory();
const router = createRouter({ routeTree, history: hashHistory });

Expand Down
83 changes: 83 additions & 0 deletions apps/web/src/features/franchise/AchievementsGallery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { Achievement } from '@mfd/engine';
import { AchievementsGalleryView } from './AchievementsGallery';
import { AchievementMedalSvg } from './achievementMedalSvg';

function achievement(overrides: Partial<Achievement>): Achievement {
return {
id: 'dynasty:first_championship',
title: 'First Championship',
description: 'Win your first title.',
category: 'dynasty',
tier: 'gold',
condition: { type: 'championships', threshold: 1 },
unlockedYear: 2030,
unlockedWeek: 22,
icon: 'trophy',
...overrides,
};
}

const ACHIEVEMENTS: Achievement[] = [
achievement({ id: 'dynasty:first_championship', title: 'First Championship', tier: 'gold', unlockedYear: 2030 }),
achievement({ id: 'roster:homegrown', title: 'Homegrown', category: 'roster', tier: 'silver', unlockedYear: null, unlockedWeek: null }),
achievement({ id: 'draft:scout_elite', title: 'Scout Elite', category: 'draft', tier: 'bronze', unlockedYear: 2029 }),
];

describe('AchievementsGallery', () => {
it('renders the Achievements header', () => {
const markup = renderToStaticMarkup(<AchievementsGalleryView achievements={ACHIEVEMENTS} />);

expect(markup).toContain('ACHIEVEMENTS');
expect(markup).toContain('Every milestone earned, every banner raised.');
});

it('renders one card per achievement in props', () => {
const markup = renderToStaticMarkup(<AchievementsGalleryView achievements={ACHIEVEMENTS} />);

expect(markup.match(/data-achievement-card=/g)).toHaveLength(3);
});

it('filters Unlocked achievements only', () => {
const markup = renderToStaticMarkup(<AchievementsGalleryView achievements={ACHIEVEMENTS} initialFilter="unlocked" />);

expect(markup).toContain('First Championship');
expect(markup).toContain('Scout Elite');
expect(markup).not.toContain('Homegrown');
});

it('filters Locked achievements only', () => {
const markup = renderToStaticMarkup(<AchievementsGalleryView achievements={ACHIEVEMENTS} initialFilter="locked" />);

expect(markup).toContain('Homegrown');
expect(markup).not.toContain('First Championship');
});

it('renders the gold medal SVG for tier gold', () => {
const markup = renderToStaticMarkup(<AchievementMedalSvg tier="gold" locked={false} title="Gold medal" />);

expect(markup).toContain('data-medal-tier="gold"');
expect(markup).toContain('data-medal-laurel="true"');
});

it('renders a lock overlay for locked medals', () => {
const markup = renderToStaticMarkup(<AchievementMedalSvg tier="silver" locked title="Locked medal" />);

expect(markup).toContain('data-medal-locked="true"');
expect(markup).toContain('aria-label="Locked medal"');
});

it('shows the correct unlocked counter', () => {
const markup = renderToStaticMarkup(<AchievementsGalleryView achievements={ACHIEVEMENTS} />);

expect(markup).toContain('2 of 3 unlocked');
});

it('renders the empty state when zero achievements are unlocked', () => {
const locked = ACHIEVEMENTS.map((entry) => ({ ...entry, unlockedYear: null, unlockedWeek: null }));
const markup = renderToStaticMarkup(<AchievementsGalleryView achievements={locked} />);

expect(markup).toContain('No achievements yet. Build something legendary.');
});
});
198 changes: 198 additions & 0 deletions apps/web/src/features/franchise/AchievementsGallery.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { useMemo, useState } from 'react';
import type { Achievement, AchievementCategory } from '@mfd/engine';
import { PixelBadge, PixelButton, PixelPanel } from '@mfd/design-system/components';
import { selectAchievements, useGameStore } from '../../app/store/game-store';
import {
PixelScreenHeader,
autoGrid,
monoSm,
screenStackStyle,
} from '../shared/pixelUi';
import { AchievementMedalSvg } from './achievementMedalSvg';

type GalleryFilter = 'all' | 'unlocked' | 'locked' | AchievementCategory;
type GallerySort = 'recent' | 'tier' | 'category';

const FILTERS: Array<{ id: GalleryFilter; label: string }> = [
{ id: 'all', label: 'All' },
{ id: 'unlocked', label: 'Unlocked' },
{ id: 'locked', label: 'Locked' },
{ id: 'dynasty', label: 'Dynasty' },
{ id: 'roster', label: 'Roster' },
{ id: 'draft', label: 'Draft' },
{ id: 'financial', label: 'Financial' },
{ id: 'coaching', label: 'Coaching' },
{ id: 'narrative', label: 'Narrative' },
{ id: 'records', label: 'Records' },
{ id: 'milestones', label: 'Milestones' },
{ id: 'hidden', label: 'Hidden' },
];

const SORTS: Array<{ id: GallerySort; label: string }> = [
{ id: 'recent', label: 'Recently Unlocked' },
{ id: 'tier', label: 'Tier Gold to Bronze' },
{ id: 'category', label: 'Category' },
];

const TIER_ORDER: Record<Achievement['tier'], number> = {
platinum: 0,
gold: 1,
silver: 2,
bronze: 3,
};

function isUnlocked(achievement: Achievement): boolean {
return achievement.unlockedYear !== null;
}

function filterAchievements(achievements: Achievement[], filter: GalleryFilter): Achievement[] {
if (filter === 'all') return achievements;
if (filter === 'unlocked') return achievements.filter(isUnlocked);
if (filter === 'locked') return achievements.filter((achievement) => !isUnlocked(achievement));
return achievements.filter((achievement) => achievement.category === filter);
}

function sortAchievements(achievements: Achievement[], sort: GallerySort): Achievement[] {
const copy = [...achievements];
if (sort === 'tier') {
return copy.sort((left, right) => TIER_ORDER[left.tier] - TIER_ORDER[right.tier] || left.title.localeCompare(right.title));
}
if (sort === 'category') {
return copy.sort((left, right) => left.category.localeCompare(right.category) || left.title.localeCompare(right.title));
}
return copy.sort((left, right) => {
const rightYear = right.unlockedYear ?? Number.MIN_SAFE_INTEGER;
const leftYear = left.unlockedYear ?? Number.MIN_SAFE_INTEGER;
if (rightYear !== leftYear) return rightYear - leftYear;
const rightWeek = right.unlockedWeek ?? Number.MIN_SAFE_INTEGER;
const leftWeek = left.unlockedWeek ?? Number.MIN_SAFE_INTEGER;
if (rightWeek !== leftWeek) return rightWeek - leftWeek;
return left.title.localeCompare(right.title);
});
}

function criteriaText(achievement: Achievement): string {
const type = achievement.condition.type.replaceAll('_', ' ');
return `Criteria: ${type} ${achievement.condition.threshold}`;
}

function tierVariant(tier: Achievement['tier']): 'gold' | 'cyan' | 'green' | 'default' {
if (tier === 'platinum' || tier === 'gold') return 'gold';
if (tier === 'silver') return 'cyan';
if (tier === 'bronze') return 'green';
return 'default';
}

function categoryLabel(category: AchievementCategory): string {
return category.replaceAll('_', ' ');
}

export function AchievementsGalleryView({
achievements,
initialFilter = 'all',
initialSort = 'recent',
}: {
achievements: Achievement[];
initialFilter?: GalleryFilter;
initialSort?: GallerySort;
}) {
const [filter, setFilter] = useState<GalleryFilter>(initialFilter);
const [sort, setSort] = useState<GallerySort>(initialSort);
const unlockedCount = achievements.filter(isUnlocked).length;
const visibleAchievements = useMemo(
() => sortAchievements(filterAchievements(achievements, filter), sort),
[achievements, filter, sort],
);

return (
<div style={screenStackStyle}>
<PixelScreenHeader
title="Achievements"
subtitle="Every milestone earned, every banner raised."
badges={(
<>
<PixelBadge variant="gold">{`${unlockedCount} of ${achievements.length} unlocked`}</PixelBadge>
<PixelBadge variant="cyan">Medal Gallery</PixelBadge>
</>
)}
/>

{unlockedCount === 0 ? (
<PixelPanel title="No medals raised" accent="default">
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)', lineHeight: 1.6 }}>
No achievements yet. Build something legendary.
</div>
</PixelPanel>
) : null}

<PixelPanel title="Filters" accent="cyan">
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{FILTERS.map((entry) => (
<PixelButton key={entry.id} accent={filter === entry.id ? 'gold' : 'default'} onClick={() => setFilter(entry.id)}>
{entry.label}
</PixelButton>
))}
</div>
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
{SORTS.map((entry) => (
<PixelButton key={entry.id} accent={sort === entry.id ? 'cyan' : 'default'} onClick={() => setSort(entry.id)}>
{entry.label}
</PixelButton>
))}
</div>
</div>
</PixelPanel>

<div style={autoGrid(320)}>
{visibleAchievements.map((achievement) => {
const unlocked = isUnlocked(achievement);
return (
<article
key={achievement.id}
data-achievement-card={achievement.id}
style={{
display: 'flex',
gap: '14px',
alignItems: 'flex-start',
minHeight: '188px',
padding: '14px',
border: `2px solid ${unlocked ? 'var(--mfd-gold)' : 'var(--mfd-border)'}`,
background: 'var(--mfd-bg-2)',
filter: unlocked ? 'none' : 'grayscale(0.55)',
}}
>
<AchievementMedalSvg tier={achievement.tier} locked={!unlocked} title={`${achievement.title} medal`} />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', minWidth: 0 }}>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<PixelBadge variant={tierVariant(achievement.tier)}>{achievement.tier}</PixelBadge>
<PixelBadge variant="default">{categoryLabel(achievement.category)}</PixelBadge>
{unlocked ? <PixelBadge variant="gold">Unlocked</PixelBadge> : <PixelBadge variant="default">Locked</PixelBadge>}
</div>
<div style={{ ...monoSm, color: 'var(--mfd-text)', fontWeight: 700, fontSize: '13px', lineHeight: 1.4 }}>
{achievement.title}
</div>
<div style={{ ...monoSm, color: 'var(--mfd-text-dim)', lineHeight: 1.6 }}>
{achievement.description}
</div>
<div style={{ ...monoSm, color: unlocked ? 'var(--mfd-cyan)' : 'var(--mfd-text-faint)', lineHeight: 1.5 }}>
{criteriaText(achievement)}
</div>
{unlocked ? (
<div style={{ ...monoSm, color: 'var(--mfd-gold)' }}>
Year {achievement.unlockedYear}{achievement.unlockedWeek !== null ? ` // Week ${achievement.unlockedWeek}` : ''}
</div>
) : null}
</div>
</article>
);
})}
</div>
</div>
);
}

export function AchievementsGallery() {
const achievements = useGameStore(selectAchievements);
return <AchievementsGalleryView achievements={achievements} />;
}
Loading
Loading