refactor(web): zero out 3 ESLint warning buckets (storybook + react-refresh + non-null-assertion)

Three rules cleaned in parallel passes — 187 fewer warnings, 0 TS
errors, 0 behaviour change beyond one incidental auth bugfix
flagged below.

storybook/no-redundant-story-name (23 → 0) — 14 stories files
  Storybook v7+ infers the story name from the variable name, so
  `name: 'Default'` next to `export const Default: Story = …` is
  pure noise. Removed only when the name was redundant ;
  preserved when the label was a French translation
  ('Par défaut', 'Chargement', 'Avec erreur', etc.) since those
  are intentional.

react-refresh/only-export-components (25 → 0) — 21 files
  Each warning marks a file that exports a React component AND a
  hook / context / constant / barrel re-export. Suppressed
  per-line with the suppression-with-justification pattern :
    // eslint-disable-next-line react-refresh/only-export-components -- <kind>; refactor would split a tightly-coupled API
  The justification matters — every comment names the specific
  thing being co-located (hook / context / CVA constant / lazy
  registry / route config / test util / backward-compat barrel).
  Splitting these would create 21 new files for a HMR-only DX
  win that's already a non-issue in practice.

@typescript-eslint/no-non-null-assertion (139 → 0) — 43 files
  Distribution of fixes :
    ~85 cases : refactored to explicit guard
                `if (!x) throw new Error('invariant: …')`
                or hoisted into local with narrowing.
    ~36 cases : helper extraction (one tooltip test had 16
                `wrapper!` patterns reduced to a single
                `getWrapper()` helper).
    ~18 cases : suppressed with specific reason :
                static literal arrays where index is provably
                in bounds, mock fixtures with structural
                guarantees, filter-then-map patterns where the
                filter excludes the null branch.
  One incidental find : services/api/auth.ts threw on missing
  tokens but didn't guard `user` ; added the missing check while
  refactoring the `user!` to a guard.

baseline post-commit : 921 warnings, 0 errors, 0 TS errors.
The remaining buckets are no-restricted-syntax (757, design-system
guardrail), no-explicit-any (115), exhaustive-deps (49).

CI --max-warnings will be lowered to 921 in the follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-30 23:30:22 +02:00
parent 12a78616df
commit 559cfbee3e
100 changed files with 386 additions and 234 deletions

View file

@ -124,14 +124,19 @@ jobs:
working-directory: apps/web
- name: Lint
# ESLint warning baseline freeze (v1.0.10 polish):
# 1204 is the current count of legacy warnings (mostly the
# custom no-restricted-syntax 721, plus @typescript-eslint
# no-non-null-assertion 139, no-unused-vars 134,
# no-explicit-any 115, react-hooks/exhaustive-deps 47).
# CI fails on ANY new warning. Lower this number as warnings
# are resorbed by feature work; never raise it.
run: npx eslint --max-warnings=1204 .
# ESLint warning baseline (v1.0.10 dette tech).
# Lowered from 1204 → 1108 after no-unused-vars sprint
# (134 → 0). Top contributors at this baseline :
# 757 no-restricted-syntax (custom design-system rule —
# Tailwind defaults / hex literals / native <button>)
# 139 @typescript-eslint/no-non-null-assertion
# 115 @typescript-eslint/no-explicit-any
# 47 react-hooks/exhaustive-deps
# 25 react-refresh/only-export-components
# 23 storybook/no-redundant-story-name
# CI fails on ANY new warning. Lower this number as
# warnings are resorbed ; never raise it.
run: npx eslint --max-warnings=1108 .
working-directory: apps/web
- name: Typecheck

View file

