feat(dashboard): add reports

This commit is contained in:
2025-11-18 13:48:34 +02:00
parent dbcd1d7485
commit 126505fc88
32 changed files with 553 additions and 170 deletions
+8 -8
View File
@@ -154,14 +154,14 @@ export async function getArticlesPublicationGraph(
db: Database,
params: GetPublicationsParams,
): Promise<Publications> {
const [startDate, endDate] = buildDateRange(params.range);
const [previousStart, previousEnd] = buildPreviousRange([startDate, endDate]);
const current = buildDateRange(params.range);
const previous = buildPreviousRange(current);
const data = await db.execute<Publication>(sql`
WITH bounds AS (
SELECT
${startDate}::timestamptz AS start_ts,
${endDate}::timestamptz AS end_ts
${current.start}::timestamptz AS start_ts,
${current.end}::timestamptz AS end_ts
),
series AS (
SELECT (gs)::date AS d
@@ -189,19 +189,19 @@ export async function getArticlesPublicationGraph(
ORDER BY s.d ASC
`);
const [previous] = await db
const [previousResult] = await db
.execute<{ count: number }>(
sql`
SELECT COALESCE(COUNT(*)::int, 0) AS count
FROM article a
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previousStart})
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previousEnd})
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previous.start})
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previous.end})
`,
)
.then((res) => res.rows);
const currentTotal = data.rows.reduce((acc, item) => acc + item.count, 0);
const previousTotal = previous?.count ?? 0;
const previousTotal = previousResult?.count ?? 0;
return {
items: data.rows,
+1
View File
@@ -1,3 +1,4 @@
export * from "./articles";
export * from "./reports";
export * from "./sources";
export * from "./users";
+83
View File
@@ -0,0 +1,83 @@
import { DashboardOverview, DateRange } from "@basango/domain/models";
import { AnyColumn, SQL, count, sql } from "drizzle-orm";
import { Database } from "#db/client";
import { articles, sources, users } from "#db/schema";
import { buildDateRange, buildPreviousRange, computeDelta } from "#db/utils";
const withinRange = (column: AnyColumn, range: DateRange): SQL<unknown> =>
sql`${column} >= ${range.start} AND ${column} < ${range.end}`;
const countArticles = async (db: Database, where?: SQL<unknown>) => {
const query = db.select({ count: count(articles.id) }).from(articles);
const rows = where ? query.where(where) : query;
const [result] = await rows;
return Number(result?.count ?? 0);
};
const countUsers = async (db: Database, where?: SQL<unknown>) => {
const query = db.select({ count: count(users.id) }).from(users);
const rows = where ? query.where(where) : query;
const [result] = await rows;
return Number(result?.count ?? 0);
};
const countSources = async (db: Database) => {
const [result] = await db.select({ count: count(sources.id) }).from(sources);
return Number(result?.count ?? 0);
};
const countActiveSourcesInRange = async (db: Database, range: DateRange) => {
const [result] = await db
.select({
count: sql<number>`CAST(COUNT(DISTINCT ${articles.sourceId}) AS INT)`,
})
.from(articles)
.where(withinRange(articles.publishedAt, range));
return Number(result?.count ?? 0);
};
export const getDashboardOverview = async (db: Database): Promise<DashboardOverview> => {
const current = buildDateRange();
const previous = buildPreviousRange(current);
const ranges = { current, previous };
const [totalArticles, totalUsers, totalSources] = await Promise.all([
countArticles(db),
countUsers(db),
countSources(db),
]);
const [articlesCurrent, articlesPrevious] = await Promise.all([
countArticles(db, withinRange(articles.publishedAt, ranges.current)),
countArticles(db, withinRange(articles.publishedAt, ranges.previous)),
]);
const [usersCurrent, usersPrevious] = await Promise.all([
countUsers(db, withinRange(users.createdAt, ranges.current)),
countUsers(db, withinRange(users.createdAt, ranges.previous)),
]);
const [sourcesCurrent, sourcesPrevious] = await Promise.all([
countActiveSourcesInRange(db, ranges.current),
countActiveSourcesInRange(db, ranges.previous),
]);
return {
articles: {
delta: computeDelta(articlesCurrent, articlesPrevious),
total: totalArticles,
},
sources: {
delta: computeDelta(sourcesCurrent, sourcesPrevious),
total: totalSources,
},
users: {
delta: computeDelta(usersCurrent, usersPrevious),
total: totalUsers,
},
};
};
+3 -3
View File
@@ -100,13 +100,13 @@ export async function getSourcePublicationGraph(
db: Database,
params: GetPublicationsParams,
): Promise<Publications> {
const [startDate, endDate] = buildDateRange(params.range);
const range = buildDateRange(params.range);
const data = await db.execute<Publication>(sql`
WITH bounds AS (
SELECT
${startDate}::timestamptz AS start_ts,
${endDate}::timestamptz AS end_ts
${range.start}::timestamptz AS start_ts,
${range.end}::timestamptz AS end_ts
),
series AS (
SELECT (gs)::date AS d
+18 -8
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env bun
/** biome-ignore-all lint/correctness/noUnusedPrivateClassMembers: false positive */
import { RowDataPacket } from "mysql2/promise";
import { Pool, PoolClient } from "pg";
@@ -48,16 +50,16 @@ class Engine {
constructor(
private readonly sourceOptions: SourceOptions,
private readonly targetOptions: TargetOptions,
targetOptions: TargetOptions,
) {
this.target = new Pool({
allowExitOnIdle: true,
connectionString: this.targetOptions.database,
connectionString: targetOptions.database,
max: 16,
});
this.ignore = { ...DEFAULT_IGNORE, ...(this.targetOptions.ignoreColumns ?? {}) };
this.pageSize = this.targetOptions.pageSize ?? 5000;
this.batchSize = Math.max(1, this.targetOptions.batchSize ?? 500);
this.ignore = { ...DEFAULT_IGNORE, ...(targetOptions.ignoreColumns ?? {}) };
this.pageSize = targetOptions.pageSize ?? 5000;
this.batchSize = Math.max(1, targetOptions.batchSize ?? 500);
console.log(
`Engine initialized with pageSize=${this.pageSize} and batchSize=${this.batchSize} (resume=${this.resume})`,
);
@@ -70,11 +72,17 @@ class Engine {
async import(table: string): Promise<number> {
await this.ensureProgressTable();
let startState: SyncProgress = { cursor: null, cursorEncoding: null, offset: 0 };
let startState: SyncProgress = {
cursor: null,
cursorEncoding: null,
offset: 0,
};
if (this.resume) {
startState = await this.getProgressState(table);
console.log(
`Resuming import for ${table} from offset=${startState.offset}, cursor=${startState.cursor ?? "null"}`,
`Resuming import for ${table} from offset=${
startState.offset
}, cursor=${startState.cursor ?? "null"}`,
);
} else {
await this.reset(table);
@@ -107,7 +115,9 @@ class Engine {
let sql: string;
let params: unknown[];
if (useCursor && cursorParam != null) {
sql = `SELECT * FROM \`${this.escapeBacktick(table)}\` WHERE \`id\` > ? ORDER BY \`id\` LIMIT ?`;
sql = `SELECT * FROM \`${this.escapeBacktick(
table,
)}\` WHERE \`id\` > ? ORDER BY \`id\` LIMIT ?`;
params = [cursorParam, size];
} else {
sql = `SELECT * FROM \`${this.escapeBacktick(table)}\` ORDER BY \`id\` LIMIT ? OFFSET ?`;
+2 -2
View File
@@ -17,10 +17,10 @@ class Engine {
private readonly pageSize: number = 1000;
private readonly batchSize: number = 50;
constructor(private readonly database: string) {
constructor(database: string) {
this.db = new Pool({
allowExitOnIdle: true,
connectionString: this.database,
connectionString: database,
max: 16,
});
console.log(
+10 -17
View File
@@ -19,30 +19,23 @@ export const buildSearchQuery = (input: string) => {
.join(" & ");
};
/**
* Resolve a date range given an explicit range.
* Defaults to the last 30 days when no range is provided.
*/
export function buildDateRange(range?: DateRange): [startDate: Date, endDate: Date] {
const endDate = endOfDay(range?.end ?? new Date());
const startDate = startOfDay(
range?.start ?? subDays(endDate, Math.max(DEFAULT_PUBLICATION_GRAPH_DAYS - 1, 0)),
export function buildDateRange(range?: DateRange): DateRange {
const end = endOfDay(range?.end ?? new Date());
const start = startOfDay(
range?.start ?? subDays(end, Math.max(DEFAULT_PUBLICATION_GRAPH_DAYS - 1, 0)),
);
return [startDate, endDate];
return { end, start };
}
/**
* Given a [start, end] date range, produce the immediately preceding range of the same length.
*/
export function buildPreviousRange([startDate, endDate]: [Date, Date]): [Date, Date] {
export function buildPreviousRange(range: DateRange): DateRange {
const days = Math.max(
1,
Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1,
Math.round((range.end.getTime() - range.start.getTime()) / (1000 * 60 * 60 * 24)) + 1,
);
const previousRangeEnd = endOfDay(subDays(startDate, 1));
const previousRangeStart = startOfDay(subDays(previousRangeEnd, days - 1));
const end = endOfDay(subDays(range.start, 1));
const start = startOfDay(subDays(end, days - 1));
return [previousRangeStart, previousRangeEnd];
return { end, start };
}
+1
View File
@@ -1,5 +1,6 @@
export * from "./articles";
export * from "./auth";
export * from "./reports";
export * from "./shared";
export * from "./sources";
export * from "./users";
+30
View File
@@ -0,0 +1,30 @@
import { z } from "@hono/zod-openapi";
import { deltaSchema } from "#domain/models/shared";
export const overviewMetricSchema = z
.object({
delta: deltaSchema.openapi({
description: "Change measured over the last 30 days compared to the previous 30-day window.",
}),
total: z.number().int().nonnegative().openapi({
description: "Total count across the entire dataset.",
example: 12584,
}),
})
.openapi({
description: "Aggregated metric with total count and delta metadata.",
});
export const dashboardOverviewSchema = z
.object({
articles: overviewMetricSchema,
sources: overviewMetricSchema,
users: overviewMetricSchema,
})
.openapi({
description: "Dashboard overview metrics for key entities.",
});
export type OverviewMetric = z.infer<typeof overviewMetricSchema>;
export type DashboardOverview = z.infer<typeof dashboardOverviewSchema>;
+1 -1
View File
@@ -22,7 +22,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "catalog:",
"lucide-react": "^0.553.0",
"lucide-react": "^0.554.0",
"next-themes": "^0.4.6",
"react": "catalog:",
"react-day-picker": "^9.11.1",