lifetracker/apps/web/components/dashboard/analytics/AnalyticsView.tsx
2025-02-01 16:17:44 -08:00

271 lines
12 KiB
TypeScript

"use client";
import LoadingSpinner from "@/components/ui/spinner";
import { api } from "@/lib/trpc";
import { use, useEffect, useRef, useState } from "react";
import spacetime from "spacetime";
import { predefinedRanges } from "@/lib/dates";
import { useSearchParams } from "next/navigation";
import { parseISO, format as fmt } from "date-fns";
import { DatePickerInput } from '@mantine/dates';
import { Calendar1, MenuIcon } from "lucide-react";
import { Anchor, Button, ButtonGroup, Menu } from "@mantine/core";
import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
// import PieChart from "./PieChart";
import { Cell, LabelList, Pie, PieChart, Tooltip } from "recharts";
import { useTimezone } from "@/lib/userLocalSettings/client";
import TimeseriesChart from "./Timeseries";
const parseDateRangeFromQuery = (): Date[] | undefined => {
const searchParams = useSearchParams();
if (!searchParams.has("dateRange")) return undefined;
const range = searchParams.get("dateRange")!
.split(",")
.map((date) => parseISO(date));
return range;
}
const updateHistory = (dateRange: Date[]) => {
const start = dateRange[0];
const end = dateRange[1];
const startStr = spacetime(start).format("iso-short");
const endStr = spacetime(end).format("iso-short");
const searchParams = new URLSearchParams();
searchParams.set("dateRange", `${startStr},${endStr}`);
history.replaceState(null, "", `?${searchParams.toString()}`);
};
export default function AnalyticsView() {
const datePickerRef = useRef<HTMLButtonElement>(null);
const { data: metrics } = api.metrics.list.useQuery();
const drugsList = metrics?.filter((metric) => metric.type === "drug");
const initialDateRange: [Date, Date] = (() => {
const range = parseDateRangeFromQuery();
if (range && range.length === 2) {
return range as [Date, Date];
}
return [
// spacetime.now().subtract(1, "week").toNativeDate(),
parseISO(spacetime.now(useTimezone()).format("iso-short")),
parseISO(spacetime.now(useTimezone()).format("iso-short")),
];
})();
const [dateRange, setDateRange] = useState(initialDateRange);
const [datePickerRange, setDatePickerRange] = useState(initialDateRange);
const [hideSleep, setHideSleep] = useState(true);
useEffect(() => {
if (datePickerRef.current?.getAttribute("aria-expanded") === "false") {
setDateRange(datePickerRange);
updateHistory(datePickerRange);
}
}, [datePickerRange]);
const { containerRef, TooltipInPortal } = useTooltipInPortal({
// TooltipInPortal is rendered in a separate child of <body /> and positioned
// with page coordinates which should be updated on scroll. consider using
// Tooltip or TooltipWithBounds if you don't need to render inside a Portal
scroll: true,
});
const rawCategoryFrequencies = api.hours.categoryFrequencies.useQuery({
dateRange,
timezone: useTimezone(),
}).data?.filter(
(category) => category.categoryCode != undefined
) ?? [];
const categoryFrequencies = hideSleep ? rawCategoryFrequencies.filter((category) => category.categoryCode > 1) : rawCategoryFrequencies;
const maybeExtendWindow = (dateRange: Date[]) => {
// If distance between dates is less than 2 days, extend the first date back by 14 days
if (spacetime(dateRange[0]).diff(dateRange[1], "day") < 2) {
return [
spacetime(dateRange[0]).subtract(14, "day").toNativeDate(),
dateRange[1]
];
}
return [dateRange[0], dateRange[1]];
}
const weightData = api.measurements.getTimeseries.useQuery({
dateRange: maybeExtendWindow(dateRange) as [Date, Date],
timezone: useTimezone(),
metricName: "weight"
}).data ?? [];
return (
<div className="px-4 flex flex-col">
<div className="flex justify-between">
<h1>Analytics for&nbsp;
{dateRange[0].getUTCDate() == dateRange[1].getUTCDate()
? spacetime(dateRange[0]).unixFmt("MMM dd, yyyy")
: dateRange.map((d) => spacetime(d).unixFmt("MMM dd, yyyy")).join(" to ")
}
</h1>
{/* <DateRangePicker
showOneCalendar
appearance="subtle"
size="lg"
ranges={predefinedRanges}
character=" &ndash; "
format="MMM dd, yyyy"
value={dateRange}
onChange={setDateRange}
style={{ width: 300, textAlignLast: "center" }}
placement="bottomEnd"
renderValue={(value, format) => {
if (fmt(value[0], format) === fmt(value[1], format)) {
return fmt(value[0], format);
}
return `${fmt(value[0], format)} - ${fmt(value[1], format)}`;
}}
/> */}
<div className="flex">
<DatePickerInput
ref={datePickerRef}
type="range"
value={datePickerRange}
onChange={setDatePickerRange}
classNames={{
calendarHeader: "text-accent",
}}
highlightToday
allowSingleDateInRange
leftSection={<Calendar1 size={18} strokeWidth={1} />}
leftSectionPointerEvents="none"
/>
<Menu position="bottom-end">
<Menu.Target>
<Button>
<MenuIcon size={18} />
</Button>
</Menu.Target>
<Menu.Dropdown>
{predefinedRanges.map((range) => (
<Menu.Item
className="py-1 px-2"
onClick={() => setDatePickerRange(range.value)}
>
<Anchor
size="xs"
>
{range.label}
</Anchor>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu></div>
</div>
<div className="mt-8 pt-4 flex gap-4">
<h2>Time</h2>
{categoryFrequencies.length === 0 ? <LoadingSpinner /> :
<div className="w-full flex flex-col lg:flex-row gap-4">
<PieChart width={500} height={500} startAngle={180} endAngle={0} >
<Pie data={categoryFrequencies} dataKey="percentage" nameKey="categoryName" label={false}
paddingAngle={0} stroke={(c) => console.log(c)}>
{categoryFrequencies.sort(
(a, b) => b.count - a.count
).map((c, _i) => (
<Cell key={`pie-category-${c.id}`} fill={c.background}
style={{ outline: 'none !important' }}
/>
))}
</Pie>
<Tooltip />
</PieChart>
<div className="grow">
<div className="flex flex-col justify-evenly h-full">
<div>
<div className="grid font-bold border-b border-gray-200 pl-4"
style={{
gridTemplateColumns: "1fr 1fr"
}}
>
<div className="whitespace-nowrap ">
Total
</div>
<div>
{categoryFrequencies.reduce((acc, category) => acc + category.count, 0)} hours
</div></div>
{
categoryFrequencies
.sort((a, b) => b.count - a.count)
.map((category, i) => (
<div key={i} className="grid p-1 pl-4"
style={{
gridTemplateColumns: "1fr 1fr",
color: category.inverse,
backgroundColor: category.color
}}
>
<div className="whitespace-nowrap">
{category.categoryName ?? "[Unallocated]"}
</div>
<div>
{category.count} hours
</div></div>
))
}</div>
</div>
</div>
<div className="mt-4 bg-accent w-fit p-2 border border-primary rounded-md cursor-pointer flex flex-col items-center">
Sleep
<ButtonGroup className="mt-2">
<Button
onClick={() => setHideSleep(true)}
color={hideSleep ? "gray" : "primary"}
>
Hide
</Button>
<Button
onClick={() => setHideSleep(false)}
color={hideSleep ? "primary" : "gray"}
>
Show
</Button>
</ButtonGroup>
</div>
</div>
}
</div>
<div className="mt-8 pt-4 flex gap-4">
<h2 className="">Drugs</h2>
<div>
{
!drugsList ? <LoadingSpinner /> :
<ul>
{drugsList.map((drug) => (
<li key={drug.id}>
{drug.name}:
</li>
))}
</ul>
}
</div>
</div>
<div className="mt-8 pt-4 flex gap-4">
<h2>Weight</h2>
{
weightData.length === 0 ? <LoadingSpinner /> :
<div>
<TimeseriesChart data={weightData} />
</div>
}
</div>
</div >
);
}