feat(expenses): 6/12/24/All range selector on chart

- service monthlySeriesForProperty now accepts months: number | 'all';
  'all' derives the span from min(incurred_at) for the matching kinds,
  capped at 120 months so the SQL stays bounded
- page load reads ?range= query param (default 12; invalid values fall
  back silently)
- chart header has a segmented control linking to ?range=6|12|24|all
  with active state highlighting; title updates to match
This commit is contained in:
2026-04-23 15:59:53 +07:00
parent 911898507a
commit 0c9a69cfb8
3 changed files with 69 additions and 13 deletions
+36 -6
View File
@@ -190,17 +190,47 @@ export interface MonthlySeriesPoint {
* is billed in a single currency; if mixed, the chart is still useful as a * is billed in a single currency; if mixed, the chart is still useful as a
* unit-less trend line. * unit-less trend line.
*/ */
export type ChartRange = number | 'all';
export async function monthlySeriesForProperty( export async function monthlySeriesForProperty(
companyId: string, companyId: string,
propertyId: string, propertyId: string,
kinds: ExpenseKind[], kinds: ExpenseKind[],
months = 12 months: ChartRange = 12
): Promise<MonthlySeriesPoint[]> { ): Promise<MonthlySeriesPoint[]> {
await assertProperty(companyId, propertyId); await assertProperty(companyId, propertyId);
if (kinds.length === 0 || months < 1) return []; if (kinds.length === 0) return [];
const now = new Date(); const now = new Date();
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (months - 1), 1)); const thisMonthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
// Resolve "all" to the actual # of months spanning earliest expense → now.
// Cap at 120 months (10 years) so the chart stays legible and the SQL stays
// bounded even if someone imports a decade of data.
let effectiveMonths: number;
if (months === 'all') {
const [row] = await db
.select({ earliest: sql<Date | null>`min(${propertyExpenses.incurredAt})` })
.from(propertyExpenses)
.where(
and(
eq(propertyExpenses.propertyId, propertyId),
inArray(propertyExpenses.kind, kinds)
)
);
const earliest = row?.earliest ? new Date(row.earliest) : null;
if (!earliest) return [];
const deltaMonths =
(now.getUTCFullYear() - earliest.getUTCFullYear()) * 12 +
(now.getUTCMonth() - earliest.getUTCMonth()) +
1;
effectiveMonths = Math.max(1, Math.min(120, deltaMonths));
} else {
if (months < 1) return [];
effectiveMonths = months;
}
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (effectiveMonths - 1), 1));
const rows = await db const rows = await db
.select({ .select({
@@ -214,7 +244,7 @@ export async function monthlySeriesForProperty(
eq(propertyExpenses.propertyId, propertyId), eq(propertyExpenses.propertyId, propertyId),
inArray(propertyExpenses.kind, kinds), inArray(propertyExpenses.kind, kinds),
gte(propertyExpenses.incurredAt, start), gte(propertyExpenses.incurredAt, start),
lt(propertyExpenses.incurredAt, new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1))) lt(propertyExpenses.incurredAt, new Date(Date.UTC(thisMonthStart.getUTCFullYear(), thisMonthStart.getUTCMonth() + 1, 1)))
) )
) )
.groupBy( .groupBy(
@@ -224,8 +254,8 @@ export async function monthlySeriesForProperty(
// Build a zero-filled series across all months. // Build a zero-filled series across all months.
const byMonth = new Map<string, Partial<Record<ExpenseKind, number>>>(); const byMonth = new Map<string, Partial<Record<ExpenseKind, number>>>();
for (let i = 0; i < months; i++) { for (let i = 0; i < effectiveMonths; i++) {
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (months - 1 - i), 1)); const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (effectiveMonths - 1 - i), 1));
const label = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`; const label = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
byMonth.set(label, {}); byMonth.set(label, {});
} }
@@ -12,6 +12,7 @@ import {
monthlySeriesForProperty, monthlySeriesForProperty,
summaryForProperty, summaryForProperty,
updateExpense, updateExpense,
type ChartRange,
type ExpenseKind type ExpenseKind
} from '$lib/server/services/expenses'; } from '$lib/server/services/expenses';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
@@ -58,8 +59,17 @@ function parseSettings(raw: string | null | undefined): CompanySettings {
} }
} }
export const load: PageServerLoad = async ({ locals, params }) => { const VALID_RANGES = new Set(['6', '12', '24', 'all']);
function parseRange(raw: string | null): ChartRange {
if (!raw || !VALID_RANGES.has(raw)) return 12;
if (raw === 'all') return 'all';
return Number.parseInt(raw, 10) as ChartRange;
}
export const load: PageServerLoad = async ({ locals, params, url }) => {
const { company } = requireCompany(locals); const { company } = requireCompany(locals);
const range = parseRange(url.searchParams.get('range'));
const [expenses, accounts, series, summary, [companyRow]] = await Promise.all([ const [expenses, accounts, series, summary, [companyRow]] = await Promise.all([
listExpensesForProperty(company.id, params.id), listExpensesForProperty(company.id, params.id),
db db
@@ -72,7 +82,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
}) })
.from(propertyAccounts) .from(propertyAccounts)
.where(eq(propertyAccounts.propertyId, params.id)), .where(eq(propertyAccounts.propertyId, params.id)),
monthlySeriesForProperty(company.id, params.id, ['electricity', 'water'], 12), monthlySeriesForProperty(company.id, params.id, ['electricity', 'water'], range),
summaryForProperty(company.id, params.id, 365), summaryForProperty(company.id, params.id, 365),
db.select({ settings: companies.settings }).from(companies).where(eq(companies.id, company.id)).limit(1) db.select({ settings: companies.settings }).from(companies).where(eq(companies.id, company.id)).limit(1)
]); ]);
@@ -81,6 +91,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
expenses, expenses,
accounts, accounts,
chartSeries: series, chartSeries: series,
chartRange: range,
summary, summary,
defaultCurrency defaultCurrency
}; };
@@ -41,13 +41,28 @@
<div class="space-y-6"> <div class="space-y-6">
<!-- Chart: electricity + water --> <!-- Chart: electricity + water -->
<section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"> <section class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
<div class="mb-2 flex items-baseline justify-between gap-3"> <div class="mb-3 flex flex-wrap items-baseline justify-between gap-3">
<h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400"> <h2 class="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Electricity & water · last 12 months Electricity & water {#if data.chartRange === 'all'}· all time{:else}· last {data.chartRange} months{/if}
</h2> </h2>
<span class="text-xs text-gray-400"> <div class="flex items-center gap-3">
{chartCurrency} <span class="text-xs text-gray-400">{chartCurrency}</span>
</span> <div role="tablist" class="inline-flex rounded-md border border-gray-200 bg-gray-50 p-0.5 text-xs font-medium dark:border-gray-700 dark:bg-gray-900">
{#each [{ v: 6, l: '6M' }, { v: 12, l: '12M' }, { v: 24, l: '24M' }, { v: 'all', l: 'All' }] as opt}
{@const active = String(data.chartRange) === String(opt.v)}
<a
role="tab"
href="?range={opt.v}"
aria-selected={active}
class="rounded px-2 py-1 transition {active
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}"
>
{opt.l}
</a>
{/each}
</div>
</div>
</div> </div>
<ExpenseChart <ExpenseChart
series={data.chartSeries} series={data.chartSeries}