Create metrics and measurements.

This commit is contained in:
Ryan Pandya 2024-12-06 14:36:52 -08:00
parent cd0ac2bf43
commit 720282c817
19 changed files with 1690 additions and 13 deletions

View File

@ -1 +1 @@
flake-profile-1-link
flake-profile-2-link

View File

@ -1 +0,0 @@
/nix/store/pd0dcksai0adl5a0xpprm2wb02dy6jry-nix-shell-env

View File

@ -0,0 +1 @@
/nix/store/w46qqqb484a76v5k0lzv524yp9s89vly-nix-shell-env

View File

@ -0,0 +1,9 @@
import MetricsView from "@/components/dashboard/categories/MetricsView";
export default async function MetricsPage() {
return (
<>
<MetricsView />
</>
);
}

View File

@ -0,0 +1,198 @@
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 { Textarea } from "@/components/ui/textarea";
import { zMetricSchema } from "@lifetracker/shared/types/metrics";
type CreateMetricSchema = z.infer<typeof zMetricSchema>;
export default function AddMetricDialog({
initialMetric = undefined,
children,
}: {
initialMetric?: CreateMetricSchema;
children?: React.ReactNode;
}) {
const apiUtils = api.useUtils();
const [isOpen, onOpenChange] = useState(false);
const form = useForm<CreateMetricSchema>({
resolver: zodResolver(zMetricSchema),
defaultValues: initialMetric
});
const handleSuccess = (message: string) => {
toast({
description: message,
});
apiUtils.metrics.list.invalidate();
onOpenChange(false);
};
const handleError = (error: any, defaultMessage: string) => {
if (error instanceof TRPCClientError) {
toast({
variant: "destructive",
description: error.message,
});
} else {
toast({
variant: "destructive",
description: defaultMessage,
});
}
};
const { mutate, isPending } = initialMetric
? api.metrics.update.useMutation({
onSuccess: () => handleSuccess("Metric updated successfully"),
onError: (error) => handleError(error, "Failed to update metric"),
})
: api.metrics.create.useMutation({
onSuccess: () => handleSuccess("New metric created successfully"),
onError: (error) => handleError(error, "Failed to create metric"),
});
useEffect(() => {
if (!isOpen) {
form.reset();
}
}, [isOpen, form]);
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Track a New Metric</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit((val) => mutate(val))}>
<div className="flex w-full flex-col space-y-2">
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "10px" }}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="text"
placeholder="Name"
{...field}
className="w-full rounded border p-2"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="unit"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
type="text"
placeholder="Unit"
{...field}
className="w-full rounded border p-2"
disabled={(form.watch("type") === "count")}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Select field with "timeseries" and "count" */}
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<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>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
{/* <FormLabel>Description</FormLabel> */}
<FormControl>
<Textarea
placeholder="Description"
{...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}
>
{initialMetric ? "Update" : "Create"}
</ActionButton>
</DialogFooter>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,183 @@
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

@ -0,0 +1,86 @@
"use client";
import { ActionButtonWithTooltip } from "@/components/ui/action-button";
import { ButtonWithTooltip } from "@/components/ui/button";
import LoadingSpinner from "@/components/ui/spinner";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/lib/trpc";
import { Pencil, Trash, FilePlus, Palette } from "lucide-react";
import { useSession } from "next-auth/react";
import AddMetricDialog from "./AddMetricDialog";
import EditMetricDialog from "./EditMetricDialog";
import Link from "next/link";
import { ZMetric } from "@lifetracker/shared/types/metrics";
export default function MetricsView() {
const { data: metrics } = api.metrics.list.useQuery();
const invalidateMetricsList = api.useUtils().metrics.list.invalidate;
const MetricsTable = ({ metrics }: { metrics: ZMetric[] }) => (
<Table>
<TableHeader className="bg-gray-200">
<TableHead>Name</TableHead>
<TableHead>Unit</TableHead>
<TableHead>Type</TableHead>
<TableHead>Description</TableHead>
<TableHead>Actions</TableHead>
</TableHeader>
<TableBody>
{metrics.map((m) => (
<TableRow key={m.id} style={{ borderBottom: 0 }}>
<TableCell className="py-1">{m.name}</TableCell>
<TableCell className="py-1">{m.unit}</TableCell>
<TableCell className="py-1">{m.type}</TableCell>
<TableCell className="py-1">{m.description}</TableCell>
<TableCell className="flex gap-1 py-1">
<ActionButtonWithTooltip
tooltip="Delete category"
variant="outline"
onClick={() => console.log({ metricId: m.id })}
loading={false}
>
<Trash size={16} color="red" />
</ActionButtonWithTooltip>
<AddMetricDialog initialMetric={m} >
<ButtonWithTooltip
tooltip="Edit"
variant="outline"
>
<Pencil size={16} color="red" />
</ButtonWithTooltip>
</AddMetricDialog>
</TableCell>
</TableRow>
))}
</TableBody>
</Table >
);
return (
<>
<div className="mb-2 flex items-center justify-between text-xl font-medium">
<span>All Metrics</span>
<div className="flex">
<AddMetricDialog>
<ButtonWithTooltip tooltip="Start tracking something..." variant="outline">
<FilePlus size={16} />
</ButtonWithTooltip>
</AddMetricDialog>
</div></div>
{metrics === undefined ? (
<LoadingSpinner />
) : (
<MetricsTable metrics={metrics} />
)}
</>
);
}

