276 lines
13 KiB
TypeScript
276 lines
13 KiB
TypeScript
"use client";
|
|
import { useEffect, useState, useRef } from "react";
|
|
import { toast } from "@/components/ui/use-toast";
|
|
import { api } from "@/lib/trpc";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import { TRPCClientError } from "@trpc/client";
|
|
import { useForm } from "react-hook-form";
|
|
import { z } from "zod";
|
|
import { zMeasurementSchema, ZMetric } from "@lifetracker/shared/types/metrics";
|
|
import { ZHour } from "@lifetracker/shared/types/days";
|
|
|
|
import { Icon } from "@/components/ui/icon";
|
|
import { titleCase } from "title-case";
|
|
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
|
import { Trash, X } from "lucide-react";
|
|
import { useDecrementCount, useIncrementCount, useSetValue, } from "@lifetracker/shared-react/hooks/measurements";
|
|
import { Separator } from "@radix-ui/react-dropdown-menu";
|
|
import { EditableText } from "../EditableText";
|
|
|
|
interface CreateMeasurementSchema {
|
|
type: string;
|
|
dayId: string;
|
|
hourId: string;
|
|
metricId: string;
|
|
}
|
|
|
|
export default function HourMeasurementsDialog({
|
|
hour: initialHour,
|
|
metrics,
|
|
reload,
|
|
children,
|
|
}: {
|
|
hour: ZHour,
|
|
metrics: ZMetric[],
|
|
reload: (newHour: ZHour) => void,
|
|
children?: React.ReactNode;
|
|
}) {
|
|
const [hour, setHour] = useState(initialHour);
|
|
const [isOpen, onOpenChange] = useState(false);
|
|
const [pendingMeasurement, setPendingMeasurement] = useState(false);
|
|
const pendingRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
pendingRef.current?.focus();
|
|
}, [pendingMeasurement]);
|
|
|
|
const addMeasurement = (measurement: CreateMeasurementSchema) => {
|
|
const { type, ...rest } = measurement;
|
|
// Use a different function based on type of measurement.
|
|
// If it's a timeseries, open a sub dialog to get the value
|
|
// Use a switch statement to determine which function to call
|
|
|
|
// Javascript case/switch:
|
|
switch (type) {
|
|
case "timeseries":
|
|
setPendingMeasurement(rest);
|
|
pendingRef.current?.focus();
|
|
break;
|
|
default:
|
|
increment(rest);
|
|
}
|
|
}
|
|
|
|
|
|
const { mutate: setValue } = useSetValue({
|
|
onSuccess: (res) => {
|
|
const oldMeasurement = hour.measurements.find(m => m.metricId === res.metricId);
|
|
const newHour = {
|
|
...hour,
|
|
measurements: oldMeasurement ? hour.measurements.map(m => m.metricId === res.metricId ? res : m) : [...hour.measurements, res],
|
|
};
|
|
setHour(newHour);
|
|
reload(newHour);
|
|
toast({
|
|
description: "Measurement added!",
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
toast({
|
|
description: error.message,
|
|
|
|
});
|
|
},
|
|
});
|
|
|
|
const { mutate: increment } = useIncrementCount({
|
|
onSuccess: (res) => {
|
|
const oldMeasurement = hour.measurements.find(m => m.metricId === res.metricId);
|
|
const newHour = {
|
|
...hour,
|
|
measurements: oldMeasurement ? hour.measurements.map(m => m.metricId === res.metricId ? res : m) : [...hour.measurements, res],
|
|
};
|
|
setHour(newHour);
|
|
reload(newHour);
|
|
toast({
|
|
description: "Measurement added!",
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
toast({
|
|
description: error.message,
|
|
|
|
});
|
|
},
|
|
});
|
|
|
|
const { mutate: decrement } = useDecrementCount({
|
|
onSuccess: (res, req) => {
|
|
const oldMeasurementIndex = hour.measurements.findIndex(m => m.metricId === req.metricId);
|
|
let newMeasurements;
|
|
|
|
if (oldMeasurementIndex !== -1) {
|
|
if (res === undefined) {
|
|
// Remove the measurement if res is undefined
|
|
newMeasurements = [
|
|
...hour.measurements.slice(0, oldMeasurementIndex),
|
|
...hour.measurements.slice(oldMeasurementIndex + 1)
|
|
];
|
|
} else {
|
|
// Update the measurement
|
|
newMeasurements = [
|
|
...hour.measurements.slice(0, oldMeasurementIndex),
|
|
res,
|
|
...hour.measurements.slice(oldMeasurementIndex + 1)
|
|
];
|
|
}
|
|
} else {
|
|
// Add the new measurement
|
|
newMeasurements = [...hour.measurements, res];
|
|
}
|
|
|
|
const newHour = {
|
|
...hour,
|
|
measurements: newMeasurements,
|
|
};
|
|
|
|
setHour(newHour);
|
|
reload(newHour);
|
|
toast({
|
|
description: res === undefined ? "Measurement removed!" : "Measurement updated!",
|
|
});
|
|
}
|
|
});
|
|
|
|
let currentMeasurements = hour.measurements.map(measurement => measurement.metricName);
|
|
|
|
// With useEffect, update currentMeasurements when hour changes
|
|
useEffect(() => {
|
|
currentMeasurements = hour.measurements.map(measurement => measurement.metricName);
|
|
}, [hour]);
|
|
|
|
function groupMetricsByType(list: ZMetric[]): Record<string, ZMetric[]> {
|
|
return list.reduce<Record<string, ZMetric[]>>((accumulator, item) => {
|
|
// Initialize the array for this type if it doesn't exist yet
|
|
if (!accumulator[item.type]) {
|
|
accumulator[item.type] = [];
|
|
}
|
|
accumulator[item.type].push(item);
|
|
return accumulator;
|
|
}, {});
|
|
}
|
|
|
|
// const metricsByType = groupMetricsByType(metrics);
|
|
const metricsByType = groupMetricsByType(metrics.filter(m => { return !currentMeasurements.includes(m.name) }));
|
|
|
|
return (
|
|
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Metrics for {hour.date} at {hour.datetime}</DialogTitle>
|
|
</DialogHeader>
|
|
<Separator />
|
|
{(hour.measurements && hour.measurements.length > 0) || pendingMeasurement ?
|
|
(<>
|
|
{/* <div className="font-bold">Measurements</div> */}
|
|
<div className="mx-4 mb-4">
|
|
{hour.measurements.map(measurement => {
|
|
const metric = metrics.find(m => m.id === measurement.metricId);
|
|
if (metric!.type === "timeseries") {
|
|
return (
|
|
<div key={measurement.id} className="flex items-center justify-between">
|
|
<div className="gap-4 flex"><Icon name={titleCase(metric.icon)} size={24} />
|
|
<div>{metric.name}</div></div>
|
|
<div className="flex gap-4 items-center">
|
|
<EditableText originalText={measurement.value + " " + metric?.unit} onSave={(value) => {
|
|
setValue({
|
|
metricId: metric.id,
|
|
hourId: hour.id,
|
|
dayId: hour.dayId,
|
|
value: parseFloat(value),
|
|
});
|
|
}} />
|
|
<Trash size={16} color="red" onClick={() => {
|
|
decrement({ metricId: metric.id, hourId: hour.id, dayId: hour.dayId });
|
|
}} />
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
else {
|
|
return (
|
|
<div key={measurement.id} className="flex items-center justify-between">
|
|
<div className="gap-4 flex"><Icon name={titleCase(metric.icon)} size={24} />
|
|
<div>{metric.name}</div></div>
|
|
<div className="flex gap-4 items-center">
|
|
<div className="hover:cursor-pointer" onClick={() => {
|
|
decrement({ metricId: metric.id, hourId: hour.id, dayId: hour.dayId });
|
|
}} ><Icon name={"Minus"} /></div>
|
|
<div>{measurement.value}</div>
|
|
<div className="hover:cursor-pointer" onClick={() => {
|
|
increment({ metricId: metric.id, hourId: hour.id, dayId: hour.dayId });
|
|
}}><Icon name={"Plus"} /></div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
})}
|
|
|
|
{pendingMeasurement && (
|
|
<div className="flex items-center justify-between">
|
|
<div className="gap-4 flex"><Icon name={titleCase(pendingMeasurement.icon)} size={24} />
|
|
<div>{metrics.find(m => m.id === pendingMeasurement.metricId)?.name}</div></div>
|
|
<div className="flex gap-4 items-center">
|
|
<div>
|
|
<input
|
|
ref={pendingRef}
|
|
type="text" className="border border-gray-300 rounded-md p-1 mr-1 w-16 text-right"
|
|
value={pendingMeasurement.value}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
setValue({
|
|
...pendingMeasurement,
|
|
value: parseFloat(pendingRef.current.value),
|
|
});
|
|
setPendingMeasurement(false);
|
|
}
|
|
}}
|
|
/>
|
|
|
|
{pendingMeasurement.unit}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
</div>
|
|
</>)
|
|
: <>
|
|
</>
|
|
}
|
|
|
|
{Object.keys(metricsByType).map(type => (
|
|
<div key={`metric-${type}`}>
|
|
<div className="font-bold border-b border-white">{titleCase(type)}</div>
|
|
<div className="grid" style={{ gridTemplateColumns: "repeat(4, 1fr)", rowGap: "1em" }}>
|
|
{metricsByType[type].map(metric => (
|
|
// If metric.name is in currentMeasurements, don't show it
|
|
currentMeasurements.includes(metric.name) ? null :
|
|
<button style={{}} key={metric.id} className="flex flex-col items-center justify-center hover:opacity-50" onClick={() => {
|
|
addMeasurement({ ...metric, metricId: metric.id!, hourId: hour.id!, dayId: hour.dayId });
|
|
// onOpenChange(false);
|
|
}}>
|
|
<Icon name={titleCase(metric.icon)} size={32} />
|
|
<span className="text-sm">{metric.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|