Compare commits

..

No commits in common. "master" and "v.0.0.7" have entirely different histories.

5 changed files with 41 additions and 367 deletions

View File

@ -38,8 +38,7 @@ jobs:
tags: | tags: |
rooftopenergy/powermeter-frontend:${{ steps.extract_tag.outputs.tag }} rooftopenergy/powermeter-frontend:${{ steps.extract_tag.outputs.tag }}
build-args: | build-args: |
NEXT_PUBLIC_FASTAPI_URL=${{ secrets.NEXT_PUBLIC_FASTAPI_URL }} NEXT_PUBLIC_FASTAPI_URL=${{ secrets.NEXT_PUBLIC_FASTAPI_URL }}
NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID }}
- name: SSH and Deploy - name: SSH and Deploy
uses: appleboy/ssh-action@master uses: appleboy/ssh-action@master

View File

@ -8,10 +8,8 @@ WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Build-time public env hook # Build-time public env hook
ARG NEXT_PUBLIC_FASTAPI_URL\ ARG NEXT_PUBLIC_FASTAPI_URL
NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID ENV NEXT_PUBLIC_FASTAPI_URL=${NEXT_PUBLIC_FASTAPI_URL}
ENV NEXT_PUBLIC_FASTAPI_URL=${NEXT_PUBLIC_FASTAPI_URL}\
NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID=${NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID}
# 1) Install deps with caching # 1) Install deps with caching
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./

View File

