lifetracker/packages/trpc/routers/hours.ts

290 lines
9.5 KiB
TypeScript

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 { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { zCategorySchema } from "@lifetracker/shared/types/categories";
import spacetime from "spacetime";
import { dateFromInput, hoursListInUTC } from "@lifetracker/shared/utils/days";
import { createDay, getDay } from "./days";
export async function createHour(day: ZDay | { id: string }, time: number, ctx: Context,) {
const newHour = (await ctx.db.insert(hours).values({
dayId: day.id!,
time: time,
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,
time: number,
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,
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, id)
));
const hourMatch = res[0] ?? createHour({ id: id }, time, 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;
return hourJoinsQuery(ctx, map.time, dayId);
}));
return dayHours;
}
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 ? [format(parseISO(start), "yyyy-MM-dd")] : 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.hourTime, 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 }) => {
const hoursList = await getHours(ctx, {
dateQuery: input.dateRange,
timezone: input.timezone
});
// 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;
}),
});