From bd74ee491590a9d5416b0448dc0ec58ebd108edd Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Tue, 28 Jul 2026 00:55:47 +0800 Subject: [PATCH 01/25] fix(api): cap unbounded meter-group/tenant duplicate-check queries These were the last full-collection .get() calls without a limit; add a defense-in-depth .limit(1000) matching the cap used elsewhere. Co-Authored-By: Claude Sonnet 5 --- .../src/features/meter-group/meter-group.validator.ts | 4 ++++ api/functions/src/features/tenant/tenant.validator.ts | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/api/functions/src/features/meter-group/meter-group.validator.ts b/api/functions/src/features/meter-group/meter-group.validator.ts index 3ce921c..6e61c66 100644 --- a/api/functions/src/features/meter-group/meter-group.validator.ts +++ b/api/functions/src/features/meter-group/meter-group.validator.ts @@ -16,9 +16,12 @@ export class MeterGroupValidator { ): Promise { // Use indexed equality query instead of full collection scan const normalizedName = normalizeMeterName(meterName); + // .limit(1000) — defense-in-depth safety net; not expected to bind at this collection's + // scale, but this was previously the one truly uncapped `.get()` in the codebase. const snap = await collectionRef(COLLECTIONS.METER_GROUPS) .where("utility_type", "==", utilityType) .where("is_deleted", "==", false) + .limit(1000) .get(); return snap.docs @@ -44,6 +47,7 @@ export class MeterGroupValidator { const snap = await collectionRef(COLLECTIONS.METER_GROUPS) .where("utility_type", "==", item.utility_type) .where("is_deleted", "==", false) + .limit(1000) .get(); existingByUtilityType.set( item.utility_type, diff --git a/api/functions/src/features/tenant/tenant.validator.ts b/api/functions/src/features/tenant/tenant.validator.ts index a063f02..3f40fb7 100644 --- a/api/functions/src/features/tenant/tenant.validator.ts +++ b/api/functions/src/features/tenant/tenant.validator.ts @@ -19,9 +19,12 @@ export class TenantValidator { ): Promise { // Indexed query scoped to one property — avoids full collection scan const normalizedTenantName = normalizeTenantName(tenantName); + // .limit(1000) — defense-in-depth; already scoped to one property_id, low risk, but matches + // the same-shape cap applied everywhere else in this codebase. const snap = await collectionRef(COLLECTIONS.TENANTS) .where("property_id", "==", propertyId) .where("is_deleted", "==", false) + .limit(1000) .get(); return snap.docs @@ -83,6 +86,7 @@ export class TenantValidator { collectionRef(COLLECTIONS.TENANTS) .where("property_id", "==", propertyId) .where("is_deleted", "==", false) + .limit(1000) .get() ) ); @@ -186,6 +190,7 @@ export class TenantValidator { const snap = collectionRef(COLLECTIONS.TENANTS) .where("property_id", "==", propertyId) .where("is_deleted", "==", false) + .limit(1000) .get(); return snap; }) From 59cd532986bb88058d6c8e00d57a3b7d8958f71e Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Tue, 28 Jul 2026 00:55:52 +0800 Subject: [PATCH 02/25] fix(api): allow cursor pagination combined with meterGroupId/propertyId These filters are applied in-memory by CachedRepository.search() (load-all-then-filter-then-paginate), so cursor pagination works fine alongside them. Only the startDate/endDate path uses a separate Firestore query that can't accept a resumption cursor. Co-Authored-By: Claude Sonnet 5 --- .../src/features/reading/reading.dto.ts | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/api/functions/src/features/reading/reading.dto.ts b/api/functions/src/features/reading/reading.dto.ts index d55c0d7..f9744b1 100644 --- a/api/functions/src/features/reading/reading.dto.ts +++ b/api/functions/src/features/reading/reading.dto.ts @@ -72,20 +72,10 @@ export const GetReadingsQueryDTOSchema = z ), }) .superRefine((value, context) => { - if (value.meterGroupId && value.cursor) { - context.addIssue({ - code: "custom", - message: "cursor cannot be combined with meterGroupId", - path: ["cursor"], - }); - } - if (value.propertyId && value.cursor) { - context.addIssue({ - code: "custom", - message: "cursor cannot be combined with propertyId", - path: ["cursor"], - }); - } + // meterGroupId/propertyId are applied in-memory by CachedRepository.search() + // (load-all-then-filter-then-paginate), so cursor pagination works fine combined + // with them. Only the startDate/endDate path uses a separate Firestore query that + // doesn't accept a resumption cursor (see reading.service.ts search()). if ((value.startDate || value.endDate) && value.cursor) { context.addIssue({ code: "custom", From 28967a53483703bfe44b523c946caced5bc3149c Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Tue, 28 Jul 2026 00:55:59 +0800 Subject: [PATCH 03/25] fix(ui): surface network/auth errors instead of silently logging out apiRequest() previously let a failed fetch (offline, API down) throw an unlabeled error; wrap it into a typed ApiError. The auth listener now retries getMe() with backoff before giving up (handles the brief window right after sign-in where the API may not be reachable yet), and only logs the user out on a genuine 401/403 rather than any error, surfacing the real message via authStore.setError(). Co-Authored-By: Claude Sonnet 5 --- ui/src/lib/api/client.ts | 30 ++++++++++++++++++++++++------ ui/src/lib/stores/auth.svelte.ts | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/ui/src/lib/api/client.ts b/ui/src/lib/api/client.ts index 4ee06ec..1d70699 100644 --- a/ui/src/lib/api/client.ts +++ b/ui/src/lib/api/client.ts @@ -46,9 +46,16 @@ export async function apiRequest(path: string, options: RequestOptions = {}): let response: Response; try { - response = await fetch(url, { ...fetchOptions, headers, signal: controller.signal }); - } finally { - clearTimeout(timeoutId); + try { + response = await fetch(url, { ...fetchOptions, headers, signal: controller.signal }); + } finally { + clearTimeout(timeoutId); + } + } catch { + throw { + status: 0, + message: 'Could not reach the server. Check your connection and that the API is running.' + } satisfies ApiError; } // Handle 401 by force-refreshing token and retrying once @@ -62,9 +69,20 @@ export async function apiRequest(path: string, options: RequestOptions = {}): const retryController = new AbortController(); const retryTimeoutId = setTimeout(() => retryController.abort(), 20_000); try { - response = await fetch(url, { ...fetchOptions, headers, signal: retryController.signal }); - } finally { - clearTimeout(retryTimeoutId); + try { + response = await fetch(url, { + ...fetchOptions, + headers, + signal: retryController.signal + }); + } finally { + clearTimeout(retryTimeoutId); + } + } catch { + throw { + status: 0, + message: 'Could not reach the server. Check your connection and that the API is running.' + } satisfies ApiError; } } } diff --git a/ui/src/lib/stores/auth.svelte.ts b/ui/src/lib/stores/auth.svelte.ts index d6daf7d..bf86fcc 100644 --- a/ui/src/lib/stores/auth.svelte.ts +++ b/ui/src/lib/stores/auth.svelte.ts @@ -3,6 +3,32 @@ import { onAuthStateChanged } from 'firebase/auth'; import { auth } from '$lib/firebase'; import { getMe } from '$lib/api/auth'; import type { AuthUser } from '$lib/types/auth.types'; +import type { ApiError } from '$lib/types/api.types'; + +const ME_RETRY_DELAYS_MS = [500, 1500, 3000]; + +function isAuthApiError(error: unknown): error is ApiError { + return typeof error === 'object' && error !== null && 'status' in error; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function getMeWithRetry(): Promise { + for (let attempt = 0; ; attempt++) { + try { + return await getMe(); + } catch (error) { + const isRealAuthFailure = + isAuthApiError(error) && (error.status === 401 || error.status === 403); + if (isRealAuthFailure || attempt >= ME_RETRY_DELAYS_MS.length) { + throw error; + } + await sleep(ME_RETRY_DELAYS_MS[attempt]); + } + } +} export interface AuthState { isAuthenticated: boolean; @@ -40,10 +66,12 @@ export function initAuthListener(): () => void { const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => { if (firebaseUser) { try { - const user = await getMe(); + const user = await getMeWithRetry(); authStore.login(user); - } catch { + } catch (error) { + const message = isAuthApiError(error) ? error.message : 'Failed to load your profile'; authStore.logout(); + authStore.setError(message); } } else { authStore.logout(); From b6f775f0acfc14116a667f25d1c6c224bddf06a5 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Tue, 28 Jul 2026 00:56:04 +0800 Subject: [PATCH 04/25] fix(ui): sign out via Firebase auth and redirect to login handleSignOut() called authStore.logout() alone, which only cleared local state without calling Firebase signOut() or navigating away, leaving the user on a protected page with a stale Firebase session. Co-Authored-By: Claude Sonnet 5 --- ui/src/routes/(app)/settings/+page.svelte | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/src/routes/(app)/settings/+page.svelte b/ui/src/routes/(app)/settings/+page.svelte index b7d9f35..e026ac0 100644 --- a/ui/src/routes/(app)/settings/+page.svelte +++ b/ui/src/routes/(app)/settings/+page.svelte @@ -1,5 +1,8 @@ + +{#if confirmState.open} + +{/if} diff --git a/mobile/src/components/Toast.svelte b/mobile/src/components/Toast.svelte new file mode 100644 index 0000000..a3d5052 --- /dev/null +++ b/mobile/src/components/Toast.svelte @@ -0,0 +1,29 @@ + + +
+ {#each toastState.toasts as toast (toast.id)} +
+

{toast.message}

+ +
+ {/each} +
diff --git a/mobile/src/lib/stores/auth-notice.svelte.ts b/mobile/src/lib/stores/auth-notice.svelte.ts new file mode 100644 index 0000000..8ff7d23 --- /dev/null +++ b/mobile/src/lib/stores/auth-notice.svelte.ts @@ -0,0 +1,25 @@ +let message = $state(null); +let manualSignOut = false; + +export const authNotice = { + get message() { + return message; + }, + clear() { + message = null; + } +}; + +export function markManualSignOut() { + manualSignOut = true; +} + +export function consumeManualSignOutFlag(): boolean { + const was = manualSignOut; + manualSignOut = false; + return was; +} + +export function setSessionExpired() { + message = 'Your session expired — please sign in again.'; +} diff --git a/mobile/src/lib/stores/confirm.svelte.ts b/mobile/src/lib/stores/confirm.svelte.ts new file mode 100644 index 0000000..9b82562 --- /dev/null +++ b/mobile/src/lib/stores/confirm.svelte.ts @@ -0,0 +1,72 @@ +interface ConfirmState { + open: boolean; + title: string; + message: string; + confirmLabel: string; + cancelLabel: string; + danger: boolean; +} + +interface ConfirmOptions { + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; +} + +let state = $state({ + open: false, + title: '', + message: '', + confirmLabel: 'Confirm', + cancelLabel: 'Cancel', + danger: false +}); + +let resolver: ((value: boolean) => void) | null = null; + +export const confirmState = { + get open() { + return state.open; + }, + get title() { + return state.title; + }, + get message() { + return state.message; + }, + get confirmLabel() { + return state.confirmLabel; + }, + get cancelLabel() { + return state.cancelLabel; + }, + get danger() { + return state.danger; + } +}; + +export function confirmAsync( + title: string, + message: string, + options?: ConfirmOptions +): Promise { + // Resolve any stale pending confirm as cancelled before opening a new one. + resolver?.(false); + state = { + open: true, + title, + message, + confirmLabel: options?.confirmLabel ?? 'Confirm', + cancelLabel: options?.cancelLabel ?? 'Cancel', + danger: options?.danger ?? false + }; + return new Promise((resolve) => { + resolver = resolve; + }); +} + +export function resolveConfirm(result: boolean) { + state = { ...state, open: false }; + resolver?.(result); + resolver = null; +} diff --git a/mobile/src/lib/stores/toast.svelte.ts b/mobile/src/lib/stores/toast.svelte.ts new file mode 100644 index 0000000..56d8af7 --- /dev/null +++ b/mobile/src/lib/stores/toast.svelte.ts @@ -0,0 +1,28 @@ +export type ToastVariant = 'success' | 'warning' | 'error'; + +export interface ToastMessage { + id: string; + message: string; + variant: ToastVariant; +} + +const AUTO_DISMISS_MS = 4000; +const MAX_STACKED = 2; + +let toasts = $state([]); + +export const toastState = { + get toasts() { + return toasts; + } +}; + +export function pushToast(message: string, variant: ToastVariant = 'success') { + const id = Math.random().toString(36).slice(2); + toasts = [...toasts, { id, message, variant }].slice(-MAX_STACKED); + setTimeout(() => dismissToast(id), AUTO_DISMISS_MS); +} + +export function dismissToast(id: string) { + toasts = toasts.filter((t) => t.id !== id); +} diff --git a/mobile/src/lib/utils/errors.ts b/mobile/src/lib/utils/errors.ts new file mode 100644 index 0000000..8c91cb9 --- /dev/null +++ b/mobile/src/lib/utils/errors.ts @@ -0,0 +1,3 @@ +export function getErrorMessage(err: unknown, fallback: string): string { + return err instanceof Error ? err.message : fallback; +} diff --git a/mobile/src/lib/utils/navigation.ts b/mobile/src/lib/utils/navigation.ts new file mode 100644 index 0000000..58e5ca0 --- /dev/null +++ b/mobile/src/lib/utils/navigation.ts @@ -0,0 +1,3 @@ +export function goToHash(hash: string) { + window.location.hash = hash; +} From 05b9e4e8460903c0779a2ed1968a6f55213b1c8c Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Tue, 28 Jul 2026 09:32:04 +0800 Subject: [PATCH 10/25] fix(mobile): wire a11y toast/confirm/focus-management into screens Replaces native confirm()/alert() with the new ConfirmSheet/Toast components, adds aria-live route announcements, focus-on-navigate for screen readers, and session-expired notices across all screens. --- mobile/src/App.svelte | 26 +++++ mobile/src/components/BottomNav.svelte | 4 +- mobile/src/screens/Billings.svelte | 135 +++++++++++++--------- mobile/src/screens/CaptureReadings.svelte | 67 +++++++++-- mobile/src/screens/Home.svelte | 53 +++++++-- mobile/src/screens/Login.svelte | 5 +- mobile/src/screens/ReadingHistory.svelte | 32 ++++- mobile/src/screens/Settings.svelte | 30 ++++- 8 files changed, 264 insertions(+), 88 deletions(-) diff --git a/mobile/src/App.svelte b/mobile/src/App.svelte index 22229c3..14cc000 100644 --- a/mobile/src/App.svelte +++ b/mobile/src/App.svelte @@ -7,16 +7,34 @@ import ReadingHistory from './screens/ReadingHistory.svelte'; import Billings from './screens/Billings.svelte'; import Settings from './screens/Settings.svelte'; + import ConfirmSheet from './components/ConfirmSheet.svelte'; + import Toast from './components/Toast.svelte'; + import { consumeManualSignOutFlag, setSessionExpired } from './lib/stores/auth-notice.svelte'; let currentScreen = $state('login'); let user = $state(auth.currentUser); + let announcement = $state(''); + + const screenTitles: Record = { + home: 'Home', + capture: 'Capture Readings', + history: 'Reading History', + billings: 'Billings', + settings: 'Settings' + }; $effect(() => { const unsubscribe = auth.onAuthStateChanged((newUser) => { + const wasLoggedIn = !!user; user = newUser; if (newUser && currentScreen === 'login') { currentScreen = 'home'; } else if (!newUser) { + if (wasLoggedIn && !consumeManualSignOutFlag()) { + setSessionExpired(); + } else { + consumeManualSignOutFlag(); + } currentScreen = 'login'; } }); @@ -28,6 +46,9 @@ const hash = window.location.hash.slice(2); if (hash && ['home', 'capture', 'history', 'billings', 'settings'].includes(hash)) { currentScreen = hash; + announcement = screenTitles[hash] ?? ''; + } else if (hash) { + window.location.hash = '#/home'; } }; @@ -37,6 +58,8 @@ }); +
{announcement}
+ {#if !user} {:else if currentScreen === 'home'} @@ -50,3 +73,6 @@ {:else if currentScreen === 'settings'} {/if} + + + diff --git a/mobile/src/components/BottomNav.svelte b/mobile/src/components/BottomNav.svelte index 4a611ee..3343d67 100644 --- a/mobile/src/components/BottomNav.svelte +++ b/mobile/src/components/BottomNav.svelte @@ -15,7 +15,7 @@ ] as const; -
+
+ diff --git a/mobile/src/screens/Billings.svelte b/mobile/src/screens/Billings.svelte index d1bf5da..ad92641 100644 --- a/mobile/src/screens/Billings.svelte +++ b/mobile/src/screens/Billings.svelte @@ -1,4 +1,5 @@ -
+
- -

New Reading Session

+ +

New Reading Session

+ + Step {step} of 3 + +
{#if error}
{error}
{/if} + {#if resultBanner} +
+ {resultBanner.message} +
+ {/if} + {#if step === 1}
@@ -277,6 +326,9 @@

Reading date: {readingDate}

+

+ {filledCount} of {properties.length} properties done +

{#each properties as property (property.id)}
@@ -400,6 +452,5 @@
{/if} - - +
diff --git a/mobile/src/screens/Home.svelte b/mobile/src/screens/Home.svelte index 8dcb0fc..0da7b00 100644 --- a/mobile/src/screens/Home.svelte +++ b/mobile/src/screens/Home.svelte @@ -1,4 +1,5 @@ diff --git a/mobile/src/screens/Login.svelte b/mobile/src/screens/Login.svelte index 68b06c4..0a496d7 100644 --- a/mobile/src/screens/Login.svelte +++ b/mobile/src/screens/Login.svelte @@ -2,12 +2,15 @@ import { signInWithEmailAndPassword } from 'firebase/auth'; import { auth } from '../firebase'; import { getReadableAuthError } from '../lib/utils/auth-errors'; + import { authNotice } from '../lib/stores/auth-notice.svelte'; let email = $state(''); let password = $state(''); - let error = $state(''); + let error = $state(authNotice.message ?? ''); let loading = $state(false); + authNotice.clear(); + async function handleLogin(e: Event) { e.preventDefault(); loading = true; diff --git a/mobile/src/screens/ReadingHistory.svelte b/mobile/src/screens/ReadingHistory.svelte index 2670e77..6bc7371 100644 --- a/mobile/src/screens/ReadingHistory.svelte +++ b/mobile/src/screens/ReadingHistory.svelte @@ -1,4 +1,5 @@
- -

Settings

+ +

Settings

{#if error}
{error} - +
{/if} -
+

Account

@@ -70,7 +88,7 @@
-
+
From e3a20d1c7038fb917fdc2e471e938f7cc90e6be1 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Tue, 28 Jul 2026 09:32:19 +0800 Subject: [PATCH 11/25] feat(ui): add accessible confirm-dialog, toast, and nav-counts infrastructure Adds a Promise-based confirmAsync() dialog and toast notifications to replace native window.confirm() calls, plus a nav-counts store for accessible badge announcements in the sidebar. --- .../components/shared/ConfirmDialog.svelte | 76 +++++++++++++++++++ ui/src/lib/components/shared/Toast.svelte | 28 +++++++ ui/src/lib/stores/confirm.svelte.ts | 72 ++++++++++++++++++ ui/src/lib/stores/nav-counts.svelte.ts | 60 +++++++++++++++ ui/src/lib/stores/toast.svelte.ts | 28 +++++++ 5 files changed, 264 insertions(+) create mode 100644 ui/src/lib/components/shared/ConfirmDialog.svelte create mode 100644 ui/src/lib/components/shared/Toast.svelte create mode 100644 ui/src/lib/stores/confirm.svelte.ts create mode 100644 ui/src/lib/stores/nav-counts.svelte.ts create mode 100644 ui/src/lib/stores/toast.svelte.ts diff --git a/ui/src/lib/components/shared/ConfirmDialog.svelte b/ui/src/lib/components/shared/ConfirmDialog.svelte new file mode 100644 index 0000000..b16f83c --- /dev/null +++ b/ui/src/lib/components/shared/ConfirmDialog.svelte @@ -0,0 +1,76 @@ + + +{#if confirmState.open} + +{/if} diff --git a/ui/src/lib/components/shared/Toast.svelte b/ui/src/lib/components/shared/Toast.svelte new file mode 100644 index 0000000..2117373 --- /dev/null +++ b/ui/src/lib/components/shared/Toast.svelte @@ -0,0 +1,28 @@ + + +
+ {#each toastState.toasts as toast (toast.id)} +
+

{toast.message}

+ +
+ {/each} +
diff --git a/ui/src/lib/stores/confirm.svelte.ts b/ui/src/lib/stores/confirm.svelte.ts new file mode 100644 index 0000000..4f601ab --- /dev/null +++ b/ui/src/lib/stores/confirm.svelte.ts @@ -0,0 +1,72 @@ +interface ConfirmState { + open: boolean; + title: string; + message: string; + confirmLabel: string; + cancelLabel: string; + danger: boolean; +} + +interface ConfirmOptions { + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; +} + +let state = $state({ + open: false, + title: '', + message: '', + confirmLabel: 'Confirm', + cancelLabel: 'Cancel', + danger: false +}); + +let resolver: ((value: boolean) => void) | null = null; + +export const confirmState = { + get open() { + return state.open; + }, + get title() { + return state.title; + }, + get message() { + return state.message; + }, + get confirmLabel() { + return state.confirmLabel; + }, + get cancelLabel() { + return state.cancelLabel; + }, + get danger() { + return state.danger; + } +}; + +export function confirmAsync( + title: string, + message: string, + options?: ConfirmOptions +): Promise { + // Resolve any stale pending confirm as cancelled before opening a new one. + resolver?.(false); + state = { + open: true, + title, + message, + confirmLabel: options?.confirmLabel ?? 'Confirm', + cancelLabel: options?.cancelLabel ?? 'Cancel', + danger: options?.danger ?? false + }; + return new Promise((resolve) => { + resolver = resolve; + }); +} + +export function resolveConfirm(result: boolean) { + state = { ...state, open: false }; + resolver?.(result); + resolver = null; +} diff --git a/ui/src/lib/stores/nav-counts.svelte.ts b/ui/src/lib/stores/nav-counts.svelte.ts new file mode 100644 index 0000000..de16ca8 --- /dev/null +++ b/ui/src/lib/stores/nav-counts.svelte.ts @@ -0,0 +1,60 @@ +import { getMeterGroups } from '$lib/api/meter-groups'; +import { getProperties } from '$lib/api/properties'; +import { getTenants } from '$lib/api/tenants'; +import { getReadings } from '$lib/api/readings'; +import { getBillings } from '$lib/api/billings'; + +interface NavCounts { + meterGroups: number | null; + properties: number | null; + tenants: number | null; + readings: number | null; + billings: number | null; +} + +let counts = $state({ + meterGroups: null, + properties: null, + tenants: null, + readings: null, + billings: null +}); + +let loaded = false; + +export const navCounts = { + get meterGroups() { + return counts.meterGroups; + }, + get properties() { + return counts.properties; + }, + get tenants() { + return counts.tenants; + }, + get readings() { + return counts.readings; + }, + get billings() { + return counts.billings; + } +}; + +export async function loadNavCounts() { + if (loaded) return; + loaded = true; + const [meterGroups, properties, tenants, readings, billings] = await Promise.all([ + getMeterGroups({ limit: 100 }).catch(() => null), + getProperties({ limit: 100 }).catch(() => null), + getTenants({ limit: 100 }).catch(() => null), + getReadings({ limit: 100 }).catch(() => null), + getBillings({ limit: 100 }).catch(() => null) + ]); + counts = { + meterGroups: meterGroups?.data.length ?? null, + properties: properties?.data.length ?? null, + tenants: tenants?.data.length ?? null, + readings: readings?.data.length ?? null, + billings: billings?.data.length ?? null + }; +} diff --git a/ui/src/lib/stores/toast.svelte.ts b/ui/src/lib/stores/toast.svelte.ts new file mode 100644 index 0000000..a1b8ad5 --- /dev/null +++ b/ui/src/lib/stores/toast.svelte.ts @@ -0,0 +1,28 @@ +export type ToastVariant = 'success' | 'warning' | 'error'; + +export interface ToastMessage { + id: string; + message: string; + variant: ToastVariant; +} + +const AUTO_DISMISS_MS = 4000; +const MAX_STACKED = 2; + +let toasts = $state([]); + +export const toastState = { + get toasts() { + return toasts; + } +}; + +export function pushToast(message: string, variant: ToastVariant = 'success') { + const id = Math.random().toString(36).slice(2); + toasts = [...toasts, { id, message, variant }].slice(-MAX_STACKED); + setTimeout(() => dismissToast(id), AUTO_DISMISS_MS); +} + +export function dismissToast(id: string) { + toasts = toasts.filter((t) => t.id !== id); +} From 2c1b71f4ca9da13196cefd45f93a43e5edc4944a Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Tue, 28 Jul 2026 09:32:29 +0800 Subject: [PATCH 12/25] fix(ui): wire a11y toast/confirm dialogs into layout, sidebar, and CRUD pages Replaces native confirm()/alert() in the crud store, ChatWidget, and PhotoDropzone with confirmAsync()/toast, and wires the sidebar nav-counts store into the app layout for accessible badge updates. --- ui/src/lib/components/layout/Sidebar.svelte | 18 +-- .../lib/components/shared/ChatWidget.svelte | 35 ++++- .../components/shared/PhotoDropzone.svelte | 87 ++++++------ ui/src/lib/stores/crud.svelte.ts | 14 +- ui/src/routes/(app)/+layout.svelte | 8 ++ ui/src/routes/(app)/billings/+page.svelte | 42 +++--- ui/src/routes/(app)/bills/+page.svelte | 8 ++ ui/src/routes/(app)/meter-groups/+page.svelte | 18 ++- ui/src/routes/(app)/properties/+page.svelte | 133 +++++++++--------- ui/src/routes/(app)/readings/+page.svelte | 73 +++++++--- ui/src/routes/(app)/tenants/+page.svelte | 9 +- 11 files changed, 280 insertions(+), 165 deletions(-) diff --git a/ui/src/lib/components/layout/Sidebar.svelte b/ui/src/lib/components/layout/Sidebar.svelte index 15ab786..eb5d05d 100644 --- a/ui/src/lib/components/layout/Sidebar.svelte +++ b/ui/src/lib/components/layout/Sidebar.svelte @@ -6,6 +6,7 @@ import { auth } from '$lib/firebase'; import { getInitials } from '$lib/utils/format'; import { authStore, type AuthState } from '$lib/stores/auth.svelte'; + import { navCounts } from '$lib/stores/nav-counts.svelte'; type NavHref = | '/dashboard' @@ -20,19 +21,19 @@ interface NavItem { label: string; href: NavHref; - badge?: number; + badge?: number | null; } - const navItems: NavItem[] = [ + const navItems: NavItem[] = $derived([ { label: 'Home', href: '/dashboard' }, - { label: 'Meter Groups', href: '/meter-groups', badge: 0 }, - { label: 'Properties', href: '/properties', badge: 4 }, - { label: 'Tenants', href: '/tenants', badge: 12 }, - { label: 'Readings', href: '/readings', badge: 0 }, - { label: 'Billings', href: '/billings', badge: 2 }, + { label: 'Meter Groups', href: '/meter-groups', badge: navCounts.meterGroups }, + { label: 'Properties', href: '/properties', badge: navCounts.properties }, + { label: 'Tenants', href: '/tenants', badge: navCounts.tenants }, + { label: 'Readings', href: '/readings', badge: navCounts.readings }, + { label: 'Billings', href: '/billings', badge: navCounts.billings }, { label: 'Reports', href: '/reports' }, { label: 'Settings', href: '/settings' } - ]; + ]); let isLoggingOut = $state(false); let authState = $state({ @@ -80,6 +81,7 @@ {#each navItems as item (item.href)}
import { sendChatMessage } from '$lib/api/chat'; + import { confirmAsync } from '$lib/stores/confirm.svelte'; interface ChatMessage { role: 'user' | 'assistant'; content: string; } + const STORAGE_KEY = 'chatWidget.messages'; + + function loadPersistedMessages(): ChatMessage[] { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } + } + let isOpen = $state(false); - let messages = $state([]); + let messages = $state(loadPersistedMessages()); let draft = $state(''); let isSending = $state(false); let error = $state(''); + let inputEl: HTMLInputElement | undefined = $state(); + + $effect(() => { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); + }); function toggleOpen() { isOpen = !isOpen; + if (isOpen) { + inputEl?.focus(); + } } async function handleSubmit(e: Event) { @@ -37,9 +57,13 @@ } } - function handleClearChat() { - // Nothing is persisted server-side — clearing here discards the - // conversation for good, there's no history to restore. + async function handleClearChat() { + const confirmed = await confirmAsync( + 'Clear chat', + 'Clear this conversation? Nothing is saved server-side, so it cannot be restored.', + { danger: true, confirmLabel: 'Clear' } + ); + if (!confirmed) return; messages = []; error = ''; } @@ -64,7 +88,7 @@ -
+
{#if messages.length === 0}

Ask about your usage, accumulation, or billing trends. @@ -88,6 +112,7 @@ - import { Camera, Upload, Eye } from 'lucide-svelte'; + import { Camera, Upload, Eye, RefreshCw } from 'lucide-svelte'; let { imageUrl = null, + alt = 'Captured meter reading photo', isBusy = false, disabled = false, onFile, onPreview }: { imageUrl?: string | null; + alt?: string; isBusy?: boolean; disabled?: boolean; onFile: (file: File) => void; @@ -56,50 +58,55 @@ - + + {#if imageUrl && onPreview} + + {/if} + + {#if imageUrl && !disabled}

+ Click or drag to replace
- {#if onPreview} -
e.key === 'Enter' && handlePreviewClick(e as unknown as MouseEvent)} - class="absolute top-1.5 right-1.5 rounded-full bg-black/60 p-1.5 text-white hover:bg-black/80" - aria-label="View full-size photo" - > - -
- {/if} - {:else} -
- {#if isBusy} - - Processing... - {:else} - - Click to add photo / drag photo here - {/if} -
{/if} - +
{ selectedIds: Set; @@ -12,14 +13,14 @@ export interface CrudStore { id: string, deleteFn: (id: string) => Promise, reload: () => Promise, - confirmFn?: (id: string) => boolean + confirmFn?: (id: string) => boolean | Promise ): Promise; isBatchDeleting: boolean; handleBatchDelete( deleteFn: (id: string) => Promise, reload: () => Promise, - confirmFn?: (count: number) => boolean + confirmFn?: (count: number) => boolean | Promise ): Promise; editModalOpen: boolean; @@ -92,9 +93,9 @@ export function createCrudStore(): CrudStore { id, deleteFn, reload, - confirmFn = () => window.confirm('Archive this item?') + confirmFn = () => confirmAsync('Archive item', 'Archive this item?', { danger: true }) ) { - if (!confirmFn(id)) return; + if (!(await confirmFn(id))) return; deletingId = id; isDeleting = true; error = ''; @@ -112,10 +113,11 @@ export function createCrudStore(): CrudStore { async handleBatchDelete( deleteFn, reload, - confirmFn = (n) => window.confirm(`Archive ${n} item(s)?`) + confirmFn = (n) => + confirmAsync('Archive items', `Archive ${n} item(s)?`, { danger: true }) ) { if (selectedIds.size === 0) return; - if (!confirmFn(selectedIds.size)) return; + if (!(await confirmFn(selectedIds.size))) return; isBatchDeleting = true; error = ''; try { diff --git a/ui/src/routes/(app)/+layout.svelte b/ui/src/routes/(app)/+layout.svelte index 462e5e1..5f9e710 100644 --- a/ui/src/routes/(app)/+layout.svelte +++ b/ui/src/routes/(app)/+layout.svelte @@ -5,7 +5,10 @@ import TopBar from '$lib/components/layout/TopBar.svelte'; import RightPanel from '$lib/components/layout/RightPanel.svelte'; import ChatWidget from '$lib/components/shared/ChatWidget.svelte'; + import ConfirmDialog from '$lib/components/shared/ConfirmDialog.svelte'; + import Toast from '$lib/components/shared/Toast.svelte'; import { authStore, initAuthListener, type AuthState } from '$lib/stores/auth.svelte'; + import { loadNavCounts } from '$lib/stores/nav-counts.svelte'; let { children } = $props(); @@ -30,6 +33,8 @@ $effect(() => { if (!authState.isLoading && !authState.isAuthenticated) { goto(resolve('/login')); + } else if (authState.isAuthenticated) { + loadNavCounts(); } }); @@ -56,4 +61,7 @@ {#if authState.user?.role === 'admin'} {/if} + + +
diff --git a/ui/src/routes/(app)/billings/+page.svelte b/ui/src/routes/(app)/billings/+page.svelte index 34d7838..e754352 100644 --- a/ui/src/routes/(app)/billings/+page.svelte +++ b/ui/src/routes/(app)/billings/+page.svelte @@ -34,6 +34,8 @@ import EditModal from '$lib/components/shared/EditModal.svelte'; import StatusPill from '$lib/components/shared/StatusPill.svelte'; import { createCrudStore } from '$lib/stores/crud.svelte'; + import { confirmAsync } from '$lib/stores/confirm.svelte'; + import { pushToast } from '$lib/stores/toast.svelte'; import { CheckCircle2, Pencil, Archive, Printer, Plus, ChevronRight } from 'lucide-svelte'; const crud = createCrudStore(); @@ -1584,12 +1586,13 @@ {/each} @@ -795,25 +843,6 @@ {:else if activeTab === 'readings'}
- -
-
- {#each [['all', 'All'], ['electricity', 'Electricity'], ['water', 'Water']] as [value, label] (value)} - - {/each} -
-
- {#if filteredReadings.length === 0}
{:else if activeTab === 'billings'}
- -
-
- {#each [['all', 'All'], ['electricity', 'Electricity'], ['water', 'Water']] as [value, label] (value)} - - {/each} -
-
- {#if filteredBillings.length === 0}
- diff --git a/ui/src/routes/(app)/readings/+page.svelte b/ui/src/routes/(app)/readings/+page.svelte index 3878ffb..07218f4 100644 --- a/ui/src/routes/(app)/readings/+page.svelte +++ b/ui/src/routes/(app)/readings/+page.svelte @@ -28,6 +28,8 @@ import ImagePreview from '$lib/components/shared/ImagePreview.svelte'; import PhotoDropzone from '$lib/components/shared/PhotoDropzone.svelte'; import { createCrudStore } from '$lib/stores/crud.svelte'; + import { confirmAsync } from '$lib/stores/confirm.svelte'; + import { pushToast } from '$lib/stores/toast.svelte'; import { Archive, Plus, X } from 'lucide-svelte'; const crud = createCrudStore(); @@ -91,6 +93,7 @@ let batchDate = $state(new Date().toISOString().split('T')[0]); let batchRows = $state([]); let batchLoading = $state(false); + let batchEmptyReason = $state('No properties found for this meter group'); // "Month day, Year" preview of the batch date, parsed as a local date to avoid // the UTC-midnight/local-timezone off-by-one shift new Date(batchDate) would cause. @@ -194,6 +197,30 @@ resetManualReadingForm(); } + function hasUnsavedBatchData() { + return batchRows.some((row) => row.reading_amount !== null || row.image_url); + } + + function hasUnsavedManualData() { + return manualReadingForm.reading_amount !== null || manualReadingForm.image_url !== ''; + } + + async function switchReadingFormTab(tab: 'batch' | 'manual') { + if (tab === readingFormTab) return; + const hasUnsaved = + readingFormTab === 'batch' ? hasUnsavedBatchData() : hasUnsavedManualData(); + if (hasUnsaved) { + const confirmed = await confirmAsync( + 'Discard entered readings?', + 'Switching tabs will discard the readings entered here — continue?', + { danger: true, confirmLabel: 'Discard' } + ); + if (!confirmed) return; + } + readingFormTab = tab; + resetReadingForm(); + } + async function loadBatchProperties() { if (!selectedMeterGroup) { error = 'Please select a meter group first'; @@ -208,7 +235,10 @@ const utilityType = selectedMeter?.utility_type || 'electricity'; if (result.data.length === 0) { - error = 'No properties found for this meter group'; + // No error banner here — the "No properties" EmptyState below already + // communicates this; a red banner on top of it would be redundant and + // wrongly implies a failure rather than an empty selection. + batchEmptyReason = 'No properties found for this meter group'; batchRows = []; } else { const filteredProperties = result.data.filter((property) => { @@ -222,7 +252,7 @@ }); if (filteredProperties.length === 0) { - error = 'No submeter properties found for this meter group (all are main meters)'; + batchEmptyReason = 'No submeter properties found for this meter group (all are main meters)'; batchRows = []; } else { batchRows = filteredProperties.map((property) => ({ @@ -303,10 +333,11 @@ readingFormOpen = false; resetManualReadingForm(); await loadData(); - alert( + pushToast( isSeed ? 'Seed reading created successfully — this establishes the baseline for this meter version.' - : 'Manual reading created successfully. If this property has a previous-month reading, the billing was auto-created.' + : 'Manual reading created successfully. If this property has a previous-month reading, the billing was auto-created.', + 'success' ); } catch (err) { error = err instanceof Error ? err.message : 'Failed to create manual reading'; @@ -402,14 +433,16 @@ await handleMeterGroupChange(); if (result.failed.length > 0) { - const failedSummary = result.failed.map((f) => `Row ${f.index + 1}: ${f.error}`).join('\n'); - alert( + const failedSummary = result.failed.map((f) => `Row ${f.index + 1}: ${f.error}`).join('; '); + pushToast( `${result.created.length} of ${result.created.length + result.failed.length} readings created. ` + - `${result.failed.length} skipped:\n${failedSummary}` + `${result.failed.length} skipped — ${failedSummary}`, + 'warning' ); } else { - alert( - 'Readings created successfully! If a previous-month reading exists for this meter group, billings have been auto-created for each property.' + pushToast( + 'Readings created successfully! If a previous-month reading exists for this meter group, billings have been auto-created for each property.', + 'success' ); } } catch (err) { @@ -509,10 +542,7 @@
{#if readingFormTab === 'batch'} + {#if batchFormError} +
+ {batchFormError} +
+ {/if}
@@ -898,13 +973,13 @@ class="rounded" /> - Property - Meter Group + Property + Meter Group Reading Meter Cycle - Date - Created - Actions + Date + Created + Actions @@ -960,7 +1035,7 @@ } as any); }} onSoftDelete={() => - crud.handleSoftDelete(item.id, softDeleteReading, handleMeterGroupChange, () => + crud.handleSoftDelete(item.id, softDeleteReading, applyFilters, () => confirmAsync( 'Archive reading', 'Archive this reading? It can be restored from the archive.', From 0b8bdaddcc07d8f7ea56a9da33d45cb8002b6c53 Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Tue, 28 Jul 2026 18:24:48 +0800 Subject: [PATCH 17/25] refactor(ui): extract property meter-group-entry helpers and form fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract getMeterGroupId()/isMainMeterEntry() into a new property.util.ts and use them at every typeof entry === 'string' ? entry : entry.meter_group_id (and the paired is_main_meter unwrap) call site in properties/+page.svelte — the most duplicated logic in this batch, spanning loadPropertyDetails, openEditModal, the property list's "Main" badge, and the selected-property header (finding #31, folds in #36). - Extract the ~90-line duplicated meter-group-select + main-meter- checkbox-with-warning block (present in both the create and edit property forms) into a shared PropertyMeterGroupFields component, mirroring how PhotoDropzone is already shared between the readings tabs (finding #35). Findings: #31, #35, #36 (decisions/20260728_ui-mobile-codebase-review-findings.md) Co-Authored-By: Claude Sonnet 5 --- .../shared/PropertyMeterGroupFields.svelte | 116 ++++++++ ui/src/lib/utils/property.util.ts | 15 + ui/src/routes/(app)/properties/+page.svelte | 265 +++--------------- 3 files changed, 167 insertions(+), 229 deletions(-) create mode 100644 ui/src/lib/components/shared/PropertyMeterGroupFields.svelte create mode 100644 ui/src/lib/utils/property.util.ts diff --git a/ui/src/lib/components/shared/PropertyMeterGroupFields.svelte b/ui/src/lib/components/shared/PropertyMeterGroupFields.svelte new file mode 100644 index 0000000..9ea2c0e --- /dev/null +++ b/ui/src/lib/components/shared/PropertyMeterGroupFields.svelte @@ -0,0 +1,116 @@ + + +
+ + +
+
+ + +
+ +{#if meterGroups.electricity || meterGroups.water} +
+ {#if meterGroups.electricity} + + {#if electricityMainMeterProperty !== null && electricityMainMeterProperty !== excludePropertyId && !isMainMeter.electricity} +

+ {getMainMeterPropertyName(meterGroups.electricity)} is already the main meter +

+ {/if} + {/if} + {#if meterGroups.water} + + {#if waterMainMeterProperty !== null && waterMainMeterProperty !== excludePropertyId && !isMainMeter.water} +

+ {getMainMeterPropertyName(meterGroups.water)} is already the main meter +

+ {/if} + {/if} +
+{/if} diff --git a/ui/src/lib/utils/property.util.ts b/ui/src/lib/utils/property.util.ts new file mode 100644 index 0000000..953aff6 --- /dev/null +++ b/ui/src/lib/utils/property.util.ts @@ -0,0 +1,15 @@ +import type { MeterGroupEntry } from '$lib/types/property.types'; + +/** A property's `meter_groups[utilityType]` entry can be a MeterGroupEntry object or, for + * backward compatibility, a bare meter-group-id string. */ +export type PropertyMeterGroupEntry = MeterGroupEntry | string | undefined; + +export function getMeterGroupId(entry: PropertyMeterGroupEntry): string | undefined { + if (!entry) return undefined; + return typeof entry === 'string' ? entry : entry.meter_group_id; +} + +export function isMainMeterEntry(entry: PropertyMeterGroupEntry): boolean { + if (!entry || typeof entry === 'string') return false; + return entry.is_main_meter ?? false; +} diff --git a/ui/src/routes/(app)/properties/+page.svelte b/ui/src/routes/(app)/properties/+page.svelte index 74aaf5d..80a8426 100644 --- a/ui/src/routes/(app)/properties/+page.svelte +++ b/ui/src/routes/(app)/properties/+page.svelte @@ -21,9 +21,11 @@ import { formatFirestoreDate, formatDateTime, formatReading } from '$lib/utils/format'; import { toDate } from '$lib/utils/timestamp'; import { getUtilityTypeBadgeClasses } from '$lib/utils/utility-colors'; + import { getMeterGroupId, isMainMeterEntry } from '$lib/utils/property.util'; import EmptyState from '$lib/components/shared/EmptyState.svelte'; import TableSkeleton from '$lib/components/shared/TableSkeleton.svelte'; import EditModal from '$lib/components/shared/EditModal.svelte'; + import PropertyMeterGroupFields from '$lib/components/shared/PropertyMeterGroupFields.svelte'; import { Plus, Archive, RotateCcw } from 'lucide-svelte'; import ActionButtons from '$lib/components/shared/ActionButtons.svelte'; import SelectionToolbar from '$lib/components/shared/SelectionToolbar.svelte'; @@ -130,13 +132,10 @@ const elecEntry = prop.meter_groups.electricity; const waterEntry = prop.meter_groups.water; - const elecId = typeof elecEntry === 'string' ? elecEntry : elecEntry?.meter_group_id; - const waterId = typeof waterEntry === 'string' ? waterEntry : waterEntry?.meter_group_id; - - if (elecId === meterGroupId && typeof elecEntry !== 'string' && elecEntry?.is_main_meter) { + if (getMeterGroupId(elecEntry) === meterGroupId && isMainMeterEntry(elecEntry)) { return prop.id; } - if (waterId === meterGroupId && typeof waterEntry !== 'string' && waterEntry?.is_main_meter) { + if (getMeterGroupId(waterEntry) === meterGroupId && isMainMeterEntry(waterEntry)) { return prop.id; } } @@ -223,16 +222,8 @@ } else if (activeTab === 'readings') { // Load readings for all available meter groups const promises = []; - const electricityId = meterGroups.electricity - ? typeof meterGroups.electricity === 'string' - ? meterGroups.electricity - : meterGroups.electricity.meter_group_id - : null; - const waterId = meterGroups.water - ? typeof meterGroups.water === 'string' - ? meterGroups.water - : meterGroups.water.meter_group_id - : null; + const electricityId = getMeterGroupId(meterGroups.electricity) ?? null; + const waterId = getMeterGroupId(meterGroups.water) ?? null; if (electricityId) promises.push(getReadings({ meterGroupId: electricityId, propertyId, limit: 50 })); @@ -248,16 +239,8 @@ }; } else if (activeTab === 'billings') { const billingsPromise = getBillings({ propertyId, limit: 50 }); - const electricityId = meterGroups.electricity - ? typeof meterGroups.electricity === 'string' - ? meterGroups.electricity - : meterGroups.electricity.meter_group_id - : null; - const waterId = meterGroups.water - ? typeof meterGroups.water === 'string' - ? meterGroups.water - : meterGroups.water.meter_group_id - : null; + const electricityId = getMeterGroupId(meterGroups.electricity) ?? null; + const waterId = getMeterGroupId(meterGroups.water) ?? null; const readingPromises = []; if (electricityId) @@ -400,26 +383,10 @@ } function openEditModal(property: Property) { - const electricityId = property.meter_groups.electricity - ? typeof property.meter_groups.electricity === 'string' - ? property.meter_groups.electricity - : property.meter_groups.electricity.meter_group_id - : ''; - const waterId = property.meter_groups.water - ? typeof property.meter_groups.water === 'string' - ? property.meter_groups.water - : property.meter_groups.water.meter_group_id - : ''; - const electricityIsMain = property.meter_groups.electricity - ? typeof property.meter_groups.electricity === 'string' - ? false - : (property.meter_groups.electricity?.is_main_meter ?? false) - : false; - const waterIsMain = property.meter_groups.water - ? typeof property.meter_groups.water === 'string' - ? false - : (property.meter_groups.water?.is_main_meter ?? false) - : false; + const electricityId = getMeterGroupId(property.meter_groups.electricity) ?? ''; + const waterId = getMeterGroupId(property.meter_groups.water) ?? ''; + const electricityIsMain = isMainMeterEntry(property.meter_groups.electricity); + const waterIsMain = isMainMeterEntry(property.meter_groups.water); editPropertyForm = { room_name: property.room_name, @@ -547,88 +514,15 @@ class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm" />
-
- - -
-
- - -
- {#if newPropertyForm.meter_groups.electricity || newPropertyForm.meter_groups.water} - {@const electricityMainMeterProperty = - newPropertyForm.meter_groups.electricity !== '' - ? getMainMeterPropertyForMeterGroup(newPropertyForm.meter_groups.electricity) - : null} - {@const waterMainMeterProperty = - newPropertyForm.meter_groups.water !== '' - ? getMainMeterPropertyForMeterGroup(newPropertyForm.meter_groups.water) - : null} -
- {#if newPropertyForm.meter_groups.electricity} - - {#if electricityMainMeterProperty !== null && !newPropertyForm.is_main_meter.electricity} -

- {getMainMeterPropertyName(newPropertyForm.meter_groups.electricity)} is already - the main meter -

- {/if} - {/if} - {#if newPropertyForm.meter_groups.water} - - {#if waterMainMeterProperty !== null && !newPropertyForm.is_main_meter.water} -

- {getMainMeterPropertyName(newPropertyForm.meter_groups.water)} is already the main - meter -

- {/if} - {/if} -
- {/if} +
-
- - -
-
- - -
- {#if editPropertyForm.meter_groups.electricity || editPropertyForm.meter_groups.water} - {@const editElectricityMainMeterProperty = - editPropertyForm.meter_groups.electricity !== '' - ? getMainMeterPropertyForMeterGroup(editPropertyForm.meter_groups.electricity) - : null} - {@const editWaterMainMeterProperty = - editPropertyForm.meter_groups.water !== '' - ? getMainMeterPropertyForMeterGroup(editPropertyForm.meter_groups.water) - : null} -
- {#if editPropertyForm.meter_groups.electricity} - - {#if editElectricityMainMeterProperty !== null && editElectricityMainMeterProperty !== crud.editingItem?.id && !editPropertyForm.is_main_meter.electricity} -

- {getMainMeterPropertyName(editPropertyForm.meter_groups.electricity)} is already the main - meter -

- {/if} - {/if} - {#if editPropertyForm.meter_groups.water} - - {#if editWaterMainMeterProperty !== null && editWaterMainMeterProperty !== crud.editingItem?.id && !editPropertyForm.is_main_meter.water} -

- {getMainMeterPropertyName(editPropertyForm.meter_groups.water)} is already the main meter -

- {/if} - {/if} -
- {/if} +
From be2f5290d807c870786ab4fdbdcde7f23deb0b3c Mon Sep 17 00:00:00 2001 From: Richmond Glenn Viloria Date: Tue, 28 Jul 2026 19:15:07 +0800 Subject: [PATCH 18/25] fix(ui): layout/nav badge staleness, breadcrumbs, shared layout vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nav-counts.svelte.ts: add invalidateNavCounts() and call it from the shared crud store's soft-delete/batch-delete handlers, so sidebar badge counts refresh after an archive instead of being stuck at their first post-login snapshot for the rest of the session (finding #11). - TopBar.svelte: wrap displayBreadcrumbs in $derived — it reads $page.url.pathname but was computed once at component init, and TopBar persists across client-side nav, so breadcrumbs never updated after the first render (finding #15). - Sidebar.svelte: use $authStore directly instead of manually mirroring it into local $state via $effect + .subscribe() — the native auto-subscription already works, as settings/+page.svelte already showed (finding #41). Also inlines the zero-logic getInitialsFromName() wrapper at its one call site (finding #44). - Introduce shared --sidebar-width/--topbar-height CSS variables in layout.css and reference them from Sidebar/TopBar/RightPanel and the (app) layout's inline offsets, replacing 5 independent hardcoded 200px/52px values that had no shared source of truth (finding #43). - SelectionToolbar.svelte: fix naive `${label}s` pluralization ("2 propertys selected" → "2 properties selected") for every consonant+y entityLabel currently in use (finding #23). - ActionButtons.svelte: delete the unused generic `actions` render path — all 4 call sites only ever use onEdit/onSoftDelete (finding #42). Findings: #11, #15, #23, #41, #42, #43, #44 (decisions/20260728_ui-mobile-codebase-review-findings.md) Co-Authored-By: Claude Sonnet 5 --- .../lib/components/layout/RightPanel.svelte | 2 +- ui/src/lib/components/layout/Sidebar.svelte | 29 ++----- ui/src/lib/components/layout/TopBar.svelte | 6 +- .../components/shared/ActionButtons.svelte | 85 ++++++------------- .../components/shared/SelectionToolbar.svelte | 9 +- ui/src/lib/stores/crud.svelte.ts | 3 + ui/src/lib/stores/nav-counts.svelte.ts | 9 ++ ui/src/routes/(app)/+layout.svelte | 4 +- ui/src/routes/layout.css | 2 + 9 files changed, 61 insertions(+), 88 deletions(-) diff --git a/ui/src/lib/components/layout/RightPanel.svelte b/ui/src/lib/components/layout/RightPanel.svelte index 6d43655..2b22091 100644 --- a/ui/src/lib/components/layout/RightPanel.svelte +++ b/ui/src/lib/components/layout/RightPanel.svelte @@ -24,7 +24,7 @@