From 9f8675fb30f55ef6b0ca3d4c8d37ff7692b46134 Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Thu, 25 Jun 2026 11:40:04 +0100 Subject: [PATCH 1/6] feat: add dashboard reporting load --- src/routes/dashboard/+page.server.ts | 369 +++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 src/routes/dashboard/+page.server.ts diff --git a/src/routes/dashboard/+page.server.ts b/src/routes/dashboard/+page.server.ts new file mode 100644 index 0000000..e74bdcd --- /dev/null +++ b/src/routes/dashboard/+page.server.ts @@ -0,0 +1,369 @@ +import { and, asc, count, desc, eq, gte, inArray, isNull, lt, lte, ne, sql } from 'drizzle-orm'; +import { db } from '$lib/server/db'; +import { + bookings, + clients, + contracts, + contractRooms, + invoices, + rooms, + services +} from '$lib/server/db/schema'; +import { loadOrganizationContext } from '$lib/server/organizations'; +import type { PageServerLoad } from './$types'; + +const dayMs = 24 * 60 * 60 * 1000; + +function pad(value: number) { + return String(value).padStart(2, '0'); +} + +function dateOnly(date: Date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function dateTimeLocal(date: Date) { + return `${dateOnly(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function startOfMonth(date: Date) { + return new Date(date.getFullYear(), date.getMonth(), 1); +} + +function endOfMonth(date: Date) { + return new Date(date.getFullYear(), date.getMonth() + 1, 0); +} + +function addDays(date: Date, days: number) { + return new Date(date.getTime() + days * dayMs); +} + +function addMonths(date: Date, months: number) { + return new Date(date.getFullYear(), date.getMonth() + months, 1); +} + +function monthKey(date: Date) { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`; +} + +function monthLabel(date: Date) { + return new Intl.DateTimeFormat('en-GB', { month: 'short', year: 'numeric' }).format(date); +} + +function roomTypeLabel(type: string) { + const labels: Record = { + meeting_room: 'Meeting rooms', + private_office: 'Private offices', + coworking_desk: 'Coworking desks' + }; + + return labels[type] ?? type; +} + +function numberValue(value: unknown) { + const parsed = Number(value ?? 0); + return Number.isFinite(parsed) ? parsed : 0; +} + +export const load: PageServerLoad = async ({ locals }) => { + const { activeOrganizationId } = await loadOrganizationContext(locals); + const today = new Date(); + const todayDate = dateOnly(today); + const nowDateTime = dateTimeLocal(today); + const soonDate = dateOnly(addDays(today, 60)); + const monthStart = dateOnly(startOfMonth(today)); + const monthEnd = dateOnly(endOfMonth(today)); + const sixMonthsAgo = startOfMonth(addMonths(today, -5)); + + const activeContractCondition = and( + eq(contracts.organizationId, activeOrganizationId), + eq(contracts.status, 'active'), + lte(contracts.startDate, todayDate), + gte(contracts.endDate, todayDate), + isNull(contracts.archivedAt) + ); + + const [ + [{ total: activeClients }], + activeContractRows, + [{ total: upcomingBookings }], + [overdueInvoiceSummary], + [monthlyBillingSummary], + roomRows, + upcomingBookingRows, + endingContractRows, + recentInvoiceRows, + billingTrendInvoiceRows + ] = await Promise.all([ + db + .select({ total: count() }) + .from(clients) + .where(and(eq(clients.organizationId, activeOrganizationId), isNull(clients.archivedAt))), + db + .select({ + id: contracts.id, + licenseFeeGbp: contracts.licenseFeeGbp + }) + .from(contracts) + .where(activeContractCondition), + db + .select({ total: count() }) + .from(bookings) + .where( + and( + eq(bookings.organizationId, activeOrganizationId), + gte(bookings.startsAt, nowDateTime), + ne(bookings.status, 'cancelled'), + isNull(bookings.archivedAt) + ) + ), + db + .select({ + total: count(), + valueGbp: sql`coalesce(sum(${invoices.totalGbp}), 0)` + }) + .from(invoices) + .where( + and( + eq(invoices.organizationId, activeOrganizationId), + lt(invoices.dueDate, todayDate), + isNull(invoices.archivedAt) + ) + ), + db + .select({ + valueGbp: sql`coalesce(sum(${invoices.totalGbp}), 0)` + }) + .from(invoices) + .where( + and( + eq(invoices.organizationId, activeOrganizationId), + gte(invoices.issueDate, monthStart), + lte(invoices.issueDate, monthEnd), + isNull(invoices.archivedAt) + ) + ), + db + .select({ + id: rooms.id, + name: rooms.name, + type: rooms.type, + sqFt: rooms.sqFt, + workstations: rooms.workstations, + pricePerMonthGbp: rooms.pricePerMonthGbp + }) + .from(rooms) + .where(and(eq(rooms.organizationId, activeOrganizationId), isNull(rooms.archivedAt))) + .orderBy(asc(rooms.name)), + db + .select({ + id: bookings.id, + clientName: clients.name, + roomName: rooms.name, + serviceName: services.name, + startsAt: bookings.startsAt, + endsAt: bookings.endsAt, + status: bookings.status + }) + .from(bookings) + .innerJoin(clients, eq(bookings.clientId, clients.id)) + .innerJoin(rooms, eq(bookings.roomId, rooms.id)) + .leftJoin(services, eq(bookings.serviceId, services.id)) + .where( + and( + eq(bookings.organizationId, activeOrganizationId), + gte(bookings.startsAt, nowDateTime), + ne(bookings.status, 'cancelled'), + isNull(bookings.archivedAt) + ) + ) + .orderBy(asc(bookings.startsAt)) + .limit(6), + db + .select({ + id: contracts.id, + clientName: clients.name, + serviceName: services.name, + licenseFeeGbp: contracts.licenseFeeGbp, + startDate: contracts.startDate, + endDate: contracts.endDate, + status: contracts.status + }) + .from(contracts) + .innerJoin(clients, eq(contracts.clientId, clients.id)) + .leftJoin(services, eq(contracts.serviceId, services.id)) + .where( + and( + eq(contracts.organizationId, activeOrganizationId), + eq(contracts.status, 'active'), + gte(contracts.endDate, todayDate), + lte(contracts.endDate, soonDate), + isNull(contracts.archivedAt) + ) + ) + .orderBy(asc(contracts.endDate)) + .limit(6), + db + .select({ + id: invoices.id, + invoiceNumber: invoices.invoiceNumber, + clientName: clients.name, + issueDate: invoices.issueDate, + dueDate: invoices.dueDate, + totalGbp: invoices.totalGbp + }) + .from(invoices) + .innerJoin(clients, eq(invoices.clientId, clients.id)) + .where(and(eq(invoices.organizationId, activeOrganizationId), isNull(invoices.archivedAt))) + .orderBy(desc(invoices.issueDate), desc(invoices.createdAt)) + .limit(6), + db + .select({ + issueDate: invoices.issueDate, + totalGbp: invoices.totalGbp + }) + .from(invoices) + .where( + and( + eq(invoices.organizationId, activeOrganizationId), + gte(invoices.issueDate, dateOnly(sixMonthsAgo)), + isNull(invoices.archivedAt) + ) + ) + ]); + + const activeContractIds = activeContractRows.map((contract) => contract.id); + const activeContractRoomLinks = + activeContractIds.length > 0 + ? await db + .select({ + contractId: contractRooms.contractId, + roomId: contractRooms.roomId, + roomName: rooms.name, + roomType: rooms.type + }) + .from(contractRooms) + .innerJoin(rooms, eq(contractRooms.roomId, rooms.id)) + .where( + and( + inArray(contractRooms.contractId, activeContractIds), + eq(rooms.organizationId, activeOrganizationId), + isNull(rooms.archivedAt) + ) + ) + .orderBy(asc(rooms.name)) + : []; + + const endingContractIds = endingContractRows.map((contract) => contract.id); + const endingContractRoomLinks = + endingContractIds.length > 0 + ? await db + .select({ + contractId: contractRooms.contractId, + roomName: rooms.name + }) + .from(contractRooms) + .innerJoin(rooms, eq(contractRooms.roomId, rooms.id)) + .where( + and( + inArray(contractRooms.contractId, endingContractIds), + eq(rooms.organizationId, activeOrganizationId), + isNull(rooms.archivedAt) + ) + ) + .orderBy(asc(rooms.name)) + : []; + const roomNamesByEndingContract = new Map(); + for (const link of endingContractRoomLinks) { + const existing = roomNamesByEndingContract.get(link.contractId) ?? []; + existing.push(link.roomName); + roomNamesByEndingContract.set(link.contractId, existing); + } + + const allocatedRoomIds = new Set(activeContractRoomLinks.map((link) => link.roomId)); + const monthlyLicenseFeesGbp = activeContractRows.reduce( + (total, contract) => total + numberValue(contract.licenseFeeGbp), + 0 + ); + + const roomTypes = new Map< + string, + { + type: string; + label: string; + total: number; + allocated: number; + workstations: number; + sqFt: number; + } + >(); + for (const room of roomRows) { + const summary = roomTypes.get(room.type) ?? { + type: room.type, + label: roomTypeLabel(room.type), + total: 0, + allocated: 0, + workstations: 0, + sqFt: 0 + }; + summary.total += 1; + summary.allocated += allocatedRoomIds.has(room.id) ? 1 : 0; + summary.workstations += room.workstations ?? 0; + summary.sqFt += room.sqFt ?? 0; + roomTypes.set(room.type, summary); + } + + const billingTrendMap = new Map(); + for (let index = 0; index < 6; index += 1) { + const month = addMonths(sixMonthsAgo, index); + billingTrendMap.set(monthKey(month), { + month: monthKey(month), + label: monthLabel(month), + totalGbp: 0 + }); + } + for (const invoice of billingTrendInvoiceRows) { + const issuedAt = new Date(`${invoice.issueDate}T00:00:00`); + const key = monthKey(issuedAt); + const existing = billingTrendMap.get(key); + if (!existing) continue; + existing.totalGbp += numberValue(invoice.totalGbp); + } + const billingTrend = [...billingTrendMap.values()]; + + return { + metrics: { + activeClients, + activeContracts: activeContractRows.length, + upcomingBookings, + overdueInvoices: overdueInvoiceSummary.total, + overdueInvoicesGbp: numberValue(overdueInvoiceSummary.valueGbp), + monthlyBillingGbp: numberValue(monthlyBillingSummary.valueGbp), + monthlyLicenseFeesGbp, + totalRooms: roomRows.length, + allocatedRooms: allocatedRoomIds.size, + occupancyPercent: + roomRows.length > 0 ? Math.round((allocatedRoomIds.size / roomRows.length) * 100) : 0 + }, + roomSummary: { + types: [...roomTypes.values()], + allocatedRooms: activeContractRoomLinks.map((link) => ({ + roomId: link.roomId, + roomName: link.roomName, + roomType: roomTypeLabel(link.roomType) + })) + }, + billingTrend, + upcomingBookings: upcomingBookingRows, + contractsEndingSoon: endingContractRows.map((contract) => ({ + ...contract, + roomNames: roomNamesByEndingContract.get(contract.id) ?? [] + })), + recentInvoices: recentInvoiceRows, + meta: { + monthLabel: monthLabel(today), + overdueInvoiceBasis: + 'Invoices have no payment status yet, so this shows invoices past due date.' + } + }; +}; From 77cbb1d8d7109175b9aab8b182cb4df5d7aa1b60 Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Thu, 25 Jun 2026 11:40:09 +0100 Subject: [PATCH 2/6] feat: build dashboard overview UI --- src/routes/dashboard/+page.svelte | 41 ++++++ src/routes/dashboard/activity-lists.svelte | 163 +++++++++++++++++++++ src/routes/dashboard/billing-trend.svelte | 55 +++++++ src/routes/dashboard/metric-cards.svelte | 77 ++++++++++ src/routes/dashboard/room-summary.svelte | 67 +++++++++ 5 files changed, 403 insertions(+) create mode 100644 src/routes/dashboard/activity-lists.svelte create mode 100644 src/routes/dashboard/billing-trend.svelte create mode 100644 src/routes/dashboard/metric-cards.svelte create mode 100644 src/routes/dashboard/room-summary.svelte diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index e69de29..5bc8b55 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -0,0 +1,41 @@ + + + + Dashboard | Clearity + + +
+
+

Dashboard

+

+ Operational overview for clients, contracts, bookings, rooms, and billing. +

+
+ + + +
+ + +
+ + + +

{data.meta.overdueInvoiceBasis}

+
diff --git a/src/routes/dashboard/activity-lists.svelte b/src/routes/dashboard/activity-lists.svelte new file mode 100644 index 0000000..a4966b0 --- /dev/null +++ b/src/routes/dashboard/activity-lists.svelte @@ -0,0 +1,163 @@ + + +
+ + + Upcoming bookings + Next room and service reservations. + + + + {#if upcomingBookings.length > 0} +
+ + + {#each upcomingBookings as booking (booking.id)} + + +
{booking.roomName}
+
{booking.clientName}
+
+ +
{formatDate(booking.startsAt, true)}
+
+ {formatValue(booking.serviceName)} +
+
+ + {booking.status} + +
+ {/each} +
+
+
+ {:else} + + + + No upcoming bookings + Future bookings will appear here. + + + {/if} +
+
+ + + + Contracts ending soon + Active contracts ending in the next 60 days. + + + + {#if contractsEndingSoon.length > 0} +
+ + + {#each contractsEndingSoon as contract (contract.id)} + + +
{contract.clientName}
+
+ {contract.roomNames.join(', ') || formatValue(contract.serviceName)} +
+
+ +
{formatDate(contract.endDate)}
+
+ {formatMoney(contract.licenseFeeGbp)} / month +
+
+
+ {/each} +
+
+
+ {:else} + + + + No near-term endings + Contracts ending soon will be listed here. + + + {/if} +
+
+ + + + Recent invoices + Latest generated billing output. + + + + {#if recentInvoices.length > 0} +
+ + + {#each recentInvoices as invoice (invoice.id)} + + +
{invoice.invoiceNumber}
+
{invoice.clientName}
+
+ +
{formatMoney(invoice.totalGbp)}
+
+ Issued {formatDate(invoice.issueDate)} +
+
+
+ {/each} +
+
+
+ {:else} + + + + No invoices yet + Generated invoices will appear here. + + + {/if} +
+
+
diff --git a/src/routes/dashboard/billing-trend.svelte b/src/routes/dashboard/billing-trend.svelte new file mode 100644 index 0000000..8726b50 --- /dev/null +++ b/src/routes/dashboard/billing-trend.svelte @@ -0,0 +1,55 @@ + + + + + Billing trend + Issued invoice totals over the last six months. + + + {#if hasValues} +
+ {#each records as record (record.month)} +
+
+
0 ? 4 : 0)}%`} + title={`${record.label}: ${formatMoney(record.totalGbp)}`} + >
+
+
+
{formatMoney(record.totalGbp)}
+
{record.label}
+
+
+ {/each} +
+ {:else} + + + + No issued invoices yet + Billing totals will appear once invoices are generated. + + + {/if} +
+
diff --git a/src/routes/dashboard/metric-cards.svelte b/src/routes/dashboard/metric-cards.svelte new file mode 100644 index 0000000..95a6882 --- /dev/null +++ b/src/routes/dashboard/metric-cards.svelte @@ -0,0 +1,77 @@ + + +
+ {#each cards as card (card.label)} + {@const Icon = card.icon} + + + + + {card.label} + + + +
{card.value}
+

{card.detail}

+
+
+ {/each} +
diff --git a/src/routes/dashboard/room-summary.svelte b/src/routes/dashboard/room-summary.svelte new file mode 100644 index 0000000..ba8d615 --- /dev/null +++ b/src/routes/dashboard/room-summary.svelte @@ -0,0 +1,67 @@ + + + + + Room inventory + Allocation is based on rooms linked to active contracts. + + + {#if metrics.totalRooms > 0} +
+
+ Allocated rooms + {metrics.allocatedRooms} / {metrics.totalRooms} +
+ +
+ +
+ + + + Type + Rooms + Allocated + Workstations + Sq ft. + + + + {#each summary.types as type (type.type)} + + {type.label} + {type.total} + {type.allocated} + {type.workstations || 'Not set'} + {type.sqFt || 'Not set'} + + {/each} + + +
+ {:else} + + + + No rooms configured + Add rooms before tracking inventory and allocation. + + + {/if} +
+
From e42479a3c0d57677354d8e657a7c4dd35a98e3aa Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Thu, 25 Jun 2026 11:40:12 +0100 Subject: [PATCH 3/6] chore: refresh worker configuration types --- worker-configuration.d.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index b28b382..44e67a2 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,11 +1,8 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 61c64c9cd1c2465ff3a92bc5ac82d57d) +// Generated by Wrangler by running `wrangler types` (hash: 4f9fe9ddb2ea1e2c41e1be5910da8551) // Runtime types generated with workerd@1.20260521.1 2026-05-23 nodejs_als interface __BaseEnv_Env { ASSETS: Fetcher; - DATABASE_URL: string; - ORIGIN: string; - BETTER_AUTH_SECRET: string; } declare namespace Cloudflare { interface Env extends __BaseEnv_Env {} From fe083ffaea967861db00358e6b2b928e516d73d5 Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Thu, 25 Jun 2026 11:46:34 +0100 Subject: [PATCH 4/6] fix: balance dashboard reporting cards --- src/routes/dashboard/+page.svelte | 2 +- worker-configuration.d.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index 5bc8b55..106f758 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -23,7 +23,7 @@ -
+
diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 44e67a2..b28b382 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,8 +1,11 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 4f9fe9ddb2ea1e2c41e1be5910da8551) +// Generated by Wrangler by running `wrangler types` (hash: 61c64c9cd1c2465ff3a92bc5ac82d57d) // Runtime types generated with workerd@1.20260521.1 2026-05-23 nodejs_als interface __BaseEnv_Env { ASSETS: Fetcher; + DATABASE_URL: string; + ORIGIN: string; + BETTER_AUTH_SECRET: string; } declare namespace Cloudflare { interface Env extends __BaseEnv_Env {} From 3642ecfba6b4946addbe29e83153b3689c4593b0 Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Thu, 25 Jun 2026 11:48:18 +0100 Subject: [PATCH 5/6] refactor: use date-fns for dashboard dates --- package.json | 3 +++ pnpm-lock.yaml | 9 ++++++++ src/routes/dashboard/+page.server.ts | 33 +++++----------------------- 3 files changed, 18 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index abfa2ee..e63a233 100644 --- a/package.json +++ b/package.json @@ -65,5 +65,8 @@ "vite": "^8.0.7", "wrangler": "^4.81.0", "zod": "^4.4.3" + }, + "dependencies": { + "date-fns": "^4.4.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9ff036..e2af95b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,10 @@ settings: importers: .: + dependencies: + date-fns: + specifier: ^4.4.0 + version: 4.4.0 devDependencies: '@better-auth/cli': specifier: ~1.4.21 @@ -2213,6 +2217,9 @@ packages: resolution: {integrity: sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==} engines: {node: '>=12'} + date-fns@4.4.0: + resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} + dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -5521,6 +5528,8 @@ snapshots: d3-delaunay: 6.0.4 d3-scale: 4.0.2 + date-fns@4.4.0: {} + dayjs@1.11.20: optional: true diff --git a/src/routes/dashboard/+page.server.ts b/src/routes/dashboard/+page.server.ts index e74bdcd..927d5ff 100644 --- a/src/routes/dashboard/+page.server.ts +++ b/src/routes/dashboard/+page.server.ts @@ -1,3 +1,4 @@ +import { addDays, addMonths, endOfMonth, format, parseISO, startOfMonth } from 'date-fns'; import { and, asc, count, desc, eq, gte, inArray, isNull, lt, lte, ne, sql } from 'drizzle-orm'; import { db } from '$lib/server/db'; import { @@ -12,42 +13,20 @@ import { import { loadOrganizationContext } from '$lib/server/organizations'; import type { PageServerLoad } from './$types'; -const dayMs = 24 * 60 * 60 * 1000; - -function pad(value: number) { - return String(value).padStart(2, '0'); -} - function dateOnly(date: Date) { - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + return format(date, 'yyyy-MM-dd'); } function dateTimeLocal(date: Date) { - return `${dateOnly(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`; -} - -function startOfMonth(date: Date) { - return new Date(date.getFullYear(), date.getMonth(), 1); -} - -function endOfMonth(date: Date) { - return new Date(date.getFullYear(), date.getMonth() + 1, 0); -} - -function addDays(date: Date, days: number) { - return new Date(date.getTime() + days * dayMs); -} - -function addMonths(date: Date, months: number) { - return new Date(date.getFullYear(), date.getMonth() + months, 1); + return format(date, "yyyy-MM-dd'T'HH:mm"); } function monthKey(date: Date) { - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}`; + return format(date, 'yyyy-MM'); } function monthLabel(date: Date) { - return new Intl.DateTimeFormat('en-GB', { month: 'short', year: 'numeric' }).format(date); + return format(date, 'MMM yyyy'); } function roomTypeLabel(type: string) { @@ -323,7 +302,7 @@ export const load: PageServerLoad = async ({ locals }) => { }); } for (const invoice of billingTrendInvoiceRows) { - const issuedAt = new Date(`${invoice.issueDate}T00:00:00`); + const issuedAt = parseISO(invoice.issueDate); const key = monthKey(issuedAt); const existing = billingTrendMap.get(key); if (!existing) continue; From 44165e39b1e4f389a7f6cd4cdc665a1da99b2d30 Mon Sep 17 00:00:00 2001 From: Daniel Kirby Date: Thu, 25 Jun 2026 11:50:24 +0100 Subject: [PATCH 6/6] fix: show latest invoice in billing trend --- src/routes/dashboard/+page.server.ts | 21 ++++++++++++--------- src/routes/dashboard/+page.svelte | 6 +++++- src/routes/dashboard/billing-trend.svelte | 4 +++- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/routes/dashboard/+page.server.ts b/src/routes/dashboard/+page.server.ts index 927d5ff..0e8042c 100644 --- a/src/routes/dashboard/+page.server.ts +++ b/src/routes/dashboard/+page.server.ts @@ -52,7 +52,7 @@ export const load: PageServerLoad = async ({ locals }) => { const soonDate = dateOnly(addDays(today, 60)); const monthStart = dateOnly(startOfMonth(today)); const monthEnd = dateOnly(endOfMonth(today)); - const sixMonthsAgo = startOfMonth(addMonths(today, -5)); + const currentTrendStart = startOfMonth(addMonths(today, -5)); const activeContractCondition = and( eq(contracts.organizationId, activeOrganizationId), @@ -202,13 +202,7 @@ export const load: PageServerLoad = async ({ locals }) => { totalGbp: invoices.totalGbp }) .from(invoices) - .where( - and( - eq(invoices.organizationId, activeOrganizationId), - gte(invoices.issueDate, dateOnly(sixMonthsAgo)), - isNull(invoices.archivedAt) - ) - ) + .where(and(eq(invoices.organizationId, activeOrganizationId), isNull(invoices.archivedAt))) ]); const activeContractIds = activeContractRows.map((contract) => contract.id); @@ -292,9 +286,17 @@ export const load: PageServerLoad = async ({ locals }) => { roomTypes.set(room.type, summary); } + const hasCurrentTrendInvoices = billingTrendInvoiceRows.some( + (invoice) => parseISO(invoice.issueDate).getTime() >= currentTrendStart.getTime() + ); + const trendEnd = + hasCurrentTrendInvoices || recentInvoiceRows.length === 0 + ? today + : parseISO(recentInvoiceRows[0].issueDate); + const trendStart = startOfMonth(addMonths(trendEnd, -5)); const billingTrendMap = new Map(); for (let index = 0; index < 6; index += 1) { - const month = addMonths(sixMonthsAgo, index); + const month = addMonths(trendStart, index); billingTrendMap.set(monthKey(month), { month: monthKey(month), label: monthLabel(month), @@ -341,6 +343,7 @@ export const load: PageServerLoad = async ({ locals }) => { recentInvoices: recentInvoiceRows, meta: { monthLabel: monthLabel(today), + billingTrendDescription: `Issued invoice totals from ${monthLabel(trendStart)} to ${monthLabel(trendEnd)}.`, overdueInvoiceBasis: 'Invoices have no payment status yet, so this shows invoices past due date.' } diff --git a/src/routes/dashboard/+page.svelte b/src/routes/dashboard/+page.svelte index 106f758..b4f0826 100644 --- a/src/routes/dashboard/+page.svelte +++ b/src/routes/dashboard/+page.svelte @@ -24,7 +24,11 @@
- +
diff --git a/src/routes/dashboard/billing-trend.svelte b/src/routes/dashboard/billing-trend.svelte index 8726b50..cb4499f 100644 --- a/src/routes/dashboard/billing-trend.svelte +++ b/src/routes/dashboard/billing-trend.svelte @@ -6,9 +6,11 @@ let { records, + description, formatMoney }: { records: PageData['billingTrend']; + description: string; formatMoney: (value: unknown) => string; } = $props(); @@ -19,7 +21,7 @@ Billing trend - Issued invoice totals over the last six months. + {description} {#if hasValues}