Update timezone logic, pull correct hours for AnalyticsView

This commit is contained in:
Ryan Pandya 2025-01-19 00:20:18 -08:00
parent aa4a843349
commit 6e96eed3f1
22 changed files with 202 additions and 162 deletions

View File

@ -4,6 +4,8 @@ import { TRPCError } from "@trpc/server";
import DayView from "@/components/dashboard/days/DayView"; import DayView from "@/components/dashboard/days/DayView";
import { getServerAuthSession } from "@/server/auth"; import { getServerAuthSession } from "@/server/auth";
import LoadingSpinner from "@/components/ui/spinner"; 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 }; }) { export default async function DayPage({ params }: { params: { dateQuery: string }; }) {
const session = await getServerAuthSession(); const session = await getServerAuthSession();
@ -11,15 +13,12 @@ export default async function DayPage({ params }: { params: { dateQuery: string
redirect("/"); redirect("/");
} }
let day; let day;
const { dateQuery } = await params;
const timezone = await api.users.getTimezone();
try { try {
day = await api.days.get({ day = await api.days.get({
dateQuery: dateQuery, dateQuery: (await params).dateQuery,
timezone: timezone, timezone: (await getUserLocalSettings()).timezone,
}); });
} catch (e) { } catch (e) {
if (e instanceof TRPCError) { if (e instanceof TRPCError) {
@ -31,7 +30,7 @@ export default async function DayPage({ params }: { params: { dateQuery: string
} }
return ( return (
<div class="container"> <div className="container">
{ {
day == undefined ? day == undefined ?
<LoadingSpinner /> : <LoadingSpinner /> :

View File

@ -20,6 +20,8 @@ import { getServerAuthSession } from "@/server/auth";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { clientConfig } from "@lifetracker/shared/config"; import { clientConfig } from "@lifetracker/shared/config";
import { useTimezone } from "@lifetracker/shared-react/hooks/timezones";
import { getUserLocalSettings } from "@/lib/userLocalSettings/userLocalSettings";
const inter = Inter({ const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
@ -61,9 +63,7 @@ export default async function RootLayout({
session={session} session={session}
clientConfig={clientConfig} clientConfig={clientConfig}
userLocalSettings={ userLocalSettings={
parseUserLocalSettings( await getUserLocalSettings()
(await cookies()).get(USER_LOCAL_SETTINGS_COOKIE_NAME)?.value
) ?? defaultUserLocalSettings()
} }
> >
{children} {children}

View File

@ -4,27 +4,27 @@ import Link from "next/link";
import { format } from "date-fns"; import { format } from "date-fns";
import { TZDate } from "@date-fns/tz"; import { TZDate } from "@date-fns/tz";
import { timezones } from '@lifetracker/shared/utils/timezones'; import { timezones } from '@lifetracker/shared/utils/timezones';
import { useTimezone } from "@lifetracker/shared-react/hooks/timezones";
import LoadingSpinner from "@/components/ui/spinner"; import LoadingSpinner from "@/components/ui/spinner";
import { db } from "@lifetracker/db"; import { db } from "@lifetracker/db";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import spacetime from "spacetime"; import spacetime from "spacetime";
import { useTimezone } from "@/lib/userLocalSettings/client";
export default function TimezoneDisplay() { export default function TimezoneDisplay() {
const clientTime = spacetime.now(); const timezone = useTimezone();
return ( return (
<div className="text-center w-full"> <div className="text-center w-full">
<Link href="/settings/app"> <Link href="/settings/app">
<b className="whitespace-nowrap"> <b className="whitespace-nowrap">
{clientTime.format('nice')} {format(TZDate.tz(timezone), 'MMM dd, hh:mm aa')}
</b> </b>
<br /> <br />
<span>in&nbsp;</span> <span>in&nbsp;</span>
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
<b> <b>
{timezones[clientTime.timezone().name] || clientTime.timezone().name} {timezones[timezone] || "Unknown Timezone"}
</b> </b>
</span> </span>
</Link> </Link>

View File

@ -8,7 +8,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { useBookmarkLayout } from "@/lib/userLocalSettings/bookmarksLayout"; import { useBookmarkLayout } from "@/lib/userLocalSettings/client";
import { updateBookmarksLayout } from "@/lib/userLocalSettings/userLocalSettings"; import { updateBookmarksLayout } from "@/lib/userLocalSettings/userLocalSettings";
import { import {
Check, Check,

View File

@ -12,6 +12,8 @@ import { Anchor, Button, Menu } from "@mantine/core";
import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip'; import { useTooltip, useTooltipInPortal, defaultStyles } from '@visx/tooltip';
import PieChart from "./PieChart"; import PieChart from "./PieChart";
import { useTimezone } from "@/lib/userLocalSettings/client";
const parseDateRangeFromQuery = (): Date[] | undefined => { const parseDateRangeFromQuery = (): Date[] | undefined => {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
if (!searchParams.has("dateRange")) return undefined; if (!searchParams.has("dateRange")) return undefined;
@ -46,8 +48,8 @@ export default function AnalyticsView() {
} }
return [ return [
// spacetime.now().subtract(1, "week").toNativeDate(), // spacetime.now().subtract(1, "week").toNativeDate(),
spacetime.now().toNativeDate(), spacetime.now(useTimezone()).toNativeDate(),
spacetime.now().toNativeDate() spacetime.now(useTimezone()).toNativeDate()
]; ];
})(); })();
@ -70,14 +72,18 @@ export default function AnalyticsView() {
const categoryFrequencies = api.hours.categoryFrequencies.useQuery({ const categoryFrequencies = api.hours.categoryFrequencies.useQuery({
dateRange, dateRange,
timezone: useTimezone(),
}).data ?? []; }).data ?? [];
console.log(categoryFrequencies);
return ( return (
<div className="px-4 flex flex-col gap-y-4"> <div className="px-4 flex flex-col gap-y-4">
<div className="flex justify-between"> <div className="flex justify-between">
<h1>Analytics</h1> <h1>Analytics for&nbsp;
{dateRange[0].getUTCDate() == dateRange[1].getUTCDate()
? spacetime(dateRange[0]).unixFmt("MMM dd, yyyy")
: dateRange.map((d) => spacetime(d).unixFmt("MMM dd, yyyy")).join(" to ")
}
</h1>
{/* <DateRangePicker {/* <DateRangePicker
showOneCalendar showOneCalendar
appearance="subtle" appearance="subtle"
@ -139,6 +145,17 @@ export default function AnalyticsView() {
data={categoryFrequencies} data={categoryFrequencies}
/> />
<div className="grow"> <div className="grow">
<div className="grid font-bold border-b border-gray-200"
style={{
gridTemplateColumns: "1fr 1fr"
}}
>
<div className="whitespace-nowrap ">
Total
</div>
<div>
{categoryFrequencies.reduce((acc, category) => acc + category.count, 0)} hours
</div></div>
{ {
categoryFrequencies categoryFrequencies
.sort((a, b) => b.count - a.count) .sort((a, b) => b.count - a.count)
@ -150,7 +167,7 @@ export default function AnalyticsView() {
}} }}
> >
<div className="whitespace-nowrap"> <div className="whitespace-nowrap">
{category.categoryName} {category.categoryName ?? "[Unallocated]"}
</div> </div>
<div> <div>
{category.count} hours {category.count} hours

View File

@ -33,8 +33,6 @@ export default function PieChart({
const centerY = innerHeight / 2; const centerY = innerHeight / 2;
const centerX = innerWidth / 2; const centerX = innerWidth / 2;
// console.log(data);
return data.length == 0 ? <LoadingSpinner /> : ( return data.length == 0 ? <LoadingSpinner /> : (
<svg width={width} height={height}> <svg width={width} height={height}>
<rect rx={14} width={width} height={height} fill="transparent" /> <rect rx={14} width={width} height={height} fill="transparent" />
@ -55,7 +53,8 @@ export default function PieChart({
console.log('clicked: ', category.categoryName) console.log('clicked: ', category.categoryName)
} }
getColor={(arc) => { 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<Datum>({
}); });
function getForeground(arc: PieArcDatum<Datum>) { function getForeground(arc: PieArcDatum<Datum>) {
return arc.data.categoryForeground; return arc.data.foreground;
} }
} }

View File

@ -4,6 +4,7 @@ import GlobalActions from "@/components/dashboard/GlobalActions";
import ProfileOptions from "@/components/dashboard/header/ProfileOptions"; import ProfileOptions from "@/components/dashboard/header/ProfileOptions";
import HoarderLogo from "@/components/HoarderIcon"; import HoarderLogo from "@/components/HoarderIcon";
import { getServerAuthSession } from "@/server/auth"; import { getServerAuthSession } from "@/server/auth";
import TimezoneDisplay from "../../TimezoneDisplay";
export default async function Header() { export default async function Header() {
const session = await getServerAuthSession(); const session = await getServerAuthSession();
@ -19,7 +20,7 @@ export default async function Header() {
</Link> </Link>
</div> </div>
<div className="flex flex-1 gap-2"> <div className="flex flex-1 gap-2">
<GlobalActions /> <TimezoneDisplay />
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<ProfileOptions /> <ProfileOptions />

View File

@ -23,6 +23,8 @@ import { Badge } from "@/components/ui/badge";
import { Icon } from "@/components/ui/icon"; import { Icon } from "@/components/ui/icon";
import { titleCase } from "title-case"; import { titleCase } from "title-case";
import { useDecrementCount } from "@lifetracker/shared-react/hooks/measurements"; import { useDecrementCount } from "@lifetracker/shared-react/hooks/measurements";
import { useTimezone } from "@/lib/userLocalSettings/client";
export default function EditableHour({ export default function EditableHour({
hour: initialHour, 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 => { const localDateTime = (h: ZHour): string => {

View File

@ -7,7 +7,7 @@ import { Archive, ArrowRightFromLine, Calendar, CheckCheck, Gauge, GaugeCircleIc
import serverConfig from "@lifetracker/shared/config"; import serverConfig from "@lifetracker/shared/config";
import AllLists from "./AllLists"; import AllLists from "./AllLists";
import TimezoneDisplay from "./TimezoneDisplay"; import TimezoneDisplay from "../../TimezoneDisplay";
import { ActionButtonWithTooltip } from "@/components/ui/action-button"; import { ActionButtonWithTooltip } from "@/components/ui/action-button";
export default async function Sidebar() { export default async function Sidebar() {
@ -76,8 +76,8 @@ export default async function Sidebar() {
</ul> </ul>
</div> </div>
<Separator /> <Separator />
<div className="mt-auto flex items-center border-t pt-2 text-sm text-gray-400"> <div className="mt-auto flex border-t pt-2 text-sm text-gray-400">
<TimezoneDisplay /> <span className="text-center w-full">Server version {serverConfig.serverVersion}</span>
</div> </div>
</aside> </aside>
} }
@ -108,6 +108,6 @@ export default async function Sidebar() {
return ( return (
<CollapsedView /> <FullView />
); );
} }

View File

@ -16,6 +16,7 @@ export default async function AppSettings() {
<div className="mb-2" > <div className="mb-2" >
<div className="mb-2 text-sm font-medium">Timezone</div> <div className="mb-2 text-sm font-medium">Timezone</div>
<div className="select-wrapper"> <div className="select-wrapper">
<ChangeTimezone <ChangeTimezone
userTimezone={userTimezone} userTimezone={userTimezone}
/> />

View File

@ -4,11 +4,13 @@ import React, { useState, useEffect } from "react";
import { timezones } from "@lifetracker/shared/utils/timezones"; import { timezones } from "@lifetracker/shared/utils/timezones";
import TimezoneSelect, { type ITimezone } from 'react-timezone-select' import TimezoneSelect, { type ITimezone } from 'react-timezone-select'
import { useUpdateUserTimezone } from "@lifetracker/shared-react/hooks/timezones"; import { useUpdateUserTimezone } from "@lifetracker/shared-react/hooks/timezones";
import { updateTimezoneCookie } from "@/lib/userLocalSettings/userLocalSettings";
export default function ChangeTimezone({ userTimezone }) { export default function ChangeTimezone({ userTimezone }) {
const [selectedTimezone, setSelectedTimezone] = useState<ITimezone>(userTimezone); const [selectedTimezone, setSelectedTimezone] = useState<ITimezone>(userTimezone);
const { mutate: updateUserTimezone, isPending } = useUpdateUserTimezone({ const { mutate: updateUserTimezone, isPending } = useUpdateUserTimezone({
onSuccess: () => { onSuccess: (_data, { newTimezone }) => {
updateTimezoneCookie(newTimezone);
toast({ toast({
description: "User DB Timezone updated!", description: "User DB Timezone updated!",
}); });

View File

@ -5,7 +5,7 @@ import { getServerAuthSession } from "@/server/auth";
import serverConfig from "@lifetracker/shared/config"; import serverConfig from "@lifetracker/shared/config";
import { settingsSidebarItems } from "./items"; import { settingsSidebarItems } from "./items";
import TimezoneDisplay from "@/components/dashboard/sidebar/TimezoneDisplay"; import TimezoneDisplay from "@/components/TimezoneDisplay";
export default async function Sidebar() { export default async function Sidebar() {
const session = await getServerAuthSession(); const session = await getServerAuthSession();

View File

@ -8,7 +8,7 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { DatesProvider } from "@mantine/dates"; 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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink, loggerLink } from "@trpc/client"; import { httpBatchLink, loggerLink } from "@trpc/client";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";

View File

@ -3,20 +3,27 @@
import type { z } from "zod"; import type { z } from "zod";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import type { BookmarksLayoutTypes, zUserLocalSettings } from "./types"; import type { BookmarksLayoutTypes, Timezones, zUserLocalSettings } from "./types";
const defaultLayout: BookmarksLayoutTypes = "masonry"; const defaultLayout: BookmarksLayoutTypes = "masonry";
const defaultTimezone: Timezones = "Etc/UTC";
export const UserLocalSettingsCtx = createContext< export const UserLocalSettingsCtx = createContext<
z.infer<typeof zUserLocalSettings> z.infer<typeof zUserLocalSettings>
>({ >({
bookmarkGridLayout: defaultLayout, bookmarkGridLayout: defaultLayout,
timezone: defaultTimezone,
}); });
function useUserLocalSettings() { function useUserLocalSettings() {
return useContext(UserLocalSettingsCtx); return useContext(UserLocalSettingsCtx);
} }
export function useTimezone() {
const settings = useUserLocalSettings();
return settings.timezone;
}
export function useBookmarkLayout() { export function useBookmarkLayout() {
const settings = useUserLocalSettings(); const settings = useUserLocalSettings();
return settings.bookmarkGridLayout; return settings.bookmarkGridLayout;

View File

@ -1,12 +1,19 @@
import { z } from "zod"; 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"]); const zBookmarkGridLayout = z.enum(["grid", "list", "masonry", "compact"]);
export type BookmarksLayoutTypes = z.infer<typeof zBookmarkGridLayout>; export type BookmarksLayoutTypes = z.infer<typeof zBookmarkGridLayout>;
const zTimezone = z.enum(
["", ...Object.keys(timezones)] as [string, ...string[]]
);
export type Timezones = z.infer<typeof zTimezone>;
export const zUserLocalSettings = z.object({ export const zUserLocalSettings = z.object({
bookmarkGridLayout: zBookmarkGridLayout.optional().default("masonry"), bookmarkGridLayout: zBookmarkGridLayout.optional().default("masonry"),
timezone: zTimezone.optional().default(""),
}); });
export type UserLocalSettings = z.infer<typeof zUserLocalSettings>; export type UserLocalSettings = z.infer<typeof zUserLocalSettings>;

View File

@ -1,9 +1,10 @@
"use server"; "use server";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import { getServerAuthSession } from "@/server/auth";
import type { BookmarksLayoutTypes } from "./types"; import type { BookmarksLayoutTypes, Timezones } from "./types";
import { import {
defaultUserLocalSettings,
parseUserLocalSettings, parseUserLocalSettings,
USER_LOCAL_SETTINGS_COOKIE_NAME, USER_LOCAL_SETTINGS_COOKIE_NAME,
} from "./types"; } from "./types";
@ -18,3 +19,33 @@ export async function updateBookmarksLayout(layout: BookmarksLayoutTypes) {
sameSite: "lax", 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,
}
}

View File

@ -19,6 +19,7 @@ import {
} from "@lifetracker/db/schema"; } from "@lifetracker/db/schema";
import serverConfig from "@lifetracker/shared/config"; import serverConfig from "@lifetracker/shared/config";
import { logAuthenticationError, validatePassword } from "@lifetracker/trpc/auth"; import { logAuthenticationError, validatePassword } from "@lifetracker/trpc/auth";
import { Timezones } from "@/lib/userLocalSettings/types";
type UserRole = "admin" | "user"; type UserRole = "admin" | "user";
@ -39,6 +40,7 @@ declare module "next-auth" {
user: { user: {
id: string; id: string;
role: UserRole; role: UserRole;
timezone: Timezones | undefined;
} & DefaultSession["user"]; } & DefaultSession["user"];
} }
@ -180,7 +182,11 @@ export const authOptions: NextAuthOptions = {
return token; return token;
}, },
async session({ session, 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; return session;
}, },
}, },

View File

@ -24,7 +24,7 @@ export const zDaySchema = z.object({
date: z.string(), date: z.string(),
mood: z.number().nullable(), mood: z.number().nullable(),
comment: z.string().nullable(), comment: z.string().nullable(),
hours: z.array(zHourSchema), hours: z.array(zHourSchema).optional(),
}); });
export type ZDay = z.infer<typeof zDaySchema>; export type ZDay = z.infer<typeof zDaySchema>;

View File

@ -38,10 +38,12 @@ function generateHour(d, t) {
} }
export function hoursListInUTC(input: { dateQuery: string, timezone: string }) { export function hoursListInUTC(input: { dateQuery: string, timezone: string }) {
const midnight = spacetime(input.dateQuery, input.timezone).time('00:00'); const localMidnight = spacetime(input.dateQuery, input.timezone).startOf("day");
const hours = Array.from({ length: 24 }, function (_, i) { return Array.from({ length: 24 }).map((_, i) => {
const utcMoment = midnight.add(i, 'hour').goto('UTC'); const utcTime = localMidnight.goto("UTC").add(i, "hours");
return { date: utcMoment.format('hh'), time: utcMoment.hour() }; return {
time: utcTime.hour(),
date: utcTime.format("iso-short"),
};
}); });
return hours;
} }

View File

@ -23,7 +23,7 @@ export const timezones = {
"America/Los_Angeles": "Pacific Time", "America/Los_Angeles": "Pacific Time",
// "Atlantic/Azores": "Azores", // "Atlantic/Azores": "Azores",
// "Atlantic/Cape_Verde": "Cape Verde Islands", // "Atlantic/Cape_Verde": "Cape Verde Islands",
// GMT: "UTC", "Etc/UTC": "UTC",
"Europe/London": "The UK", "Europe/London": "The UK",
"Europe/Dublin": "Ireland", "Europe/Dublin": "Ireland",
// "Europe/Lisbon": "Lisbon", // "Europe/Lisbon": "Lisbon",

View File

@ -5,18 +5,19 @@ import { z } from "zod";
import { db, SqliteError } from "@lifetracker/db"; import { db, SqliteError } from "@lifetracker/db";
import { categories, days, hours, users } from "@lifetracker/db/schema"; import { categories, days, hours, users } from "@lifetracker/db/schema";
import { import {
zDaySchema, ZDay zDaySchema, ZDay,
ZHour
} from "@lifetracker/shared/types/days"; } from "@lifetracker/shared/types/days";
import type { Context } from "../index"; import type { Context } from "../index";
import { authedProcedure, router } from "../index"; import { authedProcedure, router } from "../index";
import { dateFromInput, hoursListInUTC } from "@lifetracker/shared/utils/days"; import { dateFromInput, hoursListInUTC } from "@lifetracker/shared/utils/days";
import { closestIndexTo, format } from "date-fns"; import { closestIndexTo, format } from "date-fns";
import { TZDate } from "@date-fns/tz"; 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 spacetime from "spacetime";
import { getHourFromTime, getTimeFromHour } from "@lifetracker/shared/utils/hours"; 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) => { return await ctx.db.transaction(async (trx) => {
try { try {
// Create the Day object // Create the Day object
@ -33,7 +34,7 @@ async function createDay(date: string, ctx: Context) {
comment: days.comment, comment: days.comment,
}); });
return dayRes; return dayRes[0];
} catch (e) { } catch (e) {
if (e instanceof SqliteError) { if (e instanceof SqliteError) {
if (e.code == "SQLITE_CONSTRAINT_UNIQUE") { 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,) { export async function getDay(ctx: Context, input: { dateQuery: string, timezone: string }) {
const newHour = (await ctx.db.insert(hours).values({ const dayDate = dateFromInput(input);
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) {
const dayRes = await ctx.db.select({ const dayRes = await ctx.db.select({
id: days.id, id: days.id,
date: days.date, date: days.date,
@ -76,28 +61,13 @@ async function getDay(input: { dateQuery: string }, ctx: Context, date: string)
comment: days.comment, comment: days.comment,
}) })
.from(days) .from(days)
.where(eq(days.date, date)); .where(eq(days.date, dayDate));
const day = dayRes.length == 0 const day = dayRes.length == 0
? (await createDay(date, ctx))[0] ? (await createDay(dayDate, ctx))
: (dayRes[0]); : (dayRes[0]);
const dayHours = await Promise.all( const dayHours: ZHour[] = await getHours(ctx, input);
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];
}));
return { return {
hours: dayHours, hours: dayHours,
@ -109,49 +79,12 @@ export const daysAppRouter = router({
get: authedProcedure get: authedProcedure
.input(z.object({ .input(z.object({
dateQuery: z.string(), dateQuery: z.string(),
timezone: z.string().optional(), timezone: z.string(),
})) }))
.output(zDaySchema) .output(zDaySchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
// Get a Day // Get a Day
return await getDay(ctx, input);
// 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(),
};
}), }),
update: authedProcedure update: authedProcedure

View File

@ -11,10 +11,21 @@ import type { Context } from "../index";
import { authedProcedure, router } from "../index"; import { authedProcedure, router } from "../index";
import { addDays, format, parseISO } from "date-fns"; import { addDays, format, parseISO } from "date-fns";
import { TZDate } from "@date-fns/tz"; import { TZDate } from "@date-fns/tz";
import { dateFromInput } from "@lifetracker/shared/utils/days";
import { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; import { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import { zCategorySchema } from "@lifetracker/shared/types/categories"; import { zCategorySchema } from "@lifetracker/shared/types/categories";
import spacetime from "spacetime"; 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) { export async function hourColors(hour: ZHour, ctx: Context) {
const categoryColor = await ctx.db.select() const categoryColor = await ctx.db.select()
@ -41,10 +52,19 @@ export async function hourColors(hour: ZHour, ctx: Context) {
export async function hourJoinsQuery( export async function hourJoinsQuery(
ctx: Context, ctx: Context,
dayId: string,
time: number, 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, id: hours.id,
dayId: hours.dayId, dayId: hours.dayId,
time: hours.time, time: hours.time,
@ -59,9 +79,12 @@ export async function hourJoinsQuery(
.leftJoin(days, eq(days.id, hours.dayId)) .leftJoin(days, eq(days.id, hours.dayId))
.where(and( .where(and(
eq(hours.time, time), 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({ const hourMeasurements = await ctx.db.select({
id: measurements.id, id: measurements.id,
metricId: measurements.metricId, metricId: measurements.metricId,
@ -73,18 +96,48 @@ export async function hourJoinsQuery(
}) })
.from(measurements) .from(measurements)
.leftJoin(metrics, eq(metrics.id, measurements.metricId)) .leftJoin(metrics, eq(metrics.id, measurements.metricId))
.where(eq(measurements.hourId, hourMatch[0].id)); .where(eq(measurements.hourId, hourMatch.id));
const dayHour = { const dayHour = {
measurements: hourMeasurements, measurements: hourMeasurements,
...hourMatch[0], ...hourMatch,
...(await hourColors(hourMatch[0], ctx)), ...(await hourColors(hourMatch, ctx)),
}; };
return dayHour; 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]) { function listOfDates(dateRange: [Date, Date]) {
const [start, end] = dateRange.map((date) => dateFromInput({ const [start, end] = dateRange.map((date) => dateFromInput({
dateQuery: spacetime(date, "UTC").goto("UTC").format("iso-short"), 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")); dates.push(format(currentDate, "yyyy-MM-dd"));
currentDate = addDays(currentDate, 1); 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] // ...hourRes[0]
// }; // };
return hourJoinsQuery(ctx, input.dayId, input.hourTime); return hourJoinsQuery(ctx, input.hourTime, input.dayId,);
}), }),
categoryFrequencies: authedProcedure categoryFrequencies: authedProcedure
.input(z.object({ .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( .output(z.array(z.object(
{ {
count: z.number(), count: z.number(),
date: z.string(), percentage: z.number(),
time: z.number(), ...zHourSchema.shape
categoryName: z.string(),
categoryCode: z.number(),
categoryDescription: z.string(),
categoryColor: z.string(),
categoryForeground: z.string(),
percentage: z.number()
} }
))) )))
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const hoursList = (await ctx.db.select({ const hoursList = await getHours(ctx, {
date: days.date, dateQuery: input.dateRange,
time: hours.time, timezone: input.timezone
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 // Count total hours in the filtered range
const totalHours = hoursList.length; const totalHours = hoursList.length;
console.log(hoursList);
// Group hours by category and count occurrences // Group hours by category and count occurrences
const categoriesList = {}; const categoriesList: { [key: string]: any } = {};
hoursList.forEach(h => { hoursList.forEach(h => {
if (!categoriesList[h.categoryCode]) { if (!categoriesList[h.categoryCode]) {
categoriesList[h.categoryCode] = { categoriesList[h.categoryCode] = {