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
9 changes: 9 additions & 0 deletions character/models/character.py
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,15 @@ def total_link_points(self):
"""
return PlayerCharacterLink.total_link_points(self.links.all())

def get_productivity(self, now=None):
"""
Live productivity signal - see progression.ap.get_productivity for
what drives it (authored baseline x current active XpModifiers).
"""
from progression import ap

return ap.get_productivity(self, now=now)


########################################################################
#### PLAYER CHARACTER LINK MODEL
Expand Down
19 changes: 19 additions & 0 deletions frontend/.storybook/decorators/withAuthContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Decorator } from '@storybook/react-vite';
import { AuthContext, type AuthContextValue } from '../../src/context/authContext';
import { mockAuthContextValue } from '../../src/testUtils/mockAuthContext';

/**
* Wraps a story in `AuthContext` with a mock value, for components that read
* `useAuth()` outside of a real session. `authenticated` controls whether the
* mock represents a logged-in or logged-out user; `overrides` reaches any
* other field (e.g. `user: { is_staff: true }`).
*/
export function withAuthContext(
overrides: Partial<AuthContextValue> & { authenticated?: boolean } = {}
): Decorator {
return (Story) => (
<AuthContext.Provider value={mockAuthContextValue(overrides)}>
<Story />
</AuthContext.Provider>
);
}
14 changes: 14 additions & 0 deletions frontend/.storybook/decorators/withGameContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Decorator } from '@storybook/react-vite';
import { GameContext } from '../../src/context/gameContext';
import { mockGameContextValue } from '../../src/testUtils/mockGameContext';

/**
* Wraps a story in `GameContext` with `mockGameContextValue`, for components
* that read `useGame()` (directly, or transitively via `useFeatureFlag`)
* outside of a real game session.
*/
export const withGameContext: Decorator = (Story) => (
<GameContext.Provider value={mockGameContextValue}>
<Story />
</GameContext.Provider>
);
27 changes: 27 additions & 0 deletions frontend/.storybook/decorators/withQueryClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { Decorator } from '@storybook/react-vite';

/**
* Wraps a story in a fresh `QueryClient` per render, so components that call
* TanStack Query hooks (`useQuery`/`useQueryClient`) don't crash outside a
* provider. Retries are disabled - Storybook has no real API to fetch from,
* so a failed request should render its empty/error state immediately
* rather than retrying for several seconds.
*
* Seed data for a specific query (so a component renders populated instead
* of loading/empty) via a story's own decorator + `queryClient.setQueryData`,
* see `EntitySearchInput.stories.tsx` / `TutorialModal.stories.tsx`.
*/
export const withQueryClient: Decorator = (Story) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false, staleTime: Infinity },
},
});

return (
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
);
};
6 changes: 6 additions & 0 deletions frontend/.storybook/preview.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import type { Preview } from '@storybook/react-vite';
import '../src/styles/main.scss';
import { withQueryClient } from './decorators/withQueryClient';

const preview: Preview = {
// Global so any component that calls a TanStack Query hook - directly or
// transitively (e.g. `useFeatureFlag` -> `useAppConfig`) - doesn't crash
// for lack of a QueryClientProvider ancestor. See withQueryClient's
// comment for how a story seeds its own query data.
decorators: [withQueryClient],
parameters: {
controls: {
matchers: {
Expand Down
93 changes: 93 additions & 0 deletions frontend/src/components/Achievements/Achievements.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import Achievements from './Achievements';

/**
* `Achievements` renders a grid of achievement badges from a plain
* `achievements[]` prop - tier colour, progress bar, and the "time" vs.
* count value formatting are all derived from each achievement's fields.
*/
const meta: Meta<typeof Achievements> = {
title: 'Shared/Achievements',
component: Achievements,
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof Achievements>;

export const Default: Story = {
args: {
achievements: [
{
type: 'tasks_completed',
label: 'Task Master',
symbol: '✅',
tier: 1,
complete: false,
color: 'grey',
value: 12,
threshold: 25,
},
{
type: 'tasks_completed',
label: 'Task Master',
symbol: '✅',
tier: 2,
complete: true,
color: 'green',
value: 50,
threshold: 50,
},
{
type: 'time',
label: 'Time Invested',
symbol: '⏱️',
tier: 3,
complete: false,
color: 'blue',
value: 5400,
threshold: 36000,
},
{
type: 'streak',
label: 'Consistency',
symbol: '🔥',
tier: 4,
complete: false,
color: 'purple',
value: 8,
threshold: 30,
},
{
type: 'level',
label: 'Levelled Up',
symbol: '⭐',
tier: 5,
complete: true,
color: 'gold',
value: 20,
threshold: 20,
},
],
},
};

/** Every tier colour Achievements knows about (`grey`/`green`/`blue`/`purple`/`gold`), all mid-progress. */
export const AllTiers: Story = {
args: {
achievements: (['grey', 'green', 'blue', 'purple', 'gold'] as const).map((color, i) => ({
type: 'tasks_completed',
label: `Tier ${i + 1}`,
symbol: '🏅',
tier: i + 1,
complete: false,
color,
value: (i + 1) * 5,
threshold: 50,
})),
},
};

export const Empty: Story = {
args: { achievements: [] },
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import BackToTopButton from './BackToTopButton';

/**
* `BackToTopButton` is a fixed-position button that smooth-scrolls the page
* to the top (instantly, if the user prefers reduced motion). No props -
* visibility/positioning is left to the page that mounts it.
*/
const meta: Meta<typeof BackToTopButton> = {
title: 'Shared/BackToTopButton',
component: BackToTopButton,
tags: ['autodocs'],
};

export default meta;
type Story = StoryObj<typeof BackToTopButton>;

export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByRole('button', { name: 'Back to top' })).toBeVisible();
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import EntitySearchInput from './EntitySearchInput';
import { GameContext } from '../../context/gameContext';
import { mockGameContextValue } from '../../testUtils/mockGameContext';

/**
* `EntitySearchInput` is a fuzzy-search combobox over the player's past
* activities/tasks (`useEntitySearchCache`, a TanStack Query hook that in
* turn calls `useFeatureFlag` -> `useGame()`). The story seeds the cache's
* query directly and supplies a mock `GameContext` so it renders without a
* real API or game session - see `.storybook/decorators/`.
*/
const entities = [
{ id: 'a1', name: 'Wash dishes', taskId: null, completedAt: null, source: 'activity', isOptimistic: false, frequency: 4 },
{ id: 'a2', name: 'Write report', taskId: null, completedAt: null, source: 'activity', isOptimistic: false, frequency: 2 },
{ id: 't1', name: 'Write tests', taskId: 12, completedAt: null, source: 'task', isOptimistic: false, frequency: 0 },
];

function withSeededCache(Story: () => React.ReactElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
queryClient.setQueryData(['entity-search', 'activity'], entities);
queryClient.setQueryData(['appConfig'], { feature_flags: {} });

return (
<QueryClientProvider client={queryClient}>
<GameContext.Provider value={mockGameContextValue}>
<Story />
</GameContext.Provider>
</QueryClientProvider>
);
}

const meta: Meta<typeof EntitySearchInput> = {
title: 'Shared/EntitySearchInput',
component: EntitySearchInput,
tags: ['autodocs'],
decorators: [withSeededCache],
args: {
type: 'activity',
ariaLabel: 'Activity name',
placeholder: 'What are you working on?',
},
};

export default meta;
type Story = StoryObj<typeof EntitySearchInput>;

function ControlledInput(props: Partial<React.ComponentProps<typeof EntitySearchInput>>) {
const [value, setValue] = useState(props.value ?? '');
return <EntitySearchInput {...(props as React.ComponentProps<typeof EntitySearchInput>)} value={value} onChange={setValue} />;
}

export const Default: Story = {
render: (args) => <ControlledInput {...args} />,
};

/** Typing surfaces matching activities/tasks in a dropdown, grouped by source when both are present. */
export const WithSuggestions: Story = {
render: (args) => <ControlledInput {...args} />,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.type(canvas.getByRole('combobox'), 'write');

await waitFor(async () => {
await expect(canvas.getByRole('listbox')).toBeVisible();
});
await expect(canvas.getByRole('option', { name: 'Write tests' })).toBeVisible();
await expect(canvas.getByRole('option', { name: 'Write report' })).toBeVisible();
},
};

/** `alwaysOpen` keeps the dropdown mounted regardless of focus (persistent list mode), falling back to `emptyMessage` when there are no rows. */
export const AlwaysOpenEmpty: Story = {
args: {
alwaysOpen: true,
emptyMessage: 'No recent activities yet.',
defaultResults: [],
},
render: (args) => <ControlledInput {...args} />,
};

export const Disabled: Story = {
args: { disabled: true, value: 'Timer running...' },
};
42 changes: 42 additions & 0 deletions frontend/src/components/List/Li.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import Li from './Li';

/**
* `Li` is the single-row primitive `List` maps over. Storied directly for
* its own states (`isSelected`, `isHidden`, `tone`) - `List`'s stories cover
* it in context, as a full `<ul>`.
*/
const meta: Meta<typeof Li> = {
title: 'Shared/List/Li',
component: Li,
tags: ['autodocs'],
render: (args) => (
<ul>
<Li {...args} />
</ul>
),
args: {
children: 'Row content',
},
};

export default meta;
type Story = StoryObj<typeof Li>;

export const Default: Story = {};

export const Selected: Story = {
args: { isSelected: true },
};

export const Hidden: Story = {
args: { isHidden: true },
};

export const PlayerTone: Story = {
args: { tone: 'player', children: 'You finished Write docs' },
};

export const CharacterTone: Story = {
args: { tone: 'character', children: 'Rosie finished Deliver goods' },
};
Loading
Loading