diff --git a/character/models/character.py b/character/models/character.py index c5ef0ad1..053c8573 100644 --- a/character/models/character.py +++ b/character/models/character.py @@ -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 diff --git a/frontend/.storybook/decorators/withAuthContext.tsx b/frontend/.storybook/decorators/withAuthContext.tsx new file mode 100644 index 00000000..9420aa0b --- /dev/null +++ b/frontend/.storybook/decorators/withAuthContext.tsx @@ -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 & { authenticated?: boolean } = {} +): Decorator { + return (Story) => ( + + + + ); +} diff --git a/frontend/.storybook/decorators/withGameContext.tsx b/frontend/.storybook/decorators/withGameContext.tsx new file mode 100644 index 00000000..97591358 --- /dev/null +++ b/frontend/.storybook/decorators/withGameContext.tsx @@ -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) => ( + + + +); diff --git a/frontend/.storybook/decorators/withQueryClient.tsx b/frontend/.storybook/decorators/withQueryClient.tsx new file mode 100644 index 00000000..d18fa097 --- /dev/null +++ b/frontend/.storybook/decorators/withQueryClient.tsx @@ -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 ( + + + + ); +}; diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index 797871d2..ab69c54a 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -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: { diff --git a/frontend/src/components/Achievements/Achievements.stories.tsx b/frontend/src/components/Achievements/Achievements.stories.tsx new file mode 100644 index 00000000..f748ff36 --- /dev/null +++ b/frontend/src/components/Achievements/Achievements.stories.tsx @@ -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 = { + title: 'Shared/Achievements', + component: Achievements, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +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: [] }, +}; diff --git a/frontend/src/components/BackToTopButton/BackToTopButton.stories.tsx b/frontend/src/components/BackToTopButton/BackToTopButton.stories.tsx new file mode 100644 index 00000000..f6e457c2 --- /dev/null +++ b/frontend/src/components/BackToTopButton/BackToTopButton.stories.tsx @@ -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 = { + title: 'Shared/BackToTopButton', + component: BackToTopButton, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByRole('button', { name: 'Back to top' })).toBeVisible(); + }, +}; diff --git a/frontend/src/components/EntitySearchInput/EntitySearchInput.stories.tsx b/frontend/src/components/EntitySearchInput/EntitySearchInput.stories.tsx new file mode 100644 index 00000000..09782d7b --- /dev/null +++ b/frontend/src/components/EntitySearchInput/EntitySearchInput.stories.tsx @@ -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 ( + + + + + + ); +} + +const meta: Meta = { + 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; + +function ControlledInput(props: Partial>) { + const [value, setValue] = useState(props.value ?? ''); + return )} value={value} onChange={setValue} />; +} + +export const Default: Story = { + render: (args) => , +}; + +/** Typing surfaces matching activities/tasks in a dropdown, grouped by source when both are present. */ +export const WithSuggestions: Story = { + render: (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) => , +}; + +export const Disabled: Story = { + args: { disabled: true, value: 'Timer running...' }, +}; diff --git a/frontend/src/components/List/Li.stories.tsx b/frontend/src/components/List/Li.stories.tsx new file mode 100644 index 00000000..630d6733 --- /dev/null +++ b/frontend/src/components/List/Li.stories.tsx @@ -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 `
    `. + */ +const meta: Meta = { + title: 'Shared/List/Li', + component: Li, + tags: ['autodocs'], + render: (args) => ( +
      +
    • +
    + ), + args: { + children: 'Row content', + }, +}; + +export default meta; +type Story = StoryObj; + +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' }, +}; diff --git a/frontend/src/components/List/List.stories.tsx b/frontend/src/components/List/List.stories.tsx new file mode 100644 index 00000000..6ab23145 --- /dev/null +++ b/frontend/src/components/List/List.stories.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent } from 'storybook/test'; +import List from './List'; + +/** + * `List` renders a `
      ` of `Li` rows and underlies `PlayerItemList` (which + * adds sort/filter/edit-modal behaviour on top). On its own it handles + * selection, hover/compact styling, and per-item tone (e.g. distinguishing + * player vs. character rows in an activity feed). + */ +interface DemoItem { + id: string; + name: string; + player?: unknown; + character?: unknown; + isHidden?: boolean; +} + +const items: DemoItem[] = [ + { id: '1', name: 'Alice' }, + { id: '2', name: 'Bob' }, + { id: '3', name: 'Carol' }, +]; + +const meta: Meta> = { + title: 'Shared/List', + component: List, + tags: ['autodocs'], + args: { + items, + ariaLabel: 'Names', + }, +}; + +export default meta; +type Story = StoryObj>; + +export const Default: Story = {}; + +export const CanHover: Story = { + args: { canHover: true }, +}; + +export const Compact: Story = { + args: { compact: true }, +}; + +/** A hidden item (e.g. a deep-link placeholder) stays in the DOM but is visually suppressed via `isHidden`. */ +export const WithHiddenItem: Story = { + args: { + items: [...items, { id: '4', name: 'Dave', isHidden: true }], + }, +}; + +/** `itemTone`/`getItemTone` colour rows by origin - e.g. an activity feed mixing player and character entries. */ +export const MixedTone: Story = { + args: { + items: [ + { id: '1', name: 'You finished Write docs', player: {} }, + { id: '2', name: 'Rosie finished Deliver goods', character: {} }, + ], + }, +}; + +/** `canSelect` renders a `listbox`/`option` pair with keyboard (Enter/Space) activation instead of a plain list. */ +export const Selectable: Story = { + render: (args) => { + function Wrapper() { + const [selectedItem, setSelectedItem] = useState(null); + return ; + } + return ; + }, + play: async ({ canvas }) => { + const option = canvas.getByRole('option', { name: 'Bob' }); + await userEvent.click(option); + await expect(option).toHaveAttribute('aria-selected', 'true'); + }, +}; diff --git a/frontend/src/components/ModeSwitcher/ModeSwitcher.stories.tsx b/frontend/src/components/ModeSwitcher/ModeSwitcher.stories.tsx new file mode 100644 index 00000000..bcca2700 --- /dev/null +++ b/frontend/src/components/ModeSwitcher/ModeSwitcher.stories.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent } from 'storybook/test'; +import ModeSwitcher from './ModeSwitcher'; +import type { ModeOption } from './ModeSwitcher'; + +/** + * `ModeSwitcher` is a `radiogroup` of chips (e.g. Tasks/Activities panel + * mode). Fully generic over `modes`/`activeKey`/`onSelect`; arrow keys move + * (and select) between options, Home/End jump to the first/last. + */ +const modes: ModeOption[] = [ + { key: 'tasks', label: 'Tasks' }, + { key: 'activities', label: 'Activities' }, + { key: 'skills', label: 'Skills' }, +]; + +const meta: Meta = { + title: 'Shared/ModeSwitcher', + component: ModeSwitcher, + tags: ['autodocs'], + args: { + modes, + activeKey: 'tasks', + ariaLabel: 'View mode', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + function Wrapper() { + const [activeKey, setActiveKey] = useState(args.activeKey); + return ; + } + return ; + }, + play: async ({ canvas }) => { + const activities = canvas.getByRole('radio', { name: 'Activities' }); + await userEvent.click(activities); + await expect(activities).toHaveAttribute('aria-checked', 'true'); + }, +}; diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.stories.tsx b/frontend/src/components/PlayerItemList/PlayerItemList.stories.tsx new file mode 100644 index 00000000..a4edf20b --- /dev/null +++ b/frontend/src/components/PlayerItemList/PlayerItemList.stories.tsx @@ -0,0 +1,111 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { expect, userEvent, within } from 'storybook/test'; +import PlayerItemList from './PlayerItemList'; +import type { FilterOption, SortOption } from './PlayerItemList'; + +/** + * `PlayerItemList` is the generic, prop-driven list underlying + * `CategoriesPanel`, `ProjectsPanel`, `SkillsPanel`, `TasksPanel`, and + * `ActivitiesPanel` - it owns sorting, filtering, the edit/delete modal, + * hover-edit affordances, and nested children, while the panel supplies the + * item shape and callbacks. Storying it here documents most of what those + * five panels visually do. + */ +interface DemoItem { + id: number; + name: string; + detail: string; + complete?: boolean; +} + +const items: DemoItem[] = [ + { id: 1, name: 'Write docs', detail: 'Duration: 15m ยท 15 XP gained', complete: false }, + { id: 2, name: 'Fix login bug', detail: 'Duration: 42m ยท 40 XP gained', complete: true }, + { id: 3, name: 'Plan sprint', detail: 'Duration: 5m ยท 5 XP gained', complete: false }, +]; + +const sortOptions: SortOption[] = [ + { key: 'name', label: 'Name', compareFn: (a, b) => a.name.localeCompare(b.name) }, + { key: 'newest', label: 'Newest', compareFn: (a, b) => b.id - a.id }, +]; + +const filterOptions: FilterOption[] = [ + { key: 'all', label: 'All', predicate: () => true }, + { key: 'incomplete', label: 'Incomplete', predicate: (item) => !item.complete }, +]; + +const meta: Meta> = { + title: 'Shared/PlayerItemList', + component: PlayerItemList, + tags: ['autodocs'], + args: { + items, + itemLabel: 'activity', + ariaLabel: 'Activities', + renderItemMeta: (item: DemoItem) => item.detail, + onEdit: () => {}, + }, +}; + +export default meta; +type Story = StoryObj>; + +export const Default: Story = {}; + +/** `hoverEdit` swaps the row's click-to-open button for a persistent detail area plus a hover-revealed edit icon - used by panels where the row itself does something else on click. */ +export const HoverEdit: Story = { + args: { hoverEdit: true }, +}; + +/** `isItemComplete`/`onToggleComplete` add a per-row checkbox, e.g. for `TasksPanel`. */ +export const WithCompleteToggle: Story = { + args: { + isItemComplete: (item: DemoItem) => Boolean(item.complete), + onToggleComplete: () => {}, + }, +}; + +/** `sortOptions`/`filterOptions` render a controls bar above the list. */ +export const WithSortAndFilter: Story = { + args: { sortOptions, filterOptions }, + play: async ({ canvas }) => { + await expect(canvas.getByRole('group', { name: 'Filter activitys' })).toBeVisible(); + await expect(canvas.getByLabelText('Sort:')).toBeVisible(); + }, +}; + +/** `getChildren` nests an item's children directly under it (e.g. subtasks), independent of the active sort/filter. */ +export const WithNestedChildren: Story = { + args: { + items: [ + { id: 1, name: 'Ship v1', detail: '2 subtasks', complete: false }, + { id: 2, name: 'Write changelog', detail: 'Duration: 10m', complete: false }, + { id: 3, name: 'Cut release', detail: 'Duration: 5m', complete: true }, + ], + getChildren: (item: DemoItem) => + item.id === 1 + ? [ + { id: 2, name: 'Write changelog', detail: 'Duration: 10m', complete: false }, + { id: 3, name: 'Cut release', detail: 'Duration: 5m', complete: true }, + ] + : undefined, + }, +}; + +/** No items - the shared empty list state (an empty `
        `, no placeholder copy of its own). */ +export const Empty: Story = { + args: { items: [] }, +}; + +/** Clicking a row opens the edit modal; `onDelete` adds a Delete action with its own confirm step. */ +export const EditModal: Story = { + args: { onDelete: () => {} }, + play: async ({ canvasElement, canvas }) => { + await userEvent.click(canvas.getByRole('button', { name: 'Open activity Write docs' })); + + const body = within(canvasElement.ownerDocument.body); + const dialog = await body.findByRole('dialog', { name: 'Edit activity' }); + await expect(dialog).toBeVisible(); + await expect(body.getByRole('button', { name: 'Delete' })).toBeVisible(); + }, +}; diff --git a/frontend/src/components/PlayerItemList/PlayerItemList.tsx b/frontend/src/components/PlayerItemList/PlayerItemList.tsx index 9d0f2e9c..0617d707 100644 --- a/frontend/src/components/PlayerItemList/PlayerItemList.tsx +++ b/frontend/src/components/PlayerItemList/PlayerItemList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo } from "react"; +import React, { useCallback, useEffect, useMemo } from "react"; import classNames from "classnames"; import Button from "../Button/Button"; @@ -33,6 +33,8 @@ interface PlayerItemListProps getItemKey?: (item: T, index: number) => string | number; renderItemMeta?: (item: T) => React.ReactNode; renderEditSummary?: (item: T, saveHelpers: SaveStatusHelpers) => React.ReactNode; + /** Rendered next to the name input in the edit modal's title row (e.g. an icon button). */ + renderTitleRowActions?: (item: T) => React.ReactNode; onEdit?: (item: T, name: string, callbacks?: SaveCallbacks) => void; onDelete?: (item: T) => void; hoverEdit?: boolean; @@ -47,6 +49,10 @@ interface PlayerItemListProps /** Called once the requested `openItemId` has been opened, so the caller can clear it. */ onOpenItemHandled?: () => void; getChildren?: (item: T) => T[] | undefined; + /** Ids of items present in `items` (e.g. for the deep-link lookup) that should not be rendered as rows. */ + hiddenItemIds?: Set; + /** Called with the item whose edit modal just closed (via Close, backdrop, or Escape). */ + onModalClose?: (item: T) => void; } export default function PlayerItemList({ @@ -59,6 +65,7 @@ export default function PlayerItemList) { const { activeFilterKey, @@ -125,6 +134,13 @@ export default function PlayerItemList { + if (activeItem) onModalClose?.(activeItem); + handleModalClose(); + }, [activeItem, onModalClose, handleModalClose]); + const canToggleComplete = typeof onToggleComplete === "function"; const canEdit = typeof onEdit === "function"; const canDelete = typeof onDelete === "function"; @@ -140,16 +156,23 @@ export default function PlayerItemList { + if (!hiddenItemIds || hiddenItemIds.size === 0) return displayItems; + return displayItems.filter((item) => item.id === undefined || !hiddenItemIds.has(item.id)); + }, [displayItems, hiddenItemIds]); + // Sort/filter controls only apply to top-level items; a child keeps its // place directly after its parent (in `getChildren`'s order) rather than // being reordered independently. const flatDisplayItems = useMemo(() => { - if (!getChildren) return displayItems; - const topLevel = displayItems.filter( + if (!getChildren) return visibleDisplayItems; + const topLevel = visibleDisplayItems.filter( (item) => item.id === undefined || !childIds.has(item.id) ); return topLevel.flatMap((item) => [item, ...(getChildren(item) ?? [])]); - }, [displayItems, getChildren, childIds]); + }, [visibleDisplayItems, getChildren, childIds]); const renderRow = (item: T): React.ReactNode => ( <> @@ -283,7 +306,7 @@ export default function PlayerItemList setConfirmingDelete(false) : undefined} backLabel="Back" > @@ -328,17 +351,20 @@ export default function PlayerItemList { if (event.key === "Enter") handleEditSave(); - if (event.key === "Escape") handleModalClose(); + if (event.key === "Escape") closeModal(); }} /> ) : null} + {renderTitleRowActions && liveActiveItem + ? renderTitleRowActions(liveActiveItem) + : null} ) : null} {modalSummary ? (
        {modalSummary}
        ) : null}
        - {canDelete ? ( diff --git a/frontend/src/components/StaticBanner/StaticBanner.stories.tsx b/frontend/src/components/StaticBanner/StaticBanner.stories.tsx new file mode 100644 index 00000000..96e31d40 --- /dev/null +++ b/frontend/src/components/StaticBanner/StaticBanner.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import StaticBanner from './StaticBanner'; + +/** + * `StaticBanner` is a single-line site announcement. It renders nothing + * when `message` is empty/unset, so a running app with no announcement + * configured shows no banner at all. + */ +const meta: Meta = { + title: 'Shared/StaticBanner', + component: StaticBanner, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { message: "Scheduled maintenance tonight at 10pm UTC - the app may be briefly unavailable." }, +}; + +/** No `message` - renders nothing. */ +export const NoMessage: Story = { + args: {}, +}; diff --git a/frontend/src/components/TasksPanel/TasksPanel.module.scss b/frontend/src/components/TasksPanel/TasksPanel.module.scss index ca842fd1..b38d1f24 100644 --- a/frontend/src/components/TasksPanel/TasksPanel.module.scss +++ b/frontend/src/components/TasksPanel/TasksPanel.module.scss @@ -99,6 +99,27 @@ gap: sp.$spacing-sm; } +.timestampButton { + flex-shrink: 0; + height: sp.$form-control-height; + width: sp.$form-control-height; + padding: 0; + border: 1px solid rgba(c.$color-border-primary, 0.35); + border-radius: sp.$form-control-radius; + background: transparent; + color: inherit; + font-size: 1rem; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + + &:hover { + border-color: rgba(c.$color-border-primary, 0.55); + } +} + .timestampLabel { font-weight: 600; margin-bottom: 2px; diff --git a/frontend/src/components/TasksPanel/TasksPanel.test.tsx b/frontend/src/components/TasksPanel/TasksPanel.test.tsx index 6ddb5e8f..a503b5cf 100644 --- a/frontend/src/components/TasksPanel/TasksPanel.test.tsx +++ b/frontend/src/components/TasksPanel/TasksPanel.test.tsx @@ -359,7 +359,7 @@ describe("TasksPanel", () => { expect(screen.queryByText("Child subtask")).not.toBeInTheDocument(); }); - it("pre-fills the add-task form with a parent chip via the add-subtask row action", async () => { + it("opens the task detail modal for a blank draft subtask without creating one yet", async () => { const user = userEvent.setup({ pointerEventsCheck: 0 }); mockUseTasks.mockReturnValue({ isLoading: false, @@ -369,15 +369,65 @@ describe("TasksPanel", () => { await user.click(screen.getByRole("button", { name: "Add subtask to Parent project task" })); - expect(screen.getByText(/Subtask of Parent project task/)).toBeInTheDocument(); + const dialog = await screen.findByRole("dialog"); + expect(within(dialog).getByLabelText("task name")).toHaveValue(""); + expect(createMutate).not.toHaveBeenCalled(); + }); + + it("creates the subtask only once its draft name has actually been edited, then opens the persisted task", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + const newSubtask = { ...childTask, id: 7, name: "New task" }; + createMutate.mockImplementation((_data, callbacks) => { + callbacks?.onSuccess?.(newSubtask); + }); + mockUseTasks.mockReturnValue({ + isLoading: false, + data: [parentTask], + }); + const { rerender } = renderTasksPanel(); + + await user.click(screen.getByRole("button", { name: "Add subtask to Parent project task" })); + const dialog = await screen.findByRole("dialog"); + const input = within(dialog).getByLabelText("task name"); + await user.type(input, "New task"); + await user.tab(); + + expect(createMutate).toHaveBeenCalledWith( + { name: "New task", parent: 3 }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + ); + + // The new subtask isn't in `items` until the tasks query refetches with it included. + mockUseTasks.mockReturnValue({ + isLoading: false, + data: [parentTask, newSubtask], + }); + rerender( + + + , + ); - const input = screen.getByLabelText("new task"); - await user.type(input, "Buy groceries"); - await user.click(screen.getByRole("button", { name: "Add subtask" })); + const reopenedDialog = await screen.findByRole("dialog"); + expect(within(reopenedDialog).getByDisplayValue("New task")).toBeInTheDocument(); + }); + + it("discards the draft subtask, without creating anything, when its modal is closed unedited", async () => { + const user = userEvent.setup({ pointerEventsCheck: 0 }); + mockUseTasks.mockReturnValue({ + isLoading: false, + data: [parentTask], + }); + renderTasksPanel(); + + await user.click(screen.getByRole("button", { name: "Add subtask to Parent project task" })); + const dialog = await screen.findByRole("dialog"); + await user.click(within(dialog).getByRole("button", { name: "Close" })); await waitFor(() => { - expect(createMutate).toHaveBeenCalledWith({ name: "Buy groceries", parent: 3 }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + expect(createMutate).not.toHaveBeenCalled(); }); it("disables the parent picker for a task that already has subtasks", async () => { @@ -406,7 +456,7 @@ describe("TasksPanel", () => { ); const dueDateInput = screen.getByLabelText("Due date"); - await user.type(dueDateInput, "2026-06-01T09:00"); + await user.type(dueDateInput, "2026-06-01"); await user.tab(); await waitFor(() => { @@ -416,5 +466,51 @@ describe("TasksPanel", () => { ); }); }); + + it("defaults the date to today when only a time is set", async () => { + const user = userEvent.setup(); + renderTasksPanel(); + + await user.click( + screen.getAllByRole("button", { name: "Edit task Morning routine" })[0], + ); + + const dueTimeInput = screen.getByLabelText("Due time"); + await user.type(dueTimeInput, "0900"); + await user.tab(); + + await waitFor(() => { + expect(updateMutate).toHaveBeenCalledWith( + { id: 1, data: { due_at: expect.any(String) } }, + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + ); + }); + + const lastCall = updateMutate.mock.calls.at(-1) as [{ data: { due_at: string } }, unknown]; + const committedDate = new Date(lastCall[0].data.due_at); + const today = new Date(); + expect(committedDate.getFullYear()).toBe(today.getFullYear()); + expect(committedDate.getMonth()).toBe(today.getMonth()); + expect(committedDate.getDate()).toBe(today.getDate()); + }); + }); + + describe("timestamps tooltip", () => { + it("shows Created/Modified/Completed on click of the clock button", async () => { + const user = userEvent.setup(); + renderTasksPanel(); + + await user.click( + screen.getAllByRole("button", { name: "Edit task Morning routine" })[0], + ); + + expect(screen.queryByText("Created", { selector: "div" })).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "View task timestamps" })); + + expect(screen.getByText("Created", { selector: "div" })).toBeInTheDocument(); + expect(screen.getByText("Modified", { selector: "div" })).toBeInTheDocument(); + expect(screen.getByText("Completed", { selector: "div" })).toBeInTheDocument(); + }); }); }); diff --git a/frontend/src/components/TasksPanel/TasksPanel.tsx b/frontend/src/components/TasksPanel/TasksPanel.tsx index 3da46e7a..148ea471 100644 --- a/frontend/src/components/TasksPanel/TasksPanel.tsx +++ b/frontend/src/components/TasksPanel/TasksPanel.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useRef } from "react"; import classNames from "classnames"; import EntitySearchInput from "../EntitySearchInput/EntitySearchInput"; @@ -6,7 +6,7 @@ import Button from "../Button/Button"; import PlayerItemList from "../PlayerItemList/PlayerItemList"; import Tooltip from "../Tooltip/Tooltip"; import { isTaskComplete, taskSortOptions, useTasksPanel, type ItemRecord } from "./useTasksPanel"; -import { toDatetimeLocalValue, fromDatetimeLocalValue } from "../../utils/formatUtils"; +import { toDateInputValue, toTimeInputValue, fromDateAndTimeInputValues } from "../../utils/formatUtils"; import styles from "./TasksPanel.module.scss"; interface TasksPanelProps { @@ -31,9 +31,11 @@ export default function TasksPanel({ visibleTasks, getChildren, topLevelTasks, - addSubtaskParent, + pendingOpenTaskId, + hiddenItemIds, startAddSubtask, - clearAddSubtaskParent, + clearPendingOpenTaskId, + discardDraftTask, handleCreateTask, handleSubmitForm, handleEdit, @@ -48,35 +50,27 @@ export default function TasksPanel({ updateTask, } = useTasksPanel(openTaskId, onOpenNote); + // Only one task's edit summary is ever open at a time (it renders inside a modal), so a + // single pair of refs is enough to read the sibling input's value when committing due_at. + const dueDateInputRef = useRef(null); + const dueTimeInputRef = useRef(null); + if (isLoading) return

        Loading tasks...

        ; return (
        - {addSubtaskParent && ( - - Subtask of {addSubtaskParent.name} - - - )} setNewName(v)} - onCreate={(name) => handleCreateTask(name, { parent: addSubtaskParent?.id ?? undefined })} - placeholder={addSubtaskParent ? "New subtask name" : "New task name"} + onCreate={(name) => handleCreateTask(name)} + placeholder="New task name" className={styles.addTaskInput} /> @@ -108,27 +102,19 @@ export default function TasksPanel({ ); }} renderEditSummary={(taskItem, saveHelpers) => { + if (taskItem.id < 0) { + // An unsaved draft subtask: nothing to show or edit here yet + // (due date, parent, notes) until it's actually been created. + return
        Type a name to create this subtask.
        ; + } + const summary = getTaskEditSummary(taskItem); const hasSubtasks = (taskItem.subtask_count ?? 0) > 0; const parentOptions = topLevelTasks.filter((t) => t.id !== taskItem.id); + const parentTask = topLevelTasks.find((t) => t.id === taskItem.parent) ?? null; return ( <> -
        -
        -
        Created
        -
        {summary.created}
        -
        -
        -
        Modified
        -
        {summary.modified}
        -
        -
        -
        Completed
        -
        {summary.completed}
        -
        -
        -
        Total time: {summary.totalTime}
        @@ -154,20 +140,51 @@ export default function TasksPanel({ })() ) : null}
        -
        ); }} + renderTitleRowActions={(task) => { + if (task.id < 0) return null; + const summary = getTaskEditSummary(task); + return ( + +
        +
        Created
        +
        {summary.created}
        +
        +
        +
        Modified
        +
        {summary.modified}
        +
        +
        +
        Completed
        +
        {summary.completed}
        +
        +
        + } + > + + + ); + }} hoverEdit renderRowActions={(task) => ( <> @@ -262,8 +335,13 @@ export default function TasksPanel({ )} onEdit={handleEdit} onDelete={handleDelete} - openItemId={openTaskId} - onOpenItemHandled={onOpenTaskHandled} + openItemId={openTaskId ?? pendingOpenTaskId} + onOpenItemHandled={() => { + onOpenTaskHandled?.(); + clearPendingOpenTaskId(); + }} + hiddenItemIds={hiddenItemIds} + onModalClose={discardDraftTask} sortOptions={taskSortOptions} controls={