@ -14,7 +14,6 @@ import KpiBottom from '@/components/dashboards/kpibottom';
import { formatAddress } from '@/app/utils/formatAddress'; import { formatAddress } from '@/app/utils/formatAddress';
import { formatCrmTimestamp } from '@/app/utils/datetime'; import { formatCrmTimestamp } from '@/app/utils/datetime';
import LoggingControlCard from '@/components/dashboards/LoggingControl'; import LoggingControlCard from '@/components/dashboards/LoggingControl';
import { buildExportUrl, getFilenameFromCD } from "@/utils/export";
const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false }); const EnergyLineChart = dynamic(() => import('@/components/dashboards/EnergyLineChart'), { ssr: false });
const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false }); const MonthlyBarChart = dynamic(() => import('@/components/dashboards/MonthlyBarChart'), { ssr: false });
@ -337,95 +336,6 @@ useEffect(() => {
} }
}; };
// helpers
// helpers
const ymd = (d: Date) => d.toISOString().slice(0, 10);
const excelUrl = (site: string, device: string, fn: 'grid' | 'solar', dateYMD: string) =>
`${API}/excel-fs/${encodeURIComponent(site)}/${encodeURIComponent(device)}/${fn}/${dateYMD}.xlsx`;
// popup state
const [isDownloadOpen, setIsDownloadOpen] = useState(false);
const [meter, setMeter] = useState('01'); // ADW300 device id
const [fn, setFn] = useState<'grid' | 'solar'>('grid'); // which function
const [downloadDate, setDownloadDate] = useState(ymd(new Date())); // YYYY-MM-DD
const [downloading, setDownloading] = useState(false);
// action
// util: parse filename from Content-Disposition
function getFilenameFromCD(h: string | null): string | null {
if (!h) return null;
// filename*=UTF-8''name.ext (RFC 5987)
const star = /filename\*\s*=\s*([^']*)''([^;]+)/i.exec(h);
if (star && star[2]) return decodeURIComponent(star[2]);
// filename="name.ext" or filename=name.ext
const plain = /filename\s*=\s*("?)([^";]+)\1/i.exec(h);
if (plain && plain[2]) return plain[2];
return null;
}
const downloadExcel = async () => {
if (!selectedProject) return;
try {
setDownloading(true);
// Prefer the simple day-based export
const url = buildExportUrl({
baseUrl: process.env.NEXT_PUBLIC_FASTAPI_URL,
site: selectedProject.name,
suffix: fn,
serial: meter?.trim() || undefined,
day: downloadDate, // "YYYY-MM-DD"
});
const resp = await fetch(url, { credentials: "include" });
if (!resp.ok) {
// server might return JSON error; try to surface it nicely
const ctype = resp.headers.get("Content-Type") || "";
let msg = `HTTP ${resp.status}`;
if (ctype.includes("application/json")) {
const j = await resp.json().catch(() => null);
if (j?.detail) msg = String(j.detail);
} else {
const t = await resp.text().catch(() => "");
if (t) msg = t;
}
throw new Error(msg);
}
const blob = await resp.blob();
// 1) use server-provided filename if present
const cd = resp.headers.get("Content-Disposition");
let downloadName = getFilenameFromCD(cd);
// 2) client-side fallback (date-only as requested)
if (!downloadName) {
const serialPart = meter?.trim() ? meter.trim() : "ALL";
downloadName = `${selectedProject.name}_${serialPart}_${fn}_${downloadDate}.xlsx`;
}
const a = document.createElement("a");
const href = URL.createObjectURL(blob);
a.href = href;
a.download = downloadName;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(href);
setIsDownloadOpen(false);
} catch (e: any) {
alert(`Download failed: ${e?.message ?? e}`);
} finally {
setDownloading(false);
}
};
// ---------- RENDER ---------- // ---------- RENDER ----------
if (!authChecked) { if (!authChecked) {
return <div>Checking authentication</div>; return <div>Checking authentication</div>;
@ -552,151 +462,10 @@ const downloadExcel = async () => {
<button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary"> <button onClick={handlePDFExport} className="text-sm lg:text-lg btn-primary">
Export Chart Images to PDF Export Chart Images to PDF
</button> </button>
<button
onClick={() => setIsDownloadOpen(true)}
className="text-sm lg:text-lg btn-primary"
>
Download Excel Log
</button>
</div> </div>
</> </>
)} )}
{isDownloadOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center"
aria-modal="true"
role="dialog"
onKeyDown={(e) => e.key === 'Escape' && setIsDownloadOpen(false)}
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50"
onClick={() => setIsDownloadOpen(false)}
/>
{/* Modal */}
<div className="relative w-full max-w-lg mx-4 rounded-2xl bg-white dark:bg-rtgray-800 shadow-2xl">
<div className="p-5 sm:p-6 border-b border-black/5 dark:border-white/10">
<h3 className="text-lg font-semibold text-black/90 dark:text-white">
Download Excel Log
</h3>
<p className="mt-1 text-sm text-black/60 dark:text-white/60">
Choose device, function, and date to export the .xlsx generated by the logger.
</p>
</div>
<div className="p-5 sm:p-6 space-y-5">
{/* Site (read-only preview) */}
<div>
<label className="block text-sm opacity-80 mb-1 dark:text-white">Site</label>
<div className="px-3 py-2 rounded-lg bg-black/5 dark:bg-white/5 text-sm truncate dark:text-white">
{selectedProject?.project_name || selectedProject?.name}
</div>
</div>
{/* Device + Function */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm opacity-80 mb-1 dark:text-white">Meter (Device)</label>
<input
value={meter}
onChange={(e) => setMeter(e.target.value)}
placeholder="01"
className="input input-bordered w-full pl-2 rounded-lg"
/>
<p className="mt-1 text-xs opacity-70 dark:text-white">
Matches topic: <code>ADW300/&lt;site&gt;/<b>{meter || '01'}</b>/</code>
</p>
</div>
<div>
<label className="block text-sm opacity-80 mb-1 dark:text-white">Function</label>
<div className="flex rounded-xl overflow-hidden border border-black/10 dark:border-white/10">
<button
type="button"
onClick={() => setFn('grid')}
className={`flex-1 px-3 py-2 text-sm ${
fn === 'grid'
? 'bg-rtyellow-200 text-black'
: 'bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 dark:text-white'
}`}
>
Grid
</button>
<button
type="button"
onClick={() => setFn('solar')}
className={`flex-1 px-3 py-2 text-sm ${
fn === 'solar'
? 'bg-rtyellow-200 text-black'
: 'bg-black/5 dark:bg-white/5 hover:bg-black/10 dark:hover:bg-white/10 dark:text-white'
}`}
>
Solar
</button>
</div>
</div>
</div>
{/* Date + quick picks */}
<div>
<label className="block text-sm opacity-80 mb-1 dark:text-white">Date</label>
<div className="flex items-center gap-3">
<input
type="date"
value={downloadDate}
onChange={(e) => setDownloadDate(e.target.value)}
className="input input-bordered w-48 pl-2 rounded-lg"
/>
<div className="flex gap-2">
<button
type="button"
className="px-3 py-1 rounded-full text-xs border border-black/10 dark:border-white/15 hover:bg-black/5 dark:hover:bg-white/10 dark:text-white"
onClick={() => setDownloadDate(ymd(new Date()))}
>
Today
</button>
<button
type="button"
className="px-3 py-1 rounded-full text-xs border border-black/10 dark:border-white/15 hover:bg-black/5 dark:hover:bg:white/10 dark:text-white"
onClick={() => {
const d = new Date();
d.setDate(d.getDate() - 1);
setDownloadDate(ymd(d));
}}
>
Yesterday
</button>
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="p-5 sm:p-6 flex justify-end gap-3 border-t border-black/5 dark:border-white/10">
<button
type="button"
className="btn btn-primary bg-red-500 hover:bg-red-600 border-transparent"
onClick={() => setIsDownloadOpen(false)}
disabled={downloading}
>
Cancel
</button>
<button
type="button"
className="btn btn-primary border-transparent"
onClick={downloadExcel}
disabled={downloading || !meter || !downloadDate}
>
{downloading ? 'Preparing…' : 'Download'}
</button>
</div>
</div>
</div>
)}
</div> </div>
</DashboardLayout> </DashboardLayout>

