feat(dashboard): list sources with statistics
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
"@basango/encryption": "workspace:*",
|
||||
"@basango/logger": "workspace:*",
|
||||
"@date-fns/utc": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.44.7",
|
||||
"mysql2": "^3.15.3",
|
||||
"pg": "^8.16.3",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user