diff --git a/.direnv/flake-profile b/.direnv/flake-profile index 0c05709..c7ae5b7 120000 --- a/.direnv/flake-profile +++ b/.direnv/flake-profile @@ -1 +1 @@ -flake-profile-1-link \ No newline at end of file +flake-profile-2-link \ No newline at end of file diff --git a/.direnv/flake-profile-1-link b/.direnv/flake-profile-1-link deleted file mode 120000 index c64a019..0000000 --- a/.direnv/flake-profile-1-link +++ /dev/null @@ -1 +0,0 @@ -/nix/store/pd0dcksai0adl5a0xpprm2wb02dy6jry-nix-shell-env \ No newline at end of file diff --git a/.direnv/flake-profile-2-link b/.direnv/flake-profile-2-link new file mode 120000 index 0000000..d6e9d0a --- /dev/null +++ b/.direnv/flake-profile-2-link @@ -0,0 +1 @@ +/nix/store/w46qqqb484a76v5k0lzv524yp9s89vly-nix-shell-env \ No newline at end of file diff --git a/apps/web/app/dashboard/metrics/page.tsx b/apps/web/app/dashboard/metrics/page.tsx new file mode 100644 index 0000000..fe20cf8 --- /dev/null +++ b/apps/web/app/dashboard/metrics/page.tsx @@ -0,0 +1,9 @@ +import MetricsView from "@/components/dashboard/categories/MetricsView"; + +export default async function MetricsPage() { + return ( + <> + + > + ); +} diff --git a/apps/web/components/dashboard/categories/AddMetricDialog.tsx b/apps/web/components/dashboard/categories/AddMetricDialog.tsx new file mode 100644 index 0000000..403aee9 --- /dev/null +++ b/apps/web/components/dashboard/categories/AddMetricDialog.tsx @@ -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; + +export default function AddMetricDialog({ + initialMetric = undefined, + children, +}: { + initialMetric?: CreateMetricSchema; + children?: React.ReactNode; +}) { + const apiUtils = api.useUtils(); + const [isOpen, onOpenChange] = useState(false); + const form = useForm({ + 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 ( + + {children} + + + Track a New Metric + + + mutate(val))}> + + + ( + + + + + + + )} + /> + + ( + + + + + + + )} + /> + {/* Select field with "timeseries" and "count" */} + + ( + + + + + + + + + Timeseries + Count + + + + + )} + /> + + + ( + + {/* Description */} + + + + + + )} + /> + + + + Close + + + + {initialMetric ? "Update" : "Create"} + + + + + + + + ); +} diff --git a/apps/web/components/dashboard/categories/EditMetricDialog.tsx b/apps/web/components/dashboard/categories/EditMetricDialog.tsx new file mode 100644 index 0000000..65aafe9 --- /dev/null +++ b/apps/web/components/dashboard/categories/EditMetricDialog.tsx @@ -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({ + 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 ( + + {children} + + + Edit Category + + + mutate(val))}> + + ( + + Name + + + + + + )} + /> + ( + + Code + + + + + + )} + /> + ( + + Description + + + + + + )} + /> + {/* ( + + Color + + + + + + )} + /> */} + + + + Close + + + + Update + + + + + + + + ); +} diff --git a/apps/web/components/dashboard/categories/MetricsView.tsx b/apps/web/components/dashboard/categories/MetricsView.tsx new file mode 100644 index 0000000..6eea8b5 --- /dev/null +++ b/apps/web/components/dashboard/categories/MetricsView.tsx @@ -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[] }) => ( + + + Name + Unit + Type + Description + Actions + + + {metrics.map((m) => ( + + {m.name} + {m.unit} + {m.type} + {m.description} + + console.log({ metricId: m.id })} + loading={false} + > + + + + + + + + + + ))} + + + ); + + return ( + <> + + All Metrics + + + + + + + + + {metrics === undefined ? ( + + ) : ( + + )} + > + ); +} diff --git a/apps/web/components/dashboard/days/DayMetrics.tsx b/apps/web/components/dashboard/days/DayMetrics.tsx new file mode 100644 index 0000000..9c02fa4 --- /dev/null +++ b/apps/web/components/dashboard/days/DayMetrics.tsx @@ -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 ( + + ); +}; diff --git a/apps/web/components/dashboard/days/DayView.tsx b/apps/web/components/dashboard/days/DayView.tsx index 4cb8959..bf60dad 100644 --- a/apps/web/components/dashboard/days/DayView.tsx +++ b/apps/web/components/dashboard/days/DayView.tsx @@ -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({ - - + + + + + + + - - {/* , - path: "/dashboard/habits", + name: "Metrics", + icon: , + path: "/dashboard/metrics", }, { name: "Categories", diff --git a/flake.nix b/flake.nix index 2928cb5..de616f8 100644 --- a/flake.nix +++ b/flake.nix @@ -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; [ diff --git a/packages/db/migrations/0001_chemical_toro.sql b/packages/db/migrations/0001_chemical_toro.sql new file mode 100644 index 0000000..7f96759 --- /dev/null +++ b/packages/db/migrations/0001_chemical_toro.sql @@ -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`); \ No newline at end of file diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000..d73a965 --- /dev/null +++ b/packages/db/migrations/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 6e5ab3c..f33c2e9 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1733261637429, "tag": "0000_sad_lionheart", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1733352349579, + "tag": "0001_chemical_toro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 815f7f4..23fe999 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -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), }), -); \ No newline at end of file +); + +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, + }), +); diff --git a/packages/shared/types/metrics.ts b/packages/shared/types/metrics.ts new file mode 100644 index 0000000..9d74f6a --- /dev/null +++ b/packages/shared/types/metrics.ts @@ -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; + +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; diff --git a/packages/trpc/routers/_app.ts b/packages/trpc/routers/_app.ts index b8c5832..8ec247f 100644 --- a/packages/trpc/routers/_app.ts +++ b/packages/trpc/routers/_app.ts @@ -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; \ No newline at end of file diff --git a/packages/trpc/routers/metrics.ts b/packages/trpc/routers/metrics.ts new file mode 100644 index 0000000..ad03dfe --- /dev/null +++ b/packages/trpc/routers/metrics.ts @@ -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", + }); + } + }); + }), + +}); \ No newline at end of file diff --git a/scripts/backup_days_hours.sh b/scripts/backup_days_hours.sh new file mode 100755 index 0000000..d69239c --- /dev/null +++ b/scripts/backup_days_hours.sh @@ -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" +