Maybe timezones work fully in CLI now?

This commit is contained in:
Ryan Pandya 2024-11-28 23:42:37 -05:00
parent b2e2770e91
commit a85e8f294a
13 changed files with 209 additions and 95 deletions

View File

@ -23,7 +23,9 @@
"@lifetracker/trpc": "workspace:^", "@lifetracker/trpc": "workspace:^",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dotenv": "^16.4.1", "dotenv": "^16.4.1",
"spacetime": "^7.6.2",
"table": "^6.8.2", "table": "^6.8.2",
"timezone-soft": "^1.5.2",
"vite-tsconfig-paths": "^5.1.0" "vite-tsconfig-paths": "^5.1.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -12,6 +12,11 @@ import { format } from "date-fns";
import { TZDate } from "@date-fns/tz"; import { TZDate } from "@date-fns/tz";
import { utc } from "@date-fns/utc"; import { utc } from "@date-fns/utc";
import { getHourFromTime, getTimeFromHour } from "@lifetracker/shared/utils/hours"; import { getHourFromTime, getTimeFromHour } from "@lifetracker/shared/utils/hours";
import { timezones } from "@lifetracker/shared/utils/timezones";
import { hours } from "@lifetracker/db/schema";
import { doHour } from "./hours";
import soft from "timezone-soft";
import spacetime from "spacetime";
function moodToStars(mood: number) { function moodToStars(mood: number) {
// const full_stars = Math.floor(mood / 2); // const full_stars = Math.floor(mood / 2);
@ -20,38 +25,70 @@ function moodToStars(mood: number) {
return "★".repeat(mood) + "☆".repeat(10 - mood); return "★".repeat(mood) + "☆".repeat(10 - mood);
} }
function getTimezone(req: string) {
try {
const tz = soft(req)[0].iana;
if (timezones[tz] === undefined) {
throw new Error("Valid timezone, but not supported by Lifetracker. Add it to the timezones list in the shared utils or try a different request.");
}
return tz;
} catch (e) {
throw new Error("Invalid timezone..." + e);
}
}
export const daysCmd = new Command() export const daysCmd = new Command()
.name("day") .name("day")
.description("Get data for a specific day") .description("Get data for a specific day")
.argument('<date>', 'A date in ISO-8601 format, or "yesterday", "today", "tomorrow", etc.') .argument('[date]', 'A date in ISO-8601 format, or "yesterday", "today", "tomorrow", etc.', "today")
.option('-c, --comment <comment>', "edit this day's comment") .option('-c, --comment <comment>', "edit this day's comment")
.option('-m, --mood <number>', "edit this day's mood") .option('-m, --mood <number>', "edit this day's mood")
.action(async (dateQuery: string, flags?) => { .option('-h, --hour <hour...>', "manipulate a specific hour (<time> [code | @] [comment])")
.option('-t, --timezone <timezone>', "display times in a specific timezone")
.action(async (dateQuery, flags?) => {
const api = getAPIClient(); const api = getAPIClient();
try { try {
const timezone = flags?.timezone
? getTimezone(flags.timezone)
: getTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
// : await api.users.getTimezone.query();
if (flags?.comment || flags?.mood) { if (flags?.comment || flags?.mood) {
const updateProps = { dateQuery: dateQuery, ...flags }; console.log(`Updating day with date '${dateQuery}' in timezone '${flags.timezone}'`);
const updateProps = { dateQuery: dateQuery, timezone: timezone, ...flags };
await api.days.update.mutate(updateProps); await api.days.update.mutate(updateProps);
} }
const day = (await api.days.get.query({ dateQuery: dateQuery })); console.log(`Loading ${dateQuery} in ` + timezone + ` (${spacetime.now(timezone).format('{nice}')})`);
const day = (await api.days.get.query({ dateQuery: dateQuery, timezone: timezone }));
console.log(`Snagged day with id '${day.id}'`);
if (getGlobalOptions().json) { if (getGlobalOptions().json) {
printObject(day); printObject(day);
} else { } else {
const moodStr = moodToStars(day.mood); const moodStr = moodToStars(day.mood);
const dateStr = format(day.date, "EEEE, MMMM do", { in: utc }); const dateStr = format(day.date, "EEEE, MMMM do", { in: utc });
const data: string[][] = [[dateStr, '', moodStr], [day.comment ?? "No comment", '', ''], const data: string[][] = [[dateStr, '', moodStr], [day.comment ?? "", '', ''],
["Time", "Category", "Comment"] [`Time (${timezones[timezone]})`, "Category", "Comment"]
]; ];
day.hours.forEach((h, i) => { day.hours.forEach((h, i) => {
data.push([getHourFromTime(h.time), data.push([
h.categoryName ?? "--", h.comment ?? ""]); getHourFromTime(i, 'twelves', `(${timezones[timezone]})`),
h.categoryName ?? "--",
h.comment ?? ""
]);
}) })
if (flags?.hour) {
doHour(day.hours, day, flags.hour, timezone);
}
else {
console.log(table(data, { console.log(table(data, {
// border: getBorderCharacters("ramac"), // border: getBorderCharacters("ramac"),
// singleLine: true, // singleLine: true,
@ -64,7 +101,8 @@ export const daysCmd = new Command()
}, },
})); }));
} }
}
} catch (error) { } catch (error) {
printErrorMessageWithReason("Failed to get day", error as object); printErrorMessageWithReason("Failed", error as object);
} }
}); });

