From 490e9102b8384bc743fd003caebd4dade11d4803 Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Sat, 11 Apr 2026 23:47:15 +1200 Subject: [PATCH] refactor: generalize saving and view logic for more consistent and easy to manage logic, extract editor scroll tracking, fix content component and replaceable component logic --- .../ContentComponent/ContentComponent.test.ts | 27 +-- .../ContentComponent/ContentComponent.ts | 34 ++- src/components/DomComponent/DomComponent.ts | 19 +- .../EmptyComponent/EmptyComponent.test.ts | 22 ++ .../EmptyComponent/EmptyComponent.ts | 13 + src/components/EmptyComponent/index.ts | 1 + .../ReplaceableComponent.ts | 1 - src/components/Timesheet/Timesheet.test.ts | 26 +- src/components/Timesheet/Timesheet.ts | 92 ++++---- .../TimesheetApp/TimesheetApp.test.ts | 54 ----- src/components/TimesheetApp/TimesheetApp.ts | 86 ------- src/components/TimesheetApp/index.ts | 1 - .../TimesheetLoadError/TimesheetLoadError.ts | 14 +- .../TimesheetRunningEntry.test.ts | 7 +- .../TimesheetRunningEntry.ts | 7 +- .../TimesheetRunningEntryEditing.ts | 19 +- .../TimesheetRunningEntryViewing.ts | 19 +- .../TimesheetSaveError.test.ts | 7 +- .../TimesheetSaveError/TimesheetSaveError.ts | 29 +-- .../TimesheetTable/TimesheetTable.ts | 1 + src/save/TimesheetFileSaveAdapter.ts | 36 +++ src/save/TimesheetMarkdownSaveAdapter.ts | 72 ++++++ src/save/TimesheetSaveAdapter.ts | 9 + src/utils/editorScrollTracker.test.ts | 134 +++++++++++ src/utils/editorScrollTracker.ts | 48 ++++ src/views/TimekeepFileView.ts | 160 ++----------- src/views/TimekeepMarkdownView.ts | 222 ++++-------------- src/views/TimekeepView.ts | 166 +++++++++++++ test-vault/Obsidian Timekeep.md | 2 +- 29 files changed, 708 insertions(+), 620 deletions(-) create mode 100644 src/components/EmptyComponent/EmptyComponent.test.ts create mode 100644 src/components/EmptyComponent/EmptyComponent.ts create mode 100644 src/components/EmptyComponent/index.ts delete mode 100644 src/components/TimesheetApp/TimesheetApp.test.ts delete mode 100644 src/components/TimesheetApp/TimesheetApp.ts delete mode 100644 src/components/TimesheetApp/index.ts create mode 100644 src/save/TimesheetFileSaveAdapter.ts create mode 100644 src/save/TimesheetMarkdownSaveAdapter.ts create mode 100644 src/save/TimesheetSaveAdapter.ts create mode 100644 src/utils/editorScrollTracker.test.ts create mode 100644 src/utils/editorScrollTracker.ts create mode 100644 src/views/TimekeepView.ts diff --git a/src/components/ContentComponent/ContentComponent.test.ts b/src/components/ContentComponent/ContentComponent.test.ts index 3f87fc1..ed620c9 100644 --- a/src/components/ContentComponent/ContentComponent.test.ts +++ b/src/components/ContentComponent/ContentComponent.test.ts @@ -6,7 +6,6 @@ import { createMockContainer } from "@/__mocks__/obsidian"; import { ContentComponent } from "./ContentComponent"; -import { DomComponent } from "@/components/DomComponent"; import { ReplaceableComponent } from "@/components/ReplaceableComponent"; describe("ContentComponent", () => { @@ -24,29 +23,31 @@ describe("ContentComponent", () => { }); it("should be able to set content to different components", () => { - class FirstComponent extends DomComponent { + class FirstComponent extends ReplaceableComponent { constructor(containerEl: HTMLElement) { super(containerEl); } - onload(): void { - super.onload(); + createContainer(): HTMLElement { + return createDiv(); + } - const wrapperEl = this.containerEl.createSpan({ text: "First" }); - this.wrapperEl = wrapperEl; + render(containerEl: HTMLElement): void { + containerEl.createSpan({ text: "First" }); } } - class SecondComponent extends DomComponent { + class SecondComponent extends ReplaceableComponent { constructor(containerEl: HTMLElement) { super(containerEl); } - onload(): void { - super.onload(); + createContainer(): HTMLElement { + return createDiv(); + } - const wrapperEl = this.containerEl.createSpan({ text: "Second" }); - this.wrapperEl = wrapperEl; + render(containerEl: HTMLElement): void { + containerEl.createSpan({ text: "Second" }); } } @@ -58,10 +59,6 @@ describe("ContentComponent", () => { comp.setContent(new SecondComponent(container)); expect(comp.containerEl!.children[0].textContent).toBe("Second"); - - comp.setContent(undefined); - expect(comp.getContent()).toBeUndefined(); - expect(comp.containerEl!.children.length).toBe(0); }); it("using two replaceable component should trigger the previous", () => { diff --git a/src/components/ContentComponent/ContentComponent.ts b/src/components/ContentComponent/ContentComponent.ts index 949c4fe..51245c4 100644 --- a/src/components/ContentComponent/ContentComponent.ts +++ b/src/components/ContentComponent/ContentComponent.ts @@ -1,17 +1,15 @@ -import type { Component } from "obsidian"; - import { DomComponent } from "@/components/DomComponent"; import { ReplaceableComponent } from "@/components/ReplaceableComponent"; -export class ContentComponent extends DomComponent { - #content: C | undefined = undefined; +export class ContentComponent extends DomComponent { + content: C | undefined = undefined; constructor(containerEl: HTMLElement) { super(containerEl); } getContent(): C | undefined { - return this.#content; + return this.content; } /** @@ -19,32 +17,28 @@ export class ContentComponent extends DomComponent { * * @param content The new content to show */ - setContent(content: C | undefined) { - let previousContent: C | undefined = this.#content; + setContent(content: C) { + let previousContent: C | undefined = this.content; let previousElement: HTMLElement | undefined; if (previousContent) { - // Mark for replacement if both are replaceable components - if ( - content instanceof ReplaceableComponent && - previousContent instanceof ReplaceableComponent - ) { - previousElement = previousContent.wrapperEl; - previousContent.skipUnloadReplace = true; - } + previousElement = previousContent.wrapperEl; + previousContent.skipUnloadReplace = true; this.removeChild(previousContent); } - this.#content = content; + this.content = content; // Set the previous element to be replaced by the new content - if (content instanceof ReplaceableComponent && previousElement) { + if (previousElement !== undefined) { content.previousElement = previousElement; } - if (content !== undefined) { - this.addChild(content); - } + this.addChild(content); + } + + isForcedUnload(): boolean { + return true; } } diff --git a/src/components/DomComponent/DomComponent.ts b/src/components/DomComponent/DomComponent.ts index 523a0f9..891cff9 100644 --- a/src/components/DomComponent/DomComponent.ts +++ b/src/components/DomComponent/DomComponent.ts @@ -43,7 +43,10 @@ export class DomComponent extends Component { } isUnloadSkipped() { - return this.parent !== null && this.parent.unmounted; + if (this.parent === null) return false; + if (this.parent.isForcedUnload()) return false; + + return this.parent.unmounted; } unload(): void { @@ -55,9 +58,6 @@ export class DomComponent extends Component { onunload(): void { super.onunload(); - // Mark ourselves as unmounted - this.unmounted = true; - // Skip unmounting from the DOM if the parent is already unmounted if (this.isUnloadSkipped()) { return; @@ -66,4 +66,15 @@ export class DomComponent extends Component { // Remove our wrapper if the parent is not unmounted already this.wrapperEl?.remove(); } + + /** + * Whether to force the unloading of children, used by ContentComponent + * which itself does not detach anything from the DOM instead relying + * on the underlying ReplaceableComponent for DOM content + * + * @returns Whether the unload of children should be forced + */ + isForcedUnload() { + return false; + } } diff --git a/src/components/EmptyComponent/EmptyComponent.test.ts b/src/components/EmptyComponent/EmptyComponent.test.ts new file mode 100644 index 0000000..57b19ff --- /dev/null +++ b/src/components/EmptyComponent/EmptyComponent.test.ts @@ -0,0 +1,22 @@ +// @vitest-environment happy-dom + +import { describe, it, expect, beforeEach } from "vitest"; + +import { createMockContainer } from "@/__mocks__/obsidian"; + +import { EmptyComponent } from "./EmptyComponent"; + +describe("EmptyComponent", () => { + let container: HTMLElement; + let component: EmptyComponent; + + beforeEach(() => { + container = createMockContainer(); + component = new EmptyComponent(container); + }); + + it("should load without error", () => { + expect(() => component.load()).not.toThrow(); + expect(container.children.length).toBe(1); + }); +}); diff --git a/src/components/EmptyComponent/EmptyComponent.ts b/src/components/EmptyComponent/EmptyComponent.ts new file mode 100644 index 0000000..7d41bb3 --- /dev/null +++ b/src/components/EmptyComponent/EmptyComponent.ts @@ -0,0 +1,13 @@ +import { ReplaceableComponent } from "../ReplaceableComponent"; + +export class EmptyComponent extends ReplaceableComponent { + constructor(containerEl: HTMLElement) { + super(containerEl); + } + + createContainer(): HTMLElement { + return createDiv(); + } + + render(_wrapperEl: HTMLElement): void {} +} diff --git a/src/components/EmptyComponent/index.ts b/src/components/EmptyComponent/index.ts new file mode 100644 index 0000000..90478b1 --- /dev/null +++ b/src/components/EmptyComponent/index.ts @@ -0,0 +1 @@ +export { EmptyComponent } from "./EmptyComponent"; diff --git a/src/components/ReplaceableComponent/ReplaceableComponent.ts b/src/components/ReplaceableComponent/ReplaceableComponent.ts index 9343b62..226ee0c 100644 --- a/src/components/ReplaceableComponent/ReplaceableComponent.ts +++ b/src/components/ReplaceableComponent/ReplaceableComponent.ts @@ -24,7 +24,6 @@ export abstract class ReplaceableComponent extends DomComponent { } this.wrapperEl = wrapperEl; - super.onload(); this.render(wrapperEl); diff --git a/src/components/Timesheet/Timesheet.test.ts b/src/components/Timesheet/Timesheet.test.ts index 11fa6de..c13da4a 100644 --- a/src/components/Timesheet/Timesheet.test.ts +++ b/src/components/Timesheet/Timesheet.test.ts @@ -2,7 +2,7 @@ import type { App } from "obsidian"; -import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import type { CustomOutputFormat } from "@/output"; @@ -12,9 +12,6 @@ import { createStore, type Store } from "@/store"; import { Timesheet } from "./Timesheet"; -import { TimesheetApp } from "@/components/TimesheetApp"; -import { TimesheetSaveError } from "@/components/TimesheetSaveError"; - import type { Timekeep } from "@/timekeep/schema"; import { TimekeepAutocomplete } from "@/service/autocomplete"; @@ -25,52 +22,33 @@ describe("Timesheet", () => { let vault: MockVault; let app: App; let timekeep: Store; - let saveError: Store; let settings: Store; let customOutputFormats: Store>; let registry: TimekeepRegistry; let autocomplete: TimekeepAutocomplete; let component: Timesheet; - let handleSaveTimekeep: Mock<(value: Timekeep) => Promise>; - beforeEach(() => { app = {} as App; timekeep = createStore({ entries: [] }); - saveError = createStore(false); vault = new MockVault(); containerEl = createMockContainer(); settings = createStore(defaultSettings); customOutputFormats = createStore({}); registry = new TimekeepRegistry(vault.asVault(), settings); autocomplete = new TimekeepAutocomplete(registry, settings); - handleSaveTimekeep = vi.fn(); component = new Timesheet( containerEl, app, timekeep, - saveError, settings, customOutputFormats, - autocomplete, - handleSaveTimekeep + autocomplete ); }); it("should load without error", () => { expect(() => component.load()).not.toThrow(); }); - - it("default content should be the timesheet app", () => { - component.load(); - expect(component.getContent()).toBeInstanceOf(TimesheetApp); - }); - - it("on save error the content should be a save error message", () => { - component.load(); - - saveError.setState(true); - expect(component.getContent()).toBeInstanceOf(TimesheetSaveError); - }); }); diff --git a/src/components/Timesheet/Timesheet.ts b/src/components/Timesheet/Timesheet.ts index 42604f7..ea0524c 100644 --- a/src/components/Timesheet/Timesheet.ts +++ b/src/components/Timesheet/Timesheet.ts @@ -1,28 +1,29 @@ -import type { App } from "obsidian"; +import { App } from "obsidian"; -import type { CustomOutputFormat } from "@/output"; -import type { TimekeepSettings } from "@/settings"; -import type { Store } from "@/store"; +import { CustomOutputFormat } from "@/output"; +import { TimekeepSettings } from "@/settings"; +import { Store } from "@/store"; -import { ContentComponent } from "@/components/ContentComponent"; -import { TimesheetApp } from "@/components/TimesheetApp"; -import { TimesheetSaveError } from "@/components/TimesheetSaveError"; +import { ReplaceableComponent } from "../ReplaceableComponent"; -import type { Timekeep } from "@/timekeep/schema"; +import { TimesheetCounters } from "@/components/TimesheetCounters"; +import { TimesheetExportActions } from "@/components/TimesheetExportActions"; +import { TimesheetRunningEntry } from "@/components/TimesheetRunningEntry"; +import { TimesheetStartForm } from "@/components/TimesheetStartForm"; +import { TimesheetTable } from "@/components/TimesheetTable"; + +import { Timekeep } from "@/timekeep/schema"; import { TimekeepAutocomplete } from "@/service/autocomplete"; /** - * Wrapper component for the timesheet app that handles - * display error messages when saving fails + * View component for the timesheet app as a whole */ -export class Timesheet extends ContentComponent { +export class Timesheet extends ReplaceableComponent { /** Access to the app instance */ app: App; /** Access to the timekeep */ timekeep: Store; - /** Store for save error state */ - saveError: Store; /** Access to the timekeep settings */ settings: Store; /** Access to custom output formats */ @@ -30,56 +31,55 @@ export class Timesheet extends ContentComponent Promise; - constructor( containerEl: HTMLElement, app: App, timekeep: Store, - saveError: Store, settings: Store, customOutputFormats: Store>, - autocomplete: TimekeepAutocomplete, - handleSaveTimekeep: (value: Timekeep) => Promise + autocomplete: TimekeepAutocomplete ) { super(containerEl); this.app = app; this.timekeep = timekeep; - this.saveError = saveError; this.settings = settings; this.customOutputFormats = customOutputFormats; this.autocomplete = autocomplete; - this.handleSaveTimekeep = handleSaveTimekeep; } - onload(): void { - super.onload(); - - const render = this.update.bind(this); - const unsubscribeSaveError = this.saveError.subscribe(render); - this.register(unsubscribeSaveError); - render(); + createContainer(): HTMLElement { + return createDiv({ + cls: "timekeep-container", + }); } - update() { - const saveError = this.saveError.getState(); - if (saveError) { - this.setContent( - new TimesheetSaveError(this.containerEl, this.timekeep, this.handleSaveTimekeep) - ); - } else { - this.setContent( - new TimesheetApp( - this.containerEl, - this.app, - this.timekeep, - this.settings, - this.customOutputFormats, - this.autocomplete - ) - ); - } + render(wrapperEl: HTMLElement): void { + const counters = new TimesheetCounters(wrapperEl, this.settings, this.timekeep); + + const startForm = new TimesheetStartForm( + wrapperEl, + this.timekeep, + this.settings, + this.autocomplete + ); + + const runningEntry = new TimesheetRunningEntry(wrapperEl, this.timekeep, this.settings); + + const table = new TimesheetTable(wrapperEl, this.app, this.timekeep, this.settings); + + const exportActions = new TimesheetExportActions( + wrapperEl, + this.app, + this.timekeep, + this.settings, + this.customOutputFormats + ); + + this.addChild(counters); + this.addChild(runningEntry); + this.addChild(startForm); + this.addChild(table); + this.addChild(exportActions); } } diff --git a/src/components/TimesheetApp/TimesheetApp.test.ts b/src/components/TimesheetApp/TimesheetApp.test.ts deleted file mode 100644 index 98d9c7e..0000000 --- a/src/components/TimesheetApp/TimesheetApp.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -// @vitest-environment happy-dom - -import type { App } from "obsidian"; - -import { beforeEach, describe, expect, it } from "vitest"; - -import type { CustomOutputFormat } from "@/output"; - -import { createMockContainer, MockVault } from "@/__mocks__/obsidian"; -import { defaultSettings, type TimekeepSettings } from "@/settings"; -import { createStore, type Store } from "@/store"; - -import { TimesheetApp } from "./TimesheetApp"; - -import type { Timekeep } from "@/timekeep/schema"; - -import { TimekeepAutocomplete } from "@/service/autocomplete"; -import { TimekeepRegistry } from "@/service/registry"; - -describe("TimesheetApp", () => { - let containerEl: HTMLElement; - let vault: MockVault; - let app: App; - let timekeep: Store; - let settings: Store; - let customOutputFormats: Store>; - let registry: TimekeepRegistry; - let autocomplete: TimekeepAutocomplete; - let component: TimesheetApp; - - beforeEach(() => { - app = {} as App; - timekeep = createStore({ entries: [] }); - vault = new MockVault(); - containerEl = createMockContainer(); - settings = createStore(defaultSettings); - customOutputFormats = createStore({}); - registry = new TimekeepRegistry(vault.asVault(), settings); - autocomplete = new TimekeepAutocomplete(registry, settings); - - component = new TimesheetApp( - containerEl, - app, - timekeep, - settings, - customOutputFormats, - autocomplete - ); - }); - - it("should load without error", () => { - expect(() => component.load()).not.toThrow(); - }); -}); diff --git a/src/components/TimesheetApp/TimesheetApp.ts b/src/components/TimesheetApp/TimesheetApp.ts deleted file mode 100644 index 9429078..0000000 --- a/src/components/TimesheetApp/TimesheetApp.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { App } from "obsidian"; - -import { CustomOutputFormat } from "@/output"; -import { TimekeepSettings } from "@/settings"; -import { Store } from "@/store"; - -import { DomComponent } from "@/components/DomComponent"; -import { TimesheetCounters } from "@/components/TimesheetCounters"; -import { TimesheetExportActions } from "@/components/TimesheetExportActions"; -import { TimesheetRunningEntry } from "@/components/TimesheetRunningEntry"; -import { TimesheetStartForm } from "@/components/TimesheetStartForm"; -import { TimesheetTable } from "@/components/TimesheetTable"; - -import { Timekeep } from "@/timekeep/schema"; - -import { TimekeepAutocomplete } from "@/service/autocomplete"; - -/** - * View component for the timesheet app as a whole - */ -export class TimesheetApp extends DomComponent { - /** Access to the app instance */ - app: App; - /** Access to the timekeep */ - timekeep: Store; - /** Access to the timekeep settings */ - settings: Store; - /** Access to custom output formats */ - customOutputFormats: Store>; - /** Autocomplete */ - autocomplete: TimekeepAutocomplete; - - constructor( - containerEl: HTMLElement, - app: App, - timekeep: Store, - settings: Store, - customOutputFormats: Store>, - autocomplete: TimekeepAutocomplete - ) { - super(containerEl); - - this.app = app; - this.timekeep = timekeep; - this.settings = settings; - this.customOutputFormats = customOutputFormats; - this.autocomplete = autocomplete; - } - - onload(): void { - super.onload(); - - const wrapperEl = this.containerEl.createDiv({ - cls: "timekeep-container", - }); - - this.wrapperEl = wrapperEl; - - const counters = new TimesheetCounters(wrapperEl, this.settings, this.timekeep); - - const startForm = new TimesheetStartForm( - wrapperEl, - this.timekeep, - this.settings, - this.autocomplete - ); - - const runningEntry = new TimesheetRunningEntry(wrapperEl, this.timekeep, this.settings); - - const table = new TimesheetTable(wrapperEl, this.app, this.timekeep, this.settings); - - const exportActions = new TimesheetExportActions( - wrapperEl, - this.app, - this.timekeep, - this.settings, - this.customOutputFormats - ); - - this.addChild(counters); - this.addChild(runningEntry); - this.addChild(startForm); - this.addChild(table); - this.addChild(exportActions); - } -} diff --git a/src/components/TimesheetApp/index.ts b/src/components/TimesheetApp/index.ts deleted file mode 100644 index ac83ae4..0000000 --- a/src/components/TimesheetApp/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { TimesheetApp } from "./TimesheetApp"; diff --git a/src/components/TimesheetLoadError/TimesheetLoadError.ts b/src/components/TimesheetLoadError/TimesheetLoadError.ts index 8ef972f..698e5aa 100644 --- a/src/components/TimesheetLoadError/TimesheetLoadError.ts +++ b/src/components/TimesheetLoadError/TimesheetLoadError.ts @@ -1,9 +1,9 @@ -import { DomComponent } from "@/components/DomComponent"; +import { ReplaceableComponent } from "../ReplaceableComponent"; /** * Component for rendering a load error for a timesheet */ -export class TimesheetLoadError extends DomComponent { +export class TimesheetLoadError extends ReplaceableComponent { /** The load error message */ error: string; @@ -13,13 +13,13 @@ export class TimesheetLoadError extends DomComponent { this.error = error; } - onload(): void { - super.onload(); - - const wrapperEl = this.containerEl.createDiv({ + createContainer(): HTMLElement { + return createDiv({ cls: "timekeep-container", }); - this.wrapperEl = wrapperEl; + } + + render(wrapperEl: HTMLElement): void { wrapperEl.createEl("p", { text: `Failed to load timekeep: ${this.error}`, }); diff --git a/src/components/TimesheetRunningEntry/TimesheetRunningEntry.test.ts b/src/components/TimesheetRunningEntry/TimesheetRunningEntry.test.ts index 3565f5e..a48bc65 100644 --- a/src/components/TimesheetRunningEntry/TimesheetRunningEntry.test.ts +++ b/src/components/TimesheetRunningEntry/TimesheetRunningEntry.test.ts @@ -11,6 +11,7 @@ import { createMockContainer } from "@/__mocks__/obsidian"; import { defaultSettings } from "@/settings"; import { createStore } from "@/store"; +import { EmptyComponent } from "../EmptyComponent"; import { TimesheetRunningEntry } from "./TimesheetRunningEntry"; import { TimesheetRunningEntryEditing } from "./TimesheetRunningEntryEditing"; import { TimesheetRunningEntryViewing } from "./TimesheetRunningEntryViewing"; @@ -41,7 +42,7 @@ describe("TimesheetRunningEntry", () => { it("should be undefined content without a running entry", () => { component = new TimesheetRunningEntry(containerEl, timekeep, settings); component.load(); - expect(component.getContent()).toBeUndefined(); + expect(component.getContent()).toBeInstanceOf(EmptyComponent); }); it("should be TimesheetStartRunning when theres a running entry", () => { @@ -114,7 +115,7 @@ describe("TimesheetRunningEntry", () => { expect(component.getContent()).toBeInstanceOf(TimesheetRunningEntryEditing); }); - it("after a timekeep update when editing if theres no more current entry should return to undefined state", () => { + it("after a timekeep update when editing if theres no more current entry should return to empty component state", () => { const start = moment(); timekeep.setState({ entries: [ @@ -138,6 +139,6 @@ describe("TimesheetRunningEntry", () => { timekeep.setState({ entries: [] }); - expect(component.getContent()).toBeUndefined(); + expect(component.getContent()).toBeInstanceOf(EmptyComponent); }); }); diff --git a/src/components/TimesheetRunningEntry/TimesheetRunningEntry.ts b/src/components/TimesheetRunningEntry/TimesheetRunningEntry.ts index 39fc469..1dd5cf8 100644 --- a/src/components/TimesheetRunningEntry/TimesheetRunningEntry.ts +++ b/src/components/TimesheetRunningEntry/TimesheetRunningEntry.ts @@ -3,6 +3,7 @@ import type { Store } from "@/store"; import { assert } from "@/utils/assert"; +import { EmptyComponent } from "../EmptyComponent"; import { TimesheetRunningEntryEditing } from "./TimesheetRunningEntryEditing"; import { TimesheetRunningEntryViewing } from "./TimesheetRunningEntryViewing"; @@ -12,7 +13,7 @@ import { getRunningEntry } from "@/timekeep/queries"; import type { Timekeep } from "@/timekeep/schema"; export class TimesheetRunningEntry extends ContentComponent< - TimesheetRunningEntryViewing | TimesheetRunningEntryEditing + TimesheetRunningEntryViewing | TimesheetRunningEntryEditing | EmptyComponent > { /** Access to the timekeep */ timekeep: Store; @@ -57,7 +58,7 @@ export class TimesheetRunningEntry extends ContentComponent< const timekeep = this.timekeep.getState(); const currentEntry = getRunningEntry(timekeep.entries); if (!currentEntry) { - this.setContent(undefined); + this.setContent(new EmptyComponent(contentEl)); return; } @@ -82,7 +83,7 @@ export class TimesheetRunningEntry extends ContentComponent< const timekeep = this.timekeep.getState(); const currentEntry = getRunningEntry(timekeep.entries); if (!currentEntry) { - this.setContent(undefined); + this.setContent(new EmptyComponent(contentEl)); return; } diff --git a/src/components/TimesheetRunningEntry/TimesheetRunningEntryEditing.ts b/src/components/TimesheetRunningEntry/TimesheetRunningEntryEditing.ts index 63bcb7e..745ebf7 100644 --- a/src/components/TimesheetRunningEntry/TimesheetRunningEntryEditing.ts +++ b/src/components/TimesheetRunningEntry/TimesheetRunningEntryEditing.ts @@ -3,7 +3,8 @@ import type { Store } from "@/store"; import { assert } from "@/utils/assert"; -import { DomComponent } from "@/components/DomComponent"; +import { ReplaceableComponent } from "../ReplaceableComponent"; + import { createObsidianIcon } from "@/components/obsidianIcon"; import { getRunningEntry } from "@/timekeep/queries"; @@ -14,7 +15,7 @@ import { updateEntry } from "@/timekeep/update"; * The editing section for editing the currently * running time entry within the start section */ -export class TimesheetRunningEntryEditing extends DomComponent { +export class TimesheetRunningEntryEditing extends ReplaceableComponent { /** Access to the timekeep */ timekeep: Store; /** Access to the timekeep settings */ @@ -47,15 +48,17 @@ export class TimesheetRunningEntryEditing extends DomComponent { this.onFinishEditing = onFinishEditing; } - onload(): void { - super.onload(); - - const formEl = this.containerEl.createEl("form", { + createContainer(): HTMLElement { + return createEl("form", { cls: "timekeep-start-area", + attr: { + "data-area": "start", + }, }); - formEl.setAttribute("data-area", "start"); + } + + render(formEl: HTMLElement): void { this.registerDomEvent(formEl, "submit", this.onSave.bind(this)); - this.wrapperEl = formEl; const nameWrapperEl = formEl.createDiv({ cls: "timekeep-name-wrapper", diff --git a/src/components/TimesheetRunningEntry/TimesheetRunningEntryViewing.ts b/src/components/TimesheetRunningEntry/TimesheetRunningEntryViewing.ts index ada3cab..1baf23a 100644 --- a/src/components/TimesheetRunningEntry/TimesheetRunningEntryViewing.ts +++ b/src/components/TimesheetRunningEntry/TimesheetRunningEntryViewing.ts @@ -6,7 +6,8 @@ import type { Store } from "@/store"; import { assert } from "@/utils/assert"; import { formatTimestamp } from "@/utils/time"; -import { DomComponent } from "@/components/DomComponent"; +import { ReplaceableComponent } from "../ReplaceableComponent"; + import { createObsidianIcon } from "@/components/obsidianIcon"; import { getPathToEntry } from "@/timekeep/queries"; @@ -16,7 +17,7 @@ import { stopTimekeep } from "@/timekeep/update"; /** * The "Running" timer section of the timesheet start are */ -export class TimesheetRunningEntryViewing extends DomComponent { +export class TimesheetRunningEntryViewing extends ReplaceableComponent { /** Access to the timekeep */ timekeep: Store; /** Access to the timekeep settings */ @@ -52,14 +53,16 @@ export class TimesheetRunningEntryViewing extends DomComponent { this.onStartEditing = onStartEditing; } - onload(): void { - super.onload(); - - const formEl = this.containerEl.createEl("form", { + createContainer(): HTMLElement { + return createEl("form", { cls: "timekeep-start-area", + attr: { + "data-area": "running", + }, }); - formEl.setAttribute("data-area", "running"); - this.wrapperEl = formEl; + } + + render(formEl: HTMLElement): void { this.registerDomEvent(formEl, "submit", this.onStop.bind(this)); const nameWrapperEl = formEl.createDiv({ diff --git a/src/components/TimesheetSaveError/TimesheetSaveError.test.ts b/src/components/TimesheetSaveError/TimesheetSaveError.test.ts index eae4f2d..f0b4b62 100644 --- a/src/components/TimesheetSaveError/TimesheetSaveError.test.ts +++ b/src/components/TimesheetSaveError/TimesheetSaveError.test.ts @@ -12,7 +12,6 @@ import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema"; describe("TimesheetSaveError", () => { let container: HTMLElement; let timekeepStore: Store; - let handleSaveTimekeep: (value: Timekeep) => Promise; let component: TimesheetSaveError; let writeText: Mock<() => any>; @@ -20,8 +19,7 @@ describe("TimesheetSaveError", () => { beforeEach(() => { container = createMockContainer(); timekeepStore = createStore({ entries: [] }); - handleSaveTimekeep = vi.fn().mockResolvedValue(undefined as void); - component = new TimesheetSaveError(container, timekeepStore, handleSaveTimekeep); + component = new TimesheetSaveError(container, timekeepStore); writeText = vi.fn().mockResolvedValue(undefined); @@ -55,9 +53,10 @@ describe("TimesheetSaveError", () => { }); it("calls handleSaveTimekeep on retry button click", () => { + const setState = vi.spyOn(timekeepStore, "setState"); component.load(); component.onRetrySave(); - expect(handleSaveTimekeep).toHaveBeenCalledWith(timekeepStore.getState()); + expect(setState).toHaveBeenCalledWith(timekeepStore.getState()); }); it("writes JSON to clipboard on copy button click", async () => { diff --git a/src/components/TimesheetSaveError/TimesheetSaveError.ts b/src/components/TimesheetSaveError/TimesheetSaveError.ts index 46689a4..deb9617 100644 --- a/src/components/TimesheetSaveError/TimesheetSaveError.ts +++ b/src/components/TimesheetSaveError/TimesheetSaveError.ts @@ -1,39 +1,28 @@ import type { Store } from "@/store"; -import { DomComponent } from "@/components/DomComponent"; +import { ReplaceableComponent } from "../ReplaceableComponent"; import { stripTimekeepRuntimeData, type Timekeep } from "@/timekeep/schema"; -type HandleSaveTimekeep = (value: Timekeep) => Promise; - /** * Component for showing the "Failed to save current timekeep" error */ -export class TimesheetSaveError extends DomComponent { +export class TimesheetSaveError extends ReplaceableComponent { /** Access to the timekeep */ timekeep: Store; - /** Callback to save the timekeep */ - handleSaveTimekeep: HandleSaveTimekeep; - constructor( - containerEl: HTMLElement, - timekeep: Store, - handleSaveTimekeep: HandleSaveTimekeep - ) { + constructor(containerEl: HTMLElement, timekeep: Store) { super(containerEl); - this.timekeep = timekeep; - this.handleSaveTimekeep = handleSaveTimekeep; } - onload(): void { - super.onload(); - - const wrapperEl = this.containerEl.createDiv({ + createContainer(): HTMLElement { + return createDiv({ cls: "timekeep-container", }); - this.wrapperEl = wrapperEl; + } + render(wrapperEl: HTMLElement): void { const errorEl = wrapperEl.createDiv({ cls: "timekeep-error" }); errorEl.createEl("h1", { text: "Warning" }); errorEl.createEl("p", { text: "Failed to save current timekeep" }); @@ -57,8 +46,8 @@ export class TimesheetSaveError extends DomComponent { } onRetrySave() { - // Attempt to save the current timekeep - void this.handleSaveTimekeep(this.timekeep.getState()); + // Set the state to itself to trigger another save attempt + this.timekeep.setState(this.timekeep.getState()); } async onCopy() { diff --git a/src/components/TimesheetTable/TimesheetTable.ts b/src/components/TimesheetTable/TimesheetTable.ts index aa746a3..8bdbb05 100644 --- a/src/components/TimesheetTable/TimesheetTable.ts +++ b/src/components/TimesheetTable/TimesheetTable.ts @@ -108,6 +108,7 @@ export class TimesheetTable extends DomComponent { clearRows() { // Unload existing children and reset the rows list for (const row of this.#rows) { + console.log(row); this.removeChild(row); } diff --git a/src/save/TimesheetFileSaveAdapter.ts b/src/save/TimesheetFileSaveAdapter.ts new file mode 100644 index 0000000..d377b5a --- /dev/null +++ b/src/save/TimesheetFileSaveAdapter.ts @@ -0,0 +1,36 @@ +import type { TFile, Vault } from "obsidian"; + +import type { TimesheetSaveAdapter } from "./TimesheetSaveAdapter"; + +import { stripTimekeepRuntimeData, type Timekeep } from "@/timekeep/schema"; + +export class TimesheetFileSaveAdapter implements TimesheetSaveAdapter { + /** Access to the vault */ + vault: Vault; + + /** File that should be saved to */ + file: TFile | null; + + constructor(vault: Vault, file: TFile | null) { + this.vault = vault; + this.file = file; + } + + onLoad(): void {} + onUnload(): void {} + + async onSave(timekeep: Timekeep): Promise { + if (this.file === null) { + return; + } + + try { + const stripped = stripTimekeepRuntimeData(timekeep); + const serialized = JSON.stringify(stripped); + + await this.vault.modify(this.file, serialized); + } catch (error) { + console.error("failed to save file", error); + } + } +} diff --git a/src/save/TimesheetMarkdownSaveAdapter.ts b/src/save/TimesheetMarkdownSaveAdapter.ts new file mode 100644 index 0000000..d0cdc0b --- /dev/null +++ b/src/save/TimesheetMarkdownSaveAdapter.ts @@ -0,0 +1,72 @@ +import { + TFile, + Vault, + type EventRef, + type MarkdownPostProcessorContext, + type TAbstractFile, +} from "obsidian"; + +import type { TimesheetSaveAdapter } from "./TimesheetSaveAdapter"; + +import { replaceTimekeepCodeblock } from "@/timekeep/parser"; +import type { Timekeep } from "@/timekeep/schema"; + +export class TimesheetMarkdownSaveAdapter implements TimesheetSaveAdapter { + /** Access to the vault */ + vault: Vault; + + /** Context from the markdown */ + context: MarkdownPostProcessorContext; + /** HTML container element */ + containerEl: HTMLElement; + /** Path to the file the timekeep is within */ + fileSourcePath: string; + + /** Event ref for the registered rename event */ + renameEventRef: EventRef | undefined; + + constructor(vault: Vault, containerEl: HTMLElement, context: MarkdownPostProcessorContext) { + this.vault = vault; + this.containerEl = containerEl; + this.context = context; + this.fileSourcePath = context.sourcePath; + } + + onLoad(): void { + this.renameEventRef = this.vault.on("rename", this.onVaultRename.bind(this)); + } + + onUnload(): void { + if (this.renameEventRef) { + this.vault.offref(this.renameEventRef); + } + } + + onVaultRename(file: TAbstractFile, oldName: string) { + if (file instanceof TFile && oldName == this.fileSourcePath) { + this.fileSourcePath = file.path; + } + } + + async onSave(timekeep: Timekeep): Promise { + const sectionInfo = this.context.getSectionInfo(this.containerEl); + + // Ensure we actually have a section to write to + if (sectionInfo === null) throw new Error("Section to write did not exist"); + + const file = this.vault.getFileByPath(this.fileSourcePath); + + // Ensure the file still exists + if (file === null) throw new Error("File no longer exists"); + + // Replace the stored timekeep block with the new one + await this.vault.process(file, (data) => { + return replaceTimekeepCodeblock( + timekeep, + data, + sectionInfo.lineStart, + sectionInfo.lineEnd + ); + }); + } +} diff --git a/src/save/TimesheetSaveAdapter.ts b/src/save/TimesheetSaveAdapter.ts new file mode 100644 index 0000000..5774a31 --- /dev/null +++ b/src/save/TimesheetSaveAdapter.ts @@ -0,0 +1,9 @@ +import { Timekeep } from "@/timekeep/schema"; + +export interface TimesheetSaveAdapter { + onLoad(): void; + + onUnload(): void; + + onSave(timekeep: Timekeep): Promise; +} diff --git a/src/utils/editorScrollTracker.test.ts b/src/utils/editorScrollTracker.test.ts new file mode 100644 index 0000000..983ba4c --- /dev/null +++ b/src/utils/editorScrollTracker.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { EditorScrollTracker } from "./editorScrollTracker"; + +describe("EditorScrollTracker", () => { + let mockApp: any; + let mockEditor: any; + + beforeEach(() => { + mockEditor = { + getScrollInfo: vi.fn(), + scrollTo: vi.fn(), + }; + + mockApp = { + workspace: { + activeEditor: { + editor: mockEditor, + }, + }, + }; + + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + describe("save", () => { + it("saves scroll info when editor exists", () => { + const scrollInfo = { top: 100, left: 50 }; + mockEditor.getScrollInfo.mockReturnValue(scrollInfo); + + const tracker = new EditorScrollTracker(mockApp); + tracker.save(); + + expect(mockEditor.getScrollInfo).toHaveBeenCalled(); + expect(tracker.restoreScrollInfo).toEqual(scrollInfo); + }); + + it("does nothing if activeEditor is missing", () => { + mockApp.workspace.activeEditor = null; + + const tracker = new EditorScrollTracker(mockApp); + tracker.save(); + + expect(tracker.restoreScrollInfo).toBeNull(); + }); + + it("does nothing if editor is missing", () => { + mockApp.workspace.activeEditor.editor = null; + + const tracker = new EditorScrollTracker(mockApp); + tracker.save(); + + expect(tracker.restoreScrollInfo).toBeNull(); + }); + }); + + describe("restore", () => { + it("restores scroll when info and editor exist", () => { + const tracker = new EditorScrollTracker(mockApp); + tracker.restoreScrollInfo = { top: 200, left: 75 }; + + tracker.restore(); + + expect(mockEditor.scrollTo).toHaveBeenCalledWith(75, 200); + }); + + it("does nothing if restoreScrollInfo is null", () => { + const tracker = new EditorScrollTracker(mockApp); + + tracker.restore(); + + expect(mockEditor.scrollTo).not.toHaveBeenCalled(); + }); + + it("does nothing if activeEditor is missing", () => { + const tracker = new EditorScrollTracker(mockApp); + tracker.restoreScrollInfo = { top: 10, left: 20 }; + + mockApp.workspace.activeEditor = null; + + tracker.restore(); + + expect(mockEditor.scrollTo).not.toHaveBeenCalled(); + }); + + it("does nothing if editor is missing", () => { + const tracker = new EditorScrollTracker(mockApp); + tracker.restoreScrollInfo = { top: 10, left: 20 }; + + mockApp.workspace.activeEditor.editor = null; + + tracker.restore(); + + expect(mockEditor.scrollTo).not.toHaveBeenCalled(); + }); + }); + + describe("queueRestore", () => { + it("queues restore after delay", () => { + const tracker = new EditorScrollTracker(mockApp); + tracker.restoreScrollInfo = { top: 300, left: 150 }; + + tracker.queueRestore(100); + + expect(mockEditor.scrollTo).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + + expect(mockEditor.scrollTo).toHaveBeenCalledWith(150, 300); + }); + + it("clears previous timeout before setting a new one", () => { + const tracker = new EditorScrollTracker(mockApp); + tracker.restoreScrollInfo = { top: 400, left: 200 }; + + const clearSpy = vi.spyOn(global, "clearTimeout"); + + tracker.queueRestore(100); + tracker.queueRestore(200); + + expect(clearSpy).toHaveBeenCalled(); + + vi.advanceTimersByTime(200); + + expect(mockEditor.scrollTo).toHaveBeenCalledTimes(1); + expect(mockEditor.scrollTo).toHaveBeenCalledWith(200, 400); + }); + }); +}); diff --git a/src/utils/editorScrollTracker.ts b/src/utils/editorScrollTracker.ts new file mode 100644 index 0000000..721cb4d --- /dev/null +++ b/src/utils/editorScrollTracker.ts @@ -0,0 +1,48 @@ +import type { App } from "obsidian"; + +/** + * Utility to handle tracking the editor scroll for saving and restoring + * scroll on the markdown view + */ +export class EditorScrollTracker { + app: App; + + restoreScrollTimeout: ReturnType | null = null; + restoreScrollInfo: { top: number; left: number } | null = null; + + constructor(app: App) { + this.app = app; + } + + save() { + const activeEditor = this.app.workspace.activeEditor; + if (!activeEditor) return; + const editor = activeEditor.editor; + if (!editor) return; + this.restoreScrollInfo = editor.getScrollInfo(); + } + + restore() { + if (this.restoreScrollInfo === null) return; + const activeEditor = this.app.workspace.activeEditor; + + if (!activeEditor) return; + const editor = activeEditor.editor; + + if (!editor) return; + editor.scrollTo(this.restoreScrollInfo.left, this.restoreScrollInfo.top); + } + + /** + * Queue a restore to happen after a delay + * + * @param delayMs The delay in milliseconds before restoring + */ + queueRestore(delayMs: number = 50) { + if (this.restoreScrollTimeout) { + clearTimeout(this.restoreScrollTimeout); + } + + this.restoreScrollTimeout = setTimeout(this.restore.bind(this), delayMs); + } +} diff --git a/src/views/TimekeepFileView.ts b/src/views/TimekeepFileView.ts index df3f823..ed576e0 100644 --- a/src/views/TimekeepFileView.ts +++ b/src/views/TimekeepFileView.ts @@ -1,15 +1,13 @@ -import moment from "moment"; import { EditableFileView, TFile, WorkspaceLeaf } from "obsidian"; import { CustomOutputFormat } from "@/output"; +import { TimesheetFileSaveAdapter } from "@/save/TimesheetFileSaveAdapter"; import { TimekeepSettings } from "@/settings"; import { createStore, Store } from "@/store"; -import { Timesheet } from "@/components/Timesheet"; -import { TimesheetLoadError } from "@/components/TimesheetLoadError"; +import TimekeepView from "./TimekeepView"; import { load, LoadResult } from "@/timekeep/parser"; -import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema"; import { TimekeepAutocomplete } from "@/service/autocomplete"; @@ -21,13 +19,16 @@ export default class TimekeepFileView extends EditableFileView { /** Autocomplete */ autocomplete: TimekeepAutocomplete; - /** Loading result for the timekeep data */ - loadResult: Store; /** Container wrapper element */ wrapperEl: HTMLElement | undefined; - /** The rendered timesheet component */ - timesheet: Timesheet | TimesheetLoadError | undefined; + /** Loading result for the timekeep data */ + loadResult: Store; + /** The timekeep view */ + timesheet: TimekeepView | undefined; + + /** Adapter for saving to the file */ + saveAdapter: TimesheetFileSaveAdapter; constructor( leaf: WorkspaceLeaf, @@ -38,10 +39,11 @@ export default class TimekeepFileView extends EditableFileView { super(leaf); this.loadResult = createStore(null); - this.settings = settings; this.customOutputFormats = customOutputFormats; this.autocomplete = autocomplete; + + this.saveAdapter = new TimesheetFileSaveAdapter(this.app.vault, this.file); } onload(): void { @@ -50,68 +52,16 @@ export default class TimekeepFileView extends EditableFileView { const wrapperEl = this.contentEl.createDiv({ cls: "timekeep-file" }); this.wrapperEl = wrapperEl; - const render = this.render.bind(this); - const unsubscribeLoadResult = this.loadResult.subscribe(render); - render(); - this.register(unsubscribeLoadResult); - } - - render() { - if (!this.wrapperEl) { - return; - } - - const loadResult = this.loadResult.getState(); - if (!loadResult) return; - - if (this.timesheet) { - this.removeChild(this.timesheet); - } - - // Render the content - if (loadResult.success) { - const timekeep = loadResult.timekeep; - - const timekeepStore = createStore(timekeep); - const saveErrorStore = createStore(false); - - const trySave = this.trySave.bind(this); - - const handleSaveTimekeep = async (timekeep: Timekeep) => { - // Attempt to save the timekeep changes - const result = await trySave(timekeep); - - const saveError = !result; - - // Update the save error state - if (saveErrorStore.getState() !== saveError) { - saveErrorStore.setState(saveError); - } - }; - - // Subscribe to save when timekeep changes - timekeepStore.subscribe(() => { - void handleSaveTimekeep(timekeepStore.getState()); - }); - - const timesheet = new Timesheet( - this.wrapperEl, - this.app, - timekeepStore, - saveErrorStore, - this.settings, - this.customOutputFormats, - this.autocomplete, - handleSaveTimekeep - ); - - this.addChild(timesheet); - this.timesheet = timesheet; - } else { - const timesheet = new TimesheetLoadError(this.wrapperEl, loadResult.error); - this.addChild(timesheet); - this.timesheet = timesheet; - } + this.timesheet = new TimekeepView( + wrapperEl, + this.app, + this.settings, + this.customOutputFormats, + this.autocomplete, + this.loadResult, + this.saveAdapter + ); + this.addChild(this.timesheet); } getViewType(): string { @@ -129,74 +79,8 @@ export default class TimekeepFileView extends EditableFileView { async onLoadFile(file: TFile): Promise { await super.onLoadFile(file); + this.saveAdapter.file = file; const fileData = await this.app.vault.read(file); this.loadResult.setState(load(fileData)); } - - async onUnloadFile(file: TFile): Promise { - await super.onUnloadFile(file); - } - - /** - * Attempts to save the file normally, if this fails it also attempts - * to save a fallback file - * - * @param timekeep - * @returns Promise of a boolean indicating weather the save was a success - */ - async trySave(timekeep: Timekeep): Promise { - try { - await this.save(timekeep); - - return true; - } catch (e) { - console.error("Failed to save timekeep", e); - - try { - await this.saveFallback(timekeep); - } catch (e) { - console.error("Couldn't save timekeep fallback", e); - } - - return false; - } - } - - /** - * Attempts to save the timekeep within the current file - * - * @param timekeep The new timekeep data to save - */ - async save(timekeep: Timekeep) { - if (this.file === null) { - return; - } - - try { - const stripped = stripTimekeepRuntimeData(timekeep); - const serialized = JSON.stringify(stripped); - - await this.app.vault.modify(this.file, serialized); - } catch (error) { - console.error("failed to save file", error); - } - } - - /** - * Fallback saving in case writing back to the timekeep block fails, - * if writing back fails attempt to write to a backup temporary file - * using the current date time - * - * @param timekeep The timekeep to save - */ - async saveFallback(timekeep: Timekeep) { - // Fallback in case of write failure, attempt to write to another file - const backupFileName = `timekeep-write-backup-${moment().format("YYYY-MM-DD HH-mm-ss")}.json`; - - // Write to the backup file - await this.app.vault.create( - backupFileName, - JSON.stringify(stripTimekeepRuntimeData(timekeep)) - ); - } } diff --git a/src/views/TimekeepMarkdownView.ts b/src/views/TimekeepMarkdownView.ts index f87dba3..ab42aab 100644 --- a/src/views/TimekeepMarkdownView.ts +++ b/src/views/TimekeepMarkdownView.ts @@ -1,45 +1,46 @@ import type { App } from "obsidian"; -import type { TAbstractFile, MarkdownPostProcessorContext } from "obsidian"; +import type { MarkdownPostProcessorContext } from "obsidian"; -import moment from "moment"; -import { TFile, MarkdownRenderChild } from "obsidian"; +import { MarkdownRenderChild } from "obsidian"; import type { CustomOutputFormat } from "@/output"; import type { TimekeepSettings } from "@/settings"; +import { TimesheetMarkdownSaveAdapter } from "@/save/TimesheetMarkdownSaveAdapter"; import { type Store, createStore } from "@/store"; +import { EditorScrollTracker } from "@/utils/editorScrollTracker"; -import { Timesheet } from "@/components/Timesheet"; -import { TimesheetLoadError } from "@/components/TimesheetLoadError"; +import TimekeepView from "./TimekeepView"; -import { type LoadResult, load, replaceTimekeepCodeblock } from "@/timekeep/parser"; -import { type Timekeep, stripTimekeepRuntimeData } from "@/timekeep/schema"; +import { type LoadResult, load } from "@/timekeep/parser"; import { TimekeepAutocomplete } from "@/service/autocomplete"; export default class TimekeepMarkdownView extends MarkdownRenderChild { - // Obsidian app instance + /** Obsidian app instance */ app: App; - // Timekeep settings store - settingsStore: Store; - // Custom output formats store + /** Timekeep settings store */ + settings: Store; + /** Custom output formats store */ customOutputFormats: Store>; - // Markdown context for the current markdown block - context: MarkdownPostProcessorContext; - // Timekeep load result - loadResult: LoadResult; - /** The rendered timesheet component */ - timesheet: Timesheet | TimesheetLoadError | undefined; /** Autocomplete */ autocomplete: TimekeepAutocomplete; - // Path to the file the timekeep is within - fileSourcePath: string; + /** Timekeep load result */ + loadResult: Store; + /** The timekeep view */ + timesheet: TimekeepView | undefined; + + /** Scroll tracking for restoring scroll after save */ + scrollTracker: EditorScrollTracker; + + /** Adapter for how the timesheet should be saved */ + saveAdapter: TimesheetMarkdownSaveAdapter; constructor( containerEl: HTMLElement, app: App, - settingsStore: Store, + settings: Store, customOutputFormats: Store>, autocomplete: TimekeepAutocomplete, context: MarkdownPostProcessorContext, @@ -47,24 +48,24 @@ export default class TimekeepMarkdownView extends MarkdownRenderChild { ) { super(containerEl); this.app = app; - this.settingsStore = settingsStore; + this.settings = settings; this.customOutputFormats = customOutputFormats; this.autocomplete = autocomplete; - this.context = context; - this.loadResult = loadResult; - // Set initial file path - this.fileSourcePath = context.sourcePath; + this.loadResult = createStore(loadResult); + + this.scrollTracker = new EditorScrollTracker(app); + this.saveAdapter = new TimesheetMarkdownSaveAdapter(this.app.vault, containerEl, context); } /** * Create a markdown code block processor for this view * - * @param app - * @param settingsStore - * @param customOutputFormats - * @param autocomplete - * @returns + * @param app The app to use + * @param settingsStore The settings store + * @param customOutputFormats The custom output formats store + * @param autocomplete The autocomplete store + * @returns The markdown post processor factory */ static markdownPostProcessor( app: App, @@ -89,155 +90,22 @@ export default class TimekeepMarkdownView extends MarkdownRenderChild { } onload(): void { - // Hook file renaming to update the file we are saving to if its renamed - this.registerEvent( - this.app.vault.on("rename", (file: TAbstractFile, oldName: string) => { - if (file instanceof TFile && oldName == this.fileSourcePath) { - this.fileSourcePath = file.path; - } - }) + super.onload(); + + this.timesheet = new TimekeepView( + this.containerEl, + this.app, + this.settings, + this.customOutputFormats, + this.autocomplete, + this.loadResult, + this.saveAdapter ); - // Render the content - if (this.loadResult.success) { - const timekeep = this.loadResult.timekeep; + // Bind save event handlers to update scroll + this.timesheet.onBeforeSave = this.scrollTracker.save.bind(this.scrollTracker); + this.timesheet.onAfterSave = this.scrollTracker.queueRestore.bind(this.scrollTracker); - const timekeepStore = createStore(timekeep); - const saveErrorStore = createStore(false); - - const trySave = this.trySave.bind(this); - - const handleSaveTimekeep = async (timekeep: Timekeep) => { - // Attempt to save the timekeep changes - const result = await trySave(timekeep); - - const saveError = !result; - - // Update the save error state - if (saveErrorStore.getState() !== saveError) { - saveErrorStore.setState(saveError); - } - }; - - // Subscribe to save when timekeep changes - timekeepStore.subscribe(() => { - void handleSaveTimekeep(timekeepStore.getState()); - }); - - const timesheet = new Timesheet( - this.containerEl, - this.app, - timekeepStore, - saveErrorStore, - this.settingsStore, - this.customOutputFormats, - this.autocomplete, - handleSaveTimekeep - ); - - this.addChild(timesheet); - this.timesheet = timesheet; - } else { - const timesheet = new TimesheetLoadError(this.containerEl, this.loadResult.error); - this.addChild(timesheet); - this.timesheet = timesheet; - } - } - - restoreScrollTimeout: ReturnType | null = null; - restoreScrollInfo: { top: number; left: number } | null = null; - - saveEditorScroll() { - const activeEditor = this.app.workspace.activeEditor; - if (!activeEditor) return; - const editor = activeEditor.editor; - if (!editor) return; - this.restoreScrollInfo = editor.getScrollInfo(); - } - - restoreEditorScroll() { - if (this.restoreScrollInfo === null) return; - const activeEditor = this.app.workspace.activeEditor; - - if (!activeEditor) return; - const editor = activeEditor.editor; - - if (!editor) return; - editor.scrollTo(this.restoreScrollInfo.left, this.restoreScrollInfo.top); - } - - /** - * Attempts to save the file normally, if this fails it also attempts - * to save a fallback file - * - * @param timekeep - * @returns Promise of a boolean indicating weather the save was a success - */ - async trySave(timekeep: Timekeep): Promise { - this.saveEditorScroll(); - - try { - await this.save(timekeep); - - return true; - } catch (e) { - console.error("Failed to save timekeep", e); - - try { - await this.saveFallback(timekeep); - } catch (e) { - console.error("Couldn't save timekeep fallback", e); - } - - return false; - } finally { - // Queue scroll restoration after 50ms (Should be enough for the codeblock to re-render) - this.restoreScrollTimeout = setTimeout(this.restoreEditorScroll.bind(this), 50); - } - } - - /** - * Attempts to save the timekeep within the current file - * - * @param timekeep The new timekeep data to save - */ - async save(timekeep: Timekeep) { - const sectionInfo = this.context.getSectionInfo(this.containerEl); - - // Ensure we actually have a section to write to - if (sectionInfo === null) throw new Error("Section to write did not exist"); - - const file = this.app.vault.getFileByPath(this.fileSourcePath); - - // Ensure the file still exists - if (file === null) throw new Error("File no longer exists"); - - // Replace the stored timekeep block with the new one - await this.app.vault.process(file, (data) => { - return replaceTimekeepCodeblock( - timekeep, - data, - sectionInfo.lineStart, - sectionInfo.lineEnd - ); - }); - } - - /** - * Fallback saving in case writing back to the timekeep block fails, - * if writing back fails attempt to write to a backup temporary file - * using the current date time - * - * @param timekeep The timekeep to save - */ - async saveFallback(timekeep: Timekeep) { - // Fallback in case of write failure, attempt to write to another file - const backupFileName = `timekeep-write-backup-${moment().format("YYYY-MM-DD HH-mm-ss")}.json`; - - // Write to the backup file - await this.app.vault.create( - backupFileName, - JSON.stringify(stripTimekeepRuntimeData(timekeep)) - ); + this.addChild(this.timesheet); } } diff --git a/src/views/TimekeepView.ts b/src/views/TimekeepView.ts new file mode 100644 index 0000000..ac1ec68 --- /dev/null +++ b/src/views/TimekeepView.ts @@ -0,0 +1,166 @@ +import moment from "moment"; +import { App } from "obsidian"; + +import { CustomOutputFormat } from "@/output"; +import { TimesheetSaveAdapter } from "@/save/TimesheetSaveAdapter"; +import { TimekeepSettings } from "@/settings"; +import { createStore, Store } from "@/store"; + +import { ContentComponent } from "@/components/ContentComponent"; +import { EmptyComponent } from "@/components/EmptyComponent"; +import { Timesheet } from "@/components/Timesheet"; +import { TimesheetLoadError } from "@/components/TimesheetLoadError"; +import { TimesheetSaveError } from "@/components/TimesheetSaveError"; + +import { LoadResult } from "@/timekeep/parser"; +import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema"; + +import { TimekeepAutocomplete } from "@/service/autocomplete"; + +export default class TimekeepView extends ContentComponent< + Timesheet | TimesheetLoadError | TimesheetSaveError | EmptyComponent +> { + /** Access to the obsidian app */ + app: App; + /** Access to the timekeep settings */ + settings: Store; + /** Access to custom output formats */ + customOutputFormats: Store>; + /** Autocomplete */ + autocomplete: TimekeepAutocomplete; + + /** Loading result for the timekeep data */ + loadResult: Store; + /** Store for the timekeep state */ + timekeep: Store; + /** Store for save error state */ + saveError: Store; + + /** Adapter for how the timesheet should be saved */ + saveAdapter: TimesheetSaveAdapter; + + onBeforeSave: VoidFunction | undefined; + onAfterSave: VoidFunction | undefined; + + constructor( + containerEl: HTMLElement, + app: App, + settings: Store, + customOutputFormats: Store>, + autocomplete: TimekeepAutocomplete, + loadResult: Store, + saveAdapter: TimesheetSaveAdapter + ) { + super(containerEl); + + this.app = app; + + this.loadResult = loadResult; + this.timekeep = createStore({ entries: [] }); + this.saveError = createStore(false); + + this.settings = settings; + this.customOutputFormats = customOutputFormats; + this.autocomplete = autocomplete; + + this.saveAdapter = saveAdapter; + } + + onload(): void { + super.onload(); + + this.saveAdapter.onLoad(); + + const onUpdateContent = this.onUpdateContent.bind(this); + + this.register(this.loadResult.subscribe(onUpdateContent)); + this.register(this.saveError.subscribe(onUpdateContent)); + + onUpdateContent(); + } + + onunload(): void { + super.onunload(); + this.saveAdapter.onUnload(); + } + + onUpdateContent() { + const saveError = this.saveError.getState(); + const loadResult = this.loadResult.getState(); + + if (!loadResult) { + return this.setContent(new EmptyComponent(this.containerEl)); + } + + if (saveError) { + this.setContent(new TimesheetSaveError(this.containerEl, this.timekeep)); + } else if (loadResult.success) { + const timekeep = loadResult.timekeep; + this.timekeep.setState(timekeep); + + this.register(this.timekeep.subscribe(this.onSave.bind(this))); + + this.setContent( + new Timesheet( + this.containerEl, + this.app, + this.timekeep, + this.settings, + this.customOutputFormats, + this.autocomplete + ) + ); + } else { + this.setContent(new TimesheetLoadError(this.containerEl, loadResult.error)); + } + } + + async onSave() { + if (this.onBeforeSave) this.onBeforeSave(); + + const timekeep = this.timekeep.getState(); + + try { + await this.saveAdapter.onSave(timekeep); + + // Clear error state on success + if (this.saveError.getState()) { + this.saveError.setState(false); + } + + return true; + } catch (e) { + console.error("Failed to save timekeep", e); + + try { + await this.saveFallback(timekeep); + } catch (e) { + console.error("Couldn't save timekeep fallback", e); + } + + this.saveError.setState(true); + } finally { + if (this.onAfterSave) this.onAfterSave(); + } + } + + /** + * Fallback handling if saving the timekeep through the regular + * adapter fails: + * + * Write a backup to a temporary file using the current date and + * time in the file name + * + * @param timekeep The timekeep to save + */ + async saveFallback(timekeep: Timekeep) { + // Fallback in case of write failure, attempt to write to another file + const backupFileName = `timekeep-write-backup-${moment().format("YYYY-MM-DD HH-mm-ss")}.json`; + + // Write to the backup file + await this.app.vault.create( + backupFileName, + JSON.stringify(stripTimekeepRuntimeData(timekeep)) + ); + } +} diff --git a/test-vault/Obsidian Timekeep.md b/test-vault/Obsidian Timekeep.md index 5fb50a1..f4de0b9 100644 --- a/test-vault/Obsidian Timekeep.md +++ b/test-vault/Obsidian Timekeep.md @@ -26,7 +26,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-09-17T07:50:55.000Z","subEntries":null},{"name":"Part 2","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2024-03-24T05:20:54.000Z","endTime":"2024-03-24T05:20:55.000Z","subEntries":null},{"name":"Part 2","startTime":"2026-03-25T22:33:49.279Z","endTime":"2026-03-25T23:04:57.335Z","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.000Z","endTime":"2024-03-17T06:37:35.000Z","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 82","startTime":null,"endTime":null,"collapsed":true,"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":"Part 3","startTime":"2024-07-21T09:06:54.527Z","endTime":"2024-07-21T09:06:57.360Z","subEntries":null},{"name":"Part 4","startTime":"2024-07-21T09:07:10.820Z","endTime":"2024-07-21T09:07:11.426Z","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},{"name":"Part 4","startTime":"2024-07-21T09:06:52.510Z","endTime":"2024-07-21T09:06:53.351Z","subEntries":null},{"name":"Part 5","startTime":"2024-07-21T09:07:19.089Z","endTime":"2024-07-21T09:07:20.066Z","subEntries":null}]},{"name":"Block 83","startTime":"2024-07-21T09:07:21.923Z","endTime":"2024-07-21T09:07:22.516Z","subEntries":null},{"name":"Block 81","startTime":null,"endTime":null,"collapsed":true,"subEntries":[{"name":"Part 1","startTime":"2025-01-07T02:20:00.000Z","endTime":"2025-01-07T03:20:00.000Z","subEntries":null},{"name":"Part 2","startTime":"2026-03-25T23:04:57.335Z","endTime":"2026-03-25T23:05:00.051Z","subEntries":null}]},{"name":"Block 85 [[#Test]] ","startTime":"2025-01-07T04:18:19.000Z","endTime":"2025-01-07T03:18:20.000Z","subEntries":null},{"name":"Block 86","startTime":"2026-03-25T20:20:44.814Z","endTime":"2026-03-25T20:53:38.364Z","subEntries":null},{"name":"Block 87","startTime":"2026-03-25T22:20:11.034Z","endTime":"2026-03-25T22:20:13.254Z","subEntries":null},{"name":"Block 88","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2026-03-25T22:20:13.921Z","endTime":"2026-03-25T22:33:49.279Z","subEntries":null},{"name":"Part 2","startTime":"2026-03-25T23:05:33.696Z","endTime":"2026-03-25T23:05:35.361Z","subEntries":null},{"name":"Part 3","startTime":"2026-04-03T01:39:34.954Z","endTime":"2026-04-03T01:39:35.936Z","subEntries":null}]},{"name":"Part 2","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2026-03-25T23:05:12.433Z","endTime":"2026-03-25T23:05:14.427Z","subEntries":null},{"name":"Part 2","startTime":"2026-03-25T23:05:18.672Z","endTime":"2026-03-25T23:05:22.812Z","subEntries":null}]},{"name":"Part 3","startTime":"2026-03-25T23:09:44.281Z","endTime":"2026-03-25T23:09:46.096Z","subEntries":null}]},{"name":"Part 2","startTime":"2026-03-25T23:05:10.205Z","endTime":"2026-03-25T23:05:11.240Z","subEntries":null},{"name":"Part 3","startTime":"2026-03-25T23:05:15.995Z","endTime":"2026-03-25T23:05:17.046Z","subEntries":null},{"name":"Part 4","startTime":"2026-04-03T01:39:32.651Z","endTime":"2026-04-03T01:39:33.753Z","subEntries":null}]},{"name":"Block 89","startTime":"2026-03-28T04:16:49.497Z","endTime":"2026-03-28T04:21:38.831Z","subEntries":null},{"name":"Block 90","startTime":"2026-03-29T05:10:55.305Z","endTime":"2026-04-03T01:39:30.461Z","subEntries":null},{"name":"Block 91","startTime":"2026-04-09T11:55:46.857Z","endTime":"2026-04-09T11:56:54.196Z","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-09-17T07:50:55.000Z","subEntries":null},{"name":"Part 2","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2024-03-24T05:20:54.000Z","endTime":"2024-03-24T05:20:55.000Z","subEntries":null},{"name":"Part 2","startTime":"2026-03-25T22:33:49.279Z","endTime":"2026-03-25T23:04:57.335Z","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.000Z","endTime":"2024-03-17T06:37:35.000Z","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 82","startTime":null,"endTime":null,"collapsed":true,"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":"Part 3","startTime":"2024-07-21T09:06:54.527Z","endTime":"2024-07-21T09:06:57.360Z","subEntries":null},{"name":"Part 4","startTime":"2024-07-21T09:07:10.820Z","endTime":"2024-07-21T09:07:11.426Z","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},{"name":"Part 4","startTime":"2024-07-21T09:06:52.510Z","endTime":"2024-07-21T09:06:53.351Z","subEntries":null},{"name":"Part 5","startTime":"2024-07-21T09:07:19.089Z","endTime":"2024-07-21T09:07:20.066Z","subEntries":null}]},{"name":"Block 83","startTime":"2024-07-21T09:07:21.923Z","endTime":"2024-07-21T09:07:22.516Z","subEntries":null},{"name":"Block 81","startTime":null,"endTime":null,"collapsed":true,"subEntries":[{"name":"Part 1","startTime":"2025-01-07T02:20:00.000Z","endTime":"2025-01-07T03:20:00.000Z","subEntries":null},{"name":"Part 2","startTime":"2026-03-25T23:04:57.335Z","endTime":"2026-03-25T23:05:00.051Z","subEntries":null}]},{"name":"Block 85 [[#Test]] ","startTime":"2025-01-07T04:18:19.000Z","endTime":"2025-01-07T03:18:20.000Z","subEntries":null},{"name":"Block 86","startTime":"2026-03-25T20:20:44.814Z","endTime":"2026-03-25T20:53:38.364Z","subEntries":null},{"name":"Block 87","startTime":"2026-03-25T22:20:11.034Z","endTime":"2026-03-25T22:20:13.254Z","subEntries":null},{"name":"Block 88","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2026-03-25T22:20:13.921Z","endTime":"2026-03-25T22:33:49.279Z","subEntries":null},{"name":"Part 2","startTime":"2026-03-25T23:05:33.696Z","endTime":"2026-03-25T23:05:35.361Z","subEntries":null},{"name":"Part 3","startTime":"2026-04-03T01:39:34.954Z","endTime":"2026-04-03T01:39:35.936Z","subEntries":null}]},{"name":"Part 2","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2026-03-25T23:05:12.433Z","endTime":"2026-03-25T23:05:14.427Z","subEntries":null},{"name":"Part 2","startTime":"2026-03-25T23:05:18.672Z","endTime":"2026-03-25T23:05:22.812Z","subEntries":null}]},{"name":"Part 3","startTime":"2026-03-25T23:09:44.281Z","endTime":"2026-03-25T23:09:46.096Z","subEntries":null}]},{"name":"Part 2","startTime":"2026-03-25T23:05:10.205Z","endTime":"2026-03-25T23:05:11.240Z","subEntries":null},{"name":"Part 3","startTime":"2026-03-25T23:05:15.995Z","endTime":"2026-03-25T23:05:17.046Z","subEntries":null},{"name":"Part 4","startTime":"2026-04-03T01:39:32.651Z","endTime":"2026-04-03T01:39:33.753Z","subEntries":null}]},{"name":"Block 89","startTime":"2026-03-28T04:16:49.497Z","endTime":"2026-03-28T04:21:38.831Z","subEntries":null},{"name":"Block 90","startTime":"2026-03-29T05:10:55.305Z","endTime":"2026-04-03T01:39:30.461Z","subEntries":null},{"name":"Block 91","startTime":"2026-04-09T11:55:46.857Z","endTime":"2026-04-09T11:56:54.196Z","subEntries":null},{"name":"Block 92","startTime":"2026-04-11T10:52:05.131Z","endTime":"2026-04-11T10:52:05.990Z","subEntries":null},{"name":"Block 93","startTime":"2026-04-11T10:52:06.733Z","endTime":"2026-04-11T10:52:07.565Z","subEntries":null},{"name":"Block 94","startTime":"2026-04-11T10:52:08.263Z","endTime":"2026-04-11T10:52:09.043Z","subEntries":null},{"name":"Block 95","startTime":"2026-04-11T10:52:09.553Z","endTime":"2026-04-11T10:52:11.218Z","subEntries":null},{"name":"Block 96","startTime":"2026-04-11T10:52:12.035Z","endTime":"2026-04-11T10:52:17.780Z","subEntries":null},{"name":"Test","startTime":"2026-04-11T10:52:29.804Z","endTime":"2026-04-11T10:52:34.633Z","subEntries":null},{"name":"Block 98","startTime":"2026-04-11T10:52:35.871Z","endTime":"2026-04-11T10:52:37.093Z","subEntries":null},{"name":"Block 99","startTime":"2026-04-11T10:52:37.903Z","endTime":"2026-04-11T10:52:50.023Z","subEntries":null},{"name":"Block 100","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2026-04-11T11:45:12.241Z","endTime":"2026-04-11T11:45:13.877Z","subEntries":null},{"name":"Part 2","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2026-04-11T11:45:15.459Z","endTime":"2026-04-11T11:45:16.794Z","subEntries":null},{"name":"Part 2","startTime":"2026-04-11T11:45:20.582Z","endTime":"2026-04-11T11:45:21.714Z","subEntries":null}]},{"name":"Part 2","startTime":"2026-04-11T11:45:18.654Z","endTime":"2026-04-11T11:45:20.582Z","subEntries":null}]},{"name":"Part 3","startTime":"2026-04-11T11:45:16.794Z","endTime":"2026-04-11T11:45:17.687Z","subEntries":null},{"name":"Part 4","startTime":"2026-04-11T11:45:17.687Z","endTime":"2026-04-11T11:45:18.654Z","subEntries":null}]},{"name":"Block 101","startTime":"2026-04-11T11:45:21.714Z","endTime":"2026-04-11T11:45:23.050Z","subEntries":null},{"name":"Block 102","startTime":"2026-04-11T11:45:23.050Z","endTime":"2026-04-11T11:45:24.864Z","subEntries":null},{"name":"Block 103","startTime":"2026-04-11T11:45:24.864Z","endTime":"2026-04-11T11:45:25.044Z","subEntries":null},{"name":"Block 104","startTime":"2026-04-11T11:45:25.044Z","endTime":"2026-04-11T11:45:25.299Z","subEntries":null},{"name":"Block 105","startTime":"2026-04-11T11:45:25.299Z","endTime":"2026-04-11T11:45:25.569Z","subEntries":null},{"name":"Block 106","startTime":"2026-04-11T11:45:25.569Z","endTime":"2026-04-11T11:45:27.968Z","subEntries":null},{"name":"Test","startTime":"2026-04-11T11:45:27.968Z","endTime":null,"subEntries":null}]} ``` ```timekeep