feat(dashboard): source overview

This commit is contained in:
2025-11-15 01:06:03 +02:00
parent 7b01f67358
commit 1a0abbabaf
14 changed files with 371 additions and 30 deletions
+1 -2
View File
@@ -11,7 +11,6 @@
],
"allowMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
"exposeHeaders": ["Content-Length"],
"maxAge": 86400,
"origin": "%env(BASANGO_API_ALLOWED_ORIGINS)%"
"maxAge": 86400
}
}
+4 -1
View File
@@ -11,7 +11,10 @@ const ServerConfigurationSchema = z.object({
allowMethods: z.array(z.string()).optional(),
exposeHeaders: z.array(z.string()).optional(),
maxAge: z.number().int().min(0).optional(),
origin: z.string(), //z.array(z.string()).default([]),
origin: z
.array(z.string())
.optional()
.default(["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"]),
}),
server: z.object({
host: z.string().default("localhost"),
+1 -1
View File
@@ -22,7 +22,7 @@ app.use(
allowMethods: config.cors.allowMethods,
exposeHeaders: config.cors.exposeHeaders,
maxAge: config.cors.maxAge,
origin: config.cors.origin,
origin: ["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"],
}),
);
+37
View File
@@ -55,6 +55,43 @@ export const getSourceSchema = z.object({
id: idSchema,
});
export const getSourcePublicationGraphSchema = z.object({
days: z
.number()
.optional()
.openapi({
default: 60,
description: "",
example: 60,
})
.openapi({
description: "The number of days to include in the publication graph.",
}),
id: idSchema,
range: z
.object({
from: z.date().openapi({
description: "The start date of the range.",
}),
to: z.date().openapi({
description: "The end date of the range.",
}),
})
.optional()
.openapi({
description: "The date range for the publication graph.",
}),
});
export const getSourceCategorySharesSchema = z.object({
id: idSchema,
limit: z.number().int().min(1).max(100).optional().openapi({
default: 10,
description: "The maximum number of categories to return.",
example: 10,
}),
});
export const updateSourceSchema = z.object({
credibility: credibilitySchema.optional(),
description: createSourceSchema.shape.description,
+17 -7
View File
@@ -7,7 +7,13 @@ import {
updateSource,
} from "@basango/db/queries";
import { createSourceSchema, getSourceSchema, updateSourceSchema } from "#api/schemas/sources";
import {
createSourceSchema,
getSourceCategorySharesSchema,
getSourcePublicationGraphSchema,
getSourceSchema,
updateSourceSchema,
} from "#api/schemas/sources";
import { createTRPCRouter, protectedProcedure } from "#api/trpc/init";
export const sourcesRouter = createTRPCRouter({
@@ -21,13 +27,17 @@ export const sourcesRouter = createTRPCRouter({
return getSourceById(ctx.db, input.id);
}),
getCategoryShares: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => {
return getSourceCategoryShares(ctx.db, input.id);
}),
getCategoryShares: protectedProcedure
.input(getSourceCategorySharesSchema)
.query(async ({ ctx, input }) => {
return getSourceCategoryShares(ctx.db, input);
}),
getPublicationGraph: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => {
return getSourcePublicationGraph(ctx.db, input.id);
}),
getPublicationGraph: protectedProcedure
.input(getSourcePublicationGraphSchema)
.query(async ({ ctx, input }) => {
return getSourcePublicationGraph(ctx.db, input);
}),
update: protectedProcedure.input(updateSourceSchema).mutation(async ({ ctx, input }) => {
return updateSource(ctx.db, input);
@@ -0,0 +1,51 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@basango/ui/components/tabs";
import { Metadata } from "next";
import { SourceCategorySharesChart } from "#dashboard/components/charts/source-category-shares-chart";
import { SourcePublicationgGraphChart } from "#dashboard/components/charts/source-publication-graph-chart";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { HydrateClient, batchPrefetch, getQueryClient, trpc } from "#dashboard/trpc/server";
export const metadata: Metadata = {
title: "Source Details | Basango Dashboard",
};
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const queryClient = getQueryClient();
batchPrefetch([
trpc.sources.getById.queryOptions({ id }),
trpc.sources.getCategoryShares.queryOptions({ id }),
trpc.sources.getPublicationGraph.queryOptions({ id }),
]);
const source = await queryClient.fetchQuery(trpc.sources.getById.queryOptions({ id }));
return (
<HydrateClient>
<PageLayout leading={source.description ?? "No description available"} title={source.name}>
<Tabs className="space-y-4" defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="articles">Articles</TabsTrigger>
</TabsList>
<TabsContent className="space-y-4" value="overview">
<SourceCategorySharesChart sourceId={source.id} />
<SourcePublicationgGraphChart sourceId={source.id} />
</TabsContent>
<TabsContent value="articles">
<div className="flex flex-1 flex-col gap-4">
<div className="grid auto-rows-min gap-4 md:grid-cols-3">
<div className="bg-muted/50 aspect-video rounded-xl" />
<div className="bg-muted/50 aspect-video rounded-xl" />
<div className="bg-muted/50 aspect-video rounded-xl" />
</div>
<div className="bg-muted/50 min-h-screen flex-1 rounded-xl md:min-h-min" />
</div>
</TabsContent>
</Tabs>
</PageLayout>
</HydrateClient>
);
}
@@ -1,8 +1,9 @@
import { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { Metadata } from "next";
import Link from "next/link";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { SourceCard } from "#dashboard/components/source-card";
import { SourceCard } from "#dashboard/components/widgets/source-card";
import { HydrateClient, getQueryClient, prefetch, trpc } from "#dashboard/trpc/server";
export const metadata: Metadata = {
@@ -22,7 +23,9 @@ export default async function Page() {
<PageLayout leading="Manage your news sources" title="Sources">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{sources.map((source: SourceDetails) => (
<SourceCard key={source.id} source={source} />
<Link href={`/sources/${source.id}`} key={source.id}>
<SourceCard source={source} />
</Link>
))}
</div>
</PageLayout>
@@ -0,0 +1,102 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@basango/ui/components/card";
import { ToggleGroup, ToggleGroupItem } from "@basango/ui/components/toggle-group";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Bar, BarChart, Legend, ResponsiveContainer, XAxis, YAxis } from "recharts";
import { useTRPC } from "#dashboard/trpc/client";
import { getColorFromName } from "#dashboard/utils/categories";
type Props = {
sourceId: string;
};
export function SourceCategorySharesChart({ sourceId }: Props) {
const trpc = useTRPC();
const [limit, setLimit] = useState(10);
const { data } = useQuery(
trpc.sources.getCategoryShares.queryOptions({
id: sourceId,
limit: limit,
}),
);
const items = data?.items ?? [];
const chartData = [
{
name: "Total",
...Object.fromEntries(items.map((item) => [item.category, item.count])),
},
];
const barData = items.map((item) => ({
fill: getColorFromName(item.category),
name: item.category,
}));
return (
<Card className="pt-0">
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
<div className="grid flex-1 gap-1">
<CardTitle>Category Shares</CardTitle>
<CardDescription>showing top {limit} categories for this source</CardDescription>
</div>
<ToggleGroup
className="*:data-[slot=toggle-group-item]:px-4! @[767px]/card:flex"
onValueChange={(v) => setLimit(Number(v))}
type="single"
value={String(limit)}
variant="outline"
>
<ToggleGroupItem value="10">Top 10</ToggleGroupItem>
<ToggleGroupItem value="20">Top 20</ToggleGroupItem>
<ToggleGroupItem value="50">Top 50</ToggleGroupItem>
</ToggleGroup>
</CardHeader>
<CardContent>
<div className="-ml-1 h-20">
<ResponsiveContainer height="100%" width="100%">
<BarChart
data={chartData}
layout="vertical"
margin={{ bottom: 0, left: 0, right: 0, top: 0 }}
>
<YAxis
axisLine={false}
dataKey="name"
fontSize={12}
hide
scale="band"
type="category"
/>
<XAxis axisLine={false} fontSize={12} hide tickLine={false} type="number" />
<Legend align="left" iconSize={8} iconType="circle" />
{barData.map((entry, index) => (
<Bar
barSize={16}
className="transition-all delay-75"
dataKey={entry.name}
fill={entry.fill}
key={`bar-${index}`}
radius={
index === 0 ? [4, 0, 0, 4] : index === barData.length - 1 ? [0, 4, 4, 0] : 0
}
stackId="category"
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,109 @@
"use client";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@basango/ui/components/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@basango/ui/components/chart";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@basango/ui/components/select";
import { useQuery } from "@tanstack/react-query";
import * as React from "react";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { useTRPC } from "#dashboard/trpc/client";
import { formatDate } from "#dashboard/utils/utils";
const chartConfig = {
count: {
color: "var(--chart-2)",
label: "Articles",
},
views: {
label: "Articles",
},
} satisfies ChartConfig;
type Props = {
sourceId: string;
};
export function SourcePublicationgGraphChart({ sourceId }: Props) {
const trpc = useTRPC();
const [timeRange, setTimeRange] = React.useState("30");
const { data } = useQuery(
trpc.sources.getPublicationGraph.queryOptions({
days: Number(timeRange),
id: sourceId,
}),
);
return (
<Card className="pt-0">
<CardHeader className="flex items-center gap-2 space-y-0 border-b py-5 sm:flex-row">
<div className="grid flex-1 gap-1">
<CardTitle>Publication Graph</CardTitle>
<CardDescription>
Showing total crawled articles for the last {timeRange} days
</CardDescription>
</div>
<Select onValueChange={setTimeRange} value={timeRange}>
<SelectTrigger
aria-label="Select a value"
className="hidden w-40 rounded-lg sm:ml-auto sm:flex"
>
<SelectValue placeholder="Last 3 months" />
</SelectTrigger>
<SelectContent className="rounded-xl">
<SelectItem className="rounded-lg" value="7">
Last 7 days
</SelectItem>
<SelectItem className="rounded-lg" value="30">
Last 30 days
</SelectItem>
<SelectItem className="rounded-lg" value="90">
Last 3 months
</SelectItem>
<SelectItem className="rounded-lg" value="180">
Last 6 months
</SelectItem>
<SelectItem className="rounded-lg" value="365">
Last 12 months
</SelectItem>
</SelectContent>
</Select>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
<BarChart accessibilityLayer data={data?.items}>
<CartesianGrid vertical={false} />
<XAxis
axisLine={false}
dataKey="date"
minTickGap={32}
tickFormatter={(value) => formatDate(new Date(value).toISOString())}
tickLine={false}
tickMargin={8}
/>
<ChartTooltip content={<ChartTooltipContent indicator="dashed" />} cursor={false} />
<Bar dataKey="count" fill="var(--color-count)" radius={4} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}
@@ -16,7 +16,7 @@ import {
} from "@basango/ui/components/chart";
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
import { formatNumber } from "#dashboard/utils/utils";
import { formatDate, formatNumber } from "#dashboard/utils/utils";
const chartConfig = {
count: {
@@ -61,13 +61,7 @@ export function SourceCard({ source }: { source: SourceDetails }) {
axisLine={false}
dataKey="date"
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "short",
});
}}
tickFormatter={(value) => formatDate(new Date(value).toISOString())}
tickLine={false}
tickMargin={8}
/>
+12
View File
@@ -54,3 +54,15 @@ export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptio
void queryClient.prefetchQuery(queryOptions);
}
}
export function batchPrefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptionsArray: T[]) {
const queryClient = getQueryClient();
for (const queryOptions of queryOptionsArray) {
if (queryOptions.queryKey[1]?.type === "infinite") {
void queryClient.prefetchInfiniteQuery(queryOptions as any);
} else {
void queryClient.prefetchQuery(queryOptions);
}
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ 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 = 60;
export const PUBLICATION_GRAPH_DAYS = 30;
/**
* Maximum number of category shares to return for a source.
+24 -8
View File
@@ -16,7 +16,7 @@ export async function getSources(db: Database) {
rows.map(async (row) => ({
...row,
articles: await countArticlesBySourceId(db, row.id),
publicationGraph: await getSourcePublicationGraph(db, row.id),
publicationGraph: await getSourcePublicationGraph(db, { id: row.id }),
})),
);
}
@@ -133,13 +133,21 @@ export type CategoryShares = {
total: number;
};
export type GetSourcePublicationGraphParams = {
id: string;
days?: number;
range?: {
from: Date;
to: Date;
};
};
export async function getSourcePublicationGraph(
db: Database,
id: string,
days: number = PUBLICATION_GRAPH_DAYS,
params: GetSourcePublicationGraphParams,
): Promise<PublicationGraph> {
const endDate = endOfDay(new Date());
const startDate = startOfDay(subDays(endDate, days - 1));
const startDate = startOfDay(subDays(endDate, params.days ?? PUBLICATION_GRAPH_DAYS - 1));
const data = await db.execute<PublicationEntry>(sql`
WITH bounds AS (
@@ -161,7 +169,7 @@ export async function getSourcePublicationGraph(
a.published_at::date AS d,
COUNT(*)::int AS c
FROM article a, bounds b
WHERE a.source_id = ${id}::uuid
WHERE a.source_id = ${params.id}::uuid
AND a.published_at >= timezone(${TIMEZONE}, b.start_ts)
AND a.published_at <= timezone(${TIMEZONE}, b.end_ts)
GROUP BY 1
@@ -177,7 +185,15 @@ export async function getSourcePublicationGraph(
return { items: data.rows, total: data.rows.length };
}
export async function getSourceCategoryShares(db: Database, id: string): Promise<CategoryShares> {
export type GetSourceCategorySharesParams = {
id: string;
limit?: number;
};
export async function getSourceCategoryShares(
db: Database,
params: GetSourceCategorySharesParams,
): Promise<CategoryShares> {
const data = await db.execute<CategoryShare>(sql`
SELECT
cat AS category,
@@ -187,12 +203,12 @@ export async function getSourceCategoryShares(db: Database, id: string): Promise
SELECT NULLIF(BTRIM(c), '') AS cat
FROM ${articles}
CROSS JOIN LATERAL UNNEST(COALESCE(${articles.categories}, ARRAY[]::text[])) AS c
WHERE ${articles.sourceId} = ${id}
WHERE ${articles.sourceId} = ${params.id}
) t
WHERE cat IS NOT NULL
GROUP BY cat
ORDER BY count DESC
LIMIT ${CATEGORY_SHARES_LIMIT}
LIMIT ${params.limit ?? CATEGORY_SHARES_LIMIT}
`);
return { items: data.rows, total: data.rowCount ?? 0 };
+5
View File
@@ -124,4 +124,9 @@
body {
@apply bg-background text-foreground;
}
button:not(:disabled),
[role="button"]:not(:disabled) {
cursor: pointer;
}
}