View File

@ -11,51 +11,66 @@ import { getBorderCharacters, table } from "table";
import { format } from "date-fns"; import { format } from "date-fns";
import { TZDate } from "@date-fns/tz"; import { TZDate } from "@date-fns/tz";
import { getHourFromTime, getTimeFromHour } from "@lifetracker/shared/utils/hours"; import { getHourFromTime, getTimeFromHour } from "@lifetracker/shared/utils/hours";
import { ZHour } from "@lifetracker/shared/types/days"; import { ZDay, ZHour } from "@lifetracker/shared/types/days";
import soft from "timezone-soft";
import spacetime from "spacetime";
import { timezones } from "@lifetracker/shared/utils/timezones";
export const hoursCmd = new Command() export const hoursCmd = new Command()
.name("hour") .name("hour")
.description("Get or set data for a specific hour") .description("Get or set data for a specific hour")
.argument('<date>', 'A date in ISO-8601 format, or "yesterday", "today", "tomorrow", etc.')
.argument('<hour>', 'An hour between 0-23, or 1-12 [AM/PM]') .argument('<hour>', 'An hour between 0-23, or 1-12 [AM/PM]')
.argument('[code]', 'Optionally set the code for this hour') .argument('[code]', 'Optionally set the code for this hour')
.option('-c, --comment <comment>', "edit this hour's comment") .option('-c, --comment <comment>', "edit this hour's comment")
.action(async (dateQuery = "today", hour: string, code: string | undefined, flags?) => { .action(async (dateQuery = "today", hour: string, code: string | undefined, flags?) => {
return "Hello";
});
export async function doHour(hours: ZHour[], day: ZDay, hourFlags: string[], timezone = "UTC") {
const hourNum = getTimeFromHour(hourFlags.shift()!);
const hour = hours[hourNum];
if (hourFlags.length == 0) {
printHour(hour, hourNum, day, timezone);
}
else {
const api = getAPIClient(); const api = getAPIClient();
let res: string | ZHour; let res: string | ZHour;
try { try {
const props = { dateQuery: dateQuery, time: getTimeFromHour(hour), code: code, ...flags }; if (hourFlags[0] == "@") {
if (code) {
// Update if (!hour.categoryCode) {
console.log(props); throw new Error("No category code yet -- '@' is meant to set the comment when the category is already set.");
res = await api.hours.update.mutate({ ...props });
} }
else { else {
// Get hourFlags[0] = hour.categoryCode?.toString();
res = await api.hours.get.query({ ...props });
} }
}
const props = { dateQuery: hour.date!, time: hour.time, code: hourFlags[0], comment: hourFlags[1] || null };
res = await api.hours.update.mutate({ ...props });
printHour(res as ZHour, hourNum, day, timezone);
} }
catch (error) { catch (error) {
printErrorMessageWithReason("Failed to manipulate hour", error as object); printErrorMessageWithReason("Failed to manipulate hour", error as object);
} }
}
}
if (getGlobalOptions().json) { function printHour(hour: ZHour, hourNum: number, day: ZDay, timezone: string) {
printObject(res);
} else {
const data = [ const data = [
['Doing:', res.categoryName ?? "Undefined"], [hour.categoryName + ".", hour.categoryDesc ?? "Nothing stored yet."],
]; ];
if (res.comment) { if (hour.comment) {
data.push(['Comment:', res.comment ?? "Undefined"]); data.push(['Comment:', hour.comment]);
} }
console.log(table(data, { console.log(table(data, {
// border: getBorderCharacters("ramac"), // border: getBorderCharacters("ramac"),
// singleLine: true, // singleLine: true,
// spanningCells: [{ col: 0, row: 0, colSpan: 2 },], columns: [{ width: 10 }, { width: 40 }],
header: { alignment: "center", content: `${format(res.date, "EEEE, MMMM dd")} at ${getHourFromTime(res.time, true)}` }, header: { alignment: "center", content: `${spacetime(day.date, timezone).format('{day}, {month-short} {date-ordinal}')} at ${getHourFromTime(hourNum, 'all', timezones[timezone])}` },
drawVerticalLine: (lineIndex, columnCount) => { drawVerticalLine: (lineIndex, columnCount) => {
return lineIndex === 0 || lineIndex === columnCount || (lineIndex === 0 && columnCount === 2); return lineIndex === 0 || lineIndex === columnCount || (lineIndex === 0 && columnCount === 2);
}, },
@ -64,4 +79,3 @@ export const hoursCmd = new Command()
}, },
})); }));
} }
});

