feat(dashboard): list sources with statistics

This commit is contained in:
2025-11-13 11:25:07 +02:00
parent 8cc40fde67
commit 6503980cbc
24 changed files with 1016 additions and 373 deletions
+12 -1
View File
@@ -8,7 +8,18 @@ export const SOURCE_IMAGE_BASE = "https://devscast.org/images/sources/";
* Number of days to include in the publication graph for sources.
* This defines the time range for which publication data is aggregated and displayed.
*/
export const PUBLICATION_GRAPH_DAYS = 180;
export const PUBLICATION_GRAPH_DAYS = 60;
/**
* Maximum number of category shares to return for a source.
* This limits the number of categories displayed in the category share breakdown.
*/
export const CATEGORY_SHARES_LIMIT = 10;
/**
* The default timezone
*/
export const TIMEZONE = "Africa/Lubumbashi";
/**
* Default pagination settings.
+112 -10
View File
@@ -1,12 +1,24 @@
import { eq } from "drizzle-orm";
import { endOfDay, startOfDay, subDays } from "date-fns";
import { eq, sql } from "drizzle-orm";
import { v7 as uuidV7 } from "uuid";
import { Database } from "@/client";
import { CATEGORY_SHARES_LIMIT, PUBLICATION_GRAPH_DAYS, TIMEZONE } from "@/constants";
import { NotFoundError } from "@/errors";
import { Credibility, source } from "@/schema";
import { Credibility, article, source } from "@/schema";
export async function getSources(db: Database) {
return db.query.source.findMany();
const rows = await db.query.source.findMany();
const data = await Promise.all(
rows.map(async (it) => ({
...it,
categoryShares: await getCategoryShares(db, it.id),
publicationGraph: await getPublicationGraph(db, it.id),
})),
);
return data;
}
export type CreateSourceParams = {
@@ -69,10 +81,20 @@ export async function getSourceByName(db: Database, name: string) {
});
}
export async function getById(db: Database, id: string) {
return db.query.source.findFirst({
export async function getSourceById(db: Database, id: string) {
const item = db.query.source.findFirst({
where: eq(source.id, id),
});
if (item === undefined) {
throw new NotFoundError("Source not found");
}
return {
...item,
categoryShares: await getCategoryShares(db, id),
publicationGraph: await getPublicationGraph(db, id),
};
}
export async function getSourceIdByName(db: Database, name: string): Promise<string> {
@@ -84,7 +106,7 @@ export async function getSourceIdByName(db: Database, name: string): Promise<str
});
if (!result) {
throw new NotFoundError(`Source with name "${name}" not found`);
throw new NotFoundError("Source not found");
}
return result.id;
@@ -94,8 +116,88 @@ export type GetSourceByIdParams = {
id: string;
};
export async function getSourceById(db: Database, params: GetSourceByIdParams) {
return db.query.source.findFirst({
where: eq(source.id, params.id),
});
export type PublicationEntry = {
date: string;
count: number;
};
export type PublicationGraph = {
items: PublicationEntry[];
total: number;
};
export type CategoryShare = {
category: string;
count: number;
percentage: number;
};
export type CategoryShares = {
items: CategoryShare[];
total: number;
};
export async function getPublicationGraph(
db: Database,
id: string,
days: number = PUBLICATION_GRAPH_DAYS,
): Promise<PublicationGraph> {
const endDate = endOfDay(new Date());
const startDate = startOfDay(subDays(endDate, days - 1));
const data = await db.execute<{ date: string; count: number }>(sql`
WITH bounds AS (
SELECT
${startDate}::timestamptz AS start_ts,
${endDate}::timestamptz AS end_ts
),
series AS (
SELECT (gs)::date AS d
FROM bounds b,
LATERAL generate_series(
date_trunc('day', timezone(${TIMEZONE}, b.start_ts)),
date_trunc('day', timezone(${TIMEZONE}, b.end_ts)),
INTERVAL '1 day'
) AS gs
),
counts AS (
SELECT
a.published_at::date AS d,
COUNT(*)::int AS c
FROM article a, bounds b
WHERE a.source_id = ${id}::uuid
AND a.published_at >= timezone(${TIMEZONE}, b.start_ts)
AND a.published_at <= timezone(${TIMEZONE}, b.end_ts)
GROUP BY 1
)
SELECT
to_char(s.d, 'YYYY-MM-DD') AS date,
COALESCE(c.c, 0) AS count
FROM series s
LEFT JOIN counts c USING (d)
ORDER BY s.d ASC
`);
return { items: data.rows, total: data.rows.length };
}
async function getCategoryShares(db: Database, id: string): Promise<CategoryShares> {
const data = await db.execute<CategoryShare>(sql`
SELECT
cat AS category,
COUNT(*)::int AS count,
ROUND((COUNT(*)::numeric / SUM(COUNT(*)) OVER ()) * 100, 2) AS percentage
FROM (
SELECT NULLIF(BTRIM(c), '') AS cat
FROM ${article}
CROSS JOIN LATERAL UNNEST(COALESCE(${article.categories}, ARRAY[]::text[])) AS c
WHERE ${article.sourceId} = ${id}
) t
WHERE cat IS NOT NULL
GROUP BY cat
ORDER BY count DESC
LIMIT ${CATEGORY_SHARES_LIMIT}
`);
return { items: data.rows, total: data.rowCount ?? 0 };
}