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
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,30 @@ For validation issue lists (multiple items), show them as a `<ul>` inside the no

---

## Don't wrap one-liners used once

A function whose body is a single trivial expression — a ternary, a `??` chain, a
template string — and that is called exactly once is **bloat**. It forces a reader to
jump to the definition and back for no gain. Inline the expression at the call site.

```svelte
<!-- ❌ single-use trivial wrapper -->
<script>
function statusFor(row) { return row.latest ? 'online' : 'loading'; }
</script>
<CwSensorCard status={statusFor(row)} />

<!-- ✅ inlined -->
<CwSensorCard status={row.latest ? 'online' : 'loading'} />
```

Use a named function only when it is **reused** (2+ call sites), **multi-statement**,
or a genuine **multi-branch mapper** (a `switch` / 3+ `if` — inlining those as nested
ternaries hurts readability more than the wrapper does). In Svelte templates, prefer
`{@const}` over a helper function for per-row derived values.

---

## State management

### Form field state
Expand Down
2 changes: 2 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
"dashboard_loading_view": "Loading dashboard view...",
"dashboard_loading_devices": "Loading devices…",
"dashboard_search_placeholder": "Search by location, device name or EUI…",
"dashboard_grid_layout": "Grid layout",
"dashboard_masonry_layout": "Masonry layout",
"error_bad_request_title": "Bad Request",
"error_bad_request_description": "The server could not understand your request. Please check the URL and try again.",
"error_unauthorized_title": "Unauthorized",
Expand Down
2 changes: 2 additions & 0 deletions messages/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@
"dashboard_loading_view": "ダッシュボードを読み込み中...",
"dashboard_loading_devices": "デバイスを読み込み中…",
"dashboard_search_placeholder": "ロケーション、デバイス名、EUIで検索…",
"dashboard_grid_layout": "グリッドレイアウト",
"dashboard_masonry_layout": "モザイクレイアウト",
"error_bad_request_title": "不正なリクエスト",
"error_bad_request_description": "サーバーがリクエストを理解できませんでした。URL を確認して再度お試しください。",
"error_unauthorized_title": "認証が必要です",
Expand Down
75 changes: 49 additions & 26 deletions src/lib/components/dashboard/DashboardCards.svelte
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
<script module lang="ts">
/** Location-card layout: aligned responsive grid, or tightly-packed masonry. */
export type CardLayout = 'grid' | 'masonry';
</script>

<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import {
Expand Down Expand Up @@ -30,7 +35,8 @@
const PAGE_SIZE = 20;
const REFRESH_INTERVAL_MS = 10 * 60 * 1000;

let { filters }: { filters: Filters } = $props();
let { filters, cardLayout = 'grid' }: { filters: Filters; cardLayout?: CardLayout } =
$props();

const app = getAppContext();

Expand Down Expand Up @@ -185,18 +191,6 @@
return { value: v.numeric, unit, label: undefined };
}

function statusFor(row: DashboardRow) {
return row.latest ? 'online' : 'loading';
}

function expireMinutes(row: DashboardRow): number {
return row.upload_interval ?? row.device_type.default_upload_interval ?? 60;
}

function groupLabel(g: DashboardLocationGroup) {
return g.location?.name ?? m.dashboard_no_location();
}

