Compare commits
	
		
			6 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 00fe939804 | |||
| ed131acab4 | |||
| eac2bb51e2 | |||
| 4c6a1a0cb4 | |||
| f5b41dd230 | |||
| 418f23586b | 
| @ -39,6 +39,7 @@ jobs: | |||||||
|                       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 | ||||||
|  | |||||||
| @ -8,8 +8,10 @@ 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\ | ||||||
| ENV NEXT_PUBLIC_FASTAPI_URL=${NEXT_PUBLIC_FASTAPI_URL} |     NEXT_PUBLIC_GOOGLE_DRIVE_FOLDER_ID | ||||||
|  | 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* ./ | ||||||
|  | |||||||
| @ -14,6 +14,7 @@ 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 }); | ||||||
| @ -336,6 +337,95 @@ 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>; | ||||||
| @ -462,10 +552,151 @@ useEffect(() => { | |||||||
|               <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/<site>/<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> | ||||||
|  | |||||||
| @ -87,8 +87,7 @@ 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' }) | ||||||
|         ); |         ); | ||||||
|         const snappedMin = Math.floor(local.getMinutes() / 5) * 5; |         local.setSeconds(0, 0); | ||||||
|         local.setMinutes(snappedMin, 0, 0); |  | ||||||
|         key = local.toISOString(); |         key = local.toISOString(); | ||||||
|         break; |         break; | ||||||
|       } |       } | ||||||
| @ -341,20 +340,8 @@ const EnergyLineChart = ({ siteId }: EnergyLineChartProps) => { | |||||||
|     ...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); | ||||||
| 
 | 
 | ||||||
|   // ---- CHANGED: use a 5-minute grid for day view
 |   const dayGrid = viewMode === 'day' | ||||||
|   const dayGrid = |   ? buildTimeGrid(startOfDay(selectedDate), endOfDay(selectedDate), 1) | ||||||
|   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); |  | ||||||
|       })() |  | ||||||
|   : []; |   : []; | ||||||
|    |    | ||||||
|        |        | ||||||
| @ -445,6 +432,15 @@ 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)); | ||||||
| @ -486,9 +482,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.4, |         tension: 0.2, | ||||||
|         spanGaps: true, |         spanGaps: true, | ||||||
|         pointRadius: 1,        // default is 3, make smaller
 |         pointRadius: 0.7,        // default is 3, make smaller
 | ||||||
|         pointHoverRadius: 4,   // a bit bigger on hover
 |         pointHoverRadius: 4,   // a bit bigger on hover
 | ||||||
|         borderWidth: 2, |         borderWidth: 2, | ||||||
|       }, |       }, | ||||||
| @ -498,9 +494,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.4, |         tension: 0.2, | ||||||
|         spanGaps: true, |         spanGaps: true, | ||||||
|         pointRadius: 1,        // default is 3, make smaller
 |         pointRadius: 0.7,        // default is 3, make smaller
 | ||||||
|         pointHoverRadius: 4,   // a bit bigger on hover
 |         pointHoverRadius: 4,   // a bit bigger on hover
 | ||||||
|         borderWidth: 2, |         borderWidth: 2, | ||||||
|       }, |       }, | ||||||
| @ -513,7 +509,7 @@ const selectableIndices = | |||||||
|         borderDash: [5, 5], |         borderDash: [5, 5], | ||||||
|         fill: true, |         fill: true, | ||||||
|         spanGaps: true, |         spanGaps: true, | ||||||
|         pointRadius: 2,        // 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, | ||||||
|       } |       } | ||||||
| @ -523,7 +519,13 @@ 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: { | ||||||
| @ -558,6 +560,7 @@ const selectableIndices = | |||||||
|     }, |     }, | ||||||
|     scales: { |     scales: { | ||||||
|       x: { |       x: { | ||||||
|  |         type: 'category' as const, | ||||||
|         title: { |         title: { | ||||||
|           display: true, |           display: true, | ||||||
|           color: axisColor, |           color: axisColor, | ||||||
| @ -575,6 +578,33 @@ const selectableIndices = | |||||||
|         }, |         }, | ||||||
|         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;// 2–6h
 | ||||||
|  | 
 | ||||||
|  |         // 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: { | ||||||
| @ -674,11 +704,3 @@ const selectableIndices = | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default EnergyLineChart; | export default EnergyLineChart; | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
|  | |||||||
							
								
								
									
										70
									
								
								utils/export.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								utils/export.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,70 @@ | |||||||
|  | // 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; | ||||||
|  | } | ||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user