Add more metric types, update dialog, fix typo "modile" to "mobile"

This commit is contained in:
Ryan Pandya 2025-01-12 17:03:20 -08:00
parent 613d6f3f32
commit 39f250c0ec
12 changed files with 268 additions and 109 deletions

View File

@ -1,5 +1,5 @@
import Header from "@/components/dashboard/header/Header"; import Header from "@/components/dashboard/header/Header";
import MobileSidebar from "@/components/dashboard/sidebar/ModileSidebar"; import MobileSidebar from "@/components/dashboard/sidebar/MobileSidebar";
import Sidebar from "@/components/dashboard/sidebar/Sidebar"; import Sidebar from "@/components/dashboard/sidebar/Sidebar";
import DemoModeBanner from "@/components/DemoModeBanner"; import DemoModeBanner from "@/components/DemoModeBanner";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";

View File

@ -1,6 +1,6 @@
import Header from "@/components/dashboard/header/Header"; import Header from "@/components/dashboard/header/Header";
import DemoModeBanner from "@/components/DemoModeBanner"; import DemoModeBanner from "@/components/DemoModeBanner";
import MobileSidebar from "@/components/settings/sidebar/ModileSidebar"; import MobileSidebar from "@/components/settings/sidebar/MobileSidebar";
import Sidebar from "@/components/settings/sidebar/Sidebar"; import Sidebar from "@/components/settings/sidebar/Sidebar";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import ValidAccountCheck from "@/components/utils/ValidAccountCheck"; import ValidAccountCheck from "@/components/utils/ValidAccountCheck";

View File

