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 };
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"recharts": "^3.4.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cn } from "@basango/ui/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import * as React from "react";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp className={cn(badgeVariants({ variant }), className)} data-slot="badge" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,319 @@
|
||||
import { cn } from "@basango/ui/lib/utils";
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { dark: ".dark", light: "" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function ChartContainer({
|
||||
id,
|
||||
className,
|
||||
children,
|
||||
config,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
}) {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
data-chart={chartId}
|
||||
data-slot="chart"
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle config={config} id={chartId} />
|
||||
<RechartsPrimitive.ResponsiveContainer>{children}</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(([, config]) => config.theme || config.color);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
function ChartTooltipContent({
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>{labelFormatter(value, payload)}</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
key={item.dataKey}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"w-1": indicator === "line",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
function ChartLegendContent({
|
||||
className,
|
||||
hideIcon = false,
|
||||
payload,
|
||||
verticalAlign = "bottom",
|
||||
nameKey,
|
||||
}: React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}) {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3",
|
||||
)}
|
||||
key={item.value}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload && typeof payload.payload === "object" && payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config ? config[configLabelKey] : config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
Reference in New Issue
Block a user