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
15 changes: 15 additions & 0 deletions .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,21 @@ jobs:
working-directory: app
run: yarn install --frozen-lockfile

- name: Lint frontend
if: steps.check.outputs.should_test == 'true'
working-directory: app
run: yarn lint

- name: Check frontend formatting
if: steps.check.outputs.should_test == 'true'
working-directory: app
run: yarn fm:check

- name: Type-check frontend (app + tests)
if: steps.check.outputs.should_test == 'true'
working-directory: app
run: yarn type-check

- name: Run frontend tests with coverage
if: steps.check.outputs.should_test == 'true'
working-directory: app
Expand Down
6 changes: 6 additions & 0 deletions app/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
node_modules/
dist/
coverage/
yarn.lock
# Hand-tuned inline critical CSS + meta tags — keep formatting as-is
index.html
98 changes: 49 additions & 49 deletions app/cloudbuild.yaml
Original file line number Diff line number Diff line change
@@ -1,74 +1,74 @@
# Cloud Build configuration for anyplot Frontend
substitutions:
_REGION: "europe-west4"
_SERVICE_NAME: "anyplot-app"
_VITE_API_URL: "https://api.anyplot.ai"
_REGION: 'europe-west4'
_SERVICE_NAME: 'anyplot-app'
_VITE_API_URL: 'https://api.anyplot.ai'
# Only DebugPage uses this — same-origin via the Cloudflare Worker on
# anyplot.ai/api/*, so the CF Access cookie on anyplot.ai is sent with
# fetch (host-only cookies cannot cross subdomains). Other pages keep
# using VITE_API_URL directly because their endpoints are public and
# don't need the CF Access cookie.
_VITE_DEBUG_API_URL: "/api"
_VITE_DEBUG_API_URL: '/api'

steps:
# Build the container image
- name: "gcr.io/cloud-builders/docker"
- name: 'gcr.io/cloud-builders/docker'
args:
[
"build",
"-t",
"europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID",
"-t",
"europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:latest",
"--build-arg",
"VITE_API_URL=${_VITE_API_URL}",
"--build-arg",
"VITE_DEBUG_API_URL=${_VITE_DEBUG_API_URL}",
"-f",
"app/Dockerfile",
"app",
'build',
'-t',
'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID',
'-t',
'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:latest',
'--build-arg',
'VITE_API_URL=${_VITE_API_URL}',
'--build-arg',
'VITE_DEBUG_API_URL=${_VITE_DEBUG_API_URL}',
'-f',
'app/Dockerfile',
'app',
]

# Push the container image to Artifact Registry
- name: "gcr.io/cloud-builders/docker"
args: ["push", "--all-tags", "europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}"]
- name: 'gcr.io/cloud-builders/docker'
args: ['push', '--all-tags', 'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}']

# Deploy container image to Cloud Run
- name: "gcr.io/google.com/cloudsdktool/cloud-sdk"
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
entrypoint: gcloud
args:
- "run"
- "deploy"
- "${_SERVICE_NAME}"
- "--image"
- "europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID"
- "--region"
- "${_REGION}"
- "--platform"
- "managed"
- "--allow-unauthenticated"
- "--memory"
- "512Mi"
- "--cpu"
- "1"
- "--timeout"
- "60"
- "--min-instances"
- "1"
- "--max-instances"
- "3"
- "--port"
- "8080"
- "--execution-environment"
- "gen2"
- "--cpu-throttling"
- "--concurrency"
- "15"
- 'run'
- 'deploy'
- '${_SERVICE_NAME}'
- '--image'
- 'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID'
- '--region'
- '${_REGION}'
- '--platform'
- 'managed'
- '--allow-unauthenticated'
- '--memory'
- '512Mi'
- '--cpu'
- '1'
- '--timeout'
- '60'
- '--min-instances'
- '1'
- '--max-instances'
- '3'
- '--port'
- '8080'
- '--execution-environment'
- 'gen2'
- '--cpu-throttling'
- '--concurrency'
- '15'

# Store images in Artifact Registry
images:
- "europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID"
- "europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:latest"
- 'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:$BUILD_ID'
- 'europe-west4-docker.pkg.dev/$PROJECT_ID/anyplot/${_SERVICE_NAME}:latest'

options:
logging: CLOUD_LOGGING_ONLY
124 changes: 36 additions & 88 deletions app/eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import js from '@eslint/js';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';
import prettierConfig from 'eslint-config-prettier';
import perfectionist from 'eslint-plugin-perfectionist';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import unusedImports from 'eslint-plugin-unused-imports';
import globals from 'globals';