View File

@ -0,0 +1,17 @@
'use client';
import { ActionButtonWithTooltip } from "@/components/ui/action-button";
import { ButtonWithTooltip } from "@/components/ui/button";
import LoadingSpinner from "@/components/ui/spinner";
import { ZDay } from "@lifetracker/shared/types/days";
import { useState } from "react";
export function DayMetrics({
day: initialDay,
}) {
const [day, setDay] = useState(initialDay);
return (
<LoadingSpinner />
);
};

View File

@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { ArrowLeftSquare, ArrowRightSquare } from "lucide-react";
import spacetime from "spacetime";
import EditableHour from "@/components/dashboard/hours/EditableHour";
import { DayMetrics } from "./DayMetrics";
export default async function DayView({
day,
@ -63,17 +64,23 @@ export default async function DayView({
</div>
<Separator />
<div className="pl-4">
<EditableDayComment day={day}
className="text-xl"
/>
<div className="flex justify-between pr-4 flex-col gap-4 md:flex-row">
<div className="pl-4">
<EditableDayComment day={day}
className="text-xl"
/>
</div>
<div className="pl-4">
<DayMetrics day={day} />
</div>
</div>
<Separator />
<ul>
<li>
{/* <div className={cn(
{/*
TODO Possibly refactor with Table?
<div className={cn(
"p-4 grid justify-between",
)}
style={{

View File

@ -3,7 +3,7 @@ import SidebarItem from "@/components/shared/sidebar/SidebarItem";
import { Separator } from "@/components/ui/separator";
import { api } from "@/server/api/client";
import { getServerAuthSession } from "@/server/auth";
import { Archive, Calendar, CheckCheck, Home, Search, Tag } from "lucide-react";
import { Archive, Calendar, CheckCheck, Gauge, Home, LineChart, Ruler, Search, Tag } from "lucide-react";
import serverConfig from "@lifetracker/shared/config";
import AllLists from "./AllLists";
@ -44,9 +44,9 @@ export default async function Sidebar() {
},
...searchItem,
{
name: "Habits",
icon: <CheckCheck size={18} />,
path: "/dashboard/habits",
name: "Metrics",
icon: <Ruler size={18} />,
path: "/dashboard/metrics",
},
{
name: "Categories",

View File

@ -22,6 +22,8 @@
emacs-nox
nodejs
pnpm
sqlite
] ++ optional stdenv.isLinux inotify-tools
++ optional stdenv.isDarwin terminal-notifier
++ optionals stdenv.isDarwin (with darwin.apple_sdk.frameworks; [

View File

@ -0,0 +1,28 @@
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

@ -0,0 +1,955 @@
{
"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

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

View File

@ -122,6 +122,45 @@ export const verificationTokens = sqliteTable(
}),
);
export const metrics = sqliteTable("metric", {
id: text("id")
.notNull()
.primaryKey()
.$defaultFn(() => createId()),
name: text("name").notNull(),
description: text("description"),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
unit: text("unit"),
goal: real("goal"),
createdAt: createdAtField()
},
(m) => ({
uniq: unique().on(m.userId, m.name)
}),
);
export const measurements = sqliteTable("measurement", {
id: text("id")
.notNull()
.primaryKey()
.$defaultFn(() => createId()),
hourId: text("hourId").references(() => hours.id),
dayId: text("dayId").notNull().references(() => days.id, { onDelete: "cascade" }),
metricId: text("metricId").notNull().references(() => metrics.id, { onDelete: "cascade" }),
createdAt: createdAtField(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
value: text("value").notNull(),
},
(m) => ({
uniq: unique().on(m.dayId, m.metricId)
}),
);
export const days = sqliteTable("day", {
id: text("id")
.notNull()
@ -219,9 +258,11 @@ export const userRelations = relations(users, ({ many }) => ({
colors: many(colors),
days: many(days),
hours: many(hours),
metrics: many(metrics),
}));
export const colorsRelations = relations(
colors,
({ many, one }) => ({
@ -260,6 +301,7 @@ export const daysRelations = relations(
references: [users.id],
}),
hours: many(hours),
measurements: many(measurements),
}),
);
@ -279,5 +321,35 @@ export const hoursRelations = relations(
fields: [hours.dayId],
references: [days.id],
}),
measurements: many(measurements),
}),
);
export const metricsRelations = relations(
metrics,
({ many, one }) => ({
user: one(users, {
fields: [metrics.userId],
references: [users.id],
}),
measurements: many(measurements),
}),
);
export const measurementsRelations = relations(
measurements,
({ one }) => ({
metric: one(metrics, {
fields: [measurements.metricId],
references: [metrics.id],
}),
day: one(days, {
fields: [measurements.dayId],
references: [days.id],
}),
hour: measurements.hourId ? one(hours, {
fields: [measurements.hourId],
references: [hours.id],
}) : undefined,
}),
);

View File

@ -0,0 +1,20 @@
import { z } from "zod";
export const zMeasurementSchema = z.object({
id: z.string().optional(),
metricId: z.string(),
hourId: z.string().optional(),
dayId: z.string().optional(),
value: z.number(),
});
export type ZMeasurement = z.infer<typeof zMeasurementSchema>;
export const zMetricSchema = z.object({
id: z.string().optional(),
name: z.string(),
description: z.string().optional(),
unit: z.string().nullish(),
type: z.enum(["timeseries", "count"]),
measurements: z.array(zMeasurementSchema).optional(),
});
export type ZMetric = z.infer<typeof zMetricSchema>;

View File

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

View File

@ -0,0 +1,84 @@
import { TRPCError } from "@trpc/server";
import { and, desc, eq, inArray, notExists } from "drizzle-orm";
import { z } from "zod";
import { SqliteError } from "@lifetracker/db";
import { colors, metrics } 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 metricsAppRouter = router({
list: authedProcedure
.output(z.array(zMetricSchema))
.query(async ({ ctx }) => {
const dbMeasurements = await ctx.db
.select()
.from(metrics)
.where(eq(metrics.userId, ctx.user.id));
console.log(dbMeasurements);
return dbMeasurements;
}),
create: authedProcedure
.input(zMetricSchema)
.output(zMetricSchema)
.mutation(async ({ input, ctx }) => {
return ctx.db.transaction(async (trx) => {
try {
const result = await trx
.insert(metrics)
.values({
name: input.name,
userId: ctx.user!.id,
unit: input.unit ?? null,
type: input.type,
description: input.description ?? null,
})
.returning();
return result[0];
} catch (e) {
if (e instanceof SqliteError) {
if (e.code == "SQLITE_CONSTRAINT_UNIQUE") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This metric already exists",
});
}
}
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
});
}),
update: authedProcedure
.input(zMetricSchema)
.output(zMetricSchema)
.mutation(async ({ input, ctx }) => {
return ctx.db.transaction(async (trx) => {
try {
const result = await trx
.update(metrics)
.set({
name: input.name,
unit: input.unit ?? null,
type: input.type,
description: input.description ?? null,
})
.where(eq(metrics.id, input.id))
.returning();
return result[0];
} catch (e) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong",
});
}
});
}),
});

7
scripts/backup_days_hours.sh Executable file
View File

@ -0,0 +1,7 @@
#!/usr/bin/env bash
DB="/home/ryan/Notes/lifetracker/lifetracker.db"
sqlite3 "$DB" ".dump day" | tee "/home/ryan/tmp/day_backup-$(date +%s).sql"
sqlite3 "$DB" ".dump hour" | tee "/home/ryan/tmp/hour_backup-$(date +%s).sql"