View File

@ -3,7 +3,7 @@ import { useState, useEffect, use, } from "react";
import Link from "next/link"; 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 '@/lib/timezones'; import { timezones } from '@lifetracker/shared/utils/timezones';
import { useTimezone } from "@lifetracker/shared-react/hooks/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";

View File

@ -147,7 +147,7 @@ export const hours = sqliteTable(
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => users.id, { onDelete: "cascade" }),
comment: text("comment"), comment: text("comment"),
time: integer("time"), time: integer("time").notNull(),
dayId: text("dayId").notNull().references(() => days.id, { onDelete: "cascade" }), dayId: text("dayId").notNull().references(() => days.id, { onDelete: "cascade" }),
categoryId: text("categoryId").references(() => categories.id), categoryId: text("categoryId").references(() => categories.id),
}, },

View File

@ -8,6 +8,7 @@
"@date-fns/tz": "^1.2.0", "@date-fns/tz": "^1.2.0",
"@date-fns/utc": "^2.1.0", "@date-fns/utc": "^2.1.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"spacetime": "^7.6.2",
"winston": "^3.17.0", "winston": "^3.17.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },

View File

@ -6,9 +6,11 @@ export const zHourSchema = z.object({
dayId: z.string(), dayId: z.string(),
date: z.string().optional(), date: z.string().optional(),
time: z.number(), time: z.number(),
datetime: z.string().optional(),
categoryCode: z.coerce.number().nullish(), categoryCode: z.coerce.number().nullish(),
categoryId: z.string().nullish(), categoryId: z.string().nullish(),
categoryName: z.string().nullish(), categoryName: z.string().nullish(),
categoryDesc: z.string().nullish(),
comment: z.string().nullish(), comment: z.string().nullish(),
}); });
export type ZHour = z.infer<typeof zHourSchema>; export type ZHour = z.infer<typeof zHourSchema>;

View File

