feat(dashboard): add reports
This commit is contained in:
@@ -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,3 +1,4 @@
|
||||
export * from "./articles";
|
||||
export * from "./reports";
|
||||
export * from "./sources";
|
||||
export * from "./users";
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?`;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
export * from "./articles";
|
||||
export * from "./auth";
|
||||
export * from "./reports";
|
||||
export * from "./shared";
|
||||
export * from "./sources";
|
||||
export * from "./users";
|
||||
|
||||
@@ -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>;
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user