From 85f5fe446ce13858dc4afdd4d9f09264cb15add0 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 31 Jan 2025 18:28:25 +0545 Subject: [PATCH 01/27] Use command pattern to update entries --- src/App/index.tsx | 50 ++++- src/contexts/command.tsx | 20 ++ src/hooks/useCommand.ts | 169 ++++++++++++++ src/utils/command.ts | 262 ++++++++++++++++++++++ src/views/DailyJournal/index.tsx | 373 ++++++++++++++++--------------- 5 files changed, 683 insertions(+), 191 deletions(-) create mode 100644 src/contexts/command.tsx create mode 100644 src/hooks/useCommand.ts create mode 100644 src/utils/command.ts diff --git a/src/App/index.tsx b/src/App/index.tsx index 53db948..790dd57 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -19,6 +19,7 @@ import { useQuery, } from 'urql'; +import CommandContext, { CommandContextProps } from '#contexts/command'; import DateContext from '#contexts/date'; import EnumsContext, { EnumsContextProps } from '#contexts/enums'; import LocalStorageContext, { LocalStorageContextProps } from '#contexts/localStorage'; @@ -36,10 +37,14 @@ import { MeQueryVariables, } from '#generated/types/graphql'; import useThrottledValue from '#hooks/useThrottledValue'; +import { Command } from '#utils/command'; import { getWindowSize } from '#utils/common'; import { defaultConfigValue } from '#utils/constants'; import { getFromStorage } from '#utils/localStorage'; -import { ConfigStorage } from '#utils/types'; +import { + ConfigStorage, + WorkItem, +} from '#utils/types'; import wrappedRoutes, { unwrappedRoutes } from './routes'; @@ -115,7 +120,7 @@ const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouter( const router = sentryCreateBrowserRouter(unwrappedRoutes); function App() { - // Date + // DATE const [date, setDate] = useState(() => { const today = new Date(); @@ -154,7 +159,7 @@ function App() { [], ); - // Local Storage + // LOCAL STORAGE const [storageState, setStorageState] = useState(() => { const configValue = getFromStorage('timur-config'); @@ -201,7 +206,7 @@ function App() { setStorageState: handleStorageStateUpdate, }), [storageState, handleStorageStateUpdate]); - // Device Size + // DEVICE SIZE const [size, setSize] = useState(getWindowSize); const throttledSize = useThrottledValue(size); @@ -217,7 +222,7 @@ function App() { }; }, []); - // Authentication + // AUTHENTICATION const [userAuth, setUserAuth] = useState(); const [ready, setReady] = useState(false); @@ -250,7 +255,7 @@ function App() { [userAuth, removeUserAuth], ); - // Enums + // ENUMS const [enumsResult] = useQuery( { @@ -278,7 +283,26 @@ function App() { [enumsResult], ); - // Page layouts + // WORK ITEMS + + const commands = useRef[]>([]); + const zeitgeist = useRef(0); + const watch = useCallback( + (command: Command) => { + // TODO: Create a command queue + // eslint-disable-next-line no-console + console.info('Command', command); + }, + [], + ); + + const commandState = useMemo((): CommandContextProps => ({ + commands, + zeitgeist, + watch, + }), [watch]); + + // PAGE LAYOUTS const navbarStartActionRef = useRef(null); const navbarMidActionRef = useRef(null); @@ -290,7 +314,7 @@ function App() { endActionsRef: navbarEndActionRef, }), []); - // Route + // ROUTE const fallbackElement = (
@@ -316,10 +340,12 @@ function App() { - + + + diff --git a/src/contexts/command.tsx b/src/contexts/command.tsx new file mode 100644 index 0000000..51f65db --- /dev/null +++ b/src/contexts/command.tsx @@ -0,0 +1,20 @@ +import { createContext } from 'react'; + +import { type Command } from '#utils/command'; +import { type WorkItem } from '#utils/types'; + +export interface CommandContextProps { + commands: React.MutableRefObject[]>, + zeitgeist: React.MutableRefObject, + watch: (command: Command) => void, +} + +const CommandContext = createContext({ + commands: { current: [] }, + zeitgeist: { current: 0 }, + watch: () => { + // eslint-disable-next-line no-console + console.warn('CommandContext.watch called without initializing provider'); + }, +}); +export default CommandContext; diff --git a/src/hooks/useCommand.ts b/src/hooks/useCommand.ts new file mode 100644 index 0000000..1f4ee07 --- /dev/null +++ b/src/hooks/useCommand.ts @@ -0,0 +1,169 @@ +import { + useCallback, + useState, +} from 'react'; + +import { + act, + backward, + Command, + forward, +} from '#utils/command'; + +function initializeEntries(args: { + commands: Command[], + zeitgeist: number, + entries: T[], + keySelector: (entry: T) => K, + filter: (entry: T) => boolean, +}) { + const { + commands, + zeitgeist, + entries, + filter, + keySelector, + } = args; + + let currentEntries = entries; + + // NOTE: When setting up, we need to apply all the commands + if (commands && commands.length > 0) { + const { entries: newEntries } = forward({ + entries, + commands, + keySelector, + // We are applying the commands from 0 to zeitgeist + zeitgeist: 0, + watch: undefined, + }, zeitgeist); + + currentEntries = newEntries; + } + + // NOTE: After that we need to also clear out entries that do no match + if (filter && currentEntries.length > 0) { + currentEntries = currentEntries.filter(filter); + } + + return currentEntries; +} + +function useCommand(props: { + defaultEntries: T[], + keySelector: (entry: T) => K, + filter: (entry: T) => boolean, + commands: React.MutableRefObject[]>, + zeitgeist: React.MutableRefObject, + watch: (command: Command) => void, +}) { + const { + defaultEntries, + keySelector, + filter, + commands, + zeitgeist, + watch, + } = props; + + const [entries, setEntries] = useState(() => { + const newEntries = initializeEntries({ + entries: defaultEntries, + keySelector, + filter, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + commands: commands.current!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + zeitgeist: zeitgeist.current!, + }); + return newEntries; + }); + + const handleEntriesSet = useCallback( + (e: T[]) => { + const newEntries = initializeEntries({ + entries: e, + keySelector, + filter, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + commands: commands.current!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + zeitgeist: zeitgeist.current!, + }); + setEntries(newEntries); + }, + [commands, filter, keySelector, zeitgeist], + ); + + const handleRedo = useCallback( + () => { + const newState = forward( + { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + commands: commands.current!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + zeitgeist: zeitgeist.current!, + watch, + entries, + keySelector, + }, + 1, + ); + zeitgeist.current = newState.zeitgeist; + setEntries(newState.entries); + }, + [commands, entries, keySelector, watch, zeitgeist], + ); + + const handleUndo = useCallback( + () => { + const newState = backward( + { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + commands: commands.current!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + zeitgeist: zeitgeist.current!, + watch, + entries, + keySelector, + }, + 1, + ); + zeitgeist.current = newState.zeitgeist; + setEntries(newState.entries); + }, + [commands, entries, keySelector, watch, zeitgeist], + ); + + const handleUpdate = useCallback( + (command: Command) => { + // TODO: Debounce this if id and type is the same and also check temporal + const newState = act( + { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + commands: commands.current!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + zeitgeist: zeitgeist.current!, + watch, + entries, + keySelector, + }, + command, + ); + zeitgeist.current = newState.zeitgeist; + commands.current = newState.commands; + setEntries(newState.entries); + }, + [commands, entries, keySelector, watch, zeitgeist], + ); + + return { + entries, + setEntries: handleEntriesSet, + undo: handleUndo, + redo: handleRedo, + update: handleUpdate, + }; +} + +export default useCommand; diff --git a/src/utils/command.ts b/src/utils/command.ts new file mode 100644 index 0000000..0d5a3fe --- /dev/null +++ b/src/utils/command.ts @@ -0,0 +1,262 @@ +export interface AddCommand { + type: 'add', + key: K; + newValue: T; + timestamp: number; +} + +export interface EditCommand { + type: 'edit', + key: K; + oldValue: Partial; + newValue: Partial; + timestamp: number; +} + +export interface DeleteCommand { + type: 'delete', + key: K; + oldValue: T; + timestamp: number; +} + +export type Command = AddCommand | EditCommand | DeleteCommand; + +interface State { + entries: E[], + commands: Command[], + zeitgeist: number; + keySelector: (item: E) => K, + + watch: ((item: Command) => void) | undefined, +} + +export function forward(state: State, to: number): State { + const { + entries, + commands, + zeitgeist, + keySelector, + watch, + } = state; + + const newEntries = [...entries]; + const newZeitgeist = zeitgeist + to; + + if (newZeitgeist > commands.length) { + // eslint-disable-next-line no-console + console.error('Cannot go forward'); + return state; + } + + commands.slice(zeitgeist, newZeitgeist).forEach((command) => { + if (command.type === 'add') { + // TODO: Need to check if adding a item with duplicate key + newEntries.push(command.newValue); + watch?.(command); + } else if (command.type === 'delete') { + const index = newEntries.findIndex((item) => keySelector(item) === command.key); + if (index === -1) { + // eslint-disable-next-line no-console + console.error(`Could not find item ${command.key} while deleting`); + return; + } + newEntries.splice(index, 1); + watch?.(command); + } else if (command.type === 'edit') { + const index = newEntries.findIndex((item) => keySelector(item) === command.key); + if (index === -1) { + // eslint-disable-next-line no-console + console.error(`Could not find item ${command.key} while editing`); + return; + } + newEntries[index] = { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...newEntries[index]!, + ...command.newValue, + }; + watch?.(command); + } + }); + + const newState = { + ...state, + entries: newEntries, + zeitgeist: newZeitgeist, + }; + return newState; +} + +export function backward(state: State, to: number): State { + const { + entries, + commands, + zeitgeist, + keySelector, + watch, + } = state; + + const newZeitgeist = zeitgeist - to; + + if (newZeitgeist < 0) { + // eslint-disable-next-line no-console + console.error('Cannot go backward'); + return state; + } + + const newEntries = [...entries]; + + commands.slice(newZeitgeist, zeitgeist).reverse().forEach((command) => { + if (command.type === 'delete') { + newEntries.push(command.oldValue); + watch?.({ + type: 'add', + key: command.key, + newValue: command.oldValue, + timestamp: new Date().getTime(), + }); + } else if (command.type === 'add') { + const index = newEntries.findIndex((item) => keySelector(item) === command.key); + if (index === -1) { + // eslint-disable-next-line no-console + console.error(`Could not find item ${command.key} while deleting`); + return; + } + newEntries.splice(index, 1); + watch?.({ + type: 'delete', + key: command.key, + oldValue: command.newValue, + timestamp: new Date().getTime(), + }); + } else if (command.type === 'edit') { + const index = newEntries.findIndex((item) => keySelector(item) === command.key); + if (index === -1) { + // eslint-disable-next-line no-console + console.error(`Could not find item ${command.key} while editing`); + return; + } + newEntries[index] = { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...newEntries[index]!, + ...command.oldValue, + }; + watch?.({ + type: 'edit', + key: command.key, + oldValue: command.newValue, + newValue: command.oldValue, + timestamp: new Date().getTime(), + }); + } + }); + + const newState = { + ...state, + entries: newEntries, + zeitgeist: newZeitgeist, + }; + return newState; +} + +function isMergeable(foo: Command, bar: Command) { + return ( + foo.type === bar.type + && foo.key === bar.key + && Math.abs(foo.timestamp - bar.timestamp) <= 300 + ); +} + +function squash(foo: T[]) { + if (foo.length <= 0) { + return undefined; + } + return foo.reduce((acc, val) => ({ + ...acc, + ...val, + }), foo[0]); +} + +export function act(state: State, command: Command): State { + const { + commands, + zeitgeist, + } = state; + + const newHistory = [ + ...commands.slice(0, zeitgeist), + command, + ]; + + const newState = forward({ + ...state, + commands: newHistory, + }, 1); + + if (newState.commands.length < 2) { + return newState; + } + + // NOTE: We want to merge similar actions so that we do not have a lot of actions. + let cursor = newState.commands.length - 1; + do { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const currentElement = newState.commands[cursor]!; + const prevElement = newState.commands[cursor - 1]; + if (!prevElement) { + break; + } + const shouldContinue = isMergeable( + prevElement, + currentElement, + ); + if (!shouldContinue) { + break; + } + cursor -= 1; + } while (cursor >= 0); + + if (cursor === newState.commands.length - 1) { + return newState; + } + + // NOTE: These are all update actions only + const mergedAction: EditCommand = { + type: 'edit', + oldValue: squash( + newState.commands + .slice(cursor, newState.commands.length) + .reverse() + // FIXME: Need to add cast here + .map((c) => (c as EditCommand).oldValue), + ) ?? ({} as Partial), + newValue: squash( + newState.commands + .slice(cursor, newState.commands.length) + // FIXME: Need to add cast here + .map((c) => (c as EditCommand).newValue), + ) ?? ({} as Partial), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + key: newState.commands[newState.commands.length - 1]!.key, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + timestamp: newState.commands[newState.commands.length - 1]!.timestamp, + }; + + const actualCommands = [...newState.commands.slice(0, cursor), mergedAction]; + const actualZeitgeist = cursor + 1; + + const newestState = { + ...newState, + commands: actualCommands, + zeitgeist: actualZeitgeist, + }; + + return newestState; +} + +export function pick(obj: O, keys: K[]): Partial { + return keys.reduce((acc, key) => ({ + ...acc, + [key]: obj[key], + }), {}); +} diff --git a/src/views/DailyJournal/index.tsx b/src/views/DailyJournal/index.tsx index c0bab82..f6cd47b 100644 --- a/src/views/DailyJournal/index.tsx +++ b/src/views/DailyJournal/index.tsx @@ -31,7 +31,6 @@ import { } from '@togglecorp/fujs'; import { gql, - useMutation, useQuery, } from 'urql'; @@ -41,21 +40,21 @@ import CalendarInput from '#components/CalendarInput'; import Link, { resolvePath } from '#components/Link'; import Page from '#components/Page'; import Portal from '#components/Portal'; +import CommandContext from '#contexts/command'; import DateContext from '#contexts/date'; import FocusContext from '#contexts/focus'; import NavbarContext from '#contexts/navbar'; import RouteContext from '#contexts/route'; import SizeContext from '#contexts/size'; import { - BulkTimeEntryMutation, - BulkTimeEntryMutationVariables, MyTimeEntriesQuery, MyTimeEntriesQueryVariables, } from '#generated/types/graphql'; -import useBackgroundSync from '#hooks/useBackgroundSync'; +import useCommand from '#hooks/useCommand'; import { useFocusManager } from '#hooks/useFocus'; import useKeybind from '#hooks/useKeybind'; import useLocalStorage from '#hooks/useLocalStorage'; +import { pick } from '#utils/command'; import { addDays, getNewId, @@ -142,6 +141,7 @@ query MyQuery { } */ +/* const BULK_TIME_ENTRY_MUTATION = gql` mutation BulkTimeEntry($timeEntries: [TimeEntryBulkCreateInput!], $deleteIds: [ID!]) { private { @@ -169,49 +169,79 @@ const BULK_TIME_ENTRY_MUTATION = gql` } } `; +*/ // TODO: Do not use JSON.stringify for comparison // TODO: use filtered localState instead of workItems /** @knipignore */ // eslint-disable-next-line import/prefer-default-export export function Component() { - const [workItems, setWorkItems] = useState([]); - const [tasks, setTasks] = useState([]); - const routes = useContext(RouteContext); - const navigate = useNavigate(); - - const { - focus, - register, - unregister, - } = useFocusManager(); - const { date: dateFromParams } = useParams<{ date: string | undefined}>(); const { fullDate } = useContext(DateContext); - - // NOTE: We are opening the dialog from this parent component - const dialogOpenTriggerRef = useRef<(() => void) | undefined>(); - const noteDialogOpenTriggerRef = useRef<(() => void) | undefined>(); - const shortcutsDialogOpenTriggerRef = useRef<(() => void) | undefined>(); - const availabilityDialogOpenTriggerRef = useRef<(() => void) | undefined>(); - const calendarRef = useRef< - { resetView:(year: number, month: number) => void; } - >(null); - const selectedDate = useMemo(() => { if (isNotDefined(dateFromParams)) { return fullDate; } const date = new Date(dateFromParams); - if (Number.isNaN(date.getTime())) { return fullDate; } - return encodeDate(date); }, [dateFromParams, fullDate]); + const navigate = useNavigate(); + const routes = useContext(RouteContext); + const { midActionsRef } = useContext(NavbarContext); + const { screen } = useContext(SizeContext); + + // State + const filter = useCallback( + (entry: WorkItem) => entry.date === selectedDate, + [selectedDate], + ); + const keySelector = useCallback( + (entry: WorkItem) => entry.clientId, + [], + ); + + const { zeitgeist, commands, watch } = useContext(CommandContext); + + const { + entries: workItems, + setEntries: setWorkItems, + update: setWorkItemChange, + redo, + undo, + } = useCommand({ + defaultEntries: [], + filter, + commands, + keySelector, + watch, + zeitgeist, + }); + + const [tasks, setTasks] = useState([]); + const [storedConfig] = useLocalStorage('timur-config'); + + // UI + const { + focus, + register, + unregister, + } = useFocusManager(); + + // NOTE: We are opening the dialog from this parent component + interface CalendarElement { + resetView:(year: number, month: number) => void; + } + const dialogOpenTriggerRef = useRef<(() => void) | undefined>(); + const noteDialogOpenTriggerRef = useRef<(() => void) | undefined>(); + const shortcutsDialogOpenTriggerRef = useRef<(() => void) | undefined>(); + const availabilityDialogOpenTriggerRef = useRef<(() => void) | undefined>(); + const calendarRef = useRef(null); + useEffect( () => { if (calendarRef.current && selectedDate) { @@ -225,41 +255,7 @@ export function Component() { [selectedDate], ); - const setSelectedDate = useCallback((newDateStr: string | undefined) => { - const newDate = newDateStr === fullDate ? undefined : newDateStr; - - const { resolvedPath } = resolvePath('dailyJournal', routes, { date: newDate }); - if (isNotDefined(resolvedPath)) { - return; - } - - navigate(resolvedPath); - }, [routes, navigate, fullDate]); - - const getNextDay = useCallback(() => { - const nextDay = addDays(selectedDate, 1); - - if (fullDate === nextDay) { - return undefined; - } - - return nextDay; - }, [selectedDate, fullDate]); - - const getPrevDay = useCallback(() => { - const prevDay = addDays(selectedDate, -1); - - if (fullDate === prevDay) { - return undefined; - } - - return prevDay; - }, [selectedDate, fullDate]); - - const [storedConfig] = useLocalStorage('timur-config'); - - const editMode = storedConfig.editingMode ?? defaultConfigValue.editingMode; - + /* const [ bulkMutationState, triggerBulkMutation, @@ -322,6 +318,7 @@ export function Component() { } = useBackgroundSync( handleBulkAction, ); + */ const [ myTimeEntriesResult, @@ -368,16 +365,16 @@ export function Component() { setTasks(tasksFromServer); setWorkItems(workItemsFromServer); - addOrUpdateServerData(workItemsFromServer); - addOrUpdateStateData(workItemsFromServer); + // addOrUpdateServerData(workItemsFromServer); + // addOrUpdateStateData(workItemsFromServer); }, [ myTimeEntriesResult.fetching, myTimeEntriesResult.data, myTimeEntriesResult.error, setWorkItems, - addOrUpdateServerData, - addOrUpdateStateData, + // addOrUpdateServerData, + // addOrUpdateStateData, ], ); @@ -392,137 +389,109 @@ export function Component() { date: selectedDate, }; - setWorkItems((oldWorkItems = []) => ([ - ...oldWorkItems, - newItem, - ])); - addOrUpdateStateData([newItem]); + setWorkItemChange({ + type: 'add', + key: newItem.clientId, + newValue: newItem, + timestamp: new Date().getTime(), + }); focus(String(newId)); }, [ - setWorkItems, - selectedDate, - storedConfig.defaultTaskStatus, storedConfig.defaultTaskType, + storedConfig.defaultTaskStatus, + selectedDate, + setWorkItemChange, focus, - addOrUpdateStateData, ], ); const handleWorkItemClone = useCallback( (workItemClientId: string, override?: Partial) => { + const oldItem = workItems.find((item) => item.clientId === workItemClientId); + if (!oldItem) { + // eslint-disable-next-line no-console + console.error(`Could not find item ${workItemClientId} while cloning`); + return; + } const newId = getNewId(); - setWorkItems((oldWorkItems) => { - if (isNotDefined(oldWorkItems)) { - return oldWorkItems; - } - - const sourceItemIndex = oldWorkItems - .findIndex(({ clientId }) => workItemClientId === clientId); - if (sourceItemIndex === -1) { - return oldWorkItems; - } - - const targetItem = { - // FIXME: This is safe as sourceItemIndex === -1 is already checked - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ...oldWorkItems[sourceItemIndex]!, - ...override, - clientId: newId, - }; - delete targetItem.id; - // NOTE: If we have defined overrides, we don't need to clear - // description and duration - if (!override) { - delete targetItem.description; - delete targetItem.duration; - } - - const newWorkItems = [...oldWorkItems]; - newWorkItems.splice(sourceItemIndex + 1, 0, targetItem); - addOrUpdateStateData(newWorkItems); + const newItem: WorkItem = { + ...oldItem, + clientId: newId, + }; + delete newItem.id; + // NOTE: If we have defined overrides, we don't need to clear + // description and duration + if (!override) { + delete newItem.description; + delete newItem.duration; + } - return newWorkItems; + setWorkItemChange({ + type: 'add', + key: newItem.clientId, + newValue: newItem, + timestamp: new Date().getTime(), }); + focus(String(newId)); }, - [setWorkItems, focus, addOrUpdateStateData], + [workItems, setWorkItemChange, focus], ); const handleWorkItemDelete = useCallback( (workItemClientId: string) => { - setWorkItems((oldWorkItems) => { - if (isNotDefined(oldWorkItems)) { - return oldWorkItems; - } - - const sourceItemIndex = oldWorkItems - .findIndex(({ clientId }) => workItemClientId === clientId); - if (sourceItemIndex === -1) { - return oldWorkItems; - } - - // FIXME: This is safe as sourceItemIndex === -1 is already checked - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const removedItem = oldWorkItems[sourceItemIndex]!; - removeFromStateData(removedItem.clientId); - - const newWorkItems = [...oldWorkItems]; - newWorkItems.splice(sourceItemIndex, 1); - - return newWorkItems; + const oldItem = workItems.find((item) => item.clientId === workItemClientId); + if (!oldItem) { + // eslint-disable-next-line no-console + console.error(`Could not find item ${workItemClientId} while deleting`); + return; + } + setWorkItemChange({ + type: 'delete', + key: workItemClientId, + oldValue: oldItem, + timestamp: new Date().getTime(), }); }, - [setWorkItems, removeFromStateData], + [setWorkItemChange, workItems], ); const handleWorkItemChange = useCallback( (workItemClientId: string, ...entries: EntriesAsList) => { - setWorkItems((oldWorkItems) => { - if (isNotDefined(oldWorkItems)) { - return oldWorkItems; - } - - const sourceItemIndex = oldWorkItems - .findIndex(({ clientId }) => workItemClientId === clientId); - - if (sourceItemIndex === -1) { - return oldWorkItems; - } - - // FIXME: This is safe as sourceItemIndex === -1 is already checked - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const obsoleteWorkItem = oldWorkItems[sourceItemIndex]!; - - const newWorkItem = { - ...obsoleteWorkItem, - [entries[1]]: entries[0], - }; - - if ( - isDefined(newWorkItem.duration) - && newWorkItem.duration > 0 - && obsoleteWorkItem.duration !== newWorkItem.duration - && newWorkItem.status === 'TODO' - ) { - newWorkItem.status = 'DOING'; - } - - addOrUpdateStateData([newWorkItem]); - - const newWorkItems = [...oldWorkItems]; + const oldItem = workItems.find((item) => item.clientId === workItemClientId); + if (!oldItem) { + // eslint-disable-next-line no-console + console.error(`Could not find item ${workItemClientId} while editing`); + return; + } + const changes: Partial = { + [entries[1]]: entries[0], + }; - newWorkItems.splice( - sourceItemIndex, - 1, - newWorkItem, - ); + const tentativeNewItem = { + ...oldItem, + ...changes, + }; - return newWorkItems; + if ( + isDefined(tentativeNewItem.duration) + && tentativeNewItem.duration > 0 + && oldItem.duration !== tentativeNewItem.duration + && tentativeNewItem.status === 'TODO' + ) { + tentativeNewItem.status = 'DOING'; + } + setWorkItemChange({ + type: 'edit', + key: workItemClientId, + oldValue: pick(oldItem, Object.keys(changes) as (keyof WorkItem)[]), + newValue: changes, + timestamp: new Date().getTime(), }); }, - [setWorkItems, addOrUpdateStateData], + [setWorkItemChange, workItems], ); const handleNoteUpdateClick = useCallback( @@ -561,6 +530,17 @@ export function Component() { [], ); + const setSelectedDate = useCallback((newDateStr: string | undefined) => { + const newDate = newDateStr === fullDate ? undefined : newDateStr; + + const { resolvedPath } = resolvePath('dailyJournal', routes, { date: newDate }); + if (isNotDefined(resolvedPath)) { + return; + } + + navigate(resolvedPath); + }, [routes, navigate, fullDate]); + const handleKeybindingsPress = useCallback( (event: KeyboardEvent) => { if (event.ctrlKey && (event.key === ' ' || event.code === 'Space')) { @@ -601,14 +581,6 @@ export function Component() { useKeybind(handleKeybindingsPress); - const focusContextValue = useMemo( - () => ({ - register, - unregister, - }), - [register, unregister], - ); - const handleDateSelection = useCallback( (newDate: string | undefined) => { setSelectedDate(newDate); @@ -616,9 +588,6 @@ export function Component() { [setSelectedDate], ); - const { midActionsRef } = useContext(NavbarContext); - const { screen } = useContext(SizeContext); - const handleSwipeLeft = useCallback( () => { handleDateSelection(addDays(selectedDate, 1)); @@ -633,6 +602,36 @@ export function Component() { [selectedDate, handleDateSelection], ); + const focusContextValue = useMemo( + () => ({ + register, + unregister, + }), + [register, unregister], + ); + + const getNextDay = useCallback(() => { + const nextDay = addDays(selectedDate, 1); + + if (fullDate === nextDay) { + return undefined; + } + + return nextDay; + }, [selectedDate, fullDate]); + + const getPrevDay = useCallback(() => { + const prevDay = addDays(selectedDate, -1); + + if (fullDate === prevDay) { + return undefined; + } + + return prevDay; + }, [selectedDate, fullDate]); + + const editMode = storedConfig.editingMode ?? defaultConfigValue.editingMode; + // FIXME: memoize this const filteredWorkItems = workItems.filter((item) => item.date === selectedDate); @@ -672,7 +671,7 @@ export function Component() {
+ + {entriesWithError > 0 && (
From df0f385070bb0d4a87014c6b7c56a5ff8e278997 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Sat, 1 Feb 2025 23:56:59 +0545 Subject: [PATCH 02/27] Break down App component - Implement saving changes in the server (in background) - Hide undo and redo if action is not available - Remove unused functions and files - Remove usage of confirm button on delete --- backend | 2 +- src/{ => App}/PwaPrompt/index.tsx | 0 src/{ => App}/PwaPrompt/styles.module.css | 0 src/App/index.tsx | 513 ++++++++++++++---- src/contexts/command.tsx | 14 + src/hooks/useBackgroundSync.ts | 311 ----------- src/hooks/useCommand.ts | 19 +- src/index.tsx | 38 +- src/utils/common.ts | 89 +-- src/utils/temporal.ts | 3 +- .../DayView/WorkItemRow/index.tsx | 13 +- src/views/DailyJournal/DayView/index.tsx | 1 - .../DailyJournal/UpdateNoteDialog/index.tsx | 2 - src/views/DailyJournal/index.tsx | 169 ++---- .../DailyStandup/ProjectSection/index.tsx | 1 + src/views/Settings/index.tsx | 2 +- 16 files changed, 474 insertions(+), 703 deletions(-) rename src/{ => App}/PwaPrompt/index.tsx (100%) rename src/{ => App}/PwaPrompt/styles.module.css (100%) delete mode 100644 src/hooks/useBackgroundSync.ts diff --git a/backend b/backend index 3f638e0..28ec051 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit 3f638e051c66d561b36758e28553a0f188f461bb +Subproject commit 28ec051a19ba791c88f82fbb5ea0ecc1fe78fbd2 diff --git a/src/PwaPrompt/index.tsx b/src/App/PwaPrompt/index.tsx similarity index 100% rename from src/PwaPrompt/index.tsx rename to src/App/PwaPrompt/index.tsx diff --git a/src/PwaPrompt/styles.module.css b/src/App/PwaPrompt/styles.module.css similarity index 100% rename from src/PwaPrompt/styles.module.css rename to src/App/PwaPrompt/styles.module.css diff --git a/src/App/index.tsx b/src/App/index.tsx index 790dd57..c9a33a2 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -12,10 +12,16 @@ import { import * as Sentry from '@sentry/react'; import { encodeDate, + isDefined, listToMap, } from '@togglecorp/fujs'; +import { cacheExchange } from '@urql/exchange-graphcache'; import { + Client as UrqlClient, + fetchExchange, gql, + Provider as UrqlProvider, + useMutation, useQuery, } from 'urql'; @@ -31,13 +37,20 @@ import UserContext, { UserContextProps, } from '#contexts/user'; import { + BulkTimeEntryMutation, + BulkTimeEntryMutationVariables, EnumsQuery, EnumsQueryVariables, MeQuery, MeQueryVariables, } from '#generated/types/graphql'; import useThrottledValue from '#hooks/useThrottledValue'; -import { Command } from '#utils/command'; +import { + AddCommand, + Command, + DeleteCommand, + EditCommand, +} from '#utils/command'; import { getWindowSize } from '#utils/common'; import { defaultConfigValue } from '#utils/constants'; import { getFromStorage } from '#utils/localStorage'; @@ -46,10 +59,32 @@ import { WorkItem, } from '#utils/types'; +import PwaPrompt from './PwaPrompt'; import wrappedRoutes, { unwrappedRoutes } from './routes'; import styles from './styles.module.css'; +const gqlClient = new UrqlClient({ + url: `${import.meta.env.APP_GRAPHQL_DOMAIN}/graphql/`, + exchanges: [cacheExchange({ + keys: { + PrivateQuery: () => null, + PublicQuery: () => null, + AppEnumCollection: () => null, + DailyStandUpType: () => null, + AppEnumCollectionTimeEntryType: (item) => String(item.key), + AppEnumCollectionTimeEntryStatus: (item) => String(item.key), + AppEnumCollectionJournalLeaveType: (item) => String(item.key), + AppEnumCollectionJournalWfhType: (item) => String(item.key), + DjangoImageType: (item) => String(item.url), + }, + }), fetchExchange], + fetchOptions: () => ({ + credentials: 'include', + }), + requestPolicy: 'network-only', +}); + const ME_QUERY = gql` query Me { public { @@ -113,14 +148,114 @@ const ENUMS_QUERY = gql` } `; +const BULK_TIME_ENTRY_MUTATION = gql` + mutation BulkTimeEntry($timeEntries: [TimeEntryBulkCreateInput!], $deleteIds: [ID!]) { + private { + bulkTimeEntry( + items: $timeEntries, + deleteIds: $deleteIds + ) { + deleted { + id + clientId + } + errors + results { + id + clientId + date + description + duration + startTime + status + taskId + type + } + } + } + } +`; + const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouter( createBrowserRouter, ); const router = sentryCreateBrowserRouter(unwrappedRoutes); -function App() { - // DATE +const fallbackElement = ( +
+ Timur Icon +
+); + +function isAddAction(item: Command): item is AddCommand { + return item.type === 'add'; +} + +function isEditAction(item: Command): item is EditCommand { + return item.type === 'edit'; +} +function isDeleteAction(item: Command): item is DeleteCommand { + return item.type === 'delete'; +} + +interface BaseProps { + children: React.ReactNode; +} + +function AuthProvider(props: BaseProps) { + const { children } = props; + + const [userAuth, setUserAuth] = useState(); + const [ready, setReady] = useState(false); + + const [meResult] = useQuery( + { query: ME_QUERY }, + ); + + useEffect(() => { + if (meResult.fetching) { + return; + } + setUserAuth(meResult.data?.public.me ?? undefined); + setReady(true); + }, [meResult.data, meResult.fetching]); + + const removeUserAuth = useCallback( + () => { + setUserAuth(undefined); + }, + [], + ); + + const userContextValue = useMemo( + () => ({ + userAuth, + setUserAuth, + removeUserAuth, + }), + [userAuth, removeUserAuth], + ); + + // NOTE: We should block page for authentication before we mount routes + if (!ready) { + // TODO: Handle error with authentication + return fallbackElement; + } + + return ( + + {children} + + ); +} + +function DateProvider(props: BaseProps) { + const { children } = props; const [date, setDate] = useState(() => { const today = new Date(); @@ -159,8 +294,59 @@ function App() { [], ); - // LOCAL STORAGE + return ( + + {children} + + ); +} +function NavbarProvider(props: BaseProps) { + const { children } = props; + const navbarStartActionRef = useRef(null); + const navbarMidActionRef = useRef(null); + const navbarEndActionRef = useRef(null); + + const navbarContextValue = useMemo(() => ({ + startActionsRef: navbarStartActionRef, + midActionsRef: navbarMidActionRef, + endActionsRef: navbarEndActionRef, + }), []); + + return ( + + {children} + + ); +} + +function SizeProvider(props: BaseProps) { + const { children } = props; + + const [size, setSize] = useState(getWindowSize); + const throttledSize = useThrottledValue(size); + + useEffect(() => { + function handleResize() { + setSize(getWindowSize()); + } + + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + }; + }, []); + + return ( + + {children} + + ); +} + +function LocalStorageProvider(props: BaseProps) { + const { children } = props; const [storageState, setStorageState] = useState(() => { const configValue = getFromStorage('timur-config'); return ({ @@ -206,56 +392,15 @@ function App() { setStorageState: handleStorageStateUpdate, }), [storageState, handleStorageStateUpdate]); - // DEVICE SIZE - - const [size, setSize] = useState(getWindowSize); - const throttledSize = useThrottledValue(size); - useEffect(() => { - function handleResize() { - setSize(getWindowSize()); - } - - window.addEventListener('resize', handleResize); - - return () => { - window.removeEventListener('resize', handleResize); - }; - }, []); - - // AUTHENTICATION - - const [userAuth, setUserAuth] = useState(); - const [ready, setReady] = useState(false); - - const [meResult] = useQuery( - { query: ME_QUERY }, - ); - - useEffect(() => { - if (meResult.fetching) { - return; - } - setUserAuth(meResult.data?.public.me ?? undefined); - setReady(true); - }, [meResult.data, meResult.fetching]); - - const removeUserAuth = useCallback( - () => { - setUserAuth(undefined); - }, - [], - ); - - const userContextValue = useMemo( - () => ({ - userAuth, - setUserAuth, - removeUserAuth, - }), - [userAuth, removeUserAuth], + return ( + + {children} + ); +} - // ENUMS +function EnumsProvider(props: BaseProps) { + const { children } = props; const [enumsResult] = useQuery( { @@ -283,76 +428,230 @@ function App() { [enumsResult], ); - // WORK ITEMS + return ( + + {children} + + ); +} + +function CommandProvider(props: BaseProps) { + const { children } = props; - const commands = useRef[]>([]); const zeitgeist = useRef(0); - const watch = useCallback( - (command: Command) => { - // TODO: Create a command queue - // eslint-disable-next-line no-console - console.info('Command', command); + const commands = useRef[]>([]); + const [undoable, setUndobale] = useState(false); + const [redoable, setRedoable] = useState(false); + const setCommands = useCallback( + (value: Command[]) => { + commands.current = value; + const forwardSpace = commands.current.length - zeitgeist.current; + setRedoable(forwardSpace > 0); + const backwardSpace = zeitgeist.current; + setUndobale(backwardSpace > 0); }, [], ); - const commandState = useMemo((): CommandContextProps => ({ - commands, - zeitgeist, - watch, - }), [watch]); + const serverCommands = useRef[]>([]); + const [ + serverCommandsLastUpdated, + setServerCommandsLastUpdated, + ] = useState(undefined); + const setServerCommands = useCallback( + (value: Command[]) => { + serverCommands.current = value; + if (serverCommands.current.length === 0) { + setServerCommandsLastUpdated(undefined); + } else { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const val = serverCommands.current[serverCommands.current.length - 1]!.timestamp; + setServerCommandsLastUpdated(val); + } + }, + [], + ); - // PAGE LAYOUTS + const inFlightServerCommands = useRef[]>([]); + const [inFlight, setInFlight] = useState(false); - const navbarStartActionRef = useRef(null); - const navbarMidActionRef = useRef(null); - const navbarEndActionRef = useRef(null); + // TODO: We need to debounce the values here + const [ + , + triggerBulkMutation, + ] = useMutation( + BULK_TIME_ENTRY_MUTATION, + ); - const navbarContextValue = useMemo(() => ({ - startActionsRef: navbarStartActionRef, - midActionsRef: navbarMidActionRef, - endActionsRef: navbarEndActionRef, - }), []); + const throttledLastUpdated = useThrottledValue(serverCommandsLastUpdated, 1000); - // ROUTE + useEffect( + () => { + if (inFlight || !throttledLastUpdated) { + return; + } + + if (serverCommands.current.length === 0) { + return; + } - const fallbackElement = ( -
- Timur Icon -
+ inFlightServerCommands.current = serverCommands.current.slice(0, 20); + setServerCommands(serverCommands.current.slice(20)); + + setInFlight(true); + + async function mutate() { + try { + const addedItems = inFlightServerCommands.current.filter(isAddAction); + const editedItems = inFlightServerCommands.current.filter(isEditAction); + const deletedItems = inFlightServerCommands.current.filter(isDeleteAction); + // TODO: Use clientId instead in the id for edit and delete + const res = await triggerBulkMutation({ + timeEntries: [ + ...addedItems.map((item) => item.newValue), + ...editedItems.map((item) => ({ + ...item.newValue, + clientId: item.key, + })), + ], + deleteIds: deletedItems.map((item) => item.oldValue.id).filter(isDefined), + }); + + // eslint-disable-next-line no-console + console.debug(res); + } catch (ex) { + setServerCommands([ + ...inFlightServerCommands.current, + ...serverCommands.current, + ]); + } + inFlightServerCommands.current = []; + setInFlight(false); + } + mutate(); + }, + [inFlight, throttledLastUpdated, setServerCommands, triggerBulkMutation], ); - // NOTE: We should block page for authentication before we mount routes - // TODO: Handle error with authentication - if (!ready) { - return fallbackElement; - } + const setZeitgeist = useCallback( + (value: number) => { + zeitgeist.current = value; + const forwardSpace = commands.current.length - zeitgeist.current; + setRedoable(forwardSpace > 0); + const backwardSpace = zeitgeist.current; + setUndobale(backwardSpace > 0); + }, + [], + ); + + const watch = useCallback( + (action: Command) => { + const oldActions = serverCommands.current; + const existingActionIndex = oldActions.findIndex((item) => item.key === action.key); + if (existingActionIndex === -1) { + setServerCommands([...oldActions, action]); + return; + } + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const existingAction = oldActions[existingActionIndex]!; + const newActions = [...oldActions]; + if (existingAction.type === 'add' && action.type === 'delete') { + // we remove from list + newActions.splice(existingActionIndex, 1); + } else if (existingAction.type === 'add' && action.type === 'edit') { + // we update the add + newActions.splice( + existingActionIndex, + 1, + { + ...existingAction, + timestamp: action.timestamp, + newValue: { + ...existingAction.newValue, + ...action.newValue, + }, + }, + ); + } else if (existingAction.type === 'edit' && action.type === 'edit') { + // we update the edit + newActions.splice( + existingActionIndex, + 1, + { + ...existingAction, + timestamp: action.timestamp, + newValue: { + ...existingAction.newValue, + ...action.newValue, + }, + }, + ); + } else if (existingAction.type === 'edit' && action.type === 'delete') { + // we replace with delete + newActions.splice( + existingActionIndex, + 1, + action, + ); + } else if (existingAction.type === 'delete' && action.type === 'add') { + // we remove from list + newActions.splice( + existingActionIndex, + 1, + ); + } else { + // eslint-disable-next-line no-console + console.error(`We previously had ${existingAction.type} but then we got ${action.type}`); + } + setServerCommands(newActions); + }, + [setServerCommands], + ); + + const commandState = useMemo((): CommandContextProps => ({ + commands, + zeitgeist, + setZeitgeist, + setCommands, + watch, + undoable, + redoable, + }), [watch, setZeitgeist, setCommands, undoable, redoable]); return ( - - - - - - - - - - - - - - - - - + + {children} + + ); +} + +function App() { + return ( + <> + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/src/contexts/command.tsx b/src/contexts/command.tsx index 51f65db..50278f0 100644 --- a/src/contexts/command.tsx +++ b/src/contexts/command.tsx @@ -6,15 +6,29 @@ import { type WorkItem } from '#utils/types'; export interface CommandContextProps { commands: React.MutableRefObject[]>, zeitgeist: React.MutableRefObject, + setCommands: (value: Command[]) => void, + setZeitgeist: (value: number) => void, watch: (command: Command) => void, + redoable: boolean, + undoable: boolean, } const CommandContext = createContext({ commands: { current: [] }, zeitgeist: { current: 0 }, + setCommands: () => { + // eslint-disable-next-line no-console + console.warn('CommandContext.setCommands called without initializing provider'); + }, + setZeitgeist: () => { + // eslint-disable-next-line no-console + console.warn('CommandContext.setZeitgeist called without initializing provider'); + }, watch: () => { // eslint-disable-next-line no-console console.warn('CommandContext.watch called without initializing provider'); }, + undoable: false, + redoable: false, }); export default CommandContext; diff --git a/src/hooks/useBackgroundSync.ts b/src/hooks/useBackgroundSync.ts deleted file mode 100644 index 14d2b8c..0000000 --- a/src/hooks/useBackgroundSync.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { - useCallback, - useEffect, - useMemo, - useReducer, - useRef, -} from 'react'; -import { - isDefined, - isNotDefined, - listToMap, -} from '@togglecorp/fujs'; - -import { - getChangedItems, - mergeList, -} from '#utils/common'; - -type SetAction = T | ((oldValue: T) => T); - -interface Base { - id?: string | null, - clientId: string -} - -interface State { - serverState: T[], - localState: T[], -} - -interface RemoveLocalStateItem { - type: 'REMOVE_LOCAL_STATE_ITEM', - clientId: string; -} -interface UpdateServerStateItems { - type: 'UPDATE_SERVER_STATE_ITEMS', - value: T[], -} -interface UpdateLocalStateItems { - type: 'UPDATE_LOCAL_STATE_ITEMS', - value: T[], -} -interface SetCombinedState { - type: 'SET_COMBINED_STATE', - localValue: SetAction, - serverValue: SetAction, -} - -type Actions = RemoveLocalStateItem -| UpdateServerStateItems -| UpdateLocalStateItems -| SetCombinedState; - -function stateReducer(prevState: State, action: Actions): State { - if (action.type === 'REMOVE_LOCAL_STATE_ITEM') { - if (!prevState.localState) { - return prevState.localState; - } - const newData = [...prevState.localState ?? []]; - const obsoleteIndex = newData?.findIndex( - (item) => item.clientId === action.clientId, - ); - if (obsoleteIndex !== -1) { - newData.splice(obsoleteIndex, 1); - } - - return { - ...prevState, - localState: newData, - }; - } - if (action.type === 'UPDATE_SERVER_STATE_ITEMS') { - return { - ...prevState, - serverState: mergeList( - prevState.serverState, - action.value, - (item) => item.clientId, - ), - }; - } - if (action.type === 'UPDATE_LOCAL_STATE_ITEMS') { - return { - ...prevState, - localState: mergeList( - prevState.localState, - action.value, - (item) => item.clientId, - ), - }; - } - if (action.type === 'SET_COMBINED_STATE') { - const localValue = typeof action.localValue === 'function' - ? action.localValue(prevState.localState) - : action.localValue; - const serverValue = typeof action.serverValue === 'function' - ? action.serverValue(prevState.serverState) - : action.serverValue; - - return { - ...prevState, - localState: localValue, - serverState: serverValue, - }; - } - // eslint-disable-next-line no-console - console.error('Action is not supported'); - return prevState; -} - -function useBackgroundSync( - action: ( - addedItems: T[], - updatedItems: T[], - deletedItems: string[], - ) => Promise<{ ok: true, savedValues: T[], deletedValues: string[] } | { ok: false }>, -) { - const diffTriggerRef = useRef(); - - const [ - state, - dispatch, - ] = useReducer, Actions>>( - stateReducer, - { - serverState: [], - localState: [], - }, - ); - - const { - localState, - serverState, - } = state; - - const addOrUpdateServerData = useCallback( - (workItems: T[] | undefined) => { - if (isDefined(workItems) && workItems.length > 0) { - dispatch({ - type: 'UPDATE_SERVER_STATE_ITEMS', - value: workItems ?? [], - }); - } - }, - [], - ); - - const addOrUpdateLocalData = useCallback( - (workItems: T[] | undefined) => { - if (isDefined(workItems) && workItems.length > 0) { - dispatch({ - type: 'UPDATE_LOCAL_STATE_ITEMS', - value: workItems ?? [], - }); - } - }, - [], - ); - - const removeFromStateData = useCallback( - (key: string | null | undefined) => { - if (isDefined(key)) { - dispatch({ - type: 'REMOVE_LOCAL_STATE_ITEM', - clientId: key, - }); - } - }, - [], - ); - - const setStateData = useCallback( - (localValue: SetAction, serverValue: SetAction) => { - const setStateDataAction: SetCombinedState = { - type: 'SET_COMBINED_STATE', - localValue, - serverValue, - }; - dispatch(setStateDataAction); - }, - [], - ); - - useEffect( - () => { - window.clearTimeout(diffTriggerRef.current); - - if (localState === serverState) { - // eslint-disable-next-line no-console - console.info('No change detected at all...'); - return; - } - - // eslint-disable-next-line no-console - console.info('Background sync queued.'); - diffTriggerRef.current = window.setTimeout( - async () => { - const { - addedItems, - removedItems, - updatedItems, - } = getChangedItems( - serverState, - localState, - (item) => item.clientId, - ); - - const sanitizedAddedItems = addedItems - .filter((item) => isNotDefined(item.id)); - const sanitizedUpdatedItems = updatedItems - .filter((item) => isDefined(item.id)); - const sanitizedDeletedItems = removedItems - .map((item) => item.id).filter(isDefined); - - if (sanitizedAddedItems.length === 0 - && sanitizedUpdatedItems.length === 0 - && sanitizedDeletedItems.length === 0 - ) { - // eslint-disable-next-line no-console - console.info('No change detected...'); - return; - } - - // eslint-disable-next-line no-console - console.info(`Changes detected! ${sanitizedAddedItems.length} added. ${sanitizedUpdatedItems.length} modified. ${sanitizedDeletedItems.length} deleted.`); - const res = await action( - sanitizedAddedItems, - sanitizedUpdatedItems, - sanitizedDeletedItems, - ); - - if (res.ok) { - setStateData( - (prevLocalState) => { - const updatedIds = listToMap( - res.savedValues, - (item) => item.clientId, - (item) => item.id, - ); - const newLocalState = prevLocalState?.map((item) => ( - isDefined(item.id) - ? item - : { ...item, id: updatedIds[item.clientId] } - )); - - return newLocalState; - }, - (prevServerState) => { - const newServerState = mergeList( - prevServerState, - res.savedValues, - (item) => item.clientId, - ); - - const deletedItemsMapping = listToMap( - res.deletedValues, - (item) => item, - () => true, - ); - return newServerState.filter( - (item) => !deletedItemsMapping[item.clientId], - ); - }, - ); - } else { - // eslint-disable-next-line no-console - console.info('Error response from server.'); - } - }, - 1000, - ); - }, - [localState, serverState, action, setStateData], - ); - - // NOTE: We can use useThrottledValue here - const isObsolete = useMemo(() => { - const { - addedItems, - removedItems, - updatedItems, - } = getChangedItems( - serverState, - localState, - (item) => item.clientId, - ); - - const sanitizedAddedItems = addedItems - .filter((item) => isNotDefined(item.id)); - const sanitizedUpdatedItems = updatedItems - .filter((item) => isDefined(item.id)); - const sanitizedDeletedItems = removedItems - .map((item) => item.id).filter(isDefined); - - return sanitizedAddedItems.length !== 0 - || sanitizedUpdatedItems.length !== 0 - || sanitizedDeletedItems.length !== 0; - }, [localState, serverState]); - - return useMemo( - () => ({ - addOrUpdateServerData, - addOrUpdateStateData: addOrUpdateLocalData, - removeFromStateData, - isObsolete, - }), - [addOrUpdateServerData, addOrUpdateLocalData, removeFromStateData, isObsolete], - ); -} - -export default useBackgroundSync; diff --git a/src/hooks/useCommand.ts b/src/hooks/useCommand.ts index 1f4ee07..42426ab 100644 --- a/src/hooks/useCommand.ts +++ b/src/hooks/useCommand.ts @@ -55,6 +55,8 @@ function useCommand(props: { filter: (entry: T) => boolean, commands: React.MutableRefObject[]>, zeitgeist: React.MutableRefObject, + setCommands: (value: Command[]) => void, + setZeitgeist: (value: number) => void, watch: (command: Command) => void, }) { const { @@ -64,6 +66,8 @@ function useCommand(props: { commands, zeitgeist, watch, + setZeitgeist, + setCommands, } = props; const [entries, setEntries] = useState(() => { @@ -109,10 +113,10 @@ function useCommand(props: { }, 1, ); - zeitgeist.current = newState.zeitgeist; + setZeitgeist(newState.zeitgeist); setEntries(newState.entries); }, - [commands, entries, keySelector, watch, zeitgeist], + [commands, entries, keySelector, watch, zeitgeist, setZeitgeist], ); const handleUndo = useCallback( @@ -129,15 +133,14 @@ function useCommand(props: { }, 1, ); - zeitgeist.current = newState.zeitgeist; setEntries(newState.entries); + setZeitgeist(newState.zeitgeist); }, - [commands, entries, keySelector, watch, zeitgeist], + [commands, entries, keySelector, watch, zeitgeist, setZeitgeist], ); const handleUpdate = useCallback( (command: Command) => { - // TODO: Debounce this if id and type is the same and also check temporal const newState = act( { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -150,11 +153,11 @@ function useCommand(props: { }, command, ); - zeitgeist.current = newState.zeitgeist; - commands.current = newState.commands; setEntries(newState.entries); + setZeitgeist(newState.zeitgeist); + setCommands(newState.commands); }, - [commands, entries, keySelector, watch, zeitgeist], + [commands, entries, keySelector, setCommands, setZeitgeist, watch, zeitgeist], ); return { diff --git a/src/index.tsx b/src/index.tsx index e672cfc..22fb1de 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -10,42 +10,14 @@ import { } from 'react-router-dom'; import * as Sentry from '@sentry/react'; import { isNotDefined } from '@togglecorp/fujs'; -import { cacheExchange } from '@urql/exchange-graphcache'; -import { - Client as UrqlClient, - fetchExchange, - Provider as UrqlProvider, -} from 'urql'; import { Component as TemplateView } from '#components/TemplateView'; import App from './App/index.tsx'; -import PwaPrompt from './PwaPrompt/index.tsx'; const webappRootId = 'webapp-root'; const webappRootElement = document.getElementById(webappRootId); -const gqlClient = new UrqlClient({ - url: `${import.meta.env.APP_GRAPHQL_DOMAIN}/graphql/`, - exchanges: [cacheExchange({ - keys: { - PrivateQuery: () => null, - PublicQuery: () => null, - AppEnumCollection: () => null, - DailyStandUpType: () => null, - AppEnumCollectionTimeEntryType: (item) => String(item.key), - AppEnumCollectionTimeEntryStatus: (item) => String(item.key), - AppEnumCollectionJournalLeaveType: (item) => String(item.key), - AppEnumCollectionJournalWfhType: (item) => String(item.key), - DjangoImageType: (item) => String(item.url), - }, - }), fetchExchange], - fetchOptions: () => ({ - credentials: 'include', - }), - requestPolicy: 'network-only', -}); - const dsn = import.meta.env.APP_SENTRY_DSN; if (dsn) { Sentry.init({ @@ -84,7 +56,7 @@ if (isNotDefined(webappRootElement)) { // eslint-disable-next-line no-console console.error(`Could not find html element with id '${webappRootId}'`); } else { - ReactDOM.createRoot(webappRootElement).render( + const component = ( - - - - + - , + ); + ReactDOM.createRoot(webappRootElement).render(component); } diff --git a/src/utils/common.ts b/src/utils/common.ts index 10fab46..0afbab8 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -5,9 +5,6 @@ import { isDefined, isFalsyString, isNotDefined, - listToGroupList, - listToMap, - mapToList, } from '@togglecorp/fujs'; import { matchSorter, @@ -36,35 +33,6 @@ export function getWindowSize(): Size { }; } -function squash(items: T[]): T | undefined { - if (items.length <= 1) { - return items[0]; - } - // NOTE: We should use items.slice(1) instead - return items.reduce( - (acc, val) => ({ - ...acc, - ...val, - }), - items[0], - ); -} - -export function mergeList( - foo: T[], - bar: T[], - keySelector: (item: T) => string, -) { - const items = [...foo, ...bar]; - const squashedItemsMapping = listToGroupList( - items, - (item) => keySelector(item), - (item) => item, - (groupedItems) => squash(groupedItems), - ); - return mapToList(squashedItemsMapping).filter(isDefined); -} - export function getNewId(): string { return ulid(); } @@ -150,66 +118,11 @@ export function getDurationNumber(value: string | undefined) { } export function addDays(dateStr: string, numDays: number) { - // FIXME: we should always append time when converting date from string - const date = new Date(dateStr); + const date = new Date(`${dateStr}T00:00:00`); date.setDate(date.getDate() + numDays); - return encodeDate(date); } -export function getChangedItems( - initialItems: T[] | undefined, - finalItems: T[] | undefined, - keySelector: (item: T) => string, -) { - const initialKeysMap = listToMap(initialItems ?? [], keySelector); - - const finalKeysMap = listToMap(finalItems ?? [], keySelector); - - const addedKeys = Object.keys(finalKeysMap).filter( - (key) => !initialKeysMap[key], - ); - const removedKeys = Object.keys(initialKeysMap).filter( - (key) => !finalKeysMap[key], - ); - const updatedKeys = Object.keys(initialKeysMap).filter( - (key) => { - // This should be safe as we are using keys from Object.keys(initialKeysMap) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const initialObj = initialKeysMap[key]!; - const finalObj = finalKeysMap[key]; - - if (isNotDefined(finalObj)) { - return false; - } - - const initialJson = JSON.stringify( - initialObj, - initialObj ? Object.keys(initialObj).sort() : undefined, - ); - const finalJson = JSON.stringify( - finalObj, - finalObj ? Object.keys(finalObj).sort() : undefined, - ); - - return initialJson !== finalJson; - }, - ); - - return { - // NOTE: This should be safe as addedKeys is subset of Object.keys(finalKeysMap) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - addedItems: addedKeys.map((key) => finalKeysMap[key]!), - // NOTE: This should be safe as removedKeys is subset of Object.keys(initialKeysMap) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - removedItems: removedKeys.map((key) => initialKeysMap[key]!), - // NOTE: This should be safe as updatedKeys is subset of - // Object.keys(initialKeysMap) intersection Object.keys(finalKeysMap) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - updatedItems: updatedKeys.map((key) => finalKeysMap[key]!), - }; -} - export function fuzzySearch( rows: ReadonlyArray, filterValue: string, diff --git a/src/utils/temporal.ts b/src/utils/temporal.ts index 0a096c6..239e321 100644 --- a/src/utils/temporal.ts +++ b/src/utils/temporal.ts @@ -53,7 +53,6 @@ function getTemporalDiff(min: DateLike, max: DateLike) { }; } -// FIXME: We need to revisit this logic export function toRelativeDate(dateLike: DateLike): RelativeDate | undefined { const today = new Date(); const date = new Date(dateLike); @@ -95,6 +94,8 @@ export function toRelativeDate(dateLike: DateLike): RelativeDate | undefined { }; } + // FIXME: We need to revisit these logic. There are gaps where we get no. of days + // Eg. on day 22 if (temporalDiff.day > 21 && temporalDiff.month > 0 && temporalDiff.month < 10) { return { direction, diff --git a/src/views/DailyJournal/DayView/WorkItemRow/index.tsx b/src/views/DailyJournal/DayView/WorkItemRow/index.tsx index 7a0c752..18efa0f 100644 --- a/src/views/DailyJournal/DayView/WorkItemRow/index.tsx +++ b/src/views/DailyJournal/DayView/WorkItemRow/index.tsx @@ -316,21 +316,10 @@ function WorkItemRow(props: Props) { Move to another day -

- Do you want to delete this entry? -

-

- This action cannot be reverted. -

-
- )} icons={} > Delete entry diff --git a/src/views/DailyJournal/DayView/index.tsx b/src/views/DailyJournal/DayView/index.tsx index bd1b48d..cc21a3b 100644 --- a/src/views/DailyJournal/DayView/index.tsx +++ b/src/views/DailyJournal/DayView/index.tsx @@ -76,7 +76,6 @@ function DayView(props: Props) { tasks, } = props; - // FIXME: We should still get archived tasks here const { taskById: oldTaskById } = useContext(EnumsContext); // FIXME: memoize this diff --git a/src/views/DailyJournal/UpdateNoteDialog/index.tsx b/src/views/DailyJournal/UpdateNoteDialog/index.tsx index 68e19db..f219b43 100644 --- a/src/views/DailyJournal/UpdateNoteDialog/index.tsx +++ b/src/views/DailyJournal/UpdateNoteDialog/index.tsx @@ -181,8 +181,6 @@ function AddNoteDialog(props: Props) { Vim.defineEx('q', undefined, exitHandler); Vim.defineEx('x', undefined, saveAndQuitHandler); - // TODO: We need to only defineEx for this particular codemirror - // instance return () => { Vim.defineEx('w', undefined, undefined); Vim.defineEx('q', undefined, undefined); diff --git a/src/views/DailyJournal/index.tsx b/src/views/DailyJournal/index.tsx index f6cd47b..d92077a 100644 --- a/src/views/DailyJournal/index.tsx +++ b/src/views/DailyJournal/index.tsx @@ -123,56 +123,6 @@ const MY_TIME_ENTRIES_QUERY = gql` } `; -/* -query MyQuery { - private { - allTimeEntries(filters: {statuses: TODO, users: "9"}) { - clientId - id - description - date - startTime - duration - status - taskId - type - } - } -} -*/ - -/* -const BULK_TIME_ENTRY_MUTATION = gql` - mutation BulkTimeEntry($timeEntries: [TimeEntryBulkCreateInput!], $deleteIds: [ID!]) { - private { - bulkTimeEntry( - items: $timeEntries, - deleteIds: $deleteIds - ) { - deleted { - id - clientId - } - errors - results { - id - clientId - date - description - duration - startTime - status - taskId - type - } - } - } - } -`; -*/ - -// TODO: Do not use JSON.stringify for comparison -// TODO: use filtered localState instead of workItems /** @knipignore */ // eslint-disable-next-line import/prefer-default-export export function Component() { @@ -205,7 +155,15 @@ export function Component() { [], ); - const { zeitgeist, commands, watch } = useContext(CommandContext); + const { + zeitgeist, + commands, + setZeitgeist, + setCommands, + watch, + undoable, + redoable, + } = useContext(CommandContext); const { entries: workItems, @@ -215,11 +173,13 @@ export function Component() { undo, } = useCommand({ defaultEntries: [], - filter, commands, + filter, keySelector, watch, zeitgeist, + setZeitgeist, + setCommands, }); const [tasks, setTasks] = useState([]); @@ -255,71 +215,6 @@ export function Component() { [selectedDate], ); - /* - const [ - bulkMutationState, - triggerBulkMutation, - ] = useMutation( - BULK_TIME_ENTRY_MUTATION, - ); - - const handleBulkAction = useCallback( - async (addedItems: WorkItem[], updatedItems: WorkItem[], removedItems: string[]) => { - const res = await triggerBulkMutation({ - timeEntries: [ - ...addedItems, - ...updatedItems.map((item) => ({ - // NOTE: We need to send null to the server so that we - // can clear the values - clientId: item.clientId ?? null, - date: item.date ?? null, - description: item.description ?? null, - duration: item.duration ?? null, - id: item.id ?? null, - status: item.status ?? null, - task: item.task ?? null, - type: item.type ?? null, - })), - ], - deleteIds: removedItems, - }); - if (res.error) { - return { ok: false } as const; - } - - const workItemsFromServer = removeNull( - res.data?.private.bulkTimeEntry.results?.map( - (timeEntry) => { - const { taskId, ...otherTimeEntryProps } = timeEntry; - return { - ...otherTimeEntryProps, - task: taskId, - }; - }, - ) ?? [], - ); - - return { - ok: true as const, - savedValues: workItemsFromServer ?? [], - deletedValues: res.data?.private.bulkTimeEntry.deleted?.map( - (item) => item.clientId, - ) ?? [], - } as const; - }, - [triggerBulkMutation], - ); - - const { - addOrUpdateStateData, - removeFromStateData, - addOrUpdateServerData, - isObsolete, - } = useBackgroundSync( - handleBulkAction, - ); - */ - const [ myTimeEntriesResult, ] = useQuery({ @@ -365,16 +260,12 @@ export function Component() { setTasks(tasksFromServer); setWorkItems(workItemsFromServer); - // addOrUpdateServerData(workItemsFromServer); - // addOrUpdateStateData(workItemsFromServer); }, [ myTimeEntriesResult.fetching, myTimeEntriesResult.data, myTimeEntriesResult.error, setWorkItems, - // addOrUpdateServerData, - // addOrUpdateStateData, ], ); @@ -671,6 +562,7 @@ export function Component() {
@@ -714,22 +606,6 @@ export function Component() { > - - {entriesWithError > 0 && (
@@ -804,6 +680,25 @@ export function Component() { Go to today )} + + {redoable && ( + + )}
( compareNumber( diff --git a/src/views/Settings/index.tsx b/src/views/Settings/index.tsx index b4cd409..5597740 100644 --- a/src/views/Settings/index.tsx +++ b/src/views/Settings/index.tsx @@ -83,7 +83,7 @@ export function Component() { From 83469d80bf95d7b4fecd1751bb64f6cc2c899aca Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 00:33:23 +0545 Subject: [PATCH 03/27] fix: throttle instead of debouncing entries save - move "saving" icon on App - show "saving" icon --- src/App/index.tsx | 30 ++++++++++--- src/App/styles.module.css | 54 ++++++++++++++++++++++++ src/views/DailyJournal/index.tsx | 18 -------- src/views/DailyJournal/styles.module.css | 54 ------------------------ 4 files changed, 78 insertions(+), 78 deletions(-) diff --git a/src/App/index.tsx b/src/App/index.tsx index c9a33a2..b534562 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -59,6 +59,7 @@ import { WorkItem, } from '#utils/types'; +import timurLogo from './icon.svg'; import PwaPrompt from './PwaPrompt'; import wrappedRoutes, { unwrappedRoutes } from './routes'; @@ -475,7 +476,6 @@ function CommandProvider(props: BaseProps) { const inFlightServerCommands = useRef[]>([]); const [inFlight, setInFlight] = useState(false); - // TODO: We need to debounce the values here const [ , triggerBulkMutation, @@ -483,11 +483,9 @@ function CommandProvider(props: BaseProps) { BULK_TIME_ENTRY_MUTATION, ); - const throttledLastUpdated = useThrottledValue(serverCommandsLastUpdated, 1000); - useEffect( () => { - if (inFlight || !throttledLastUpdated) { + if (inFlight || !serverCommandsLastUpdated) { return; } @@ -528,9 +526,14 @@ function CommandProvider(props: BaseProps) { inFlightServerCommands.current = []; setInFlight(false); } - mutate(); + + // NOTE: This will act as a rate limit + setTimeout( + mutate, + 1000, + ); }, - [inFlight, throttledLastUpdated, setServerCommands, triggerBulkMutation], + [inFlight, serverCommandsLastUpdated, setServerCommands, triggerCudTimeEntryMutation], ); const setZeitgeist = useCallback( @@ -621,6 +624,21 @@ function CommandProvider(props: BaseProps) { return ( {children} +
+ Timur Icon +
+ Committing... +
+
); } diff --git a/src/App/styles.module.css b/src/App/styles.module.css index 6dbff78..c870a47 100644 --- a/src/App/styles.module.css +++ b/src/App/styles.module.css @@ -12,3 +12,57 @@ height: 6rem; } } + +.last-saved-status { + display: flex; + position: fixed; + right: var(--spacing-md); + bottom: var(--spacing-md); + align-items: center; + transition: .5s opacity ease-in-out; + opacity: 0; + border-radius: 0.5rem; + background-color: var(--color-quaternary); + padding: var(--spacing-sm) var(--spacing-md); + color: var(--color-text-light); + gap: var(--spacing-xs); + + .timur-icon { + height: 1rem; + } + + &.active { + opacity: 1; + + .timur-icon { + animation: shake-it-off 2.2s ease-in infinite; + } + } +} + +@keyframes shake-it-off { + 10% { + transform: rotate(0); + } + 20% { + transform: rotate(-160deg); + } + 30% { + transform: rotate(-160deg) translateY(2px); + } + 35% { + transform: rotate(-160deg) translateY(-2px); + } + 50% { + transform: rotate(-160deg) translateY(2px); + } + 55% { + transform: rotate(-160deg) translateY(-2px); + } + 70% { + transform: rotate(-160deg) translateY(0); + } + 80% { + transform: rotate(0); + } +} diff --git a/src/views/DailyJournal/index.tsx b/src/views/DailyJournal/index.tsx index d92077a..6760a79 100644 --- a/src/views/DailyJournal/index.tsx +++ b/src/views/DailyJournal/index.tsx @@ -22,7 +22,6 @@ import { useParams, } from 'react-router-dom'; import { - _cs, compareStringAsNumber, encodeDate, isDefined, @@ -67,7 +66,6 @@ import { WorkItem, } from '#utils/types'; -import timurLogo from '../../App/icon.svg'; import AddWorkItemDialog from './AddWorkItemDialog'; import AvailabilityDialog from './AvailabilityDialog'; import DayView from './DayView'; @@ -559,22 +557,6 @@ export function Component() { onSwipeLeft={handleSwipeLeft} onSwipeRight={handleSwipeRight} > -
- Timur Icon -
- Syncing... -
-
{screen === 'desktop' && ( diff --git a/src/views/DailyJournal/styles.module.css b/src/views/DailyJournal/styles.module.css index ea4b092..bba5c1d 100644 --- a/src/views/DailyJournal/styles.module.css +++ b/src/views/DailyJournal/styles.module.css @@ -1,33 +1,6 @@ .daily-journal { position: relative; - .last-saved-status { - display: flex; - position: fixed; - right: var(--spacing-md); - bottom: var(--spacing-md); - align-items: center; - transition: .5s opacity ease-in-out; - opacity: 0; - border-radius: 0.5rem; - background-color: var(--color-quaternary); - padding: var(--spacing-2xs) var(--spacing-sm); - color: var(--color-text-light); - gap: var(--spacing-xs); - - .timur-icon { - height: 1rem; - } - - &.active { - opacity: 1; - - .timur-icon { - animation: shake-it-off 2.2s ease-in infinite; - } - } - } - .bottom-actions { display: flex; gap: var(--spacing-sm); @@ -58,30 +31,3 @@ flex-grow: 1; } } - -@keyframes shake-it-off { - 10% { - transform: rotate(0); - } - 20% { - transform: rotate(-160deg); - } - 30% { - transform: rotate(-160deg) translateY(2px); - } - 35% { - transform: rotate(-160deg) translateY(-2px); - } - 50% { - transform: rotate(-160deg) translateY(2px); - } - 55% { - transform: rotate(-160deg) translateY(-2px); - } - 70% { - transform: rotate(-160deg) translateY(0); - } - 80% { - transform: rotate(0); - } -} From eb8e40405109079e0b5927433b732800655f7492 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 00:35:43 +0545 Subject: [PATCH 04/27] fix: better display issues with entries - use red underline instead of red background --- src/views/DailyJournal/DayView/WorkItemRow/index.tsx | 9 +++++++-- .../DailyJournal/DayView/WorkItemRow/styles.module.css | 9 +++++++++ src/views/DailyJournal/DayView/index.tsx | 9 +++------ src/views/DailyJournal/DayView/styles.module.css | 5 ----- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/views/DailyJournal/DayView/WorkItemRow/index.tsx b/src/views/DailyJournal/DayView/WorkItemRow/index.tsx index 18efa0f..b66be0d 100644 --- a/src/views/DailyJournal/DayView/WorkItemRow/index.tsx +++ b/src/views/DailyJournal/DayView/WorkItemRow/index.tsx @@ -85,6 +85,9 @@ interface Props { tasks: Task[] | undefined; contractId: string | undefined; + typeErrored?: boolean; + durationErrored?: boolean; + onClone?: (clientId: string, override?: Partial) => void; onChange?: (clientId: string, ...entries: EntriesAsList) => void; onDelete?: (clientId: string) => void; @@ -99,6 +102,8 @@ function WorkItemRow(props: Props) { onClone, onDelete, onChange, + typeErrored, + durationErrored, } = props; const { enums } = useContext(EnumsContext); @@ -246,7 +251,7 @@ function WorkItemRow(props: Props) { const typeInput = ( )} Date: Fri, 23 Jan 2026 00:36:31 +0545 Subject: [PATCH 05/27] feat: move nagbar to bottom of the page --- src/views/RootLayout/index.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/views/RootLayout/index.tsx b/src/views/RootLayout/index.tsx index 5001b4c..a19eacb 100644 --- a/src/views/RootLayout/index.tsx +++ b/src/views/RootLayout/index.tsx @@ -47,15 +47,15 @@ export function Component() { )} /> )} - {userAuth && daysBeforeLogout < REMAINING_DAYS_THRESHOLD && ( -
- {`You'll be automatically logged out in ${Math.floor(daysBeforeLogout)} days. Please re-login to avoid unexpected logout.`} -
- )}
+ {userAuth && daysBeforeLogout < REMAINING_DAYS_THRESHOLD && ( +
+ {`You'll be automatically logged out in ${Math.floor(daysBeforeLogout)} days unless you re-login.`} +
+ )}
); } From e035e0c524e354fc2d4c94b75ed21271286e4f12 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 00:37:05 +0545 Subject: [PATCH 06/27] fix: use override when cloning an entry --- src/views/DailyJournal/index.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/views/DailyJournal/index.tsx b/src/views/DailyJournal/index.tsx index 6760a79..94c0328 100644 --- a/src/views/DailyJournal/index.tsx +++ b/src/views/DailyJournal/index.tsx @@ -304,9 +304,11 @@ export function Component() { console.error(`Could not find item ${workItemClientId} while cloning`); return; } + const newId = getNewId(); const newItem: WorkItem = { ...oldItem, + ...override, clientId: newId, }; delete newItem.id; From f932e895bf960a0454fb6d40851526a24e90120b Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 00:38:31 +0545 Subject: [PATCH 07/27] chore: use cud entries mutation instead of bulk entries mutation --- backend | 2 +- src/App/index.tsx | 51 +++++++++++++++++++++++++++++------------------ 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/backend b/backend index 28ec051..9048d54 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit 28ec051a19ba791c88f82fbb5ea0ecc1fe78fbd2 +Subproject commit 9048d547c8a4e38b51ed4a8ce8ce3fe2e02000b9 diff --git a/src/App/index.tsx b/src/App/index.tsx index b534562..a08d18c 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -37,8 +37,8 @@ import UserContext, { UserContextProps, } from '#contexts/user'; import { - BulkTimeEntryMutation, - BulkTimeEntryMutationVariables, + CudTimeEntryMutation, + CudTimeEntryMutationVariables, EnumsQuery, EnumsQueryVariables, MeQuery, @@ -149,11 +149,16 @@ const ENUMS_QUERY = gql` } `; -const BULK_TIME_ENTRY_MUTATION = gql` - mutation BulkTimeEntry($timeEntries: [TimeEntryBulkCreateInput!], $deleteIds: [ID!]) { +const CUD_TIME_ENTRY_MUTATION = gql` + mutation CudTimeEntry( + $createItems: [TimeEntryBulkCreateInput!], + $updateItems: [TimeEntryBulkUpdateInput!], + $deleteIds: [ID!], + ) { private { - bulkTimeEntry( - items: $timeEntries, + cudTimeEntry( + createItems: $createItems, + updateItems: $updateItems, deleteIds: $deleteIds ) { deleted { @@ -161,7 +166,18 @@ const BULK_TIME_ENTRY_MUTATION = gql` clientId } errors - results { + createItems { + id + clientId + date + description + duration + startTime + status + taskId + type + } + updateItems { id clientId date @@ -478,9 +494,9 @@ function CommandProvider(props: BaseProps) { const [ , - triggerBulkMutation, - ] = useMutation( - BULK_TIME_ENTRY_MUTATION, + triggerCudTimeEntryMutation, + ] = useMutation( + CUD_TIME_ENTRY_MUTATION, ); useEffect( @@ -503,15 +519,12 @@ function CommandProvider(props: BaseProps) { const addedItems = inFlightServerCommands.current.filter(isAddAction); const editedItems = inFlightServerCommands.current.filter(isEditAction); const deletedItems = inFlightServerCommands.current.filter(isDeleteAction); - // TODO: Use clientId instead in the id for edit and delete - const res = await triggerBulkMutation({ - timeEntries: [ - ...addedItems.map((item) => item.newValue), - ...editedItems.map((item) => ({ - ...item.newValue, - clientId: item.key, - })), - ], + const res = await triggerCudTimeEntryMutation({ + createItems: addedItems.map((item) => item.newValue), + updateItems: editedItems.map((item) => ({ + ...item.newValue, + clientId: item.key, + })), deleteIds: deletedItems.map((item) => item.oldValue.id).filter(isDefined), }); From 82c533d15fadcb09ef8dd1c9e9828d18e421fb44 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 00:39:30 +0545 Subject: [PATCH 08/27] fixup! fix: throttle instead of debouncing entries save --- src/App/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/App/index.tsx b/src/App/index.tsx index a08d18c..cfb7c81 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -11,6 +11,7 @@ import { } from 'react-router-dom'; import * as Sentry from '@sentry/react'; import { + _cs, encodeDate, isDefined, listToMap, From f3e2b66da0b015d0aa1f6cb334b35cdd644cad0d Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 00:39:48 +0545 Subject: [PATCH 09/27] fix: use ref instead of state for entries to avoid race condition --- src/hooks/useCommand.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/hooks/useCommand.ts b/src/hooks/useCommand.ts index 42426ab..394720a 100644 --- a/src/hooks/useCommand.ts +++ b/src/hooks/useCommand.ts @@ -1,5 +1,6 @@ import { useCallback, + useRef, useState, } from 'react'; @@ -70,6 +71,7 @@ function useCommand(props: { setCommands, } = props; + const entriesRef = useRef([]); const [entries, setEntries] = useState(() => { const newEntries = initializeEntries({ entries: defaultEntries, @@ -80,6 +82,7 @@ function useCommand(props: { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion zeitgeist: zeitgeist.current!, }); + entriesRef.current = newEntries; return newEntries; }); @@ -95,6 +98,7 @@ function useCommand(props: { zeitgeist: zeitgeist.current!, }); setEntries(newEntries); + entriesRef.current = newEntries; }, [commands, filter, keySelector, zeitgeist], ); @@ -108,15 +112,16 @@ function useCommand(props: { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion zeitgeist: zeitgeist.current!, watch, - entries, + entries: entriesRef.current, keySelector, }, 1, ); setZeitgeist(newState.zeitgeist); setEntries(newState.entries); + entriesRef.current = newState.entries; }, - [commands, entries, keySelector, watch, zeitgeist, setZeitgeist], + [commands, keySelector, watch, zeitgeist, setZeitgeist], ); const handleUndo = useCallback( @@ -128,15 +133,16 @@ function useCommand(props: { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion zeitgeist: zeitgeist.current!, watch, - entries, + entries: entriesRef.current, keySelector, }, 1, ); setEntries(newState.entries); + entriesRef.current = newState.entries; setZeitgeist(newState.zeitgeist); }, - [commands, entries, keySelector, watch, zeitgeist, setZeitgeist], + [commands, keySelector, watch, zeitgeist, setZeitgeist], ); const handleUpdate = useCallback( @@ -148,16 +154,17 @@ function useCommand(props: { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion zeitgeist: zeitgeist.current!, watch, - entries, + entries: entriesRef.current, keySelector, }, command, ); setEntries(newState.entries); + entriesRef.current = newState.entries; setZeitgeist(newState.zeitgeist); setCommands(newState.commands); }, - [commands, entries, keySelector, setCommands, setZeitgeist, watch, zeitgeist], + [commands, keySelector, setCommands, setZeitgeist, watch, zeitgeist], ); return { From 3c864dc8d9b65ee30f172fe452d860e00869c0b5 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 00:42:12 +0545 Subject: [PATCH 10/27] fixup! chore: use cud entries mutation instead of bulk entries mutation --- backend | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend b/backend index 9048d54..a8eb355 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit 9048d547c8a4e38b51ed4a8ce8ce3fe2e02000b9 +Subproject commit a8eb3556d0dd5a27c275df30a090d94bfd929293 From 1b42d86e167d725f6059c2cec4e82ad01afde0a1 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 01:09:35 +0545 Subject: [PATCH 11/27] fix: remove ctrl+enter to open notes --- src/views/DailyJournal/ShortcutsDialog/index.tsx | 7 ------- src/views/DailyJournal/index.tsx | 4 ---- 2 files changed, 11 deletions(-) diff --git a/src/views/DailyJournal/ShortcutsDialog/index.tsx b/src/views/DailyJournal/ShortcutsDialog/index.tsx index 56e376a..f3b24ff 100644 --- a/src/views/DailyJournal/ShortcutsDialog/index.tsx +++ b/src/views/DailyJournal/ShortcutsDialog/index.tsx @@ -44,13 +44,6 @@ function ShortcutsDialog(props: Props) { {' '} to add a new entry.
-
- Hit - {' '} - Ctrl+Enter - {' '} - to add a new note. -
Hit {' '} diff --git a/src/views/DailyJournal/index.tsx b/src/views/DailyJournal/index.tsx index 94c0328..2a6e982 100644 --- a/src/views/DailyJournal/index.tsx +++ b/src/views/DailyJournal/index.tsx @@ -438,10 +438,6 @@ export function Component() { event.preventDefault(); event.stopPropagation(); handleAddEntryClick(); - } else if (event.ctrlKey && event.key === 'Enter') { - event.preventDefault(); - event.stopPropagation(); - handleNoteUpdateClick(); } else if (event.ctrlKey && event.shiftKey && event.key === 'ArrowLeft') { event.preventDefault(); event.stopPropagation(); From 7ab853dadb8f90c42965b96ea07abd313e5979ee Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 01:10:06 +0545 Subject: [PATCH 12/27] feat: add feature to assit entry breakdown on ctrl+enter --- .../DayView/WorkItemRow/index.tsx | 15 ++++ src/views/DailyJournal/DayView/index.tsx | 3 + src/views/DailyJournal/index.tsx | 84 +++++++++++++++++++ 3 files changed, 102 insertions(+) diff --git a/src/views/DailyJournal/DayView/WorkItemRow/index.tsx b/src/views/DailyJournal/DayView/WorkItemRow/index.tsx index b66be0d..f53cdd6 100644 --- a/src/views/DailyJournal/DayView/WorkItemRow/index.tsx +++ b/src/views/DailyJournal/DayView/WorkItemRow/index.tsx @@ -1,4 +1,5 @@ import { + KeyboardEvent, useCallback, useContext, useMemo, @@ -89,6 +90,7 @@ interface Props { durationErrored?: boolean; onClone?: (clientId: string, override?: Partial) => void; + onAssist?: (clientId: string) => void; onChange?: (clientId: string, ...entries: EntriesAsList) => void; onDelete?: (clientId: string) => void; } @@ -100,6 +102,7 @@ function WorkItemRow(props: Props) { tasks, contractId, onClone, + onAssist, onDelete, onChange, typeErrored, @@ -193,6 +196,17 @@ function WorkItemRow(props: Props) { [onClone, workItem.clientId], ); + const handleShortcuts = useCallback( + (event: KeyboardEvent) => { + if (event.ctrlKey && event.key === 'Enter' && onAssist) { + event.preventDefault(); + event.stopPropagation(); + onAssist(workItem.clientId); + } + }, + [onAssist, workItem.clientId], + ); + const statusInput = config.checkboxForStatus ? ( diff --git a/src/views/DailyJournal/DayView/index.tsx b/src/views/DailyJournal/DayView/index.tsx index 371faf6..dd7ad19 100644 --- a/src/views/DailyJournal/DayView/index.tsx +++ b/src/views/DailyJournal/DayView/index.tsx @@ -58,6 +58,7 @@ interface Props { loading: boolean; errored: boolean; onWorkItemClone: (clientId: string, override?: Partial) => void; + onWorkItemAssist: (clientId: string) => void; onWorkItemChange: (clientId: string, ...entries: EntriesAsList) => void; onWorkItemDelete: (clientId: string) => void; selectedDate: string; @@ -68,6 +69,7 @@ function DayView(props: Props) { className, workItems, onWorkItemClone, + onWorkItemAssist, onWorkItemChange, onWorkItemDelete, loading, @@ -413,6 +415,7 @@ function DayView(props: Props) { typeErrored={groupedItem.value.status !== 'TODO' && isNotDefined(groupedItem.value.type)} durationErrored={groupedItem.value.status !== 'TODO' && isNotDefined(groupedItem.value.duration)} onClone={onWorkItemClone} + onAssist={onWorkItemAssist} onChange={onWorkItemChange} onDelete={onWorkItemDelete} contractId={taskDetails?.contract.id} diff --git a/src/views/DailyJournal/index.tsx b/src/views/DailyJournal/index.tsx index 2a6e982..951f614 100644 --- a/src/views/DailyJournal/index.tsx +++ b/src/views/DailyJournal/index.tsx @@ -48,6 +48,7 @@ import SizeContext from '#contexts/size'; import { MyTimeEntriesQuery, MyTimeEntriesQueryVariables, + TimeEntryTypeEnum, } from '#generated/types/graphql'; import useCommand from '#hooks/useCommand'; import { useFocusManager } from '#hooks/useFocus'; @@ -76,6 +77,29 @@ import UpdateNoteDialog from './UpdateNoteDialog'; import styles from './styles.module.css'; +function inferTypeFromDescription(desc: string): TimeEntryTypeEnum | undefined { + const sanitizedDesc = desc.toLowerCase(); + if (sanitizedDesc.includes('meeting') || sanitizedDesc.includes('standup') || sanitizedDesc.includes('all hands') || sanitizedDesc.includes('catchup')) { + return 'INTERNAL_MEETING'; + } + if (sanitizedDesc.includes('discuss')) { + return 'INTERNAL_DISCUSSION'; + } + if (sanitizedDesc.includes('deploy')) { + return 'DEV_OPS'; + } + if (sanitizedDesc.includes('research') || sanitizedDesc.includes('study')) { + return 'RESEARCH'; + } + if (sanitizedDesc.includes('documentation')) { + return 'DOCUMENTATION'; + } + if (sanitizedDesc.includes('review pr') || sanitizedDesc.includes('refactor') || sanitizedDesc.includes('fix') || sanitizedDesc.includes('debug')) { + return 'DEVELOPMENT'; + } + return undefined; +} + const MY_TIME_ENTRIES_QUERY = gql` query MyTimeEntries($date: Date!) { private { @@ -331,6 +355,65 @@ export function Component() { [workItems, setWorkItemChange, focus], ); + const handleWorkItemAssist = useCallback( + (workItemClientId: string) => { + const sourceItem = workItems.find((item) => item.clientId === workItemClientId); + if (!sourceItem) { + // eslint-disable-next-line no-console + console.error(`Could not find item ${workItemClientId} while splitting`); + return; + } + + // NOTE: split on 2 new liness + const descriptions = (sourceItem.description ?? '') + .split(/\n\n+/) + .map((line) => line.trim()).filter((item) => item !== ''); + + if (descriptions.length <= 0) { + return; + } + + const [firstDescription, ...otherDescriptions] = descriptions; + + const now = new Date().getTime(); + + otherDescriptions.forEach((desc) => { + const type = inferTypeFromDescription(desc); + const targetItem = { + ...sourceItem, + description: desc, + type, + clientId: getNewId(), + }; + delete targetItem.id; + delete targetItem.duration; + + setWorkItemChange({ + type: 'add', + key: targetItem.clientId, + newValue: targetItem, + timestamp: now, + }); + }); + + setWorkItemChange({ + type: 'edit', + key: sourceItem.clientId, + oldValue: { + description: sourceItem.description, + type: sourceItem.type, + }, + newValue: { + description: firstDescription, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + type: inferTypeFromDescription(firstDescription!) ?? sourceItem.type, + }, + timestamp: now, + }); + }, + [workItems, setWorkItemChange], + ); + const handleWorkItemDelete = useCallback( (workItemClientId: string) => { const oldItem = workItems.find((item) => item.clientId === workItemClientId); @@ -638,6 +721,7 @@ export function Component() { workItems={filteredWorkItems} tasks={tasks} onWorkItemClone={handleWorkItemClone} + onWorkItemAssist={handleWorkItemAssist} onWorkItemChange={handleWorkItemChange} onWorkItemDelete={handleWorkItemDelete} selectedDate={selectedDate} From 7cc516b9ad311cbc4e0d993fca1eb46fcf7cb6ca Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 01:17:51 +0545 Subject: [PATCH 13/27] fix: send null to clear values instead of undefined --- src/App/index.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/App/index.tsx b/src/App/index.tsx index cfb7c81..248b990 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -522,10 +522,18 @@ function CommandProvider(props: BaseProps) { const deletedItems = inFlightServerCommands.current.filter(isDeleteAction); const res = await triggerCudTimeEntryMutation({ createItems: addedItems.map((item) => item.newValue), - updateItems: editedItems.map((item) => ({ - ...item.newValue, - clientId: item.key, - })), + updateItems: editedItems.map((item) => { + const finalItem = { + ...item.newValue, + clientId: item.key, + }; + // NOTE: We want to replace all undefined with null so that + // we can indicate to server that the fields should be cleared + Object.entries(finalItem).forEach(([field, value]) => { + finalItem[field as keyof typeof finalItem] = value ?? null; + }); + return finalItem; + }), deleteIds: deletedItems.map((item) => item.oldValue.id).filter(isDefined), }); From de0e96b85d25a2dc0112ced42d97fec503e0fb2c Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 15:51:25 +0545 Subject: [PATCH 14/27] fixup! chore: use cud entries mutation instead of bulk entries mutation --- backend | 2 +- src/App/index.tsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/backend b/backend index a8eb355..dba567a 160000 --- a/backend +++ b/backend @@ -1 +1 @@ -Subproject commit a8eb3556d0dd5a27c275df30a090d94bfd929293 +Subproject commit dba567a01be781864ffe2b03bab92d387cbac242 diff --git a/src/App/index.tsx b/src/App/index.tsx index 248b990..c0fb816 100644 --- a/src/App/index.tsx +++ b/src/App/index.tsx @@ -13,7 +13,6 @@ import * as Sentry from '@sentry/react'; import { _cs, encodeDate, - isDefined, listToMap, } from '@togglecorp/fujs'; import { cacheExchange } from '@urql/exchange-graphcache'; @@ -534,7 +533,7 @@ function CommandProvider(props: BaseProps) { }); return finalItem; }), - deleteIds: deletedItems.map((item) => item.oldValue.id).filter(isDefined), + deleteIds: deletedItems.map((item) => item.oldValue.clientId), }); // eslint-disable-next-line no-console From 5bd31c59f1da0efb3b2e507cf48eb7ec6bec39a4 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 15:51:58 +0545 Subject: [PATCH 15/27] fixup! fix: remove ctrl+enter to open notes --- src/views/DailyJournal/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/views/DailyJournal/index.tsx b/src/views/DailyJournal/index.tsx index 951f614..8a11080 100644 --- a/src/views/DailyJournal/index.tsx +++ b/src/views/DailyJournal/index.tsx @@ -545,7 +545,6 @@ export function Component() { setSelectedDate, handleAddEntryClick, handleShortcutsButtonClick, - handleNoteUpdateClick, ], ); From c72ea43872a94e23d703ae8487b2f89b1a9874e0 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Fri, 23 Jan 2026 15:52:08 +0545 Subject: [PATCH 16/27] feat: select first task option using Enter --- .../DailyJournal/AddWorkItemDialog/index.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/views/DailyJournal/AddWorkItemDialog/index.tsx b/src/views/DailyJournal/AddWorkItemDialog/index.tsx index 5cf6029..ad47718 100644 --- a/src/views/DailyJournal/AddWorkItemDialog/index.tsx +++ b/src/views/DailyJournal/AddWorkItemDialog/index.tsx @@ -1,4 +1,5 @@ import { + KeyboardEvent, useCallback, useContext, useEffect, @@ -84,6 +85,22 @@ function AddWorkItemDialog(props: Props) { [searchText, enums], ); + const handleAcceptFirstOption = useCallback( + (event: KeyboardEvent) => { + if (event.key !== 'Enter') { + return; + } + event.preventDefault(); + event.stopPropagation(); + const firstItem = filteredTaskList[0]; + if (!firstItem) { + return; + } + handleWorkItemCreate(firstItem.id); + }, + [filteredTaskList, handleWorkItemCreate], + ); + return ( )} From 9b04716037a4a4c172631aa3ea9ed65958a377c7 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Sat, 24 Jan 2026 19:59:04 +0545 Subject: [PATCH 17/27] feat: update dialog heading size - rename calendar input headings --- src/components/CalendarInput/index.tsx | 2 +- src/components/Dialog/index.tsx | 4 ++-- src/views/DailyJournal/DayView/WorkItemRow/index.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/CalendarInput/index.tsx b/src/components/CalendarInput/index.tsx index 1e9bf87..3ac6303 100644 --- a/src/components/CalendarInput/index.tsx +++ b/src/components/CalendarInput/index.tsx @@ -59,7 +59,7 @@ function CalendarInput(props: Props) { open={confirmationShown} mode="center" onClose={handleModalClose} - heading="Select date" + heading="Jump to" contentClassName={styles.modalContent} className={styles.calendarDialog} size="auto" diff --git a/src/components/Dialog/index.tsx b/src/components/Dialog/index.tsx index 2b38bc7..86055f0 100644 --- a/src/components/Dialog/index.tsx +++ b/src/components/Dialog/index.tsx @@ -97,9 +97,9 @@ function Dialog(props: Props) { {open && ( <>
-

+

{heading} -

+ )} {screen === 'desktop' && ( @@ -689,10 +695,25 @@ export function Component() { name={undefined} variant="quaternary" onClick={handleShortcutsButtonClick} + icons={( + + )} > - + Shortcuts )} + {screen === 'desktop' && ( + + )} + > + Settings + + )}
Add entry - {selectedDate !== fullDate && ( - - Go to today - - )} + - {redoable && ( - - )}
Standup Deck - } - > - Settings -
); From 783b2f859c563e88e894bc3c0d4392c083c2bf96 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Sat, 24 Jan 2026 20:06:14 +0545 Subject: [PATCH 21/27] fix: move settings from left-side bar to settings page --- src/views/DailyJournal/StartSidebar/index.tsx | 260 +--------------- .../StartSidebar/styles.module.css | 65 ---- src/views/Settings/index.tsx | 280 +++++++++++++++++- src/views/Settings/styles.module.css | 41 ++- 4 files changed, 303 insertions(+), 343 deletions(-) diff --git a/src/views/DailyJournal/StartSidebar/index.tsx b/src/views/DailyJournal/StartSidebar/index.tsx index cb86c67..c1ab6a9 100644 --- a/src/views/DailyJournal/StartSidebar/index.tsx +++ b/src/views/DailyJournal/StartSidebar/index.tsx @@ -1,156 +1,10 @@ -import { - useCallback, - useContext, - useMemo, -} from 'react'; -import { RiDraggable } from 'react-icons/ri'; -import { - closestCenter, - DndContext, - DragEndEvent, - DraggableAttributes, - PointerSensor, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; -import { - SortableContext, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { - _cs, - isDefined, - isNotDefined, -} from '@togglecorp/fujs'; +import { useContext } from 'react'; -import Link from '#components/Link'; import MonthlyCalendar from '#components/MonthlyCalendar'; -import RadioInput from '#components/RadioInput'; import DateContext from '#contexts/date'; -import useLocalStorage from '#hooks/useLocalStorage'; -import useSetFieldValue from '#hooks/useSetFieldValue'; -import { - defaultConfigValue, - numericOptionKeySelector, - numericOptionLabelSelector, - numericOptions, -} from '#utils/constants'; -import { - DailyJournalAttribute, - DailyJournalAttributeKeys, - DailyJournalGrouping, -} from '#utils/types'; import styles from './styles.module.css'; -const dailyJournalAttributeDetails: Record = { - project: { label: 'Project' }, - contract: { label: 'Contract' }, - task: { label: 'Task' }, - status: { label: 'Status' }, -}; - -interface ItemProps { - className?: string; - attribute: DailyJournalAttribute; - setNodeRef?: (node: HTMLElement | null) => void; - draggableAttributes?: DraggableAttributes; - draggableListeners?: SyntheticListenerMap | undefined; - transformStyle?: string | undefined; - transitionStyle?: string | undefined; -} - -function Item(props: ItemProps) { - const { - className, - setNodeRef, - attribute, - draggableAttributes, - draggableListeners, - transformStyle, - transitionStyle, - } = props; - - return ( -
-
- -
-
- {dailyJournalAttributeDetails[attribute.key].label} -
-
- ); -} - -interface SortableItemProps { - className?: string; - attribute: DailyJournalAttribute; -} - -function SortableItem(props: SortableItemProps) { - const { - attribute, - className, - } = props; - - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - over, - } = useSortable({ id: attribute.key }); - - const transformStyle = useMemo(() => { - if (isNotDefined(transform)) { - return undefined; - } - - const transformations = [ - // isDefined(transform.x) && `translateX(${transform.x}px)`, - isDefined(transform.y) && `translateY(${transform.y}px)`, - isDefined(transform.scaleX) && `scaleY(${transform.scaleX})`, - isDefined(transform.scaleY) && `scaleY(${transform.scaleY})`, - ]; - - return transformations.filter(Boolean).join(' '); - }, [transform]); - - return ( - - ); -} - interface Props { selectedDate: string; setSelectedDate: (newDate: string) => void; @@ -166,64 +20,8 @@ function StartSidebar(props: Props) { setSelectedDate, } = props; - const [storedConfig, setStoredConfig] = useLocalStorage('timur-config'); - - const setConfigFieldValue = useSetFieldValue(setStoredConfig); - const { year, month } = useContext(DateContext); - const updateJournalGrouping = useCallback((value: number, name: 'groupLevel' | 'joinLevel') => { - const oldValue = storedConfig.dailyJournalGrouping - ?? defaultConfigValue.dailyJournalGrouping; - - if (name === 'groupLevel') { - setConfigFieldValue({ - groupLevel: value, - joinLevel: Math.min(oldValue.joinLevel, value), - } satisfies DailyJournalGrouping, 'dailyJournalGrouping'); - - return; - } - - setConfigFieldValue({ - groupLevel: oldValue.groupLevel, - joinLevel: Math.min(oldValue.groupLevel, value), - } satisfies DailyJournalGrouping, 'dailyJournalGrouping'); - }, [storedConfig.dailyJournalGrouping, setConfigFieldValue]); - - const sensors = useSensors( - useSensor(PointerSensor), - ); - - const handleDndEnd = useCallback((dragEndEvent: DragEndEvent) => { - const { - active, - over, - } = dragEndEvent; - - const oldAttributes = storedConfig.dailyJournalAttributeOrder - ?? defaultConfigValue.dailyJournalAttributeOrder; - - if (isNotDefined(active) || isNotDefined(over)) { - return; - } - - const newAttributes = [...oldAttributes]; - const sourceIndex = newAttributes.findIndex(({ key }) => active.id === key); - const destinationIndex = newAttributes.findIndex(({ key }) => over.id === key); - - if (sourceIndex === -1 || destinationIndex === -1) { - return; - } - - const [removedItem] = newAttributes.splice(sourceIndex, 1); - // NOTE: We can assert removedItem is not undefined as sourceIndex is already checked for -1 - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - newAttributes.splice(destinationIndex, 0, removedItem!); - - setConfigFieldValue(newAttributes, 'dailyJournalAttributeOrder'); - }, [setConfigFieldValue, storedConfig.dailyJournalAttributeOrder]); - return (
-
-

Ordering

-
- - ({ id: key }), - )} - strategy={verticalListSortingStrategy} - > - {storedConfig.dailyJournalAttributeOrder.map((attribute) => ( - - ))} - - -
-
-
-

- Grouping -

- - -
- - Other Settings -
); } diff --git a/src/views/DailyJournal/StartSidebar/styles.module.css b/src/views/DailyJournal/StartSidebar/styles.module.css index 382fbc7..eaa8075 100644 --- a/src/views/DailyJournal/StartSidebar/styles.module.css +++ b/src/views/DailyJournal/StartSidebar/styles.module.css @@ -3,69 +3,4 @@ flex-direction: column; gap: var(--spacing-lg); padding: var(--spacing-md); - - .actions { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - } - - .attributes { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - - .attribute-list { - display: flex; - flex-direction: column; - gap: var(--width-separator-md); - - &.dragging-over { - outline: var(--width-separator-md) solid var(--color-separator); - } - - .attribute { - display: flex; - align-items: center; - background-color: var(--color-foreground); - padding: var(--spacing-xs) var(--spacing-sm); - gap: var(--spacing-xs); - - &.dragging { - opacity: 0.8; - z-index: 1; - box-shadow: var(--box-shadow-md); - } - - .drag-handle { - cursor: grab; - } - - &:hover { - background-color: var(--color-tertiary); - } - - .label { - flex-grow: 1; - } - } - } - } - - .grouping { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - } - - - .quick-settings { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - - .type-option-list { - flex-direction: column; - } - } } diff --git a/src/views/Settings/index.tsx b/src/views/Settings/index.tsx index 5597740..d921c45 100644 --- a/src/views/Settings/index.tsx +++ b/src/views/Settings/index.tsx @@ -1,19 +1,162 @@ -import { useContext } from 'react'; +import { + useCallback, + useContext, + useMemo, +} from 'react'; +import { RiDraggable } from 'react-icons/ri'; +import { + closestCenter, + DndContext, + DragEndEvent, + DraggableAttributes, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { SyntheticListenerMap } from '@dnd-kit/core/dist/hooks/utilities'; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { + _cs, + isDefined, + isNotDefined, +} from '@togglecorp/fujs'; import Checkbox from '#components/Checkbox'; import Page from '#components/Page'; +import RadioInput from '#components/RadioInput'; import SelectInput from '#components/SelectInput'; import EnumsContext from '#contexts/enums'; import { EnumsQuery } from '#generated/types/graphql'; import useLocalStorage from '#hooks/useLocalStorage'; import useSetFieldValue from '#hooks/useSetFieldValue'; -import { colorscheme } from '#utils/constants'; -import { EditingMode } from '#utils/types'; +import { + colorscheme, + defaultConfigValue, + numericOptionKeySelector, + numericOptionLabelSelector, + numericOptions, +} from '#utils/constants'; +import { + DailyJournalAttribute, + DailyJournalAttributeKeys, + DailyJournalGrouping, + EditingMode, +} from '#utils/types'; import WorkItemRow from '../DailyJournal/DayView/WorkItemRow'; import styles from './styles.module.css'; +const dailyJournalAttributeDetails: Record = { + project: { label: 'Project' }, + contract: { label: 'Contract' }, + task: { label: 'Task' }, + status: { label: 'Status' }, +}; + +interface ItemProps { + className?: string; + attribute: DailyJournalAttribute; + setNodeRef?: (node: HTMLElement | null) => void; + draggableAttributes?: DraggableAttributes; + draggableListeners?: SyntheticListenerMap | undefined; + transformStyle?: string | undefined; + transitionStyle?: string | undefined; +} + +function Item(props: ItemProps) { + const { + className, + setNodeRef, + attribute, + draggableAttributes, + draggableListeners, + transformStyle, + transitionStyle, + } = props; + + return ( +
+
+ +
+
+ {dailyJournalAttributeDetails[attribute.key].label} +
+
+ ); +} + +interface SortableItemProps { + className?: string; + attribute: DailyJournalAttribute; +} + +function SortableItem(props: SortableItemProps) { + const { + attribute, + className, + } = props; + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + over, + } = useSortable({ id: attribute.key }); + + const transformStyle = useMemo(() => { + if (isNotDefined(transform)) { + return undefined; + } + + const transformations = [ + // isDefined(transform.x) && `translateX(${transform.x}px)`, + isDefined(transform.y) && `translateY(${transform.y}px)`, + isDefined(transform.scaleX) && `scaleY(${transform.scaleX})`, + isDefined(transform.scaleY) && `scaleY(${transform.scaleY})`, + ]; + + return transformations.filter(Boolean).join(' '); + }, [transform]); + + return ( + + ); +} + type EditingOption = { key: EditingMode, label: string }; function editingOptionKeySelector(item: EditingOption) { return item.key; @@ -64,6 +207,58 @@ export function Component() { const [storedConfig, setStoredConfig] = useLocalStorage('timur-config'); const setConfigFieldValue = useSetFieldValue(setStoredConfig); + const updateJournalGrouping = useCallback((value: number, name: 'groupLevel' | 'joinLevel') => { + const oldValue = storedConfig.dailyJournalGrouping + ?? defaultConfigValue.dailyJournalGrouping; + + if (name === 'groupLevel') { + setConfigFieldValue({ + groupLevel: value, + joinLevel: Math.min(oldValue.joinLevel, value), + } satisfies DailyJournalGrouping, 'dailyJournalGrouping'); + + return; + } + + setConfigFieldValue({ + groupLevel: oldValue.groupLevel, + joinLevel: Math.min(oldValue.groupLevel, value), + } satisfies DailyJournalGrouping, 'dailyJournalGrouping'); + }, [storedConfig.dailyJournalGrouping, setConfigFieldValue]); + + const sensors = useSensors( + useSensor(PointerSensor), + ); + + const handleDndEnd = useCallback((dragEndEvent: DragEndEvent) => { + const { + active, + over, + } = dragEndEvent; + + const oldAttributes = storedConfig.dailyJournalAttributeOrder + ?? defaultConfigValue.dailyJournalAttributeOrder; + + if (isNotDefined(active) || isNotDefined(over)) { + return; + } + + const newAttributes = [...oldAttributes]; + const sourceIndex = newAttributes.findIndex(({ key }) => active.id === key); + const destinationIndex = newAttributes.findIndex(({ key }) => over.id === key); + + if (sourceIndex === -1 || destinationIndex === -1) { + return; + } + + const [removedItem] = newAttributes.splice(sourceIndex, 1); + // NOTE: We can assert removedItem is not undefined as sourceIndex is already checked for -1 + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + newAttributes.splice(destinationIndex, 0, removedItem!); + + setConfigFieldValue(newAttributes, 'dailyJournalAttributeOrder'); + }, [setConfigFieldValue, storedConfig.dailyJournalAttributeOrder]); + return ( - -
+
+

+ Entry ordering +

+
+ + ({ id: key }), + )} + strategy={verticalListSortingStrategy} + > + {storedConfig.dailyJournalAttributeOrder.map((attribute) => ( + + ))} + + +
+
+
+

+ Entry Grouping +

+ + + + +

Create Entry diff --git a/src/views/Settings/styles.module.css b/src/views/Settings/styles.module.css index c5b9727..8bf72c8 100644 --- a/src/views/Settings/styles.module.css +++ b/src/views/Settings/styles.module.css @@ -13,9 +13,7 @@ .container { display: flex; flex-direction: column; - background-color: var(--color-quaternary); - padding: var(--spacing-xs); - gap: var(--spacing-xs); + gap: var(--width-separator-lg); .work-item { background-color: var(--color-foreground); @@ -23,4 +21,41 @@ } } } + + + .attribute-list { + display: flex; + flex-direction: column; + gap: var(--width-separator-lg); + + &.dragging-over { + outline: var(--width-separator-md) solid var(--color-separator); + } + + .attribute { + display: flex; + align-items: center; + background-color: var(--color-foreground); + padding: var(--spacing-xs) var(--spacing-sm); + gap: var(--spacing-xs); + + &.dragging { + opacity: 0.8; + z-index: 1; + box-shadow: var(--box-shadow-md); + } + + .drag-handle { + cursor: grab; + } + + &:hover { + background-color: var(--color-tertiary); + } + + .label { + flex-grow: 1; + } + } + } } From ae134be24c6b6000d17c98a6aac804c94dfc4ae7 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Mon, 26 Jan 2026 10:40:48 +0545 Subject: [PATCH 22/27] fix: fix issue with auto updating to DOING --- src/views/DailyJournal/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/views/DailyJournal/index.tsx b/src/views/DailyJournal/index.tsx index 2ec506a..40e3c6f 100644 --- a/src/views/DailyJournal/index.tsx +++ b/src/views/DailyJournal/index.tsx @@ -456,8 +456,9 @@ export function Component() { && oldItem.duration !== tentativeNewItem.duration && tentativeNewItem.status === 'TODO' ) { - tentativeNewItem.status = 'DOING'; + changes.status = 'DOING'; } + setWorkItemChange({ type: 'edit', key: workItemClientId, From 06be8d7f3acca49c87f4d3e2496ac597ee416f7f Mon Sep 17 00:00:00 2001 From: tnagorra Date: Mon, 26 Jan 2026 10:43:07 +0545 Subject: [PATCH 23/27] feat: allow selecting task from any project/contract - change all search behavior with fuzzy search --- src/components/SearchSelectInput/index.tsx | 2 +- src/components/SelectInput/index.tsx | 5 +- src/utils/common.ts | 42 +++++++-------- .../DayView/WorkItemRow/index.tsx | 52 ++++++++++++------- src/views/DailyJournal/DayView/index.tsx | 3 -- src/views/Settings/index.tsx | 2 - 6 files changed, 58 insertions(+), 48 deletions(-) diff --git a/src/components/SearchSelectInput/index.tsx b/src/components/SearchSelectInput/index.tsx index 2fb06ae..9e6e612 100644 --- a/src/components/SearchSelectInput/index.tsx +++ b/src/components/SearchSelectInput/index.tsx @@ -186,7 +186,7 @@ function SearchSelectInput< if (sortFunction) { return [ - ...rankedSearchOnList(initiallySelected, searchInputValue, labelSelector), + ...sortFunction(initiallySelected, searchInputValue, labelSelector), ...sortFunction(initiallyNotSelected, searchInputValue, labelSelector), ]; } diff --git a/src/components/SelectInput/index.tsx b/src/components/SelectInput/index.tsx index 606aa8d..3fd9f80 100644 --- a/src/components/SelectInput/index.tsx +++ b/src/components/SelectInput/index.tsx @@ -36,6 +36,7 @@ function SelectInput< nonClearable, // eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars onChange, + sortFunction = rankedSearchOnList, ...otherProps } = props; @@ -53,7 +54,7 @@ function SelectInput< nonClearable={props.nonClearable} name={name} options={options} - sortFunction={rankedSearchOnList} + sortFunction={sortFunction} searchOptions={options} selectedOnTop={false} /> @@ -69,7 +70,7 @@ function SelectInput< nonClearable={props.nonClearable} name={name} options={options} - sortFunction={rankedSearchOnList} + sortFunction={sortFunction} searchOptions={options} selectedOnTop={false} /> diff --git a/src/utils/common.ts b/src/utils/common.ts index 0afbab8..c231ed1 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,6 +1,4 @@ import { - caseInsensitiveSubmatch, - compareStringSearch, encodeDate, isDefined, isFalsyString, @@ -37,24 +35,6 @@ export function getNewId(): string { return ulid(); } -export function rankedSearchOnList( - list: T[], - searchString: string | undefined, - labelSelector: (item: T) => string, -) { - if (isFalsyString(searchString)) { - return list; - } - - return list - .filter((option) => caseInsensitiveSubmatch(labelSelector(option), searchString)) - .sort((a, b) => compareStringSearch( - labelSelector(a), - labelSelector(b), - searchString, - )); -} - export function getDurationString(totalMinutes: number) { const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; @@ -124,7 +104,7 @@ export function addDays(dateStr: string, numDays: number) { } export function fuzzySearch( - rows: ReadonlyArray, + rows: ItemType[], filterValue: string, options?: MatchSorterOptions, ) { @@ -326,3 +306,23 @@ const dateTimeFormatter = new Intl.DateTimeFormat( export function formatDateTime(date: Date) { return dateTimeFormatter.format(date); } + +export function rankedSearchOnList( + list: T[], + searchString: string | undefined, + labelSelector: (item: T) => string, +) { + if (isFalsyString(searchString)) { + return list; + } + + return fuzzySearch( + list, + searchString, + { + keys: [ + labelSelector, + ], + }, + ); +} diff --git a/src/views/DailyJournal/DayView/WorkItemRow/index.tsx b/src/views/DailyJournal/DayView/WorkItemRow/index.tsx index 6b483f8..7246c1c 100644 --- a/src/views/DailyJournal/DayView/WorkItemRow/index.tsx +++ b/src/views/DailyJournal/DayView/WorkItemRow/index.tsx @@ -7,7 +7,6 @@ import { } from 'react'; import { RiDeleteBin2Line, - RiEditBoxLine, RiFileCopyLine, RiMoreLine, RiSwap2Line, @@ -33,6 +32,7 @@ import SizeContext from '#contexts/size'; import { EnumsQuery } from '#generated/types/graphql'; import { useFocusClient } from '#hooks/useFocus'; import useLocalStorage from '#hooks/useLocalStorage'; +import { fuzzySearch } from '#utils/common'; import { colorscheme } from '#utils/constants'; import { EntriesAsList, @@ -52,6 +52,11 @@ function taskKeySelector(item: Task) { function taskLabelSelector(item: Task) { return item.name; } +function taskDescriptionSelector(item: Task) { + const { contract } = item; + const { project } = contract; + return `${project.name} › ${contract.name}`; +} function workItemTypeKeySelector(item: WorkItemTypeOption) { return item.key; } @@ -84,7 +89,6 @@ interface Props { className?: string; workItem: WorkItem; tasks: Task[] | undefined; - contractId: string | undefined; typeErrored?: boolean; durationErrored?: boolean; @@ -100,7 +104,6 @@ function WorkItemRow(props: Props) { className, workItem, tasks, - contractId, onClone, onAssist, onDelete, @@ -124,7 +127,7 @@ function WorkItemRow(props: Props) { [workItem.clientId, onChange], ); - const filteredTaskList = useMemo( + const taskList: Task[] = useMemo( () => ( unique( [ @@ -132,11 +135,31 @@ function WorkItemRow(props: Props) { ...tasks ?? [], ], (item) => item.id, - ).filter( - (task) => task.contract.id === contractId, ) ), - [contractId, enums, tasks], + [enums, tasks], + ); + + // FIXME: re-use this + const filterTaskList = useCallback( + (items: Task[], value: string | undefined | null): Task[] => { + if (!value) { + return items; + } + return fuzzySearch( + items, + value, + { + keys: [ + (task) => task.name, + (task) => task.contract.name, + (task) => task.contract.project.name, + (task) => task.contract.project.projectClient.name, + ], + }, + ); + }, + [], ); const handleStatusCheck = useCallback(() => { @@ -237,11 +260,12 @@ function WorkItemRow(props: Props) { @@ -307,16 +331,6 @@ function WorkItemRow(props: Props) { persistent title="Show additional entry options" > - } - disabled - > - Edit entry -

); diff --git a/src/views/Settings/index.tsx b/src/views/Settings/index.tsx index d921c45..348fed9 100644 --- a/src/views/Settings/index.tsx +++ b/src/views/Settings/index.tsx @@ -303,7 +303,6 @@ export function Component() { task: '1', type: 'DEVELOPMENT', }} - contractId="1" /> From 5555a90a9f6951b4bb6fd88dc08b995ec09f6057 Mon Sep 17 00:00:00 2001 From: tnagorra Date: Mon, 26 Jan 2026 10:47:38 +0545 Subject: [PATCH 24/27] fix: stop triggering DurationInput onChange on blur if value has not changed --- src/components/DurationInput/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/DurationInput/index.tsx b/src/components/DurationInput/index.tsx index 8481eb5..83f0952 100644 --- a/src/components/DurationInput/index.tsx +++ b/src/components/DurationInput/index.tsx @@ -95,12 +95,12 @@ function DurationInput(props: Props) { const handleBlur = useCallback(() => { const newValue = getDurationNumber(tempValue); - if (newValue !== null && onChange) { + if (onChange && newValue !== null && newValue !== valueFromProps) { onChange(newValue, name); } setCounter((oldVal) => (oldVal + 1)); - }, [name, tempValue, onChange]); + }, [name, tempValue, valueFromProps, onChange]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { From 11f628212ffc65677881d91f8631efdcfc2b50ff Mon Sep 17 00:00:00 2001 From: tnagorra Date: Mon, 2 Feb 2026 09:24:41 +0545 Subject: [PATCH 25/27] fix: remove CalendarInput --- src/components/CalendarInput/index.tsx | 78 ------------------- .../CalendarInput/styles.module.css | 8 -- 2 files changed, 86 deletions(-) delete mode 100644 src/components/CalendarInput/index.tsx delete mode 100644 src/components/CalendarInput/styles.module.css diff --git a/src/components/CalendarInput/index.tsx b/src/components/CalendarInput/index.tsx deleted file mode 100644 index 3ac6303..0000000 --- a/src/components/CalendarInput/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { - useCallback, - useContext, - useState, -} from 'react'; - -import Button, { Props as ButtonProps } from '#components//Button'; -import Dialog from '#components/Dialog'; -import MonthlyCalendar from '#components/MonthlyCalendar'; -import DateContext from '#contexts/date'; - -import styles from './styles.module.css'; - -interface Props extends Omit, 'onClick' | 'onChange'> { - value: string | undefined, - onChange: (value: string | undefined) => void; -} - -function CalendarInput(props: Props) { - const { - value, - onChange, - ...buttonProps - } = props; - - const [confirmationShown, setConfirmationShown] = useState(false); - const { year, month } = useContext(DateContext); - - const handleModalOpen = useCallback( - () => { - setConfirmationShown(true); - }, - [], - ); - - const handleModalClose = useCallback( - () => { - setConfirmationShown(false); - }, - [], - ); - - const handleDateClick = useCallback( - (newValue: string) => { - onChange(newValue); - setConfirmationShown(false); - }, - [onChange], - ); - - return ( - <> -