Compare commits

...

2 Commits

Author SHA1 Message Date
86c88307df I dont' want to commit this 2025-01-15 16:16:57 -08:00
bbe612ae43 Implement setting a measurement value by api POST 2025-01-15 16:14:59 -08:00
9 changed files with 161 additions and 25 deletions

View File

@ -0,0 +1,43 @@
import { NextRequest } from "next/server";
import { zMeasurementInputSchema } from "@lifetracker/shared/types/metrics";
import { buildHandler } from "../utils/handler";
import spacetime from "spacetime";
export const POST = (req: NextRequest) =>
buildHandler({
req,
bodySchema: zMeasurementInputSchema,
handler: async ({ api, body }) => {
const datetime = spacetime(
body?.dateTimeQuery || new Date(),
body?.timezone || "Etc/UTC"
).goto("Etc/UTC");
// const dayId = (await api.days.get({
// dateQuery: datetime.format("iso-short")
// })).id;
const hour = await api.hours.get({
dateQuery: datetime.format("iso-short"),
time: datetime.hour(),
});
const obj = {
metricName: body!.metricName,
hourId: hour.id!,
dayId: hour.dayId
};
const measurement =
body?.value
? await api.measurements.setValue({
...obj,
value: body.value!,
})
: await api.measurements.incrementCount(obj);
return { status: 201, resp: measurement };
},
});

View File

@ -0,0 +1,9 @@
import React from "react";
import AnalyticsView from "@/components/dashboard/analytics/AnalyticsView";
export default async function AnalyticsPage() {
return (
<AnalyticsView />
);
}

View File

@ -0,0 +1,33 @@
"use client";
import LoadingSpinner from "@/components/ui/spinner";
import { api } from "@/lib/trpc";
export default function AnalyticsView() {
const { data: metrics } = api.metrics.list.useQuery();
const drugsList = metrics?.filter((metric) => metric.type === "drug");
const timeSinceDrug = drugsList?.map((drug) => {
console.log(api.measurements.timeSinceLastMeasurement.useQuery({ metricId: drug.id! }));
return drug.name;
});
console.log(timeSinceDrug);
return (
<div className="flex gap-4">
<h1 className="font-bold text-xl">Drugs</h1>
<div>
{
!drugsList ? <LoadingSpinner /> :
<ul>
{drugsList.map((drug) => (
<li key={drug.id}>
{drug.name}:
</li>
))}
</ul>
}
</div>
</div>
);
}

View File

@ -12,7 +12,7 @@ export default async function MobileSidebar() {
/> />
<MobileSidebarItem logo={<Tag />} path="/dashboard/categories" /> <MobileSidebarItem logo={<Tag />} path="/dashboard/categories" />
<MobileSidebarItem logo={<CheckCheck />} path="/dashboard/metrics" /> <MobileSidebarItem logo={<CheckCheck />} path="/dashboard/metrics" />
<MobileSidebarItem logo={<GaugeCircleIcon />} path="/analytics" /> <MobileSidebarItem logo={<GaugeCircleIcon />} path="/dashboard/analytics" />
</ul> </ul>
</aside> </aside>
); );

View File

