278 lines
9.1 KiB
TypeScript
278 lines
9.1 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(),
|
|
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 }) => {
|
|
|
|
// 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;
|
|
}),
|
|
});
|