Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(charts): Add Bookmarked link metrics in Team page #77

Merged
merged 5 commits into from
Nov 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"react-hot-toast": "^2.4.0",
"react-query": "^3.39.3",
"react-tweet": "^3.1.1",
"recharts": "^2.9.3",
"remark": "^14.0.3",
"remark-html": "^15.0.2",
"satori": "^0.10.2",
Expand All @@ -93,8 +94,11 @@
"@types/lodash": "^4.14.194",
"@types/mjml": "^4.7.0",
"@types/mjml-react": "^2.0.6",
"@types/node": "^18",
"@types/nodemailer": "^6.4.7",
"@types/react": "^18",
"@types/react-beautiful-dnd": "^13.1.4",
"@types/react-dom": "^18",
"autoprefixer": "^10.4.14",
"clsx": "^1.2.1",
"cross-env": "^7.0.3",
Expand All @@ -107,9 +111,6 @@
"tailwindcss-animate": "^1.0.5",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.1",
"typescript": "^5",
"@types/node": "^18",
"@types/react": "^18",
"@types/react-dom": "^18"
"typescript": "^5"
}
}
6 changes: 4 additions & 2 deletions src/app/(routes)/teams/[teamSlug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getTeamLinks,
getTeamDigests,
updateDefaultTeam,
countAllTeamLinks,
} from '@/lib/queries';
import { getCurrentUserOrRedirect } from '@/lib/sessions';
import { Metadata } from 'next';
Expand Down Expand Up @@ -39,11 +40,12 @@ const TeamPage = async ({ params, searchParams }: TeamPageProps) => {

const linksPage = Number(searchParams?.page || 1);
const search = searchParams?.search || '';
const { linksCount, teamLinks } = await getTeamLinks(team.id, {
const { teamLinks } = await getTeamLinks(team.id, {
page: linksPage,
onlyNotInDigest: !searchParams?.all,
search,
});
const totalLinksCount = await countAllTeamLinks(team.id);

const digestsPage = Number(searchParams?.digestPage || 1);
const { digests, digestsCount } = await getTeamDigests(
Expand All @@ -56,7 +58,7 @@ const TeamPage = async ({ params, searchParams }: TeamPageProps) => {
return (
<Team
team={team}
linkCount={linksCount}
linkCount={totalLinksCount}
teamLinks={teamLinks}
digests={digests}
digestsCount={digestsCount}
Expand Down
9 changes: 8 additions & 1 deletion src/components/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ export const Card = ({
header,
footer,
className,
contentClassName,
...props
}: PropsWithChildren & {
header?: ReactNode;
footer?: ReactNode;
contentClassName?: string;
} & HTMLProps<HTMLDivElement>) => {
return (
<section
Expand All @@ -20,7 +22,12 @@ export const Card = ({
{...props}
>
{header && <div className="p-4">{header}</div>}
<div className="px-4 py-5 sm:p-6 w-full flex justify-center">
<div
className={clsx(
'px-4 py-5 sm:p-6 w-full flex justify-center',
contentClassName
)}
>
{children}
</div>
{footer && <div className="px-4 py-4 sm:px-6">{footer}</div>}
Expand Down
1 change: 1 addition & 0 deletions src/components/bookmark/BookmarksListControls.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
import { PropsWithChildren, useState } from 'react';
import Pagination from '../list/Pagination';
import clsx from 'clsx';
Expand Down
70 changes: 70 additions & 0 deletions src/components/charts/Charts.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';
import React from 'react';

import { Tooltip, ResponsiveContainer, Line, LineChart } from 'recharts';

import ChartsTooltip from './ChartsTooltip';
interface Props {
teamLinksByMonth: Array<{
month: number;
link_count: number;
}>;
}

type GraphData = Array<{
month: (typeof MONTH_SHORT_NAMES)[number];
amt: number;
}>;

const MONTH_SHORT_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sept',
'Oct',
'Nov',
'Dec',
] as const;

function refinePropToData(props: Props['teamLinksByMonth']): GraphData {
return MONTH_SHORT_NAMES.map((month, index) => {
const monthData = props.find((d) => d.month === index + 1);
return {
month,
amt: monthData ? monthData.link_count : 0,
};
});
}

export default function ClientCharts({ teamLinksByMonth }: Props) {
const data = refinePropToData(teamLinksByMonth);
return (
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={data}
margin={{ top: 6, right: 20, left: 20, bottom: 6 }}
>
<Tooltip content={<ChartsTooltip />} />
<Line
baseLine={0}
type="monotone"
strokeWidth={2}
dataKey="amt"
stroke="#7C3AED80"
dot={false}
activeDot={{
stroke: '#7C3AED80',
strokeWidth: 2,
r: 4,
fill: '#FFF',
}}
/>
</LineChart>
</ResponsiveContainer>
);
}
54 changes: 54 additions & 0 deletions src/components/charts/ChartsServer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React from 'react';
import Charts from './Charts';
import db from '@/lib/db';

interface Props {
linkCount: number;
teamId: string;
}

async function getTeamLinksCountByMonth(teamId: string) {
// Safe from SQL injection --> https://www.prisma.io/docs/concepts/components/prisma-client/raw-database-access#queryraw
const result = await db.$queryRaw`SELECT
CAST(EXTRACT(MONTH FROM b."createdAt") AS INTEGER) AS month,
CAST(COUNT(link."id") AS INTEGER) AS link_count
FROM
bookmarks b
JOIN
links link ON b."linkId" = link."id"
JOIN
teams t ON b."teamId" = t."id"
WHERE
t."id" = ${teamId}
AND EXTRACT(YEAR FROM b."createdAt") = EXTRACT(YEAR FROM CURRENT_DATE)
GROUP BY
month;`;

return result as Array<{
month: number;
link_count: number;
}>;
}

export default async function ChartsServer({ linkCount, teamId }: Props) {
const teamLinkCountByMonth = await getTeamLinksCountByMonth(teamId);
const emptyCount = teamLinkCountByMonth.length === 0;
const text = linkCount > 1 ? 'bookmarks' : 'bookmark';
if (emptyCount) {
return null;
}

return (
<div className="h-full flex flex-grow-0 w-ful items-end">
<p className="flex flex-col justify-end">
<span className="text-3xl font-bold">{linkCount}</span>
<span title="Link bookmarked" className="text-xs text-gray-400">
{text}
</span>
</p>
<div className="h-[60px] w-full">
<Charts teamLinksByMonth={teamLinkCountByMonth} />
</div>
</div>
);
}
13 changes: 13 additions & 0 deletions src/components/charts/ChartsSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';

export default function ChartsSkeleton() {
return (
<div className="h-[60px] w-full">
<div className="w-[60px] h-full rounded-sm flex flex-col justify-end">
<div className="w-full h-[36px]"></div>
<div className="w-full h-[16px"></div>
</div>
<div className="w-full h-full bg-gradient-to-r from-transparent via-gray-700/10 to-transparent -translate-x-full animate-[shimmer_2s_infinite]"></div>
</div>
);
}
49 changes: 49 additions & 0 deletions src/components/charts/ChartsTooltip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
const MONTH_LONG_NAMES = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
] as const;

type MonthLongName = (typeof MONTH_LONG_NAMES)[number];

const getMonthFullName = (index: number): MonthLongName => {
return MONTH_LONG_NAMES[index];
};

const ChartsTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
value: number;
}>;
label?: any;
}) => {
const year = new Date().getFullYear();
if (active && payload && payload.length) {
return (
<div className="overflow-hidden rounded-lg bg-white shadow p-3 flex flex-col gap-2">
<p className="text-sm text-gray-500">{`${getMonthFullName(
label
)} ${year}`}</p>
<div className="flex items-center justify-start gap-2">
<span className="w-[20px] h-[2px] bg-violet-400 content-[''] inline-block relative -bottom-[1px]" />
<span className="text-sm">{payload[0].value} bookmarked </span>
</div>
</div>
);
}
};

export default ChartsTooltip;
15 changes: 10 additions & 5 deletions src/components/digests/Digests.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
import useTransitionRefresh from '@/hooks/useTransitionRefresh';
import { TeamDigestsResult } from '@/lib/queries';
import { CheckIcon, ChartBarIcon } from '@heroicons/react/24/solid';
Expand Down Expand Up @@ -44,7 +45,8 @@ export const Digests = ({ digests, teamSlug }: Props) => {
{digest.publishedAt ? (
<>
<div className="flex items-center">
<CheckIcon className="text-green-600 h-4 w-4 mr-1" /> Published
<CheckIcon className="text-green-600 h-4 w-4 mr-1" />{' '}
Published
</div>
<time className="text-gray-500 pl-1">
- {formatDate(digest.publishedAt!, 'MMMM dd, yyyy')}
Expand All @@ -58,11 +60,14 @@ export const Digests = ({ digests, teamSlug }: Props) => {
{`${bookmarkCount} bookmark${bookmarkCount > 1 ? 's' : ''}`}
</div>
</div>
{digest.publishedAt && digest.views > 0 && <div className="flex items-center text-sm text-gray-400">
<div className="flex items-center">
<ChartBarIcon className="text-gray-400 h-4 w-4 mr-1" /> {`${digest.views} view${digest.views > 1 ? 's' : ''}`}
{digest.publishedAt && digest.views > 0 && (
<div className="flex items-center text-sm text-gray-400">
<div className="flex items-center">
<ChartBarIcon className="text-gray-400 h-4 w-4 mr-1" />{' '}
{`${digest.views} view${digest.views > 1 ? 's' : ''}`}
</div>
</div>
</div>}
)}
</div>
);
})}
Expand Down
1 change: 1 addition & 0 deletions src/components/digests/templates/SelectTemplateModal.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
'use client';
import Button from '@/components/Button';
import { Dialog, DialogTrigger, DialogContent } from '@/components/Dialog';
import { Digest, Team } from '@prisma/client';
Expand Down
23 changes: 16 additions & 7 deletions src/components/pages/Team.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
'use client';
import { TeamDigestsResult, TeamLinks, getTeamBySlug } from '@/lib/queries';
import { BsFillBookmarkFill } from '@react-icons/all-files/bs/BsFillBookmarkFill';
import Link from 'next/link';
Expand All @@ -12,6 +11,9 @@ import NoContent from '../layout/NoContent';
import PageContainer from '../layout/PageContainer';
import Pagination from '../list/Pagination';
import SelectTemplateModal from '../digests/templates/SelectTemplateModal';
import ChartsServer from '../charts/ChartsServer';
import { Suspense } from 'react';
import ChartsSkeleton from '../charts/ChartsSkeleton';

type Props = {
linkCount: number;
Expand All @@ -37,14 +39,21 @@ const Team = ({
<div className="flex max-lg:flex-col gap-5 pb-4">
<Card
className="w-full lg:w-2/3"
contentClassName="sm:py-2 sm:px-6"
header={
<div className="flex items-center justify-between gap-3 max-sm:flex-col max-sm:items-start">
<div className="flex items-center gap-3 h-8">
<h2 className="text-xl">Bookmarks</h2>
<CounterTag count={linkCount} />
<>
<div className="flex items-center justify-between gap-3 max-sm:flex-col max-sm:items-start">
<div className="flex items-center gap-3 h-8">
<h2 className="text-xl">Bookmarks</h2>
{/* <CounterTag count={linkCount} /> */}
</div>

<BookmarksListControls linkCount={linkCount} />
</div>
<BookmarksListControls linkCount={linkCount} />
</div>
<Suspense fallback={<ChartsSkeleton />}>
<ChartsServer linkCount={linkCount} teamId={team.id} />
</Suspense>
</>
}
footer={
<div className="flex items-center justify-end">
Expand Down
15 changes: 15 additions & 0 deletions src/lib/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,21 @@ export const getTeamLinks = async (
};
};

/**
* Returns the number links of a team, (all links, not only the ones in the team page)
*/
export const countAllTeamLinks = async (teamId: string) => {
return db.link.count({
where: {
bookmark: {
some: {
teamId,
},
},
},
});
};

export type TeamLinksData = Awaited<ReturnType<typeof getTeamLinks>>;

export type TeamLinks = TeamLinksData['teamLinks'];
Expand Down
Loading
Loading