import { experimental_trpcMiddleware, TRPCError } from "@trpc/server"; import { and, desc, eq, inArray, notExists } from "drizzle-orm"; import { date, z } from "zod"; import { SqliteError } from "@lifetracker/db"; import { categories, days, hours, colors, measurements, metrics } from "@lifetracker/db/schema"; import { zDaySchema, ZDay, ZHour, zHourSchema } from "@lifetracker/shared/types/days"; import type { Context } from "../index"; import { authedProcedure, router } from "../index"; import { addDays, format, parseISO } from "date-fns"; import { TZDate } from "@date-fns/tz"; import { dateFromInput } from "@lifetracker/shared/utils/days"; import { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; import { zCategorySchema } from "@lifetracker/shared/types/categories"; import spacetime from "spacetime"; 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, dayId: string, time: number, ) { const hourMatch = await ctx.db.select({ id: hours.id, dayId: hours.dayId, time: hours.time, 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.time, time), eq(hours.dayId, dayId) )); 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[0].id)); const dayHour = { measurements: hourMeasurements, ...hourMatch[0], ...(await hourColors(hourMatch[0], ctx)), }; return dayHour; }; function listOfDates(dateRange: [Date, Date]) { const [start, end] = dateRange.map((date) => dateFromInput({ dateQuery: spacetime(date, "UTC").goto("UTC").format("iso-short"), timezone: "Etc/UTC" })); const dates = []; let currentDate = parseISO(start); while (currentDate <= parseISO(end)) { dates.push(format(currentDate, "yyyy-MM-dd")); currentDate = addDays(currentDate, 1); } return dates.length === 0 ? parseISO(start) : dates; } export const hoursAppRouter = router({ get: authedProcedure .input(z.object({ dateQuery: z.string(), time: z.number() })) .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.time, input.time), eq(days.date, input.dateQuery), eq(hours.userId, ctx.user!.id) ) ); return hourRes[0].hour; }), update: authedProcedure .input( z.object({ date: z.string(), hourTime: z.number(), 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.time, input.hourTime), eq(hours.dayId, input.dayId), eq(hours.userId, ctx.user!.id) ) ).returning(); // return { // date: input.date, // ...hourRes[0] // }; return hourJoinsQuery(ctx, input.dayId, input.hourTime); }), categoryFrequencies: authedProcedure .input(z.object({ dateRange: z.tuple([z.date(), z.date()]) })) .output(z.array(z.object( { count: z.number(), date: z.string(), time: z.number(), categoryName: z.string(), categoryCode: z.number(), categoryDescription: z.string(), categoryColor: z.string(), categoryForeground: z.string(), percentage: z.number() } ))) .query(async ({ input, ctx }) => { const hoursList = (await ctx.db.select({ date: days.date, time: hours.time, categoryName: categories.name, categoryCode: categories.code, categoryDescription: categories.description, categoryColor: colors.hexcode, categoryForeground: colors.inverse, }) .from(hours) .leftJoin(days, eq(days.id, hours.dayId)) .leftJoin(categories, eq(categories.id, hours.categoryId)) .leftJoin(colors, eq(colors.id, categories.colorId)) .where(and( eq(hours.userId, ctx.user!.id), inArray(days.date, listOfDates(input.dateRange)) ))).filter(h => h.categoryCode != null); // Count total hours in the filtered range const totalHours = hoursList.length; console.log(hoursList); // Group hours by category and count occurrences const categoriesList = {}; 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; }), });