diff --git a/README.md b/README.md index 63379a0..63c5810 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,17 @@ If you have frequently used entry names you can define them in your template by This will create an entry that is not yet started which you can click the play button on and start without having to type out the name. +## Known issues + +### Jumpy rendering behavior on modification + +If your lists become longer you will likely see some jumpy/flickery behavior with timekeep when making modifications (add/save/delete/collapse/expand), this is a limitation of how Obsidian re-renders the app. + +I am unable to take advantage of the React virtual DOM to only update the changed DOM elements for the entries, because Obsidian re-creates the entire app when the code block changes (Since the timekeep data is stored in the codeblock, modifications cause this to happen. Thus the virtual DOM and real DOM are thrown away causing a full re-render). This issue also means local state will all be lost on modification (Which is why the collapsed state must be persisted to the timekeep) + + +I do not believe this can be fixed but PRs are welcome if you are aware of a way to fix this. + ## 📄 License This project is licensed under the [MIT License](./LICENSE.md) \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index ff7a3a5..2b70fc1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,122 +1,53 @@ -import { Timekeep } from "@/schema"; -import { isKeepRunning } from "@/timekeep"; +import React from "react"; import { App as ObsidianApp } from "obsidian"; import TimesheetStart from "@/components/TimesheetStart"; import TimesheetTable from "@/components/TimesheetTable"; import TimesheetCounters from "@/components/TimesheetCounters"; -import { SettingsContext } from "@/contexts/use-settings-context"; -import { TimekeepContext } from "@/contexts/use-timekeep-context"; -import React, { useState, useCallback, SetStateAction } from "react"; import TimesheetExportActions from "@/components/TimesheetExportActions"; -import { ConfirmModal } from "./utils/confirm-modal"; import { AppContext } from "./contexts/use-app-context"; +import TimesheetSaveError from "./components/TimesheetSaveError"; +import { SettingsContext } from "./contexts/use-settings-context"; import { SettingsStore, useSettingsStore } from "./store/settings-store"; +import { + useSaveError, + TimekeepStore, + TimekeepStoreContext, +} from "./store/timekeep-store"; export type AppProps = { + // Obsidian app for creating modals app: ObsidianApp; - // Initial state loaded from the document - initialState: Timekeep; - // The timekeep settings + // Timekeep state store + timekeepStore: TimekeepStore; + // Timekeep settings store settingsStore: SettingsStore; - // Function to save the timekeep data - save: (timekeep: Timekeep) => Promise; }; /** * Main app component, handles managing the app state and * providing the contexts. - * - * Wraps the state updates for `setTimekeep` with logic that - * saves the changes to the vault */ -export default function App({ - app, - initialState, - save, - settingsStore, -}: AppProps) { - const [timekeep, setTimekeep] = useState(initialState); - const [saveError, setSaveError] = useState(false); +export default function App({ app, timekeepStore, settingsStore }: AppProps) { const settings = useSettingsStore(settingsStore); - - const trySave = (timekeep: Timekeep) => { - save(timekeep).then((isSaved) => { - setSaveError(!isSaved); - }); - }; - - // Wrapper around setTimekeep state to save the file on changes - const setTimekeepWrapper = useCallback( - (value: SetStateAction) => { - setTimekeep((storedValue) => { - const updatedValue = - value instanceof Function ? value(storedValue) : value; - trySave(updatedValue); - return updatedValue; - }); - }, - [setTimekeep] - ); - - const onRetrySave = () => { - trySave(timekeep); - }; - - const onClickCopy = () => { - navigator.clipboard.writeText(JSON.stringify(timekeep)); - }; - - const showConfirm = useCallback( - (title: string, message: string): Promise => { - return new Promise((resolve) => { - const modal = new ConfirmModal(app, message, resolve); - modal.setTitle(title); - modal.open(); - }); - }, - [app] - ); - - // Saving error fallback screen - if (saveError) { - return ( -
-
-

Warning

-

Failed to save current timekeep

-

- Press "Retry" to try again or "Copy Timekeep" to copy a - backup to clipboard, an automated backup JSON file will - be generated in the root of this vault -