View File

@ -87,7 +87,8 @@ function groupTimeSeries(
const local = new Date( const local = new Date(
date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }) date.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' })
); );
local.setSeconds(0, 0); const snappedMin = Math.floor(local.getMinutes() / 5) * 5;
local.setMinutes(snappedMin, 0, 0);
key = local.toISOString(); key = local.toISOString();
break; break;
} }
@ -334,15 +335,27 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => {
const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value])); const forecastMap = Object.fromEntries(groupedForecast.map(d => [d.time, d.value]));
const dataTimesDay = [ const dataTimesDay = [
...groupedConsumption.map(d => Date.parse(d.time)), ...groupedConsumption.map(d => Date.parse(d.time)),
...groupedGeneration.map(d => Date.parse(d.time)), ...groupedGeneration.map(d => Date.parse(d.time)),
...groupedForecast.map(d => Date.parse(d.time)), ...groupedForecast.map(d => Date.parse(d.time)),
].filter(Number.isFinite).sort((a, b) => a - b); ].filter(Number.isFinite).sort((a, b) => a - b);
const dayGrid = viewMode === 'day' // ---- CHANGED: use a 5-minute grid for day view
? buildTimeGrid(startOfDay(selectedDate), endOfDay(selectedDate), 1) const dayGrid =
: []; viewMode === 'day'
? (() => {
const dayStart = startOfDay(selectedDate).getTime();
const dayEnd = endOfDay(selectedDate).getTime();
if (dataTimesDay.length) {
const minT = Math.max(dayStart, dataTimesDay[0]);
const maxT = Math.min(dayEnd, dataTimesDay[dataTimesDay.length - 1]);
return buildTimeGrid(new Date(minT), new Date(maxT), 5)
}
// no data → keep full day
return buildTimeGrid(new Date(dayStart), new Date(dayEnd), 5);
})()
: [];
const unionTimes = Array.from(new Set([ const unionTimes = Array.from(new Set([
@ -432,15 +445,6 @@ const selectableIndices =
const filteredLabels = allTimes.slice(startIndex, endIndex + 1); const filteredLabels = allTimes.slice(startIndex, endIndex + 1);
const minutesOfDayForLabels =
viewMode === 'day'
? filteredLabels.map((iso) => {
const d = new Date(iso);
const kl = new Date(d.toLocaleString('en-US', { timeZone: 'Asia/Kuala_Lumpur' }));
return kl.getHours() * 60 + kl.getMinutes();
})
: [];
// ---- CHANGED: use nulls for missing buckets (not zeros) // ---- CHANGED: use nulls for missing buckets (not zeros)
const filteredConsumption = filteredLabels.map(t => (t in consumptionMap ? consumptionMap[t] : null)); const filteredConsumption = filteredLabels.map(t => (t in consumptionMap ? consumptionMap[t] : null));
const filteredGeneration = filteredLabels.map(t => (t in generationMap ? generationMap[t] : null)); const filteredGeneration = filteredLabels.map(t => (t in generationMap ? generationMap[t] : null));
@ -482,9 +486,9 @@ const selectableIndices =
borderColor: consumptionColor, borderColor: consumptionColor,
backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor), backgroundColor: (ctx: any) => areaGradient(ctx, consumptionColor),
fill: true, fill: true,
tension: 0.2, tension: 0.4,
spanGaps: true, spanGaps: true,
pointRadius: 0.7, // default is 3, make smaller pointRadius: 1, // default is 3, make smaller
pointHoverRadius: 4, // a bit bigger on hover pointHoverRadius: 4, // a bit bigger on hover
borderWidth: 2, borderWidth: 2,
}, },
@ -494,9 +498,9 @@ const selectableIndices =
borderColor: generationColor, borderColor: generationColor,
backgroundColor: (ctx: any) => areaGradient(ctx, generationColor), backgroundColor: (ctx: any) => areaGradient(ctx, generationColor),
fill: true, fill: true,
tension: 0.2, tension: 0.4,
spanGaps: true, spanGaps: true,
pointRadius: 0.7, // default is 3, make smaller pointRadius: 1, // default is 3, make smaller
pointHoverRadius: 4, // a bit bigger on hover pointHoverRadius: 4, // a bit bigger on hover
borderWidth: 2, borderWidth: 2,
}, },
@ -509,7 +513,7 @@ const selectableIndices =
borderDash: [5, 5], borderDash: [5, 5],
fill: true, fill: true,
spanGaps: true, spanGaps: true,
pointRadius: 1, // default is 3, make smaller pointRadius: 2, // default is 3, make smaller
pointHoverRadius: 4, // a bit bigger on hover pointHoverRadius: 4, // a bit bigger on hover
borderWidth: 2, borderWidth: 2,
} }
@ -519,13 +523,7 @@ const selectableIndices =
const options = { const options = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
normalized: true, // faster lookup
plugins: { plugins: {
decimation: {
enabled: true,
algorithm: 'lttb', // best visual fidelity
samples: 400, // cap points actually drawn (~400 is a good default)
},
legend: { legend: {
position: 'top', position: 'top',
labels: { labels: {
@ -560,7 +558,6 @@ const selectableIndices =
}, },
scales: { scales: {
x: { x: {
type: 'category' as const,
title: { title: {
display: true, display: true,
color: axisColor, color: axisColor,
@ -577,35 +574,8 @@ const selectableIndices =
font: { weight: 'normal' as const }, font: { weight: 'normal' as const },
}, },
ticks: { ticks: {
color: axisColor, color: axisColor,
autoSkip: false, // let our callback decide },
maxRotation: 0,
callback(
this: any,
tickValue: string | number,
index: number,
ticks: any[]
) {
if (viewMode !== 'day') return this.getLabelForValue(tickValue as number);
const scale = this.chart.scales.x;
const min = Math.max(0, Math.floor(scale.min ?? 0));
const max = Math.min(ticks.length - 1, Math.ceil(scale.max ?? ticks.length - 1));
const visibleCount = Math.max(1, max - min + 1);
let step = 30; // ≥ 6h
if (visibleCount < 80) step = 10;// 26h
// On a category scale, tickValue is usually the index (number).
const idx = typeof tickValue === 'number' ? tickValue : index;
const m = minutesOfDayForLabels[idx];
if (m != null && m % step === 0) {
return this.getLabelForValue(idx);
}
return ''; // hide crowded labels
},
},
}, },
y: { y: {
beginAtZero: true, beginAtZero: true,
@ -703,4 +673,12 @@ const selectableIndices =
); );
}; };
export default EnergyLineChart; export default EnergyLineChart;

View File

@ -1,70 +0,0 @@
// utils/export.ts
export type ExportParams = {
baseUrl?: string; // e.g. process.env.NEXT_PUBLIC_API_URL
site: string; // PROJ-0028
suffix?: "grid" | "solar"; // default "grid"
serial?: string | null; // device id like "01"
day?: string; // "YYYY-MM-DD" (preferred)
start?: string; // ISO string (if not using day)
end?: string; // ISO string (if not using day)
columns?: string[]; // optional list of columns
localTz?: string; // default "Asia/Kuala_Lumpur"
};
export function buildExportUrl(p: ExportParams): string {
const {
baseUrl = "",
site,
suffix = "grid",
serial,
day,
start,
end,
columns,
localTz = "Asia/Kuala_Lumpur",
} = p;
const params = new URLSearchParams();
params.set("site", site);
params.set("suffix", suffix);
params.set("local_tz", localTz);
const s = serial?.trim();
if (s) params.set("serial", s);
if (day) {
params.set("day", day); // simple whole-day export
} else {
if (!start || !end) throw new Error("Provide either day=YYYY-MM-DD or both start and end.");
params.set("start", start); // URLSearchParams will encode '+' correctly
params.set("end", end);
}
if (columns?.length) {
// backend expects ?columns=... repeated; append each
columns.forEach(c => params.append("columns", c));
}
// ensure there's a single slash join for /export/xlsx
const root = baseUrl.replace(/\/+$/, "");
return `${root}/export/xlsx?${params.toString()}`;
}
/** Parse filename from Content-Disposition (handles RFC5987 filename*) */
export function getFilenameFromCD(cd: string | null): string | null {
if (!cd) return null;
// filename*=UTF-8''encoded-name.xlsx
const star = /filename\*\s*=\s*([^']*)''([^;]+)/i.exec(cd);
if (star && star[2]) {
try {
return decodeURIComponent(star[2]);
} catch {
return star[2];
}
}
// filename="name.xlsx" OR filename=name.xlsx
const plain = /filename\s*=\s*("?)([^";]+)\1/i.exec(cd);
return plain ? plain[2] : null;
}