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:
@@ -190,17 +190,47 @@ export interface MonthlySeriesPoint {
|
||||
* is billed in a single currency; if mixed, the chart is still useful as a
|
||||
* unit-less trend line.
|
||||
*/
|
||||
export type ChartRange = number | 'all';
|
||||
|
||||
export async function monthlySeriesForProperty(
|
||||
companyId: string,
|
||||
propertyId: string,
|
||||
kinds: ExpenseKind[],
|
||||
months = 12
|
||||
months: ChartRange = 12
|
||||
): Promise<MonthlySeriesPoint[]> {
|
||||
await assertProperty(companyId, propertyId);
|
||||
if (kinds.length === 0 || months < 1) return [];
|
||||
if (kinds.length === 0) return [];
|
||||
|
||||
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
|
||||
.select({
|
||||
@@ -214,7 +244,7 @@ export async function monthlySeriesForProperty(
|
||||
eq(propertyExpenses.propertyId, propertyId),
|
||||
inArray(propertyExpenses.kind, kinds),
|
||||
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(
|
||||
@@ -224,8 +254,8 @@ export async function monthlySeriesForProperty(
|
||||
|
||||
// Build a zero-filled series across all months.
|
||||
const byMonth = new Map<string, Partial<Record<ExpenseKind, number>>>();
|
||||
for (let i = 0; i < months; i++) {
|
||||
const d = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - (months - 1 - i), 1));
|
||||
for (let i = 0; i < effectiveMonths; i++) {
|
||||
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')}`;
|
||||
byMonth.set(label, {});
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
monthlySeriesForProperty,
|
||||
summaryForProperty,
|
||||
updateExpense,
|
||||
type ChartRange,
|
||||
type ExpenseKind
|
||||
} from '$lib/server/services/expenses';
|
||||
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 range = parseRange(url.searchParams.get('range'));
|
||||
const [expenses, accounts, series, summary, [companyRow]] = await Promise.all([
|
||||
listExpensesForProperty(company.id, params.id),
|
||||
db
|
||||
@@ -72,7 +82,7 @@ export const load: PageServerLoad = async ({ locals, params }) => {
|
||||
})
|
||||
.from(propertyAccounts)
|
||||
.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),
|
||||
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,
|
||||
accounts,
|
||||
chartSeries: series,
|
||||
chartRange: range,
|
||||
summary,
|
||||
defaultCurrency
|
||||
};
|
||||
|
||||
@@ -41,13 +41,28 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Chart: electricity + water -->
|
||||
<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">
|
||||
Electricity & water · last 12 months
|
||||
Electricity & water {#if data.chartRange === 'all'}· all time{:else}· last {data.chartRange} months{/if}
|
||||
</h2>
|
||||
<span class="text-xs text-gray-400">
|
||||
{chartCurrency}
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xs text-gray-400">{chartCurrency}</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>
|
||||
<ExpenseChart
|
||||
series={data.chartSeries}
|
||||
|
||||
Reference in New Issue
Block a user