-
- -
- - -
-
- ); - } + const saveError = useSaveError(timekeepStore); return ( - -
- - - - -
-
+ + {saveError ? ( + // Error page when saving fails + + ) : ( +
+ + + + +
+ )} +
); diff --git a/src/components/TimesheetCounters.tsx b/src/components/TimesheetCounters.tsx index 904e5d8..02c0e97 100644 --- a/src/components/TimesheetCounters.tsx +++ b/src/components/TimesheetCounters.tsx @@ -1,10 +1,11 @@ import moment from "moment"; import { Timekeep } from "@/schema"; import React, { useState, useEffect } from "react"; -import { useTimekeep } from "@/contexts/use-timekeep-context"; import { useSettings } from "@/contexts/use-settings-context"; import { formatDuration, formatDurationHoursTrunc } from "@/utils"; +import { useTimekeep, useTimekeepStore } from "@/store/timekeep-store"; import { + isKeepRunning, getRunningEntry, getTotalDuration, getEntryDuration, @@ -42,9 +43,11 @@ function getTimingState(timekeep: Timekeep): TimingState { } export default function TimesheetCounters() { - const { timekeep, isTimekeepRunning } = useTimekeep(); - const [timing, setTiming] = useState(getTimingState(timekeep)); const settings = useSettings(); + const store = useTimekeepStore(); + const timekeep = useTimekeep(store); + + const [timing, setTiming] = useState(getTimingState(timekeep)); // Update the current timings every second useEffect(() => { @@ -54,14 +57,14 @@ export default function TimesheetCounters() { updateTiming(); // Only schedule further updates if we are running - if (isTimekeepRunning) { + if (isKeepRunning(timekeep)) { const intervalID = window.setInterval(updateTiming, 1000); return () => { clearInterval(intervalID); }; } - }, [timekeep, isTimekeepRunning]); + }, [timekeep]); return (
diff --git a/src/components/TimesheetExportActions.tsx b/src/components/TimesheetExportActions.tsx index 0faf40d..0239fd9 100644 --- a/src/components/TimesheetExportActions.tsx +++ b/src/components/TimesheetExportActions.tsx @@ -7,14 +7,15 @@ import { Platform } from "obsidian"; import { mkdir, writeFile } from "fs/promises"; import { PdfExportBehavior } from "@/settings"; import { createCSV, createMarkdownTable } from "@/export"; +import { useTimekeepStore } from "@/store/timekeep-store"; import { useSettings } from "@/contexts/use-settings-context"; -import { useTimekeep } from "@/contexts/use-timekeep-context"; export default function TimekeepExportActions() { - const { timekeep } = useTimekeep(); const settings = useSettings(); + const store = useTimekeepStore(); const onCopyMarkdown = () => { + const timekeep = store.getTimekeep(); const currentTime = moment(); const output = createMarkdownTable(timekeep, settings, currentTime); @@ -25,6 +26,7 @@ export default function TimekeepExportActions() { }; const onCopyCSV = () => { + const timekeep = store.getTimekeep(); const currentTime = moment(); const output = createCSV(timekeep, settings, currentTime); @@ -35,6 +37,7 @@ export default function TimekeepExportActions() { }; const onCopyJSON = () => { + const timekeep = store.getTimekeep(); const output = JSON.stringify(timekeep); navigator.clipboard @@ -44,6 +47,8 @@ export default function TimekeepExportActions() { }; const onSavePDF = async () => { + const timekeep = store.getTimekeep(); + // Pdf exports don't work in mobile mode if (Platform.isMobileApp) return; diff --git a/src/components/TimesheetRow.tsx b/src/components/TimesheetRow.tsx index 9676ab0..9c9b5b8 100644 --- a/src/components/TimesheetRow.tsx +++ b/src/components/TimesheetRow.tsx @@ -1,8 +1,8 @@ import moment from "moment"; import { TimeEntry } from "@/schema"; -import React, { useState, useCallback } from "react"; +import React, { useMemo, useState } from "react"; +import { useTimekeepStore } from "@/store/timekeep-store"; import { useSettings } from "@/contexts/use-settings-context"; -import { useTimekeep } from "@/contexts/use-timekeep-context"; import TimesheetRowEditing from "@/components/TimesheetRowEditing"; import TimesheetRowDuration from "@/components/TimesheetRowDuration"; import { @@ -21,17 +21,26 @@ import ObsidianIcon from "./ObsidianIcon"; type Props = { entry: TimeEntry; indent: number; + isTimekeepRunning: boolean; }; -export default function TimesheetRow({ entry, indent }: Props) { - const isSelfRunning = entry.subEntries === null && isEntryRunning(entry); +export default function TimesheetRow({ + entry, + indent, + isTimekeepRunning, +}: Props) { const settings = useSettings(); - const { setTimekeep, isTimekeepRunning } = useTimekeep(); + const store = useTimekeepStore(); const [editing, setEditing] = useState(false); + const isSelfRunning = useMemo( + () => entry.subEntries === null && isEntryRunning(entry), + [entry] + ); + const onClickStart = () => { - setTimekeep((timekeep) => { + store.setTimekeep((timekeep) => { // Don't start if already running if (isKeepRunning(timekeep)) { return timekeep; @@ -65,15 +74,15 @@ export default function TimesheetRow({ entry, indent }: Props) { }; // Handles toggling the collapsed state for an entry - const handleToggleCollapsed = useCallback(() => { + const handleToggleCollapsed = () => { if (entry.subEntries === null) return; - setTimekeep((timekeep) => { + store.setTimekeep((timekeep) => { const newEntry = setEntryCollapsed(entry, !entry.collapsed); const entries = updateEntry(timekeep.entries, entry, newEntry); return { ...timekeep, entries }; }); - }, [setTimekeep, entry]); + }; if (editing) { return ( diff --git a/src/components/TimesheetRowDuration.tsx b/src/components/TimesheetRowDuration.tsx index e06287e..3805c93 100644 --- a/src/components/TimesheetRowDuration.tsx +++ b/src/components/TimesheetRowDuration.tsx @@ -4,18 +4,6 @@ import { formatDuration } from "@/utils"; import React, { useState, useEffect } from "react"; import { isEntryRunning, getEntryDuration } from "@/timekeep"; -/** - * Obtains the formatted duration string for an entry - * - * @param entry The entry - * @returns The formatted duration - */ -function getDuration(entry: TimeEntry): string { - const currentTime = moment(); - const duration = getEntryDuration(entry, currentTime); - return formatDuration(duration); -} - type Props = { entry: TimeEntry; }; @@ -26,11 +14,11 @@ type Props = { * latest duration. */ export default function TimesheetRowDuration({ entry }: Props) { - const [duration, setDuration] = useState(getDuration(entry)); + const [duration, setDuration] = useState(getFormattedDuration(entry)); useEffect(() => { const isRunning = isEntryRunning(entry); - const updateTiming = () => setDuration(getDuration(entry)); + const updateTiming = () => setDuration(getFormattedDuration(entry)); // Initial update updateTiming(); @@ -48,3 +36,15 @@ export default function TimesheetRowDuration({ entry }: Props) { return {duration}; } + +/** + * Obtains the formatted duration string for an entry + * + * @param entry The entry + * @returns The formatted duration + */ +function getFormattedDuration(entry: TimeEntry): string { + const currentTime = moment(); + const duration = getEntryDuration(entry, currentTime); + return formatDuration(duration); +} diff --git a/src/components/TimesheetRowEditing.tsx b/src/components/TimesheetRowEditing.tsx index 74bf067..7f8dc4b 100644 --- a/src/components/TimesheetRowEditing.tsx +++ b/src/components/TimesheetRowEditing.tsx @@ -1,8 +1,9 @@ import { TimeEntry } from "@/schema"; +import { useDialog } from "@/contexts/use-dialog"; import { removeEntry, updateEntry } from "@/timekeep"; -import React, { useState, useEffect, FormEvent } from "react"; +import { useTimekeepStore } from "@/store/timekeep-store"; import { useSettings } from "@/contexts/use-settings-context"; -import { useTimekeep } from "@/contexts/use-timekeep-context"; +import React, { useState, useEffect, FormEvent } from "react"; import { parseEditableTimestamp, formatEditableTimestamp } from "@/utils"; import ObsidianIcon from "./ObsidianIcon"; @@ -14,7 +15,8 @@ type Props = { export default function TimesheetRowEditing({ entry, onFinishEditing }: Props) { const settings = useSettings(); - const { setTimekeep, showConfirm } = useTimekeep(); + const { showConfirm } = useDialog(); + const store = useTimekeepStore(); const [name, setName] = useState(entry.name); const [startTime, setStartTime] = useState(""); @@ -44,7 +46,7 @@ export default function TimesheetRowEditing({ entry, onFinishEditing }: Props) { return; } - setTimekeep((timekeep) => ({ + store.setTimekeep((timekeep) => ({ entries: removeEntry(timekeep.entries, entry), })); }; @@ -76,7 +78,7 @@ export default function TimesheetRowEditing({ entry, onFinishEditing }: Props) { } // Save the updated entry - setTimekeep((timekeep) => ({ + store.setTimekeep((timekeep) => ({ entries: updateEntry(timekeep.entries, entry, newEntry), })); diff --git a/src/components/TimesheetRows.tsx b/src/components/TimesheetRows.tsx index bb633a5..02873ab 100644 --- a/src/components/TimesheetRows.tsx +++ b/src/components/TimesheetRows.tsx @@ -9,13 +9,19 @@ type Props = { entries: TimeEntry[]; // Indentation for the entries indent: number; + // Whether the timekeep is currently running + isTimekeepRunning: boolean; }; /** * Renders a collection of timesheet entries ordered based * on the current settings */ -export default function TimesheetRows({ entries, indent }: Props) { +export default function TimesheetRows({ + entries, + indent, + isTimekeepRunning, +}: Props) { const settings = useSettings(); // Memoized sub entries const entriesOrdered = useMemo( @@ -25,10 +31,18 @@ export default function TimesheetRows({ entries, indent }: Props) { return entriesOrdered.map((entry) => ( - + {entry.subEntries !== null && !entry.collapsed && ( - + )} )); diff --git a/src/components/TimesheetSaveError.tsx b/src/components/TimesheetSaveError.tsx new file mode 100644 index 0000000..bda65a6 --- /dev/null +++ b/src/components/TimesheetSaveError.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { useTimekeepStore } from "@/store/timekeep-store"; + +export default function TimesheetSaveError() { + const store = useTimekeepStore(); + + // Attempts to re-save the current timekeep + const onRetrySave = () => { + // Attempt to save the current timekeep + store.saveTimekeep(store.getTimekeep()); + }; + + // Copies the current timekeep state as JSON to clipboard + const onClickCopy = () => { + navigator.clipboard.writeText(JSON.stringify(store.getTimekeep())); + }; + + return ( +
+
+

Warning

+

Failed to save current timekeep

+

+ Press "Retry" to try again or "Copy Timekeep" to copy a + backup to clipboard, an automated backup JSON file will be + generated in the root of this vault +

+
+ +
+ + +
+
+ ); +} diff --git a/src/components/TimesheetStart.tsx b/src/components/TimesheetStart.tsx index f6200c0..bbe253c 100644 --- a/src/components/TimesheetStart.tsx +++ b/src/components/TimesheetStart.tsx @@ -1,8 +1,8 @@ import moment from "moment"; import { formatTimestamp } from "@/utils"; import React, { useMemo, useState, FormEvent } from "react"; -import { useTimekeep } from "@/contexts/use-timekeep-context"; import { useSettings } from "@/contexts/use-settings-context"; +import { useTimekeep, useTimekeepStore } from "@/store/timekeep-store"; import { withEntry, isKeepRunning, @@ -18,14 +18,18 @@ import ObsidianIcon from "./ObsidianIcon"; * name field */ export default function TimekeepStart() { - const { timekeep, setTimekeep, isTimekeepRunning } = useTimekeep(); + const store = useTimekeepStore(); + const timekeep = useTimekeep(store); const [name, setName] = useState(""); const settings = useSettings(); + const currentEntry = useMemo( () => getRunningEntry(timekeep.entries), [timekeep] ); + const isTimekeepRunning = currentEntry !== null; + /** * Handles the click of the start/stop button */ @@ -34,7 +38,7 @@ export default function TimekeepStart() { event.preventDefault(); event.stopPropagation(); - setTimekeep((timekeep) => { + store.setTimekeep((timekeep) => { const currentTime = moment(); let entries; diff --git a/src/components/TimesheetTable.tsx b/src/components/TimesheetTable.tsx index 0eeaf21..2302baa 100644 --- a/src/components/TimesheetTable.tsx +++ b/src/components/TimesheetTable.tsx @@ -1,12 +1,20 @@ -import React from "react"; +import React, { useMemo } from "react"; +import { isKeepRunning } from "@/timekeep"; import TimesheetRows from "@/components/TimesheetRows"; import { useSettings } from "@/contexts/use-settings-context"; -import { useTimekeep } from "@/contexts/use-timekeep-context"; +import { useTimekeep, useTimekeepStore } from "@/store/timekeep-store"; export default function TimesheetTable() { - const { timekeep } = useTimekeep(); + const store = useTimekeepStore(); + const timekeep = useTimekeep(store); const settings = useSettings(); + // Determine whether the keep is running + const isTimekeepRunning = useMemo( + () => isKeepRunning(timekeep), + [timekeep] + ); + // Max size and scroll for table with "Limit table size" option const limitedSize: React.CSSProperties = settings.limitTableSize ? { maxHeight: 600, overflowY: "auto" } @@ -25,7 +33,11 @@ export default function TimesheetTable() { - +
diff --git a/src/contexts/use-dialog.ts b/src/contexts/use-dialog.ts new file mode 100644 index 0000000..e5cbd63 --- /dev/null +++ b/src/contexts/use-dialog.ts @@ -0,0 +1,27 @@ +import { useCallback } from "react"; +import { ConfirmModal } from "@/utils/confirm-modal"; + +import { useApp } from "./use-app-context"; + +type UseDialog = { + // Show a confirmation dialog, provides a promise that resolves + // to the users choice + showConfirm: (title: string, message: string) => Promise; +}; + +export function useDialog(): UseDialog { + const app = useApp(); + + const showConfirm = useCallback( + (title: string, message: string): Promise => { + return new Promise((resolve) => { + const modal = new ConfirmModal(app, message, resolve); + modal.setTitle(title); + modal.open(); + }); + }, + [app] + ); + + return { showConfirm }; +} diff --git a/src/contexts/use-timekeep-context.ts b/src/contexts/use-timekeep-context.ts deleted file mode 100644 index 06b343d..0000000 --- a/src/contexts/use-timekeep-context.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Timekeep } from "@/schema"; -import React, { Dispatch, useContext, SetStateAction } from "react"; - -type TimekeepContext = { - timekeep: Timekeep; - setTimekeep: Dispatch>; - isTimekeepRunning: boolean; - - // Show a confirmation dialog, provides a promise that resolves - // to the users choice - showConfirm: (title: string, message: string) => Promise; -}; - -export const TimekeepContext = React.createContext(null!); - -export function useTimekeep(): TimekeepContext { - return useContext(TimekeepContext); -} diff --git a/src/main.ts b/src/main.ts index 3b32211..ebd6b23 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,6 +25,7 @@ import { } from "@/timekeep"; import { Timekeep, TimeEntry } from "./schema"; +import { createTimekeepStore } from "./store/timekeep-store"; import { SettingsStore, createSettingsStore } from "./store/settings-store"; export default class TimekeepPlugin extends Plugin { @@ -160,15 +161,20 @@ class TimekeepComponent extends MarkdownRenderChild { if (this.loadResult.success) { const timekeep = this.loadResult.timekeep; + // Create a store for the timekeep state + const timekeepStore = createTimekeepStore( + timekeep, + this.trySave.bind(this) + ); + this.root.render( React.createElement( StrictMode, {}, React.createElement(App, { app: this.app, - initialState: timekeep, + timekeepStore, settingsStore: this.settingsStore, - save: this.trySave.bind(this), }) ) ); diff --git a/src/store/timekeep-store.ts b/src/store/timekeep-store.ts new file mode 100644 index 0000000..6ba8486 --- /dev/null +++ b/src/store/timekeep-store.ts @@ -0,0 +1,156 @@ +import { Timekeep } from "@/schema"; +import { + useState, + useEffect, + useContext, + createContext, + SetStateAction, +} from "react"; + +export const TimekeepStoreContext = createContext(null!); + +export function useTimekeepStore(): TimekeepStore { + return useContext(TimekeepStoreContext); +} + +/** + * Simple reactive store for storing the current settings + * and allowing the app to subscribe to changes + */ +export type TimekeepStore = { + // Getter for the current value + getTimekeep: () => Timekeep; + + // Sets the current settings value and updates all subscribers + setTimekeep: (value: SetStateAction) => void; + + // Saves the provided timekeep state to the file + saveTimekeep: (value: Timekeep) => void; + + // Save error state + getSaveError: () => boolean; + + // Allows subscribing to changes using a callback + subscribe: (callback: VoidFunction) => void; + // Allows unsubscribing from changes for the provided callback + unsubscribe: (callback: VoidFunction) => void; +}; + +type SaveFunction = (timekeep: Timekeep) => Promise; + +type TimekeepState = { + timekeep: Timekeep; + saveError: boolean; +}; + +/** + * Creates a new timekeep store with the provided initial value + * + * @param initialValue The initial value + * @returns The created store + */ +export function createTimekeepStore( + initialValue: Timekeep, // Function to save the timekeep data + save: SaveFunction +): TimekeepStore { + const eventTarget = new EventTarget(); + const object: TimekeepState = { timekeep: initialValue, saveError: false }; + + const getTimekeep = () => object.timekeep; + const setTimekeep = (value: SetStateAction) => { + const newValue = + value instanceof Function ? value(object.timekeep) : value; + + object.timekeep = newValue; + eventTarget.dispatchEvent(new Event("stateChange")); + + // Save the updated timekeep + saveTimekeep(newValue); + }; + + const getSaveError = () => object.saveError; + + const saveTimekeep = (value: Timekeep) => { + // Try save save the timekeep + save(value).then((isSaved) => { + const saveError = !isSaved; + + if (object.saveError !== saveError) { + object.saveError = saveError; + // Dispatch the state change + eventTarget.dispatchEvent(new Event("stateChange")); + } + }); + }; + + const subscribe = (callback: VoidFunction) => + eventTarget.addEventListener("stateChange", callback); + + const unsubscribe = (callback: VoidFunction) => + eventTarget.removeEventListener("stateChange", callback); + + return { + getTimekeep, + setTimekeep, + saveTimekeep, + getSaveError, + subscribe, + unsubscribe, + }; +} + +/** + * React hook to subscribe to the timekeep store. Will + * trigger state updates and re-render when the timekeep + * changes + * + * @param store The store to subscribe to + * @returns The current timekeep value in the store + */ +export function useTimekeep(store: TimekeepStore) { + const [state, setState] = useState(store.getTimekeep()); + + // Effect to handle state updates + useEffect(() => { + const handleUpdate = () => { + const value = store.getTimekeep(); + setState(value); + }; + + store.subscribe(handleUpdate); + + return () => { + store.unsubscribe(handleUpdate); + }; + }, [setState, store]); + + return state; +} + +/** + * React hook to subscribe to the timekeep store save + * error state. Will trigger state updates and re-render + * when the save error state changes + * + * @param store The store to subscribe to + * @returns The current save error value in the store + */ +export function useSaveError(store: TimekeepStore) { + const [saveError, setSaveError] = useState(store.getSaveError()); + + // Effect to track the save error + useEffect(() => { + const handleUpdate = () => { + const value = store.getSaveError(); + setSaveError(value); + }; + + store.subscribe(handleUpdate); + + return () => { + store.unsubscribe(handleUpdate); + }; + }, [setSaveError, store]); + + return saveError; +} diff --git a/src/timekeep.test.ts b/src/timekeep.test.ts index ccc5772..7fcc525 100644 --- a/src/timekeep.test.ts +++ b/src/timekeep.test.ts @@ -1526,7 +1526,7 @@ describe("extracting code blocks", () => { }); describe("collapse state", () => { - it("should update group collapse state", () => { + it("should update group collapse state when set to true", () => { const currentTime = moment(); const input: TimeEntryGroup = { @@ -1550,6 +1550,29 @@ describe("collapse state", () => { expect((collapsed as TimeEntryGroup).collapsed).toBe(true); }); + it("collapsed state should be undefined when false", () => { + const currentTime = moment(); + + const input: TimeEntryGroup = { + name: "Test", + startTime: null, + endTime: null, + subEntries: [ + { + name: "Test", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + ], + }; + + const collapsed = setEntryCollapsed(input, false); + + expect(collapsed.subEntries).not.toBeNull(); + expect((collapsed as TimeEntryGroup).collapsed).toBeUndefined(); + }); + it("should not set collapse state on single entry", () => { const currentTime = moment(); diff --git a/src/timekeep.ts b/src/timekeep.ts index 25b2569..a6ce028 100644 --- a/src/timekeep.ts +++ b/src/timekeep.ts @@ -188,7 +188,14 @@ export function setEntryCollapsed( // Entry cannot be collapsed if (entry.subEntries === null) return entry; - return { ...entry, collapsed }; + const newEntry: TimeEntry = { ...entry, collapsed }; + + // Delete the collapsed field if not collapsed + if (!collapsed) { + delete newEntry.collapsed; + } + + return newEntry; } /** diff --git a/test-vault/Obsidian Timekeep.md b/test-vault/Obsidian Timekeep.md index 65014d7..a3e41d1 100644 --- a/test-vault/Obsidian Timekeep.md +++ b/test-vault/Obsidian Timekeep.md @@ -28,7 +28,7 @@ dv.span(totalRunningDuration); ```timekeep -{"entries":[{"name":"Block 4 1","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2024-03-17T01:33:51.000Z","endTime":"2024-03-17T01:33:55.000Z","subEntries":null},{"name":"Part 2","startTime":"2024-03-24T05:20:54.000Z","endTime":"2024-03-24T05:20:55.000Z","subEntries":null}]},{"name":"Part 2","startTime":"2024-03-24T05:20:51.498Z","endTime":"2024-03-24T05:20:53.370Z","subEntries":null},{"name":"Part 3","startTime":"2024-03-24T05:20:56.000Z","endTime":"2024-03-24T05:21:30.000Z","subEntries":null}]},{"name":"Block 5","startTime":"2024-03-17T01:33:57.375Z","endTime":"2024-03-17T01:33:58.867Z","subEntries":null},{"name":"Block 8","startTime":"2024-03-17T01:37:33.417Z","endTime":"2024-03-17T01:37:35.048Z","subEntries":null},{"name":"Block 9","startTime":"2024-03-17T01:37:44.470Z","endTime":"2024-03-17T01:37:45.141Z","subEntries":null},{"name":"Block 6","startTime":"2024-03-17T02:00:38.491Z","endTime":"2024-03-17T02:00:39.208Z","subEntries":null},{"name":"Block 7","startTime":"2024-03-17T02:00:39.528Z","endTime":"2024-03-17T02:00:39.823Z","subEntries":null},{"name":"Block 8","startTime":"2024-03-17T02:00:40.118Z","endTime":"2024-03-17T02:00:40.428Z","subEntries":null},{"name":"Block 9","startTime":"2024-03-17T02:00:40.814Z","endTime":"2024-03-17T02:00:41.066Z","subEntries":null},{"name":"Block 10","startTime":"2024-03-17T02:00:41.327Z","endTime":"2024-03-17T02:00:41.507Z","subEntries":null},{"name":"Block 11","startTime":"2024-03-17T02:00:41.783Z","endTime":"2024-03-17T02:00:42.009Z","subEntries":null},{"name":"Block 13","startTime":"2024-03-17T02:57:16.000Z","endTime":"2024-03-17T02:57:16.000Z","subEntries":null},{"name":"Block 14","startTime":"2024-03-17T02:57:19.000Z","endTime":"2024-03-17T02:57:19.000Z","subEntries":null},{"name":"Block 15","startTime":"2024-03-17T02:57:24.000Z","endTime":"2024-03-17T02:57:24.000Z","subEntries":null},{"name":"Block 16 Supper reallly long name that leaves the table bounds","startTime":"2024-03-17T02:57:25.000Z","endTime":"2024-03-17T02:57:25.000Z","subEntries":null},{"name":"Block 17","startTime":"2024-03-17T03:29:53.766Z","endTime":"2024-03-17T03:29:54.925Z","subEntries":null},{"name":"Block 18","startTime":"2024-03-17T03:30:26.368Z","endTime":"2024-03-17T03:30:34.257Z","subEntries":null},{"name":"New Block","startTime":"2024-03-17T03:32:42.451Z","endTime":"2024-03-17T03:33:41.318Z","subEntries":null},{"name":"Block 20","startTime":"2024-03-17T03:37:45.552Z","endTime":"2024-03-17T03:37:47.568Z","subEntries":null},{"name":"Block 21","startTime":"2024-03-17T03:38:14.405Z","endTime":"2024-03-17T03:38:18.039Z","subEntries":null},{"name":"Block 21","startTime":"2024-03-17T05:01:30.553Z","endTime":"2024-03-17T05:01:36.012Z","subEntries":null},{"name":"Block 22","startTime":"2024-03-17T05:01:37.365Z","endTime":"2024-03-17T05:01:40.180Z","subEntries":null},{"name":"Block 23","startTime":"2024-03-24T03:44:16.500Z","endTime":"2024-03-24T03:44:18.168Z","subEntries":null},{"name":"Block 24","startTime":"2024-03-24T03:44:34.125Z","endTime":"2024-03-24T03:44:35.421Z","subEntries":null},{"name":"Block 25","startTime":"2024-03-24T03:44:38.073Z","endTime":"2024-03-24T03:44:50.001Z","subEntries":null},{"name":"Block 26","startTime":"2024-03-24T04:05:50.912Z","endTime":"2024-03-24T04:05:53.243Z","subEntries":null},{"name":"This is my block","startTime":"2024-03-24T04:09:02.146Z","endTime":"2024-03-24T04:09:06.662Z","subEntries":null},{"name":"Block 28","startTime":"2024-03-24T04:15:47.980Z","endTime":"2024-03-24T04:15:48.924Z","subEntries":null},{"name":"Block 29","startTime":"2024-03-24T04:15:49.518Z","endTime":"2024-03-24T04:15:50.561Z","subEntries":null},{"name":"Block 30","startTime":"2024-03-24T04:15:51.654Z","endTime":"2024-03-24T04:15:52.560Z","subEntries":null},{"name":"Block 31","startTime":"2024-03-24T04:17:34.822Z","endTime":"2024-03-24T04:17:35.571Z","subEntries":null},{"name":"Block 32","startTime":"2024-03-24T04:17:36.303Z","endTime":"2024-03-24T04:17:36.936Z","subEntries":null},{"name":"Block 33","startTime":"2024-03-24T04:17:43.413Z","endTime":"2024-03-24T04:17:44.142Z","subEntries":null},{"name":"Block 34","startTime":"2024-03-24T04:17:44.916Z","endTime":"2024-03-24T04:17:45.552Z","subEntries":null},{"name":"Block 35","startTime":"2024-03-24T04:17:52.315Z","endTime":"2024-03-24T04:17:52.971Z","subEntries":null},{"name":"Block 36","startTime":"2024-03-24T04:17:53.590Z","endTime":"2024-03-24T04:17:54.318Z","subEntries":null},{"name":"Block 37","startTime":"2024-03-24T04:17:54.858Z","endTime":"2024-03-24T04:17:56.340Z","subEntries":null},{"name":"Block 38","startTime":"2024-03-24T04:21:19.540Z","endTime":"2024-03-24T04:21:20.803Z","subEntries":null},{"name":"Block 39","startTime":"2024-03-24T04:21:21.876Z","endTime":"2024-03-24T04:21:24.420Z","subEntries":null},{"name":"Block 40","startTime":"2024-03-24T04:22:14.073Z","endTime":"2024-03-24T04:22:14.793Z","subEntries":null},{"name":"Block 41","startTime":"2024-03-24T04:22:15.463Z","endTime":"2024-03-24T04:22:16.016Z","subEntries":null},{"name":"Block 42","startTime":"2024-03-24T04:28:14.807Z","endTime":"2024-03-24T04:28:15.565Z","subEntries":null},{"name":"Block 43","startTime":"2024-03-24T04:28:39.885Z","endTime":"2024-03-24T04:28:42.912Z","subEntries":null},{"name":"Block 44","startTime":"2024-03-24T04:28:44.495Z","endTime":"2024-03-24T04:28:45.736Z","subEntries":null},{"name":"Block 44","startTime":"2024-03-24T04:30:43.184Z","endTime":"2024-03-24T04:30:45.307Z","subEntries":null},{"name":"Block 45","startTime":"2024-03-24T04:30:46.565Z","endTime":"2024-03-24T04:30:47.749Z","subEntries":null},{"name":"Block 46","startTime":"2024-03-24T04:30:48.502Z","endTime":"2024-03-24T04:30:49.762Z","subEntries":null},{"name":"Block 47","startTime":"2024-03-24T04:31:01.261Z","endTime":"2024-03-24T04:31:12.831Z","subEntries":null},{"name":"Block 48","startTime":"2024-03-24T04:31:14.603Z","endTime":"2024-03-24T04:31:16.328Z","subEntries":null},{"name":"Block 49","startTime":"2024-03-24T04:31:17.249Z","endTime":"2024-03-24T04:31:18.115Z","subEntries":null},{"name":"Block 50","startTime":"2024-03-24T04:31:19.266Z","endTime":"2024-03-24T04:31:20.199Z","subEntries":null},{"name":"Block 51","startTime":"2024-03-24T04:33:17.046Z","endTime":"2024-03-24T04:33:17.646Z","subEntries":null},{"name":"Block 52","startTime":"2024-03-24T04:33:18.253Z","endTime":"2024-03-24T04:33:18.969Z","subEntries":null},{"name":"Block 53","startTime":"2024-03-24T04:33:19.684Z","endTime":"2024-03-24T04:33:21.706Z","subEntries":null},{"name":"Block 54","startTime":"2024-03-24T04:33:22.293Z","endTime":"2024-03-24T04:33:22.674Z","subEntries":null},{"name":"Block 55","startTime":"2024-03-24T06:35:53.785Z","endTime":"2024-03-24T06:35:54.322Z","subEntries":null},{"name":"Block 56","startTime":"2024-03-24T06:35:54.693Z","endTime":"2024-03-24T06:35:54.864Z","subEntries":null},{"name":"Block 57","startTime":"2024-03-24T06:35:55.031Z","endTime":"2024-03-24T06:35:55.190Z","subEntries":null},{"name":"Block 58","startTime":"2024-03-24T06:35:55.631Z","endTime":"2024-03-24T06:35:57.836Z","subEntries":null},{"name":"Block 59","startTime":"2024-03-24T06:36:06.400Z","endTime":"2024-03-24T06:36:08.452Z","subEntries":null},{"name":"Block 60","startTime":"2024-03-24T06:36:53.108Z","endTime":"2024-03-24T06:36:53.791Z","subEntries":null},{"name":"Block 61","startTime":"2024-03-24T06:42:04.593Z","endTime":"2024-03-24T06:42:05.961Z","subEntries":null},{"name":"Block 62","startTime":"2024-03-24T06:42:30.789Z","endTime":"2024-03-24T06:42:31.609Z","subEntries":null},{"name":"Block 63","startTime":"2024-05-01T08:17:37.721Z","endTime":"2024-05-01T08:20:49.070Z","subEntries":null},{"name":"Test dwada uidawh dawhiud hawhiduia uhiwdiuhhauiw dhawd ihuawuidh awudhiu ahiwudhiu ahwiudihu awhiudhiu hiud ihuawhid hiuahiud ihwad","startTime":"2024-05-01T08:20:56.478Z","endTime":"2024-05-01T08:21:01.643Z","subEntries":null},{"name":"Test","startTime":"2024-05-01T08:21:03.005Z","endTime":"2024-05-01T08:21:35.567Z","subEntries":null},{"name":"tEWADAWDAWDAWD","startTime":"2024-05-01T08:21:37.838Z","endTime":"2024-05-01T08:22:25.344Z","subEntries":null},{"name":"Tewadwdawd","startTime":"2024-05-01T08:22:27.180Z","endTime":"2024-05-01T08:22:28.683Z","subEntries":null},{"name":"awdawd","startTime":"2024-05-01T08:22:30.186Z","endTime":"2024-05-01T08:22:36.241Z","subEntries":null},{"name":"Block 69","startTime":"2024-05-01T08:22:36.760Z","endTime":"2024-05-01T08:22:37.361Z","subEntries":null},{"name":"Block 70","startTime":"2024-05-01T08:22:37.695Z","endTime":"2024-05-01T08:22:38.428Z","subEntries":null},{"name":"Block 71","startTime":"2024-05-01T08:22:39.077Z","endTime":"2024-05-01T08:22:40.410Z","subEntries":null},{"name":"Block 72","startTime":"2024-05-01T08:22:40.991Z","endTime":"2024-05-01T08:22:41.538Z","subEntries":null},{"name":"Block 73","startTime":"2024-05-01T08:22:41.943Z","endTime":"2024-05-01T08:22:42.729Z","subEntries":null},{"name":"Block 74","startTime":"2024-05-01T08:22:43.160Z","endTime":"2024-05-01T08:22:44.806Z","subEntries":null},{"name":"Block 75","startTime":"2024-05-01T08:22:45.423Z","endTime":"2024-05-01T08:22:46.040Z","subEntries":null},{"name":"Block 76","startTime":"2024-05-01T08:22:49.574Z","endTime":"2024-05-01T08:22:50.712Z","subEntries":null},{"name":"Block 77","startTime":"2024-05-01T08:22:51.298Z","endTime":"2024-05-01T08:23:06.033Z","subEntries":null},{"name":"Block 78","startTime":"2024-05-01T08:23:07.620Z","endTime":"2024-05-01T08:23:08.124Z","subEntries":null},{"name":"Block 79","startTime":"2024-05-01T08:23:09.529Z","endTime":"2024-05-01T08:23:09.984Z","subEntries":null},{"name":"Block 80","startTime":"2024-05-01T08:23:44.681Z","endTime":"2024-05-01T08:23:56.772Z","subEntries":null}]} +{"entries":[{"name":"Block 4 1","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2024-03-17T01:33:51.000Z","endTime":"2024-03-17T01:33:55.000Z","subEntries":null},{"name":"Part 2","startTime":"2024-03-24T05:20:54.000Z","endTime":"2024-03-24T05:20:55.000Z","subEntries":null}]},{"name":"Part 2","startTime":"2024-03-24T05:20:51.498Z","endTime":"2024-03-24T05:20:53.370Z","subEntries":null},{"name":"Part 3","startTime":"2024-03-24T05:20:56.000Z","endTime":"2024-03-24T05:21:30.000Z","subEntries":null}]},{"name":"Block 5","startTime":"2024-03-17T01:33:57.375Z","endTime":"2024-03-17T01:33:58.867Z","subEntries":null},{"name":"Block 8","startTime":"2024-03-17T01:37:33.417Z","endTime":"2024-03-17T01:37:35.048Z","subEntries":null},{"name":"Block 9","startTime":"2024-03-17T01:37:44.470Z","endTime":"2024-03-17T01:37:45.141Z","subEntries":null},{"name":"Block 6","startTime":"2024-03-17T02:00:38.491Z","endTime":"2024-03-17T02:00:39.208Z","subEntries":null},{"name":"Block 7","startTime":"2024-03-17T02:00:39.528Z","endTime":"2024-03-17T02:00:39.823Z","subEntries":null},{"name":"Block 8","startTime":"2024-03-17T02:00:40.118Z","endTime":"2024-03-17T02:00:40.428Z","subEntries":null},{"name":"Block 9","startTime":"2024-03-17T02:00:40.814Z","endTime":"2024-03-17T02:00:41.066Z","subEntries":null},{"name":"Block 10","startTime":"2024-03-17T02:00:41.327Z","endTime":"2024-03-17T02:00:41.507Z","subEntries":null},{"name":"Block 11","startTime":"2024-03-17T02:00:41.783Z","endTime":"2024-03-17T02:00:42.009Z","subEntries":null},{"name":"Block 13","startTime":"2024-03-17T02:57:16.000Z","endTime":"2024-03-17T02:57:16.000Z","subEntries":null},{"name":"Block 14","startTime":"2024-03-17T02:57:19.000Z","endTime":"2024-03-17T02:57:19.000Z","subEntries":null},{"name":"Block 15","startTime":"2024-03-17T02:57:24.000Z","endTime":"2024-03-17T02:57:24.000Z","subEntries":null},{"name":"Block 16 Supper reallly long name that leaves the table bounds","startTime":"2024-03-17T02:57:25.000Z","endTime":"2024-03-17T02:57:25.000Z","subEntries":null},{"name":"Block 17","startTime":"2024-03-17T03:29:53.766Z","endTime":"2024-03-17T03:29:54.925Z","subEntries":null},{"name":"Block 18","startTime":"2024-03-17T03:30:26.368Z","endTime":"2024-03-17T03:30:34.257Z","subEntries":null},{"name":"New Block","startTime":"2024-03-17T03:32:42.451Z","endTime":"2024-03-17T03:33:41.318Z","subEntries":null},{"name":"Block 20","startTime":"2024-03-17T03:37:45.552Z","endTime":"2024-03-17T03:37:47.568Z","subEntries":null},{"name":"Block 21","startTime":"2024-03-17T03:38:14.405Z","endTime":"2024-03-17T03:38:18.039Z","subEntries":null},{"name":"Block 21","startTime":"2024-03-17T05:01:30.553Z","endTime":"2024-03-17T05:01:36.012Z","subEntries":null},{"name":"Block 22","startTime":"2024-03-17T05:01:37.365Z","endTime":"2024-03-17T05:01:40.180Z","subEntries":null},{"name":"Block 23","startTime":"2024-03-24T03:44:16.500Z","endTime":"2024-03-24T03:44:18.168Z","subEntries":null},{"name":"Block 24","startTime":"2024-03-24T03:44:34.125Z","endTime":"2024-03-24T03:44:35.421Z","subEntries":null},{"name":"Block 25","startTime":"2024-03-24T03:44:38.073Z","endTime":"2024-03-24T03:44:50.001Z","subEntries":null},{"name":"Block 26","startTime":"2024-03-24T04:05:50.912Z","endTime":"2024-03-24T04:05:53.243Z","subEntries":null},{"name":"This is my block","startTime":"2024-03-24T04:09:02.146Z","endTime":"2024-03-24T04:09:06.662Z","subEntries":null},{"name":"Block 28","startTime":"2024-03-24T04:15:47.980Z","endTime":"2024-03-24T04:15:48.924Z","subEntries":null},{"name":"Block 29","startTime":"2024-03-24T04:15:49.518Z","endTime":"2024-03-24T04:15:50.561Z","subEntries":null},{"name":"Block 30","startTime":"2024-03-24T04:15:51.654Z","endTime":"2024-03-24T04:15:52.560Z","subEntries":null},{"name":"Block 31","startTime":"2024-03-24T04:17:34.822Z","endTime":"2024-03-24T04:17:35.571Z","subEntries":null},{"name":"Block 32","startTime":"2024-03-24T04:17:36.303Z","endTime":"2024-03-24T04:17:36.936Z","subEntries":null},{"name":"Block 33","startTime":"2024-03-24T04:17:43.413Z","endTime":"2024-03-24T04:17:44.142Z","subEntries":null},{"name":"Block 34","startTime":"2024-03-24T04:17:44.916Z","endTime":"2024-03-24T04:17:45.552Z","subEntries":null},{"name":"Block 35","startTime":"2024-03-24T04:17:52.315Z","endTime":"2024-03-24T04:17:52.971Z","subEntries":null},{"name":"Block 36","startTime":"2024-03-24T04:17:53.590Z","endTime":"2024-03-24T04:17:54.318Z","subEntries":null},{"name":"Block 37","startTime":"2024-03-24T04:17:54.858Z","endTime":"2024-03-24T04:17:56.340Z","subEntries":null},{"name":"Block 38","startTime":"2024-03-24T04:21:19.540Z","endTime":"2024-03-24T04:21:20.803Z","subEntries":null},{"name":"Block 39","startTime":"2024-03-24T04:21:21.876Z","endTime":"2024-03-24T04:21:24.420Z","subEntries":null},{"name":"Block 40","startTime":"2024-03-24T04:22:14.073Z","endTime":"2024-03-24T04:22:14.793Z","subEntries":null},{"name":"Block 41","startTime":"2024-03-24T04:22:15.463Z","endTime":"2024-03-24T04:22:16.016Z","subEntries":null},{"name":"Block 42","startTime":"2024-03-24T04:28:14.807Z","endTime":"2024-03-24T04:28:15.565Z","subEntries":null},{"name":"Block 43","startTime":"2024-03-24T04:28:39.885Z","endTime":"2024-03-24T04:28:42.912Z","subEntries":null},{"name":"Block 44","startTime":"2024-03-24T04:28:44.495Z","endTime":"2024-03-24T04:28:45.736Z","subEntries":null},{"name":"Block 44","startTime":"2024-03-24T04:30:43.184Z","endTime":"2024-03-24T04:30:45.307Z","subEntries":null},{"name":"Block 45","startTime":"2024-03-24T04:30:46.565Z","endTime":"2024-03-24T04:30:47.749Z","subEntries":null},{"name":"Block 46","startTime":"2024-03-24T04:30:48.502Z","endTime":"2024-03-24T04:30:49.762Z","subEntries":null},{"name":"Block 47","startTime":"2024-03-24T04:31:01.261Z","endTime":"2024-03-24T04:31:12.831Z","subEntries":null},{"name":"Block 48","startTime":"2024-03-24T04:31:14.603Z","endTime":"2024-03-24T04:31:16.328Z","subEntries":null},{"name":"Block 49","startTime":"2024-03-24T04:31:17.249Z","endTime":"2024-03-24T04:31:18.115Z","subEntries":null},{"name":"Block 50","startTime":"2024-03-24T04:31:19.266Z","endTime":"2024-03-24T04:31:20.199Z","subEntries":null},{"name":"Block 51","startTime":"2024-03-24T04:33:17.046Z","endTime":"2024-03-24T04:33:17.646Z","subEntries":null},{"name":"Block 52","startTime":"2024-03-24T04:33:18.253Z","endTime":"2024-03-24T04:33:18.969Z","subEntries":null},{"name":"Block 53","startTime":"2024-03-24T04:33:19.684Z","endTime":"2024-03-24T04:33:21.706Z","subEntries":null},{"name":"Block 54","startTime":"2024-03-24T04:33:22.293Z","endTime":"2024-03-24T04:33:22.674Z","subEntries":null},{"name":"Block 55","startTime":"2024-03-24T06:35:53.785Z","endTime":"2024-03-24T06:35:54.322Z","subEntries":null},{"name":"Block 56","startTime":"2024-03-24T06:35:54.693Z","endTime":"2024-03-24T06:35:54.864Z","subEntries":null},{"name":"Block 57","startTime":"2024-03-24T06:35:55.031Z","endTime":"2024-03-24T06:35:55.190Z","subEntries":null},{"name":"Block 58","startTime":"2024-03-24T06:35:55.631Z","endTime":"2024-03-24T06:35:57.836Z","subEntries":null},{"name":"Block 59","startTime":"2024-03-24T06:36:06.400Z","endTime":"2024-03-24T06:36:08.452Z","subEntries":null},{"name":"Block 60","startTime":"2024-03-24T06:36:53.108Z","endTime":"2024-03-24T06:36:53.791Z","subEntries":null},{"name":"Block 61","startTime":"2024-03-24T06:42:04.593Z","endTime":"2024-03-24T06:42:05.961Z","subEntries":null},{"name":"Block 62","startTime":"2024-03-24T06:42:30.789Z","endTime":"2024-03-24T06:42:31.609Z","subEntries":null},{"name":"Block 63","startTime":"2024-05-01T08:17:37.721Z","endTime":"2024-05-01T08:20:49.070Z","subEntries":null},{"name":"Test dwada uidawh dawhiud hawhiduia uhiwdiuhhauiw dhawd ihuawuidh awudhiu ahiwudhiu ahwiudihu awhiudhiu hiud ihuawhid hiuahiud ihwad","startTime":"2024-05-01T08:20:56.478Z","endTime":"2024-05-01T08:21:01.643Z","subEntries":null},{"name":"Test","startTime":"2024-05-01T08:21:03.005Z","endTime":"2024-05-01T08:21:35.567Z","subEntries":null},{"name":"tEWADAWDAWDAWD","startTime":"2024-05-01T08:21:37.838Z","endTime":"2024-05-01T08:22:25.344Z","subEntries":null},{"name":"Tewadwdawd","startTime":"2024-05-01T08:22:27.180Z","endTime":"2024-05-01T08:22:28.683Z","subEntries":null},{"name":"awdawd","startTime":"2024-05-01T08:22:30.186Z","endTime":"2024-05-01T08:22:36.241Z","subEntries":null},{"name":"Block 69","startTime":"2024-05-01T08:22:36.760Z","endTime":"2024-05-01T08:22:37.361Z","subEntries":null},{"name":"Block 70","startTime":"2024-05-01T08:22:37.695Z","endTime":"2024-05-01T08:22:38.428Z","subEntries":null},{"name":"Block 71","startTime":"2024-05-01T08:22:39.077Z","endTime":"2024-05-01T08:22:40.410Z","subEntries":null},{"name":"Block 72","startTime":"2024-05-01T08:22:40.991Z","endTime":"2024-05-01T08:22:41.538Z","subEntries":null},{"name":"Block 73","startTime":"2024-05-01T08:22:41.943Z","endTime":"2024-05-01T08:22:42.729Z","subEntries":null},{"name":"Block 74","startTime":"2024-05-01T08:22:43.160Z","endTime":"2024-05-01T08:22:44.806Z","subEntries":null},{"name":"Block 75","startTime":"2024-05-01T08:22:45.423Z","endTime":"2024-05-01T08:22:46.040Z","subEntries":null},{"name":"Block 76","startTime":"2024-05-01T08:22:49.574Z","endTime":"2024-05-01T08:22:50.712Z","subEntries":null},{"name":"Block 77","startTime":"2024-05-01T08:22:51.298Z","endTime":"2024-05-01T08:23:06.033Z","subEntries":null},{"name":"Block 78","startTime":"2024-05-01T08:23:07.620Z","endTime":"2024-05-01T08:23:08.124Z","subEntries":null},{"name":"Block 79","startTime":"2024-05-01T08:23:09.529Z","endTime":"2024-05-01T08:23:09.984Z","subEntries":null},{"name":"Block 80","startTime":"2024-05-01T08:23:44.681Z","endTime":"2024-05-01T08:23:56.772Z","subEntries":null},{"name":"Block 81","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2024-07-21T07:12:56.433Z","endTime":"2024-07-21T07:13:33.907Z","subEntries":null},{"name":"Part 2","startTime":"2024-07-21T07:24:44.769Z","endTime":"2024-07-21T07:24:48.149Z","subEntries":null}]},{"name":"Block 82","startTime":null,"endTime":null,"collapsed":true,"subEntries":[{"name":"Part 1","startTime":"2024-07-21T07:13:34.743Z","endTime":"2024-07-21T07:14:13.095Z","subEntries":null},{"name":"Part 2","startTime":"2024-07-21T07:14:13.562Z","endTime":"2024-07-21T07:14:14.679Z","subEntries":null},{"name":"Part 3","startTime":"2024-07-21T07:14:15.566Z","endTime":"2024-07-21T07:14:15.970Z","subEntries":null}]}]} ``` ```timekeep {"entries":[{"name":"Block 1","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2024-03-31T07:27:36.569Z","endTime":"2024-03-31T07:27:37.726Z","subEntries":null},{"name":"Part 2","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2024-03-31T07:27:42.846Z","endTime":"2024-03-31T07:27:44.468Z","subEntries":null},{"name":"Part 2","startTime":"2024-03-31T07:27:47.517Z","endTime":"2024-03-31T07:27:48.459Z","subEntries":null}]}]},{"name":"Block 2","startTime":"2024-03-31T07:27:38.418Z","endTime":"2024-03-31T07:27:38.917Z","subEntries":null},{"name":"Block 3","startTime":"2024-03-31T07:27:39.480Z","endTime":"2024-03-31T07:27:39.796Z","subEntries":null},{"name":"Block 4","startTime":"2024-03-31T07:27:40.088Z","endTime":"2024-03-31T07:27:40.331Z","subEntries":null},{"name":"Block 5","startTime":"2024-03-31T07:27:40.548Z","endTime":"2024-03-31T07:27:40.795Z","subEntries":null},{"name":"Block 6","startTime":"2024-03-31T07:27:41.037Z","endTime":"2024-03-31T07:27:41.520Z","subEntries":null}]}