@ -1,12 +1,16 @@
import { format, addHours } from "date-fns"; import { format, addHours } from "date-fns";
import { TZDate } from "@date-fns/tz"; import { TZDate } from "@date-fns/tz";
import { UTCDate, utc } from "@date-fns/utc"; import { UTCDate, utc } from "@date-fns/utc";
import spacetime from "spacetime";
export function dateFromInput(input: { dateQuery: string, timezone: string }) { export function dateFromInput(input: { dateQuery: string, timezone: string }) {
console.log(`Looking for ${input.dateQuery} in ${input.timezone}`);
let t: string; let t: string;
if (input.dateQuery == "today") { if (input.dateQuery == "today") {
t = new Date(); t = spacetime(input.dateQuery, input.timezone).format("yyyy-MM-dd");
return format(t, "yyyy-MM-dd", { in: input.timezone }); return t;
} }
else { else {
t = new UTCDate(input.dateQuery); t = new UTCDate(input.dateQuery);
@ -17,16 +21,18 @@ export function dateFromInput(input: { dateQuery: string, timezone: string }) {
function generateHour(d, t) { function generateHour(d, t) {
const dt: TZDate = addHours(d, t); const dt: TZDate = addHours(d, t);
console.log(dt);
return { return {
date: format(dt, 'yyyy-MM-dd'), date: format(dt, 'yyyy-MM-dd'),
time: parseInt(format(dt, 'H')), time: parseInt(format(dt, 'H')),
}; };
} }
export function hoursListInTimezone(input: { dateQuery: string, timezone: string }) { export function hoursListInUTC(input: { dateQuery: string, timezone: string }) {
const dateStr = dateFromInput(input); const midnight = spacetime(input.dateQuery, input.timezone).time('00:00');
const d = new TZDate(dateStr, input.timezone); const hours = Array.from({ length: 24 }, function (_, i) {
return Array.from({ length: 24 }, (_, t) => const utcMoment = midnight.add(i, 'hour').goto('UTC');
generateHour(d, t) return { date: utcMoment.format('hh'), time: utcMoment.hour() };
); });
return hours;
} }

View File

@ -1,7 +1,18 @@
export function getHourFromTime(time: number, includePeriod = false) {
export function getHourFromTime(time: number, includePeriod: 'none' | 'twelves' | 'all' = 'none', displayTZ = "") {
const hour = time == 0 || time == 12 ? 12 : time % 12; const hour = time == 0 || time == 12 ? 12 : time % 12;
const period = time < 12 ? "AM" : "PM"; const period = time < 12 ? "AM" : "PM";
return includePeriod ? `${hour} ${period}` : `${hour}`; switch (includePeriod) {
case 'none':
return `${hour}`;
case 'twelves':
return hour === 12 ? `${hour} ${period}` : `${hour}`;
case 'all':
return `${hour} ${period}`;
default:
throw new Error("Invalid includePeriod option. Use either 'none', 'twelves', or 'all'");
}
} }
export function getTimeFromHour(hour: string) { export function getTimeFromHour(hour: string) {
@ -13,9 +24,9 @@ export function getTimeFromHour(hour: string) {
let time = parseInt(match[1]); let time = parseInt(match[1]);
const period = match[2] ? match[2].toUpperCase() : null; const period = match[2] ? match[2].toUpperCase() : null;
if (time > 12 || time < 1) { // if (time > 12 || time < 0) {
throw new Error("Invalid hour"); // throw new Error("Invalid hour");
} // }
if (period === 'PM' && time !== 12) { if (period === 'PM' && time !== 12) {
time += 12; time += 12;

View File

@ -9,7 +9,7 @@ import {
} 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, hoursListInTimezone } 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 } from "./hours"; import { hoursAppRouter } from "./hours";
@ -106,17 +106,18 @@ export const daysAppRouter = router({
get: authedProcedure get: authedProcedure
.input(z.object({ .input(z.object({
dateQuery: z.string(), dateQuery: z.string(),
timezone: z.string().optional(),
})) }))
.output(zDaySchema) .output(zDaySchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const timezone = await getTimezone(ctx); const timezone = input.timezone ?? await getTimezone(ctx);
const date = dateFromInput({ const date = dateFromInput({
dateQuery: input.dateQuery, dateQuery: input.dateQuery,
timezone: timezone timezone: timezone
}); });
const allHours = hoursListInTimezone({ const allHours = hoursListInUTC({
timezone, timezone,
...input ...input
}); });
@ -131,21 +132,43 @@ export const daysAppRouter = router({
} }
})); }));
const dayHours = await Promise.all(allHours.map(async function ({ date, time }) { const dayHours = await Promise.all(allHours.map(async function (map: { date, time }) {
const dayId = allDayIds.find((_value: { id, date }) => date == date)!.id; const dayId = allDayIds.find((dayIds: { id, date }) => map.date == dayIds.date)!.id;
const hourMatch = await ctx.db.select({
const hourMatch = await ctx.db.select().from(hours) id: hours.id,
dayId: hours.dayId,
time: hours.time,
categoryId: hours.categoryId,
categoryCode: categories.code,
categoryName: categories.name,
categoryDesc: categories.description,
comment: hours.comment,
}).from(hours)
.leftJoin(categories, eq(categories.id, hours.categoryId))
.where(and( .where(and(
eq(hours.time, time), eq(hours.time, map.time),
eq(hours.dayId, dayId))); eq(hours.dayId, dayId)));
// console.log({
// console.log("Search values:: ", `d: ${date}, t: ${time}, dayId: ${dayId}`) // ...allHours,
// dayId: dayId
// });
// console.log("Search values:: ", `allDayIds: ${allDayIds}, d: ${date}, t: ${time}, dayId: ${dayId}`)
// console.log("hourMatch", hourMatch[0]); // console.log("hourMatch", hourMatch[0]);
return hourMatch; const dayHour = {
...hourMatch[0],
};
return {
...dayHour,
date: map.date,
};
})); }));
// console.log(dayHours.flat());
return { return {
...await getDay(input, ctx, date), ...await getDay(input, ctx, date),
hours: dayHours.flat(), hours: dayHours.flat(),
@ -158,10 +181,11 @@ export const daysAppRouter = router({
mood: z.string().optional().or(z.number()), mood: z.string().optional().or(z.number()),
comment: z.string().optional(), comment: z.string().optional(),
dateQuery: z.string(), dateQuery: z.string(),
timezone: z.string().optional(),
}), }),
) )
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { dateQuery, ...updatedProps } = input; const { dateQuery, timezone, ...updatedProps } = input;
// Convert mood to number, if it exists // Convert mood to number, if it exists
if (updatedProps.mood) { if (updatedProps.mood) {
@ -170,6 +194,6 @@ export const daysAppRouter = router({
await ctx.db await ctx.db
.update(days) .update(days)
.set(updatedProps) .set(updatedProps)
.where(eq(days.date, dateFromInput({ dateQuery: dateQuery, timezone: ctx.user.timezone }))); .where(eq(days.date, dateFromInput({ dateQuery: dateQuery, timezone: timezone ?? ctx.user.timezone })));
}), }),
}); });

