Skip to content

Commit

Permalink
more graph wip
Browse files Browse the repository at this point in the history
  • Loading branch information
ze-kel committed Apr 7, 2024
1 parent 18b04a3 commit 32682bd
Show file tree
Hide file tree
Showing 3 changed files with 232 additions and 39 deletions.
263 changes: 228 additions & 35 deletions src/components/Graphs/index.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
"use client";
import { Bar } from "@visx/shape";
import { scaleLinear, scaleBand } from "@visx/scale";
import { useQuery } from "@tanstack/react-query";
import { BarRounded } from "@visx/shape";
import { scaleLinear, scaleBand, scaleUtc } from "@visx/scale";
import { useQueries, useQuery } from "@tanstack/react-query";
import { RSAGetTrackableData } from "src/app/api/trackables/serverActions";
import { getDaysInMonth } from "date-fns";
import { useRef } from "react";
import { add, format, getDaysInMonth } from "date-fns";
import { useMemo, useRef, useState } from "react";
import { useResizeObserver } from "usehooks-ts";
import {
AxisBottom,
} from "@visx/axis";
import { AxisBottom, AxisLeft } from "@visx/axis";
import { Spinner } from "@/components/ui/spinner";
import { useDayCellContextNumber } from "@components/Providers/DayCellProvider";
import { makeColorString } from "src/helpers/colorTools";
import { useTheme } from "next-themes";
import { useTrackableContextSafe } from "@components/Providers/TrackableProvider";
import { NumberFormatter } from "@components/DayCell/DayCellNumber";
import { getDateInTimezone } from "src/helpers/timezone";
import { useUserSettings } from "@components/Providers/UserSettingsProvider";
import { create, windowScheduler } from "@yornaath/batshit";
import { RadioTabItem, RadioTabs } from "@/components/ui/radio-tabs";

