Metrics and measurements work!

This commit is contained in:
Ryan Pandya 2024-12-07 15:19:32 -08:00
parent 720282c817
commit 7e46f2c38d
23 changed files with 752 additions and 1268 deletions

View File

@ -1,183 +0,0 @@
import { useEffect, useState } from "react";
import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
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 { zUpdateCategoryRequestSchema, ZUpdateCategoryRequest } from "@lifetracker/shared/types/categories";
export default function EditMetricDialog({
category: initialCategory,
children,
}: {
category: ZUpdateCategoryRequest;
children?: React.ReactNode;
}) {
const apiUtils = api.useUtils();
const [isOpen, onOpenChange] = useState(false);
const form = useForm<ZUpdateCategoryRequest>({
resolver: zodResolver(zUpdateCategoryRequestSchema),
defaultValues: initialCategory,
});
const { mutate, isPending } = api.categories.update.useMutation({
onSuccess: () => {
apiUtils.categories.list.invalidate();
toast({
description: "Category updated successfully",
});
onOpenChange(false);
},
onError: (error) => {
if (error instanceof TRPCClientError) {
toast({
variant: "destructive",
description: error.message,
});
} else {
toast({
variant: "destructive",
description: "Failed to update category",
});
}
},
});
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Category</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((val) => mutate(val))}>
<div className="flex w-full flex-col space-y-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Name"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>Code</FormLabel>
<FormControl>
<Input
type="number"
placeholder="Code"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Description"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* <FormField
control={form.control}
name="colorId"
render={({ field }) => (
<FormItem>
<FormLabel>Color</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Color"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/> */}
<DialogFooter className="sm:justify-end">
<DialogClose asChild>
<Button type="button" variant="secondary">
Close
</Button>
</DialogClose>
<ActionButton
type="submit"
loading={isPending}
disabled={isPending}
>
Update
</ActionButton>
</DialogFooter>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -15,8 +15,7 @@ import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
import { Pencil, Trash, FilePlus, Palette } from "lucide-react"; import { Pencil, Trash, FilePlus, Palette } from "lucide-react";
import { useSession } from "next-auth/react"; import { useSession } from "next-auth/react";
import AddMetricDialog from "./AddMetricDialog"; import AddMetricDialog from "../metrics/AddMetricDialog";
import EditMetricDialog from "./EditMetricDialog";
import Link from "next/link"; import Link from "next/link";
import { ZMetric } from "@lifetracker/shared/types/metrics"; import { ZMetric } from "@lifetracker/shared/types/metrics";
@ -24,6 +23,15 @@ export default function MetricsView() {
const { data: metrics } = api.metrics.list.useQuery(); const { data: metrics } = api.metrics.list.useQuery();
const invalidateMetricsList = api.useUtils().metrics.list.invalidate; const invalidateMetricsList = api.useUtils().metrics.list.invalidate;
const { mutate: deleteMetric } = api.metrics.delete.useMutation({
onSettled: () => {
invalidateMetricsList();
toast({
description: "Metric deleted",
});
},
});
const MetricsTable = ({ metrics }: { metrics: ZMetric[] }) => ( const MetricsTable = ({ metrics }: { metrics: ZMetric[] }) => (
<Table> <Table>
<TableHeader className="bg-gray-200"> <TableHeader className="bg-gray-200">
@ -44,7 +52,7 @@ export default function MetricsView() {
<ActionButtonWithTooltip <ActionButtonWithTooltip
tooltip="Delete category" tooltip="Delete category"
variant="outline" variant="outline"
onClick={() => console.log({ metricId: m.id })} onClick={() => deleteMetric({ id: m.id! })}
loading={false} loading={false}
> >
<Trash size={16} color="red" /> <Trash size={16} color="red" />

View File

@ -10,6 +10,7 @@ import { ArrowLeftSquare, ArrowRightSquare } from "lucide-react";
import spacetime from "spacetime"; import spacetime from "spacetime";
import EditableHour from "@/components/dashboard/hours/EditableHour"; import EditableHour from "@/components/dashboard/hours/EditableHour";
import { DayMetrics } from "./DayMetrics"; import { DayMetrics } from "./DayMetrics";
import { api } from "@/server/api/client";
export default async function DayView({ export default async function DayView({
day, day,
@ -24,6 +25,9 @@ export default async function DayView({
const prevDay = spacetime(day.date).subtract(1, "day").format("iso-short"); const prevDay = spacetime(day.date).subtract(1, "day").format("iso-short");
const nextDay = spacetime(day.date).add(1, "day").format("iso-short"); const nextDay = spacetime(day.date).add(1, "day").format("iso-short");
const userMetrics = await api.metrics.list();
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@ -101,7 +105,7 @@ export default async function DayView({
</li> </li>
{day.hours.map((hour, i) => ( {day.hours.map((hour, i) => (
<li key={hour.time} id={"hour-" + i.toString()}> <li key={hour.time} id={"hour-" + i.toString()}>
<EditableHour hour={hour} i={i} /> <EditableHour hour={hour} i={i} metrics={userMetrics} />
</li> </li>
))} ))}
</ul> </ul>

View File

@ -10,21 +10,29 @@ import { EditableText } from "@/components/dashboard/EditableText";
import { format } from "date-fns"; import { format } from "date-fns";
import { TZDate } from "@date-fns/tz"; import { TZDate } from "@date-fns/tz";
import { ZHour } from "@lifetracker/shared/types/days"; import { ZHour } from "@lifetracker/shared/types/days";
import { MessageCircle, Pencil } from "lucide-react"; import { MessageCircle, Pencil, Plus } from "lucide-react";
import { ButtonWithTooltip } from "@/components/ui/button"; import { ButtonWithTooltip } from "@/components/ui/button";
import { EditableHourCode } from "./EditableHourCode"; import { EditableHourCode } from "./EditableHourCode";
import { EditableHourComment } from "./EditableHourComment"; import { EditableHourComment } from "./EditableHourComment";
import { api } from "@/lib/trpc";
import spacetime from 'spacetime'; import spacetime from 'spacetime';
import { eq, is } from "drizzle-orm"; import { eq, is } from "drizzle-orm";
import HourMeasurementsDialog from "@/components/dashboard/hours/HourMeasurementsDialog";
import { ActionButtonWithTooltip } from "@/components/ui/action-button";
import { ZMetric } from "@lifetracker/shared/types/metrics";
import { Badge } from "@/components/ui/badge";
import { Icon } from "@/components/ui/icon";
import { titleCase } from "title-case";
import { useDecrementCount } from "@lifetracker/shared-react/hooks/measurements";
export default function EditableHour({ export default function EditableHour({
hour: initialHour, hour: initialHour,
i, i,
metrics,
className, className,
}: { }: {
hour: ZHour, hour: ZHour,
i: number, i: number,
metrics: ZMetric[],
className?: string; className?: string;
}) { }) {
const [hour, setHour] = useState(initialHour); const [hour, setHour] = useState(initialHour);
@ -36,7 +44,6 @@ export default function EditableHour({
comment: oldComment, comment: oldComment,
...res, ...res,
}; };
console.log(res);
setHour(newHour); setHour(newHour);
// Only show toast if client screen is larger than mobile // Only show toast if client screen is larger than mobile
if (window.innerWidth > 640) { if (window.innerWidth > 640) {
@ -46,12 +53,48 @@ export default function EditableHour({
} }
}, },
}); });
const { mutate: decrementCount } = 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);
toast({
description: res === undefined ? "Measurement removed!" : "Measurement updated!",
});
}
});
const tzOffset = spacetime().offset() / 60; const tzOffset = spacetime().offset() / 60;
const localDateTime = spacetime(hour.date).add(hour.time + tzOffset, "hour"); const localDateTime = spacetime(hour.date).add(hour.time + tzOffset, "hour");
hour.datetime = `${localDateTime.format('{hour} {ampm}')}`; hour.datetime = `${localDateTime.format('{hour} {ampm}')}`;
useEffect(() => { useEffect(() => {
// console.log(hour.categoryDesc); // console.log(hour.categoryDesc);
}, [hour]); }, [hour]);
@ -63,6 +106,11 @@ export default function EditableHour({
const isToday = (localDateTime.format("iso-short") == format(now, "yyyy-MM-dd")); const isToday = (localDateTime.format("iso-short") == format(now, "yyyy-MM-dd"));
return isToday && isCurrentHour; return isToday && isCurrentHour;
} }
function reload(newHour: ZHour) {
setHour(newHour);
}
return ( return (
<div <div
data-hourid={hour.id} data-hourid={hour.id}
@ -71,7 +119,7 @@ export default function EditableHour({
)} )}
style={{ style={{
background: hour.background!, color: hour.foreground!, fontFamily: "inherit", background: hour.background!, color: hour.foreground!, fontFamily: "inherit",
gridTemplateColumns: window.innerWidth > 640 ? "50px 100px 1fr 50px" : "50px 100px 1fr", // Known issue: This won't work if the screen is resized, only on reload gridTemplateColumns: window.innerWidth > 640 ? "50px 100px 3fr 2fr" : "50px 100px 1fr", // Known issue: This won't work if the screen is resized, only on reload
}} }}
> >
<span className="text-right"> <span className="text-right">
@ -88,12 +136,14 @@ export default function EditableHour({
/> />
</div> </div>
<span className="hidden sm:block"> <span className="hidden sm:block">
{hour.categoryCode != undefined ?
<EditableHourComment <EditableHourComment
originalText={hour.categoryDesc} originalText={hour.comment ?? hour.categoryName}
hour={hour} hour={hour}
onSubmit={updateHour} onSubmit={updateHour}
i={i} i={i}
/> />
: ""}
</span> </span>
<span className="block sm:hidden"> <span className="block sm:hidden">
<div className="w-full text-left edit-hour-comment" <div className="w-full text-left edit-hour-comment"
@ -103,19 +153,30 @@ export default function EditableHour({
{hour.categoryName} {hour.categoryName}
</div> </div>
</span> </span>
<div className="text-right hidden sm:block"> <div className="hidden sm:flex items-center justify-end">
<ButtonWithTooltip
delayDuration={500} {hour.categoryCode != undefined ?
tooltip="Edit"
size="none" <div className="flex items-center gap-2">
variant="ghost" {hour.measurements?.map(m =>
className="align-middle text-gray-400 hover:bg-active" Array.from({ length: m.value }).map((_, index) =>
onClick={() => { <div key={`${m.id}-${index}`} className="hover:cursor-no-drop" onClick={(e) => {
console.log("Pushed edit") decrementCount({ metricId: m.metricId, hourId: hour.id });
}} }}>
> <Icon name={titleCase(m.icon)} size={24} />
<Pencil className="size-4" /> </div>
</ButtonWithTooltip> )
)}
{(hour.measurements.length > 0) ?
(<span className="mx-2 opacity-50">|</span>)
: ""
}
<HourMeasurementsDialog hour={hour} metrics={metrics} reload={reload}>
<Plus size={16} className="opacity-50 hover:cursor-pointer" />
</HourMeasurementsDialog>
</div>
: ""}
</div> </div>
</div> </div>
); );

View File

@ -1,7 +1,8 @@
"use client"; "use client";
import { or } from "drizzle-orm"; import { set } from "date-fns";
import { useEffect, useRef } from "react"; import { MessageCircle } from "lucide-react";
import { useEffect, useRef, useState } from "react";
function selectHourCode(time: number) { function selectHourCode(time: number) {
document.getElementById("hour-" + (time).toString())?.getElementsByClassName("edit-hour-code")[0].focus(); document.getElementById("hour-" + (time).toString())?.getElementsByClassName("edit-hour-code")[0].focus();
@ -21,38 +22,42 @@ export function EditableHourComment({
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
const [text, setText] = useState(originalText);
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref.current) {
ref.current.value = originalText; ref.current.value = text;
} }
}, [ref]); }, [ref]);
// Update the text when Hour changes
useEffect(() => {
setText(hour.comment ?? hour.categoryName);
}, [hour]);
// Update the ref element whenever the Text changes
useEffect(() => {
if (ref.current) {
ref.current.value = text;
}
}, [text]);
const submit = () => { const submit = () => {
let newComment: string | null = ref.current?.value ?? null; setText(ref.current?.value ?? originalText);
if (originalText == newComment) {
// Nothing to do here
selectHourComment(hour.time + 1);
return;
}
if (newComment == "") {
if (originalText == null) {
// Set value to previous hour's value
newComment = document.getElementById("hour-" + (i - 1).toString())?.getElementsByClassName("edit-hour-comment")[0].value;
}
else {
newComment = null;
}
}
onSubmit({ onSubmit({
date: hour.date, date: hour.date,
hourTime: hour.time, hourTime: hour.time,
dayId: hour.dayId, dayId: hour.dayId,
comment: newComment, comment: ref.current?.value ?? "",
code: hour.categoryCode.toString(), code: hour.categoryCode.toString(),
}) });
selectHourComment(hour.time + 1); selectHourComment(i + 1);
}; };
return ( return (
<div className="flex">
{hour.comment ?
<MessageCircle className="mr-4" />
: ""}
<input <input
className="w-full text-left edit-hour-comment hover:border-b hover:border-dashed" className="w-full text-left edit-hour-comment hover:border-b hover:border-dashed"
style={{ style={{
@ -60,7 +65,7 @@ export function EditableHourComment({
borderColor: hour.foreground ?? "inherit" borderColor: hour.foreground ?? "inherit"
}} }}
ref={ref} ref={ref}
value={originalText ?? ""} defaultValue={text}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
@ -85,6 +90,12 @@ export function EditableHourComment({
onFocus={(e) => { onFocus={(e) => {
e.target.select(); e.target.select();
}} }}
onBlur={(e) => {
if (!e.target.value) {
e.target.value = originalText ?? "";
}
}}
/> />
</div>
); );
} }

View File

@ -0,0 +1,101 @@
"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 { X } from "lucide-react";
import { useIncrementCount, } from "@lifetracker/shared-react/hooks/measurements";
type CreateMeasurementSchema = z.infer<typeof zMeasurementSchema>;
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 { mutate: increment } = useIncrementCount({
onSuccess: (res) => {
onOpenChange(false);
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],
};
reload(newHour);
toast({
description: "Measurement added!",
});
},
onError: (error) => {
toast({
description: error.message,
});
},
});
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Measurement</DialogTitle>
</DialogHeader>
<div className="grid" style={{ gridTemplateColumns: "repeat(6, 1fr)" }}>
{metrics.map(metric => (
<button style={{ aspectRatio: "1/1" }} key={metric.id} className="flex flex-col items-center justify-center hover:opacity-50" onClick={() => {
increment({ metricId: metric.id, hourId: hour.id, dayId: hour.dayId });
}}>
<Icon name={titleCase(metric.icon)} size={32} />
<span className="text-sm">{metric.name}</span>
</button>
))}
</div>
</DialogContent>
</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

@ -1,3 +1,4 @@
import Link from "next/link";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { ActionButton } from "@/components/ui/action-button"; import { ActionButton } from "@/components/ui/action-button";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -34,7 +35,8 @@ import { useForm } from "react-hook-form";
import { z } from "zod"; import { z } from "zod";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { zMetricSchema } from "@lifetracker/shared/types/metrics"; import { zMetricSchema } from "@lifetracker/shared/types/metrics";
import { Icon, } from "@/components/ui/icon";
import { icons } from "lucide-react";
type CreateMetricSchema = z.infer<typeof zMetricSchema>; type CreateMetricSchema = z.infer<typeof zMetricSchema>;
export default function AddMetricDialog({ export default function AddMetricDialog({
@ -87,6 +89,25 @@ export default function AddMetricDialog({
} }
}, [isOpen, form]); }, [isOpen, form]);
const [icon, setIcon] = useState("FileQuestion");
if (initialMetric?.icon) {
useEffect(() => {
searchIcons(initialMetric.icon);
}, []);
}
const searchIcons = (query: string) => {
const icon = Object.keys(icons).find((i) => i.toLowerCase() == (query.toLowerCase()));
if (icon) {
setIcon(icon);
}
else {
setIcon("FileQuestion");
return;
}
}
return ( return (
<Dialog open={isOpen} onOpenChange={onOpenChange}> <Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
@ -97,6 +118,7 @@ export default function AddMetricDialog({
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit((val) => mutate(val))}> <form onSubmit={form.handleSubmit((val) => mutate(val))}>
<div className="flex w-full flex-col space-y-2"> <div className="flex w-full flex-col space-y-2">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "10px" }}> <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "10px" }}>
<FormField <FormField
control={form.control} control={form.control}
@ -141,7 +163,7 @@ export default function AddMetricDialog({
name="type" name="type"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Select onValueChange={field.onChange} defaultValue={field.value}> <Select onValueChange={field.onChange} defaultValue={"count"}>
<FormControl> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a type of metric to track" /> <SelectValue placeholder="Select a type of metric to track" />
@ -174,7 +196,37 @@ export default function AddMetricDialog({
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/><div className="flex gap-4 items-center">
<Icon color="white" size={36}
name={icon}
/><FormField control={form.control} name="icon" render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="text"
placeholder="Icon"
{...field}
className="w-full rounded border p-2"
onChange={(e) => {
searchIcons(e.target.value);
form.setValue("icon", e.target.value);
}
}
onBlur={() => {
if (form.getValues("icon") == "" && initialMetric?.icon) {
{
form.setValue("icon", initialMetric.icon);
searchIcons(initialMetric.icon);
}
}
}}
/> />
</FormControl>
<FormMessage />
</FormItem>
)} />
<Link href="https://lucide.dev/icons" target="_blank" className="text-blue-500">Browse icons</Link>
</div>
<DialogFooter className="sm:justify-end"> <DialogFooter className="sm:justify-end">
<DialogClose asChild> <DialogClose asChild>
<Button type="button" variant="secondary"> <Button type="button" variant="secondary">

View File

@ -0,0 +1,14 @@
"use client";
import { icons } from 'lucide-react';
export const Icon = ({ name = "FileQuestion", color = "white", size = 16, ...props }) => {
const icon = Object.keys(icons).find((i) => i.toLowerCase() == (name.toLowerCase()));
if (!icon) {
return null;
}
const LucideIcon = icons[name as keyof typeof icons]; // Add an index signature to allow indexing with a string
return <LucideIcon color={color} size={size} {...props} />;
};

View File

@ -19,6 +19,7 @@ const nextConfig = withPWA({
buildActivity: true, buildActivity: true,
buildActivityPosition: "bottom-left", buildActivityPosition: "bottom-left",
}, },
transpilePackages: ['lucide-react'],
async headers() { async headers() {
return [ return [
{ {

View File

@ -75,6 +75,33 @@ CREATE TABLE `hour` (
FOREIGN KEY (`categoryId`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE no action FOREIGN KEY (`categoryId`) REFERENCES `category`(`id`) ON UPDATE no action ON DELETE no action
); );
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `measurement` (
`id` text PRIMARY KEY NOT NULL,
`hourId` text,
`dayId` text NOT NULL,
`metricId` text NOT NULL,
`createdAt` integer NOT NULL,
`userId` text NOT NULL,
`value` text NOT NULL,
FOREIGN KEY (`hourId`) REFERENCES `hour`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`dayId`) REFERENCES `day`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`metricId`) REFERENCES `metric`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `metric` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`description` text,
`userId` text NOT NULL,
`type` text NOT NULL,
`unit` text,
`goal` real,
`icon` text,
`createdAt` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `session` ( CREATE TABLE `session` (
`sessionToken` text PRIMARY KEY NOT NULL, `sessionToken` text PRIMARY KEY NOT NULL,
`userId` text NOT NULL, `userId` text NOT NULL,
@ -106,4 +133,6 @@ CREATE UNIQUE INDEX `category_userId_code_unique` ON `category` (`userId`,`code`
CREATE UNIQUE INDEX `color_userId_name_unique` ON `color` (`userId`,`name`);--> statement-breakpoint CREATE UNIQUE INDEX `color_userId_name_unique` ON `color` (`userId`,`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `day_date_unique` ON `day` (`date`);--> statement-breakpoint CREATE UNIQUE INDEX `day_date_unique` ON `day` (`date`);--> statement-breakpoint
CREATE UNIQUE INDEX `hour_dayId_time_unique` ON `hour` (`dayId`,`time`);--> statement-breakpoint CREATE UNIQUE INDEX `hour_dayId_time_unique` ON `hour` (`dayId`,`time`);--> statement-breakpoint
CREATE UNIQUE INDEX `measurement_dayId_metricId_unique` ON `measurement` (`dayId`,`metricId`);--> statement-breakpoint
CREATE UNIQUE INDEX `metric_userId_name_unique` ON `metric` (`userId`,`name`);--> statement-breakpoint
CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`); CREATE UNIQUE INDEX `user_email_unique` ON `user` (`email`);

View File

@ -1,28 +0,0 @@
CREATE TABLE `measurement` (
`id` text PRIMARY KEY NOT NULL,
`hourId` text,
`dayId` text NOT NULL,
`metricId` text NOT NULL,
`createdAt` integer NOT NULL,
`userId` text NOT NULL,
`value` text NOT NULL,
FOREIGN KEY (`hourId`) REFERENCES `hour`(`id`) ON UPDATE no action ON DELETE no action,
FOREIGN KEY (`dayId`) REFERENCES `day`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`metricId`) REFERENCES `metric`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `metric` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`description` text,
`userId` text NOT NULL,
`type` text NOT NULL,
`unit` text,
`goal` real,
`createdAt` integer NOT NULL,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `measurement_dayId_metricId_unique` ON `measurement` (`dayId`,`metricId`);--> statement-breakpoint
CREATE UNIQUE INDEX `metric_userId_name_unique` ON `metric` (`userId`,`name`);

View File

@ -1,7 +1,7 @@
{ {
"version": "6", "version": "6",
"dialect": "sqlite", "dialect": "sqlite",
"id": "a91a2a0e-7727-4187-8a75-c96d8e304f27", "id": "31bbb29b-e786-4716-9c91-c9d2217be582",
"prevId": "00000000-0000-0000-0000-000000000000", "prevId": "00000000-0000-0000-0000-000000000000",
"tables": { "tables": {
"account": { "account": {
@ -577,6 +577,221 @@
"compositePrimaryKeys": {}, "compositePrimaryKeys": {},
"uniqueConstraints": {} "uniqueConstraints": {}
}, },
"measurement": {
"name": "measurement",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"hourId": {
"name": "hourId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dayId": {
"name": "dayId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metricId": {
"name": "metricId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"measurement_dayId_metricId_unique": {
"name": "measurement_dayId_metricId_unique",
"columns": [
"dayId",
"metricId"
],
"isUnique": true
}
},
"foreignKeys": {
"measurement_hourId_hour_id_fk": {
"name": "measurement_hourId_hour_id_fk",
"tableFrom": "measurement",
"tableTo": "hour",
"columnsFrom": [
"hourId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"measurement_dayId_day_id_fk": {
"name": "measurement_dayId_day_id_fk",
"tableFrom": "measurement",
"tableTo": "day",
"columnsFrom": [
"dayId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"measurement_metricId_metric_id_fk": {
"name": "measurement_metricId_metric_id_fk",
"tableFrom": "measurement",
"tableTo": "metric",
"columnsFrom": [
"metricId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"measurement_userId_user_id_fk": {
"name": "measurement_userId_user_id_fk",
"tableFrom": "measurement",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"metric": {
"name": "metric",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"unit": {
"name": "unit",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"goal": {
"name": "goal",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"icon": {
"name": "icon",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"metric_userId_name_unique": {
"name": "metric_userId_name_unique",
"columns": [
"userId",
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"metric_userId_user_id_fk": {
"name": "metric_userId_user_id_fk",
"tableFrom": "metric",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": { "session": {
"name": "session", "name": "session",
"columns": { "columns": {

View File

@ -1,955 +0,0 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7a2251dd-4578-4c91-b44f-b1a07fadab7b",
"prevId": "a91a2a0e-7727-4187-8a75-c96d8e304f27",
"tables": {
"account": {
"name": "account",
"columns": {
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"token_type": {
"name": "token_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"session_state": {
"name": "session_state",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"account_userId_user_id_fk": {
"name": "account_userId_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": [
"provider",
"providerAccountId"
],
"name": "account_provider_providerAccountId_pk"
}
},
"uniqueConstraints": {}
},
"apiKey": {
"name": "apiKey",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyId": {
"name": "keyId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"keyHash": {
"name": "keyHash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"apiKey_keyId_unique": {
"name": "apiKey_keyId_unique",
"columns": [
"keyId"
],
"isUnique": true
},
"apiKey_name_userId_unique": {
"name": "apiKey_name_userId_unique",
"columns": [
"name",
"userId"
],
"isUnique": true
}
},
"foreignKeys": {
"apiKey_userId_user_id_fk": {
"name": "apiKey_userId_user_id_fk",
"tableFrom": "apiKey",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"category": {
"name": "category",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"code": {
"name": "code",
"type": "real",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"colorId": {
"name": "colorId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"parentId": {
"name": "parentId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"category_userId_code_unique": {
"name": "category_userId_code_unique",
"columns": [
"userId",
"code"
],
"isUnique": true
}
},
"foreignKeys": {
"category_colorId_color_id_fk": {
"name": "category_colorId_color_id_fk",
"tableFrom": "category",
"tableTo": "color",
"columnsFrom": [
"colorId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"category_parentId_category_id_fk": {
"name": "category_parentId_category_id_fk",
"tableFrom": "category",
"tableTo": "category",
"columnsFrom": [
"parentId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"category_userId_user_id_fk": {
"name": "category_userId_user_id_fk",
"tableFrom": "category",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"color": {
"name": "color",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"hexcode": {
"name": "hexcode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"inverse": {
"name": "inverse",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"color_userId_name_unique": {
"name": "color_userId_name_unique",
"columns": [
"userId",
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"color_userId_user_id_fk": {
"name": "color_userId_user_id_fk",
"tableFrom": "color",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"config": {
"name": "config",
"columns": {
"key": {
"name": "key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"day": {
"name": "day",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"date": {
"name": "date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mood": {
"name": "mood",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"day_date_unique": {
"name": "day_date_unique",
"columns": [
"date"
],
"isUnique": true
}
},
"foreignKeys": {
"day_userId_user_id_fk": {
"name": "day_userId_user_id_fk",
"tableFrom": "day",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"hour": {
"name": "hour",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"comment": {
"name": "comment",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"time": {
"name": "time",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dayId": {
"name": "dayId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"categoryId": {
"name": "categoryId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"hour_dayId_time_unique": {
"name": "hour_dayId_time_unique",
"columns": [
"dayId",
"time"
],
"isUnique": true
}
},
"foreignKeys": {
"hour_userId_user_id_fk": {
"name": "hour_userId_user_id_fk",
"tableFrom": "hour",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"hour_dayId_day_id_fk": {
"name": "hour_dayId_day_id_fk",
"tableFrom": "hour",
"tableTo": "day",
"columnsFrom": [
"dayId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"hour_categoryId_category_id_fk": {
"name": "hour_categoryId_category_id_fk",
"tableFrom": "hour",
"tableTo": "category",
"columnsFrom": [
"categoryId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"measurement": {
"name": "measurement",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"hourId": {
"name": "hourId",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dayId": {
"name": "dayId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"metricId": {
"name": "metricId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"measurement_dayId_metricId_unique": {
"name": "measurement_dayId_metricId_unique",
"columns": [
"dayId",
"metricId"
],
"isUnique": true
}
},
"foreignKeys": {
"measurement_hourId_hour_id_fk": {
"name": "measurement_hourId_hour_id_fk",
"tableFrom": "measurement",
"tableTo": "hour",
"columnsFrom": [
"hourId"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"measurement_dayId_day_id_fk": {
"name": "measurement_dayId_day_id_fk",
"tableFrom": "measurement",
"tableTo": "day",
"columnsFrom": [
"dayId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"measurement_metricId_metric_id_fk": {
"name": "measurement_metricId_metric_id_fk",
"tableFrom": "measurement",
"tableTo": "metric",
"columnsFrom": [
"metricId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"measurement_userId_user_id_fk": {
"name": "measurement_userId_user_id_fk",
"tableFrom": "measurement",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"metric": {
"name": "metric",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"unit": {
"name": "unit",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"goal": {
"name": "goal",
"type": "real",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"createdAt": {
"name": "createdAt",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"metric_userId_name_unique": {
"name": "metric_userId_name_unique",
"columns": [
"userId",
"name"
],
"isUnique": true
}
},
"foreignKeys": {
"metric_userId_user_id_fk": {
"name": "metric_userId_user_id_fk",
"tableFrom": "metric",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"session": {
"name": "session",
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"session_userId_user_id_fk": {
"name": "session_userId_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"user": {
"name": "user",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'user'"
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'America/Los_Angeles'"
}
},
"indexes": {
"user_email_unique": {
"name": "user_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"verificationToken": {
"name": "verificationToken",
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": [
"identifier",
"token"
],
"name": "verificationToken_identifier_token_pk"
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@ -5,15 +5,8 @@
{ {
"idx": 0, "idx": 0,
"version": "6", "version": "6",
"when": 1733261637429, "when": 1733533929464,
"tag": "0000_sad_lionheart", "tag": "0000_gorgeous_sasquatch",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1733352349579,
"tag": "0001_chemical_toro",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@ -135,6 +135,7 @@ export const metrics = sqliteTable("metric", {
type: text("type").notNull(), type: text("type").notNull(),
unit: text("unit"), unit: text("unit"),
goal: real("goal"), goal: real("goal"),
icon: text("icon").notNull(),
createdAt: createdAtField() createdAt: createdAtField()
}, },
(m) => ({ (m) => ({

View File

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

View File

@ -1,4 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { zMeasurementSchema } from "./metrics";
// Define the schema for the "hour" object // Define the schema for the "hour" object
export const zHourSchema = z.object({ export const zHourSchema = z.object({
@ -14,6 +15,7 @@ export const zHourSchema = z.object({
comment: z.string().nullish(), comment: z.string().nullish(),
background: z.string().nullish(), background: z.string().nullish(),
foreground: z.string().nullish(), foreground: z.string().nullish(),
measurements: z.array(zMeasurementSchema).optional(),
}); });
export type ZHour = z.infer<typeof zHourSchema>; export type ZHour = z.infer<typeof zHourSchema>;

View File

@ -5,7 +5,8 @@ export const zMeasurementSchema = z.object({
metricId: z.string(), metricId: z.string(),
hourId: z.string().optional(), hourId: z.string().optional(),
dayId: z.string().optional(), dayId: z.string().optional(),
value: z.number(), value: z.coerce.number(),
icon: z.string().optional(),
}); });
export type ZMeasurement = z.infer<typeof zMeasurementSchema>; export type ZMeasurement = z.infer<typeof zMeasurementSchema>;
@ -15,6 +16,7 @@ export const zMetricSchema = z.object({
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"]),
icon: z.string(),
measurements: z.array(zMeasurementSchema).optional(), measurements: z.array(zMeasurementSchema).optional(),
}); });
export type ZMetric = z.infer<typeof zMetricSchema>; export type ZMetric = z.infer<typeof zMetricSchema>;

View File

@ -8,6 +8,7 @@ import { colorsAppRouter } from "./colors";
import { daysAppRouter } from "./days"; import { daysAppRouter } from "./days";
import { hoursAppRouter } from "./hours"; import { hoursAppRouter } from "./hours";
import { metricsAppRouter } from "./metrics"; import { metricsAppRouter } from "./metrics";
import { measurementsAppRouter } from "./measurements";
export const appRouter = router({ export const appRouter = router({
users: usersAppRouter, users: usersAppRouter,
@ -18,6 +19,7 @@ export const appRouter = router({
colors: colorsAppRouter, colors: colorsAppRouter,
categories: categoriesAppRouter, categories: categoriesAppRouter,
metrics: metricsAppRouter, metrics: metricsAppRouter,
measurements: measurementsAppRouter,
}); });
// export type definition of API // export type definition of API
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;

View File

@ -3,7 +3,7 @@ import { and, desc, eq, inArray, notExists } from "drizzle-orm";
import { date, z } from "zod"; import { date, z } from "zod";
import { SqliteError } from "@lifetracker/db"; import { SqliteError } from "@lifetracker/db";
import { categories, days, hours, colors } from "@lifetracker/db/schema"; import { categories, days, hours, colors, measurements, metrics } from "@lifetracker/db/schema";
import { import {
zDaySchema, ZDay, ZHour, zHourSchema zDaySchema, ZDay, ZHour, zHourSchema
} from "@lifetracker/shared/types/days"; } from "@lifetracker/shared/types/days";
@ -60,7 +60,18 @@ export async function hourJoinsQuery(
eq(hours.dayId, dayId) eq(hours.dayId, dayId)
)); ));
const hourMeasurements = await ctx.db.select({
id: measurements.id,
metricId: measurements.metricId,
value: measurements.value,
icon: metrics.icon,
})
.from(measurements)
.leftJoin(metrics, eq(metrics.id, measurements.metricId))
.where(eq(measurements.hourId, hourMatch[0].id));
const dayHour = { const dayHour = {
measurements: hourMeasurements,
...hourMatch[0], ...hourMatch[0],
...(await hourColors(hourMatch[0], ctx)), ...(await hourColors(hourMatch[0], ctx)),
}; };

View File

@ -0,0 +1,103 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq, inArray, notExists } from "drizzle-orm";
import { z, ZodNull } from "zod";
import { SqliteError } from "@lifetracker/db";
import { colors, metrics, measurements, hours } from "@lifetracker/db/schema";
import { zMetricSchema, zMeasurementSchema } from "@lifetracker/shared/types/metrics";
import type { Context } from "../index";
import { authedProcedure, router } from "../index";
import { zColorSchema } from "@lifetracker/shared/types/colors";
export const measurementsAppRouter = router({
list: authedProcedure
.input(z.object({ hourId: z.string() }))
.output(z.array(zMeasurementSchema))
.query(async ({ input, ctx }) => {
const dbMeasurements = await ctx.db
.select()
.from(measurements)
.leftJoin(hours, eq(measurements.hourId, hours.id))
.where(and(eq(measurements.userId, ctx.user.id),
eq(hours.id, input.hourId))
);
return dbMeasurements;
}),
incrementCount: authedProcedure
.input(z.object({ metricId: z.string(), hourId: z.string(), dayId: z.string() }))
.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, increment it, if not, create it with value 1
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: (parseInt(existingMeasurement[0].value) + 1).toString(),
}).where(eq(measurements.id, existingMeasurement[0].id)).returning();
return {
...updatedMeasurement[0],
icon: metric[0].icon,
};
} else {
const newMeasurement = await ctx.db.insert(measurements).values({
metricId: input.metricId,
hourId: input.hourId,
dayId: input.dayId,
value: 1,
userId: ctx.user.id,
}).returning();
return {
...newMeasurement[0],
icon: metric[0].icon,
};
}
}),
decrementCount: authedProcedure
.input(z.object({ metricId: z.string(), hourId: z.string() }))
.output(zMeasurementSchema.optional())
.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, decrement it, if not, throw an error
const existingMeasurement = await ctx.db.select().from(measurements).where(and(
eq(measurements.metricId, input.metricId),
eq(measurements.hourId, input.hourId),
));
if (existingMeasurement[0]) {
if (parseInt(existingMeasurement[0].value) > 1) {
const updatedMeasurement = await ctx.db.update(measurements).set({
value: (parseInt(existingMeasurement[0].value) - 1).toString(),
}).where(eq(measurements.id, existingMeasurement[0].id)).returning();
return {
...updatedMeasurement[0],
icon: metric[0].icon,
};
} else {
// Delete the measurement if it's zerooo
await ctx.db.delete(measurements).where(eq(measurements.id, existingMeasurement[0].id));
}
} else {
throw new TRPCError({
code: "NOT_FOUND",
message: "Measurement not found",
});
}
}),
});

View File

@ -18,8 +18,6 @@ export const metricsAppRouter = router({
.from(metrics) .from(metrics)
.where(eq(metrics.userId, ctx.user.id)); .where(eq(metrics.userId, ctx.user.id));
console.log(dbMeasurements);
return dbMeasurements; return dbMeasurements;
}), }),
create: authedProcedure create: authedProcedure
@ -36,6 +34,7 @@ export const metricsAppRouter = router({
unit: input.unit ?? null, unit: input.unit ?? null,
type: input.type, type: input.type,
description: input.description ?? null, description: input.description ?? null,
icon: input.icon,
}) })
.returning(); .returning();
return result[0]; return result[0];
@ -68,6 +67,7 @@ export const metricsAppRouter = router({
unit: input.unit ?? null, unit: input.unit ?? null,
type: input.type, type: input.type,
description: input.description ?? null, description: input.description ?? null,
icon: input.icon,
}) })
.where(eq(metrics.id, input.id)) .where(eq(metrics.id, input.id))
.returning(); .returning();
@ -80,5 +80,20 @@ export const metricsAppRouter = router({
} }
}); });
}), }),
delete: authedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
return ctx.db.transaction(async (trx) => {
try {
const result = await trx
.delete(metrics)
.where(and(eq(metrics.id, input.id), eq(metrics.userId, ctx.user!.id)))
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
});
}),
}); });

0
scripts/~~ Normal file
View File