Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,11 @@ const CreatorCard: React.FC<CreatorCardProps> = ({
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-transparent opacity-0 transition-opacity duration-300 md:group-hover:opacity-100" />
{creator.volume24h !== undefined && (
<div className="absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-slate-950/75 border border-white/10 px-2.5 py-1 backdrop-blur-md">
// #313: the .creator-card-overlay-text class swaps this
// pill to system high-contrast tokens (Canvas / CanvasText
// / ButtonBorder) when forced-colors mode is active so
// the text stays legible over the image overlay.
<div className="creator-card-overlay-text absolute right-3 top-3 z-10 flex items-center gap-1.5 rounded-full bg-slate-950/75 border border-white/10 px-2.5 py-1 backdrop-blur-md">
<TrendingUp className="creator-action-icon text-emerald-400" />
<span className="text-xs font-bold text-white/90">
{creator.volume24h > 0
Expand Down
66 changes: 66 additions & 0 deletions src/components/common/StaleDataWarning.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { formatStaleAge } from '@/utils/staleData.utils';

interface StaleDataWarningProps {
/** Whether the warning should be shown. */
stale: boolean;
/**
* Milliseconds since the data was fetched — used to surface the
* exact "Updated N ago" label when the warning is visible.
*/
ageMs?: number;
/**
* Custom copy override. When omitted the warning falls back to the
* standard "Stats may be out of date. Refreshing…" wording plus the
* formatted age.
*/
message?: string;
className?: string;
}

/**
* Subtle inline warning shown when creator data is stale (#301).
*
* Visual treatment: a small amber pill with an icon, sized so it slots
* into a stat row without pushing other content around. The component
* returns `null` when `stale` is false so callers can render it
* unconditionally.
*
* Accessibility: `role="status" aria-live="polite"` so assistive tech
* announces the warning the moment it appears (and again when it
* disappears, by way of the surrounding state change). The icon is
* `aria-hidden` because the textual message already conveys the same
* meaning.
*/
const StaleDataWarning: React.FC<StaleDataWarningProps> = ({
stale,
ageMs,
message,
className,
}) => {
if (!stale) return null;

const ageLabel =
ageMs != null && isFinite(ageMs) ? ` · ${formatStaleAge(ageMs)}` : '';
const copy = message ?? 'Stats may be out of date. Refreshing…';

return (
<p
role="status"
aria-live="polite"
className={cn(
'inline-flex items-center gap-1.5 rounded-full border border-amber-500/25 bg-amber-500/10 px-2.5 py-1 text-[0.7rem] font-medium text-amber-200',
className
)}
>
<AlertTriangle className="size-3 text-amber-300" aria-hidden="true" />
<span>
{copy}
{ageLabel}
</span>
</p>
);
};

export default StaleDataWarning;
43 changes: 43 additions & 0 deletions src/components/common/__tests__/StaleDataWarning.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import StaleDataWarning from '@/components/common/StaleDataWarning';

describe('StaleDataWarning (#301)', () => {
it('renders nothing when not stale', () => {
const { container } = render(<StaleDataWarning stale={false} />);
expect(container).toBeEmptyDOMElement();
});

it('renders the standard copy when stale with no ageMs', () => {
render(<StaleDataWarning stale={true} />);
expect(
screen.getByText(/Stats may be out of date\. Refreshing…/)
).toBeInTheDocument();
});

it('appends the formatted age when ageMs is provided', () => {
render(<StaleDataWarning stale={true} ageMs={180_000} />);
expect(
screen.getByText(/Updated 3 min ago/)
).toBeInTheDocument();
});

it('uses status role + polite live region for assistive tech', () => {
render(<StaleDataWarning stale={true} ageMs={2_000} />);
const status = screen.getByRole('status');
expect(status).toHaveAttribute('aria-live', 'polite');
});

it('respects a custom message override', () => {
render(
<StaleDataWarning
stale={true}
ageMs={5_000}
message="Custom stale copy"
/>
);
expect(screen.getByText(/Custom stale copy/)).toBeInTheDocument();
// The age suffix is still appended.
expect(screen.getByRole('status').textContent).toMatch(/Custom stale copy/);
});
});
115 changes: 115 additions & 0 deletions src/hooks/__tests__/useStaleData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { useStaleData } from '@/hooks/useStaleData';

describe('useStaleData', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-28T00:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});

it('reports the data as fresh when lastFetchedAt is within the window', () => {
const now = Date.now();
const { result } = renderHook(() =>
useStaleData(now - 10_000, { thresholdMs: 60_000, autoEvaluate: false })
);
expect(result.current.stale).toBe(false);
expect(result.current.ageMs).toBe(10_000);
});

it('reports the data as stale once the timestamp crosses the threshold', () => {
const now = Date.now();
const { result } = renderHook(() =>
useStaleData(now - 60_000, { thresholdMs: 60_000, autoEvaluate: false })
);
expect(result.current.stale).toBe(true);
});

it('fires onStale exactly once per lastFetchedAt epoch', () => {
const onStale = vi.fn();
const now = Date.now();

const { rerender } = renderHook(
({ ts }) =>
useStaleData(ts, {
thresholdMs: 60_000,
autoEvaluate: false,
onStale,
}),
{ initialProps: { ts: now - 90_000 } }
);

// Stale from the get-go → fires once.
expect(onStale).toHaveBeenCalledTimes(1);

// Re-render with the SAME timestamp: must not fire again.
rerender({ ts: now - 90_000 });
expect(onStale).toHaveBeenCalledTimes(1);

// New fetch baseline (fresh again) → no additional fire.
rerender({ ts: now });
expect(onStale).toHaveBeenCalledTimes(1);

// And when THAT new epoch goes stale, we fire one more time.
rerender({ ts: now - 90_000 });
expect(onStale).toHaveBeenCalledTimes(2);
});

it('does not fire onStale when the data is fresh', () => {
const onStale = vi.fn();
renderHook(() =>
useStaleData(Date.now() - 5_000, {
thresholdMs: 60_000,
autoEvaluate: false,
onStale,
})
);
expect(onStale).not.toHaveBeenCalled();
});

it('auto-evaluates exactly at the staleness boundary', () => {
const onStale = vi.fn();
const ts = Date.now() - 10_000; // 50s of headroom on a 60s window
const { result } = renderHook(() =>
useStaleData(ts, { thresholdMs: 60_000, onStale })
);
expect(result.current.stale).toBe(false);

act(() => {
vi.advanceTimersByTime(50_000);
});
expect(result.current.stale).toBe(true);
expect(onStale).toHaveBeenCalledTimes(1);
});

it('treats a null/undefined timestamp as stale', () => {
const onStale = vi.fn();
const { result } = renderHook(() =>
useStaleData(null, {
thresholdMs: 60_000,
autoEvaluate: false,
onStale,
})
);
expect(result.current.stale).toBe(true);
expect(onStale).toHaveBeenCalledTimes(1);
});

it('exposes a revalidate() escape hatch that re-evaluates without changing inputs', () => {
const ts = Date.now() - 30_000;
const { result } = renderHook(() =>
useStaleData(ts, { thresholdMs: 60_000, autoEvaluate: false })
);
expect(result.current.stale).toBe(false);

// Advance the wall clock without auto-eval scheduling, then revalidate.
vi.setSystemTime(new Date(Date.now() + 60_000));
act(() => {
result.current.revalidate();
});
expect(result.current.stale).toBe(true);
});
});
95 changes: 95 additions & 0 deletions src/hooks/useStaleData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { useEffect, useMemo, useState } from 'react';
import {
DEFAULT_STALE_THRESHOLD_MS,
isStale,
type StaleDataResult,
} from '@/utils/staleData.utils';

export interface UseStaleDataOptions {
/** Override the default 60s freshness window. */
thresholdMs?: number;
/**
* Called when the data first crosses the staleness boundary.
* Production callers wire this to a background refresh; tests pass
* a spy.
*/
onStale?: () => void;
/**
* If `true` (the default), schedule a `setTimeout` to re-evaluate
* staleness exactly when the threshold expires so the warning shows
* without waiting for an external re-render.
*/
autoEvaluate?: boolean;
}

export interface UseStaleDataReturn extends StaleDataResult {
/** Force-recompute now (useful right after a refetch resolves). */
revalidate: () => void;
}

/**
* Track whether the caller-supplied `lastFetchedAt` is past the staleness
* threshold (#301). When the timestamp crosses the boundary, the hook
* fires `onStale` exactly once until `lastFetchedAt` changes — so wiring
* it to a `refetch` callback drives a background refresh without
* thrashing.
*/
export const useStaleData = (
lastFetchedAt: number | null | undefined,
options: UseStaleDataOptions = {}
): UseStaleDataReturn => {
const {
thresholdMs = DEFAULT_STALE_THRESHOLD_MS,
onStale,
autoEvaluate = true,
} = options;

const [tick, setTick] = useState(0);

const result = useMemo(
() => isStale(lastFetchedAt, thresholdMs),
// `tick` is intentionally part of the dep array so a `setTick`
// re-evaluates the result without changing `lastFetchedAt`.
// eslint-disable-next-line react-hooks/exhaustive-deps
[lastFetchedAt, thresholdMs, tick]
);

// Drive `onStale` exactly once per (lastFetchedAt, stale-transition)
// pair. When the data becomes fresh again the latch resets so the next
// stale transition fires once more — a flow that re-fetches and goes
// stale repeatedly will see one fire per stale transition, not one
// fire ever.
const [hasFiredForCurrentEpoch, setHasFiredForCurrentEpoch] =
useState(false);
useEffect(() => {
if (!result.stale) {
// Becoming fresh resets the latch so the next stale transition
// can fire `onStale` again.
if (hasFiredForCurrentEpoch) {
setHasFiredForCurrentEpoch(false);
}
return;
}
if (hasFiredForCurrentEpoch) return;
onStale?.();
setHasFiredForCurrentEpoch(true);
// `hasFiredForCurrentEpoch` is intentionally checked but not
// listed: we never want a state change *here* to re-trigger the
// effect — only the `stale` transition does.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [result.stale, lastFetchedAt]);

// Schedule a re-check at the precise moment the data goes stale.
useEffect(() => {
if (!autoEvaluate || result.stale || result.msUntilStale <= 0) return;
const id = window.setTimeout(
() => setTick(t => t + 1),
result.msUntilStale
);
return () => window.clearTimeout(id);
}, [autoEvaluate, result.stale, result.msUntilStale]);

const revalidate = () => setTick(t => t + 1);

return { ...result, revalidate };
};
59 changes: 59 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,62 @@
background-position: -100% 0;
}
}

/*
* Creator-card entry animation (#300).
*
* The card opts in by applying `.creator-card-entry` and inline-setting
* `--creator-card-entry-delay` (the helper at
* `src/utils/cardEntryAnimation.utils.ts` does this). When the
* `prefers-reduced-motion` media query is active, the animation is
* suppressed but the card still ends up at the same visual final state
* (no transform, no opacity reduction) because the keyframe end-state
* and the default rendered state are equivalent.
*/
@keyframes creator-card-entry {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

.creator-card-entry {
animation: creator-card-entry 220ms ease-out both;
animation-delay: var(--creator-card-entry-delay, 0ms);
}

@media (prefers-reduced-motion: reduce) {
.creator-card-entry {
animation: none;
}
}

/*
* High-contrast / forced-colors overlay text guardrails (#313).
*
* The image-overlay pills on the creator card render text over a
* semi-transparent surface that loses contrast in forced-colors mode
* (Windows High Contrast). When forced-colors is active we drop the
* semi-transparent backdrop and pin the text + background to the
* system tokens (`Canvas`, `CanvasText`, `ButtonBorder`) which are
* guaranteed to meet the WCAG contrast ratio for the active high-
* contrast theme.
*
* Scoped to the overlay pill (`.creator-card-overlay-text` class
* applied on the relevant element) so non-overlay text is not touched.
*/
@media (forced-colors: active) {
.creator-card-overlay-text {
background: Canvas !important;
color: CanvasText !important;
border: 1px solid ButtonBorder !important;
backdrop-filter: none !important;
}
.creator-card-overlay-text * {
color: CanvasText !important;
}
}
Loading
Loading