diff --git a/apps/web/app/dashboard/day/[dateQuery]/page.tsx b/apps/web/app/dashboard/day/[dateQuery]/page.tsx index a31f34e..bbd050e 100644 --- a/apps/web/app/dashboard/day/[dateQuery]/page.tsx +++ b/apps/web/app/dashboard/day/[dateQuery]/page.tsx @@ -4,6 +4,8 @@ import { TRPCError } from "@trpc/server"; import DayView from "@/components/dashboard/days/DayView"; import { getServerAuthSession } from "@/server/auth"; import LoadingSpinner from "@/components/ui/spinner"; +import { useTimezone } from "@/lib/userLocalSettings/client"; +import { getUserLocalSettings } from "@/lib/userLocalSettings/userLocalSettings"; export default async function DayPage({ params }: { params: { dateQuery: string }; }) { const session = await getServerAuthSession(); @@ -11,15 +13,12 @@ export default async function DayPage({ params }: { params: { dateQuery: string redirect("/"); } - let day; - const { dateQuery } = await params; - const timezone = await api.users.getTimezone(); try { day = await api.days.get({ - dateQuery: dateQuery, - timezone: timezone, + dateQuery: (await params).dateQuery, + timezone: (await getUserLocalSettings()).timezone, }); } catch (e) { if (e instanceof TRPCError) { @@ -31,7 +30,7 @@ export default async function DayPage({ params }: { params: { dateQuery: string } return ( -
+
{ day == undefined ? : diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 65d9c96..1d92260 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -20,6 +20,8 @@ import { getServerAuthSession } from "@/server/auth"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { clientConfig } from "@lifetracker/shared/config"; +import { useTimezone } from "@lifetracker/shared-react/hooks/timezones"; +import { getUserLocalSettings } from "@/lib/userLocalSettings/userLocalSettings"; const inter = Inter({ subsets: ["latin"], @@ -61,9 +63,7 @@ export default async function RootLayout({ session={session} clientConfig={clientConfig} userLocalSettings={ - parseUserLocalSettings( - (await cookies()).get(USER_LOCAL_SETTINGS_COOKIE_NAME)?.value - ) ?? defaultUserLocalSettings() + await getUserLocalSettings() } > {children} diff --git a/apps/web/components/dashboard/sidebar/TimezoneDisplay.tsx b/apps/web/components/TimezoneDisplay.tsx similarity index 89% rename from apps/web/components/dashboard/sidebar/TimezoneDisplay.tsx rename to apps/web/components/TimezoneDisplay.tsx index 6b22be3..4536466 100644 --- a/apps/web/components/dashboard/sidebar/TimezoneDisplay.tsx +++ b/apps/web/components/TimezoneDisplay.tsx @@ -4,27 +4,27 @@ import Link from "next/link"; import { format } from "date-fns"; import { TZDate } from "@date-fns/tz"; import { timezones } from '@lifetracker/shared/utils/timezones'; -import { useTimezone } from "@lifetracker/shared-react/hooks/timezones"; import LoadingSpinner from "@/components/ui/spinner"; import { db } from "@lifetracker/db"; import { Button } from "@/components/ui/button"; import spacetime from "spacetime"; +import { useTimezone } from "@/lib/userLocalSettings/client"; export default function TimezoneDisplay() { - const clientTime = spacetime.now(); + const timezone = useTimezone(); return (
- {clientTime.format('nice')} + {format(TZDate.tz(timezone), 'MMM dd, hh:mm aa')}
in  - {timezones[clientTime.timezone().name] || clientTime.timezone().name} + {timezones[timezone] || "Unknown Timezone"} diff --git a/apps/web/components/dashboard/ChangeLayout.tsx b/apps/web/components/dashboard/ChangeLayout.tsx index 6ec3824..e91ecc8 100644 --- a/apps/web/components/dashboard/ChangeLayout.tsx +++ b/apps/web/components/dashboard/ChangeLayout.tsx @@ -8,7 +8,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useBookmarkLayout } from "@/lib/userLocalSettings/bookmarksLayout"; +import { useBookmarkLayout } from "@/lib/userLocalSettings/client"; import { updateBookmarksLayout } from "@/lib/userLocalSettings/userLocalSettings"; import { Check, diff --git a/apps/web/components/dashboard/analytics/AnalyticsView.tsx b/apps/web/components/dashboard/analytics/AnalyticsView.tsx index 13a926b..b8e71b8 100644 --- a/apps/web/components/dashboard/analytics/AnalyticsView.tsx +++ b/apps/web/components/dashboard/analytics/AnalyticsView.tsx @@ -12,6 +12,8 @@ import { Anchor, Button, Menu } from "@mantine/core"; import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip'; import PieChart from "./PieChart"; +import { useTimezone } from "@/lib/userLocalSettings/client"; + const parseDateRangeFromQuery = (): Date[] | undefined => { const searchParams = useSearchParams(); if (!searchParams.has("dateRange")) return undefined; @@ -46,8 +48,8 @@ export default function AnalyticsView() { } return [ // spacetime.now().subtract(1, "week").toNativeDate(), - spacetime.now().toNativeDate(), - spacetime.now().toNativeDate() + spacetime.now(useTimezone()).toNativeDate(), + spacetime.now(useTimezone()).toNativeDate() ]; })(); @@ -70,14 +72,18 @@ export default function AnalyticsView() { const categoryFrequencies = api.hours.categoryFrequencies.useQuery({ dateRange, + timezone: useTimezone(), }).data ?? []; - console.log(categoryFrequencies); - return (
-

Analytics

+

Analytics for  + {dateRange[0].getUTCDate() == dateRange[1].getUTCDate() + ? spacetime(dateRange[0]).unixFmt("MMM dd, yyyy") + : dateRange.map((d) => spacetime(d).unixFmt("MMM dd, yyyy")).join(" to ") + } +

{/*
+
+
+ Total +
+
+ {categoryFrequencies.reduce((acc, category) => acc + category.count, 0)} hours +
{ categoryFrequencies .sort((a, b) => b.count - a.count) @@ -150,7 +167,7 @@ export default function AnalyticsView() { }} >
- {category.categoryName} + {category.categoryName ?? "[Unallocated]"}
{category.count} hours diff --git a/apps/web/components/dashboard/analytics/PieChart.tsx b/apps/web/components/dashboard/analytics/PieChart.tsx index 8d72761..02432c6 100644 --- a/apps/web/components/dashboard/analytics/PieChart.tsx +++ b/apps/web/components/dashboard/analytics/PieChart.tsx @@ -33,8 +33,6 @@ export default function PieChart({ const centerY = innerHeight / 2; const centerX = innerWidth / 2; - // console.log(data); - return data.length == 0 ? : ( @@ -55,7 +53,8 @@ export default function PieChart({ console.log('clicked: ', category.categoryName) } getColor={(arc) => { - return arc.data.categoryColor; + if (arc.data.background == "inherit") { return "color-mix(in srgb, hsl(var(--muted)) 95%, black 5%)" }; + return arc.data.background ?? 'transparent'; }} /> )} @@ -142,7 +141,7 @@ function AnimatedPie({ }); function getForeground(arc: PieArcDatum) { - return arc.data.categoryForeground; + return arc.data.foreground; } } diff --git a/apps/web/components/dashboard/header/Header.tsx b/apps/web/components/dashboard/header/Header.tsx index 786bc87..a5a38cd 100644 --- a/apps/web/components/dashboard/header/Header.tsx +++ b/apps/web/components/dashboard/header/Header.tsx @@ -4,6 +4,7 @@ import GlobalActions from "@/components/dashboard/GlobalActions"; import ProfileOptions from "@/components/dashboard/header/ProfileOptions"; import HoarderLogo from "@/components/HoarderIcon"; import { getServerAuthSession } from "@/server/auth"; +import TimezoneDisplay from "../../TimezoneDisplay"; export default async function Header() { const session = await getServerAuthSession(); @@ -19,7 +20,7 @@ export default async function Header() {
- +
diff --git a/apps/web/components/dashboard/hours/EditableHour.tsx b/apps/web/components/dashboard/hours/EditableHour.tsx index 4acbec8..c79ad6d 100644 --- a/apps/web/components/dashboard/hours/EditableHour.tsx +++ b/apps/web/components/dashboard/hours/EditableHour.tsx @@ -23,6 +23,8 @@ import { Badge } from "@/components/ui/badge"; import { Icon } from "@/components/ui/icon"; import { titleCase } from "title-case"; import { useDecrementCount } from "@lifetracker/shared-react/hooks/measurements"; +import { useTimezone } from "@/lib/userLocalSettings/client"; + export default function EditableHour({ hour: initialHour, @@ -97,7 +99,7 @@ export default function EditableHour({ }); - const tzOffset = spacetime().offset() / 60; + const tzOffset = spacetime.now(useTimezone()).offset() / 60; const localDateTime = (h: ZHour): string => { diff --git a/apps/web/components/dashboard/sidebar/Sidebar.tsx b/apps/web/components/dashboard/sidebar/Sidebar.tsx index 7ea8c73..0e0a18c 100644 --- a/apps/web/components/dashboard/sidebar/Sidebar.tsx +++ b/apps/web/components/dashboard/sidebar/Sidebar.tsx @@ -7,7 +7,7 @@ import { Archive, ArrowRightFromLine, Calendar, CheckCheck, Gauge, GaugeCircleIc import serverConfig from "@lifetracker/shared/config"; import AllLists from "./AllLists"; -import TimezoneDisplay from "./TimezoneDisplay"; +import TimezoneDisplay from "../../TimezoneDisplay"; import { ActionButtonWithTooltip } from "@/components/ui/action-button"; export default async function Sidebar() { @@ -76,8 +76,8 @@ export default async function Sidebar() {
-
- +
+ Server version {serverConfig.serverVersion}
} @@ -108,6 +108,6 @@ export default async function Sidebar() { return ( - + ); } diff --git a/apps/web/components/settings/AppSettings.tsx b/apps/web/components/settings/AppSettings.tsx index 947b01e..8330ade 100644 --- a/apps/web/components/settings/AppSettings.tsx +++ b/apps/web/components/settings/AppSettings.tsx @@ -16,6 +16,7 @@ export default async function AppSettings() {
Timezone
+ diff --git a/apps/web/components/settings/ChangeTimezone.tsx b/apps/web/components/settings/ChangeTimezone.tsx index dfec3d3..6282c06 100644 --- a/apps/web/components/settings/ChangeTimezone.tsx +++ b/apps/web/components/settings/ChangeTimezone.tsx @@ -4,11 +4,13 @@ import React, { useState, useEffect } from "react"; import { timezones } from "@lifetracker/shared/utils/timezones"; import TimezoneSelect, { type ITimezone } from 'react-timezone-select' import { useUpdateUserTimezone } from "@lifetracker/shared-react/hooks/timezones"; +import { updateTimezoneCookie } from "@/lib/userLocalSettings/userLocalSettings"; export default function ChangeTimezone({ userTimezone }) { const [selectedTimezone, setSelectedTimezone] = useState(userTimezone); const { mutate: updateUserTimezone, isPending } = useUpdateUserTimezone({ - onSuccess: () => { + onSuccess: (_data, { newTimezone }) => { + updateTimezoneCookie(newTimezone); toast({ description: "User DB Timezone updated!", }); diff --git a/apps/web/components/settings/sidebar/Sidebar.tsx b/apps/web/components/settings/sidebar/Sidebar.tsx index 16ed895..b95f523 100644 --- a/apps/web/components/settings/sidebar/Sidebar.tsx +++ b/apps/web/components/settings/sidebar/Sidebar.tsx @@ -5,7 +5,7 @@ import { getServerAuthSession } from "@/server/auth"; import serverConfig from "@lifetracker/shared/config"; import { settingsSidebarItems } from "./items"; -import TimezoneDisplay from "@/components/dashboard/sidebar/TimezoneDisplay"; +import TimezoneDisplay from "@/components/TimezoneDisplay"; export default async function Sidebar() { const session = await getServerAuthSession(); diff --git a/apps/web/lib/providers.tsx b/apps/web/lib/providers.tsx index 9e052f5..f2d9541 100644 --- a/apps/web/lib/providers.tsx +++ b/apps/web/lib/providers.tsx @@ -8,7 +8,7 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { DatesProvider } from "@mantine/dates"; -import { UserLocalSettingsCtx } from "@/lib/userLocalSettings/bookmarksLayout"; +import { UserLocalSettingsCtx } from "@/lib/userLocalSettings/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { httpBatchLink, loggerLink } from "@trpc/client"; import { SessionProvider } from "next-auth/react"; diff --git a/apps/web/lib/userLocalSettings/bookmarksLayout.tsx b/apps/web/lib/userLocalSettings/client.tsx similarity index 74% rename from apps/web/lib/userLocalSettings/bookmarksLayout.tsx rename to apps/web/lib/userLocalSettings/client.tsx index 424046b..f335eb4 100644 --- a/apps/web/lib/userLocalSettings/bookmarksLayout.tsx +++ b/apps/web/lib/userLocalSettings/client.tsx @@ -3,20 +3,27 @@ import type { z } from "zod"; import { createContext, useContext } from "react"; -import type { BookmarksLayoutTypes, zUserLocalSettings } from "./types"; +import type { BookmarksLayoutTypes, Timezones, zUserLocalSettings } from "./types"; const defaultLayout: BookmarksLayoutTypes = "masonry"; +const defaultTimezone: Timezones = "Etc/UTC"; export const UserLocalSettingsCtx = createContext< z.infer >({ bookmarkGridLayout: defaultLayout, + timezone: defaultTimezone, }); function useUserLocalSettings() { return useContext(UserLocalSettingsCtx); } +export function useTimezone() { + const settings = useUserLocalSettings(); + return settings.timezone; +} + export function useBookmarkLayout() { const settings = useUserLocalSettings(); return settings.bookmarkGridLayout; diff --git a/apps/web/lib/userLocalSettings/types.ts b/apps/web/lib/userLocalSettings/types.ts index 08e3863..cc16de4 100644 --- a/apps/web/lib/userLocalSettings/types.ts +++ b/apps/web/lib/userLocalSettings/types.ts @@ -1,12 +1,19 @@ import { z } from "zod"; +import { timezones } from "@lifetracker/shared/utils/timezones"; -export const USER_LOCAL_SETTINGS_COOKIE_NAME = "hoarder-user-local-settings"; +export const USER_LOCAL_SETTINGS_COOKIE_NAME = "lifetracker-user-local-settings"; const zBookmarkGridLayout = z.enum(["grid", "list", "masonry", "compact"]); export type BookmarksLayoutTypes = z.infer; +const zTimezone = z.enum( + ["", ...Object.keys(timezones)] as [string, ...string[]] +); +export type Timezones = z.infer; + export const zUserLocalSettings = z.object({ bookmarkGridLayout: zBookmarkGridLayout.optional().default("masonry"), + timezone: zTimezone.optional().default(""), }); export type UserLocalSettings = z.infer; diff --git a/apps/web/lib/userLocalSettings/userLocalSettings.ts b/apps/web/lib/userLocalSettings/userLocalSettings.ts index 826e6cf..8e6f083 100644 --- a/apps/web/lib/userLocalSettings/userLocalSettings.ts +++ b/apps/web/lib/userLocalSettings/userLocalSettings.ts @@ -1,9 +1,10 @@ "use server"; import { cookies } from "next/headers"; - -import type { BookmarksLayoutTypes } from "./types"; +import { getServerAuthSession } from "@/server/auth"; +import type { BookmarksLayoutTypes, Timezones } from "./types"; import { + defaultUserLocalSettings, parseUserLocalSettings, USER_LOCAL_SETTINGS_COOKIE_NAME, } from "./types"; @@ -18,3 +19,33 @@ export async function updateBookmarksLayout(layout: BookmarksLayoutTypes) { sameSite: "lax", }); } + +export async function updateTimezoneCookie(timezone: Timezones) { + const userSettings = (await cookies()).get(USER_LOCAL_SETTINGS_COOKIE_NAME); + const parsed = parseUserLocalSettings(userSettings?.value); + (await cookies()).set({ + name: USER_LOCAL_SETTINGS_COOKIE_NAME, + value: JSON.stringify({ ...parsed, timezone: timezone }), + maxAge: 34560000, // Chrome caps max age to 400 days + sameSite: "lax", + }); +} + +export async function getUserLocalSettings() { + const parsed = parseUserLocalSettings( + (await cookies()).get(USER_LOCAL_SETTINGS_COOKIE_NAME)?.value + ) + + const session = (await getServerAuthSession()); + const dbTimezone = session?.user?.timezone as string | undefined; + + // if (parsed?.timezone) { + // // console.log("Found timezone in cookie:", parsed.timezone); + // return parsed; + // } + + return { + bookmarkGridLayout: parsed?.bookmarkGridLayout ?? defaultUserLocalSettings().bookmarkGridLayout, + timezone: parsed!.timezone || dbTimezone || defaultUserLocalSettings().timezone, + } +} \ No newline at end of file diff --git a/apps/web/server/auth.ts b/apps/web/server/auth.ts index b8479a2..0a6e6c1 100644 --- a/apps/web/server/auth.ts +++ b/apps/web/server/auth.ts @@ -19,6 +19,7 @@ import { } from "@lifetracker/db/schema"; import serverConfig from "@lifetracker/shared/config"; import { logAuthenticationError, validatePassword } from "@lifetracker/trpc/auth"; +import { Timezones } from "@/lib/userLocalSettings/types"; type UserRole = "admin" | "user"; @@ -39,6 +40,7 @@ declare module "next-auth" { user: { id: string; role: UserRole; + timezone: Timezones | undefined; } & DefaultSession["user"]; } @@ -180,7 +182,11 @@ export const authOptions: NextAuthOptions = { return token; }, async session({ session, token }) { - session.user = { ...token.user }; + const userTimezone = await db.query.users.findFirst({ + columns: { timezone: true }, + where: eq(users.id, token.user.id), + }).then((user) => user?.timezone); + session.user = { ...token.user, timezone: userTimezone }; return session; }, }, diff --git a/packages/shared/types/days.ts b/packages/shared/types/days.ts index 129deca..4b41552 100644 --- a/packages/shared/types/days.ts +++ b/packages/shared/types/days.ts @@ -24,7 +24,7 @@ export const zDaySchema = z.object({ date: z.string(), mood: z.number().nullable(), comment: z.string().nullable(), - hours: z.array(zHourSchema), + hours: z.array(zHourSchema).optional(), }); export type ZDay = z.infer; diff --git a/packages/shared/utils/days.ts b/packages/shared/utils/days.ts index 60c7f8c..5dda8b0 100644 --- a/packages/shared/utils/days.ts +++ b/packages/shared/utils/days.ts @@ -38,10 +38,12 @@ function generateHour(d, t) { } export function hoursListInUTC(input: { dateQuery: string, timezone: string }) { - const midnight = spacetime(input.dateQuery, input.timezone).time('00:00'); - const hours = Array.from({ length: 24 }, function (_, i) { - const utcMoment = midnight.add(i, 'hour').goto('UTC'); - return { date: utcMoment.format('hh'), time: utcMoment.hour() }; + const localMidnight = spacetime(input.dateQuery, input.timezone).startOf("day"); + return Array.from({ length: 24 }).map((_, i) => { + const utcTime = localMidnight.goto("UTC").add(i, "hours"); + return { + time: utcTime.hour(), + date: utcTime.format("iso-short"), + }; }); - return hours; } diff --git a/packages/shared/utils/timezones.ts b/packages/shared/utils/timezones.ts index b26073f..4373fbe 100644 --- a/packages/shared/utils/timezones.ts +++ b/packages/shared/utils/timezones.ts @@ -23,7 +23,7 @@ export const timezones = { "America/Los_Angeles": "Pacific Time", // "Atlantic/Azores": "Azores", // "Atlantic/Cape_Verde": "Cape Verde Islands", - // GMT: "UTC", + "Etc/UTC": "UTC", "Europe/London": "The UK", "Europe/Dublin": "Ireland", // "Europe/Lisbon": "Lisbon", diff --git a/packages/trpc/routers/days.ts b/packages/trpc/routers/days.ts index 8f766a1..103be92 100644 --- a/packages/trpc/routers/days.ts +++ b/packages/trpc/routers/days.ts @@ -5,18 +5,19 @@ import { z } from "zod"; import { db, SqliteError } from "@lifetracker/db"; import { categories, days, hours, users } from "@lifetracker/db/schema"; import { - zDaySchema, ZDay + zDaySchema, ZDay, + ZHour } from "@lifetracker/shared/types/days"; import type { Context } from "../index"; import { authedProcedure, router } from "../index"; import { dateFromInput, hoursListInUTC } from "@lifetracker/shared/utils/days"; import { closestIndexTo, format } from "date-fns"; import { TZDate } from "@date-fns/tz"; -import { hoursAppRouter, hourColors, hourJoinsQuery } from "./hours"; +import { hoursAppRouter, hourColors, hourJoinsQuery, getHours, createHour } from "./hours"; import spacetime from "spacetime"; import { getHourFromTime, getTimeFromHour } from "@lifetracker/shared/utils/hours"; -async function createDay(date: string, ctx: Context) { +export async function createDay(date: string, ctx: Context) { return await ctx.db.transaction(async (trx) => { try { // Create the Day object @@ -33,7 +34,7 @@ async function createDay(date: string, ctx: Context) { comment: days.comment, }); - return dayRes; + return dayRes[0]; } catch (e) { if (e instanceof SqliteError) { if (e.code == "SQLITE_CONSTRAINT_UNIQUE") { @@ -51,24 +52,8 @@ async function createDay(date: string, ctx: Context) { }); } -async function createHour(day: ZDay, 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]; -} - -async function getTimezone(ctx: Context) { - const dbTimezone = await ctx.db.select({ - timezone: users.timezone - }).from(users).where(eq(users.id, ctx.user!.id)); - - return dbTimezone[0].timezone as string; -} - -async function getDay(input: { dateQuery: string }, ctx: Context, date: string) { +export async function getDay(ctx: Context, input: { dateQuery: string, timezone: string }) { + const dayDate = dateFromInput(input); const dayRes = await ctx.db.select({ id: days.id, date: days.date, @@ -76,28 +61,13 @@ async function getDay(input: { dateQuery: string }, ctx: Context, date: string) comment: days.comment, }) .from(days) - .where(eq(days.date, date)); + .where(eq(days.date, dayDate)); const day = dayRes.length == 0 - ? (await createDay(date, ctx))[0] + ? (await createDay(dayDate, ctx)) : (dayRes[0]); - const dayHours = await Promise.all( - Array.from({ length: 24 }).map(async function (_, i) { - const existing = await ctx.db.select({ - dayId: hours.dayId, - time: hours.time, - userId: hours.userId, - date: days.date - }).from(hours) - .leftJoin(days, eq(days.id, hours.dayId)) // Ensure days table is joined first - .where(and( - eq(hours.userId, ctx.user!.id), - eq(hours.dayId, day.id), - eq(hours.time, i) - )) - return existing.length == 0 ? createHour(day, i, ctx) : existing[0]; - })); + const dayHours: ZHour[] = await getHours(ctx, input); return { hours: dayHours, @@ -109,49 +79,12 @@ export const daysAppRouter = router({ get: authedProcedure .input(z.object({ dateQuery: z.string(), - timezone: z.string().optional(), + timezone: z.string(), })) .output(zDaySchema) .query(async ({ input, ctx }) => { // Get a Day - - // Use timezone and date string to get the local date - const timezone = input.timezone ?? await getTimezone(ctx); - const date = dateFromInput({ - dateQuery: input.dateQuery, - timezone: timezone - }); - - // Get the list of UTC hours corresponding to this day - const utcHours = hoursListInUTC({ - timezone, - ...input - }); - - // console.log(`utcHours:\n,${utcHours.map(({ date, time }) => `${date} ${time}\n`)}`); - - // Flatten the 24 hours to the 2 unique days - const uniqueDays = [...new Set(utcHours.map(({ date: date, time: _time }) => date))]; - // ...and get their IDs - const uniqueDayIds = await Promise.all(uniqueDays.map(async function (date) { - const dayObj = await getDay(input, ctx, date); - return { - id: dayObj.id, - date: dayObj.date - } - })); - - // 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(utcHours.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, dayId, map.time); - })); - - return { - ...await getDay(input, ctx, date), - hours: dayHours.flat(), - }; + return await getDay(ctx, input); }), update: authedProcedure diff --git a/packages/trpc/routers/hours.ts b/packages/trpc/routers/hours.ts index 8e36290..fe07f6d 100644 --- a/packages/trpc/routers/hours.ts +++ b/packages/trpc/routers/hours.ts @@ -11,10 +11,21 @@ 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"; +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() @@ -41,10 +52,19 @@ export async function hourColors(hour: ZHour, ctx: Context) { export async function hourJoinsQuery( ctx: Context, - dayId: string, time: number, + dayId?: string, + day?: ZDay, ) { - const hourMatch = await ctx.db.select({ + 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, @@ -59,9 +79,12 @@ export async function hourJoinsQuery( .leftJoin(days, eq(days.id, hours.dayId)) .where(and( eq(hours.time, time), - eq(hours.dayId, dayId) + 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, @@ -73,18 +96,48 @@ export async function hourJoinsQuery( }) .from(measurements) .leftJoin(metrics, eq(metrics.id, measurements.metricId)) - .where(eq(measurements.hourId, hourMatch[0].id)); + .where(eq(measurements.hourId, hourMatch.id)); const dayHour = { measurements: hourMeasurements, - ...hourMatch[0], - ...(await hourColors(hourMatch[0], ctx)), + ...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"), @@ -96,7 +149,7 @@ function listOfDates(dateRange: [Date, Date]) { dates.push(format(currentDate, "yyyy-MM-dd")); currentDate = addDays(currentDate, 1); } - return dates.length === 0 ? parseISO(start) : dates; + return dates.length === 0 ? [format(parseISO(start), "yyyy-MM-dd")] : dates; } @@ -185,53 +238,33 @@ export const hoursAppRouter = router({ // ...hourRes[0] // }; - return hourJoinsQuery(ctx, input.dayId, input.hourTime); + return hourJoinsQuery(ctx, input.hourTime, input.dayId,); }), categoryFrequencies: authedProcedure .input(z.object({ - dateRange: z.tuple([z.date(), z.date()]) + dateRange: z.tuple([z.date(), z.date()]), + timezone: z.string() })) .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() + percentage: z.number(), + ...zHourSchema.shape } ))) .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); + const hoursList = await getHours(ctx, { + dateQuery: input.dateRange, + timezone: input.timezone + }); // Count total hours in the filtered range const totalHours = hoursList.length; - console.log(hoursList); - // Group hours by category and count occurrences - const categoriesList = {}; + const categoriesList: { [key: string]: any } = {}; hoursList.forEach(h => { if (!categoriesList[h.categoryCode]) { categoriesList[h.categoryCode] = {