export type CurveProps = {
width: number;
Expand All @@ -22,36 +26,174 @@ export type CurveProps = {

const graphHeight = 300;

export const Graph = ({
export const GraphWrapper = ({
year,
month,
id,
}: {
year: number;
month: number;
id: string;
}) => {
const { resolvedTheme } = useTheme();
const [mode, setMode] = useState("month");

return (
<div>
<div className="flex w-full justify-end">
<RadioTabs className="w-fit" value={mode} onValueChange={setMode}>
<RadioTabItem value="year">{year}</RadioTabItem>
<RadioTabItem value="month">
{format(new Date(year, month, 1), "MMMM")}
</RadioTabItem>
</RadioTabs>
</div>
{mode === "year" && <GraphYear year={year} />}
{mode === "month" && <GraphMonths year={year} month={month} />}
</div>
);
};

export const GraphYear = ({ year }: { year: number }) => {
const { trackable } = useTrackableContextSafe();

if (!trackable) throw new Error("no trackable in context");

const { settings } = useUserSettings();

const now = getDateInTimezone(settings.timezone);

const needMonths = now.getFullYear() === year ? now.getMonth() + 1 : 12;

const monthsArray = Array(needMonths)
.fill(null)
.map((_, i) => i);

// Months are stored in Tanstack Query individually, however here we need to fetch a year and do that in one request.
// So Queries fetch through batch fetcher which requests a full year and then returns month that is being asked for.
const batchFetcher = useMemo(
() =>
create({
// eslint-disable-next-line @typescript-eslint/no-unused-vars
fetcher: async (_: number[]) => {
const res = await RSAGetTrackableData({
trackableId: trackable.id,
limits: {
type: "year",
year,
},
});

return res;
},
resolver: (data, requestedMonth) => {
return data?.[year]?.[requestedMonth] || {};
},
scheduler: windowScheduler(10), // Default and can be omitted.
}),
[year, trackable.id],
);

const qq = useQueries({
queries: monthsArray.map((month) => ({
queryKey: ["trackable", trackable.id, year, month],
queryFn: async () => {
return await batchFetcher.fetch(month);
},
})),
});

const start = new Date(year, 0, 1);
const end =
needMonths >= 12 ? new Date(year + 1, 0, 1) : new Date(year, needMonths, 1);

const isLoading = qq.some((v) => v.isLoading);

const datapoints = monthsArray
.map((month) => {
const days = getDaysInMonth(new Date(year, month, 1));

const data = qq?.[month]?.data || {};

return Array(days)
.fill(days)
.map((_, i) => Number(data[i + 1] || 0));
})
.flat();

return (
<Graph
year
isLoading={isLoading}
datapoints={datapoints}
start={start}
end={end}
/>
);
};

export const GraphMonths = ({
year,
month,
}: {
year: number;
month: number;
}) => {
const { trackable } = useTrackableContextSafe();

if (!trackable) throw new Error("no trackable in context");

const { data, isLoading } = useQuery({
queryKey: ["trackable", id, year, month],
queryKey: ["trackable", trackable.id, year, month],
queryFn: async () => {
const data = await RSAGetTrackableData({
trackableId: id,
trackableId: trackable.id,
limits: { type: "month", month, year },
});

return data[year]?.[month] || {};
},
});

const start = new Date(Date.UTC(year, month, 1));
const daysInMonth = getDaysInMonth(start);
const end = add(start, { months: 1 });

const datapoints = Array(daysInMonth)
.fill(null)
.map((_, i) => Number(data?.[i + 1] || 0));

return (
<Graph
isLoading={isLoading}
datapoints={datapoints}
start={start}
end={end}
/>
);
};

const LEFT_OFFSET = 50;
const TOP_OFFSET = 10;

export const Graph = ({
datapoints,
start,
end,
isLoading,
year,
}: {
datapoints: number[];
start: Date;
end: Date;
isLoading?: boolean;
year?: boolean;
}) => {
const { resolvedTheme } = useTheme();

const wrapperRef = useRef<HTMLDivElement>(null);

const { width } = useResizeObserver({ ref: wrapperRef });

const { valueToColor } = useDayCellContextNumber();

if (isLoading || !data || !width)
if (!width || isLoading)
return (
<div
ref={wrapperRef}
Expand All @@ -62,42 +204,52 @@ export const Graph = ({
</div>
);

const daysInMonth = getDaysInMonth(new Date(year, month, 1));
const daysArray = Array(daysInMonth)
const daysArray = Array(datapoints.length)
.fill(null)
.map((_, i) => i + 1);

const numbersArray = daysArray.map((d) => Number(data[d]) || 0);
const maxY = Math.max(...numbersArray);
const minY = Math.min(...numbersArray);
const maxY = Math.max(...datapoints);
const minY = Math.min(...datapoints);

// scales
const xScale = scaleBand<number>({
range: [0, width || 600],
padding: 0.3,
range: [LEFT_OFFSET, width || 600],
padding: 0.2,
round: true,
domain: daysArray,
});

const timeScale = scaleUtc({
range: [LEFT_OFFSET, width || 600],
domain: [start, end],
});

const yScale = scaleLinear<number>({
domain: [minY, maxY],
range: [0, graphHeight],
range: [graphHeight - TOP_OFFSET, 0],
round: true,
});

const detailsColor = resolvedTheme === "dark" ? "#ffffff" : "black";

return (
<div ref={wrapperRef}>
<svg width={width} height={graphHeight + 40} className="">
{numbersArray.map((ent, i) => {
{JSON.stringify(start)}
{JSON.stringify(end)}
<svg width={width} height={graphHeight + 50} className="">
{datapoints.map((ent, i) => {
const barHeight = yScale(Number(ent) || 0);
const barWidth = xScale.bandwidth();
const barX = xScale(i + 1);
const barX = xScale(i + 1) || 0;

const color = valueToColor(ent);

const h = graphHeight - TOP_OFFSET - barHeight;

return (
<Bar
<BarRounded
radius={3}
top
data-date={i + 1}
data-value={ent}
fill={makeColorString(
Expand All @@ -106,25 +258,66 @@ export const Graph = ({
x={barX}
width={barWidth}
key={i}
height={barHeight}
y={graphHeight - barHeight}
height={h}
y={graphHeight - h}
/>
);
})}

<AxisBottom
top={graphHeight}
hideTicks
scale={xScale}
<AxisLeft
top={TOP_OFFSET}
left={LEFT_OFFSET}
scale={yScale}
stroke={detailsColor}
tickStroke={detailsColor}
numTicks={width > 600 ? daysInMonth : Math.round(daysInMonth / 2)}
numTicks={6}
tickFormat={(v) => {
return NumberFormatter.format(Number(v));
}}
tickLabelProps={{
fill: detailsColor,
fontSize: 14,
fontFamily: "system-ui",
}}
/>

{year ? (
<AxisBottom
top={graphHeight}
scale={timeScale}
stroke={detailsColor}
tickStroke={detailsColor}
numTicks={4}
tickFormat={(v) => {
return datapoints.length > 31
? format(v as Date, "MMM d")
: format(v as Date, "d");
}}
tickLabelProps={{
fill: detailsColor,
fontSize: 14,
fontFamily: "system-ui",
}}
/>
) : (
<AxisBottom
top={graphHeight}
scale={timeScale}
stroke={detailsColor}
tickStroke={detailsColor}
numTicks={31}
tickFormat={(v) => {
return datapoints.length > 31
? format(v as Date, "MMM d")
: format(v as Date, "d");
}}
tickLabelProps={{
fill: detailsColor,
fontSize: 14,
fontFamily: "system-ui",
}}
/>
)}
</svg>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/Sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ export const Sidebar = () => {
{CoreLinks.map((v) => (
<Link key={v.link} href={v.link} className="block w-full">
<Button
className="h-12 w-full justify-start gap-4 px-3 text-lg"
className=" w-full justify-start gap-4 px-3 "
size={"lg"}
variant={v.link === pathName ? "secondary" : "ghost"}
>
<v.icon className="h-5 w-5" />
<v.icon className="" />
<div>{v.name}</div>
</Button>
</Link>
Expand Down
4 changes: 2 additions & 2 deletions src/components/TrackableView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
import { useUserSettings } from "@components/Providers/UserSettingsProvider";
import { getDateInTimezone } from "src/helpers/timezone";
import { YearSelector } from "@components/TrackableView/yearSelector";
import { Graph } from "@components/Graphs";
import { GraphWrapper } from "@components/Graphs";

const Month = ({
month,
Expand Down Expand Up @@ -282,7 +282,7 @@ const StatsRouter = ({ year, month }: { year: number; month: number }) => {
const { trackable } = useTrackableContextSafe();

if (trackable?.type === "number")
return <Graph year={year} month={month} id={trackable.id} />;
return <GraphWrapper year={year} month={month} />;

return <></>;
};
Expand Down

0 comments on commit 32682bd

Please sign in to comment.