@ -3,7 +3,7 @@ import SidebarItem from "@/components/shared/sidebar/SidebarItem";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { api } from "@/server/api/client"; import { api } from "@/server/api/client";
import { getServerAuthSession } from "@/server/auth"; import { getServerAuthSession } from "@/server/auth";
import { Archive, ArrowRightFromLine, Calendar, CheckCheck, Gauge, Home, LineChart, PanelLeftOpen, Ruler, Search, SunMoon, Tag } from "lucide-react"; import { Archive, ArrowRightFromLine, Calendar, CheckCheck, Gauge, GaugeCircleIcon, Home, LineChart, PanelLeftOpen, Ruler, Search, SunMoon, Tag } from "lucide-react";
import serverConfig from "@lifetracker/shared/config"; import serverConfig from "@lifetracker/shared/config";
import AllLists from "./AllLists"; import AllLists from "./AllLists";
@ -43,6 +43,11 @@ export default async function Sidebar() {
icon: <Calendar size={18} />, icon: <Calendar size={18} />,
path: "/dashboard/timeline", path: "/dashboard/timeline",
}, },
{
name: "Analytics",
icon: <GaugeCircleIcon size={18} />,
path: "/dashboard/analytics",
},
...searchItem, ...searchItem,
{ {
name: "Metrics", name: "Metrics",

View File

@ -13,6 +13,13 @@ export const zMeasurementSchema = z.object({
}); });
export type ZMeasurement = z.infer<typeof zMeasurementSchema>; export type ZMeasurement = z.infer<typeof zMeasurementSchema>;
export const zMeasurementInputSchema = z.object({
metricName: z.string(),
dateTimeQuery: z.string().nullish(),
timezone: z.string().nullish(),
value: z.coerce.number().nullish(),
});
export const zMetricSchema = z.object({ export const zMetricSchema = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string(), name: z.string(),

View File

@ -92,15 +92,16 @@ export const hoursAppRouter = router({
})) }))
.output(zHourSchema) .output(zHourSchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const date = dateFromInput({ dateQuery: input.dateQuery }); const hourRes = await ctx.db.select().from(hours)
const hourRes = await getHourSelectQuery(ctx, date, input.time, .leftJoin(days, eq(days.id, hours.dayId))
and(eq(hours.time, input.time), eq(days.date, date)) .where(
and(
eq(hours.time, input.time),
eq(days.date, input.dateQuery),
eq(hours.userId, ctx.user!.id)
)
); );
return hourRes[0].hour;
return {
date: format(date, "yyyy-MM-dd"),
...hourRes[0]
};
}), }),
update: authedProcedure update: authedProcedure
.input( .input(

View File

@ -8,6 +8,23 @@ import { zMetricSchema, zMeasurementSchema } from "@lifetracker/shared/types/met
import type { Context } from "../index"; import type { Context } from "../index";
import { authedProcedure, router } from "../index"; import { authedProcedure, router } from "../index";
import { zColorSchema } from "@lifetracker/shared/types/colors"; import { zColorSchema } from "@lifetracker/shared/types/colors";
import { titleCase } from "title-case";
const getMetricFromInput = async (ctx: Context, input:
{ metricId?: string | null | undefined; metricName?: string | null | undefined; }) => {
const metric = input.metricName
? await ctx.db.select().from(metrics).where(eq(metrics.name, titleCase(input.metricName)))
: 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",
});
}
return metric[0];
}
export const measurementsAppRouter = router({ export const measurementsAppRouter = router({
list: authedProcedure list: authedProcedure
@ -25,19 +42,23 @@ export const measurementsAppRouter = router({
return dbMeasurements; return dbMeasurements;
}), }),
setValue: authedProcedure setValue: authedProcedure
.input(z.object({ metricId: z.string(), hourId: z.string(), dayId: z.string(), value: z.number() })) .input(z.object({
metricId: z.string().nullish(), metricName: z.string().nullish(),
hourId: z.string(), dayId: z.string(), value: z.number()
}))
.output(zMeasurementSchema) .output(zMeasurementSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const metric = await ctx.db.select().from(metrics).where(eq(metrics.id, input.metricId)); if (!input.metricId && !input.metricName) {
if (!metric[0]) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "BAD_REQUEST",
message: "Metric not found", message: "Metric name or id is required",
}); });
} }
const metric = await getMetricFromInput(ctx, input);
// Check if there is a measurement for this metric in this hour, if so, update it, if not, create it // Check if there is a measurement for this metric in this hour, if so, update it, if not, create it
const existingMeasurement = await ctx.db.select().from(measurements).where(and( const existingMeasurement = await ctx.db.select().from(measurements).where(and(
eq(measurements.metricId, input.metricId), eq(measurements.metricId, metric.id),
eq(measurements.hourId, input.hourId), eq(measurements.hourId, input.hourId),
)); ));
if (existingMeasurement[0]) { if (existingMeasurement[0]) {
@ -47,12 +68,12 @@ export const measurementsAppRouter = router({
return { return {
...updatedMeasurement[0], ...updatedMeasurement[0],
icon: metric[0].icon, icon: metric.icon,
metricName: metric[0].name, metricName: metric.name,
}; };
} else { } else {
const newMeasurement = await ctx.db.insert(measurements).values({ const newMeasurement = await ctx.db.insert(measurements).values({
metricId: input.metricId, metricId: metric.id,
hourId: input.hourId, hourId: input.hourId,
dayId: input.dayId, dayId: input.dayId,
value: input.value.toString(), value: input.value.toString(),
@ -60,16 +81,16 @@ export const measurementsAppRouter = router({
}).returning(); }).returning();
return { return {
...newMeasurement[0], ...newMeasurement[0],
icon: metric[0].icon, icon: metric.icon,
metricName: metric[0].name, metricName: metric.name,
}; };
} }
}), }),
incrementCount: authedProcedure incrementCount: authedProcedure
.input(z.object({ metricId: z.string(), hourId: z.string(), dayId: z.string() })) .input(z.object({ metricId: z.string().nullish(), metricName: z.string().nullish(), hourId: z.string(), dayId: z.string() }))
.output(zMeasurementSchema) .output(zMeasurementSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const metric = await ctx.db.select().from(metrics).where(eq(metrics.id, input.metricId)); const metric = await getMetricFromInput(ctx, input);
if (!metric[0]) { if (!metric[0]) {
throw new TRPCError({ throw new TRPCError({
code: "NOT_FOUND", code: "NOT_FOUND",
@ -144,4 +165,20 @@ export const measurementsAppRouter = router({
}); });
} }
}), }),
timeSinceLastMeasurement: authedProcedure
.input(z.object({ metricId: z.string() }))
.output(z.number())
.query(async ({ input, ctx }) => {
const lastMeasurement = await ctx.db.select().from(measurements).where(and(
eq(measurements.metricId, input.metricId),
eq(measurements.userId, ctx.user.id),
)).orderBy(desc(measurements.createdAt)).limit(1);
if (lastMeasurement[0]) {
const lastMeasurementTime = new Date(lastMeasurement[0].createdAt).getTime();
const currentTime = new Date().getTime();
return (currentTime - lastMeasurementTime) / 1000;
} else {
return 0;
}
}),
}); });

View File

@ -8,6 +8,7 @@ import { zMetricSchema, zMeasurementSchema } from "@lifetracker/shared/types/met
import type { Context } from "../index"; import type { Context } from "../index";
import { authedProcedure, router } from "../index"; import { authedProcedure, router } from "../index";
import { zColorSchema } from "@lifetracker/shared/types/colors"; import { zColorSchema } from "@lifetracker/shared/types/colors";
import { titleCase } from "title-case";
export const metricsAppRouter = router({ export const metricsAppRouter = router({
list: authedProcedure list: authedProcedure
@ -29,7 +30,7 @@ export const metricsAppRouter = router({
const result = await trx const result = await trx
.insert(metrics) .insert(metrics)
.values({ .values({
name: input.name, name: titleCase(input.name),
userId: ctx.user!.id, userId: ctx.user!.id,
unit: input.unit ?? null, unit: input.unit ?? null,
type: input.type, type: input.type,