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
16 changes: 16 additions & 0 deletions api/routers/seo.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,22 @@ async def seo_palette():
)


@router.get("/seo-proxy/map")
async def seo_map():
"""Bot-optimized network map page with correct og:tags."""
return HTMLResponse(
BOT_HTML_TEMPLATE.format(
title="Network Map | anyplot.ai",
description=(
"Interactive network map of plot specifications grouped by visual similarity — "
"explore relationships across all anyplot.ai chart types."
),
image=DEFAULT_HOME_IMAGE,
url="https://anyplot.ai/map",
)
)
Comment on lines +263 to +276


@router.get("/seo-proxy/stats")
async def seo_stats():
"""Bot-optimized stats page with correct og:tags."""
Expand Down
13 changes: 13 additions & 0 deletions app/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,18 @@ export default [
Blob: 'readonly',
File: 'readonly',
FileReader: 'readonly',
// Idle / animation callback APIs
requestIdleCallback: 'readonly',
cancelIdleCallback: 'readonly',
IdleRequestCallback: 'readonly',
IdleDeadline: 'readonly',
IdleRequestOptions: 'readonly',
IdleCallbackHandle: 'readonly',
FrameRequestCallback: 'readonly',
// IntersectionObserver type aliases
IntersectionObserverCallback: 'readonly',
IntersectionObserverInit: 'readonly',
IntersectionObserverEntry: 'readonly',
// React (for JSX runtime)
React: 'readonly',
},
Expand Down Expand Up @@ -102,6 +114,7 @@ export default [
beforeAll: 'readonly',
afterAll: 'readonly',
globalThis: 'readonly',
global: 'readonly',
},
},
},
Expand Down
11 changes: 7 additions & 4 deletions app/src/components/RelatedSpecs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,15 +47,18 @@ export function RelatedSpecs({ specId, mode = 'spec', library, onHoverTags }: Re
const [related, setRelated] = useState<RelatedSpec[]>([]);
const [loading, setLoading] = useState(true);
const [expanded, setExpanded] = useState(false);
const [prevDeps, setPrevDeps] = useState({ specId, mode, library });
const { isDark } = useTheme();

useEffect(() => {
setExpanded(false);
}, [specId]);
// React 19 "Adjusting state on prop change": runs during render, no cascading re-render.
if (prevDeps.specId !== specId || prevDeps.mode !== mode || prevDeps.library !== library) {
setPrevDeps({ specId, mode, library });
setLoading(true);
if (prevDeps.specId !== specId) setExpanded(false);
}

useEffect(() => {
let cancelled = false;
setLoading(true);
const params = new URLSearchParams({ limit: '24', mode });
if (library && mode === 'full') params.set('library', library);
fetch(`${API_URL}/insights/related/${specId}?${params}`)
Expand Down
8 changes: 5 additions & 3 deletions app/src/components/SpecDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,12 +132,14 @@ export function SpecDetailView({
};
}, [viewMode, updateScale]);

// Reset interactive size when switching library
useEffect(() => {
// Reset interactive size when switching library — React 19 "adjust state on prop change".
const [prevLibrary, setPrevLibrary] = useState(selectedLibrary);
if (prevLibrary !== selectedLibrary) {
setPrevLibrary(selectedLibrary);
setSizeReady(false);
setContentWidth(INITIAL_WIDTH);
setContentHeight(INITIAL_HEIGHT);
}, [selectedLibrary]);
}

const handleZoomToggle = useCallback(
(e: React.MouseEvent) => {
Expand Down
5 changes: 1 addition & 4 deletions app/src/components/SpecOverview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,7 @@ describe('SpecOverview', () => {
});
// SpecOverview checks `impl.preview_url` as truthy -> falsy string renders skeleton
const { container } = render(
<SpecOverview
{...defaultProps}
implementations={[makeImpl({ library_id: 'bokeh', preview_url: '' as unknown as string })]}
/>,
<SpecOverview {...defaultProps} implementations={[implNoPreview]} />,
);
const skeleton = container.querySelector('.MuiSkeleton-root');
expect(skeleton).toBeInTheDocument();
Expand Down
7 changes: 6 additions & 1 deletion app/src/hooks/useFeaturedSpecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import { shuffleArray } from '../utils/shuffle';
import { useAppData } from './useLayoutContext';


function pickRandom<T>(items: T[]): T {
return items[Math.floor(Math.random() * items.length)];
}


export interface FeaturedImpl {
spec_id: string;
spec_title: string;
Expand Down Expand Up @@ -60,7 +65,7 @@ export function useFeaturedSpecs(count: number = 5): FeaturedImpl[] | null {

return shuffled.map((spec) => {
const impls = imagesBySpec[spec.id];
const pick = impls[Math.floor(Math.random() * impls.length)];
const pick = pickRandom(impls);
return {
spec_id: spec.id,
spec_title: spec.title,
Expand Down
14 changes: 10 additions & 4 deletions app/src/pages/DebugPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -204,17 +204,23 @@ export function DebugPage() {
const [missingLibrary, setMissingLibrary] = useState('');

const [pings, setPings] = useState<Array<{ ms: number; ok: boolean }>>([]);
const [prevFetchKey, setPrevFetchKey] = useState({ adminToken, reloadCounter });

useEffect(() => {
// React 19 "adjust state on prop change": reset loading/error when fetch deps change.
if (prevFetchKey.adminToken !== adminToken || prevFetchKey.reloadCounter !== reloadCounter) {
setPrevFetchKey({ adminToken, reloadCounter });
setLoading(true);
setError(null);
}

useEffect(() => {
adminFetch(`${DEBUG_API_URL}/debug/status`, adminToken)
.then(async r => {
// Reaching here means the fetch promise resolved (the response may
// still be 401/403/503 — those are handled below). Clear the one-shot
// reload guard so a future cross-origin CF Access redirect can
// re-trigger the bootstrap.
try { sessionStorage.removeItem(RELOAD_GUARD_KEY); } catch {}
try { sessionStorage.removeItem(RELOAD_GUARD_KEY); } catch { /* sessionStorage may be unavailable in private mode */ }
// 403 is the Cloudflare Access JWT path's denial: a signed-in Google
// account that isn't on the admin_allowed_emails allow-list. Surface
// it on the auth-required screen with the server's message so the
Expand Down Expand Up @@ -242,9 +248,9 @@ export function DebugPage() {
// the second load ALSO fails (e.g. wrong allow-list).
if (e instanceof TypeError) {
let alreadyTried = false;
try { alreadyTried = !!sessionStorage.getItem(RELOAD_GUARD_KEY); } catch {}
try { alreadyTried = !!sessionStorage.getItem(RELOAD_GUARD_KEY); } catch { /* sessionStorage may be unavailable in private mode */ }
if (!alreadyTried) {
try { sessionStorage.setItem(RELOAD_GUARD_KEY, '1'); } catch {}
try { sessionStorage.setItem(RELOAD_GUARD_KEY, '1'); } catch { /* sessionStorage may be unavailable in private mode */ }
// replace() not assign() — assign would push the broken pre-auth
// /debug onto the back-stack, so the user could navigate back
// into the same loop after logging in.
Expand Down
1 change: 0 additions & 1 deletion app/src/pages/MapPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,6 @@ describe('MapPage', () => {
it('is a no-op when there are no nodes', () => {
const force = outlierSquashForce(0.95, 200, 0.18);
// initialize with empty array; force(alpha) must not throw.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(force as unknown as { initialize: (n: SimNode[]) => void }).initialize([]);
expect(() => force(1)).not.toThrow();
});
Expand Down
23 changes: 13 additions & 10 deletions app/src/pages/SpecsListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,14 @@ interface SpecListItem {
images: PlotImage[];
}

function initRotationIndices(specs: SpecListItem[]): Record<string, number> {
const init: Record<string, number> = {};
specs.forEach((spec) => {
init[spec.id] = Math.floor(Math.random() * spec.images.length);
});
return init;
}

export function SpecsListPage() {
const { specsData } = useAppData();
const { saveScrollPosition } = useHomeState();
Expand Down Expand Up @@ -95,16 +103,11 @@ export function SpecsListPage() {
return specs;
}, [allImages, specsData]);

// Initialize random rotation indices once specs are loaded
useEffect(() => {
if (specList.length > 0 && Object.keys(rotationIndex).length === 0) {
const initialIndices: Record<string, number> = {};
specList.forEach((spec) => {
initialIndices[spec.id] = Math.floor(Math.random() * spec.images.length);
});
setRotationIndex(initialIndices);
}
}, [specList, rotationIndex]);
// Initialize random rotation indices once specs are loaded — runs once because rotationIndex
// becomes non-empty after the first call, and stays non-empty.
if (specList.length > 0 && Object.keys(rotationIndex).length === 0) {
setRotationIndex(initRotationIndices(specList));
}

// Show/hide scroll-to-top button based on scroll position
useEffect(() => {
Expand Down
2 changes: 1 addition & 1 deletion core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ class Settings(BaseSettings):
# AI MODEL CONFIGURATION
# =============================================================================

claude_model: str = "claude-3-5-sonnet-20240620"
claude_model: str = "claude-sonnet-4-6"
"""Claude model to use for code generation and review"""

claude_max_tokens: int = 4000
Expand Down
6 changes: 5 additions & 1 deletion docs/reference/plausible.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,8 @@ carry `spec`, `library`, or `value` for richer breakdowns.
| `LCP` | `value`, `rating` | reportWebVitals.ts | Largest Contentful Paint (rounded to nearest 100ms) |
| `CLS` | `value`, `rating` | reportWebVitals.ts | Cumulative Layout Shift (2 decimal places) |
| `INP` | `value`, `rating` | reportWebVitals.ts | Interaction to Next Paint (rounded to nearest 50ms) |
| `FCP` | `value`, `rating` | reportWebVitals.ts | First Contentful Paint (rounded to nearest 100ms) |
| `TTFB` | `value`, `rating` | reportWebVitals.ts | Time to First Byte (rounded to nearest 100ms) |

**Rating values**: `good`, `needs-improvement`, `poor` (per web-vitals thresholds)

Expand Down Expand Up @@ -460,9 +462,11 @@ User lands on anyplot.ai
| `LCP` | `value`, `rating` | reportWebVitals.ts |
| `CLS` | `value`, `rating` | reportWebVitals.ts |
| `INP` | `value`, `rating` | reportWebVitals.ts |
| `FCP` | `value`, `rating` | reportWebVitals.ts |
| `TTFB` | `value`, `rating` | reportWebVitals.ts |
| `og_image_view` | `page`, `platform`, `spec`?, `language`?, `library`?, `filter_*`? | api/analytics.py (server-side) |

**Total: 28 client-side + 1 server-side = 29 events**
**Total: 30 client-side + 1 server-side = 31 events**

> Every pageview and event additionally carries a `theme` ambient prop (`dark` /
> `light`). Set in `RootLayout` via `setAnalyticsAmbientProps` whenever the user
Expand Down
Loading
Loading