Skip to content

Commit

Permalink
feat: implemented the Contributions by type of contributor graph for …
Browse files Browse the repository at this point in the history
…the list activity page (#2101)
  • Loading branch information
nickytonline authored Nov 13, 2023
2 parents 93c533d + 84b5eab commit 0088c23
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 134 deletions.
Original file line number Diff line number Diff line change
@@ -1,63 +1,38 @@
import { useState } from "react";
import { ResponsiveLine } from "@nivo/line";
import { format } from "date-fns";
import Button from "components/atoms/Button/button";
import { useMemo } from "react";
import Card from "components/atoms/Card/card";
import Icon from "components/atoms/Icon/icon";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "components/atoms/Dropdown/dropdown";
import PeopleIcon from "img/icons/people.svg";
import CalendarIcon from "img/calendar.svg";
import ChevronDownIcon from "img/chevron-down.svg";
import SVGIcon from "components/atoms/SVGIcon/svg-icon";

const dataTypes = ["active", "new", "churned"] as const;
const dataTypes = ["active", "new", "alumni"] as const;
type Stat = (typeof dataTypes)[number];

const dataLabels = {
active: "Active",
new: "New",
churned: "Churned",
alumni: "Alumni",
} as const satisfies Record<Stat, string>;

const colors = {
active: "#46a758", // green
new: "#0ea5e9", // blue
churned: "#f59e0b", // orange
alumni: "#f59e0b", // orange
} as const satisfies Record<Stat, string>;

export interface ContributionEvolutionByTypeDatum {
startTime: string;
time_start: string;
time_end: string;
active: number;
new: number;
churned: number;
alumni: number;
all: number;
}

interface Props {
data: ContributionEvolutionByTypeDatum[];
interface ContributionsEvolutionByTypeProps {
data?: ContributionEvolutionByTypeDatum[];
isLoading: boolean;
}

const dateFilters = {
last7days: "Last 7 days",
last30days: "Last 30 days",
last3months: "Last 3 months",
};

const peopleFilters = {
all: "All Contributors",
active: "Active Contributors",
new: "New Contributors",
churned: "Churned Contributors",
};

export default function ContributionsEvolutionByType(props: Props) {
const [currentDateFilter, setCurrentDateFilter] = useState<keyof typeof dateFilters>("last7days"); // TODO: make this a prop
const [currentPeopleFilter, setCurrentPeopleFilter] = useState<keyof typeof peopleFilters>("all"); // TODO: make this a prop

export default function ContributionsEvolutionByType({ data = [], isLoading }: ContributionsEvolutionByTypeProps) {
/*
Group the data by kind of contributor 'active', 'new', 'churned'
format it like so:
Expand All @@ -81,22 +56,42 @@ export default function ContributionsEvolutionByType(props: Props) {
]
*/

const groupedData = dataTypes.map((type) => ({
id: dataLabels[type],
color: colors[type],
data: props.data.map((datum) => ({
x: new Date(datum.startTime),
y: datum[type],
})),
}));
const groupedData = useMemo(() => {
// Calculates the average at each point in time for each type of contributor
const averageDataByType = dataTypes.map((type) => {
const typeData = data.map((datum) => ({
x: new Date(datum.time_start),
y: datum[type] as number,
}));

const averageDataPoints = [];

for (let i = 0; i < typeData.length; i++) {
const averageY = typeData.slice(0, i + 1).reduce((sum, dataPoint) => sum + dataPoint.y, 0) / (i + 1);

averageDataPoints.push({
x: typeData[i].x,
y: averageY,
});
}

return {
id: dataLabels[type],
color: colors[type],
data: averageDataPoints,
};
});

return averageDataByType;
}, [data]);

return (
<div>
<Card className="grid place-content-stretch overflow-hidden">
<div
className="grid p-2"
style={{
gridTemplateRows: "auto auto auto 1fr auto",
gridTemplateRows: "auto auto 1fr auto",
maxHeight: "500px",
minHeight: "500px",
justifyItems: "stretch",
Expand All @@ -105,84 +100,48 @@ export default function ContributionsEvolutionByType(props: Props) {
>
<div className="text-lg text-slate-900 mb-2">Contributions Evolution</div>
<div className="text-sm font-medium text-slate-400 mb-4">This is going to be an auto-generated insight.</div>
{/* buttons */}
<div className="flex gap-1 mb-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="items-center gap-1">
<Icon IconImage={CalendarIcon} className="w-4 h-4" />
{dateFilters[currentDateFilter]}
<Icon IconImage={ChevronDownIcon} className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="flex flex-col gap-2">
{Object.entries(dateFilters).map(([key, value]) => (
<DropdownMenuItem
key={key}
className="rounded-md !cursor-pointer"
onClick={() => setCurrentDateFilter(key as keyof typeof dateFilters)}
>
{value}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="default" className="items-center gap-1">
<SVGIcon IconImage={`${PeopleIcon.src}#icon`} className="w-4 h-4" />
{peopleFilters[currentPeopleFilter]}
<Icon IconImage={ChevronDownIcon} className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="flex flex-col gap-2">
{Object.entries(peopleFilters).map(([key, value]) => (
<DropdownMenuItem
key={key}
className="rounded-md !cursor-pointer"
onClick={() => setCurrentPeopleFilter(key as keyof typeof peopleFilters)}
>
{value}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* chart */}
<div className="mb-3 grid " style={{ height: "auto" }}>
<div>
<ResponsiveLine
data={groupedData}
lineWidth={3}
enablePoints={false}
enableGridX={false}
enablePointLabel={false}
enableCrosshair={true}
enableSlices="x"
isInteractive={true}
useMesh={true}
xScale={{
type: "time",
format: "%Y-%m-%d",
useUTC: false,
precision: "day",
}}
xFormat="time:%Y-%m-%d"
yScale={{
type: "linear",
}}
axisLeft={{ tickValues: 5, tickSize: 0 }}
axisBottom={{
format: (value) => format(value, "MM/dd"),
tickSize: 0,
}}
margin={{ top: 20, right: 40, bottom: 30, left: 40 }}
motionConfig="stiff"
curve="monotoneX"
colors={(d) => d.color}
/>
</div>
<div className="sr-only" aria-live="polite">
{isLoading ? "Loading the contributions evolution graph" : "The contributions evolution graph has loaded"}
</div>
{isLoading ? (
<div className="loading mb-4 rounded" />
) : (
<div className="mb-3 grid" style={{ height: "auto" }}>
<div>
<ResponsiveLine
data={groupedData}
lineWidth={3}
enablePoints={false}
enableGridX={false}
enablePointLabel={false}
enableCrosshair={true}
enableSlices="x"
isInteractive={true}
useMesh={true}
xScale={{
type: "time",
format: "%Y-%m-%d",
useUTC: false,
precision: "day",
}}
xFormat="time:%Y-%m-%d"
yScale={{
type: "linear",
}}
axisLeft={{ tickValues: 5, tickSize: 0 }}
axisBottom={{
format: (value) => format(value, "MM/dd"),
tickSize: 0,
}}
margin={{ top: 20, right: 40, bottom: 30, left: 40 }}
motionConfig="stiff"
curve="natural"
colors={(d) => d.color}
/>
</div>
</div>
)}
{/* key */}
<div className="flex justify-center gap-4">
{dataTypes.map((type) => (
Expand Down
43 changes: 43 additions & 0 deletions lib/hooks/api/useContributionsByEvolutionType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useState } from "react";
import useSWR, { Fetcher } from "swr";

import publicApiFetcher from "lib/utils/public-api-fetcher";
import { ContributorType } from "components/molecules/MostActiveContributorsCard/most-active-contributors-card";
import { ContributionEvolutionByTypeDatum } from "components/molecules/ContributionsEvolutionByTypeCard/contributions-evolution-by-type-card";

/**
* Fetch most active contributors from a list.
*
*/
const useContributionsEvolutionByType = ({
listId,
range = "30",
defaultContributorType = "all",
}: {
listId: string;
range: string;
defaultContributorType?: ContributorType;
}) => {
const [contributorType, setContributorType] = useState<ContributorType>(defaultContributorType);

const query = new URLSearchParams();
query.set("contributorType", `${contributorType}`);
query.set("range", range);

const apiEndpoint = `lists/${listId}/stats/contributions-evolution-by-contributor-type?${query.toString()}`;

const { data, error, mutate } = useSWR<ContributionEvolutionByTypeDatum[], Error>(
listId ? apiEndpoint : null,
publicApiFetcher as Fetcher<ContributionEvolutionByTypeDatum[], Error>
);

return {
data,
isLoading: !error && !data,
isError: !!error,
contributorType,
setContributorType,
};
};

export default useContributionsEvolutionByType;
22 changes: 13 additions & 9 deletions pages/lists/[listId]/activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { ContributionsTreemap } from "components/molecules/ContributionsTreemap/
import { useContributorsByProject } from "lib/hooks/api/useContributorsByProject";
import { useContributionsByProject } from "lib/hooks/api/useContributionsByProject";
import { getGraphColorPalette } from "lib/utils/color-utils";
import ContributionsEvolutionByType from "components/molecules/ContributionsEvolutionByTypeCard/contributions-evolution-by-type-card";
import useContributionsEvolutionByType from "lib/hooks/api/useContributionsByEvolutionType";
import { setQueryParams } from "lib/utils/query-params";

interface ContributorListPageProps {
Expand Down Expand Up @@ -84,7 +86,7 @@ export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {

const ListActivityPage = ({ list, numberOfContributors, isError, activityData }: ContributorListPageProps) => {
const router = useRouter();
const { range } = router.query;
const range = router.query.range as string;
const isOwner = false;
const {
data: contributorStats,
Expand All @@ -100,16 +102,11 @@ const ListActivityPage = ({ list, numberOfContributors, isError, activityData }:
}
}, [range]);

const {
setRepoId,
error,
data: projectContributionsByUser,
repoId,
} = useContributorsByProject(list!.id, range as string);
const { setRepoId, error, data: projectContributionsByUser, repoId } = useContributorsByProject(list!.id, range);

const { data: projectData, error: projectDataError } = useContributionsByProject({
listId: list!.id,
range: range as string,
range,
initialData: activityData.projectData,
});

Expand All @@ -136,12 +133,18 @@ const ListActivityPage = ({ list, numberOfContributors, isError, activityData }:
}),
};

const {
data: evolutionData,
isError: evolutionError,
isLoading: isLoadingEvolution,
} = useContributionsEvolutionByType({ listId: list!.id, range });

return (
<ListPageLayout list={list} numberOfContributors={numberOfContributors} isOwner={isOwner}>
{isError ? (
<Error errorMessage="Unable to load list activity" />
) : (
<div className="lg:grid lg:grid-cols-2 lg:grid-rows-2 gap-4 flex flex-col">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<ClientOnly>
{/* TODO: Remove client only once server data is being used in the hook on initial load client-side */}
<MostActiveContributorsCard
Expand All @@ -160,6 +163,7 @@ const ListActivityPage = ({ list, numberOfContributors, isError, activityData }:
data={treemapData}
color={getGraphColorPalette()}
/>
<ContributionsEvolutionByType data={evolutionData} isLoading={isLoadingEvolution} />
</div>
)}
</ListPageLayout>
Expand Down
Loading

0 comments on commit 0088c23

Please sign in to comment.