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(), ...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, ...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; }), });