Calendar events support multiple reminder offsets with presets and per-user defaults. Lists index adds inline task entry and list property editing. Generic entity comments on list detail. New bangs.stats dashboard widget. Closes Gitea #28, #29, #30, #31. Migrations 0022 and 0023.
116 lines
3.4 KiB
TypeScript
116 lines
3.4 KiB
TypeScript
import { asc, count, desc, eq, sql } from "drizzle-orm";
|
|
import { db } from "@/lib/db";
|
|
import { users } from "@/modules/_core/schema";
|
|
import { bangEvents } from "../schema";
|
|
|
|
export type BangStatsDto = {
|
|
total: number;
|
|
recent: RecentBangDto[];
|
|
};
|
|
|
|
export type RecentBangDto = {
|
|
id: string;
|
|
occurredOn: string;
|
|
recordedByName: string | null;
|
|
};
|
|
|
|
export type BangAggregatesDto = {
|
|
thisMonth: number;
|
|
thisYear: number;
|
|
averageDaysBetween: number | null;
|
|
monthlyCounts: { month: string; count: number }[];
|
|
};
|
|
|
|
export async function getBangStatsForScope(
|
|
householdId: string,
|
|
limit: number,
|
|
): Promise<BangStatsDto> {
|
|
const [totalRow] = await db
|
|
.select({ total: count() })
|
|
.from(bangEvents)
|
|
.where(eq(bangEvents.householdId, householdId));
|
|
|
|
const recent = await db
|
|
.select({
|
|
id: bangEvents.id,
|
|
occurredOn: bangEvents.occurredOn,
|
|
recordedByName: users.name,
|
|
})
|
|
.from(bangEvents)
|
|
.leftJoin(users, eq(bangEvents.recordedBy, users.id))
|
|
.where(eq(bangEvents.householdId, householdId))
|
|
.orderBy(desc(bangEvents.occurredOn), desc(bangEvents.createdAt))
|
|
.limit(limit);
|
|
|
|
return {
|
|
total: totalRow?.total ?? 0,
|
|
recent: recent.map((r) => ({
|
|
id: r.id,
|
|
occurredOn: r.occurredOn,
|
|
recordedByName: r.recordedByName,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export async function getBangStats(householdId: string, limit: number): Promise<BangStatsDto> {
|
|
return getBangStatsForScope(householdId, limit);
|
|
}
|
|
|
|
export async function getBangAggregates(householdId: string): Promise<BangAggregatesDto> {
|
|
const now = new Date();
|
|
const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
const yearPrefix = `${now.getFullYear()}-`;
|
|
|
|
const [thisMonthRow] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(bangEvents)
|
|
.where(
|
|
sql`${bangEvents.householdId} = ${householdId} and ${bangEvents.occurredOn} like ${`${monthPrefix}%`}`,
|
|
);
|
|
|
|
const [thisYearRow] = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(bangEvents)
|
|
.where(
|
|
sql`${bangEvents.householdId} = ${householdId} and ${bangEvents.occurredOn} like ${`${yearPrefix}%`}`,
|
|
);
|
|
|
|
const monthlyRows = await db
|
|
.select({
|
|
month: sql<string>`substring(${bangEvents.occurredOn}, 1, 7)`,
|
|
count: sql<number>`count(*)::int`,
|
|
})
|
|
.from(bangEvents)
|
|
.where(eq(bangEvents.householdId, householdId))
|
|
.groupBy(sql`substring(${bangEvents.occurredOn}, 1, 7)`)
|
|
.orderBy(asc(sql`substring(${bangEvents.occurredOn}, 1, 7)`));
|
|
|
|
const dateRows = await db
|
|
.select({ occurredOn: bangEvents.occurredOn })
|
|
.from(bangEvents)
|
|
.where(eq(bangEvents.householdId, householdId))
|
|
.orderBy(asc(bangEvents.occurredOn));
|
|
|
|
let averageDaysBetween: number | null = null;
|
|
if (dateRows.length >= 2) {
|
|
const dates = dateRows.map((row) => parseBangDate(row.occurredOn).getTime());
|
|
let totalGapDays = 0;
|
|
for (let i = 1; i < dates.length; i++) {
|
|
totalGapDays += (dates[i]! - dates[i - 1]!) / (1000 * 60 * 60 * 24);
|
|
}
|
|
averageDaysBetween = Math.round((totalGapDays / (dates.length - 1)) * 10) / 10;
|
|
}
|
|
|
|
return {
|
|
thisMonth: thisMonthRow?.count ?? 0,
|
|
thisYear: thisYearRow?.count ?? 0,
|
|
averageDaysBetween,
|
|
monthlyCounts: monthlyRows.map((row) => ({ month: row.month, count: row.count })),
|
|
};
|
|
}
|
|
|
|
function parseBangDate(isoDate: string): Date {
|
|
const [year, month, day] = isoDate.split("-").map(Number);
|
|
return new Date(year!, month! - 1, day!);
|
|
}
|