export default [
js.configs.recommended,
Expand All @@ -17,110 +21,54 @@ export default [
jsx: true,
},
},
globals: {
// Browser globals
window: 'readonly',
document: 'readonly',
navigator: 'readonly',
console: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
fetch: 'readonly',
URL: 'readonly',
URLSearchParams: 'readonly',
localStorage: 'readonly',
sessionStorage: 'readonly',
Storage: 'readonly',
history: 'readonly',
location: 'readonly',
requestAnimationFrame: 'readonly',
cancelAnimationFrame: 'readonly',
performance: 'readonly',
crypto: 'readonly',
// DOM types
HTMLElement: 'readonly',
HTMLDivElement: 'readonly',
HTMLInputElement: 'readonly',
HTMLSelectElement: 'readonly',
HTMLButtonElement: 'readonly',
HTMLAnchorElement: 'readonly',
HTMLImageElement: 'readonly',
HTMLCanvasElement: 'readonly',
HTMLIFrameElement: 'readonly',
Element: 'readonly',
Node: 'readonly',
NodeList: 'readonly',
// Events
MouseEvent: 'readonly',
KeyboardEvent: 'readonly',
TouchEvent: 'readonly',
ClipboardEvent: 'readonly',
Event: 'readonly',
MessageEvent: 'readonly',
MediaQueryListEvent: 'readonly',
// APIs
AbortController: 'readonly',
AbortSignal: 'readonly',
RequestInit: 'readonly',
Response: 'readonly',
ResizeObserver: 'readonly',
IntersectionObserver: 'readonly',
MutationObserver: 'readonly',
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',
},
globals: globals.browser,
},
plugins: {
'@typescript-eslint': tseslint,
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
perfectionist,
'unused-imports': unusedImports,
},
rules: {
...tseslint.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
'no-unused-vars': 'off',
// TypeScript reports undefined identifiers itself (incl. DOM lib types);
// with no-undef active every DOM global would need hand-listing here.
'no-undef': 'off',
// Allow ref updates during render (common pattern for keeping refs in sync)
'react-hooks/refs': 'off',
'unused-imports/no-unused-imports': 'error',
'perfectionist/sort-imports': [
'error',
{
type: 'natural',
newlinesBetween: 1,
internalPattern: ['^src/'],
groups: [
'side-effect-style',
'side-effect',
'react',
['builtin', 'external'],
'mui',
'internal',
['parent', 'sibling', 'index'],
'unknown',
],
customGroups: [
{ groupName: 'react', elementNamePattern: ['^react$', '^react-dom'] },
{ groupName: 'mui', elementNamePattern: ['^@mui/'] },
],
},
],
'perfectionist/sort-named-imports': ['error', { type: 'natural' }],
},
},
{
files: ['src/**/*.test.{ts,tsx}'],
languageOptions: {
globals: {
// Vitest globals
describe: 'readonly',
it: 'readonly',
expect: 'readonly',
vi: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
globalThis: 'readonly',
global: 'readonly',
},
},
},
{
ignores: ['dist/**', 'node_modules/**', '*.config.js'],
ignores: ['dist/**', 'node_modules/**', 'coverage/**', '*.config.js', '*.config.mjs'],
},
prettierConfig,
];
12 changes: 11 additions & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src",
"type-check": "tsc --noEmit",
"lint:fix": "eslint src --fix",
"fm:check": "prettier --check .",
"fm:fix": "prettier --write .",
"fix:all": "yarn lint:fix && yarn fm:fix",
"type-check": "tsc --noEmit && tsc -p tsconfig.test.json --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
Expand Down Expand Up @@ -46,11 +50,17 @@
"@vitejs/plugin-react-swc": "^4.3.1",
"@vitest/coverage-v8": "^4.1.8",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-perfectionist": "^5.9.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"eslint-plugin-unused-imports": "^4.4.1",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"prettier": "^3.8.4",
"typescript": "^6.0.3",
"vite": "^8.0.16",
"vite-plugin-checker": "^0.14.1",
"vite-plugin-compression2": "^2.5.3",
"vitest": "^4.1.8"
}
Expand Down
13 changes: 13 additions & 0 deletions app/prettier.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Prettier configuration for the anyplot frontend.
* Values match the predominant existing style to minimize reformat churn.
*/
export default {
printWidth: 100,
tabWidth: 2,
singleQuote: true,
semi: true,
trailingComma: 'es5',
arrowParens: 'avoid',
endOfLine: 'lf',
};
17 changes: 11 additions & 6 deletions app/src/analytics/reportWebVitals.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { reportWebVitals } from './reportWebVitals';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { setAnalyticsAmbientProps } from '../hooks/useAnalytics';
import { reportWebVitals } from './reportWebVitals';

// Single hoisted mock (vi.mock dedupes by module path — last call wins, so
// keeping one shared mock avoids cross-test interference).
vi.mock('web-vitals', () => ({
onLCP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 2500, rating: 'good' }),
onCLS: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 0.15, rating: 'needs-improvement' }),
onLCP: (cb: (m: { value: number; rating: string }) => void) =>
cb({ value: 2500, rating: 'good' }),
onCLS: (cb: (m: { value: number; rating: string }) => void) =>
cb({ value: 0.15, rating: 'needs-improvement' }),
onINP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 200, rating: 'good' }),
onFCP: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 1200, rating: 'good' }),
onTTFB: (cb: (m: { value: number; rating: string }) => void) => cb({ value: 400, rating: 'good' }),
onFCP: (cb: (m: { value: number; rating: string }) => void) =>
cb({ value: 1200, rating: 'good' }),
onTTFB: (cb: (m: { value: number; rating: string }) => void) =>
cb({ value: 400, rating: 'good' }),
}));

describe('reportWebVitals', () => {
Expand Down
Loading
Loading