Implement pie chart on analytics page!
This commit is contained in:
parent
1ed249eec9
commit
aa4a843349
@ -3,6 +3,7 @@ import AnalyticsView from "@/components/dashboard/analytics/AnalyticsView";
|
|||||||
|
|
||||||
export default async function AnalyticsPage() {
|
export default async function AnalyticsPage() {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnalyticsView />
|
<AnalyticsView />
|
||||||
);
|
);
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export default async function DayPage({ params }: { params: { dateQuery: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div class="container">
|
||||||
{
|
{
|
||||||
day == undefined ?
|
day == undefined ?
|
||||||
<LoadingSpinner /> :
|
<LoadingSpinner /> :
|
||||||
@ -39,6 +39,6 @@ export default async function DayPage({ params }: { params: { dateQuery: string
|
|||||||
day={day}
|
day={day}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,7 +29,7 @@ export default async function Dashboard({
|
|||||||
<Separator />
|
<Separator />
|
||||||
</div>
|
</div>
|
||||||
{modal}
|
{modal}
|
||||||
<div className={cn("min-h-30 p-4 container")}>{children}</div>
|
<div className={cn("min-h-30 p-4")}>{children}</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
|
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Inter } from "next/font/google";
|
import { Inter } from "next/font/google";
|
||||||
|
|
||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import "@mantine/dates/styles.css";
|
||||||
import "@lifetracker/tailwind-config/globals.css";
|
import "@lifetracker/tailwind-config/globals.css";
|
||||||
|
|
||||||
import type { Viewport } from "next";
|
import type { Viewport } from "next";
|
||||||
|
|||||||
@ -1,32 +1,179 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import LoadingSpinner from "@/components/ui/spinner";
|
import LoadingSpinner from "@/components/ui/spinner";
|
||||||
import { api } from "@/lib/trpc";
|
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, Menu } from "@mantine/core";
|
||||||
|
import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
|
||||||
|
import PieChart from "./PieChart";
|
||||||
|
|
||||||
|
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() {
|
export default function AnalyticsView() {
|
||||||
|
const datePickerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const { data: metrics } = api.metrics.list.useQuery();
|
const { data: metrics } = api.metrics.list.useQuery();
|
||||||
|
|
||||||
const drugsList = metrics?.filter((metric) => metric.type === "drug");
|
const drugsList = metrics?.filter((metric) => metric.type === "drug");
|
||||||
const timeSinceDrug = drugsList?.map((drug) => {
|
|
||||||
console.log(api.measurements.timeSinceLastMeasurement.useQuery({ metricId: drug.id! }));
|
const initialDateRange: [Date, Date] = (() => {
|
||||||
return drug.name;
|
const range = parseDateRangeFromQuery();
|
||||||
|
if (range && range.length === 2) {
|
||||||
|
return range as [Date, Date];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
// spacetime.now().subtract(1, "week").toNativeDate(),
|
||||||
|
spacetime.now().toNativeDate(),
|
||||||
|
spacetime.now().toNativeDate()
|
||||||
|
];
|
||||||
|
})();
|
||||||
|
|
||||||
|
const [dateRange, setDateRange] = useState(initialDateRange);
|
||||||
|
const [datePickerRange, setDatePickerRange] = useState(initialDateRange);
|
||||||
|
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
console.log(timeSinceDrug);
|
|
||||||
|
const categoryFrequencies = api.hours.categoryFrequencies.useQuery({
|
||||||
|
dateRange,
|
||||||
|
}).data ?? [];
|
||||||
|
|
||||||
|
console.log(categoryFrequencies);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4">
|
<div className="px-4 flex flex-col gap-y-4">
|
||||||
<h1 className="font-bold text-xl">Drugs</h1>
|
<div className="flex justify-between">
|
||||||
<div>
|
<h1>Analytics</h1>
|
||||||
{
|
{/* <DateRangePicker
|
||||||
!drugsList ? <LoadingSpinner /> :
|
showOneCalendar
|
||||||
<ul>
|
appearance="subtle"
|
||||||
{drugsList.map((drug) => (
|
size="lg"
|
||||||
<li key={drug.id}>
|
ranges={predefinedRanges}
|
||||||
{drug.name}:
|
character=" – "
|
||||||
</li>
|
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>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Menu.Dropdown>
|
||||||
}
|
</Menu></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex ">
|
||||||
|
<PieChart width={500} height={500}
|
||||||
|
data={categoryFrequencies}
|
||||||
|
/>
|
||||||
|
<div className="grow">
|
||||||
|
{
|
||||||
|
categoryFrequencies
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.map((category, i) => (
|
||||||
|
|
||||||
|
<div key={i} className="grid"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: "1fr 1fr"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="whitespace-nowrap">
|
||||||
|
{category.categoryName}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{category.count} hours
|
||||||
|
</div></div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
207
apps/web/components/dashboard/analytics/BarStackChart.tsx
Normal file
207
apps/web/components/dashboard/analytics/BarStackChart.tsx
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BarStack } from '@visx/shape';
|
||||||
|
import { SeriesPoint } from '@visx/shape/lib/types';
|
||||||
|
import { Group } from '@visx/group';
|
||||||
|
import { Grid } from '@visx/grid';
|
||||||
|
import { AxisBottom } from '@visx/axis';
|
||||||
|
import cityTemperature, { CityTemperature } from '@visx/mock-data/lib/mocks/cityTemperature';
|
||||||
|
import { scaleBand, scaleLinear, scaleOrdinal } from '@visx/scale';
|
||||||
|
import { timeParse, timeFormat } from '@visx/vendor/d3-time-format';
|
||||||
|
import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
|
||||||
|
import { LegendOrdinal } from '@visx/legend';
|
||||||
|
import { localPoint } from '@visx/event';
|
||||||
|
|
||||||
|
type CityName = 'New York' | 'San Francisco' | 'Austin';
|
||||||
|
|
||||||
|
type TooltipData = {
|
||||||
|
bar: SeriesPoint<CityTemperature>;
|
||||||
|
key: CityName;
|
||||||
|
index: number;
|
||||||
|
height: number;
|
||||||
|
width: number;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BarStackProps = {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
margin?: { top: number; right: number; bottom: number; left: number };
|
||||||
|
events?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const purple1 = '#6c5efb';
|
||||||
|
const purple2 = '#c998ff';
|
||||||
|
export const purple3 = '#a44afe';
|
||||||
|
export const background = 'transparent';
|
||||||
|
const defaultMargin = { top: 40, right: 0, bottom: 0, left: 0 };
|
||||||
|
const tooltipStyles = {
|
||||||
|
...defaultStyles,
|
||||||
|
minWidth: 60,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.9)',
|
||||||
|
color: 'white',
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = cityTemperature.slice(0, 12);
|
||||||
|
const keys = Object.keys(data[0]).filter((d) => d !== 'date') as CityName[];
|
||||||
|
|
||||||
|
const temperatureTotals = data.reduce((allTotals, currentDate) => {
|
||||||
|
const totalTemperature = keys.reduce((dailyTotal, k) => {
|
||||||
|
dailyTotal += Number(currentDate[k]);
|
||||||
|
return dailyTotal;
|
||||||
|
}, 0);
|
||||||
|
allTotals.push(totalTemperature);
|
||||||
|
return allTotals;
|
||||||
|
}, [] as number[]);
|
||||||
|
|
||||||
|
const parseDate = timeParse('%Y-%m-%d');
|
||||||
|
const format = timeFormat('%b %d');
|
||||||
|
const formatDate = (date: string) => format(parseDate(date) as Date);
|
||||||
|
|
||||||
|
// accessors
|
||||||
|
const getDate = (d: CityTemperature) => d.date;
|
||||||
|
|
||||||
|
// scales
|
||||||
|
const dateScale = scaleBand<string>({
|
||||||
|
domain: data.map(getDate),
|
||||||
|
padding: 0.2,
|
||||||
|
});
|
||||||
|
const temperatureScale = scaleLinear<number>({
|
||||||
|
domain: [0, Math.max(...temperatureTotals)],
|
||||||
|
nice: true,
|
||||||
|
});
|
||||||
|
const colorScale = scaleOrdinal<CityName, string>({
|
||||||
|
domain: keys,
|
||||||
|
range: [purple1, purple2, purple3],
|
||||||
|
});
|
||||||
|
|
||||||
|
let tooltipTimeout: number;
|
||||||
|
|
||||||
|
console.log(cityTemperature);
|
||||||
|
|
||||||
|
export default function BarStackChart({
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
events = false,
|
||||||
|
margin = defaultMargin,
|
||||||
|
}: BarStackProps) {
|
||||||
|
const { tooltipOpen, tooltipLeft, tooltipTop, tooltipData, hideTooltip, showTooltip } =
|
||||||
|
useTooltip<TooltipData>();
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (width < 10) return null;
|
||||||
|
// bounds
|
||||||
|
const xMax = width;
|
||||||
|
const yMax = height - margin.top - 100;
|
||||||
|
|
||||||
|
dateScale.rangeRound([0, xMax]);
|
||||||
|
temperatureScale.range([yMax, 0]);
|
||||||
|
|
||||||
|
return width < 10 ? null : (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<svg ref={containerRef} width={width} height={height}>
|
||||||
|
<rect x={0} y={0} width={width} height={height} fill={background} rx={14} />
|
||||||
|
<Grid
|
||||||
|
top={margin.top}
|
||||||
|
left={margin.left}
|
||||||
|
xScale={dateScale}
|
||||||
|
yScale={temperatureScale}
|
||||||
|
width={xMax}
|
||||||
|
height={yMax}
|
||||||
|
stroke="black"
|
||||||
|
strokeOpacity={0.1}
|
||||||
|
xOffset={dateScale.bandwidth() / 2}
|
||||||
|
/>
|
||||||
|
<Group top={margin.top}>
|
||||||
|
<BarStack<CityTemperature, CityName>
|
||||||
|
data={data}
|
||||||
|
keys={keys}
|
||||||
|
x={getDate}
|
||||||
|
xScale={dateScale}
|
||||||
|
yScale={temperatureScale}
|
||||||
|
color={colorScale}
|
||||||
|
>
|
||||||
|
{(barStacks) =>
|
||||||
|
barStacks.map((barStack) =>
|
||||||
|
barStack.bars.map((bar) => (
|
||||||
|
<rect
|
||||||
|
key={`bar-stack-${barStack.index}-${bar.index}`}
|
||||||
|
x={bar.x}
|
||||||
|
y={bar.y}
|
||||||
|
height={bar.height}
|
||||||
|
width={bar.width}
|
||||||
|
fill={bar.color}
|
||||||
|
onClick={() => {
|
||||||
|
if (events) alert(`clicked: ${JSON.stringify(bar)}`);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
tooltipTimeout = window.setTimeout(() => {
|
||||||
|
hideTooltip();
|
||||||
|
}, 300);
|
||||||
|
}}
|
||||||
|
onMouseMove={(event) => {
|
||||||
|
if (tooltipTimeout) clearTimeout(tooltipTimeout);
|
||||||
|
// TooltipInPortal expects coordinates to be relative to containerRef
|
||||||
|
// localPoint returns coordinates relative to the nearest SVG, which
|
||||||
|
// is what containerRef is set to in this example.
|
||||||
|
const eventSvgCoords = localPoint(event);
|
||||||
|
const left = bar.x + bar.width / 2;
|
||||||
|
showTooltip({
|
||||||
|
tooltipData: bar,
|
||||||
|
tooltipTop: eventSvgCoords?.y,
|
||||||
|
tooltipLeft: left,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</BarStack>
|
||||||
|
</Group>
|
||||||
|
<AxisBottom
|
||||||
|
top={yMax + margin.top}
|
||||||
|
scale={dateScale}
|
||||||
|
tickFormat={formatDate}
|
||||||
|
stroke={purple3}
|
||||||
|
tickStroke={purple3}
|
||||||
|
tickLabelProps={{
|
||||||
|
fill: purple3,
|
||||||
|
fontSize: 11,
|
||||||
|
textAnchor: 'middle',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: margin.top / 2 - 10,
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LegendOrdinal scale={colorScale} direction="row" labelMargin="0 15px 0 0" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tooltipOpen && tooltipData && (
|
||||||
|
<TooltipInPortal top={tooltipTop} left={tooltipLeft} style={tooltipStyles}>
|
||||||
|
<div style={{ color: colorScale(tooltipData.key) }}>
|
||||||
|
<strong>{tooltipData.key}</strong>
|
||||||
|
</div>
|
||||||
|
<div>{tooltipData.bar.data[tooltipData.key]}℉</div>
|
||||||
|
<div>
|
||||||
|
<small>{formatDate(getDate(tooltipData.bar.data))}</small>
|
||||||
|
</div>
|
||||||
|
</TooltipInPortal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
apps/web/components/dashboard/analytics/PieChart.tsx
Normal file
148
apps/web/components/dashboard/analytics/PieChart.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import Pie, { ProvidedProps, PieArcDatum } from '@visx/shape/lib/shapes/Pie';
|
||||||
|
import { scaleOrdinal } from '@visx/scale';
|
||||||
|
import { Group } from '@visx/group';
|
||||||
|
import { animated, useTransition, interpolate } from '@react-spring/web';
|
||||||
|
import LoadingSpinner from '@/components/ui/spinner';
|
||||||
|
|
||||||
|
// accessor functions
|
||||||
|
const frequency = (c) => c.percentage;
|
||||||
|
|
||||||
|
const defaultMargin = { top: 20, right: 20, bottom: 20, left: 20 };
|
||||||
|
|
||||||
|
export type PieProps = {
|
||||||
|
data: any
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
margin?: typeof defaultMargin;
|
||||||
|
animate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PieChart({
|
||||||
|
data,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
margin = defaultMargin,
|
||||||
|
animate = true,
|
||||||
|
}: PieProps) {
|
||||||
|
if (width < 10) return null;
|
||||||
|
|
||||||
|
const innerWidth = width - margin.left - margin.right;
|
||||||
|
const innerHeight = height - margin.top - margin.bottom;
|
||||||
|
const radius = Math.min(innerWidth, innerHeight) / 2;
|
||||||
|
const centerY = innerHeight / 2;
|
||||||
|
const centerX = innerWidth / 2;
|
||||||
|
|
||||||
|
// console.log(data);
|
||||||
|
|
||||||
|
return data.length == 0 ? <LoadingSpinner /> : (
|
||||||
|
<svg width={width} height={height}>
|
||||||
|
<rect rx={14} width={width} height={height} fill="transparent" />
|
||||||
|
<Group top={centerY + margin.top} left={centerX + margin.left}>
|
||||||
|
<Pie
|
||||||
|
data={data}
|
||||||
|
pieValue={frequency}
|
||||||
|
pieSort={(a, b) => b.categoryCode - a.categoryCode}
|
||||||
|
outerRadius={radius}
|
||||||
|
cornerRadius={0}
|
||||||
|
>
|
||||||
|
{(pie) => (
|
||||||
|
<AnimatedPie
|
||||||
|
{...pie}
|
||||||
|
animate={false}
|
||||||
|
getKey={(c) => c.data.categoryName}
|
||||||
|
onClickDatum={({ data: category }) =>
|
||||||
|
console.log('clicked: ', category.categoryName)
|
||||||
|
}
|
||||||
|
getColor={(arc) => {
|
||||||
|
return arc.data.categoryColor;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Pie>
|
||||||
|
</Group>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// react-spring transition definitions
|
||||||
|
type AnimatedStyles = { startAngle: number; endAngle: number; opacity: number };
|
||||||
|
|
||||||
|
const fromLeaveTransition = ({ endAngle }: PieArcDatum<any>) => ({
|
||||||
|
// enter from 360° if end angle is > 180°
|
||||||
|
startAngle: endAngle > Math.PI ? 2 * Math.PI : 0,
|
||||||
|
endAngle: endAngle > Math.PI ? 2 * Math.PI : 0,
|
||||||
|
opacity: 0,
|
||||||
|
});
|
||||||
|
const enterUpdateTransition = ({ startAngle, endAngle }: PieArcDatum<any>) => ({
|
||||||
|
startAngle,
|
||||||
|
endAngle,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
type AnimatedPieProps<Datum> = ProvidedProps<Datum> & {
|
||||||
|
animate?: boolean;
|
||||||
|
getKey: (d: PieArcDatum<Datum>) => string;
|
||||||
|
getColor: (d: PieArcDatum<Datum>) => string;
|
||||||
|
onClickDatum: (d: PieArcDatum<Datum>) => void;
|
||||||
|
delay?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AnimatedPie<Datum>({
|
||||||
|
animate,
|
||||||
|
arcs,
|
||||||
|
path,
|
||||||
|
getKey,
|
||||||
|
getColor,
|
||||||
|
onClickDatum,
|
||||||
|
}: AnimatedPieProps<Datum>) {
|
||||||
|
const transitions = useTransition<PieArcDatum<Datum>, AnimatedStyles>(arcs, {
|
||||||
|
from: animate ? fromLeaveTransition : enterUpdateTransition,
|
||||||
|
enter: enterUpdateTransition,
|
||||||
|
update: enterUpdateTransition,
|
||||||
|
leave: animate ? fromLeaveTransition : enterUpdateTransition,
|
||||||
|
keys: getKey,
|
||||||
|
});
|
||||||
|
return transitions((props, arc, { key }) => {
|
||||||
|
const [centroidX, centroidY] = path.centroid(arc);
|
||||||
|
const hasSpaceForLabel = arc.endAngle - arc.startAngle >= 0.1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g key={key}>
|
||||||
|
<animated.path
|
||||||
|
// compute interpolated path d attribute from intermediate angle values
|
||||||
|
d={interpolate([props.startAngle, props.endAngle], (startAngle, endAngle) =>
|
||||||
|
path({
|
||||||
|
...arc,
|
||||||
|
startAngle,
|
||||||
|
endAngle,
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
fill={getColor(arc)}
|
||||||
|
onClick={() => onClickDatum(arc)}
|
||||||
|
onTouchStart={() => onClickDatum(arc)}
|
||||||
|
/>
|
||||||
|
{hasSpaceForLabel && false && (
|
||||||
|
<animated.g style={{ opacity: props.opacity }}>
|
||||||
|
<text
|
||||||
|
fill={getForeground(arc)}
|
||||||
|
x={centroidX}
|
||||||
|
y={centroidY}
|
||||||
|
dy=".33em"
|
||||||
|
fontSize={9}
|
||||||
|
textAnchor="middle"
|
||||||
|
pointerEvents="none"
|
||||||
|
>
|
||||||
|
{getKey(arc)}
|
||||||
|
</text>
|
||||||
|
</animated.g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function getForeground(arc: PieArcDatum<Datum>) {
|
||||||
|
return arc.data.categoryForeground;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
29
apps/web/lib/dates.ts
Normal file
29
apps/web/lib/dates.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import addDays from "date-fns/addDays";
|
||||||
|
|
||||||
|
export const predefinedRanges = [
|
||||||
|
{
|
||||||
|
label: 'Today',
|
||||||
|
value: [new Date(), new Date()],
|
||||||
|
placement: 'left'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Yesterday',
|
||||||
|
value: [addDays(new Date(), -1), addDays(new Date(), -1)],
|
||||||
|
placement: 'left'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last 7 Days',
|
||||||
|
value: [addDays(new Date(), -7), new Date()],
|
||||||
|
placement: 'left'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Last 30 Days',
|
||||||
|
value: [addDays(new Date(), -30), new Date()],
|
||||||
|
placement: 'left'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Year to Date',
|
||||||
|
value: [new Date(new Date().getFullYear(), 0, 1), new Date()],
|
||||||
|
placement: 'left'
|
||||||
|
}
|
||||||
|
];
|
||||||
@ -5,6 +5,9 @@ import type { Session } from "next-auth";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
import { DatesProvider } from "@mantine/dates";
|
||||||
|
|
||||||
import { UserLocalSettingsCtx } from "@/lib/userLocalSettings/bookmarksLayout";
|
import { UserLocalSettingsCtx } from "@/lib/userLocalSettings/bookmarksLayout";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { httpBatchLink, loggerLink } from "@trpc/client";
|
import { httpBatchLink, loggerLink } from "@trpc/client";
|
||||||
@ -15,6 +18,7 @@ import type { ClientConfig } from "@lifetracker/shared/config";
|
|||||||
|
|
||||||
import { ClientConfigCtx } from "./clientConfig";
|
import { ClientConfigCtx } from "./clientConfig";
|
||||||
import { api } from "./trpc";
|
import { api } from "./trpc";
|
||||||
|
import { MantineProvider } from "@mantine/core";
|
||||||
|
|
||||||
function makeQueryClient() {
|
function makeQueryClient() {
|
||||||
return new QueryClient({
|
return new QueryClient({
|
||||||
@ -87,9 +91,11 @@ export default function Providers({
|
|||||||
enableSystem
|
enableSystem
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<TooltipProvider delayDuration={0}>
|
<MantineProvider>
|
||||||
{children}
|
<TooltipProvider delayDuration={0}>
|
||||||
</TooltipProvider>
|
{children}
|
||||||
|
</TooltipProvider>
|
||||||
|
</MantineProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</api.Provider>
|
</api.Provider>
|
||||||
|
|||||||
@ -25,6 +25,9 @@
|
|||||||
"@lifetracker/shared": "workspace:^0.1.0",
|
"@lifetracker/shared": "workspace:^0.1.0",
|
||||||
"@lifetracker/shared-react": "workspace:^0.1.0",
|
"@lifetracker/shared-react": "workspace:^0.1.0",
|
||||||
"@lifetracker/trpc": "workspace:^",
|
"@lifetracker/trpc": "workspace:^",
|
||||||
|
"@mantine/core": "^7.16.0",
|
||||||
|
"@mantine/dates": "^7.16.0",
|
||||||
|
"@mantine/hooks": "^7.16.0",
|
||||||
"@radix-ui/react-collapsible": "^1.0.3",
|
"@radix-ui/react-collapsible": "^1.0.3",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
@ -39,6 +42,7 @@
|
|||||||
"@radix-ui/react-toast": "^1.1.5",
|
"@radix-ui/react-toast": "^1.1.5",
|
||||||
"@radix-ui/react-toggle": "^1.0.3",
|
"@radix-ui/react-toggle": "^1.0.3",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
|
"@react-spring/web": "^9.7.5",
|
||||||
"@svgr/webpack": "^8.1.0",
|
"@svgr/webpack": "^8.1.0",
|
||||||
"@tanstack/react-query": "^5.24.8",
|
"@tanstack/react-query": "^5.24.8",
|
||||||
"@tanstack/react-query-devtools": "^5.21.0",
|
"@tanstack/react-query-devtools": "^5.21.0",
|
||||||
@ -46,6 +50,18 @@
|
|||||||
"@trpc/react-query": "11.0.0-next-beta.308",
|
"@trpc/react-query": "11.0.0-next-beta.308",
|
||||||
"@trpc/server": "11.0.0-next-beta.308",
|
"@trpc/server": "11.0.0-next-beta.308",
|
||||||
"@types/formidable": "^3.4.5",
|
"@types/formidable": "^3.4.5",
|
||||||
|
"@visx/axis": "^3.12.0",
|
||||||
|
"@visx/event": "^3.12.0",
|
||||||
|
"@visx/gradient": "^3.12.0",
|
||||||
|
"@visx/grid": "^3.12.0",
|
||||||
|
"@visx/group": "^3.12.0",
|
||||||
|
"@visx/legend": "^3.12.0",
|
||||||
|
"@visx/mock-data": "^3.12.0",
|
||||||
|
"@visx/responsive": "^3.12.0",
|
||||||
|
"@visx/scale": "^3.12.0",
|
||||||
|
"@visx/shape": "^3.12.0",
|
||||||
|
"@visx/tooltip": "^3.12.0",
|
||||||
|
"@visx/vendor": "^3.12.0",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^11.3.0",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
@ -53,7 +69,7 @@
|
|||||||
"color-2-name": "^1.4.4",
|
"color-2-name": "^1.4.4",
|
||||||
"csv-parse": "^5.5.6",
|
"csv-parse": "^5.5.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.13",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
"fastest-levenshtein": "^1.0.16",
|
"fastest-levenshtein": "^1.0.16",
|
||||||
"formidable": "^3.5.2",
|
"formidable": "^3.5.2",
|
||||||
@ -78,6 +94,7 @@
|
|||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
"remark-gfm": "^4.0.0",
|
"remark-gfm": "^4.0.0",
|
||||||
"request-ip": "^3.3.0",
|
"request-ip": "^3.3.0",
|
||||||
|
"rsuite": "^5.76.3",
|
||||||
"sharp": "^0.33.3",
|
"sharp": "^0.33.3",
|
||||||
"spacetime": "^7.6.2",
|
"spacetime": "^7.6.2",
|
||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
|
|||||||
@ -1,50 +1,10 @@
|
|||||||
|
import { ZCategories } from "../../shared/types/categories";
|
||||||
import { api } from "../trpc";
|
import { api } from "../trpc";
|
||||||
|
|
||||||
export function useUpdateLabel(
|
export function getCategoryFrequencies(
|
||||||
...opts: Parameters<typeof api.labels.update.useMutation>
|
dateRange: [Date, Date]
|
||||||
) {
|
) {
|
||||||
const apiUtils = api.useUtils();
|
return api.hours.categoryFrequencies.useQuery({
|
||||||
|
dateRange,
|
||||||
return api.tags.update.useMutation({
|
}).data ?? [];
|
||||||
...opts[0],
|
|
||||||
onSuccess: (res, req, meta) => {
|
|
||||||
apiUtils.labels.list.invalidate();
|
|
||||||
apiUtils.labels.get.invalidate({ labelId: res.id });
|
|
||||||
// apiUtils.bookmarks.getBookmarks.invalidate({
|
|
||||||
// labelId: res.id;
|
|
||||||
|
|
||||||
// TODO: Maybe we can only look at the cache and invalidate only affected bookmarks
|
|
||||||
// apiUtils.bookmarks.getBookmark.invalidate();
|
|
||||||
return opts[0]?.onSuccess?.(res, req, meta);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteLabel(
|
|
||||||
...opts: Parameters<typeof api.labels.delete.useMutation>
|
|
||||||
) {
|
|
||||||
const apiUtils = api.useUtils();
|
|
||||||
|
|
||||||
return api.labels.delete.useMutation({
|
|
||||||
...opts[0],
|
|
||||||
onSuccess: (res, req, meta) => {
|
|
||||||
apiUtils.labels.list.invalidate();
|
|
||||||
// apiUtils.bookmarks.getBookmark.invalidate();
|
|
||||||
return opts[0]?.onSuccess?.(res, req, meta);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteUnusedTags(
|
|
||||||
...opts: Parameters<typeof api.tags.deleteUnused.useMutation>
|
|
||||||
) {
|
|
||||||
const apiUtils = api.useUtils();
|
|
||||||
|
|
||||||
return api.tags.deleteUnused.useMutation({
|
|
||||||
...opts[0],
|
|
||||||
onSuccess: (res, req, meta) => {
|
|
||||||
apiUtils.tags.list.invalidate();
|
|
||||||
return opts[0]?.onSuccess?.(res, req, meta);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
@ -84,4 +84,17 @@
|
|||||||
.select-wrapper {
|
.select-wrapper {
|
||||||
color: black !important;
|
color: black !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@apply text-2xl mt-2 mb-8 font-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
@apply text-xl font-bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.rs-calendar-body span {
|
||||||
|
color: gray !important;
|
||||||
}
|
}
|
||||||
@ -71,31 +71,6 @@ async function createCategory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const categoriesAppRouter = router({
|
export const categoriesAppRouter = router({
|
||||||
categoryStats: authedProcedure
|
|
||||||
.output(
|
|
||||||
z.record(
|
|
||||||
z.string(),
|
|
||||||
z.object({
|
|
||||||
numEntries: z.number(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.query(async ({ ctx }) => {
|
|
||||||
const [categoryIds] = await Promise.all([
|
|
||||||
ctx.db.select({ id: categories.id }).from(categories)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const results: Record<
|
|
||||||
string,
|
|
||||||
{ numEntries: number }
|
|
||||||
> = {};
|
|
||||||
for (const category of categoryIds) {
|
|
||||||
results[category.id] = {
|
|
||||||
numEntries: 3330,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}),
|
|
||||||
list: authedProcedure
|
list: authedProcedure
|
||||||
.output(
|
.output(
|
||||||
z.object({
|
z.object({
|
||||||
|
|||||||
@ -9,10 +9,12 @@ import {
|
|||||||
} from "@lifetracker/shared/types/days";
|
} from "@lifetracker/shared/types/days";
|
||||||
import type { Context } from "../index";
|
import type { Context } from "../index";
|
||||||
import { authedProcedure, router } from "../index";
|
import { authedProcedure, router } from "../index";
|
||||||
import { format } from "date-fns";
|
import { addDays, format, parseISO } from "date-fns";
|
||||||
import { TZDate } from "@date-fns/tz";
|
import { TZDate } from "@date-fns/tz";
|
||||||
import { dateFromInput } from "@lifetracker/shared/utils/days";
|
import { dateFromInput } from "@lifetracker/shared/utils/days";
|
||||||
import { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
import { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
||||||
|
import { zCategorySchema } from "@lifetracker/shared/types/categories";
|
||||||
|
import spacetime from "spacetime";
|
||||||
|
|
||||||
export async function hourColors(hour: ZHour, ctx: Context) {
|
export async function hourColors(hour: ZHour, ctx: Context) {
|
||||||
const categoryColor = await ctx.db.select()
|
const categoryColor = await ctx.db.select()
|
||||||
@ -83,6 +85,20 @@ export async function hourJoinsQuery(
|
|||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function listOfDates(dateRange: [Date, Date]) {
|
||||||
|
const [start, end] = dateRange.map((date) => dateFromInput({
|
||||||
|
dateQuery: spacetime(date, "UTC").goto("UTC").format("iso-short"),
|
||||||
|
timezone: "Etc/UTC"
|
||||||
|
}));
|
||||||
|
const dates = [];
|
||||||
|
let currentDate = parseISO(start);
|
||||||
|
while (currentDate <= parseISO(end)) {
|
||||||
|
dates.push(format(currentDate, "yyyy-MM-dd"));
|
||||||
|
currentDate = addDays(currentDate, 1);
|
||||||
|
}
|
||||||
|
return dates.length === 0 ? parseISO(start) : dates;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export const hoursAppRouter = router({
|
export const hoursAppRouter = router({
|
||||||
get: authedProcedure
|
get: authedProcedure
|
||||||
@ -172,4 +188,69 @@ export const hoursAppRouter = router({
|
|||||||
return hourJoinsQuery(ctx, input.dayId, input.hourTime);
|
return hourJoinsQuery(ctx, input.dayId, input.hourTime);
|
||||||
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
categoryFrequencies: authedProcedure
|
||||||
|
.input(z.object({
|
||||||
|
dateRange: z.tuple([z.date(), z.date()])
|
||||||
|
}))
|
||||||
|
.output(z.array(z.object(
|
||||||
|
{
|
||||||
|
count: z.number(),
|
||||||
|
date: z.string(),
|
||||||
|
time: z.number(),
|
||||||
|
categoryName: z.string(),
|
||||||
|
categoryCode: z.number(),
|
||||||
|
categoryDescription: z.string(),
|
||||||
|
categoryColor: z.string(),
|
||||||
|
categoryForeground: z.string(),
|
||||||
|
percentage: z.number()
|
||||||
|
}
|
||||||
|
)))
|
||||||
|
.query(async ({ input, ctx }) => {
|
||||||
|
const hoursList = (await ctx.db.select({
|
||||||
|
date: days.date,
|
||||||
|
time: hours.time,
|
||||||
|
categoryName: categories.name,
|
||||||
|
categoryCode: categories.code,
|
||||||
|
categoryDescription: categories.description,
|
||||||
|
categoryColor: colors.hexcode,
|
||||||
|
categoryForeground: colors.inverse,
|
||||||
|
})
|
||||||
|
.from(hours)
|
||||||
|
.leftJoin(days, eq(days.id, hours.dayId))
|
||||||
|
.leftJoin(categories, eq(categories.id, hours.categoryId))
|
||||||
|
.leftJoin(colors, eq(colors.id, categories.colorId))
|
||||||
|
.where(and(
|
||||||
|
eq(hours.userId, ctx.user!.id),
|
||||||
|
inArray(days.date, listOfDates(input.dateRange))
|
||||||
|
))).filter(h => h.categoryCode != null);
|
||||||
|
|
||||||
|
// Count total hours in the filtered range
|
||||||
|
const totalHours = hoursList.length;
|
||||||
|
|
||||||
|
console.log(hoursList);
|
||||||
|
|
||||||
|
// Group hours by category and count occurrences
|
||||||
|
const categoriesList = {};
|
||||||
|
hoursList.forEach(h => {
|
||||||
|
if (!categoriesList[h.categoryCode]) {
|
||||||
|
categoriesList[h.categoryCode] = {
|
||||||
|
count: 0,
|
||||||
|
...h
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const old = (categoriesList[h.categoryCode].count);
|
||||||
|
categoriesList[h.categoryCode].count = old + 1;
|
||||||
|
});
|
||||||
|
// Calculate percentages
|
||||||
|
const categoryPercentages: any = Object.keys(categoriesList).map(categoryCode => {
|
||||||
|
const count = categoriesList[categoryCode].count;
|
||||||
|
const percentage = (count / totalHours);
|
||||||
|
return {
|
||||||
|
...categoriesList[categoryCode],
|
||||||
|
percentage: percentage
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return categoryPercentages;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -172,7 +172,7 @@ export const measurementsAppRouter = router({
|
|||||||
const lastMeasurement = await ctx.db.select().from(measurements).where(and(
|
const lastMeasurement = await ctx.db.select().from(measurements).where(and(
|
||||||
eq(measurements.metricId, input.metricId),
|
eq(measurements.metricId, input.metricId),
|
||||||
eq(measurements.userId, ctx.user.id),
|
eq(measurements.userId, ctx.user.id),
|
||||||
)).orderBy(desc(measurements.createdAt)).limit(1);
|
)).orderBy(desc(measurements)).limit(1);
|
||||||
if (lastMeasurement[0]) {
|
if (lastMeasurement[0]) {
|
||||||
const lastMeasurementTime = new Date(lastMeasurement[0].createdAt).getTime();
|
const lastMeasurementTime = new Date(lastMeasurement[0].createdAt).getTime();
|
||||||
const currentTime = new Date().getTime();
|
const currentTime = new Date().getTime();
|
||||||
|
|||||||
1017
pnpm-lock.yaml
generated
1017
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user