View File

@ -50,11 +50,17 @@ export const hoursAppRouter = router({
comment: z.string().nullable().optional(), comment: z.string().nullable().optional(),
}), }),
) )
// .output(zHourSchema) .output(zHourSchema)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const { dateQuery, time, code, ...updatedProps } = input; const { dateQuery, time, code, ...updatedProps } = input;
var date = dateFromInput({ dateQuery: dateQuery }); var date = dateFromInput({ dateQuery: dateQuery });
const category = await ctx.db.select(
const category =
code == "" ? [{
id: null,
name: null
}]
: await ctx.db.select(
{ {
id: categories.id, id: categories.id,
name: categories.name, name: categories.name,
@ -82,6 +88,7 @@ export const hoursAppRouter = router({
const newProps = { const newProps = {
categoryId: category[0].id, categoryId: category[0].id,
code: code,
...updatedProps ...updatedProps
}; };

9
pnpm-lock.yaml generated
View File

@ -41,9 +41,15 @@ importers:
dotenv: dotenv:
specifier: ^16.4.1 specifier: ^16.4.1
version: 16.4.5 version: 16.4.5
spacetime:
specifier: ^7.6.2
version: 7.6.2
table: table:
specifier: ^6.8.2 specifier: ^6.8.2
version: 6.8.2 version: 6.8.2
timezone-soft:
specifier: ^1.5.2
version: 1.5.2
vite-tsconfig-paths: vite-tsconfig-paths:
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.1.0(typescript@5.6.3)(vite@5.4.10(@types/node@20.11.24)(lightningcss@1.28.1)(terser@5.34.1)) version: 5.1.0(typescript@5.6.3)(vite@5.4.10(@types/node@20.11.24)(lightningcss@1.28.1)(terser@5.34.1))
@ -490,6 +496,9 @@ importers:
date-fns: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.0 version: 4.1.0
spacetime:
specifier: ^7.6.2
version: 7.6.2
winston: winston:
specifier: ^3.17.0 specifier: ^3.17.0
version: 3.17.0 version: 3.17.0