diff --git a/README.md b/README.md index 35bff04..ecf8f07 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,9 @@ _Search a roof. Tweak the layout. Get a NEM-accurate savings report. As easy as > [!NOTE] > SolarSim is an **assessment** tool. It produces an estimate report, not a quotation, not a contract, and not an installation order. Final pricing and feasibility always come from a licensed Malaysian installer. +> [!WARNING] +> **The live deployment is currently offline.** The Supabase project backing `solarsim.tech` is paused pending a database provider migration (tracked in open issues). Everything below still describes the shipped product — clone it and run the local quickstart to see it working. + --- ## ✨ At a Glance @@ -301,7 +304,7 @@ The production stack is **two services**: a Heroku web dyno (frontend bundle + E > [!TIP] > The full deploy walkthrough — `heroku create`, every config var, custom domain attachment, Vercel link, CI/CD secrets, and post-deploy smoke tests — lives in **[RUNBOOK.md §7-§10](RUNBOOK.md)**. It's the canonical guide; this section is a one-screen reference for maintainers who already deployed once and just need a refresher. -**Live deployment:** +**Deployment Architecture:** - Frontend + API: (Heroku dyno behind custom domain) - PDF render function: Vercel Hobby tier (URL set via `PDF_EXPORT_URL`) @@ -332,6 +335,15 @@ The production stack is **two services**: a Heroku web dyno (frontend bundle + E --- +## 🧭 Known Limitations + +- **Panel placement is approximate.** The Google Solar API derives suggested panel positions from flux heuristics, not true roof-edge segmentation, so a layout occasionally doesn't align cleanly with the actual roof. This is a permanent trade-off rather than an open bug — closing the gap properly would take ML-based roof segmentation or constrained re-optimisation against the roof mask, both research-grade efforts outside this project's scope. The Workbench's drag-and-snap editing exists precisely for this: move a panel and it snaps flush against its neighbours. +- **Solar API coverage is uneven across Malaysia.** Some addresses, including parts of the Klang Valley, have thin or missing `HIGH`-quality imagery. SolarSim probes for the best available quality before committing to a location and falls back to `BASE` imagery with expanded coverage where possible, surfaced in the UI as an amber "Imagery: BASE" badge — lower-resolution imagery means less precise flux sampling and panel placement. +- **NEM billing is an estimate, not a utility quote.** The billing engine simulates NEM Rakyat 3.0 self-consumption and export against seeded TNB RP4 tariffs (with EEI, AFA, SST, and RE Fund adjustments). Multi-year Lifecycle projections apply a configurable tariff escalation rate that defaults to 0% — future TNB rate revisions aren't predicted, only modelled if you choose to set one. +- **Mobile and touch testing is deferred.** The Konva canvas, sidebar, and panel drawer have been verified on desktop and browser devtools emulation, not on real mobile devices. Tracked as an open item. + +--- + ## 👤 Developer diff --git a/RUNBOOK.md b/RUNBOOK.md index f796b87..8d077de 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -77,14 +77,14 @@ vercel login Create these accounts before provisioning anything: -| Provider | Purpose | Signup URL | -| --- | --- | --- | -| Supabase | Postgres, Auth, Storage | https://supabase.com | +| Provider | Purpose | Signup URL | +| ------------ | ---------------------------------------------- | -------------------------------- | +| Supabase | Postgres, Auth, Storage | https://supabase.com | | Google Cloud | Solar API, Maps API, OAuth, optional Vertex AI | https://console.cloud.google.com | -| Resend | Transactional email | https://resend.com | -| Heroku | Backend deploy | https://heroku.com | -| Vercel | PDF render function | https://vercel.com | -| Porkbun | Domain registration / DNS | https://porkbun.com | +| Resend | Transactional email | https://resend.com | +| Heroku | Backend deploy | https://heroku.com | +| Vercel | PDF render function | https://vercel.com | +| Porkbun | Domain registration / DNS | https://porkbun.com | ## 2. Clone and install @@ -162,11 +162,11 @@ values ('geotiffs', 'geotiffs', false); Create a Resend API key, then verify your sender domain before testing with real users. For SolarSim, use `solarsim.tech` and send from `noreply@solarsim.tech`. -| Type | Host | Value | TTL | -| --- | --- | --- | --- | -| TXT | `send` | `v=spf1 include:amazonses.com ~all` | 600 | -| TXT | `resend._domainkey` | DKIM public key from Resend | 600 | -| MX | `send` | `feedback-smtp..amazonses.com` (priority 10) | 600 | +| Type | Host | Value | TTL | +| ---- | ------------------- | ---------------------------------------------------- | --- | +| TXT | `send` | `v=spf1 include:amazonses.com ~all` | 600 | +| TXT | `resend._domainkey` | DKIM public key from Resend | 600 | +| MX | `send` | `feedback-smtp..amazonses.com` (priority 10) | 600 | [!WARNING] Porkbun auto-appends the domain suffix. Enter `send`, not `send.solarsim.tech`, or you will create the wrong record. For the apex, use ALIAS/ANAME if the registrar supports it; do not try to force a CNAME at the root. @@ -188,13 +188,13 @@ heroku certs:auto:enable -a solar-layout-generator Porkbun DNS records should end up like this: -| Record | Host | Value | -| --- | --- | --- | -| ALIAS (or ANAME) | apex / blank | `.herokudns.com` | -| CNAME | `www` | `.herokudns.com` | -| TXT | `send` | Resend SPF record | -| TXT | `resend._domainkey` | Resend DKIM record | -| MX | `send` | Resend bounce MX record | +| Record | Host | Value | +| ---------------- | ------------------- | ----------------------------- | +| ALIAS (or ANAME) | apex / blank | `.herokudns.com` | +| CNAME | `www` | `.herokudns.com` | +| TXT | `send` | Resend SPF record | +| TXT | `resend._domainkey` | Resend DKIM record | +| MX | `send` | Resend bounce MX record | [!NOTE] Keep the Heroku DNS targets separate from the Resend sender records. They live on different subdomains and do not conflict. @@ -205,34 +205,34 @@ Keep the Heroku DNS targets separate from the Resend sender records. They live o Set the root `.env` from the table below. `VITE_*` variables are baked into the frontend bundle at build time, so they must be correct before the first Heroku deploy. -| Variable | Where the value comes from | What breaks if missing | -| --- | --- | --- | -| `GOOGLE_API_KEY` | GCP API key restricted to Solar, Maps, and Geocoding APIs | Roof lookup, map tiles, and Solar API calls fail | -| `VITE_GOOGLE_API_KEY` | `GOOGLE_API_KEY` via dotenv-expand | Frontend Google Maps JS loader fails | -| `GOOGLE_CLOUD_PROJECT` | GCP project ID, e.g. `solar-layout-generator` | Vertex AI chat path cannot start | -| `GOOGLE_CLOUD_LOCATION` | Usually `global` | Vertex AI requests target the wrong region | -| `GEMINI_API_KEY` | Google AI Studio API key | Sol has no fallback chat auth | -| `CHAT_MODEL` | Gemini model name | Chat boots with the wrong or default model | -| `GOOGLE_OAUTH_CLIENT_ID` | GCP OAuth client | Google sign-in fails | -| `GOOGLE_OAUTH_SECRET` | Same OAuth client | Google sign-in fails | -| `SUPABASE_URL` | Supabase Settings -> API | Auth, storage, and backend client creation fail | -| `SUPABASE_ANON_KEY` | Supabase Settings -> API | Frontend Supabase client cannot sign in | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Settings -> API | Backend cannot read/write privileged data | -| `SUPABASE_DATABASE_URL` | Supabase Settings -> Database | Prisma migrations and runtime DB access fail | -| `VITE_SUPABASE_URL` | `SUPABASE_URL` via dotenv-expand | Frontend Supabase client cannot boot | -| `VITE_SUPABASE_ANON_KEY` | `SUPABASE_ANON_KEY` via dotenv-expand | Frontend auth fails | -| `SITE_URL` | Final public origin, local or production | Supabase redirect links point to the wrong place | -| `APEX_DOMAIN` | `solarsim.tech` in production, blank elsewhere | Apex redirect middleware does not activate | -| `BACKEND_PORT` | Local dev only, usually `3001` | Local backend starts on the wrong port | -| `FRONTEND_PORT` | Local dev only, usually `5173` | Local frontend starts on the wrong port | -| `FRONTEND_URL` | Local `http://localhost:5173`, prod `https://solarsim.tech` | Backend CORS rejects browser requests | -| `PDF_TOKEN_SECRET` | `openssl rand -hex 32` | PDF token signing and verification fail | -| `PDF_EXPORT_URL` | Vercel PDF function URL | Analysis page export button points nowhere | -| `VITE_PDF_EXPORT_URL` | `PDF_EXPORT_URL` via dotenv-expand | Frontend bundle cannot call the PDF service | -| `RESEND_API_KEY` | Resend API key | Supabase auth email delivery fails | -| `PORT` | Heroku runtime injection | Do not set manually; Heroku supplies it | -| `NODE_ENV` | `production` on Heroku, `development` locally | Redirect middleware and prod behavior diverge | -| `ALLOWED_FRONTEND_ORIGIN` | Vercel env var for the PDF service | PDF function rejects browser requests | +| Variable | Where the value comes from | What breaks if missing | +| --------------------------- | ----------------------------------------------------------- | ------------------------------------------------ | +| `GOOGLE_API_KEY` | GCP API key restricted to Solar, Maps, and Geocoding APIs | Roof lookup, map tiles, and Solar API calls fail | +| `VITE_GOOGLE_API_KEY` | `GOOGLE_API_KEY` via dotenv-expand | Frontend Google Maps JS loader fails | +| `GOOGLE_CLOUD_PROJECT` | GCP project ID, e.g. `solar-layout-generator` | Vertex AI chat path cannot start | +| `GOOGLE_CLOUD_LOCATION` | Usually `global` | Vertex AI requests target the wrong region | +| `GEMINI_API_KEY` | Google AI Studio API key | Sol has no fallback chat auth | +| `CHAT_MODEL` | Gemini model name | Chat boots with the wrong or default model | +| `GOOGLE_OAUTH_CLIENT_ID` | GCP OAuth client | Google sign-in fails | +| `GOOGLE_OAUTH_SECRET` | Same OAuth client | Google sign-in fails | +| `SUPABASE_URL` | Supabase Settings -> API | Auth, storage, and backend client creation fail | +| `SUPABASE_ANON_KEY` | Supabase Settings -> API | Frontend Supabase client cannot sign in | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase Settings -> API | Backend cannot read/write privileged data | +| `SUPABASE_DATABASE_URL` | Supabase Settings -> Database | Prisma migrations and runtime DB access fail | +| `VITE_SUPABASE_URL` | `SUPABASE_URL` via dotenv-expand | Frontend Supabase client cannot boot | +| `VITE_SUPABASE_ANON_KEY` | `SUPABASE_ANON_KEY` via dotenv-expand | Frontend auth fails | +| `SITE_URL` | Final public origin, local or production | Supabase redirect links point to the wrong place | +| `APEX_DOMAIN` | `solarsim.tech` in production, blank elsewhere | Apex redirect middleware does not activate | +| `BACKEND_PORT` | Local dev only, usually `3001` | Local backend starts on the wrong port | +| `FRONTEND_PORT` | Local dev only, usually `5173` | Local frontend starts on the wrong port | +| `FRONTEND_URL` | Local `http://localhost:5173`, prod `https://solarsim.tech` | Backend CORS rejects browser requests | +| `PDF_TOKEN_SECRET` | `openssl rand -hex 32` | PDF token signing and verification fail | +| `PDF_EXPORT_URL` | Vercel PDF function URL | Analysis page export button points nowhere | +| `VITE_PDF_EXPORT_URL` | `PDF_EXPORT_URL` via dotenv-expand | Frontend bundle cannot call the PDF service | +| `RESEND_API_KEY` | Resend API key | Supabase auth email delivery fails | +| `PORT` | Heroku runtime injection | Do not set manually; Heroku supplies it | +| `NODE_ENV` | `production` on Heroku, `development` locally | Redirect middleware and prod behavior diverge | +| `ALLOWED_FRONTEND_ORIGIN` | Vercel env var for the PDF service | PDF function rejects browser requests | [!NOTE] The repo also contains commented Supabase config hooks for optional keys such as `OPENAI_API_KEY` and S3-related variables. They are not needed for the SolarSim production path described here. @@ -443,7 +443,7 @@ heroku certs:auto -a solar-layout-generator If renewal ever fails, inspect the DNS targets first and make sure the ACME challenge path is not being redirected away from Heroku. -### 11.5 Rebuilding after VITE_* env var changes (empty commit trick) +### 11.5 Rebuilding after VITE\_\* env var changes (empty commit trick) Any change to `VITE_GOOGLE_API_KEY`, `VITE_SUPABASE_URL`, `VITE_SUPABASE_ANON_KEY`, or `VITE_PDF_EXPORT_URL` requires a fresh frontend build. The fastest safe path is an empty commit: @@ -457,23 +457,23 @@ If you skip the rebuild, Heroku will keep serving the old bundle and the browser ## 12. Troubleshooting -| Symptom | Cause | Fix | -| --- | --- | --- | -| Signup succeeds, but no email arrives | Resend domain unverified or `RESEND_API_KEY` invalid | Check Resend logs, confirm the SPF/DKIM/MX records, then re-run `supabase config push` | -| Resend returns `403 Testing domain restriction` | Still using `onboarding@resend.dev` for arbitrary recipients | Verify `solarsim.tech` in Resend and switch `admin_email` to `noreply@solarsim.tech` | -| `supabase config push` shows an empty SMTP password | `.env` was not sourced before the push | Re-run `set -a && source .env && set +a && yes | supabase config push` | -| Local backend refuses to boot with a chat auth error | Neither `GEMINI_API_KEY` nor `GOOGLE_CLOUD_PROJECT` is set | Add at least one of them to `.env` | -| Heroku app loads at `herokuapp.com` but not the custom domain | DNS targets are wrong or TLS is still provisioning | Re-check `heroku domains -a ...`, then update Porkbun and wait for ACM | -| Requests bounce between `www.solarsim.tech` and the apex forever | `APEX_DOMAIN`, `SITE_URL`, or `FRONTEND_URL` do not match the canonical host | Set all three to `https://solarsim.tech` and redeploy | -| PDF export returns `500 Server misconfigured: ALLOWED_FRONTEND_ORIGIN unset` | Vercel env var missing | Set `ALLOWED_FRONTEND_ORIGIN` with `vercel env add` and redeploy | -| PDF export returns `403 CORS rejected` | Frontend origin in Vercel does not exactly match the browser origin | Re-set `ALLOWED_FRONTEND_ORIGIN` to the exact production origin, no trailing slash | -| PDF export button still points at the placeholder URL | `PDF_EXPORT_URL` changed after the frontend build | Update Heroku config and trigger the empty-commit rebuild | -| Chat returns `409` immediately | The project location is still processing | Wait for the location to finish resolving before opening chat | -| Chat ends with `service_unavailable` | Gemini or Vertex AI stayed on `503` after retries | Retry later; the backend already exhausted its retries | -| Heroku release fails on migration | A Prisma migration is broken or the DB URL is wrong | Inspect `heroku logs --tail`, fix the migration locally, then redeploy | -| OAuth sign-in redirects to localhost in production | `SITE_URL` or Supabase redirect config is stale | Update `.env`, push Supabase config, then redeploy Heroku | -| Sign-in works locally but not on Heroku | `FRONTEND_URL` is not the production origin | Set `FRONTEND_URL=https://solarsim.tech` and redeploy | -| `dig` shows no DNS change after updating Porkbun | DNS propagation lag or record typed incorrectly | Re-check the host fields, then wait and query again | +| Symptom | Cause | Fix | +| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| Signup succeeds, but no email arrives | Resend domain unverified or `RESEND_API_KEY` invalid | Check Resend logs, confirm the SPF/DKIM/MX records, then re-run `supabase config push` | +| Resend returns `403 Testing domain restriction` | Still using `onboarding@resend.dev` for arbitrary recipients | Verify `solarsim.tech` in Resend and switch `admin_email` to `noreply@solarsim.tech` | +| `supabase config push` shows an empty SMTP password | `.env` was not sourced before the push | Re-run `set -a && source .env && set +a && yes \| supabase config push` | +| Local backend refuses to boot with a chat auth error | Neither `GEMINI_API_KEY` nor `GOOGLE_CLOUD_PROJECT` is set | Add at least one of them to `.env` | +| Heroku app loads at `herokuapp.com` but not the custom domain | DNS targets are wrong or TLS is still provisioning | Re-check `heroku domains -a ...`, then update Porkbun and wait for ACM | +| Requests bounce between `www.solarsim.tech` and the apex forever | `APEX_DOMAIN`, `SITE_URL`, or `FRONTEND_URL` do not match the canonical host | Set all three to `https://solarsim.tech` and redeploy | +| PDF export returns `500 Server misconfigured: ALLOWED_FRONTEND_ORIGIN unset` | Vercel env var missing | Set `ALLOWED_FRONTEND_ORIGIN` with `vercel env add` and redeploy | +| PDF export returns `403 CORS rejected` | Frontend origin in Vercel does not exactly match the browser origin | Re-set `ALLOWED_FRONTEND_ORIGIN` to the exact production origin, no trailing slash | +| PDF export button still points at the placeholder URL | `PDF_EXPORT_URL` changed after the frontend build | Update Heroku config and trigger the empty-commit rebuild | +| Chat returns `409` immediately | The project location is still processing | Wait for the location to finish resolving before opening chat | +| Chat ends with `service_unavailable` | Gemini or Vertex AI stayed on `503` after retries | Retry later; the backend already exhausted its retries | +| Heroku release fails on migration | A Prisma migration is broken or the DB URL is wrong | Inspect `heroku logs --tail`, fix the migration locally, then redeploy | +| OAuth sign-in redirects to localhost in production | `SITE_URL` or Supabase redirect config is stale | Update `.env`, push Supabase config, then redeploy Heroku | +| Sign-in works locally but not on Heroku | `FRONTEND_URL` is not the production origin | Set `FRONTEND_URL=https://solarsim.tech` and redeploy | +| `dig` shows no DNS change after updating Porkbun | DNS propagation lag or record typed incorrectly | Re-check the host fields, then wait and query again | ## 13. Tearing down @@ -496,45 +496,45 @@ Supabase project deletion is still dashboard-only. After deleting the hosted pro ## Appendix A: env var reference card -| Variable | Source | Notes | -| --- | --- | --- | -| `GOOGLE_API_KEY` | GCP API key | Solar API + Maps + Geocoding | -| `VITE_GOOGLE_API_KEY` | Derived from `GOOGLE_API_KEY` | Frontend map loader | -| `GOOGLE_CLOUD_PROJECT` | GCP project ID | Vertex AI chat path | -| `GOOGLE_CLOUD_LOCATION` | Usually `global` | Vertex AI region | -| `GEMINI_API_KEY` | Google AI Studio | Chat fallback | -| `CHAT_MODEL` | Gemini model name | Chat model selector | -| `GOOGLE_OAUTH_CLIENT_ID` | GCP OAuth client | Google sign-in | -| `GOOGLE_OAUTH_SECRET` | GCP OAuth client | Google sign-in | -| `SUPABASE_URL` | Supabase settings | Backend + auth | -| `SUPABASE_ANON_KEY` | Supabase settings | Frontend auth | -| `SUPABASE_SERVICE_ROLE_KEY` | Supabase settings | Backend privileged access | -| `SUPABASE_DATABASE_URL` | Supabase DB connection string | Prisma | -| `VITE_SUPABASE_URL` | Derived from `SUPABASE_URL` | Frontend auth | -| `VITE_SUPABASE_ANON_KEY` | Derived from `SUPABASE_ANON_KEY` | Frontend auth | -| `SITE_URL` | Final public origin | Supabase auth redirects | -| `APEX_DOMAIN` | `solarsim.tech` | Apex redirect middleware | -| `BACKEND_PORT` | Local only | Backend dev port | -| `FRONTEND_PORT` | Local only | Frontend dev port | -| `FRONTEND_URL` | Local or production origin | Backend CORS | -| `PDF_TOKEN_SECRET` | `openssl rand -hex 32` | PDF token signing | -| `PDF_EXPORT_URL` | Vercel function URL | PDF export endpoint | -| `VITE_PDF_EXPORT_URL` | Derived from `PDF_EXPORT_URL` | Frontend export URL | -| `RESEND_API_KEY` | Resend API key | Supabase SMTP | -| `PORT` | Heroku runtime | Do not set by hand | -| `NODE_ENV` | Runtime / Heroku config | Production mode | -| `ALLOWED_FRONTEND_ORIGIN` | Vercel env | PDF function CORS allowlist | +| Variable | Source | Notes | +| --------------------------- | -------------------------------- | ---------------------------- | +| `GOOGLE_API_KEY` | GCP API key | Solar API + Maps + Geocoding | +| `VITE_GOOGLE_API_KEY` | Derived from `GOOGLE_API_KEY` | Frontend map loader | +| `GOOGLE_CLOUD_PROJECT` | GCP project ID | Vertex AI chat path | +| `GOOGLE_CLOUD_LOCATION` | Usually `global` | Vertex AI region | +| `GEMINI_API_KEY` | Google AI Studio | Chat fallback | +| `CHAT_MODEL` | Gemini model name | Chat model selector | +| `GOOGLE_OAUTH_CLIENT_ID` | GCP OAuth client | Google sign-in | +| `GOOGLE_OAUTH_SECRET` | GCP OAuth client | Google sign-in | +| `SUPABASE_URL` | Supabase settings | Backend + auth | +| `SUPABASE_ANON_KEY` | Supabase settings | Frontend auth | +| `SUPABASE_SERVICE_ROLE_KEY` | Supabase settings | Backend privileged access | +| `SUPABASE_DATABASE_URL` | Supabase DB connection string | Prisma | +| `VITE_SUPABASE_URL` | Derived from `SUPABASE_URL` | Frontend auth | +| `VITE_SUPABASE_ANON_KEY` | Derived from `SUPABASE_ANON_KEY` | Frontend auth | +| `SITE_URL` | Final public origin | Supabase auth redirects | +| `APEX_DOMAIN` | `solarsim.tech` | Apex redirect middleware | +| `BACKEND_PORT` | Local only | Backend dev port | +| `FRONTEND_PORT` | Local only | Frontend dev port | +| `FRONTEND_URL` | Local or production origin | Backend CORS | +| `PDF_TOKEN_SECRET` | `openssl rand -hex 32` | PDF token signing | +| `PDF_EXPORT_URL` | Vercel function URL | PDF export endpoint | +| `VITE_PDF_EXPORT_URL` | Derived from `PDF_EXPORT_URL` | Frontend export URL | +| `RESEND_API_KEY` | Resend API key | Supabase SMTP | +| `PORT` | Heroku runtime | Do not set by hand | +| `NODE_ENV` | Runtime / Heroku config | Production mode | +| `ALLOWED_FRONTEND_ORIGIN` | Vercel env | PDF function CORS allowlist | ## Appendix B: Glossary of CLIs -| CLI | Meaning | -| --- | --- | +| CLI | Meaning | +| ---------- | ------------------------------------------------------- | | `supabase` | Manages hosted Supabase auth, config, and project links | -| `gcloud` | Manages Google Cloud projects, APIs, and credentials | -| `gh` | GitHub CLI for secrets, PRs, and workflow inspection | -| `heroku` | Manages the backend app, config vars, domains, and logs | -| `vercel` | Deploys and configures the PDF function | -| `dig` | Queries DNS records from the terminal | -| `openssl` | Generates secure random secrets and checks TLS | +| `gcloud` | Manages Google Cloud projects, APIs, and credentials | +| `gh` | GitHub CLI for secrets, PRs, and workflow inspection | +| `heroku` | Manages the backend app, config vars, domains, and logs | +| `vercel` | Deploys and configures the PDF function | +| `dig` | Queries DNS records from the terminal | +| `openssl` | Generates secure random secrets and checks TLS | Last verified: 03/05/26 (against main @ ) diff --git a/backend/src/services/__tests__/fluxRecomputeService.test.ts b/backend/src/services/__tests__/fluxRecomputeService.test.ts index b3c6144..73e93fd 100644 --- a/backend/src/services/__tests__/fluxRecomputeService.test.ts +++ b/backend/src/services/__tests__/fluxRecomputeService.test.ts @@ -59,11 +59,12 @@ function setupHappyPath() { latLngToPixelMock.mockReturnValue({ px: 100, py: 200 }) metersToPixelsMock.mockReturnValueOnce(10).mockReturnValueOnce(18) getRotatedCornersMock.mockReturnValue([ - [95, 191], [105, 191], [105, 209], [95, 209] + [95, 191], + [105, 191], + [105, 209], + [95, 209] ]) - computeMonthlyEnergyMock.mockResolvedValue( - [30, 28, 31, 29, 32, 30, 33, 31, 30, 29, 27, 28] - ) + computeMonthlyEnergyMock.mockResolvedValue([30, 28, 31, 29, 32, 30, 33, 31, 30, 29, 27, 28]) } describe('recomputeSinglePanel', () => { diff --git a/backend/src/services/__tests__/locationPipeline.test.ts b/backend/src/services/__tests__/locationPipeline.test.ts index 71baa54..778da0c 100644 --- a/backend/src/services/__tests__/locationPipeline.test.ts +++ b/backend/src/services/__tests__/locationPipeline.test.ts @@ -20,8 +20,7 @@ vi.mock('../solarApiService.js', () => ({ enrichBuildingInsights: (...args: unknown[]) => enrichBuildingInsightsMock(...args), // Delegate to globalThis.fetch so the existing fetch spies in this suite keep observing layer // downloads. Timeout is irrelevant in jsdom — the spy resolves synchronously before any timer fires. - fetchWithTimeout: (input: string | URL, _timeoutMs: number, init?: RequestInit) => - globalThis.fetch(input, init), + fetchWithTimeout: (input: string | URL, _timeoutMs: number, init?: RequestInit) => globalThis.fetch(input, init), DOWNLOAD_TIMEOUT_MS: 45_000, PROBE_TIMEOUT_MS: 8_000, METADATA_TIMEOUT_MS: 20_000 @@ -73,9 +72,9 @@ describe('fetchLocationPipelineInputs', () => { monthlyFluxUrl: 'https://example.com/monthly' }) - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => - new Response(new ArrayBuffer(8), { status: 200 }) - ) + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async () => new Response(new ArrayBuffer(8), { status: 200 })) const result = await fetchLocationPipelineInputs(3.14, 101.69, 'HIGH', false) @@ -106,9 +105,9 @@ describe('fetchLocationPipelineInputs', () => { monthlyFluxUrl: 'https://example.com/monthly' }) - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => - new Response(new ArrayBuffer(8), { status: 200 }) - ) + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async () => new Response(new ArrayBuffer(8), { status: 200 })) const result = await fetchLocationPipelineInputs(3.14, 101.69, 'HIGH', false) @@ -130,9 +129,9 @@ describe('fetchLocationPipelineInputs', () => { monthlyFluxUrl: null }) - const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async () => - new Response(new ArrayBuffer(8), { status: 200 }) - ) + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async () => new Response(new ArrayBuffer(8), { status: 200 })) await fetchLocationPipelineInputs(3.14, 101.69, 'HIGH', false) @@ -159,8 +158,6 @@ describe('fetchLocationPipelineInputs', () => { new Response(null, { status: 500, statusText: 'Internal Server Error' }) ) - await expect(fetchLocationPipelineInputs(3.14, 101.69, 'HIGH', false)).rejects.toThrow( - 'Failed to download dsmUrl' - ) + await expect(fetchLocationPipelineInputs(3.14, 101.69, 'HIGH', false)).rejects.toThrow('Failed to download dsmUrl') }) }) diff --git a/backend/src/services/chat/__tests__/digest.test.ts b/backend/src/services/chat/__tests__/digest.test.ts index 48eefcb..79d98e9 100644 --- a/backend/src/services/chat/__tests__/digest.test.ts +++ b/backend/src/services/chat/__tests__/digest.test.ts @@ -194,7 +194,9 @@ describe('renderProjectDigest', () => { expect(digest).toContain('### Monthly Breakdown') expect(digest).toContain('| Jan |') expect(digest).toContain('| Dec |') - const pipeLines = digest.split('\n').filter((line) => /^\| (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \|/.test(line)) + const pipeLines = digest + .split('\n') + .filter((line) => /^\| (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \|/.test(line)) expect(pipeLines).toHaveLength(12) }) diff --git a/backend/src/services/locationService.ts b/backend/src/services/locationService.ts index c50cbaa..d154100 100644 --- a/backend/src/services/locationService.ts +++ b/backend/src/services/locationService.ts @@ -85,8 +85,7 @@ export async function resolveLocation( if (existing) { const isStaleProcessing = - existing.status === 'processing' && - Date.now() - existing.createdAt.getTime() > STALE_PROCESSING_THRESHOLD_MS + existing.status === 'processing' && Date.now() - existing.createdAt.getTime() > STALE_PROCESSING_THRESHOLD_MS if (isStaleProcessing) { const ageMs = Date.now() - existing.createdAt.getTime() diff --git a/backend/src/services/solarApiService.ts b/backend/src/services/solarApiService.ts index f89946b..9e626ed 100644 --- a/backend/src/services/solarApiService.ts +++ b/backend/src/services/solarApiService.ts @@ -29,11 +29,7 @@ export const DOWNLOAD_TIMEOUT_MS = 45_000 * named timeout error so callers see something readable in logs and don't have * to do their own AbortError detection. */ -export async function fetchWithTimeout( - input: string | URL, - timeoutMs: number, - init?: RequestInit -): Promise { +export async function fetchWithTimeout(input: string | URL, timeoutMs: number, init?: RequestInit): Promise { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeoutMs) try { diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..60006a8 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,77 @@ +const js = require('@eslint/js') +const prettier = require('eslint-config-prettier') +const reactHooks = require('eslint-plugin-react-hooks') +const tseslint = require('typescript-eslint') +const globals = require('globals') + +const typescriptFiles = [ + 'shared/**/*.{ts,tsx}', + 'backend/**/*.{ts,tsx}', + 'frontend/**/*.{ts,tsx}', + 'services/pdf-service/**/*.{ts,tsx}' +] + +const isOff = (setting) => setting === 'off' || setting === 0 || (Array.isArray(setting) && isOff(setting[0])) + +const warnings = (rules) => + Object.fromEntries( + Object.entries(rules) + .filter(([, setting]) => !isOff(setting)) + .map(([name, setting]) => [name, Array.isArray(setting) ? ['warn', ...setting.slice(1)] : 'warn']) + ) + +module.exports = [ + { + ignores: [ + '**/node_modules/**', + '**/dist/**', + '**/build/**', + '**/.next/**', + '**/generated/**', + '**/prisma/generated/**', + '**/*.d.ts', + 'graphify-out/**', + 'docs/**', + 'tests/**', + '.claude/**', + '.agents/**', + '.codex/**', + 'backend/src/**/*.js', + 'frontend/src/**/*.js', + 'shared/*.js', + 'prisma/seed.js' + ] + }, + { + files: ['**/*.{js,mjs,cjs}'], + ...js.configs.recommended, + languageOptions: { + globals: globals.node + }, + rules: { + ...warnings(js.configs.recommended.rules), + 'no-debugger': 'error' + } + }, + ...tseslint.configs.recommended.map((config) => ({ + ...config, + files: typescriptFiles + })), + { + files: typescriptFiles, + rules: { + ...warnings(Object.assign({}, ...tseslint.configs.recommended.map((config) => config.rules || {}))), + 'no-debugger': 'error', + '@typescript-eslint/no-explicit-any': 'error' + } + }, + { + files: ['frontend/**/*.{ts,tsx}'], + ...reactHooks.configs.flat.recommended, + rules: { + ...warnings(reactHooks.configs.flat.recommended.rules), + 'react-hooks/rules-of-hooks': 'error' + } + }, + prettier +] diff --git a/frontend/src/hooks/usePanelState.ts b/frontend/src/hooks/usePanelState.ts index 97c92d0..577ae80 100644 --- a/frontend/src/hooks/usePanelState.ts +++ b/frontend/src/hooks/usePanelState.ts @@ -341,9 +341,7 @@ export function usePanelState({ } function deletePanel(panelId: string) { - const nextPanels = panelsRef.current.map((panel) => - panel.id === panelId ? { ...panel, deleted: true } : panel - ) + const nextPanels = panelsRef.current.map((panel) => (panel.id === panelId ? { ...panel, deleted: true } : panel)) const nextVisibleCount = Math.max(minVisibleCount, visibleCountRef.current - 1) commitPanels(nextPanels) commitVisibleCount(nextVisibleCount) @@ -352,9 +350,7 @@ export function usePanelState({ function updatePanelEnergy(panelId: string, monthlyEnergyDcKwh: number[]) { // Energy refreshes are derived data from the backend recompute; not part of the undo history. - const next = panelsRef.current.map((panel) => - panel.id === panelId ? { ...panel, monthlyEnergyDcKwh } : panel - ) + const next = panelsRef.current.map((panel) => (panel.id === panelId ? { ...panel, monthlyEnergyDcKwh } : panel)) commitPanels(next) } diff --git a/frontend/src/pages/PrivacyPage.tsx b/frontend/src/pages/PrivacyPage.tsx index 34c9001..874d1fe 100644 --- a/frontend/src/pages/PrivacyPage.tsx +++ b/frontend/src/pages/PrivacyPage.tsx @@ -72,10 +72,7 @@ export function PrivacyPage() {
    {SECTION_ORDER.map((key, i) => (
  1. - + {i + 1}. {sections[key]?.title}
  2. diff --git a/frontend/src/pages/__tests__/AnalyticsPage.test.tsx b/frontend/src/pages/__tests__/AnalyticsPage.test.tsx index 2a9339c..3f44301 100644 --- a/frontend/src/pages/__tests__/AnalyticsPage.test.tsx +++ b/frontend/src/pages/__tests__/AnalyticsPage.test.tsx @@ -16,7 +16,9 @@ vi.mock('react-router-dom', async () => { }) vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (k: string, opts?: Record) => (opts ? `${k}:${JSON.stringify(opts)}` : k) }) + useTranslation: () => ({ + t: (k: string, opts?: Record) => (opts ? `${k}:${JSON.stringify(opts)}` : k) + }) })) vi.mock('@/api/projects', () => ({ @@ -37,7 +39,11 @@ vi.mock('@/components/analysis/ChartTooltipContent', () => ({ })) vi.mock('@/components/dashboard/StatCard', () => ({ - StatCard: ({ label, value }: { label: string; value: string }) =>
    {label}: {value}
    + StatCard: ({ label, value }: { label: string; value: string }) => ( +
    + {label}: {value} +
    + ) })) vi.mock('@/components/dashboard/helpers', () => ({ @@ -116,9 +122,7 @@ describe('AnalyticsPage', () => { // AN-02 it('renders the empty-state when all projects are still in progress', async () => { - listProjectsMock.mockResolvedValue([ - { id: 'p1', name: 'WIP', status: 'layout_saved', analysisResults: null } - ]) + listProjectsMock.mockResolvedValue([{ id: 'p1', name: 'WIP', status: 'layout_saved', analysisResults: null }]) renderPage() await waitFor(() => expect(screen.getByText('analytics.noData.title')).toBeTruthy()) @@ -141,9 +145,7 @@ describe('AnalyticsPage', () => { ]) renderPage() - await waitFor(() => - expect(screen.getByText(/analytics\.subtitleWithCount/)).toBeTruthy() - ) + await waitFor(() => expect(screen.getByText(/analytics\.subtitleWithCount/)).toBeTruthy()) }) // AN-04 diff --git a/frontend/src/pages/__tests__/DashboardPage.test.tsx b/frontend/src/pages/__tests__/DashboardPage.test.tsx index b2367dc..68df21d 100644 --- a/frontend/src/pages/__tests__/DashboardPage.test.tsx +++ b/frontend/src/pages/__tests__/DashboardPage.test.tsx @@ -85,7 +85,9 @@ describe('DashboardPage', () => { listProjectsMock.mockResolvedValue([]) useAuthMock.mockReturnValue({ user: { id: 'u1', email: 'tester@example.com' } }) - useQuotaMock.mockReturnValue({ data: { used: 0, limit: 5, resetsAt: new Date(Date.now() + 3600000).toISOString() } }) + useQuotaMock.mockReturnValue({ + data: { used: 0, limit: 5, resetsAt: new Date(Date.now() + 3600000).toISOString() } + }) }) // DP-01 diff --git a/frontend/src/pages/__tests__/ProjectsPage.test.tsx b/frontend/src/pages/__tests__/ProjectsPage.test.tsx index 23c4820..e754d51 100644 --- a/frontend/src/pages/__tests__/ProjectsPage.test.tsx +++ b/frontend/src/pages/__tests__/ProjectsPage.test.tsx @@ -18,7 +18,9 @@ vi.mock('react-router-dom', async () => { }) vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (k: string, opts?: Record) => (opts ? `${k}:${JSON.stringify(opts)}` : k) }) + useTranslation: () => ({ + t: (k: string, opts?: Record) => (opts ? `${k}:${JSON.stringify(opts)}` : k) + }) })) vi.mock('@/api/projects', () => ({ @@ -46,7 +48,9 @@ vi.mock('@/components/dashboard/ProjectCard', () => ({ ProjectCard: ({ project, onDelete }: { project: { id: string; name: string }; onDelete: () => void }) => (
    {project.name} - +
    ) })) @@ -78,7 +82,9 @@ describe('ProjectsPage', () => { deleteProjectMock.mockReset() useQuotaMock.mockReset() - useQuotaMock.mockReturnValue({ data: { used: 1, limit: 5, resetsAt: new Date(Date.now() + 3600000).toISOString() } }) + useQuotaMock.mockReturnValue({ + data: { used: 1, limit: 5, resetsAt: new Date(Date.now() + 3600000).toISOString() } + }) }) // PP-01 diff --git a/frontend/src/pages/__tests__/SignUpPage.test.tsx b/frontend/src/pages/__tests__/SignUpPage.test.tsx index ea99c2d..fb12558 100644 --- a/frontend/src/pages/__tests__/SignUpPage.test.tsx +++ b/frontend/src/pages/__tests__/SignUpPage.test.tsx @@ -94,7 +94,11 @@ describe('SignUpPage', () => { it('disables the submit button while the request is in flight', async () => { setAuth() let resolveFn: (value: { error: null }) => void = () => undefined - signUpMock.mockReturnValue(new Promise((r) => { resolveFn = r })) + signUpMock.mockReturnValue( + new Promise((r) => { + resolveFn = r + }) + ) renderPage() diff --git a/frontend/src/pages/__tests__/WorkbenchPage.test.tsx b/frontend/src/pages/__tests__/WorkbenchPage.test.tsx index 1155aef..6056984 100644 --- a/frontend/src/pages/__tests__/WorkbenchPage.test.tsx +++ b/frontend/src/pages/__tests__/WorkbenchPage.test.tsx @@ -44,7 +44,9 @@ vi.mock('react-router-dom', async () => { }) vi.mock('react-i18next', () => ({ - useTranslation: () => ({ t: (k: string, opts?: Record) => (opts ? `${k}:${JSON.stringify(opts)}` : k) }) + useTranslation: () => ({ + t: (k: string, opts?: Record) => (opts ? `${k}:${JSON.stringify(opts)}` : k) + }) })) vi.mock('react-konva', () => ({ @@ -194,8 +196,13 @@ vi.mock('@/components/ui/LoadingOverlay', () => ({ LoadingOverlay: () =>
    lo vi.mock('@/components/ui/GuidedTour', () => ({ GuidedTour: () => null })) vi.mock('@/components/workbench/CanvasControls', () => ({ CanvasControls: () => null })) vi.mock('@/components/workbench/CanvasLegends', () => ({ CanvasLegends: () => null })) -vi.mock('@/components/workbench/WorkbenchSidebar', () => ({ WorkbenchSidebar: () =>