@ -13,6 +13,7 @@ const ENDPOINTS = [
export const APIPlaygroundView: React.FC = () => {
const { addToast } = useToast();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- ENDPOINTS is a non-empty static literal
const [selectedEndpoint, setSelectedEndpoint] = useState(ENDPOINTS[0]!);
const [params, setParams] = useState('{\n "limit": 10,\n "offset": 0\n}');
const [response, setResponse] = useState<string | null>(null);

View file

@ -19,6 +19,7 @@ interface ToastContextValue {
const ToastContext = createContext<ToastContextValue | undefined>(undefined);
// Export hooks for usage
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to the provider's context
export function useToastContext() {
const context = useContext(ToastContext);
if (!context) {
@ -31,6 +32,7 @@ export function useToastContext() {
* @deprecated S1.2: Use `useToast` from `@/hooks/useToast` or `toast` from `@/utils/toast` instead.
* Legacy compatibility hook delegates to react-hot-toast. Works without ToastProvider.
*/
// eslint-disable-next-line react-refresh/only-export-components -- co-located deprecated hook; kept here until consumers migrate to @/hooks/useToast
export function useToast() {
const addToast = (messageOrToast: string | Omit<Toast, 'id'>, type?: 'success' | 'error' | 'warning' | 'info') => {
if (typeof messageOrToast === 'string') {

View file

@ -61,7 +61,6 @@ export const Default: Story = {
* v0.102: QueueView lit le store synchrone ; le chargement réel se fait via useQueueSync au layout.
*/
export const Loading: Story = {
name: 'Loading',
decorators: [
(_Story) => {
usePlayerStore.setState({ queue: [], currentIndex: -1, currentTrack: null });

View file

@ -28,7 +28,6 @@ const mockLicense = {
};
export const Basic: Story = {
name: 'Basic',
args: {
license: mockLicense,
onSelect: () => { },
@ -38,7 +37,6 @@ export const Basic: Story = {
export const Pro: Story = {
name: 'Pro',
args: {
license: { ...mockLicense, name: 'Pro Lease', price: 49.99, isPopular: true },
onSelect: () => { },
@ -47,7 +45,6 @@ export const Pro: Story = {
};
export const Exclusive: Story = {
name: 'Exclusive',
args: {
license: { ...mockLicense, name: 'Exclusive Rights', price: 199.99, features: ['Unlimited Streams', 'Trackout Stems', 'Full Ownership'] },
onSelect: () => { },

View file

@ -76,7 +76,6 @@ export const Loading: Story = {
/** Empty similar products */
export const Empty: Story = {
name: 'Empty',
args: {
product: mockProduct,
similarProducts: [],

View file

@ -49,6 +49,7 @@ export function Breadcrumbs({
<li key={itemKey} className="flex items-center gap-1 sm:gap-2">
{isClickable ? (
<Link
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- isClickable narrows item.href to truthy
to={item.href!}
className={cn(
'flex items-center gap-1 text-sm font-medium text-muted-foreground',

View file

@ -2,5 +2,6 @@
* Re-export from audio-player module.
* @see apps/web/src/components/player/audio-player/
*/
// eslint-disable-next-line react-refresh/only-export-components -- re-export barrel; formatTime is a utility function co-located with the AudioPlayer component
export { AudioPlayer, AudioPlayerSkeleton, formatTime } from './audio-player';
export type { AudioPlayerProps } from './audio-player';

View file

@ -9,13 +9,14 @@ export const LyricsPanel: React.FC = () => {
// Auto-scroll logic
useEffect(() => {
if (autoScroll && scrollRef.current && currentTrack?.lyrics) {
const activeIndex = currentTrack.lyrics.findIndex(
const lyrics = currentTrack?.lyrics;
if (autoScroll && scrollRef.current && lyrics) {
const activeIndex = lyrics.findIndex(
(line: { time: number; text: string }, i: number) => {
return (
currentTime >= line.time &&
(i === currentTrack.lyrics!.length - 1 ||
currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity))
(i === lyrics.length - 1 ||
currentTime < (lyrics[i + 1]?.time ?? Infinity))
);
},
);
@ -59,10 +60,11 @@ export const LyricsPanel: React.FC = () => {
>
{currentTrack.lyrics.map(
(line: { time: number; text: string }, i: number) => {
const lyrics = currentTrack.lyrics ?? [];
const isActive =
currentTime >= line.time &&
(i === currentTrack.lyrics!.length - 1 ||
currentTime < (currentTrack.lyrics![i + 1]?.time ?? Infinity));
(i === lyrics.length - 1 ||
currentTime < (lyrics[i + 1]?.time ?? Infinity));
return (
<p
key={i}

View file

@ -23,6 +23,5 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Loading: Story = {
name: 'Loading',
render: () => <CreateProductViewSkeleton />,
};

View file

@ -38,6 +38,5 @@ export const Default: Story = {
};
export const Loading: Story = {
name: 'Loading',
render: () => <AccountSettingsSkeleton />,
};

View file

@ -336,6 +336,7 @@ export function ThemeProvider({
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to ThemeContext defined in this file
export const useTheme = () => {
const context = useContext(ThemeContext);
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');

View file

@ -1,4 +1,5 @@
export {
// eslint-disable-next-line react-refresh/only-export-components -- lazy-component registry
createLazyComponent,
LazyErrorFallback,
LazyErrorBoundary,

View file

@ -19,7 +19,8 @@ describe('WaveformVisualizer Component', () => {
it('calls onSeek when canvas is clicked', () => {
const { container } = render(<WaveformVisualizer progress={50} onSeek={mockOnSeek} />);
const canvas = container.querySelector('canvas')!;
const canvas = container.querySelector('canvas');
if (!canvas) throw new Error('canvas not found');
// Mock getBoundingClientRect
const mockRect = {
@ -69,7 +70,8 @@ describe('WaveformVisualizer Component', () => {
it('clamps seek percentage between 0 and 100', () => {
const { container } = render(<WaveformVisualizer progress={50} onSeek={mockOnSeek} />);
const canvas = container.querySelector('canvas')!;
const canvas = container.querySelector('canvas');
if (!canvas) throw new Error('canvas not found');
const mockRect = {
left: 0,
top: 0,

View file

@ -138,4 +138,5 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
);
Button.displayName = 'Button';
// eslint-disable-next-line react-refresh/only-export-components -- co-located CVA variants constant; tightly coupled to the Button component API
export { Button, buttonVariants };

View file

@ -181,5 +181,6 @@ export {
CardAction,
CardDescription,
CardContent,
// eslint-disable-next-line react-refresh/only-export-components -- co-located CVA variants constant; tightly coupled to the Card component API
cardVariants,
}

View file

@ -109,7 +109,8 @@ describe('Dropdown Component', () => {
);
// The trigger is a div with role="button", so click it
const triggerButton = screen.getByText('Open Menu').closest('[role="button"]')!;
const triggerButton = screen.getByText('Open Menu').closest('[role="button"]');
if (!triggerButton) throw new Error('triggerButton not found');
fireEvent.click(triggerButton);
await waitFor(() => {
@ -125,7 +126,8 @@ describe('Dropdown Component', () => {
);
// The trigger is a div with role="button", so click it
const triggerButton = screen.getByText('Open Menu').closest('[role="button"]')!;
const triggerButton = screen.getByText('Open Menu').closest('[role="button"]');
if (!triggerButton) throw new Error('triggerButton not found');
fireEvent.click(triggerButton);
await waitFor(() => {

View file

@ -144,13 +144,14 @@ describe('FileUpload Component', () => {
.getByText('Drag & drop files here, or click to select')
.closest('.border-2');
expect(dropZone).toBeInTheDocument();
if (!dropZone) throw new Error('dropZone not found');
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
const dataTransfer = {
files: [file],
};
fireEvent.dragEnter(dropZone!, {
fireEvent.dragEnter(dropZone, {
dataTransfer,
});
@ -158,7 +159,7 @@ describe('FileUpload Component', () => {
expect(dropZone).toHaveClass('border-primary');
});
fireEvent.drop(dropZone!, {
fireEvent.drop(dropZone, {
dataTransfer,
});

View file

@ -6,6 +6,7 @@ export {
OptimizedImage,
OptimizedImageSkeleton,
ResponsiveImage,
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the optimized-image module
useImagePreloader,
} from './optimized-image/index';
export type { OptimizedImageProps } from './optimized-image/index';

View file

@ -77,7 +77,8 @@ describe('RadioGroup Component', () => {
const input = el.querySelector('input[type="radio"]');
return input?.getAttribute('value') === 'option2';
});
fireEvent.click(option2Label!);
if (!option2Label) throw new Error('option2Label not found');
fireEvent.click(option2Label);
expect(handleChange).toHaveBeenCalledWith('option2');
});

View file

@ -65,7 +65,8 @@ export function SelectDropdownContent({
case ' ':
if (highlightedIndex >= 0 && highlightedIndex < allOptions.length) {
e.preventDefault();
onSelect(allOptions[highlightedIndex]!.value);
const opt = allOptions[highlightedIndex];
if (opt) onSelect(opt.value);
}
break;
case 'Home':

View file

@ -17,8 +17,9 @@ export function useSelect({
const ungrouped: SelectOption[] = [];
options.forEach((option) => {
if (option.group) {
if (!groups[option.group]) groups[option.group] = [];
groups[option.group]!.push(option);
const group = groups[option.group] ?? [];
group.push(option);
groups[option.group] = group;
} else {
ungrouped.push(option);
}

View file

@ -3,6 +3,13 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { Tooltip } from './tooltip';
// Helper: querySelector that throws on null (Tooltip always renders this wrapper).
function getWrapper(container: HTMLElement): Element {
const el = container.querySelector('.relative.inline-block');
if (!el) throw new Error('tooltip wrapper not found');
return el;
}
describe('Tooltip Component', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -41,8 +48,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(310);
@ -58,8 +65,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
@ -67,7 +74,7 @@ describe('Tooltip Component', () => {
expect(screen.getByText('Tooltip text')).toBeInTheDocument();
fireEvent.mouseLeave(wrapper!);
fireEvent.mouseLeave(wrapper);
await act(async () => {
vi.advanceTimersByTime(250);
});
@ -86,8 +93,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
@ -103,8 +110,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
@ -121,8 +128,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
@ -139,8 +146,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
@ -157,8 +164,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
@ -192,8 +199,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
@ -209,8 +216,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
@ -227,8 +234,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
@ -245,14 +252,14 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(200);
});
fireEvent.mouseLeave(wrapper!);
fireEvent.mouseLeave(wrapper);
await act(async () => {
vi.advanceTimersByTime(200);
@ -362,8 +369,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
});
@ -380,8 +387,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
});
@ -423,8 +430,8 @@ describe('Tooltip Component', () => {
</Tooltip>,
);
const wrapper = container.querySelector('.relative.inline-block');
fireEvent.mouseEnter(wrapper!);
const wrapper = getWrapper(container);
fireEvent.mouseEnter(wrapper);
await act(async () => {
vi.advanceTimersByTime(10);
});

View file

@ -1,6 +1,8 @@
export type { VirtualizedListProps } from './virtualized-list/index';
export {
VirtualizedList,
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the virtualized-list module
useInfiniteScroll,
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the virtualized-list module
useScrollPosition,
} from './virtualized-list/index';

View file

@ -56,8 +56,8 @@ export const VirtualizedList = React.forwardRef<
0,
Math.floor(scrollTop / itemHeight) - overscan,
);
const endIndex = virtualItems[virtualItems.length - 1]!.index;
onItemsRendered(startIndex, endIndex);
const last = virtualItems[virtualItems.length - 1];
if (last) onItemsRendered(startIndex, last.index);
}
}, [onScroll, onItemsRendered, virtualItems, itemHeight, overscan]);

View file

@ -42,7 +42,8 @@ export function useBatchUpload<T>(
const processNext = () => {
while (active < MAX_PARALLEL && queue.length > 0) {
const item = queue.shift()!;
const item = queue.shift();
if (!item) continue;
if (cancelRef.current.has(item.id)) continue;
active++;

View file

@ -1,2 +1,3 @@
export type { VisualizerSettings } from './audio-context';
// eslint-disable-next-line react-refresh/only-export-components -- re-export barrel; useAudio hook is tightly coupled to the AudioProvider context
export { useAudio, AudioProvider } from './audio-context';

View file

@ -4,6 +4,7 @@ import { useAudioContextValue } from './useAudioContextValue';
const AudioContext = createContext<AudioContextType | undefined>(undefined);
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to AudioContext defined in this file
export const useAudio = () => {
const context = useContext(AudioContext);
if (!context) throw new Error('useAudio must be used within AudioProvider');

View file

@ -19,7 +19,6 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Loading: Story = {
name: 'Loading',
render: () => <AnalyticsViewSkeleton />,
};

View file

@ -62,14 +62,16 @@ export function AnalyticsViewAudience({ data }: AnalyticsViewAudienceProps) {
</div>
)}
{data.top_listening_times && data.top_listening_times.length > 0 && (
{data.top_listening_times && data.top_listening_times.length > 0 && (() => {
const times = data.top_listening_times;
return (
<div>
<p className="text-muted-foreground text-xs uppercase tracking-wider mb-3 flex items-center gap-1">
<Clock className="w-3 h-3" aria-hidden="true" /> Peak listening hours
</p>
<div className="grid grid-cols-6 gap-1" role="list" aria-label="Listening hours distribution">
{data.top_listening_times.map((slot) => {
const maxPlays = Math.max(...data.top_listening_times!.map((s) => s.play_count));
{times.map((slot) => {
const maxPlays = Math.max(...times.map((s) => s.play_count));
const intensity = maxPlays > 0 ? slot.play_count / maxPlays : 0;
return (
<div
@ -90,7 +92,8 @@ export function AnalyticsViewAudience({ data }: AnalyticsViewAudienceProps) {
})}
</div>
</div>
)}
);
})()}
</div>
)}
</Card>

View file

@ -74,14 +74,16 @@ export function AnalyticsViewMarketplace({ data }: Props) {
)}
{/* Revenue timeline */}
{data.revenue_timeline && data.revenue_timeline.length > 0 && (
{data.revenue_timeline && data.revenue_timeline.length > 0 && (() => {
const timeline = data.revenue_timeline;
const maxRevenue = Math.max(...timeline.map((d) => d.revenue), 1);
return (
<div className="rounded-xl border border-white/5 bg-card p-6">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-widest mb-4">
Revenue Timeline
</h3>
<div className="flex gap-1 items-end h-32" role="img" aria-label="Revenue timeline chart">
{data.revenue_timeline.map((day, i) => {
const maxRevenue = Math.max(...data.revenue_timeline!.map((d) => d.revenue), 1);
{timeline.map((day, i) => {
const height = (day.revenue / maxRevenue) * 100;
return (
<div
@ -102,7 +104,8 @@ export function AnalyticsViewMarketplace({ data }: Props) {
})}
</div>
</div>
)}
);
})()}
</div>
);
}

View file

@ -45,7 +45,10 @@ export function AnalyticsViewSales({ data, onExportSales }: AnalyticsViewSalesPr
</div>
{/* Revenue over time */}
{data.revenue_by_period && data.revenue_by_period.length > 0 && (
{data.revenue_by_period && data.revenue_by_period.length > 0 && (() => {
const periods = data.revenue_by_period;
const maxRev = Math.max(...periods.map((p) => p.revenue));
return (
<Card variant="glass" className="p-6 bg-black/40 border-white/5">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-foreground text-sm uppercase tracking-widest flex items-center gap-2">
@ -62,8 +65,7 @@ export function AnalyticsViewSales({ data, onExportSales }: AnalyticsViewSalesPr
)}
</div>
<div className="space-y-2" role="list" aria-label="Daily revenue">
{data.revenue_by_period.slice(-14).map((period) => {
const maxRev = Math.max(...data.revenue_by_period!.map((p) => p.revenue));
{periods.slice(-14).map((period) => {
const width = maxRev > 0 ? (period.revenue / maxRev) * 100 : 0;
return (
<div key={period.date} className="flex items-center gap-3 text-xs" role="listitem">
@ -82,7 +84,8 @@ export function AnalyticsViewSales({ data, onExportSales }: AnalyticsViewSalesPr
})}
</div>
</Card>
)}
);
})()}
{/* Top selling tracks */}
{hasRevenue && data.top_selling_tracks && data.top_selling_tracks.length > 0 && (

View file

@ -32,7 +32,6 @@ export const Default: Story = { name: 'Par défaut' };
/** Skeleton pendant chargement (layout aligné). */
export const Loading: Story = {
name: 'Loading',
render: () => <RegisterPageSkeleton />,
};

View file

@ -37,13 +37,11 @@ export const Default: Story = {
/** État de chargement (skeleton). */
export const Loading: Story = {
name: 'Loading',
render: () => <SessionsPageSkeleton />,
};
/** Liste vide (override via MSW ou props). */
export const Empty: Story = {
name: 'Empty',
args: {
initialSessions: [],
},
@ -51,7 +49,6 @@ export const Empty: Story = {
/** Erreur chargement sessions */
export const Error: Story = {
name: 'Error',
parameters: {
msw: {
handlers: [

View file

@ -67,7 +67,9 @@ describe('ChatInput Component', () => {
render(<ChatInput />);
const input = screen.getByPlaceholderText(/Broadcast message/);
fireEvent.change(input, { target: { value: 'Hello' } });
fireEvent.submit(input.closest('form')!);
const form = input.closest('form');
if (!form) throw new Error('form not found');
fireEvent.submit(form);
expect(mockSendMessage).toHaveBeenCalledWith('Hello', undefined, null);
});
@ -75,14 +77,17 @@ describe('ChatInput Component', () => {
render(<ChatInput />);
const input = screen.getByPlaceholderText(/Broadcast message/);
fireEvent.change(input, { target: { value: 'Hello' } });
fireEvent.submit(input.closest('form')!);
const form = input.closest('form');
if (!form) throw new Error('form not found');
fireEvent.submit(form);
expect(input).toHaveValue('');
});
it('does not submit when input is empty and no attachments', () => {
render(<ChatInput />);
const form = screen.getByPlaceholderText(/Broadcast message/).closest('form');
fireEvent.submit(form!);
if (!form) throw new Error('form not found');
fireEvent.submit(form);
expect(mockSendMessage).not.toHaveBeenCalled();
});

View file

@ -34,6 +34,5 @@ export const ProductionRoom: Story = {
};
export const Loading: Story = {
name: 'Loading',
render: () => <ChatInterfaceSkeleton />,
};

View file

@ -90,7 +90,8 @@ export function MentionAutocomplete({
setSelectedIndex((i) => (i - 1 + filtered.length) % filtered.length);
} else if (e.key === 'Enter' && show) {
e.preventDefault();
handleSelect(filtered[selectedIndex]!.username);
const target = filtered[selectedIndex];
if (target) handleSelect(target.username);
} else if (e.key === 'Escape') {
setShow(false);
}

View file

@ -1,5 +1,6 @@
export {
VirtualizedChatMessages,
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the virtualized-chat-messages module
useChatMessages,
VirtualizedChatMessagesSkeleton,
} from './virtualized-chat-messages';

View file

@ -183,10 +183,9 @@ export const useChatStore = create<ChatState>()(
}),
addMessage: (message) =>
set((state) => {
if (!state.messages[message.conversation_id]) {
state.messages[message.conversation_id] = [];
}
state.messages[message.conversation_id]!.push(message);
const list = state.messages[message.conversation_id] ?? [];
list.push(message);
state.messages[message.conversation_id] = list;
}),
loadMessages: (conversationId, newMessages) =>
set((state) => {
@ -224,21 +223,19 @@ export const useChatStore = create<ChatState>()(
if (messages) {
const message = messages.find((m) => m.id === messageId);
if (message) {
if (!message.reactions) message.reactions = {};
const reactions = message.reactions ?? {};
message.reactions = reactions;
// Remove existing reaction from this user if any
Object.keys(message.reactions).forEach((e) => {
const users = message.reactions![e];
Object.keys(reactions).forEach((e) => {
const users = reactions[e];
if (!users) return;
message.reactions![e] = users.filter(
(id) => id !== userId,
);
if (message.reactions![e]?.length === 0)
delete message.reactions![e];
reactions[e] = users.filter((id) => id !== userId);
if (reactions[e]?.length === 0) delete reactions[e];
});
// Add new reaction
if (!message.reactions[emoji]) message.reactions[emoji] = [];
if (!message.reactions[emoji].includes(userId)) {
message.reactions[emoji].push(userId);
if (!reactions[emoji]) reactions[emoji] = [];
if (!reactions[emoji].includes(userId)) {
reactions[emoji].push(userId);
}
}
}
@ -249,12 +246,13 @@ export const useChatStore = create<ChatState>()(
if (messages) {
const message = messages.find((m) => m.id === messageId);
if (message && message.reactions) {
const emojisToRemove = emoji ? [emoji] : Object.keys(message.reactions);
const reactions = message.reactions;
const emojisToRemove = emoji ? [emoji] : Object.keys(reactions);
emojisToRemove.forEach((e) => {
const users = message.reactions![e];
const users = reactions[e];
if (!users) return;
message.reactions![e] = users.filter((id) => id !== userId);
if (message.reactions![e]?.length === 0) delete message.reactions![e];
reactions[e] = users.filter((id) => id !== userId);
if (reactions[e]?.length === 0) delete reactions[e];
});
}
}

View file

@ -16,7 +16,10 @@ export function CheckoutCompletePage() {
const { data: order, isLoading, error } = useQuery({
queryKey: ['order', orderId],
queryFn: () => marketplaceService.getOrder(orderId!),
queryFn: () => {
if (!orderId) throw new Error('orderId required');
return marketplaceService.getOrder(orderId);
},
enabled: !!orderId,
});

View file

@ -70,11 +70,13 @@ export function DiscoverPage() {
refetch: refetchGenre,
} = useInfiniteQuery({
queryKey: ['discoverGenre', browseGenre],
queryFn: ({ pageParam }) =>
discoverService.getTracksByGenre(browseGenre!, {
queryFn: ({ pageParam }) => {
if (!browseGenre) throw new Error('browseGenre required');
return discoverService.getTracksByGenre(browseGenre, {
cursor: pageParam,
limit: 20,
}),
});
},
getNextPageParam: (last) => last.next_cursor ?? undefined,
initialPageParam: undefined as string | undefined,
enabled: !!browseGenre,
@ -90,11 +92,13 @@ export function DiscoverPage() {
refetch: refetchTag,
} = useInfiniteQuery({
queryKey: ['discoverTag', browseTag],
queryFn: ({ pageParam }) =>
discoverService.getTracksByTag(browseTag!, {
queryFn: ({ pageParam }) => {
if (!browseTag) throw new Error('browseTag required');
return discoverService.getTracksByTag(browseTag, {
cursor: pageParam,
limit: 20,
}),
});
},
getNextPageParam: (last) => last.next_cursor ?? undefined,
initialPageParam: undefined as string | undefined,
enabled: !!browseTag,

View file

@ -39,6 +39,7 @@ export const WithImages: Story = {
};
export const SingleImage: Story = {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockImages declared above with entries
args: { images: [mockImages[0]!] },
};

View file

@ -49,7 +49,10 @@ export function GearImageGallery({
{onRemoveImage && sorted[selected] && (
<button
type="button"
onClick={() => onRemoveImage(sorted[selected]!.id)}
onClick={() => {
const img = sorted[selected];
if (img) onRemoveImage(img.id);
}}
className="absolute top-2 right-2 p-1 bg-black/60 rounded-full text-white hover:bg-black/80 transition-colors"
aria-label="Remove image"
>

View file

@ -23,6 +23,7 @@ export const Default: Story = {
export const WarrantyExpiringSoon: Story = {
args: {
item: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockGearInventory has entries
...mockGearInventory[0]!,
warrantyExpire: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
},

View file

@ -19,7 +19,6 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Loading: Story = {
name: 'Loading',
render: () => <LiveViewSkeleton />,
};

View file

@ -26,13 +26,11 @@ export const Default: Story = { name: 'Par défaut' };
/** Loading state — skeleton to avoid layout shift */
export const Loading: Story = {
name: 'Loading',
render: () => <NotificationsPageSkeleton />,
};
/** Empty list */
export const Empty: Story = {
name: 'Empty',
parameters: {
msw: {
handlers: [
@ -53,7 +51,6 @@ export const Empty: Story = {
/** Error loading notifications */
export const Error: Story = {
name: 'Error',
parameters: {
msw: {
handlers: [

View file

@ -45,6 +45,7 @@ function groupNotificationsByDate(
// Return in stable order defined by DATE_GROUP_ORDER
return DATE_GROUP_ORDER.filter((g) => groups.has(g)).map((g) => [
g,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by groups.has(g) in the filter above
groups.get(g)!,
]);
}

View file

@ -83,7 +83,10 @@ export function PlayerQueue({ isOpen, onClose, onPlay }: PlayerQueueProps) {
// Poll session when active (v0.203 Lot D1)
const { data: sessionData } = useQuery({
queryKey: ['queue-session', activeSessionToken],
queryFn: () => queueApi.getQueueSession(activeSessionToken!),
queryFn: () => {
if (!activeSessionToken) throw new Error('activeSessionToken required');
return queueApi.getQueueSession(activeSessionToken);
},
enabled: !!activeSessionToken && isOpen,
refetchInterval: 8000,
});

View file

@ -55,9 +55,12 @@ export function shuffleTracks(tracks: Track[]): Track[] {
const shuffled = [...tracks];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temp = shuffled[i]!;
shuffled[i] = shuffled[j]!;
shuffled[j] = temp;
const a = shuffled[i];
const b = shuffled[j];
if (a !== undefined && b !== undefined) {
shuffled[i] = b;
shuffled[j] = a;
}
}
return shuffled;
}
@ -358,7 +361,7 @@ export class AudioPlayerService {
step++;
if (step >= steps) {
clearInterval(id);
this.audioElement!.volume = 0;
if (this.audioElement) this.audioElement.volume = 0;
onComplete();
return;
}

View file

@ -1,6 +1,7 @@
export {
PlaylistAnalytics,
PlaylistAnalyticsSkeleton,
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook re-exported from the playlist-analytics module
usePlaylistAnalytics,
} from './playlist-analytics';
export type { PlaylistAnalyticsData, PlaylistAnalyticsProps } from './playlist-analytics';

View file

@ -64,6 +64,7 @@ export const Default: Story = {
export const SingleSelection: Story = {
name: 'Une playlist sélectionnée',
args: {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockPlaylists has entries
selectedPlaylists: [mockPlaylists[0]!],
onSelectionClear: () => {},
},

View file

@ -90,6 +90,7 @@ export class PlaylistErrorBoundary extends Component<Props, State> {
* T0502: Create Playlist Error Handling Improvements
* Uses ErrorDisplay component for consistent error presentation
*/
// eslint-disable-next-line react-refresh/only-export-components -- private fallback component co-located with the error-boundary class; refactor would require duplicating its onReset contract
function ErrorFallback({
error,
onReset,

View file

@ -2,5 +2,6 @@
* Re-export from playlist-list module for backward compatibility.
*/
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
export { PlaylistList, default } from './playlist-list';
export type { PlaylistListProps, SortField, SortOrder } from './playlist-list';

View file

@ -199,12 +199,15 @@ function getToastConfig(notification: PlaylistNotification): {
description: notification.content,
options: {
action: notification.link
? {
label: 'Voir',
onClick: () => {
safeNavigate(notification.link!);
},
}
? (() => {
const link = notification.link;
return {
label: 'Voir',
onClick: () => {
if (link) safeNavigate(link);
},
};
})()
: undefined,
},
};
@ -215,12 +218,15 @@ function getToastConfig(notification: PlaylistNotification): {
description: notification.content,
options: {
action: notification.link
? {
label: 'Voir',
onClick: () => {
safeNavigate(notification.link!);
},
}
? (() => {
const link = notification.link;
return {
label: 'Voir',
onClick: () => {
if (link) safeNavigate(link);
},
};
})()
: undefined,
},
};
@ -231,12 +237,15 @@ function getToastConfig(notification: PlaylistNotification): {
description: notification.content,
options: {
action: notification.link
? {
label: 'Voir',
onClick: () => {
safeNavigate(notification.link!);
},
}
? (() => {
const link = notification.link;
return {
label: 'Voir',
onClick: () => {
if (link) safeNavigate(link);
},
};
})()
: undefined,
},
};
@ -247,12 +256,15 @@ function getToastConfig(notification: PlaylistNotification): {
description: notification.content,
options: {
action: notification.link
? {
label: 'Voir',
onClick: () => {
safeNavigate(notification.link!);
},
}
? (() => {
const link = notification.link;
return {
label: 'Voir',
onClick: () => {
if (link) safeNavigate(link);
},
};
})()
: undefined,
},
};

View file

@ -104,6 +104,7 @@ const defaultHookReturn = {
},
collaborators: [],
tracks: [mockTrack],
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: mockPlaylist.tracks declared above
playlistTracks: mockPlaylist.tracks!,
isAddTrackModalOpen: false,
setIsAddTrackModalOpen: vi.fn(),

View file

@ -9,7 +9,10 @@ import { getPresence } from '../services/presenceService';
export function usePresence(userId: string | null | undefined) {
return useQuery({
queryKey: ['presence', userId],
queryFn: () => getPresence(userId!),
queryFn: () => {
if (!userId) throw new Error('userId required');
return getPresence(userId);
},
enabled: !!userId,
staleTime: 30_000, // 30s - presence doesn't change that often
});

View file

@ -153,11 +153,12 @@ describe('AvatarUpload', () => {
const dropZone = container.querySelector('.rounded-full');
expect(dropZone).toBeInTheDocument();
if (!dropZone) throw new Error('dropZone not found');
const validFile = new File(['content'], 'test.jpg', { type: 'image/jpeg' });
const dataTransfer = { files: [validFile] };
fireEvent.drop(dropZone!, {
fireEvent.drop(dropZone, {
dataTransfer,
});

View file

@ -37,6 +37,7 @@ export function ProfileSocialLinksSection({ socialLinks }: ProfileSocialLinksSec
key,
label,
Icon,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by socialLinks?.[key] != null in filter above
href: String(socialLinks![key]),
}));

View file

@ -39,7 +39,10 @@ export function useUserProfilePage() {
const { data: postsData, isLoading: isPostsLoading } = useQuery({
queryKey: ['userPosts', profile?.id],
queryFn: () => socialService.getPostsByUser(profile!.id, 1, profile ?? undefined),
queryFn: () => {
if (!profile?.id) throw new Error('profile.id required');
return socialService.getPostsByUser(profile.id, 1, profile);
},
enabled: !!profile?.id,
retry: false,
});
@ -53,7 +56,10 @@ export function useUserProfilePage() {
const { data: repostsData } = useQuery({
queryKey: ['userReposts', profile?.id],
queryFn: () => usersApi.getUserReposts(profile!.id, 20, 0),
queryFn: () => {
if (!profile?.id) throw new Error('profile.id required');
return usersApi.getUserReposts(profile.id, 20, 0);
},
enabled: !!profile?.id,
retry: false,
});

View file

@ -19,7 +19,6 @@ export default meta;
type Story = StoryObj<typeof meta>;
export const Loading: Story = {
name: 'Loading',
render: () => <PurchasesViewSkeleton />,
};

View file

@ -94,7 +94,9 @@ describe('AssignRoleModal', () => {
// Open the Select dropdown to see role options
const dialog = screen.getByRole('dialog');
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
await user.click(selectTriggers[0]!);
const trigger = selectTriggers[0];
if (!trigger) throw new Error('select trigger not found');
await user.click(trigger);
await waitFor(() => {
expect(screen.getByRole('option', { name: /administrator/i })).toBeInTheDocument();
@ -121,7 +123,9 @@ describe('AssignRoleModal', () => {
const dialog = screen.getByRole('dialog');
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
await user.click(selectTriggers[0]!);
const trigger = selectTriggers[0];
if (!trigger) throw new Error('select trigger not found');
await user.click(trigger);
const option = await screen.findByRole('option', { name: /administrator/i });
await user.click(option);
@ -178,7 +182,9 @@ describe('AssignRoleModal', () => {
const dialog = screen.getByRole('dialog');
const selectTriggers = within(dialog).getAllByRole('button', { name: /select a role/i });
await user.click(selectTriggers[0]!);
const trigger = selectTriggers[0];
if (!trigger) throw new Error('select trigger not found');
await user.click(trigger);
const option = await screen.findByRole('option', { name: /administrator/i });
await user.click(option);

View file

@ -26,13 +26,11 @@ export const Default: Story = { name: 'Par défaut' };
/** Loading state — skeleton to avoid layout shift. */
export const Loading: Story = {
name: 'Loading',
render: () => <SearchPageSkeleton />,
};
/** Empty results (no tracks, artists, playlists). */
export const Empty: Story = {
name: 'Empty',
parameters: {
msw: {
handlers: [
@ -49,7 +47,6 @@ export const Empty: Story = {
/** Error loading search. */
export const Error: Story = {
name: 'Error',
parameters: {
msw: {
handlers: [

View file

@ -2,4 +2,5 @@
* Re-export from account-settings module for backward compatibility.
*/
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
export { AccountSettings, AccountSettingsSkeleton, default } from './account-settings';

View file

@ -19,7 +19,10 @@ export function PresenceInvisibleToggle() {
const { data: presence } = useQuery({
queryKey: ['presence', user?.id],
queryFn: () => getPresence(user!.id),
queryFn: () => {
if (!user?.id) throw new Error('user.id required');
return getPresence(user.id);
},
enabled: !!user?.id,
});

View file

@ -17,13 +17,18 @@ export function ProfileVisibilityCard() {
const { data: profile, isLoading } = useQuery({
queryKey: ['profile', user?.id],
queryFn: () => usersApi.getProfile(user!.id),
queryFn: () => {
if (!user?.id) throw new Error('user.id required');
return usersApi.getProfile(user.id);
},
enabled: !!user?.id,
});
const mutation = useMutation({
mutationFn: (isPublic: boolean) =>
usersApi.updateProfile(user!.id, { is_public: isPublic }),
mutationFn: (isPublic: boolean) => {
if (!user?.id) throw new Error('user.id required');
return usersApi.updateProfile(user.id, { is_public: isPublic });
},
onSuccess: (updatedProfile) => {
queryClient.setQueryData(['profile', user?.id], updatedProfile);
queryClient.invalidateQueries({ queryKey: ['userProfile'] });

View file

@ -136,7 +136,9 @@ export function SocialViewFeedItem({ item, onPlay }: SocialViewFeedItemProps) {
<button
type="button"
className="appearance-none bg-transparent border-0 p-0 text-left w-full bg-card p-4 rounded-xl flex items-center gap-4 shadow-[0_0_8px_rgba(26,26,30,0.05)] hover:shadow-[0_0_12px_rgba(26,26,30,0.08)] group cursor-pointer transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
onClick={() => onPlay(item.track!)}
onClick={() => {
if (item.track) onPlay(item.track);
}}
>
<div className="w-16 h-16 rounded-lg overflow-hidden relative">
<img

View file

@ -49,7 +49,6 @@ export const Loading: Story = {
/** Données vides (aucun segment) */
export const Empty: Story = {
name: 'Empty',
args: {
trackId: '123',
initialHeatmap: {
@ -62,7 +61,6 @@ export const Empty: Story = {
/** Erreur chargement */
export const Error: Story = {
name: 'Error',
parameters: {
msw: {
handlers: [

View file

@ -36,16 +36,17 @@ export function getSkipBorderColor(skipCount: number, listenCount: number): stri
}
function computeStats(heatmap: PlaybackHeatmapData): HeatmapStats | null {
if (!heatmap.segments.length) return null;
const first = heatmap.segments[0];
if (!first) return null;
const totalListens = heatmap.segments.reduce((sum, seg) => sum + seg.listen_count, 0);
const totalSkips = heatmap.segments.reduce((sum, seg) => sum + seg.skip_count, 0);
const maxIntensitySegment = heatmap.segments.reduce(
(max, seg) => (seg.intensity > max.intensity ? seg : max),
heatmap.segments[0]!,
first,
);
const maxSkipSegment = heatmap.segments.reduce(
(max, seg) => (seg.skip_count > max.skip_count ? seg : max),
heatmap.segments[0]!,
first,
);
return { totalListens, totalSkips, maxIntensitySegment, maxSkipSegment };
}

View file

@ -94,6 +94,7 @@ describe('useBitrateAdaptation', () => {
expect(result.current.isAdapting).toBe(true);
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
resolvePromise!({
recommended_bitrate: 320,
});
@ -217,6 +218,7 @@ describe('useBitrateAdaptation', () => {
// Resolve the promise after unmount
await act(async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
resolvePromise!({
recommended_bitrate: 320,
});

View file

@ -184,6 +184,7 @@ describe('useHLSStream', () => {
unmount();
// Resolve the promise after unmount
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
resolvePromise!({
status: 'ready',
bitrates: [128],

View file

@ -185,7 +185,7 @@ export function SubscriptionPage(): React.ReactElement {
}`}
>
{currentSub.status === 'trialing'
? `Trial (ends ${formatDate(currentSub.trial_end!)})`
? `Trial (ends ${formatDate(currentSub.trial_end ?? '')})`
: currentSub.status}
</span>
{currentSub.cancel_at_period_end && (

View file

@ -95,9 +95,11 @@ export const DeeplyNested: Story = {
...mockComment,
replies: [
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
...mockCommentWithReplies.replies![0],
replies: [
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
...mockCommentWithReplies.replies![1],
content: 'Nested reply',
id: 'r3'
@ -182,10 +184,12 @@ export const VisualStressTest: Story = {
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.\n\nDuis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.',
replies: [
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
...mockCommentWithReplies.replies![0],
content: 'Short reply.',
} as TrackComment,
{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- mock fixture: replies declared above with 2 entries
...mockCommentWithReplies.replies![1],
content:
'A much longer reply that wraps multiple lines and tests tracking-tight, radius tokens, and hover states without breaking the thread layout or overflowing.',

View file

@ -5,6 +5,7 @@
export {
TrackFilters,
TrackFiltersSkeleton,
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
default,
} from './track-filters';
export type { TrackFiltersProps, TrackFilterOptions } from './track-filters';

View file

@ -71,7 +71,9 @@ describe('TrackGrid', () => {
} else {
// Si pas de role="button", chercher dans le conteneur parent
const track1Text = screen.getByText('Track 1');
await user.click(track1Text.closest('div')!);
const div = track1Text.closest('div');
if (!div) throw new Error('parent div not found');
await user.click(div);
expect(mockOnTrackClick).toHaveBeenCalledWith(mockTracks[0]);
}
});

View file

@ -59,7 +59,8 @@ describe('TrackList', () => {
render(<TrackList tracks={mockTracks} onTrackClick={mockOnTrackClick} />);
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
await user.click(track1!);
if (!track1) throw new Error('track1 listitem not found');
await user.click(track1);
expect(mockOnTrackClick).toHaveBeenCalledWith(mockTracks[0]);
});
@ -71,7 +72,8 @@ describe('TrackList', () => {
// Hover to show play button
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
await user.hover(track1!);
if (!track1) throw new Error('track1 listitem not found');
await user.hover(track1);
await new Promise((resolve) => setTimeout(resolve, 100));
@ -215,7 +217,8 @@ describe('TrackList', () => {
// Hover to show like button
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
await user.hover(track1!);
if (!track1) throw new Error('track1 listitem not found');
await user.hover(track1);
await new Promise((resolve) => setTimeout(resolve, 100));
@ -233,7 +236,8 @@ describe('TrackList', () => {
// Hover to show more button
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
await user.hover(track1!);
if (!track1) throw new Error('track1 listitem not found');
await user.hover(track1);
await new Promise((resolve) => setTimeout(resolve, 100));
@ -285,7 +289,8 @@ describe('TrackList', () => {
// Hover to show like button
const track1 = screen.getByText('Track 1').closest('[role="listitem"]');
await user.hover(track1!);
if (!track1) throw new Error('track1 listitem not found');
await user.hover(track1);
await new Promise((resolve) => setTimeout(resolve, 100));

View file

@ -5,6 +5,7 @@
export {
TrackSearchFilters,
TrackSearchFiltersSkeleton,
// eslint-disable-next-line react-refresh/only-export-components -- backward-compat re-export barrel; named + default exports point to the same component
default,
} from './track-search-filters';
export type {

View file

@ -103,7 +103,7 @@ export function TrackStemsSection({ track, isCreator }: TrackStemsSectionProps)
<p className="text-muted-foreground">{t('tracks.stemsSection.uploadHelp')}</p>
) : (
<ul className="space-y-2">
{stems!.map((stem) => (
{(stems ?? []).map((stem) => (
<li
key={stem.id}
className="flex items-center justify-between gap-4 py-2 px-3 rounded-lg bg-muted/30 hover:bg-muted/50"

View file

@ -82,7 +82,9 @@ export function CommentThreadContent({
{onSeek ? (
<button
type="button"
onClick={() => onSeek(comment.timestamp!)}
onClick={() => {
if (comment.timestamp != null) onSeek(comment.timestamp);
}}
className="text-xs text-primary hover:underline font-medium tabular-nums"
aria-label={`Aller à ${formatTime(comment.timestamp)}`}
>

View file

@ -26,7 +26,7 @@ export function useCommentReplies({
});
const replies = hasInitialReplies
? initialReplies!
? (initialReplies ?? [])
: (repliesData?.replies ?? []);
const isLoadingReplies =

View file

@ -94,9 +94,10 @@ describe('useInfiniteScroll', () => {
// Simuler l'intersection si l'observer existe
if (observerCallback && mockObserverInstance) {
const cb = observerCallback;
const sentinel = document.createElement('div');
act(() => {
observerCallback!(
cb(
[
{
isIntersecting: true,
@ -140,9 +141,10 @@ describe('useInfiniteScroll', () => {
// Simuler l'intersection si l'observer existe
if (observerCallback && mockObserverInstance) {
const cb = observerCallback;
const sentinel = document.createElement('div');
act(() => {
observerCallback!(
cb(
[
{
isIntersecting: true,
@ -288,9 +290,10 @@ describe('useInfiniteScroll', () => {
// Simuler l'intersection si l'observer existe
if (observerCallback && mockObserverInstance) {
const cb = observerCallback;
const sentinel = document.createElement('div');
act(() => {
observerCallback!(
cb(
[
{
isIntersecting: true,
@ -336,10 +339,11 @@ describe('useInfiniteScroll', () => {
await new Promise((resolve) => setTimeout(resolve, 100));
if (observerCallback && mockObserverInstance) {
const cb = observerCallback;
const sentinel = document.createElement('div');
// Simuler plusieurs intersections rapides
act(() => {
observerCallback!(
cb(
[
{
isIntersecting: true,
@ -355,7 +359,7 @@ describe('useInfiniteScroll', () => {
);
// Simuler une deuxième intersection immédiatement
observerCallback!(
cb(
[
{
isIntersecting: true,

View file

@ -305,6 +305,7 @@ describe('useTrackList', () => {
expect(result.current.isLoading).toBe(true);
act(() => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- assigned synchronously in Promise executor above
resolvePromise!({
data: mockTracks,
total: mockTracks.length,

View file

@ -74,12 +74,14 @@ export function useTrackList(
const getInitialFilters = () => {
if (syncUrlParams) {
const filters: TrackFilters = {};
if (searchParams.get('genre')) filters.genre = searchParams.get('genre')!;
if (searchParams.get('artist'))
filters.artist = searchParams.get('artist')!;
if (searchParams.get('album')) filters.album = searchParams.get('album')!;
if (searchParams.get('year'))
filters.year = parseInt(searchParams.get('year')!);
const genre = searchParams.get('genre');
if (genre) filters.genre = genre;
const artist = searchParams.get('artist');
if (artist) filters.artist = artist;
const album = searchParams.get('album');
if (album) filters.album = album;
const year = searchParams.get('year');
if (year) filters.year = parseInt(year);
// Basic mapping
return { ...initialFilterOptions, ...filters };
}
@ -92,9 +94,10 @@ export function useTrackList(
const getInitialSort = () => {
if (syncUrlParams) {
if (searchParams.get('sortField')) {
const sortField = searchParams.get('sortField');
if (sortField) {
return {
field: searchParams.get('sortField')!,
field: sortField,
order: (searchParams.get('sortOrder') as 'asc' | 'desc') || 'asc',
};
}

View file

@ -115,6 +115,10 @@ export class ChunkedUploadManager {
);
this.state.uploadId = uploadId;
}
const uploadId = this.state.uploadId;
if (!uploadId) {
throw new Error('uploadId must be set after initiateChunkedUpload');
}
// 2. Uploader chaque chunk séquentiellement
for (let i = this.currentChunkIndex; i < this.chunks.length; i++) {
@ -134,7 +138,7 @@ export class ChunkedUploadManager {
for (let retry = 0; retry <= this.MAX_RETRIES; retry++) {
try {
const response = await trackService.uploadChunk(
this.state.uploadId!,
uploadId,
chunkNumber,
this.state.totalChunks,
this.file.size,
@ -184,9 +188,7 @@ export class ChunkedUploadManager {
// 4. Compléter l'upload
this.updateProgress(95);
const track = await trackService.completeChunkedUpload(
this.state.uploadId!,
);
const track = await trackService.completeChunkedUpload(uploadId);
this.state.track = track;
this.state.isComplete = true;

View file

@ -101,6 +101,7 @@ function getSeasonForDate(date: Date): { current: SeasonConfig; dayOfSeason: num
// Build array of season start dates for this year and next
const starts = SEASONS.map((s, i) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- SEASONS is a static array of 4 elements; modulo guarantees in-bounds
const nextSeason = SEASONS[(i + 1) % SEASONS.length]!;
return {
config: s,
@ -125,6 +126,7 @@ function getSeasonForDate(date: Date): { current: SeasonConfig; dayOfSeason: num
}
// Fallback: winter (SEASONS[3] is always 'winter')
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- SEASONS literal has 4 elements, index 3 always present
return { current: SEASONS[3]!, dayOfSeason: 1, progress: 0 };
}
@ -133,6 +135,7 @@ const TRANSITION_DAYS = 7;
function computeSeasonalContext(date: Date): SeasonalContext {
const { current, dayOfSeason, progress } = getSeasonForDate(date);
const currentIndex = SEASONS.indexOf(current);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- modulo on 4-elem static SEASONS guarantees in-bounds
const previousSeason = SEASONS[(currentIndex - 1 + SEASONS.length) % SEASONS.length]!;
const isTransitioning = dayOfSeason <= TRANSITION_DAYS;
@ -170,6 +173,7 @@ export function useSeason(overrideSeason?: Season): SeasonalContext {
return useMemo(() => {
if (overrideSeason) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- SEASONS[0] exists in static 4-elem array
const config = SEASONS.find((s) => s.season === overrideSeason) ?? SEASONS[0]!;
return {
season: config.season,

View file

@ -142,6 +142,7 @@ function getPeriodForHour(hour: number): PeriodConfig {
if (hour >= p.startHour || hour < p.endHour) return p;
}
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- PERIODS literal has 6 entries, index 5 always present
return PERIODS[5]!; // fallback to night
}
@ -174,6 +175,7 @@ function computePalette(now: Date): TimeOfDayPalette {
// Find next period for smooth interpolation
const nextPeriodIndex = (PERIODS.indexOf(current) + 1) % PERIODS.length;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- modulo on static PERIODS array guarantees in-bounds
const next = PERIODS[nextPeriodIndex]!;
// Smooth transition in the last 30% of each period
@ -228,6 +230,7 @@ export function useTimeOfDay(enabled = true): TimeOfDayPalette {
/** Force a specific period (for Storybook / testing) */
export function getTimeOfDayPalette(period: TimeOfDayPeriod): TimeOfDayPalette {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- PERIODS[2] is midday, present in static array
const config = PERIODS.find((p) => p.period === period) ?? PERIODS[2]!;
return {
period: config.period,

View file

@ -213,7 +213,9 @@ const waitForStylesheets = (): Promise<void> => {
import { ThemeProvider } from './components/theme/ThemeProvider';
const renderApp = () => {
ReactDOM.createRoot(document.getElementById('root')!).render(
const root = document.getElementById('root');
if (!root) throw new Error('root element not found in index.html');
ReactDOM.createRoot(root).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">

View file

@ -56,6 +56,7 @@ export const AuthProvider = ({ children, loadingComponent }: AuthProviderProps)
* Hook to check if auth is currently initializing
* Can be used by components that need to know auth initialization status
*/
// eslint-disable-next-line react-refresh/only-export-components -- co-located hook; tightly coupled to the AuthProvider lifecycle
export const useAuthInitialization = () => {
const [isInitializing, setIsInitializing] = useState(true);

View file

@ -61,6 +61,7 @@ import { useUser } from '@/features/auth/hooks/useUser';
import type { RouteEntry } from './types';
/** Redirects /profile to /u/<current_username> */
// eslint-disable-next-line react-refresh/only-export-components -- route config co-located with lazy components
function ProfileRedirect() {
const { data: user } = useUser();
const navigate = useNavigate();

View file

@ -204,8 +204,11 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
// INT-TYPE-008: Return format aligned with backend LoginResponse
// Backend format: { user: UserResponse, token: TokenResponse, requires_2fa?: boolean }
if (!user) {
throw new Error('Login response missing user data');
}
return {
user: user!,
user,
token: {
access_token: accessToken,
refresh_token: refreshToken || '', // Ensure string

View file

@ -100,9 +100,10 @@ export function createResponseSuccessHandler(apiClient: AxiosInstance) {
statusText: response.statusText,
headers: sanitizeForLogging(response.headers),
data: sanitizeForLogging(response.data),
duration: (response.config as AxiosResponse['config'] & { _requestStartTime?: number })?._requestStartTime
? Date.now() - (response.config as AxiosResponse['config'] & { _requestStartTime?: number })._requestStartTime!
: undefined,
duration: ((): number | undefined => {
const cfg = response.config as AxiosResponse['config'] & { _requestStartTime?: number };
return cfg._requestStartTime ? Date.now() - cfg._requestStartTime : undefined;
})(),
},
);
}

View file

@ -97,7 +97,7 @@ async function searchWithFacets(
if (opts.cursor) params.cursor = opts.cursor;
if (opts.limit != null && opts.limit > 0) params.limit = Math.min(opts.limit, 50);
const f = opts.facets!;
const f = opts.facets ?? {};
if (f.genre) params.genre = f.genre;
if (f.musicalKey) params.musical_key = f.musicalKey;
if (f.bpmMin && f.bpmMin > 0) params.bpm_min = f.bpmMin;

View file

@ -423,8 +423,9 @@ describe('OfflineQueueService', () => {
// Check localStorage
const stored = localStorageMock.getItem('veza_offline_queue');
expect(stored).not.toBeNull();
if (stored === null) throw new Error('stored is null');
const parsed = JSON.parse(stored!);
const parsed = JSON.parse(stored);
expect(parsed.length).toBe(1);
expect(parsed[0].config.url).toBe('/api/v1/tracks');
});
@ -485,7 +486,8 @@ describe('OfflineQueueService', () => {
// The service should filter out old requests on load
// Since we can't easily test singleton initialization, we'll verify the logic
const stored = localStorageMock.getItem('veza_offline_queue');
const parsed = JSON.parse(stored!);
if (stored === null) throw new Error('stored is null');
const parsed = JSON.parse(stored);
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
const filtered = parsed.filter(
(req: QueuedRequest) => req.timestamp > oneDayAgo,

View file

@ -53,14 +53,15 @@ class PWAService {
if ('serviceWorker' in navigator) {
try {
this.registration = await navigator.serviceWorker.register('/sw.js', {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
});
this.registration = registration;
logger.info('[PWA] Service Worker registered successfully');
this.registration.addEventListener('updatefound', () => {
const newWorker = this.registration!.installing;
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (
@ -223,7 +224,8 @@ class PWAService {
* Clear all caches
*/
public async clearCaches(): Promise<void> {
if (this.registration) {
const registration = this.registration;
if (registration) {
const messageChannel = new MessageChannel();
return new Promise((resolve) => {
@ -233,7 +235,7 @@ class PWAService {
}
};
this.registration!.active?.postMessage({ type: 'CLEAR_CACHE' }, [
registration.active?.postMessage({ type: 'CLEAR_CACHE' }, [
messageChannel.port2,
]);
});
@ -244,7 +246,9 @@ class PWAService {
* Get service worker version
*/
public async getVersion(): Promise<string> {
if (this.registration && this.registration.active) {
const registration = this.registration;
const active = registration?.active;
if (registration && active) {
const messageChannel = new MessageChannel();
return new Promise((resolve) => {
@ -254,9 +258,7 @@ class PWAService {
}
};
this.registration!.active!.postMessage({ type: 'GET_VERSION' }, [
messageChannel.port2,
]);
active.postMessage({ type: 'GET_VERSION' }, [messageChannel.port2]);
});
}

View file

@ -47,10 +47,13 @@ class RequestDeduplicationService {
// Sort params for consistent key generation
const params = config.params
? Object.keys(config.params)
.sort()
.map((key) => `${key}=${JSON.stringify(config.params![key])}`)
.join('&')
? (() => {
const cp = config.params;
return Object.keys(cp)
.sort()
.map((key) => `${key}=${JSON.stringify(cp[key])}`)
.join('&');
})()
: '';
// Serialize body for consistent key generation

View file

@ -69,10 +69,13 @@ class ResponseCacheService {
// Sort params for consistent key generation
const params = config.params
? Object.keys(config.params)
.sort()
.map((key) => `${key}=${JSON.stringify(config.params![key])}`)
.join('&')
? (() => {
const cp = config.params;
return Object.keys(cp)
.sort()
.map((key) => `${key}=${JSON.stringify(cp[key])}`)
.join('&');
})()
: '';
// Include Authorization header in key for user-specific cache

View file

@ -81,7 +81,8 @@ function decodeJWT(token: string): JWTPayload | null {
return null;
}
// Décoder le payload (base64url)
const payload = parts[1]!;
const payload = parts[1];
if (!payload) return null;
const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
return JSON.parse(decoded) as JWTPayload;
} catch (error) {

View file

@ -17,6 +17,7 @@ const queryClient = new QueryClient({
},
});
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
function AllTheProviders({ children }: AllTheProvidersProps) {
return (
<BrowserRouter>
@ -32,5 +33,6 @@ const customRender = (
) => render(ui, { wrapper: AllTheProviders, ...options });
// Re-export everything
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
export * from '@testing-library/react';
export { customRender as render };

View file

@ -28,6 +28,7 @@ interface AllProvidersProps {
initialEntries?: string[];
}
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
const AllProviders = ({ children, initialEntries }: AllProvidersProps) => {
const queryClient = createTestQueryClient();
const Router = initialEntries ? MemoryRouter : BrowserRouter;
@ -58,6 +59,7 @@ const customRender = (ui: ReactElement, options?: CustomRenderOptions) => {
};
// Re-export everything from @testing-library/react
// eslint-disable-next-line react-refresh/only-export-components -- test util, no HMR concern
export * from '@testing-library/react';
// Override render with our custom render

View file

@ -77,21 +77,19 @@ export function parseRGB(rgbString: string): [number, number, number] | null {
// Handle CSS variable format: "255 255 255"
const spaceSeparated = rgbString.trim().match(/^(\d+)\s+(\d+)\s+(\d+)$/);
if (spaceSeparated) {
return [
parseInt(spaceSeparated[1]!, 10),
parseInt(spaceSeparated[2]!, 10),
parseInt(spaceSeparated[3]!, 10),
];
const [, r, g, b] = spaceSeparated;
if (r && g && b) {
return [parseInt(r, 10), parseInt(g, 10), parseInt(b, 10)];
}
}
// Handle rgb() format: "rgb(255, 255, 255)"
const rgbMatch = rgbString.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (rgbMatch) {
return [
parseInt(rgbMatch[1]!, 10),
parseInt(rgbMatch[2]!, 10),
parseInt(rgbMatch[3]!, 10),
];
const [, r, g, b] = rgbMatch;
if (r && g && b) {
return [parseInt(r, 10), parseInt(g, 10), parseInt(b, 10)];
}
}
return null;

View file

@ -121,7 +121,10 @@ export function sanitizeHTML(
content: string,
options: SanitizeOptions = {},
): string {
const config = { ...DEFAULT_OPTIONS, ...options };
const config: Required<SanitizeOptions> = {
...(DEFAULT_OPTIONS as Required<SanitizeOptions>),
...options,
};
let sanitized = content;
// Supprimer les patterns dangereux
@ -131,14 +134,14 @@ export function sanitizeHTML(
// Supprimer les tags non autorisés
if (config.stripUnknownTags) {
sanitized = stripUnknownTags(sanitized, config.allowedTags!);
sanitized = stripUnknownTags(sanitized, config.allowedTags);
}
// Nettoyer les attributs
sanitized = sanitizeAttributes(sanitized, config.allowedAttributes!);
sanitized = sanitizeAttributes(sanitized, config.allowedAttributes);
// Valider les URLs
sanitized = validateURLs(sanitized, config.allowedSchemes!);
sanitized = validateURLs(sanitized, config.allowedSchemes);
// Supprimer les tags vides
if (config.stripEmptyTags) {