From 81128b2db55c62c35c509512582548f8d9801ad6 Mon Sep 17 00:00:00 2001 From: Darragh ORiordan Date: Mon, 25 Sep 2023 23:40:27 +1000 Subject: [PATCH] feat: make the calendar better --- jest.config.js | 2 +- src/app/DevHistory/Calendar.tsx | 317 ++++++++++++++++++ .../DevHistory/Components/DiscreteDayNav.tsx | 47 +++ .../DevHistory/Components/ScheduleItem.tsx | 30 ++ .../DevHistory/Components/TimeScaleMenu.tsx | 98 ++++++ src/app/DevHistory/Components/colorMap.ts | 61 ++++ src/app/DevHistory/DevHistoryScreen.tsx | 68 +++- src/app/DevHistory/ReactQueryWrappers.ts | 2 +- src/app/DevHistory/time-degrees.test.ts | 20 ++ .../channels/DevHistoryGetDayChannelSub.ts | 2 +- .../devHistory/services/openai-service.ts | 132 +++++++- 11 files changed, 752 insertions(+), 27 deletions(-) create mode 100644 src/app/DevHistory/Calendar.tsx create mode 100644 src/app/DevHistory/Components/DiscreteDayNav.tsx create mode 100644 src/app/DevHistory/Components/ScheduleItem.tsx create mode 100644 src/app/DevHistory/Components/TimeScaleMenu.tsx create mode 100644 src/app/DevHistory/Components/colorMap.ts create mode 100644 src/app/DevHistory/time-degrees.test.ts diff --git a/jest.config.js b/jest.config.js index 0f9f90f..29f70ad 100644 --- a/jest.config.js +++ b/jest.config.js @@ -6,7 +6,7 @@ module.exports = { roots: ['/src'], preset: 'ts-jest', testRegex: '(.*.(test|spec)).(jsx?|tsx?|ts?)$', - moduleFileExtensions: ['ts', 'js', 'json'], + moduleFileExtensions: ['ts', 'js', 'json', 'tsx'], setupFilesAfterEnv: ['./src/tests/setupTests.ts'], collectCoverage: true, collectCoverageFrom: [ diff --git a/src/app/DevHistory/Calendar.tsx b/src/app/DevHistory/Calendar.tsx new file mode 100644 index 0000000..78c2a91 --- /dev/null +++ b/src/app/DevHistory/Calendar.tsx @@ -0,0 +1,317 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +import React, { Fragment, useEffect, useRef } from 'react' +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid' +import { IncrementAnalysis } from '../../electron/devHistory/models/IncrementAnalysis' +import { ScheduledItem } from './Components/ScheduleItem' + +const days = [ + { date: '2021-12-27' }, + { date: '2021-12-28' }, + { date: '2021-12-29' }, + { date: '2021-12-30' }, + { date: '2021-12-31' }, + { date: '2022-01-01', isCurrentMonth: true }, + { date: '2022-01-02', isCurrentMonth: true }, + { date: '2022-01-03', isCurrentMonth: true }, + { date: '2022-01-04', isCurrentMonth: true }, + { date: '2022-01-05', isCurrentMonth: true }, + { date: '2022-01-06', isCurrentMonth: true }, + { date: '2022-01-07', isCurrentMonth: true }, + { date: '2022-01-08', isCurrentMonth: true }, + { date: '2022-01-09', isCurrentMonth: true }, + { date: '2022-01-10', isCurrentMonth: true }, + { date: '2022-01-11', isCurrentMonth: true }, + { date: '2022-01-12', isCurrentMonth: true }, + { date: '2022-01-13', isCurrentMonth: true }, + { date: '2022-01-14', isCurrentMonth: true }, + { date: '2022-01-15', isCurrentMonth: true }, + { date: '2022-01-16', isCurrentMonth: true }, + { date: '2022-01-17', isCurrentMonth: true }, + { date: '2022-01-18', isCurrentMonth: true }, + { date: '2022-01-19', isCurrentMonth: true }, + { date: '2022-01-20', isCurrentMonth: true, isToday: true }, + { date: '2022-01-21', isCurrentMonth: true }, + { date: '2022-01-22', isCurrentMonth: true, isSelected: true }, + { date: '2022-01-23', isCurrentMonth: true }, + { date: '2022-01-24', isCurrentMonth: true }, + { date: '2022-01-25', isCurrentMonth: true }, + { date: '2022-01-26', isCurrentMonth: true }, + { date: '2022-01-27', isCurrentMonth: true }, + { date: '2022-01-28', isCurrentMonth: true }, + { date: '2022-01-29', isCurrentMonth: true }, + { date: '2022-01-30', isCurrentMonth: true }, + { date: '2022-01-31', isCurrentMonth: true }, + { date: '2022-02-01' }, + { date: '2022-02-02' }, + { date: '2022-02-03' }, + { date: '2022-02-04' }, + { date: '2022-02-05' }, + { date: '2022-02-06' }, +] +const times = [ + '12AM', + '1AM', + '2AM', + '3AM', + '4AM', + '5AM', + '6AM', + '7AM', + '8AM', + '9AM', + '10AM', + '11AM', + '12PM', + '1PM', + '2PM', + '3PM', + '4PM', + '5PM', + '6PM', + '7PM', + '8PM', + '9PM', + '10PM', + '11PM', +] + +function classNames(...classes: (string | boolean | undefined)[]) { + return classes.filter(Boolean).join(' ') +} +export function calculateTimeDegree(inputTime: Date) { + // Calculate the integer representation within the range [0, 288] + const integerRepresentation = Math.min( + Math.max(0, (inputTime.getHours() * 60 + inputTime.getMinutes()) / 5), + 288, + ) + + return integerRepresentation +} +export default function Calendar({ + analysis, +}: { + analysis: IncrementAnalysis[] +}) { + const container = useRef(null) + const containerNav = useRef(null) + const containerOffset = useRef(null) + + useEffect(() => { + // Set the container scroll position based on the current time. + const currentMinute = new Date().getHours() * 60 + if ( + container === null || + container.current === null || + containerNav === null || + containerNav.current === null || + containerOffset === null || + containerOffset.current === null + ) { + return + } + + container.current.scrollTop = + ((container.current.scrollHeight - + containerNav.current.offsetHeight - + containerOffset.current.offsetHeight) * + currentMinute) / + 1440 + }, [analysis]) + + return ( +
+
+
+ + + + + + + +
+
+
+
+ {/* Horizontal lines */} +
+
+ {times.map(time => { + return ( + +
+
+ {time} +
+
+
+ + ) + })} +
+ + {/* Events */} +
    + {analysis.map((item, index) => { + if (!item.raw.analysis?.summary) { + return null + } + const startDegree = calculateTimeDegree( + item.increment.startDate, + ) + const spanDegree = + calculateTimeDegree(item.increment.endDate) - startDegree + + return ( +
  1. + +
  2. + ) + })} +
+
+
+
+
+
+ +
January 2022
+ +
+
+
M
+
T
+
W
+
T
+
F
+
S
+
S
+
+
+ {days.map((day, dayIdx) => ( + + ))} +
+
+
+ ) +} diff --git a/src/app/DevHistory/Components/DiscreteDayNav.tsx b/src/app/DevHistory/Components/DiscreteDayNav.tsx new file mode 100644 index 0000000..7fe8fd6 --- /dev/null +++ b/src/app/DevHistory/Components/DiscreteDayNav.tsx @@ -0,0 +1,47 @@ +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline' +import isToday from 'date-fns/isToday' + +export function DiscreteDayNav({ + date, + setSelectedDate, +}: { + date: Date + setSelectedDate: React.Dispatch> +}) { + return ( +
+ + + + +
+ ) +} diff --git a/src/app/DevHistory/Components/ScheduleItem.tsx b/src/app/DevHistory/Components/ScheduleItem.tsx new file mode 100644 index 0000000..98cd44b --- /dev/null +++ b/src/app/DevHistory/Components/ScheduleItem.tsx @@ -0,0 +1,30 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +import { CategoryEnum } from '../../../electron/devHistory/services/openai-service' +import { colorMap, defaultColor } from './colorMap' + +export function ScheduledItem({ + item, +}: { + item: { category: CategoryEnum; text: string } +}) { + const color = colorMap.get(item.category) || defaultColor + return ( + <> + + {/*

+ {item.category} +

*/} +

+ + {'[ '} + {item.category} {' ] - '} + + {item.text} +

+
+ + ) +} diff --git a/src/app/DevHistory/Components/TimeScaleMenu.tsx b/src/app/DevHistory/Components/TimeScaleMenu.tsx new file mode 100644 index 0000000..a903bd8 --- /dev/null +++ b/src/app/DevHistory/Components/TimeScaleMenu.tsx @@ -0,0 +1,98 @@ +import { Menu, Transition } from '@headlessui/react' +import { ChevronDownIcon } from '@heroicons/react/24/outline' +import clsx from 'clsx' +import { Fragment } from 'react' + +/* eslint-disable jsx-a11y/anchor-is-valid */ +export function TimeScaleMenu() { + return ( +
+ + + Day view + + + + +
+ + {({ active }) => ( + + Day view + + )} + + + {({ active }) => ( + + Week view + + )} + + + {({ active }) => ( + + Month view + + )} + + + {({ active }) => ( + + Year view + + )} + +
+
+
+
+
+ +
+ ) +} diff --git a/src/app/DevHistory/Components/colorMap.ts b/src/app/DevHistory/Components/colorMap.ts new file mode 100644 index 0000000..b3017ab --- /dev/null +++ b/src/app/DevHistory/Components/colorMap.ts @@ -0,0 +1,61 @@ +import { CategoryEnum } from '../../../electron/devHistory/services/openai-service' + +export const defaultColor = { + text: 'text-stone-500', + textDark: 'text-stone-700', + hoverTextDark: 'group-hover:text-stone-700', + bg: 'bg-stone-50', + bgDark: 'hover:bg-stone-100', +} + +export const colorMap = new Map< + CategoryEnum, + { + text: string + textDark: string + bg: string + hoverTextDark: string + bgDark: string + } +>([ + [ + 'marketing', + { + text: 'text-sky-500', + textDark: 'text-sky-700', + bg: 'bg-sky-50', + hoverTextDark: 'group-hover:text-sky-700', + bgDark: 'hover:bg-sky-100', + }, + ], + [ + 'software-development', + { + text: 'text-green-500', + textDark: 'text-green-700', + bg: 'bg-green-50', + hoverTextDark: 'group-hover:text-green-700', + bgDark: 'hover:bg-green-100', + }, + ], + [ + 'sales', + { + text: 'text-rose-500', + textDark: 'text-rose-700', + bg: 'bg-rose-50', + hoverTextDark: 'group-hover:text-rose-700', + bgDark: 'hover:bg-rose-100', + }, + ], + [ + 'personal', + { + text: 'text-purple-500', + textDark: 'text-purple-700', + bg: 'bg-purple-50', + hoverTextDark: 'group-hover:text-purple-700', + bgDark: 'hover:bg-purple-100', + }, + ], +]) diff --git a/src/app/DevHistory/DevHistoryScreen.tsx b/src/app/DevHistory/DevHistoryScreen.tsx index 7c38df0..a171af7 100644 --- a/src/app/DevHistory/DevHistoryScreen.tsx +++ b/src/app/DevHistory/DevHistoryScreen.tsx @@ -4,11 +4,14 @@ import PageHeader from '../components/PageHeader' import { DocumentCheckIcon } from '@heroicons/react/24/outline' import { useDevHistoryGetDay } from './ReactQueryWrappers' import { ConsoleContext } from '../ConsoleArea/ConsoleContext' +import Calendar from './Calendar' +import { DiscreteDayNav } from './Components/DiscreteDayNav' export function DevHistoryScreen() { const [_logMessages, logAMessage] = useContext(ConsoleContext) + const [selectedDate, setSelectedDate] = React.useState(new Date()) // - const { data, isLoading } = useDevHistoryGetDay({ date: new Date() }) + const { data, isLoading } = useDevHistoryGetDay({ date: selectedDate }) let control: ReactElement | undefined = undefined const onRefreshClick = ( @@ -17,18 +20,59 @@ export function DevHistoryScreen() { e.preventDefault() logAMessage({ message: 'Refresh Clicked', level: 'info' }) } - if (!isLoading && data) { - control = ( -
-
-
{JSON.stringify(data, null, 2)}
- {data.analysis.map((item, index) => { - return

{item.summary}

- })} + control = ( +
+
+
+

+ + +

+

+ {selectedDate.toLocaleDateString(undefined, { + weekday: 'long', + })} +

-
- ) - } +
+ {isLoading ? ( +
+ + Loading... +
+ ) : null} + + {/* */} +
+ + {data ? : null} +
+ ) return (
diff --git a/src/app/DevHistory/ReactQueryWrappers.ts b/src/app/DevHistory/ReactQueryWrappers.ts index c9bb58a..c7b11e0 100644 --- a/src/app/DevHistory/ReactQueryWrappers.ts +++ b/src/app/DevHistory/ReactQueryWrappers.ts @@ -8,7 +8,7 @@ export const wellKnownQueries = { export function useDevHistoryGetDay({ date }: { date: Date }) { const [_logMessages, logAMessage] = useContext(ConsoleContext) return useQuery( - [wellKnownQueries.getSingleDay], + [wellKnownQueries.getSingleDay, date.toISOString()], async () => { return window.GetDevHistorySingleDay.invoke({ date: date, diff --git a/src/app/DevHistory/time-degrees.test.ts b/src/app/DevHistory/time-degrees.test.ts new file mode 100644 index 0000000..2366241 --- /dev/null +++ b/src/app/DevHistory/time-degrees.test.ts @@ -0,0 +1,20 @@ +import { calculateTimeDegree } from './Calendar' + +describe('time-degrees', () => { + const date = new Date() + it('should return 0 degrees for 12:00', () => { + date.setHours(0) + date.setMinutes(0) + expect(calculateTimeDegree(date)).toBe(0) + }) + it('should return 12 degrees for 1:00', () => { + date.setHours(1) + date.setMinutes(0) + expect(calculateTimeDegree(date)).toBe(12) + }) + it('should return 18 degrees for 1:30', () => { + date.setHours(1) + date.setMinutes(30) + expect(calculateTimeDegree(date)).toBe(18) + }) +}) diff --git a/src/electron/devHistory/channels/DevHistoryGetDayChannelSub.ts b/src/electron/devHistory/channels/DevHistoryGetDayChannelSub.ts index e50ccc7..dded25d 100644 --- a/src/electron/devHistory/channels/DevHistoryGetDayChannelSub.ts +++ b/src/electron/devHistory/channels/DevHistoryGetDayChannelSub.ts @@ -18,7 +18,7 @@ export class DevHistoryGetDayChannelSub ): Promise { // read the list of entries from the chrome history sqlite database // return the list of entries - const analysis = await analyseDay(new Date()) + const analysis = await analyseDay(request.date) console.log(analysis) return { analysis, diff --git a/src/electron/devHistory/services/openai-service.ts b/src/electron/devHistory/services/openai-service.ts index a3fcaad..4c8335c 100644 --- a/src/electron/devHistory/services/openai-service.ts +++ b/src/electron/devHistory/services/openai-service.ts @@ -5,6 +5,15 @@ import { HistoryEntry } from '../models/HistoryEntry' import { encode } from 'gpt-3-encoder' import z from 'zod' +const CategoryEnumSchema = z.enum([ + 'software-development', + 'marketing', + 'sales', + 'other', + 'personal', +]) +export type CategoryEnum = z.infer +// 'Salmon' | 'Tuna' | 'Trout' export const IncrementGPTResponseSchema = z.object({ tokensUsed: z.number(), errorMessage: z.string().optional(), @@ -12,7 +21,7 @@ export const IncrementGPTResponseSchema = z.object({ summary: z .object({ text: z.string(), - category: z.string(), + category: CategoryEnumSchema, }) .or(z.undefined()), }) @@ -31,17 +40,30 @@ export async function runChatCompletion( const browserHistoryItems = request.historyItems .filter(x => x.type === 'browser history') .map(x => x.metadata) - + const completedSummaries = [] if (browserHistoryItems.length > 0) { - return await runBrowserHistoryCompletion(browserHistoryItems, openai) + completedSummaries.push( + runBrowserHistoryCompletion(browserHistoryItems, openai), + ) } + const codeHistoryItems = request.historyItems + .filter(x => x.type === 'git commit') + .map(x => x.metadata) - return { - tokensUsed: 0, - summary: undefined, - finishReason: undefined, - errorMessage: 'No handler to run for the provided history items', + if (codeHistoryItems.length > 0) { + completedSummaries.push(runCodeDiffCompletion(codeHistoryItems, openai)) } + const allSummaries = await Promise.all(completedSummaries) + if (allSummaries.length === 1) { + return allSummaries[0] + } + + const summaryCompletion = await runCombinationSummaryCompletion( + allSummaries, + openai, + ) + // kinda lying about tokens used here + return summaryCompletion } catch (error) { // try not to throw from here. makes the path easier to follow in callers // the http library will throw on 400s from cat gpt but these are handlable errors @@ -60,6 +82,91 @@ export async function runChatCompletion( } } +async function runCombinationSummaryCompletion( + responses: IncrementGPTResponse[], + openai: OpenAI, +): Promise { + const chatMessages: ChatMessage[] = [ + { + role: 'user', + content: `Summaries: ${JSON.stringify( + responses.map(x => x.summary?.text).join('\n'), + )} + + I want to condense the summaries provided into one paragraph. + + You will generate a concise, entity-dense summary. The summary should condense all items, do not write a summary for individual items. + + Guidelines: + + - The summary should be 2-3 sentences, ~25 words, highly specific and information dense. Do not use filler if it takes up words from information. + - Only return the summary, do not explain + `, + }, + ] + const model = selectBestModel(chatMessages) + + const completion = await openai.chat.completions.create({ + model: model, + messages: chatMessages, + }) + + const extractedSummary = completion.choices[0].message.content + + if (!extractedSummary) { + throw new Error(`No summary generated`) + } + + return { + tokensUsed: completion.usage?.total_tokens || 0, + finishReason: completion.choices[0].finish_reason, + summary: { text: extractedSummary, category: 'software-development' }, + } +} + +async function runCodeDiffCompletion( + codeHistoryItems: any, + openai: OpenAI, +): Promise { + console.log('running code history completion') + const chatMessages: ChatMessage[] = [ + { + role: 'user', + content: `Git commits for coding session: ${JSON.stringify( + codeHistoryItems, + )} + + I want to summarise the git commits for the coding session provided into one paragraph. + + You will generate a concise, entity-dense summary of the overall session. The summary should cover all items, do not write a summary for individual items. + + Guidelines: + + - The summary should be 2-3 sentences, ~25 words, highly specific and information dense. Do not use filler if it takes up words from information. + - Only return the summary, do not explain + - do not use subjects like "The user's session...", instead use "Coding session..."`, + }, + ] + const model = selectBestModel(chatMessages) + + const completion = await openai.chat.completions.create({ + model: model, + messages: chatMessages, + }) + + const extractedSummary = completion.choices[0].message.content + + if (!extractedSummary) { + throw new Error(`No summary generated`) + } + + return { + tokensUsed: completion.usage?.total_tokens || 0, + finishReason: completion.choices[0].finish_reason, + summary: { text: extractedSummary, category: 'software-development' }, + } +} + async function runBrowserHistoryCompletion( browserHistoryItems: any, openai: OpenAI, @@ -90,9 +197,7 @@ async function runBrowserHistoryCompletion( const extractedSummary = completion.choices[0].message.content if (!extractedSummary) { - throw new Error( - `No summary generated for ${JSON.stringify(browserHistoryItems)}`, - ) + throw new Error(`No summary generated`) } chatMessages.push({ @@ -117,7 +222,10 @@ async function runBrowserHistoryCompletion( return { tokensUsed: categoryCompletion.usage?.total_tokens || 0, finishReason: completion.choices[0].finish_reason, - summary: { text: extractedSummary, category: extractedCategory || 'other' }, + summary: { + text: extractedSummary, + category: (extractedCategory || 'other') as CategoryEnum, // kinda hacky + }, } }