@ -173,17 +173,21 @@ export default function EditableHour({
{ {
hour.measurements?.map(m => hour.measurements?.map(m =>
m.metricType === "timeseries" ?
Array.from({ length: m.value }).map((_, index) => (<Icon name={titleCase(m.icon)} size={24}
<div key={`${m.id}-${index}`} className="hover:cursor-no-drop" onClick={(e) => { color={hour.foreground}
decrementCount({ metricId: m.metricId, hourId: hour.id }); tooltip={`${m.metricName}: ${m.value} ${m.unit}`}
}}> />)
<Icon name={titleCase(m.icon)} size={24} : Array.from({ length: m.value }).map((_, index) =>
color={hour.foreground} <div key={`${m.id}-${index}`} className="hover:cursor-no-drop" onClick={(e) => {
tooltip={m.metricName} decrementCount({ metricId: m.metricId, hourId: hour.id });
/> }}>
</div> <Icon name={titleCase(m.icon)} size={24}
) color={hour.foreground}
tooltip={`${m.metricName} x${m.value}`}
/>
</div>
)
)} )}
{(hour.measurements.length > 0) ? {(hour.measurements.length > 0) ?
(<span className="mx-2 opacity-50"> (<span className="mx-2 opacity-50">
@ -194,7 +198,11 @@ export default function EditableHour({
} }
<HourMeasurementsDialog hour={hour} metrics={metrics} reload={reload}> <HourMeasurementsDialog hour={hour} metrics={metrics} reload={reload}>
<Plus size={16} className="opacity-50 hover:cursor-pointer" /> {
hour.measurements.length > 0
? <Pencil size={16} className="opacity-50 hover:cursor-pointer" />
: <Plus size={16} className="opacity-50 hover:cursor-pointer" />
}
</HourMeasurementsDialog> </HourMeasurementsDialog>
</div> </div>
: ""} : ""}

View File

@ -12,11 +12,17 @@ import { ZHour } from "@lifetracker/shared/types/days";
import { Icon } from "@/components/ui/icon"; import { Icon } from "@/components/ui/icon";
import { titleCase } from "title-case"; import { titleCase } from "title-case";
import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogClose, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { X } from "lucide-react"; import { Trash, X } from "lucide-react";
import { useDecrementCount, useIncrementCount, } from "@lifetracker/shared-react/hooks/measurements"; import { useDecrementCount, useIncrementCount, useSetValue, } from "@lifetracker/shared-react/hooks/measurements";
import { Separator } from "@radix-ui/react-dropdown-menu"; import { Separator } from "@radix-ui/react-dropdown-menu";
import { EditableText } from "../EditableText";
type CreateMeasurementSchema = z.infer<typeof zMeasurementSchema>; interface CreateMeasurementSchema {
type: string;
dayId: string;
hourId: string;
metricId: string;
}
export default function HourMeasurementsDialog({ export default function HourMeasurementsDialog({
hour: initialHour, hour: initialHour,
@ -31,6 +37,51 @@ export default function HourMeasurementsDialog({
}) { }) {
const [hour, setHour] = useState(initialHour); const [hour, setHour] = useState(initialHour);
const [isOpen, onOpenChange] = useState(false); 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({ const { mutate: increment } = useIncrementCount({
onSuccess: (res) => { onSuccess: (res) => {
@ -41,7 +92,6 @@ export default function HourMeasurementsDialog({
}; };
setHour(newHour); setHour(newHour);
reload(newHour); reload(newHour);
console.log("New hour's deets", newHour.measurements);
toast({ toast({
description: "Measurement added!", description: "Measurement added!",
}); });
@ -99,6 +149,19 @@ export default function HourMeasurementsDialog({
currentMeasurements = hour.measurements.map(measurement => measurement.metricName); currentMeasurements = hour.measurements.map(measurement => measurement.metricName);
}, [hour]); }, [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 ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
@ -108,72 +171,104 @@ export default function HourMeasurementsDialog({
<DialogTitle>Metrics for {hour.date} at {hour.datetime}</DialogTitle> <DialogTitle>Metrics for {hour.date} at {hour.datetime}</DialogTitle>
</DialogHeader> </DialogHeader>
<Separator /> <Separator />
{hour.measurements && hour.measurements.length > 0 ? {(hour.measurements && hour.measurements.length > 0) || pendingMeasurement ?
(<> (<>
<div className="font-bold">Measurements</div> {/* <div className="font-bold">Measurements</div> */}
<div className="mx-4 mb-4"> <div className="mx-4 mb-4">
{hour.measurements.map(measurement => { {hour.measurements.map(measurement => {
const metric = metrics.find(m => m.id === measurement.metricId); const metric = metrics.find(m => m.id === measurement.metricId);
return ( if (metric!.type === "timeseries") {
<div key={measurement.id} className="flex items-center justify-between"> return (
<div className="gap-4 flex"><Icon name={titleCase(metric.icon)} size={24} /> <div key={measurement.id} className="flex items-center justify-between">
<div>{metric.name}</div></div> <div className="gap-4 flex"><Icon name={titleCase(metric.icon)} size={24} />
<div className="flex gap-4 items-center"> <div>{metric.name}</div></div>
<div className="hover:cursor-pointer" onClick={() => { <div className="flex gap-4 items-center">
decrement({ metricId: metric.id, hourId: hour.id, dayId: hour.dayId }); <EditableText originalText={measurement.value + " " + metric?.unit} onSave={(value) => {
}} ><Icon name={"Minus"} /></div> setValue({
<div>{measurement.value}</div> metricId: metric.id,
<div className="hover:cursor-pointer" onClick={() => { hourId: hour.id,
increment({ metricId: metric.id, hourId: hour.id, dayId: hour.dayId }); dayId: hour.dayId,
}}><Icon name={"Plus"} /></div> 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);
}
}}
/>
&nbsp;
{pendingMeasurement.unit}
</div> </div>
</div> </div>
); </div>
})} )
}
</div> </div>
</>) </>)
: <> : <>
</> </>
} }
<div className="font-bold">Add Measurement</div>
<div className="grid" style={{ gridTemplateColumns: "repeat(6, 1fr)" }}> {Object.keys(metricsByType).map(type => (
{metrics.map(metric => ( <>
// If metric.name is in currentMeasurements, don't show it <div className="font-bold border-b border-white">{titleCase(type)}</div>
currentMeasurements.includes(metric.name) ? null : <div className="grid" style={{ gridTemplateColumns: "repeat(4, 1fr)", rowGap: "1em" }}>
<button style={{ aspectRatio: "1/1" }} key={metric.id} className="flex flex-col items-center justify-center hover:opacity-50" onClick={() => { {metricsByType[type].map(metric => (
increment({ metricId: metric.id, hourId: hour.id, dayId: hour.dayId }); // If metric.name is in currentMeasurements, don't show it
// onOpenChange(false); currentMeasurements.includes(metric.name) ? null :
}}> <button style={{}} key={metric.id} className="flex flex-col items-center justify-center hover:opacity-50" onClick={() => {
<Icon name={titleCase(metric.icon)} size={32} /> addMeasurement({ ...metric, metricId: metric.id!, hourId: hour.id!, dayId: hour.dayId });
<span className="text-sm">{metric.name}</span> // onOpenChange(false);
</button> }}>
))} <Icon name={titleCase(metric.icon)} size={32} />
</div> <span className="text-sm">{metric.name}</span>
</button>
))}
</div></>
))}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
// // Show a dropdown menu with an option for each metric. When the user selects a metric, increment the count for that metric in the hour.
// // The dropdown should show the metric name and icon.
// // Reset the dropdown to the default value after the user selects a metric.
// <Select
// className="w-full h-2"
// unstyled={true}
// isSearchable={false}
// placeholder="Add"
// options={metrics.map(metric =>
// ({ value: metric.id, label: metric.name, icon: metric.icon }))}
// onChange={(selectedOption) => incrementCount(selectedOption.value)}
// classNames={{
// menu: (state) =>
// "bg-card px-4 py-2 text-white rounded-md shadow-lg",
// }}
// />
); );
} }

View File

@ -52,8 +52,8 @@ export default function AddMetricDialog({
const form = useForm<CreateMetricSchema>({ const form = useForm<CreateMetricSchema>({
resolver: zodResolver(zMetricSchema), resolver: zodResolver(zMetricSchema),
defaultValues: { defaultValues: {
type: "count",
...initialMetric, ...initialMetric,
type: "count"
}, },
}); });
const handleSuccess = (message: string) => { const handleSuccess = (message: string) => {
@ -117,7 +117,12 @@ export default function AddMetricDialog({
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Track a New {titleCase(form.watch("type"))} Metric</DialogTitle> <DialogTitle>
{initialMetric
? "Update " + initialMetric.name
: "New " + titleCase(form.watch('type')) + " Metric"
}
</DialogTitle>
</DialogHeader> </DialogHeader>
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit((val) => mutate(val))}> <form onSubmit={form.handleSubmit((val) => mutate(val))}>
@ -142,6 +147,30 @@ export default function AddMetricDialog({
)} )}
/> />
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} defaultValue={initialMetric?.type ?? "count"}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a type of metric to track" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="drug">Drug</SelectItem>
<SelectItem value="diet">Diet</SelectItem>
<SelectItem value="workout">Workout</SelectItem>
<SelectItem value="sex">Sex</SelectItem>
<SelectItem value="count">Count</SelectItem>
<SelectItem value="timeseries">Timeseries</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="unit" name="unit"
@ -153,35 +182,12 @@ export default function AddMetricDialog({
placeholder="Unit" placeholder="Unit"
{...field} {...field}
className="w-full rounded border p-2" className="w-full rounded border p-2"
disabled={(form.watch("type") === "count")}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* Select field with "timeseries" and "count" */}
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} defaultValue={"count"}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a type of metric to track" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="timeseries">Timeseries</SelectItem>
<SelectItem value="count">Count</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div> </div>
<FormField <FormField

View File

@ -54,20 +54,12 @@ export default function MetricsView() {
<TableCell className="py-1">{m.type}</TableCell> <TableCell className="py-1">{m.type}</TableCell>
<TableCell className="py-1">{m.description}</TableCell> <TableCell className="py-1">{m.description}</TableCell>
<TableCell className="flex gap-1 py-1"> <TableCell className="flex gap-1 py-1">
<ActionButtonWithTooltip
tooltip="Delete category"
variant="outline"
onClick={() => deleteMetric({ id: m.id! })}
loading={false}
>
<Trash size={16} color="red" />
</ActionButtonWithTooltip>
<AddMetricDialog initialMetric={m} > <AddMetricDialog initialMetric={m} >
<ButtonWithTooltip <ButtonWithTooltip
tooltip="Edit" tooltip="Edit"
variant="outline" variant="outline"
> >
<Pencil size={16} color="red" /> <Pencil size={16} color="white" />
</ButtonWithTooltip> </ButtonWithTooltip>
</AddMetricDialog> </AddMetricDialog>
</TableCell> </TableCell>

View File

@ -1,4 +1,4 @@
import MobileSidebarItem from "@/components/shared/sidebar/ModileSidebarItem"; import MobileSidebarItem from "@/components/shared/sidebar/MobileSidebarItem";
import HoarderLogoIcon from "@/public/icons/logo-icon.svg"; import HoarderLogoIcon from "@/public/icons/logo-icon.svg";
import { CheckCheck, ClipboardList, GaugeCircleIcon, Home, HomeIcon, Search, Tag } from "lucide-react"; import { CheckCheck, ClipboardList, GaugeCircleIcon, Home, HomeIcon, Search, Tag } from "lucide-react";
@ -11,7 +11,7 @@ export default async function MobileSidebar() {
path="/dashboard/day/today" path="/dashboard/day/today"
/> />
<MobileSidebarItem logo={<Tag />} path="/dashboard/categories" /> <MobileSidebarItem logo={<Tag />} path="/dashboard/categories" />
<MobileSidebarItem logo={<CheckCheck />} path="/dashboard/habits" /> <MobileSidebarItem logo={<CheckCheck />} path="/dashboard/metrics" />
<MobileSidebarItem logo={<GaugeCircleIcon />} path="/analytics" /> <MobileSidebarItem logo={<GaugeCircleIcon />} path="/analytics" />
</ul> </ul>
</aside> </aside>

View File

@ -1,5 +1,18 @@
import { api } from "../trpc"; import { api } from "../trpc";
export function useSetValue(
...opts: Parameters<typeof api.measurements.setValue.useMutation>
) {
const apiUtils = api.useUtils();
return api.measurements.setValue.useMutation({
...opts[0],
onSuccess: (res, req, meta) => {
return opts[0]?.onSuccess?.(res, req, meta);
},
});
}
export function useIncrementCount( export function useIncrementCount(
...opts: Parameters<typeof api.measurements.incrementCount.useMutation> ...opts: Parameters<typeof api.measurements.incrementCount.useMutation>
) { ) {

View File

@ -4,6 +4,8 @@ export const zMeasurementSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
metricId: z.string(), metricId: z.string(),
metricName: z.string().optional(), metricName: z.string().optional(),
metricType: z.string().optional(),
unit: z.string().nullish(),
hourId: z.string().optional(), hourId: z.string().optional(),
dayId: z.string().optional(), dayId: z.string().optional(),
value: z.coerce.number(), value: z.coerce.number(),
@ -16,7 +18,7 @@ export const zMetricSchema = z.object({
name: z.string(), name: z.string(),
description: z.string().optional(), description: z.string().optional(),
unit: z.string().nullish(), unit: z.string().nullish(),
type: z.enum(["timeseries", "count"]), type: z.enum(["timeseries", "count", "drug", "diet", "workout", "sex"]),
icon: z.string(), icon: z.string(),
measurements: z.array(zMeasurementSchema).optional(), measurements: z.array(zMeasurementSchema).optional(),
}); });

View File

@ -64,8 +64,10 @@ export async function hourJoinsQuery(
id: measurements.id, id: measurements.id,
metricId: measurements.metricId, metricId: measurements.metricId,
value: measurements.value, value: measurements.value,
unit: metrics.unit,
icon: metrics.icon, icon: metrics.icon,
metricName: metrics.name, metricName: metrics.name,
metricType: metrics.type,
}) })
.from(measurements) .from(measurements)
.leftJoin(metrics, eq(metrics.id, measurements.metricId)) .leftJoin(metrics, eq(metrics.id, measurements.metricId))

View File

@ -24,6 +24,47 @@ export const measurementsAppRouter = router({
console.log(dbMeasurements.length); console.log(dbMeasurements.length);
return dbMeasurements; return dbMeasurements;
}), }),
setValue: authedProcedure
.input(z.object({ metricId: z.string(), hourId: z.string(), dayId: z.string(), value: z.number() }))
.output(zMeasurementSchema)
.mutation(async ({ input, ctx }) => {
const metric = await ctx.db.select().from(metrics).where(eq(metrics.id, input.metricId));
if (!metric[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Metric not found",
});
}
// Check if there is a measurement for this metric in this hour, if so, update it, if not, create it
const existingMeasurement = await ctx.db.select().from(measurements).where(and(
eq(measurements.metricId, input.metricId),
eq(measurements.hourId, input.hourId),
));
if (existingMeasurement[0]) {
const updatedMeasurement = await ctx.db.update(measurements).set({
value: input.value.toString(),
}).where(eq(measurements.id, existingMeasurement[0].id)).returning();
return {
...updatedMeasurement[0],
icon: metric[0].icon,
metricName: metric[0].name,
};
} else {
const newMeasurement = await ctx.db.insert(measurements).values({
metricId: input.metricId,
hourId: input.hourId,
dayId: input.dayId,
value: input.value.toString(),
userId: ctx.user.id,
}).returning();
return {
...newMeasurement[0],
icon: metric[0].icon,
metricName: metric[0].name,
};
}
}),
incrementCount: authedProcedure incrementCount: authedProcedure
.input(z.object({ metricId: z.string(), hourId: z.string(), dayId: z.string() })) .input(z.object({ metricId: z.string(), hourId: z.string(), dayId: z.string() }))
.output(zMeasurementSchema) .output(zMeasurementSchema)