feat(dashboard): source overview
This commit is contained in:
@@ -11,7 +11,6 @@
|
|||||||
],
|
],
|
||||||
"allowMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
"allowMethods": ["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"],
|
||||||
"exposeHeaders": ["Content-Length"],
|
"exposeHeaders": ["Content-Length"],
|
||||||
"maxAge": 86400,
|
"maxAge": 86400
|
||||||
"origin": "%env(BASANGO_API_ALLOWED_ORIGINS)%"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ const ServerConfigurationSchema = z.object({
|
|||||||
allowMethods: z.array(z.string()).optional(),
|
allowMethods: z.array(z.string()).optional(),
|
||||||
exposeHeaders: z.array(z.string()).optional(),
|
exposeHeaders: z.array(z.string()).optional(),
|
||||||
maxAge: z.number().int().min(0).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({
|
server: z.object({
|
||||||
host: z.string().default("localhost"),
|
host: z.string().default("localhost"),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ app.use(
|
|||||||
allowMethods: config.cors.allowMethods,
|
allowMethods: config.cors.allowMethods,
|
||||||
exposeHeaders: config.cors.exposeHeaders,
|
exposeHeaders: config.cors.exposeHeaders,
|
||||||
maxAge: config.cors.maxAge,
|
maxAge: config.cors.maxAge,
|
||||||
origin: config.cors.origin,
|
origin: ["http://localhost:3000", "http://127.0.0.1:3000", "https://dashboard.basango.io"],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,43 @@ export const getSourceSchema = z.object({
|
|||||||
id: idSchema,
|
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({
|
export const updateSourceSchema = z.object({
|
||||||
credibility: credibilitySchema.optional(),
|
credibility: credibilitySchema.optional(),
|
||||||
description: createSourceSchema.shape.description,
|
description: createSourceSchema.shape.description,
|
||||||
|
|||||||
@@ -7,7 +7,13 @@ import {
|
|||||||
updateSource,
|
updateSource,
|
||||||
} from "@basango/db/queries";
|
} 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";
|
import { createTRPCRouter, protectedProcedure } from "#api/trpc/init";
|
||||||
|
|
||||||
export const sourcesRouter = createTRPCRouter({
|
export const sourcesRouter = createTRPCRouter({
|
||||||
@@ -21,13 +27,17 @@ export const sourcesRouter = createTRPCRouter({
|
|||||||
return getSourceById(ctx.db, input.id);
|
return getSourceById(ctx.db, input.id);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getCategoryShares: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => {
|
getCategoryShares: protectedProcedure
|
||||||
return getSourceCategoryShares(ctx.db, input.id);
|
.input(getSourceCategorySharesSchema)
|
||||||
}),
|
.query(async ({ ctx, input }) => {
|
||||||
|
return getSourceCategoryShares(ctx.db, input);
|
||||||
|
}),
|
||||||
|
|
||||||
getPublicationGraph: protectedProcedure.input(getSourceSchema).query(async ({ ctx, input }) => {
|
getPublicationGraph: protectedProcedure
|
||||||
return getSourcePublicationGraph(ctx.db, input.id);
|
.input(getSourcePublicationGraphSchema)
|
||||||
}),
|
.query(async ({ ctx, input }) => {
|
||||||
|
return getSourcePublicationGraph(ctx.db, input);
|
||||||
|
}),
|
||||||
|
|
||||||
update: protectedProcedure.input(updateSourceSchema).mutation(async ({ ctx, input }) => {
|
update: protectedProcedure.input(updateSourceSchema).mutation(async ({ ctx, input }) => {
|
||||||
return updateSource(ctx.db, 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 { RouterOutputs } from "@basango/api/trpc/routers/_app";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
import { PageLayout } from "#dashboard/components/shell/page-layout";
|
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";
|
import { HydrateClient, getQueryClient, prefetch, trpc } from "#dashboard/trpc/server";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -22,7 +23,9 @@ export default async function Page() {
|
|||||||
<PageLayout leading="Manage your news sources" title="Sources">
|
<PageLayout leading="Manage your news sources" title="Sources">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
{sources.map((source: SourceDetails) => (
|
{sources.map((source: SourceDetails) => (
|
||||||
<SourceCard key={source.id} source={source} />
|
<Link href={`/sources/${source.id}`} key={source.id}>
|
||||||
|
<SourceCard source={source} />
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-8
@@ -16,7 +16,7 @@ import {
|
|||||||
} from "@basango/ui/components/chart";
|
} from "@basango/ui/components/chart";
|
||||||
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
|
||||||
|
|
||||||
import { formatNumber } from "#dashboard/utils/utils";
|
import { formatDate, formatNumber } from "#dashboard/utils/utils";
|
||||||
|
|
||||||
const chartConfig = {
|
const chartConfig = {
|
||||||
count: {
|
count: {
|
||||||
@@ -61,13 +61,7 @@ export function SourceCard({ source }: { source: SourceDetails }) {
|
|||||||
axisLine={false}
|
axisLine={false}
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
minTickGap={32}
|
minTickGap={32}
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => formatDate(new Date(value).toISOString())}
|
||||||
const date = new Date(value);
|
|
||||||
return date.toLocaleDateString("en-US", {
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickMargin={8}
|
tickMargin={8}
|
||||||
/>
|
/>
|
||||||
@@ -54,3 +54,15 @@ export function prefetch<T extends ReturnType<TRPCQueryOptions<any>>>(queryOptio
|
|||||||
void queryClient.prefetchQuery(queryOptions);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
* Number of days to include in the publication graph for sources.
|
||||||
* This defines the time range for which publication data is aggregated and displayed.
|
* 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.
|
* Maximum number of category shares to return for a source.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function getSources(db: Database) {
|
|||||||
rows.map(async (row) => ({
|
rows.map(async (row) => ({
|
||||||
...row,
|
...row,
|
||||||
articles: await countArticlesBySourceId(db, row.id),
|
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;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GetSourcePublicationGraphParams = {
|
||||||
|
id: string;
|
||||||
|
days?: number;
|
||||||
|
range?: {
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export async function getSourcePublicationGraph(
|
export async function getSourcePublicationGraph(
|
||||||
db: Database,
|
db: Database,
|
||||||
id: string,
|
params: GetSourcePublicationGraphParams,
|
||||||
days: number = PUBLICATION_GRAPH_DAYS,
|
|
||||||
): Promise<PublicationGraph> {
|
): Promise<PublicationGraph> {
|
||||||
const endDate = endOfDay(new Date());
|
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`
|
const data = await db.execute<PublicationEntry>(sql`
|
||||||
WITH bounds AS (
|
WITH bounds AS (
|
||||||
@@ -161,7 +169,7 @@ export async function getSourcePublicationGraph(
|
|||||||
a.published_at::date AS d,
|
a.published_at::date AS d,
|
||||||
COUNT(*)::int AS c
|
COUNT(*)::int AS c
|
||||||
FROM article a, bounds b
|
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.start_ts)
|
||||||
AND a.published_at <= timezone(${TIMEZONE}, b.end_ts)
|
AND a.published_at <= timezone(${TIMEZONE}, b.end_ts)
|
||||||
GROUP BY 1
|
GROUP BY 1
|
||||||
@@ -177,7 +185,15 @@ export async function getSourcePublicationGraph(
|
|||||||
return { items: data.rows, total: data.rows.length };
|
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`
|
const data = await db.execute<CategoryShare>(sql`
|
||||||
SELECT
|
SELECT
|
||||||
cat AS category,
|
cat AS category,
|
||||||
@@ -187,12 +203,12 @@ export async function getSourceCategoryShares(db: Database, id: string): Promise
|
|||||||
SELECT NULLIF(BTRIM(c), '') AS cat
|
SELECT NULLIF(BTRIM(c), '') AS cat
|
||||||
FROM ${articles}
|
FROM ${articles}
|
||||||
CROSS JOIN LATERAL UNNEST(COALESCE(${articles.categories}, ARRAY[]::text[])) AS c
|
CROSS JOIN LATERAL UNNEST(COALESCE(${articles.categories}, ARRAY[]::text[])) AS c
|
||||||
WHERE ${articles.sourceId} = ${id}
|
WHERE ${articles.sourceId} = ${params.id}
|
||||||
) t
|
) t
|
||||||
WHERE cat IS NOT NULL
|
WHERE cat IS NOT NULL
|
||||||
GROUP BY cat
|
GROUP BY cat
|
||||||
ORDER BY count DESC
|
ORDER BY count DESC
|
||||||
LIMIT ${CATEGORY_SHARES_LIMIT}
|
LIMIT ${params.limit ?? CATEGORY_SHARES_LIMIT}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return { items: data.rows, total: data.rowCount ?? 0 };
|
return { items: data.rows, total: data.rowCount ?? 0 };
|
||||||
|
|||||||
@@ -124,4 +124,9 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:not(:disabled),
|
||||||
|
[role="button"]:not(:disabled) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user