diff --git a/CLAUDE.md b/CLAUDE.md
index d99d8c04..4cab4c4f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -426,6 +426,38 @@ Pages such as `/reports` and `/rules` that render a `CwDataTable` **must remain
---
+## Viewport-fill pages (dashboard exception)
+
+The `/` dashboard is a **viewport-fill** page: it fills the full viewport height and handles its own scrolling internally (the table uses `CwDataTable fillParent`; the card view has its own `overflow-y: auto` scroll container). It does **not** scroll via `.app-shell__main`.
+
+Because `AppPage` globally uses `flex: 1 0 auto` (required for list-page scrolling), the dashboard must override that behaviour with a scoped `:global` style so that `AppPage` and its shell are bounded to viewport height and pass a definite height down the flex chain:
+
+```svelte
+
+
+
+
+
+
+
+
+```
+
+**Why this matters — past regression:** Changing `AppPage`'s CSS from `flex: 1 1 auto; min-height: 0` to `flex: 1 0 auto` (no `min-height`) was the correct fix for list-page scrolling, but it silently broke the dashboard because neither the table's internal scroll nor the card view's scroll container received a definite height. The scoped override keeps both modes working without conflict.
+
+**Rules:**
+- Never remove the `flex: 1 1 auto; min-height: 0` override from `.app-page.dashboard-page` — doing so will break scrolling in both the table and card views on the dashboard.
+- Never change the global `AppPage` flex back to `flex: 1 1 auto` to "fix" the dashboard — that re-breaks scrolling on every other page.
+- The dashboard's inner wrapper div **must** keep `flex-1 min-h-0 overflow-hidden` so the bounded height is propagated to the child scroll containers.
+
+---
+
## Checklist for new pages
### UI
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index 1f297113..19caf1b3 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -145,6 +145,21 @@
});
+
+
{m.dashboard_page_title()}
diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts b/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts
index 891fe393..c38f7899 100644
--- a/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts
+++ b/src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts
@@ -22,7 +22,7 @@ interface DownloadCsvOptions {
const CREATED_AT_KEY = 'created_at';
const TIMESTAMP_COLUMN_NAMES = new Set(['created_at', 'timestamp', 'traffic_hour']);
-const MINUTES_PER_HOUR = 60;
+const CSV_ALLOWED_COLUMNS = [CREATED_AT_KEY, 'co2', 'humidity', 'temperature_c', 'moisture', 'ec'];
const MILLISECONDS_PER_MINUTE = 60_000;
const TIMEZONE_SUFFIX_PATTERN = /(?:[zZ]|[+-]\d{2}(?::?\d{2})?)$/;
@@ -155,11 +155,7 @@ function getColumns(rows: CsvRow[]): string[] {
}
}
- const preferredOrder = ['created_at', 'traffic_hour', 'timestamp', 'id', 'dev_eui'];
- const ordered = preferredOrder.filter((key) => keys.delete(key) || false);
- const remaining = Array.from(keys).sort((a, b) => a.localeCompare(b));
-
- return [...ordered, ...remaining];
+ return CSV_ALLOWED_COLUMNS.filter((col) => keys.has(col));
}
function sanitizeFileName(value: string): string {
@@ -290,13 +286,6 @@ function getTimeZoneOffsetMilliseconds(date: Date, timeZone: string): number {
return asUtc - date.getTime();
}
-function formatOffset(offsetMinutes: number): string {
- const sign = offsetMinutes >= 0 ? '+' : '-';
- const absoluteMinutes = Math.abs(offsetMinutes);
- const hours = Math.floor(absoluteMinutes / MINUTES_PER_HOUR);
- const minutes = absoluteMinutes % MINUTES_PER_HOUR;
- return `${sign}${padDatePart(hours)}:${padDatePart(minutes)}`;
-}
function formatDateForTimeZone(date: Date, timeZone: string): string {
const offsetMinutes = Math.round(
@@ -304,10 +293,9 @@ function formatDateForTimeZone(date: Date, timeZone: string): string {
);
const shiftedDate = new Date(date.getTime() + offsetMinutes * MILLISECONDS_PER_MINUTE);
- return [
- `${shiftedDate.getUTCFullYear()}-${padDatePart(shiftedDate.getUTCMonth() + 1)}-${padDatePart(shiftedDate.getUTCDate())}`,
- `${padDatePart(shiftedDate.getUTCHours())}:${padDatePart(shiftedDate.getUTCMinutes())}:${padDatePart(shiftedDate.getUTCSeconds())}.${padDatePart(shiftedDate.getUTCMilliseconds(), 3)}${formatOffset(offsetMinutes)}`
- ].join('T');
+ const datePart = `${shiftedDate.getUTCFullYear()}-${padDatePart(shiftedDate.getUTCMonth() + 1)}-${padDatePart(shiftedDate.getUTCDate())}`;
+ const timePart = `${padDatePart(shiftedDate.getUTCHours())}:${padDatePart(shiftedDate.getUTCMinutes())}:${padDatePart(shiftedDate.getUTCSeconds())}`;
+ return `${datePart} ${timePart}`;
}
function formatFileDateTime(date: Date, timeZone: string): string {