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
14 changes: 13 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Comment on lines +45 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the deployment status consistent throughout the README.

This warning says solarsim.tech is offline, but the deployment section at Lines 307-313 still presents the same URLs and services as the “Live deployment.” Rename that section to indicate the deployment is currently offline, or add the same status warning there so readers do not mistake the historical deployment details for an operational service.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 45 - 47, Update the deployment section identified by
its live deployment heading so it consistently communicates that solarsim.tech
is currently offline. Either rename the heading to indicate the offline status
or add the existing status warning alongside the historical URLs and service
details, without presenting them as operational.

---

## ✨ At a Glance
Expand Down Expand Up @@ -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: <https://solarsim.tech> (Heroku dyno behind custom domain)
- PDF render function: Vercel Hobby tier (URL set via `PDF_EXPORT_URL`)
Expand Down Expand Up @@ -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

<table align="center">
Expand Down
202 changes: 101 additions & 101 deletions RUNBOOK.md

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions backend/src/services/__tests__/fluxRecomputeService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
25 changes: 11 additions & 14 deletions backend/src/services/__tests__/locationPipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand All @@ -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)

Expand All @@ -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')
})
})
4 changes: 3 additions & 1 deletion backend/src/services/chat/__tests__/digest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
3 changes: 1 addition & 2 deletions backend/src/services/locationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
6 changes: 1 addition & 5 deletions backend/src/services/solarApiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
export async function fetchWithTimeout(input: string | URL, timeoutMs: number, init?: RequestInit): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
Expand Down
77 changes: 77 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
@@ -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'])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)

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
]
8 changes: 2 additions & 6 deletions frontend/src/hooks/usePanelState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}

Expand Down
5 changes: 1 addition & 4 deletions frontend/src/pages/PrivacyPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,7 @@ export function PrivacyPage() {
<ol className="grid gap-1 text-sm sm:grid-cols-2">
{SECTION_ORDER.map((key, i) => (
<li key={key}>
<a
href={`#${key}`}
className="text-muted-foreground transition-colors hover:text-foreground"
>
<a href={`#${key}`} className="text-muted-foreground transition-colors hover:text-foreground">
{i + 1}. {sections[key]?.title}
</a>
</li>
Expand Down
18 changes: 10 additions & 8 deletions frontend/src/pages/__tests__/AnalyticsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ vi.mock('react-router-dom', async () => {
})

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string, opts?: Record<string, unknown>) => (opts ? `${k}:${JSON.stringify(opts)}` : k) })
useTranslation: () => ({
t: (k: string, opts?: Record<string, unknown>) => (opts ? `${k}:${JSON.stringify(opts)}` : k)
})
}))

vi.mock('@/api/projects', () => ({
Expand All @@ -37,7 +39,11 @@ vi.mock('@/components/analysis/ChartTooltipContent', () => ({
}))

vi.mock('@/components/dashboard/StatCard', () => ({
StatCard: ({ label, value }: { label: string; value: string }) => <div>{label}: {value}</div>
StatCard: ({ label, value }: { label: string; value: string }) => (
<div>
{label}: {value}
</div>
)
}))

vi.mock('@/components/dashboard/helpers', () => ({
Expand Down Expand Up @@ -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())
Expand All @@ -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
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/pages/__tests__/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions frontend/src/pages/__tests__/ProjectsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ vi.mock('react-router-dom', async () => {
})

vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (k: string, opts?: Record<string, unknown>) => (opts ? `${k}:${JSON.stringify(opts)}` : k) })
useTranslation: () => ({
t: (k: string, opts?: Record<string, unknown>) => (opts ? `${k}:${JSON.stringify(opts)}` : k)
})
}))

vi.mock('@/api/projects', () => ({
Expand Down Expand Up @@ -46,7 +48,9 @@ vi.mock('@/components/dashboard/ProjectCard', () => ({
ProjectCard: ({ project, onDelete }: { project: { id: string; name: string }; onDelete: () => void }) => (
<div data-testid={`project-card-${project.id}`}>
<span>{project.name}</span>
<button type="button" onClick={onDelete}>delete</button>
<button type="button" onClick={onDelete}>
delete
</button>
</div>
)
}))
Expand Down Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/pages/__tests__/SignUpPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading