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
|
* 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}
|
||||||
|
|||||||
Reference in New Issue
Block a user