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
32 changes: 32 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- src/routes/+page.svelte -->
<style>
:global(.app-page.dashboard-page),
:global(.app-page.dashboard-page .app-page__shell) {
flex: 1 1 auto;
min-height: 0;
}
</style>

<AppPage width="full" class="dashboard-page">
<div class="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<!-- header (flex-none) + DashboardDeviceTable or DashboardDeviceCards -->
</div>
</AppPage>
```

**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
Expand Down
15 changes: 15 additions & 0 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,21 @@
});
</script>

<style>
/*
* The dashboard uses viewport-fill layout (internal scroll in table/cards).
* AppPage now uses flex: 1 0 auto globally to allow page-scroll on other routes,
* but the dashboard needs the old bounded/shrinkable behaviour so its child
* scroll containers (CwDataTable fillParent, .dashboard-device-cards__scroll)
* receive a definite height from the flex chain.
*/
:global(.app-page.dashboard-page),
:global(.app-page.dashboard-page .app-page__shell) {
flex: 1 1 auto;
min-height: 0;
}
</style>

<svelte:head>
<title>{m.dashboard_page_title()}</title>
</svelte:head>
Expand Down
22 changes: 5 additions & 17 deletions src/routes/locations/[location_id]/devices/[dev_eui]/csvExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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})?)$/;

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -290,24 +286,16 @@ 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(
getTimeZoneOffsetMilliseconds(date, timeZone) / MILLISECONDS_PER_MINUTE
);
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 {
Expand Down
Loading