$effect(() => {
// Track filter values AND the auth token so this effect re-runs on filter
// change and once the token arrives after a fresh login — never on internal
Expand Down Expand Up @@ -243,9 +237,12 @@
{:else if groups.length === 0}
<p class="dashboard-cards__empty">{m.dashboard_no_devices()}</p>
{:else}
<div class="dashboard-cards__groups">
<div class="dashboard-cards__groups dashboard-cards__groups--{cardLayout}">
{#each groups as group (group.key)}
<CwLocationCard title={groupLabel(group)} class="dashboard-cards__location">
<CwLocationCard
title={group.location?.name ?? m.dashboard_no_location()}
class="dashboard-cards__location"
>
{#each group.devices as row (row.dev_eui)}
{@const primary = primaryProps(row)}
{@const secondary = secondaryProps(row)}
Expand All @@ -254,15 +251,17 @@
{@const lastSeen = row.last_data_updated_at ?? row.latest?.created_at ?? null}
<CwSensorCard
label={row.name}
status={statusFor(row)}
status={row.latest ? 'online' : 'loading'}
primaryValue={primary.value}
primaryUnit={primary.unit}
primaryLabel={primary.label}
secondaryValue={secondary.value}
secondaryUnit={secondary.unit}
secondaryLabel={secondary.label}
lastSeenAt={row.last_data_updated_at ?? row.latest?.created_at ?? undefined}
expireAfterMinutes={expireMinutes(row)}
expireAfterMinutes={row.upload_interval ??
row.device_type.default_upload_interval ??
60}
storageKey={`dashboard:${row.dev_eui}`}
onExpand={() => loadDetails(row.dev_eui)}
>
Expand Down Expand Up @@ -347,33 +346,57 @@
color: var(--cw-text-muted, #94a3b8);
}

/* Responsive grid of location cards.
Mobile: 1. Tablet: 2. Small laptop: 3. Desktop: 5. */
.dashboard-cards__groups {
/* Responsive layout of location cards.
Columns — Mobile: 1. Tablet: 2. Small laptop: 3. Desktop: 5. */
.dashboard-cards__groups :global(.dashboard-cards__location) {
width: 100%;
min-width: 0;
}

/* Grid layout: aligned rows — a tall card stretches its whole row, leaving
shorter neighbours with empty space below them. */
.dashboard-cards__groups--grid {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
align-items: start;
}
.dashboard-cards__groups :global(.dashboard-cards__location) {
width: 100%;
min-width: 0;

/* Masonry layout: CSS columns pack cards top-to-bottom, so a short card
sits directly under another regardless of how tall neighbours are. */
.dashboard-cards__groups--masonry {
display: block;
columns: 1;
column-gap: 1rem;
}
.dashboard-cards__groups--masonry :global(.dashboard-cards__location) {
break-inside: avoid;
margin-bottom: 1rem;
}

@media (min-width: 640px) {
.dashboard-cards__groups {
.dashboard-cards__groups--grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-cards__groups--masonry {
columns: 2;
}
}
@media (min-width: 1024px) {
.dashboard-cards__groups {
.dashboard-cards__groups--grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dashboard-cards__groups--masonry {
columns: 3;
}
}
@media (min-width: 1280px) {
.dashboard-cards__groups {
.dashboard-cards__groups--grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
.dashboard-cards__groups--masonry {
columns: 5;
}
}

.dashboard-cards__details-list {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,6 @@
let { row }: { row: AirRow } = $props();
let open = $state(false);

function getNoteTitle(note: Note): string {
const trimmedTitle = note.title.trim();
return trimmedTitle.length > 0 ? trimmedTitle : m.display_note_untitled();
}

function getReportVisibilityLabel(note: Note): string {
return note.includeInReport
? m.display_note_report_included()
: m.display_note_report_excluded();
}

function getNoteMeta(note: Note): string {
const timestamp = formatDateTime(note.created_at);
const author = note.created_by.trim();
Expand All @@ -39,12 +28,19 @@
>
<div class="notes-view-dialog">
{#each row.cw_air_annotations ?? [] as note (note.id)}
{@const noteTitle = note.title.trim()}
<article class="notes-view-dialog__note">
<div class="notes-view-dialog__header">
<h3 class="notes-view-dialog__title">{getNoteTitle(note)}</h3>
<h3 class="notes-view-dialog__title">
{noteTitle.length > 0 ? noteTitle : m.display_note_untitled()}
</h3>
<p class="notes-view-dialog__meta">{getNoteMeta(note)}</p>
</div>
<p class="notes-view-dialog__report-status">{getReportVisibilityLabel(note)}</p>
<p class="notes-view-dialog__report-status">
{note.includeInReport
? m.display_note_report_included()
: m.display_note_report_excluded()}
</p>
<p class="notes-view-dialog__body">{note.note}</p>
</article>
{/each}
Expand Down
44 changes: 42 additions & 2 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,29 @@
import Icon from '$lib/components/Icon.svelte';
import { AppPage } from '$lib/components/layout';
import { CwButton, CwSearchInput } from '@cropwatchdevelopment/cwui';
import DashboardCards from '$lib/components/dashboard/DashboardCards.svelte';
import DashboardCards, {
type CardLayout
} from '$lib/components/dashboard/DashboardCards.svelte';
import DashboardTable from '$lib/components/dashboard/DashboardTable.svelte';
import { getAppContext } from '$lib/appContext.svelte';
import { ApiService } from '$lib/api/api.service';
import { m } from '$lib/paraglide/messages.js';
import TABLE_ICON from '$lib/images/icons/table.svg';
import SENSOR_CARDS_ICON from '$lib/images/icons/sensor_cards.svg';
import GRID_VIEW_ICON from '$lib/images/icons/grid_view.svg';
import MASONRY_VIEW_ICON from '$lib/images/icons/masonary.svg';

type DashboardView = 'table' | 'cards';

const VIEW_STORAGE_KEY = 'cropwatch.dashboard.view';
const CARD_LAYOUT_STORAGE_KEY = 'cropwatch.dashboard.cardLayout';
const MOBILE_QUERY = '(max-width: 767px)';
const SEARCH_DEBOUNCE_MS = 300;

const app = getAppContext();

let view = $state<DashboardView>('table');
let cardLayout = $state<CardLayout>('grid');
let viewReady = $state(!browser);

// Free-text search box. `searchName` updates on every keystroke; `debouncedName`
Expand All @@ -48,13 +54,24 @@
if (browser) window.localStorage.setItem(VIEW_STORAGE_KEY, next);
}

function setCardLayout(next: CardLayout) {
cardLayout = next;
if (browser) window.localStorage.setItem(CARD_LAYOUT_STORAGE_KEY, next);
}

onMount(() => {
const stored = window.localStorage.getItem(VIEW_STORAGE_KEY);
if (stored === 'table' || stored === 'cards') {
view = stored;
} else if (window.matchMedia(MOBILE_QUERY).matches) {
view = 'cards';
}

const storedLayout = window.localStorage.getItem(CARD_LAYOUT_STORAGE_KEY);
if (storedLayout === 'grid' || storedLayout === 'masonry') {
cardLayout = storedLayout;
}

viewReady = true;
});

Expand Down Expand Up @@ -106,6 +123,7 @@
</div>
<span class="flex-1"></span>
<div
id="view-layout-selection-section"
class="flex w-full flex-row gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end"
>
<CwButton
Expand All @@ -126,13 +144,35 @@
<Icon src={SENSOR_CARDS_ICON} alt={m.dashboard_sensor_cards_view()} />
{m.dashboard_sensor_cards_view()}
</CwButton>
{#if view === 'cards'}
<div
class="hidden items-center justify-end gap-1 border-t border-slate-600/70 pt-2 sm:border-t-0 sm:border-l sm:pt-0 sm:pl-2 md:flex"
>
<CwButton
class="px-2 text-xs"
size="sm"
variant={cardLayout === 'grid' ? 'info' : 'secondary'}
onclick={() => setCardLayout('grid')}
>
<Icon src={GRID_VIEW_ICON} alt={m.dashboard_grid_layout()} />
</CwButton>
<CwButton
class="px-2 text-xs"
size="sm"
variant={cardLayout === 'masonry' ? 'info' : 'secondary'}
onclick={() => setCardLayout('masonry')}
>
<Icon src={MASONRY_VIEW_ICON} alt={m.dashboard_masonry_layout()} />
</CwButton>
</div>
{/if}
</div>
</div>
</header>

{#if viewReady}
{#if view === 'cards'}
<DashboardCards {filters} />
<DashboardCards {filters} {cardLayout} />
{:else}
<DashboardTable {filters} />
{/if}
Expand Down
19 changes: 12 additions & 7 deletions src/routes/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -140,13 +140,19 @@
// return [allItem, ...locationItems];
// });

// On mobile the sidebar is an overlay covering the page; collapse it after
// the user picks a link or filter so they immediately see the result. On
// larger screens the sidebar is docked, so it is left open.
function closeSidebarOnMobile() {
if (typeof window !== 'undefined' && window.innerWidth < 1024) {
mode = 'hidden';
}
}

// ── Navigate with updated search params on filter change ────
function applyFilter(key: string, value: string) {
// Close Sidebar
if (window.innerWidth < 1024) {
mode = 'closed';
}

closeSidebarOnMobile();

const params = new URL(page.url);
if (value) {
params.searchParams.set(key, value);
Expand All @@ -162,7 +168,7 @@
}
</script>

<CwSideNav bind:mode items={navItems} responsive>
<CwSideNav bind:mode items={navItems} responsive onselect={closeSidebarOnMobile}>
{#snippet header()}
<div class="app-sidebar__brand">
<img src={CROPWATCH_LOGO} alt={m.app_name()} class="app-sidebar__brand-mark" />
Expand All @@ -172,7 +178,6 @@

{#snippet aboveContent()}
<div class="app-sidebar__mobile-utilities lg:hidden">
<p class="app-sidebar__mobile-utilities-label">{m.nav_settings()}</p>
<div class="app-sidebar__mobile-utilities-controls">
<LanguageSwitcher compact />
<CwThemePicker />
Expand Down
Loading
Loading