feat(dashboard): more type safety

This commit is contained in:
2025-11-17 01:26:33 +02:00
parent f39635e04f
commit 22aab9ffc6
25 changed files with 120 additions and 71 deletions
+3 -5
View File
@@ -31,11 +31,9 @@ export const sourcesRouter = createTRPCRouter({
return getSourceCategoryShares(ctx.db, input);
}),
getPublicationGraph: protectedProcedure
.input(getPublicationsSchema)
.query(async ({ ctx, input }) => {
return getSourcePublicationGraph(ctx.db, input);
}),
getPublications: protectedProcedure.input(getPublicationsSchema).query(async ({ ctx, input }) => {
return getSourcePublicationGraph(ctx.db, input);
}),
list: protectedProcedure.query(async ({ ctx }) => getSources(ctx.db)),
+7
View File
@@ -1,4 +1,11 @@
{
"compilerOptions": {
"paths": {
"#api/*": ["./src/*"],
"#db/*": ["../../packages/db/src/*"],
"#domain/*": ["../../packages/domain/src/*"]
}
},
"extends": "@basango/tsconfig/base.json",
"include": ["src"]
}
+6
View File
@@ -1,4 +1,10 @@
{
"compilerOptions": {
"paths": {
"#crawler/*": ["./src/*"],
"#domain/*": ["../../packages/domain/src/*"]
}
},
"extends": "@basango/tsconfig/base.json",
"include": ["src"]
}
@@ -1,9 +1,9 @@
import { Source } from "@basango/domain/models/sources";
import { Button } from "@basango/ui/components/button";
import { PlusIcon } from "lucide-react";
import { Metadata } from "next";
import Link from "next/link";
import { RouterOutputs } from "#api/trpc/routers/_app";
import { SourceCreateModal } from "#dashboard/components/modals/source-create-modal";
import { PageLayout } from "#dashboard/components/shell/page-layout";
import { SourceCard } from "#dashboard/components/source-card";
@@ -13,11 +13,13 @@ export const metadata: Metadata = {
title: "Sources | Basango Dashboard",
};
type Source = RouterOutputs["sources"]["list"][number];
export default async function Page() {
const queryClient = getQueryClient();
prefetch(trpc.sources.get.queryOptions());
const sources: Source[] = await queryClient.fetchQuery(trpc.sources.get.queryOptions());
prefetch(trpc.sources.list.queryOptions());
const sources = await queryClient.fetchQuery(trpc.sources.list.queryOptions());
return (
<HydrateClient>
+9 -15
View File
@@ -23,18 +23,8 @@ type ArticleCardProps = {
article: Article;
};
function getDescription(article: Article) {
return (
article.metadata?.description ??
article.excerpt ??
"No description was provided for this article."
);
}
export function ArticleCard({ article }: ArticleCardProps) {
const [copied, setCopied] = React.useState(false);
const description = getDescription(article);
const imageUrl = article.image ?? undefined;
const copyLink = React.useCallback(async () => {
try {
@@ -50,12 +40,12 @@ export function ArticleCard({ article }: ArticleCardProps) {
<Card className="flex h-full flex-col overflow-hidden border border-border/80 p-0">
<CardHeader className="relative h-40 overflow-hidden p-0">
<div className="relative h-full w-full bg-muted">
{imageUrl ? (
{article.image ? (
<img
alt={article.title}
className="h-full w-full object-cover transition duration-200 hover:scale-105"
loading="lazy"
src={imageUrl}
src={article.image}
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-muted-foreground">
@@ -63,7 +53,7 @@ export function ArticleCard({ article }: ArticleCardProps) {
</div>
)}
<div className="absolute left-3 top-3">
<Badge variant="secondary">{article.sourceName}</Badge>
<Badge variant="secondary">{article.source?.name}</Badge>
</div>
<div className="absolute right-3 top-3">
<DropdownMenu>
@@ -103,14 +93,18 @@ export function ArticleCard({ article }: ArticleCardProps) {
{article.title}
</Link>
</CardTitle>
<p className="text-sm text-muted-foreground line-clamp-3">{description}</p>
<p className="text-sm text-muted-foreground line-clamp-3">
{article.metadata?.description ??
article.excerpt ??
"No description was provided for this article."}
</p>
</CardContent>
<CardFooter className="flex items-center justify-between gap-2 px-4 py-3 text-xs text-muted-foreground">
<div className="flex flex-col">
<span className="font-medium text-foreground">
{formatDate(article.publishedAt.toISOString(), "PP", false)}
</span>
<span>{formatRelativeTime(new Date(article.publishedAt))}</span>
<span>{formatRelativeTime(article.publishedAt)}</span>
</div>
<span>{article.readingTime} min</span>
</CardFooter>
@@ -5,11 +5,11 @@ import { Area, AreaChart as BaseAreachart, CartesianGrid, XAxis, YAxis } from "r
import { formatDate, formatNumber } from "#dashboard/utils/utils";
type AreaChartProps = {
data: unknown;
type AreaChartProps<T> = {
data: T[];
};
export function AreaChart({ data }: AreaChartProps) {
export function AreaChart<T>({ data }: AreaChartProps<T>) {
return (
<BaseAreachart accessibilityLayer data={data}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
@@ -1,4 +1,3 @@
// @ts-nocheck
"use client";
import {
@@ -33,7 +32,7 @@ export function PublicationGraphChart() {
const period = useChartPeriodFilter();
const { data } = useQuery(
trpc.articles.getPublicationGraph.queryOptions({
trpc.articles.getPublications.queryOptions({
range: period.range,
}),
);
@@ -45,19 +44,19 @@ export function PublicationGraphChart() {
<CardTitle>{formatNumber(data?.meta?.current)} articles</CardTitle>
<CardDescription>
<div className="flex items-center justify-start gap-1 text-xs">
<Status value={data?.delta} />
<Status value={data?.meta?.delta} />
<span className="text-muted-foreground">vs previous</span>
</div>
</CardDescription>
</div>
<div className="flex items-center gap-3">
<ChartPeriodPicker defaultDays={period.defaultDays} paramKey="articlesPeriod" />
<ChartPeriodPicker defaultDays={period.defaultDays} />
</div>
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
<AreaChart data={data?.items} />
<AreaChart data={data?.items ?? []} />
</ChartContainer>
</CardContent>
@@ -68,7 +67,7 @@ export function PublicationGraphChart() {
<span className="font-semibold text-foreground">
{formatNumber(data?.meta?.current)} vs {formatNumber(data?.meta?.previous)} articles
</span>
<Status icons={false} percentage={true} value={data?.deltaPercentage} />
<Status icons={false} percentage={true} value={data?.meta?.delta} />
<span className="text-muted-foreground">period</span>
{data?.meta?.previous === 0 && data?.meta?.current === 0 && (
<span className="text-muted-foreground">(no articles yet)</span>
@@ -1,4 +1,3 @@
// @ts-nocheck
"use client";
import {
@@ -5,11 +5,11 @@ import { Bar, BarChart as BaseBarChart, CartesianGrid, XAxis } from "recharts";
import { formatDate } from "#dashboard/utils/utils";
type BarChartProps = {
data: unknown;
type BarChartProps<T> = {
data: T[];
};
export function BarChart({ data }: BarChartProps) {
export function BarChart<T>({ data }: BarChartProps<T>) {
return (
<BaseBarChart accessibilityLayer data={data}>
<CartesianGrid vertical={false} />
@@ -25,8 +25,6 @@ const DEFAULT_PERIOD_OPTIONS = [
{ label: "Last 12 months", value: 365 },
] as const;
type DateInput = number | Date | null | undefined;
const createRangeFromDays = (days: number): DateRange => {
const end = new Date();
@@ -75,7 +73,7 @@ export function useChartPeriodFilter(options: ChartPeriodFilterOptions = {}) {
return undefined;
}, [from, to]);
const range = useMemo(() => {
const calendarRange: DateRange | undefined = useMemo(() => {
if (from && to) {
return { from, to };
}
@@ -83,7 +81,10 @@ export function useChartPeriodFilter(options: ChartPeriodFilterOptions = {}) {
return defaultRange;
}, [defaultRange, from, to]);
const range = useMemo(() => formatDomainDateRange(calendarRange), [calendarRange]);
return {
calendarRange,
defaultDays,
keys: { fromKey, toKey },
range,
@@ -118,19 +119,22 @@ export function ChartPeriodPicker({
paramKey = "chartPeriod",
disabled,
}: ChartPeriodPickerProps & { disabled?: boolean }) {
const { range, selectedRange, keys, setState } = useChartPeriodFilter({ defaultDays, paramKey });
const { calendarRange, selectedRange, keys, setState } = useChartPeriodFilter({
defaultDays,
paramKey,
});
const [open, setOpen] = useState(false);
const selectValue = useMemo(() => {
if (!range?.from || !range?.to) {
if (!calendarRange?.from || !calendarRange?.to) {
return "custom";
}
const diff = differenceInCalendarDays(range.to, range.from) + 1;
const diff = differenceInCalendarDays(calendarRange.to, calendarRange.from) + 1;
const match = options.find((option) => option.value === diff);
return match ? String(match.value) : "custom";
}, [options, range]);
}, [calendarRange, options]);
const handlePresetChange = (value: string) => {
if (value === "custom") {
@@ -160,7 +164,7 @@ export function ChartPeriodPicker({
};
const displayLabel =
formatDateRange(range) ??
formatDateRange(calendarRange) ??
options.find((option) => String(option.value) === selectValue)?.label ??
"Select range";
@@ -196,7 +200,7 @@ export function ChartPeriodPicker({
mode="range"
numberOfMonths={2}
onSelect={handleCalendarSelect}
selected={(selectedRange ?? range) as DateRange | undefined}
selected={(selectedRange ?? calendarRange) as DateRange | undefined}
/>
<div className="flex justify-end gap-2">
@@ -252,8 +256,14 @@ export function ChartLimitToggle({
);
}
function formatDateRange(range?: { from?: DateInput; to?: DateInput }) {
function formatDateRange(range?: DateRange) {
if (!range?.from || !range?.to) return null;
return `${format(range.from, "MMM d, yyyy")} - ${format(range.to, "MMM d, yyyy")}`;
}
function formatDomainDateRange(range?: DateRange) {
if (!range?.from || !range?.to) return undefined;
return { end: range.to, start: range.from };
}
@@ -1,4 +1,3 @@
// @ts-nocheck
"use client";
import {
@@ -11,6 +10,7 @@ import {
import { useQuery } from "@tanstack/react-query";
import { Bar, BarChart, Legend, ResponsiveContainer, XAxis, YAxis } from "recharts";
import { RouterOutputs } from "#api/trpc/routers/_app";
import { ChartLimitToggle, useChartLimitFilter } from "#dashboard/components/charts/chart-filters";
import { useTRPC } from "#dashboard/trpc/client";
import { getColorFromName } from "#dashboard/utils/categories";
@@ -19,6 +19,8 @@ type Props = {
sourceId: string;
};
type CategoryShare = RouterOutputs["sources"]["getCategoryShares"]["items"][number];
export function CategorySharesChart({ sourceId }: Props) {
const trpc = useTRPC();
const { limit } = useChartLimitFilter();
@@ -29,11 +31,12 @@ export function CategorySharesChart({ sourceId }: Props) {
limit,
}),
);
const items: CategoryShare[] = data?.items || [];
const chartData = [
{
name: "Total",
...Object.fromEntries(data?.items.map((item) => [item.category, item.count])),
...Object.fromEntries(items.map((item) => [item.category, item.count])),
},
];
@@ -44,7 +47,7 @@ export function CategorySharesChart({ sourceId }: Props) {
<CardTitle>Category Shares</CardTitle>
<CardDescription>showing top {limit} categories for this source</CardDescription>
</div>
<ChartLimitToggle paramKey={`categoryLimit-${sourceId}`} />
<ChartLimitToggle />
</CardHeader>
<CardContent>
<div className="-ml-1 h-20">
@@ -1,4 +1,3 @@
// @ts-nocheck
"use client";
import {
@@ -37,7 +36,7 @@ export function PublicationGraphChart({ sourceId }: Props) {
const period = useChartPeriodFilter();
const { data } = useQuery(
trpc.sources.getPublicationGraph.queryOptions({
trpc.sources.getPublications.queryOptions({
id: sourceId,
range: period.range,
}),
@@ -50,11 +49,11 @@ export function PublicationGraphChart({ sourceId }: Props) {
<CardTitle>Publication Graph</CardTitle>
<CardDescription>Showing total crawled articles for the selected period</CardDescription>
</div>
<ChartPeriodPicker defaultDays={period.defaultDays} paramKey={`sourcePeriod-${sourceId}`} />
<ChartPeriodPicker defaultDays={period.defaultDays} />
</CardHeader>
<CardContent className="px-2 pt-4 sm:px-6 sm:pt-6">
<ChartContainer className="aspect-auto h-[250px] w-full" config={chartConfig}>
<AreaChart data={data?.items} />
<AreaChart data={data?.items ?? []} />
</ChartContainer>
</CardContent>
</Card>
@@ -1,7 +1,7 @@
"use client";
import type { RouterOutputs } from "@basango/api/trpc/routers/_app";
import { updateSourceSchema } from "@basango/domain/models/sources";
import { updateSourceSchema } from "@basango/domain/models";
import {
Field,
FieldDescription,
@@ -1,6 +1,6 @@
"use client";
import { createSourceSchema } from "@basango/domain/models/sources";
import { createSourceSchema } from "@basango/domain/models";
import {
Field,
FieldDescription,
@@ -1,6 +1,5 @@
"use client";
import { Source } from "@basango/domain/models/sources";
import {
Card,
CardContent,
@@ -17,6 +16,7 @@ import {
} from "@basango/ui/components/chart";
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { RouterOutputs } from "#api/trpc/routers/_app";
import { formatDate, formatNumber } from "#dashboard/utils/utils";
const chartConfig = {
@@ -29,7 +29,11 @@ const chartConfig = {
},
} satisfies ChartConfig;
export function SourceCard({ source }: { source: Source }) {
type Props = {
source: RouterOutputs["sources"]["list"][number];
};
export function SourceCard({ source }: Props) {
return (
<Card>
<CardHeader>
+6
View File
@@ -1,5 +1,11 @@
{
"compilerOptions": {
"paths": {
"#api/*": ["../api/src/*"],
"#dashboard/*": ["./src/*"],
"#db/*": ["../../packages/db/src/*"],
"#domain/*": ["../../packages/domain/src/*"]
},
"plugins": [
{
"name": "next"
+2
View File
@@ -35,9 +35,11 @@
"private": true,
"scripts": {
"android": "expo start --android",
"clean": "rm -rf .expo node_modules",
"ios": "expo start --ios",
"lint": "expo lint",
"start": "expo start",
"typecheck": "tsc --noEmit",
"web": "expo start --web"
},
"version": "1.0.0"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"compilerOptions": {
"paths": {
"#mobile/*": ["./*"]
"#mobile/*": ["./src/*"]
},
"strict": true
},
+5 -4
View File
@@ -1,5 +1,6 @@
import { DEFAULT_TIMEZONE } from "@basango/domain/constants";
import {
Article,
Distribution,
Distributions,
ID,
@@ -143,7 +144,7 @@ export async function getArticles(db: Database, params: GetArticlesParams) {
.orderBy(desc(articles.publishedAt), desc(articles.id))
.limit(pagination.limit + 1);
return buildPaginatedResult(rows, pagination, {
return buildPaginatedResult<Article>(rows, pagination, {
date: "publishedAt",
id: "id",
});
@@ -154,7 +155,7 @@ export async function getArticlesPublicationGraph(
params: GetPublicationsParams,
): Promise<Publications> {
const [startDate, endDate] = buildDateRange(params.range);
const [previousRangeStart, previousRangeEnd] = buildPreviousRange([startDate, endDate]);
const [previousStart, previousEnd] = buildPreviousRange([startDate, endDate]);
const data = await db.execute<Publication>(sql`
WITH bounds AS (
@@ -193,8 +194,8 @@ export async function getArticlesPublicationGraph(
sql`
SELECT COALESCE(COUNT(*)::int, 0) AS count
FROM article a
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previousRangeStart})
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previousRangeEnd})
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previousStart})
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previousEnd})
`,
)
.then((res) => res.rows);
+1 -1
View File
@@ -24,7 +24,7 @@ export async function getSources(db: Database) {
rows.map(async (row) => ({
...row,
articles: await countArticlesBySourceId(db, row.id),
publicationGraph: await getSourcePublicationGraph(db, { id: row.id }),
publications: await getSourcePublicationGraph(db, { id: row.id }),
})),
);
}
+6
View File
@@ -1,4 +1,10 @@
{
"compilerOptions": {
"paths": {
"#db/*": ["./src/*"],
"#domain/*": ["../domain/src/*"]
}
},
"exclude": ["node_modules"],
"extends": "@basango/tsconfig/base.json",
"include": ["src"]
+15
View File
@@ -2,6 +2,8 @@ import { z } from "@hono/zod-openapi";
import { idSchema, sentimentSchema } from "#domain/models/shared";
import { sourceSchema } from "./sources";
// schemas
export const articleMetadataSchema = z.object({
author: z.string().optional().openapi({
@@ -70,11 +72,19 @@ export const articleSchema = z.object({
description: "The date and time when the article was created in the system.",
example: "2023-01-01T12:00:00Z",
}),
excerpt: z.string().optional().openapi({
description: "A brief excerpt or summary of the article.",
example: "This article discusses the latest advancements in AI technology.",
}),
hash: z.string().min(1).openapi({
description: "The unique hash of the article link.",
example: "d41d8cd98f00b204e9800998ecf8427e",
}),
id: idSchema,
image: z.url().optional().openapi({
description: "The URL of the main image associated with the article.",
example: "https://example.com/image.jpg",
}),
link: z.string().url().openapi({
description: "The URL of the article.",
example: "https://example.com/article",
@@ -84,6 +94,11 @@ export const articleSchema = z.object({
description: "The publication date of the article as a Date object.",
example: "2023-01-01T00:00:00Z",
}),
readingTime: z.number().int().min(1).openapi({
description: "Estimated reading time of the article in minutes.",
example: 5,
}),
source: sourceSchema.optional(),
sourceId: z.union([z.uuid(), z.string().min(1)]).openapi({
description: "The unique identifier of the source from which the article was crawled.",
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
+5
View File
@@ -1,4 +1,9 @@
{
"compilerOptions": {
"paths": {
"#domain/*": ["./src/*"]
}
},
"exclude": ["node_modules"],
"extends": "@basango/tsconfig/base.json",
"include": ["src"]
+1 -1
View File
@@ -1,5 +1,5 @@
{
"exclude": ["node_modules"],
"extends": "@basango/tsconfig/base.json",
"include": ["src/**/*"]
"include": ["src"]
}
-7
View File
@@ -11,13 +11,6 @@
"moduleDetection": "force",
"moduleResolution": "Bundler",
"noUncheckedIndexedAccess": true,
"paths": {
"#api/*": ["../../apps/api/src/*"],
"#crawler/*": ["../../apps/crawler/src/*"],
"#dashboard/*": ["../../apps/dashboard/src/*"],
"#db/*": ["../db/src/*"],
"#domain/*": ["../domain/src/*"]
},
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,