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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ TZ=America/New_York
APP_PORT=3000
# Where bill PDFs are stored on the host (the DB and session use Docker volumes).
PDF_DIR=./data/pdfs
# Root data dir INSIDE the container. The saved Playwright session and the
# auto-generated crypto key (session/secret.key), the downloaded bill PDFs
# (pdfs/), and the pre-migrate backups all live under it. Defaults to /data;
# override only if you mount the persistent volume somewhere else.
# DATA_DIR=/data

# Scheduler: set to "false" to disable automatic bill checking (manual only).
SCHEDULER_ENABLED=true
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,3 @@ npm-debug.log*

# agent worktrees / local tooling
.claude/
app/package-lock.json
2 changes: 1 addition & 1 deletion app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
FROM mcr.microsoft.com/playwright:v1.60.0-noble AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
RUN npm ci

# Test stage: source + deps, no build. `docker build --target test -t ng-test ./app`
# then `docker run --rm ng-test` runs the hand-calculated unit tests.
Expand Down
4 changes: 1 addition & 3 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@
"scrape": "tsx src/lib/ngrid/cli.ts",
"test": "vitest run",
"test:watch": "vitest",
"prisma:generate": "prisma generate",
"prisma:migrate": "prisma migrate deploy",
"prisma:dev": "prisma migrate dev"
"prisma:generate": "prisma generate"
},
"dependencies": {
"@prisma/client": "5.22.0",
Expand Down
5 changes: 3 additions & 2 deletions app/src/app/api/interval/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ export const runtime = 'nodejs';
// that don't pass from/to.
// ?accountId=<id> — scopes to that account (the shared resolveRequestAccount
// dance); omitted = the default account, bad id = 400.
// ?grain=… — IGNORED (back-compat: older widget builds sent it; the
// server now picks the bucket itself). Harmless if present.
// ?grain=… — IGNORED and no longer parsed (back-compat: older widget
// builds sent it; the server now picks the bucket itself).
// Harmless if present.
// ?bucket=<secs> — WS8 OVERSCAN (optional): an EXPLICIT bucket width (one of
// the chooseBucket ladder widths) to aggregate this [from,to]
// at, SKIPPING the span→bucket choice. The overscan client
Expand Down
1 change: 0 additions & 1 deletion app/src/components/WidgetLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ import {
PINNED_PAGE_ROWS,
FIT_BREAKPOINT,
STRIP_COLS,
STRIP_KEY,
computePageFit,
findFreeSlot,
generateDefaultPlacements,
Expand Down
2 changes: 0 additions & 2 deletions app/src/components/widgets/VizCharts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import {
Area,
CartesianGrid,
ComposedChart,
Legend,
Line,
ResponsiveContainer,
Scatter,
Expand Down Expand Up @@ -48,7 +47,6 @@ const tooltipStyle = {
const axisStyle = { stroke: '#475569', fontSize: 11 } as const;

const AMBER = '#f59e0b';
const SKY = '#38bdf8';

// A wrapper giving each viz a definite-height box so Recharts'
// ResponsiveContainer always measures a non-zero plot (the same reason
Expand Down
33 changes: 1 addition & 32 deletions app/src/lib/intervalParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,35 +14,6 @@ export function parseFuel(raw: string | null): 'ELECTRIC' | 'GAS' {
return raw === 'GAS' ? 'GAS' : 'ELECTRIC';
}

// The number of seconds in the 15-minute grain — the only non-hourly grain the
// AMI feed produces (15-min electric NRT). Used both to query the DB filtered to
// 15-min rows and as the `?grain=15m` request value.
export const FIFTEEN_MIN_SECONDS = 900;

// The interval HISTORY widget's resolution grain. `'all'` (the default) returns
// every grain, downsampled to ≤ MAX_POINTS for the smooth multi-year line — the
// long-standing behaviour, kept for back-compat / non-dashboard callers. `'15m'`
// (issue: the 15m view was eating the downsampled feed and collapsing the recent
// 15-min sliver to a handful of points) returns ONLY the raw 900s rows for the
// window, UN-decimated — 15-min data is inherently recent/bounded (NRT, ~days), so
// serving it raw is cheap and avoids the spurious sparsity the time-bucket
// downsampler caused over a wide range. `'1h'` is the symmetric hourly path: it
// RECONCILES the raw rows to one value per hour (4 complete 15-min slots win, else
// the API hourly row) and THEN downsamples — the order matters, because doing it
// the other way (downsample the mixed feed, then reconcile on the client) silently
// drops every downsampled lone-15-min slot via reconcile's "partial 15-min, no
// hourly → skip" rule, capping the 1h line at the moment 15-min data begins.
export type IntervalGrain = 'all' | '15m' | '1h';

// Parse the `?grain=` param. An exact `'15m'` selects the raw-15-min path; `'1h'`
// selects the reconcile-then-downsample hourly path; anything else (absent,
// garbage) falls back to the default 'all' (all grains, downsampled). PURE.
export function parseGrain(raw: string | null): IntervalGrain {
if (raw === '15m') return '15m';
if (raw === '1h') return '1h';
return 'all';
}

// WS8 OVERSCAN: an EXPLICIT bucket width (seconds) the caller can request so an
// OVERSCAN fetch is aggregated at the VIEW's grain, not the (wider) overscan
// span's grain. Background: WS8 preloads a superset wider than the visible window
Expand Down Expand Up @@ -134,15 +105,13 @@ export type IntervalWindow =
export function parseIntervalQuery(params: URLSearchParams): {
fuelType: 'ELECTRIC' | 'GAS';
window: IntervalWindow;
grain: IntervalGrain;
// WS8: an EXPLICIT, ladder-validated bucket width (seconds), or null when the
// caller didn't request one (server picks the bucket — the pre-WS8 default). The
// overscan client passes the VIEW-span bucket so the wider superset is aggregated
// at the view's grain. See parseBucket.
bucket: number | null;
} {
const fuelType = parseFuel(params.get('fuel'));
const grain = parseGrain(params.get('grain'));
const bucket = parseBucket(params.get('bucket'));
let from = parseDate(params.get('from'), false);
let to = parseDate(params.get('to'), true);
Expand All @@ -154,7 +123,7 @@ export function parseIntervalQuery(params: URLSearchParams): {
const window: IntervalWindow = hasWindow
? { from: from ?? undefined, to: to ?? undefined }
: { sinceDays: parseSinceDays(params.get('sinceDays')) };
return { fuelType, window, grain, bucket };
return { fuelType, window, bucket };
}

const DAY_MS = 24 * 60 * 60 * 1000;
Expand Down
20 changes: 8 additions & 12 deletions app/src/lib/intervalProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,15 @@ export type ProfileBucket = {
// are tolerated. `intervalSeconds` is carried for completeness/symmetry with the
// DB row; the hourly-bucket shaping keys off the local clock time, not the read
// length, so the shaper does not currently weight by duration.
// `fuelType` is optional here so the base type stays lean; reconcileToHourly
// accepts a superset that carries it (ReconcileRow) and groups by (fuelType, hour).
// `fuelType` is optional here so the base type stays lean; reconcileToHourly reads
// it (when present) to group by (fuelType, hour).
export type IntervalProfileRow = {
intervalStart: Date | string;
intervalSeconds: number;
quantity: number;
fuelType?: string;
};

// Extended row type accepted by reconcileToHourly — a strict superset of
// IntervalProfileRow so averageDayProfile's signature is unchanged.
type ReconcileRow = IntervalProfileRow & { fuelType?: string };

export type AverageDayProfileOpts = {
// The IANA timezone the time-of-day buckets are computed in. Defaults to the
// account's region (Upstate NY) so a "typical day" reads in local clock time,
Expand Down Expand Up @@ -233,13 +229,13 @@ export function averageDayProfile(
// by `Math.floor(utcMs / 3_600_000)` is therefore equivalent to grouping by the
// local clock-hour and is DST-safe.
//
// Input type is the same superset (ReconcileRow ⊇ IntervalProfileRow) so callers
// can pass widget rows (which carry fuelType) without a cast. Tolerates
// string/Date intervalStart and drops rows with non-finite quantity. Returns rows
// sorted ascending by intervalStart. PURE — no React/DOM/DB.
export function reconcileToHourly(rows: ReconcileRow[]): IntervalProfileRow[] {
// Input rows carry an optional fuelType so callers can pass widget rows (which
// carry it) without a cast. Tolerates string/Date intervalStart and drops rows
// with non-finite quantity. Returns rows sorted ascending by intervalStart. PURE —
// no React/DOM/DB.
export function reconcileToHourly(rows: IntervalProfileRow[]): IntervalProfileRow[] {
// Group by (fuelType, UTC-hour-epoch). The key is `"${fuelType}|${hourEpoch}"`.
type Slot = { slot900: number[]; row3600: ReconcileRow | null; hourEpoch: number; fuelType: string };
type Slot = { slot900: number[]; row3600: IntervalProfileRow | null; hourEpoch: number; fuelType: string };
const groups = new Map<string, Slot>();

for (const row of rows) {
Expand Down
31 changes: 4 additions & 27 deletions app/src/lib/layoutEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
// • `mergePlacements(...)` — the migration safety net: a saved blob is repaired
// against a freshly generated default (unknown widgets dropped, newly-added
// widgets appended) so a round-trip never loses or corrupts placements.
// • `computeFitRowHeight(...)` — the runtime no-scroll fit math (RFC §3.3): it
// derives the grid rowHeight from the measured chrome so the page fills the
// viewport without scrolling.
// • `computePageFit(...)` — the runtime no-scroll fit math (RFC §3.3): it derives
// the grid rowHeight (clamped to MIN_ROW_HEIGHT) from the measured band so a
// page fills the viewport without scrolling.

// The responsive breakpoints, widest → narrowest, mirroring RGL's keys. We use
// four (RFC §3.3: "lg ≥1280 / md / sm / xs"):
Expand Down Expand Up @@ -192,7 +192,7 @@ function withMins(p: Placement, mins: WidgetMins | undefined): Placement {
// • xs (2 col): stat cards 2-up (w=1 each), charts + panels full-width (w=2),
// order stats → charts → panels, mobile scrolls (issue #110).
//
// Heights are in grid rows; the component's runtime rowHeight (computeFitRowHeight
// Heights are in grid rows; the component's runtime rowHeight (computePageFit
// at lg, a fixed rowHeight below) turns rows into pixels. Stat cards are short
// (1 row); charts are tall (CHART_ROWS); the bills rail spans the chart block.

Expand Down Expand Up @@ -726,29 +726,6 @@ export function placementsEqual(a: Placements, b: Placements): boolean {
// unit-tested.
export const MIN_ROW_HEIGHT = 24;

export function computeFitRowHeight(opts: {
viewportHeight: number;
measuredChrome: number;
rows: number;
marginY: number;
}): number {
const { viewportHeight, measuredChrome, marginY } = opts;
const rows = Math.max(1, Math.floor(opts.rows));
const available = viewportHeight - measuredChrome;
const usable = available - marginY * (rows + 1);
const rh = usable / rows;
return Math.max(MIN_ROW_HEIGHT, rh);
}

// The maximum `y + h` across a breakpoint's placements = the row count the grid
// actually occupies. The fit math uses this (not the default constant) once a
// user has customized, so the no-scroll target tracks the real layout. Returns
// at least 1 so a degenerate/empty layout still divides safely. PURE.
export function placementRows(placements: Placement[] | undefined): number {
if (!placements || placements.length === 0) return 1;
return Math.max(1, ...placements.map((p) => p.y + p.h));
}

// Do two boxes (x/y/w/h cells) overlap on the grid? Used to find a collision-free
// drop slot for a newly-added widget. Half-open intervals — tiles that merely
// touch edge-to-edge ([0,6) and [6,12)) do NOT overlap. PURE.
Expand Down
Loading
Loading