lifetracker/packages/trpc/routers/hours.ts
2025-02-01 16:17:44 -08:00

282 lines
9.3 KiB
TypeScript

import { TRPCError } from "@trpc/server";
import { and, desc, eq, inArray, notExists } from "drizzle-orm";
import { z } from "zod";
import { DatabaseError } from "@lifetracker/db";
import { categories, days, hours, colors, measurements, metrics } from "@lifetracker/db/schema";
import {
ZDay, ZHour, zHourSchema
} from "@lifetracker/shared/types/days";
import type { Context } from "../index";
import { authedProcedure, router } from "../index";
import spacetime from "spacetime";
import { hoursListInUTC } from "@lifetracker/shared/utils/days";
import { createDay, listOfDates } from "./days";
export async function createHour(day: { id: string }, datetime: Date, ctx: Context,) {
const newHour = (await ctx.db.insert(hours).values({
dayId: day.id,
datetime: datetime,
userId: ctx.user!.id,
}).returning());
return newHour[0];
}
export async function hourColors(hour: ZHour, ctx: Context) {
const categoryColor = await ctx.db.select()
.from(colors)
.leftJoin(categories, eq(categories.id, hour.categoryId))
.where(and(
eq(colors.id, categories.colorId),
eq(colors.userId, ctx.user!.id)
))
if (!categoryColor[0]) {
return {
background: "inherit",
foreground: "inherit"
}
}
else {
return {
background: categoryColor[0].color.hexcode,
foreground: categoryColor[0].color.inverse,
}
}
}
export async function hourJoinsQuery(
ctx: Context,
datetime: Date,
dayId?: string,
day?: ZDay,
) {
if (!dayId && !day?.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "dayId string or Day object with an ID is required"
});
}
const id = dayId! ?? day!.id;
const res = await ctx.db.select({
id: hours.id,
dayId: hours.dayId,
datetime: hours.datetime,
categoryId: hours.categoryId,
categoryCode: categories.code,
categoryName: categories.name,
categoryDesc: categories.description,
comment: hours.comment,
date: days.date,
}).from(hours)
.leftJoin(categories, eq(categories.id, hours.categoryId))
.leftJoin(days, eq(days.id, hours.dayId))
.where(and(
eq(hours.datetime, datetime),
eq(hours.dayId, id)
));
const hourMatch = res[0] ?? createHour({ id }, datetime, ctx);
const hourMeasurements = await ctx.db.select({
id: measurements.id,
metricId: measurements.metricId,
value: measurements.value,
unit: metrics.unit,
icon: metrics.icon,
metricName: metrics.name,
metricType: metrics.type,
})
.from(measurements)
.leftJoin(metrics, eq(metrics.id, measurements.metricId))
.where(eq(measurements.hourId, hourMatch.id));
const dayHour = {
measurements: hourMeasurements,
...hourMatch,
...(await hourColors(hourMatch, ctx)),
};
return dayHour;
};
export async function getHours(ctx: Context, input: { dateQuery: string | [Date, Date], timezone: string }) {
const dateRange = Array.isArray(input.dateQuery) ? listOfDates(input.dateQuery) : [input.dateQuery];
const utcHours = dateRange.map(date => { return hoursListInUTC({ dateQuery: date, timezone: input.timezone }) });
// Dedup utcHours
const uniqueHours = [...new Set(utcHours.flat().map((x) => x))];
// Flatten the list of hours to the unique days
const uniqueDays = [...new Set(uniqueHours.map(({ date: date, time: _time }) => date))];
// ...and get their IDs
const uniqueDayIds = (await Promise.all(uniqueDays.map(async function (date) {
const dayObj = await ctx.db.select({
id: days.id,
date: days.date,
})
.from(days)
.where(eq(days.date, date));
return dayObj[0] ?? createDay(date, ctx);
}))
);
// Finally, use the two unique day IDs and the 24 hours to get the actual Hour objects for each day
const dayHours = await Promise.all(uniqueHours.map(async function (map: { date: string, time: number }, i) {
const dayId = uniqueDayIds.find((dayIds: { id: string, date: string }) => map.date == dayIds.date)!.id;
const datetime = spacetime(map.date, "Etc/UTC").hour(map.time).toNativeDate();
return hourJoinsQuery(ctx, datetime, dayId);
}));
return dayHours;
}
export const hoursAppRouter = router({
get: authedProcedure
.input(z.object({
dateQuery: z.string(),
}))
.output(zHourSchema)
.query(async ({ input, ctx }) => {
const hourRes = await ctx.db.select().from(hours)
.leftJoin(days, eq(days.id, hours.dayId))
.where(
and(
eq(hours.datetime, spacetime(input.dateQuery, "Etc/UTC").toNativeDate()),
eq(hours.userId, ctx.user!.id)
)
);
return hourRes[0].hour;
}),
update: authedProcedure
.input(
z.object({
date: z.string(),
datetime: z.date(),
dayId: z.string(),
code: z.string().nullish(),
comment: z.string().nullable().optional(),
}),
)
.output(zHourSchema)
.mutation(async ({ input, ctx }) => {
const { code, ...updatedProps } = input;
let dateCondition;
if (input.dayId) {
dateCondition = eq(days.id, input.dayId)
}
else {
throw new TRPCError({ code: "BAD_REQUEST", message: "dayId is required" });
}
const category =
code == "" ? [{
id: null,
name: null
}]
: await ctx.db.select(
{
id: categories.id,
name: categories.name,
description: categories.description
}
)
.from(categories)
.where(
and(
eq(categories.code, code),
eq(categories.userId, ctx.user!.id),
)
);
const newProps = {
categoryId: category[0].id,
code: code,
...updatedProps
};
if (newProps.comment == "") { newProps.comment = null }
const hourRes = await ctx.db
.update(hours)
.set(newProps)
.where(
and(
eq(hours.datetime, input.datetime),
eq(hours.dayId, input.dayId),
eq(hours.userId, ctx.user!.id)
)
).returning();
// return {
// date: input.date,
// ...hourRes[0]
// };
return hourJoinsQuery(ctx, input.datetime, input.dayId,);
}),
categoryFrequencies: authedProcedure
.input(z.object({
dateRange: z.tuple([z.date(), z.date()]),
timezone: z.string()
}))
.output(z.array(z.object(
{
count: z.number(),
percentage: z.number(),
color: z.string(),
inverse: z.string(),
...zHourSchema.shape
}
)))
.query(async ({ input, ctx }) => {
// console.log(input.dateRange, input.timezone);
const hoursList = await getHours(ctx, {
dateQuery: input.dateRange,
timezone: input.timezone
});
// console.log(hoursList.map(h => {
// return {
// datetime: spacetime(h.datetime).goto("America/Los_Angeles").format("{iso-short} {hour-24}:{minute}"),
// id: h.id,
// date: h.date,
// };
// }));
// Count total hours in the filtered range
const totalHours = hoursList.length;
// Group hours by category and count occurrences
const categoriesList: { [key: string]: any } = {};
hoursList.forEach(h => {
if (!categoriesList[h.categoryCode]) {
categoriesList[h.categoryCode] = {
count: 0,
color: h.background,
inverse: h.foreground,
...h
};
}
const old = (categoriesList[h.categoryCode].count);
categoriesList[h.categoryCode].count = old + 1;
});
// Calculate percentages
const categoryPercentages: any = Object.keys(categoriesList).map(categoryCode => {
const count = categoriesList[categoryCode].count;
const percentage = (count / totalHours);
return {
...categoriesList[categoryCode],
percentage: percentage,
};
});
return categoryPercentages;
}),
});