}
@@ -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] = {