diff --git a/cspell.json b/cspell.json index b627351..0df52f0 100644 --- a/cspell.json +++ b/cspell.json @@ -9,6 +9,7 @@ "timesheet", "unstarted", "Timekeeps", - "pdfmake" + "pdfmake", + "activedescendant" ] } diff --git a/package-lock.json b/package-lock.json index 646fa8c..08d9231 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.16.0", "license": "MIT", "dependencies": { + "fuse.js": "^7.1.0", "moment": "^2.30.1", "p-limit": "^7.3.0", "pdfmake": "^0.3.7", @@ -1664,6 +1665,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fuse.js": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-7.1.0.tgz", + "integrity": "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", diff --git a/package.json b/package.json index 94e4c38..8a750dd 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "test": "vitest run --coverage" }, "dependencies": { + "fuse.js": "^7.1.0", "moment": "^2.30.1", "p-limit": "^7.3.0", "pdfmake": "^0.3.7", diff --git a/src/components/timesheet.ts b/src/components/timesheet.ts index f505226..91aa043 100644 --- a/src/components/timesheet.ts +++ b/src/components/timesheet.ts @@ -1,6 +1,7 @@ import { App, Component } from "obsidian"; import { CustomOutputFormat } from "@/output"; +import { TimekeepAutocomplete } from "@/service/autocomplete"; import { TimekeepSettings } from "@/settings"; import { Store } from "@/store"; import { Timekeep } from "@/timekeep/schema"; @@ -26,6 +27,8 @@ export class Timesheet extends Component { settings: Store; /** Access to custom output formats */ customOutputFormats: Store>; + /** Autocomplete */ + autocomplete: TimekeepAutocomplete; /** Callback to save the timekeep */ handleSaveTimekeep: (value: Timekeep) => Promise; @@ -40,6 +43,7 @@ export class Timesheet extends Component { saveError: Store, settings: Store, customOutputFormats: Store>, + autocomplete: TimekeepAutocomplete, handleSaveTimekeep: (value: Timekeep) => Promise ) { super(); @@ -51,6 +55,7 @@ export class Timesheet extends Component { this.saveError = saveError; this.settings = settings; this.customOutputFormats = customOutputFormats; + this.autocomplete = autocomplete; this.handleSaveTimekeep = handleSaveTimekeep; } @@ -83,6 +88,7 @@ export class Timesheet extends Component { this.timekeep, this.settings, this.customOutputFormats, + this.autocomplete, this.handleSaveTimekeep ); } diff --git a/src/components/timesheetApp.ts b/src/components/timesheetApp.ts index c51a4e2..ae586eb 100644 --- a/src/components/timesheetApp.ts +++ b/src/components/timesheetApp.ts @@ -1,6 +1,7 @@ import { App, Component } from "obsidian"; import { CustomOutputFormat } from "@/output"; +import { TimekeepAutocomplete } from "@/service/autocomplete"; import { TimekeepSettings } from "@/settings"; import { Store } from "@/store"; import { Timekeep } from "@/timekeep/schema"; @@ -25,6 +26,8 @@ export class TimesheetApp extends Component { settings: Store; /** Access to custom output formats */ customOutputFormats: Store>; + /** Autocomplete */ + autocomplete: TimekeepAutocomplete; /** Callback to save the timekeep */ handleSaveTimekeep: (value: Timekeep) => Promise; @@ -38,6 +41,7 @@ export class TimesheetApp extends Component { timekeep: Store, settings: Store, customOutputFormats: Store>, + autocomplete: TimekeepAutocomplete, handleSaveTimekeep: (value: Timekeep) => Promise ) { super(); @@ -48,6 +52,8 @@ export class TimesheetApp extends Component { this.timekeep = timekeep; this.settings = settings; this.customOutputFormats = customOutputFormats; + this.autocomplete = autocomplete; + this.handleSaveTimekeep = handleSaveTimekeep; } @@ -67,7 +73,13 @@ export class TimesheetApp extends Component { const counters = new TimesheetCounters(wrapperEl, this.settings, this.timekeep); - const start = new TimesheetStart(wrapperEl, this.app, this.timekeep, this.settings); + const start = new TimesheetStart( + wrapperEl, + this.app, + this.timekeep, + this.settings, + this.autocomplete + ); const table = new TimesheetTable(wrapperEl, this.app, this.timekeep, this.settings); diff --git a/src/components/timesheetNameInput.ts b/src/components/timesheetNameInput.ts new file mode 100644 index 0000000..c686a3d --- /dev/null +++ b/src/components/timesheetNameInput.ts @@ -0,0 +1,374 @@ +import Fuse, { FuseResult } from "fuse.js"; +import { Component, debounce } from "obsidian"; + +import { TimekeepAutocomplete } from "@/service/autocomplete"; + +export class TimesheetNameInput extends Component { + #containerEl: HTMLElement; + + /** Access to autocomplete */ + autocomplete: TimekeepAutocomplete; + + /** Name input wrapper container */ + #wrapperEl: HTMLDivElement | undefined; + /** Name input */ + #inputEl: HTMLInputElement | undefined; + /** Suggestions container element */ + #suggestionsEl: HTMLDivElement | undefined; + + /** Current list of suggestions */ + #suggestions: FuseResult[] = []; + /** Whether the suggestions should be open */ + #suggestionsOpen: boolean = false; + /** Index of the currently focused suggestion */ + #suggestionFocusIndex: number = -1; + + constructor(containerEl: HTMLElement, autocomplete: TimekeepAutocomplete) { + super(); + + this.#containerEl = containerEl; + this.autocomplete = autocomplete; + } + + onload(): void { + super.onload(); + + const wrapperEl = this.#containerEl.createDiv({ cls: "timekeep-name-containers" }); + this.#wrapperEl = wrapperEl; + + const inputEl = wrapperEl.createEl("input", { + cls: "timekeep-name", + placeholder: "Example Block", + type: "text", + }); + inputEl.id = "timekeepBlockName"; + inputEl.role = "combobox"; + inputEl.setAttribute("aria-expanded", "false"); + inputEl.setAttribute("aria-controls", "timekeepSuggestions"); + inputEl.setAttribute("aria-autocomplete", "list"); + this.#inputEl = inputEl; + + const suggestionsEl = wrapperEl.createDiv({ cls: "timekeep-suggestions" }); + suggestionsEl.id = "timekeepSuggestions"; + suggestionsEl.role = "listbox"; + suggestionsEl.hidden = true; + this.#suggestionsEl = suggestionsEl; + + this.registerDomEvent( + inputEl, + "input", + debounce(this.onDebouncedChange.bind(this), 300, true) + ); + + this.registerDomEvent(inputEl, "focus", this.onFocus.bind(this)); + this.registerDomEvent(inputEl, "keydown", this.onKeyDown.bind(this)); + + this.registerDomEvent(wrapperEl, "mousedown", this.onClickOutside.bind(this)); + this.registerDomEvent(wrapperEl, "touchstart", this.onClickOutside.bind(this), { + passive: true, + }); + + this.registerDomEvent(suggestionsEl, "mousedown", this.onClickSuggestions.bind(this)); + } + + onunload(): void { + super.onunload(); + this.#wrapperEl?.remove(); + } + + /** + * Get the current list of suggestions based on the currently + * typed input words + */ + getFilteredSuggestions(): FuseResult[] { + const autocomplete = this.autocomplete; + const suggestions = autocomplete.names.getState(); + + const value = this.getValue(); + const fuse = new Fuse(suggestions, { + includeMatches: true, + shouldSort: true, + }); + const results = fuse.search(value); + + return results; + } + + /** + * Renders the suggestion children within the suggestion container + * for the current available suggestions + */ + renderSuggestions() { + const suggestionsEl = this.#suggestionsEl; + if (!suggestionsEl) return; + + suggestionsEl.empty(); + const suggestions = this.#suggestions; + if (suggestions.length < 1) return; + + for (let i = 0; i < suggestions.length; i += 1) { + const result = suggestions[i]; + const suggestion = result.item; + + const suggestionEl = suggestionsEl.createDiv({ cls: "timekeep-suggestion" }); + suggestionEl.role = "option"; + suggestionEl.id = `timekeepSuggestion-${i}`; + suggestionEl.setAttribute("aria-selected", "false"); + suggestionEl.setAttribute("value", suggestion); + + if (result.matches && result.matches.length > 0) { + const match = result.matches[0]; + let lastIndex = 0; + + for (const [start, end] of match.indices) { + if (start > lastIndex) { + suggestionEl.appendText(suggestion.slice(lastIndex, start)); + } + + const text = suggestion.slice(start, end + 1); + suggestionEl.createEl("mark", { text }); + lastIndex = end + 1; + } + + if (lastIndex < suggestion.length) { + suggestionEl.appendText(suggestion.slice(lastIndex)); + } + } else { + suggestionEl.setText(suggestion); + } + } + } + + /** + * Handle clicking suggestion elements + * + * @param event The click event + */ + onClickSuggestions(event: MouseEvent) { + const target = event.target; + if (!(target instanceof HTMLElement)) return; + if (!target.id.startsWith("timekeepSuggestion-")) return; + const suggestion = target; + const value = suggestion.getAttribute("value"); + if (!value) return; + this.onSelectSuggestion(value); + } + + /** + * Handle changes to the input value, this updates the filtered + * suggestions list and triggers re-rendering of the suggestions + */ + onDebouncedChange() { + const suggestions = this.getFilteredSuggestions(); + this.#suggestions = suggestions; + + this.updateSuggestionsOpen(); + this.renderSuggestions(); + + if (suggestions.length > 0) { + this.setSuggestionFocus(0); + } + } + + /** + * Handle clicks and close the suggestion box if they are + * outside of the container + * + * @param event The click / touch event + */ + onClickOutside(event: MouseEvent | TouchEvent) { + if ( + event.target && + event.target instanceof HTMLElement && + this.#wrapperEl && + !this.#wrapperEl.contains(event.target) + ) { + this.setSuggestionsOpen(false); + } + } + + /** + * Handle input focus, this should open the suggestions + * box if it is not already open + */ + onFocus() { + this.setSuggestionsOpen(true); + } + + /** + * Handle keyboard events for selecting the suggestion + * items using the keyboard + * + * @param event The keyboard event + */ + onKeyDown(event: KeyboardEvent) { + const suggestionsEl = this.#suggestionsEl; + if (!suggestionsEl) return; + + const suggestionsOpen = !(suggestionsEl.hidden ?? false); + + if (!suggestionsOpen && event.key === "ArrowDown") { + this.setSuggestionsOpen(true); + this.setSuggestionFocus(0); + return; + } + + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + + const focusIndex = this.#suggestionFocusIndex; + const nextFocusIndex = Math.min(focusIndex + 1, this.#suggestions.length - 1); + this.setSuggestionFocus(nextFocusIndex); + + break; + } + + case "ArrowUp": { + event.preventDefault(); + + const focusIndex = this.#suggestionFocusIndex; + const prevFocusIndex = Math.max(focusIndex - 1, 0); + this.setSuggestionFocus(prevFocusIndex); + + break; + } + + case "Enter": { + const focusIndex = this.#suggestionFocusIndex; + if (focusIndex >= 0) { + event.preventDefault(); + const suggestion = this.#suggestions[focusIndex]; + if (suggestion) { + this.onSelectSuggestion(suggestion.item); + } + } + break; + } + + case "Escape": { + this.setSuggestionsOpen(false); + break; + } + + default: + break; + } + } + + /** + * Clamps the current suggestion focus index to be within + * the current suggestion bounds + */ + clampSuggestionFocus() { + if (this.#suggestions.length > 0) { + const focusIndex = this.#suggestionFocusIndex; + const newFocusIndex = + focusIndex === -1 ? 0 : Math.min(focusIndex, this.#suggestions.length - 1); + + this.setSuggestionFocus(newFocusIndex); + } else { + this.setSuggestionFocus(-1); + } + } + + /** + * Set the focused suggestion index within the drop down menu. + * + * @param index The index that is focused + */ + setSuggestionFocus(index: number) { + this.#suggestionFocusIndex = index; + + const inputEl = this.#inputEl; + const suggestionsEl = this.#suggestionsEl; + if (!suggestionsEl || !inputEl) return; + + const children = suggestionsEl.querySelectorAll(".timekeep-suggestion"); + for (let i = 0; i < children.length; i++) { + const child = children.item(i); + + const selected = index === i; + child.setAttribute("aria-selected", String(selected)); + + // Bring the suggestion element into view if its selected + if (selected) { + child.scrollIntoView({ + block: "nearest", + }); + } + } + + // Update the input aria-activedescendant for screen readers + if (index === -1) { + inputEl.removeAttribute("aria-activedescendant"); + } else { + inputEl.setAttribute( + "aria-activedescendant", + `timekeepSuggestion-${this.#suggestionFocusIndex}` + ); + } + } + + /** + * Set the open state of the suggestions box + * + * @param value The open state of the box + */ + setSuggestionsOpen(value: boolean) { + this.#suggestionsOpen = value; + this.updateSuggestionsOpen(); + } + + /** + * Updates the open state of the suggestion box to actually show or + * hide the element, this is to ensure an empty suggestion box is + * not shown + */ + updateSuggestionsOpen() { + const inputEl = this.#inputEl; + const suggestionsEl = this.#suggestionsEl; + if (!inputEl || !suggestionsEl) return; + + // Force closed state if theres no suggestions + const open = this.#suggestions.length < 1 ? false : this.#suggestionsOpen; + + suggestionsEl.hidden = !open; + inputEl.setAttribute("aria-expanded", String(open)); + + // Update the focus + this.clampSuggestionFocus(); + } + + /** + * Handle selecting a suggestion value, this updates the + * current input value to match the suggestion + * + * @param value The suggestion value + */ + onSelectSuggestion(value: string) { + if (this.#inputEl) { + this.#inputEl.value = value; + } + + this.setSuggestionsOpen(false); + this.setSuggestionFocus(-1); + } + + /** + * Getter for the name input value + * + * @returns The name value + */ + getValue(): string { + return this.#inputEl?.value ?? ""; + } + + /** + * Reset the name input value to an empty string + */ + resetValue() { + if (!this.#inputEl) return; + this.#inputEl.value = ""; + } +} diff --git a/src/components/timesheetStart.ts b/src/components/timesheetStart.ts index 3167fd1..2af250f 100644 --- a/src/components/timesheetStart.ts +++ b/src/components/timesheetStart.ts @@ -5,9 +5,11 @@ import type { TimekeepSettings } from "@/settings"; import type { Store } from "@/store"; import type { Timekeep } from "@/timekeep/schema"; +import { TimekeepAutocomplete } from "@/service/autocomplete"; import { getRunningEntry, startNewEntry } from "@/timekeep"; import { createObsidianIcon } from "./obsidianIcon"; +import { TimesheetNameInput } from "./timesheetNameInput"; import { TimekeepStartEditing } from "./timesheetStartEditing"; import { TimesheetStartRunning } from "./timesheetStartRunning"; @@ -24,6 +26,8 @@ export class TimesheetStart extends Component { timekeep: Store; /** Access to the timekeep settings */ settings: Store; + /** Access to autocomplete */ + autocomplete: TimekeepAutocomplete; /** Wrapper for the component content */ #wrapperEl: HTMLElement | undefined; @@ -31,7 +35,8 @@ export class TimesheetStart extends Component { #contentEl: HTMLElement | undefined; /** Name input for starting entries */ - #nameInputEl: HTMLInputElement | undefined; + #nameInput: TimesheetNameInput | undefined; + /** Warning message element */ #blockPauseWarningEl: HTMLElement | undefined; /** Start button element */ @@ -44,13 +49,16 @@ export class TimesheetStart extends Component { containerEl: HTMLElement, app: App, timekeep: Store, - settings: Store + settings: Store, + autocomplete: TimekeepAutocomplete ) { super(); this.app = app; this.timekeep = timekeep; this.settings = settings; + this.autocomplete = autocomplete; + this.#containerEl = containerEl; } @@ -87,13 +95,9 @@ export class TimesheetStart extends Component { blockPauseWarningEl.hidden = true; - const nameInputEl = nameWrapperEl.createEl("input", { - cls: "timekeep-name", - placeholder: "Example Block", - type: "text", - }); - nameInputEl.id = "timekeepBlockName"; - this.#nameInputEl = nameInputEl; + const nameInput = new TimesheetNameInput(nameWrapperEl, this.autocomplete); + this.#nameInput = nameInput; + this.addChild(nameInput); const startButton = formEl.createEl("button", { cls: "timekeep-start", @@ -212,17 +216,15 @@ export class TimesheetStart extends Component { event.preventDefault(); event.stopPropagation(); - const nameInputEl = this.#nameInputEl; - if (!nameInputEl) return; - - const name = nameInputEl.value; + if (!this.#nameInput) return; + const name = this.#nameInput.getValue(); this.timekeep.setState((timekeep) => { const currentTime = moment(); const entries = startNewEntry(name, currentTime, timekeep.entries); // Reset name input - nameInputEl.value = ""; + this.#nameInput?.resetValue(); return { ...timekeep, diff --git a/src/main.ts b/src/main.ts index f0880a3..ab1071b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -23,8 +23,8 @@ import { load, replaceTimekeepCodeblock, extractTimekeepCodeblocks } from "@/tim import { stopAllTimekeeps } from "./commands/stopAllTimekeeps"; import { stopFileTimekeeps } from "./commands/stopFileTimekeeps"; -import { TimesheetStatusBarItem } from "./components/timesheetStatusBarItem"; import { CustomOutputFormat } from "./output"; +import { TimekeepAutocomplete } from "./service/autocomplete"; import { TimekeepRegistry } from "./service/registry"; import { Timekeep, TimeEntry } from "./timekeep/schema"; import { TimekeepLocatorModal } from "./views/timekeep-locator-modal"; @@ -54,6 +54,9 @@ export default class TimekeepPlugin extends Plugin { /** Registry of all timekeeps within the vault */ registry: TimekeepRegistry | null = null; + /** Currently loaded status bar view if present */ + #statusBarView: TimekeepStatusBarView | null = null; + constructor(app: ObsidianApp, manifest: PluginManifest) { super(app, manifest); @@ -96,6 +99,16 @@ export default class TimekeepPlugin extends Plugin { this.addSettingTab(new TimekeepSettingsTab(this.app, this)); + const registry = new TimekeepRegistry(this.app.vault, this.settingsStore); + this.registry = registry; + + const autocomplete = new TimekeepAutocomplete(this.registry, this.settingsStore); + this.addChild(autocomplete); + + const onLoadStatusBar = this.onLoadStatusBar.bind(this); + this.settingsStore.subscribe(onLoadStatusBar); + onLoadStatusBar(); + this.app.workspace.onLayoutReady(() => { const settings = this.settingsStore.getState(); @@ -103,15 +116,8 @@ export default class TimekeepPlugin extends Plugin { return; } - // Initialize the registry (After the layout is ready and the vault is loaded) - this.registry = new TimekeepRegistry(this.app.vault); - this.registry.concurrencyLimit = settings.registryConcurrencyLimit; - this.registry.onload(); - - if (settings.statusBarEnabled) { - const containerEl = this.addStatusBarItem(); - new TimekeepStatusBarView(containerEl, this.app, this.registry, this.settingsStore); - } + // Initialize the registry (Only after the layout is ready and the vault is loaded) + this.addChild(registry); }); this.registerMarkdownCodeBlockProcessor( @@ -125,6 +131,7 @@ export default class TimekeepPlugin extends Plugin { this.app, this.settingsStore, this.customOutputFormats, + autocomplete, context, loadResult ) @@ -246,4 +253,23 @@ export default class TimekeepPlugin extends Plugin { return newState; }); } + + private onLoadStatusBar() { + if (!this.registry) { + throw new Error("registry is not initialized"); + } + + if (this.#statusBarView) { + this.removeChild(this.#statusBarView); + this.#statusBarView = null; + } + + const settings = this.settingsStore.getState(); + if (!settings.statusBarEnabled) return; + + const containerEl = this.addStatusBarItem(); + const statusBarView = new TimekeepStatusBarView(containerEl, this.app, this.registry); + this.addChild(statusBarView); + this.#statusBarView = statusBarView; + } } diff --git a/src/service/autocomplete.ts b/src/service/autocomplete.ts new file mode 100644 index 0000000..1fd9c11 --- /dev/null +++ b/src/service/autocomplete.ts @@ -0,0 +1,98 @@ +import { Component } from "obsidian"; + +import { TimekeepSettings } from "@/settings"; +import { createStore, Store } from "@/store"; +import { getEntriesNames } from "@/timekeep/queries"; +import { isNumberText } from "@/utils/number"; + +import { TimekeepRegistry } from "./registry"; + +/** + * Autocomplete registry to provide entry name autocomplete based + * on all the existing tasks within the vault + */ +export class TimekeepAutocomplete extends Component { + /** The registry of timekeeps */ + registry: TimekeepRegistry; + /** Access to settings */ + settings: Store; + /** The collection of timekeep names */ + names: Store; + + constructor(registry: TimekeepRegistry, settings: Store) { + super(); + + this.registry = registry; + this.settings = settings; + + this.names = createStore([]); + } + + onload() { + super.onload(); + + const onUpdateRegistry = this.onUpdateRegistry.bind(this); + const unsubscribe = this.settings.subscribe(onUpdateRegistry); + onUpdateRegistry(); + this.register(unsubscribe); + } + + onUpdateRegistry() { + const settings = this.settings.getState(); + if (!settings.autocompleteEnabled) { + this.names.setState([]); + return; + } + + const onChangeEntries = this.onChangeEntries.bind(this); + const unsubscribe = this.registry.entries.subscribe(onChangeEntries); + onChangeEntries(); + this.register(unsubscribe); + } + + onChangeEntries() { + const entries = this.registry.entries.getState(); + const namesSet = new Set(); + + for (const entry of entries) { + for (const timekeep of entry.timekeeps) { + getEntriesNames(timekeep.timekeep.entries, namesSet); + } + } + + const names = Array.from(namesSet) + // + .filter((name) => !TimekeepAutocomplete.isIgnoredName(name)); + + names.sort(); + + this.names.setState(names); + } + + /** + * Checks if a name should be ignored from autocomplete + * + * @param name The name to check + * @returns Whether the name should be ignored + */ + static isIgnoredName(name: string) { + if (name.length < 1) { + return true; + } + + // Ignore "Part 1" "Part 2", "Block 1" ...etc + if (name.startsWith("Part") || name.startsWith("Block")) { + const parts = name.split(" "); + if (parts.length !== 2) { + return false; + } + + const numericPart = parts[1]; + if (isNumberText(numericPart)) { + return true; + } + } + + return false; + } +} diff --git a/src/service/registry.ts b/src/service/registry.ts index 879690f..e56ec37 100644 --- a/src/service/registry.ts +++ b/src/service/registry.ts @@ -1,6 +1,7 @@ -import { EventRef, TAbstractFile, TFile, Vault } from "obsidian"; +import { Component, TAbstractFile, TFile, Vault } from "obsidian"; import { limitFunction } from "p-limit"; +import { TimekeepSettings } from "@/settings"; import { createStore, Store } from "@/store"; import { extractTimekeepCodeblocksWithPosition, @@ -15,30 +16,41 @@ export type TimekeepRegistryEntry = { /** * Obsidian vault registry of all timekeep instances */ -export class TimekeepRegistry { +export class TimekeepRegistry extends Component { /** Vault that the registry is operating on */ #vault: Vault; /** Store for entries within the registry */ entries: Store; - /** Tracked events */ - #events: EventRef[] = []; + /** Settings access */ + settings: Store; - /** Concurrency limit for registry indexing */ - concurrencyLimit: number = 10; + /** Whether the store is initialized */ + initialized: boolean; - constructor(vault: Vault) { + constructor(vault: Vault, settings: Store) { + super(); this.#vault = vault; this.entries = createStore([]); + this.settings = settings; } onload() { + const settings = this.settings.getState(); + if (!settings.registryEnabled) { + return; + } + // Attach vault events const createEvent = this.#vault.on("create", this.onFileCreated.bind(this)); const modifyEvent = this.#vault.on("modify", this.onFileModified.bind(this)); const deleteEvent = this.#vault.on("delete", this.onFileRemoved.bind(this)); - this.#events.push(createEvent, modifyEvent, deleteEvent); + + // Register events for unloading + this.registerEvent(createEvent); + this.registerEvent(modifyEvent); + this.registerEvent(deleteEvent); // Load the registry from the vault void this.loadFromVault() @@ -93,7 +105,6 @@ export class TimekeepRegistry { this.entries.setState((entries) => { const newEntries = entries.filter((entry) => { - console.log(file, entry.file, file === entry.file); return entry.file !== file; }); return newEntries; @@ -104,10 +115,11 @@ export class TimekeepRegistry { * Load the registry from the current vault */ async loadFromVault() { + const settings = this.settings.getState(); const entries = await TimekeepRegistry.getTimekeepsWithinVault( this.#vault, true, - this.concurrencyLimit + settings.registryConcurrencyLimit ); this.entries.setState(entries); } diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 7633173..4de1ac2 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -440,5 +440,26 @@ export class TimekeepSettingsTab extends PluginSettingTab { })); }); }); + + // Autocomplete section + new Setting(this.containerEl) + .setName("Autocomplete") + .setDesc( + "Timekeep can autocomplete entry names from existing timekeeps. This requires that the registry option above is enabled" + ) + .setHeading(); + + new Setting(this.containerEl) + .setName("Enabled") + .setDesc("Whether to enable autocomplete.") + .addToggle((t) => { + t.setValue(settings.autocompleteEnabled); + t.onChange((v) => { + this.settingsStore.setState((currentValue) => ({ + ...currentValue, + autocompleteEnabled: v, + })); + }); + }); } } diff --git a/src/settings.ts b/src/settings.ts index 52df1c9..950d725 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -74,6 +74,8 @@ export interface TimekeepSettings { registryConcurrencyLimit: number; statusBarEnabled: boolean; + + autocompleteEnabled: boolean; } export const defaultSettings: TimekeepSettings = { @@ -102,6 +104,8 @@ export const defaultSettings: TimekeepSettings = { registryConcurrencyLimit: 15, statusBarEnabled: true, + + autocompleteEnabled: true, }; export function legacySettingsCompatibility(settings: TimekeepSettings): void { diff --git a/src/store.ts b/src/store.ts index 366c0e7..78445c5 100644 --- a/src/store.ts +++ b/src/store.ts @@ -21,6 +21,16 @@ type StoreState = { listeners: VoidFunction[]; }; +export function derived(store: Store, derive: (value: T) => V): Store { + const derivedStore = createStore(derive(store.getState())); + store.subscribe(() => { + const newValue = derive(store.getState()); + derivedStore.setState(newValue); + }); + + return derivedStore; +} + /** * Creates a new store * diff --git a/src/styles.css b/src/styles.css index f8307ec..57987c1 100644 --- a/src/styles.css +++ b/src/styles.css @@ -194,7 +194,7 @@ flex-flow: column; flex: auto; gap: 0.5rem; - overflow: hidden; + overflow: visible; overflow-wrap: anywhere; } @@ -299,3 +299,41 @@ gap: 0.25rem; cursor: pointer; } + +.timekeep-name-containers { + display: flex; + position: relative; +} + +.timekeep-suggestions { + position: absolute; + top: 100%; + left: 0; + z-index: 1000; + + width: 100%; + max-height: 100px; + background-color: var(--color-base-20); + border: 1px solid var(--color-base-30); + overflow-y: scroll; + overflow-x: hidden; + scrollbar-gutter: stable; + + padding: 0.5rem; + list-style: none; +} + +.timekeep-suggestion { + height: calc((var(--font-ui-small) * var(--line-height-normal)) + 0.5rem); + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; + white-space: nowrap; + padding: 0.25rem 0.5rem; + font-size: var(--font-ui-small); +} + +.timekeep-suggestion[aria-selected="true"] { + background-color: var(--color-base-40); +} diff --git a/src/timekeep/__fixtures__/names/flatNames.ts b/src/timekeep/__fixtures__/names/flatNames.ts new file mode 100644 index 0000000..48f208d --- /dev/null +++ b/src/timekeep/__fixtures__/names/flatNames.ts @@ -0,0 +1,20 @@ +import { TimeEntry } from "@/timekeep/schema"; + +export const input: TimeEntry[] = [ + { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Part 1", + startTime: null, + endTime: null, + subEntries: null, + }, + { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Part 2", + startTime: null, + endTime: null, + subEntries: null, + }, +]; + +export const expected: string[] = ["Part 1", "Part 2"].sort(); diff --git a/src/timekeep/__fixtures__/names/nestedNames.ts b/src/timekeep/__fixtures__/names/nestedNames.ts new file mode 100644 index 0000000..d5aea93 --- /dev/null +++ b/src/timekeep/__fixtures__/names/nestedNames.ts @@ -0,0 +1,57 @@ +import { TimeEntry } from "@/timekeep/schema"; + +export const input: TimeEntry[] = [ + { + id: "9054dee3-8c15-493b-ad31-f070e08c2699", + name: "Part 1", + startTime: null, + endTime: null, + subEntries: null, + }, + { + id: "d25ab2f5-00ac-4c89-9168-6062f7e01688", + name: "Part 2", + startTime: null, + endTime: null, + subEntries: [ + { + id: "8fec44f0-9d5d-4b83-b960-63f73ec2d29d", + name: "Part 3", + startTime: null, + endTime: null, + subEntries: null, + }, + { + id: "4a806a42-7463-4bbc-82e8-4ef193ed5bc6", + name: "Part 4", + startTime: null, + endTime: null, + subEntries: [ + { + id: "609c792d-eebc-42bb-85a7-1614b5b0a792", + name: "Part 5", + startTime: null, + endTime: null, + subEntries: null, + }, + { + id: "7c7955f5-4363-4cc1-93a5-9f654ffc8767", + name: "Part 6", + startTime: null, + endTime: null, + subEntries: null, + }, + ], + }, + ], + }, +]; + +export const expected: string[] = [ + "Part 1", + "Part 2", + "Part 3", + "Part 4", + "Part 5", + "Part 6", +].sort(); diff --git a/src/timekeep/queries.test.ts b/src/timekeep/queries.test.ts index 9dbd939..b4d15e7 100644 --- a/src/timekeep/queries.test.ts +++ b/src/timekeep/queries.test.ts @@ -8,7 +8,9 @@ import { getRunningEntry, getEntryDuration, getTotalDuration, + getEntriesNames, } from "./queries"; +import { TimeEntry } from "./schema"; describe("getEntryById", () => { it("find top level entry", async () => { @@ -163,3 +165,41 @@ describe("getTotalDuration", () => { expect(output).toBe(expected); }); }); + +describe("getEntriesNames", () => { + it("empty list should return no names", () => { + const input: TimeEntry[] = []; + const expected: string[] = []; + + const output = new Set(); + + getEntriesNames(input, output); + + // Sort output for consistent result + const outputSet = Array.from(output).sort(); + expect(outputSet).toEqual(expected); + }); + + it("should return all names from a flat list", async () => { + const { input, expected } = await import("./__fixtures__/names/flatNames"); + + const output = new Set(); + + getEntriesNames(input, output); + + // Sort output for consistent result + const outputSet = Array.from(output).sort(); + expect(outputSet).toEqual(expected); + }); + + it("should return all names including names from nested entries", async () => { + const { input, expected } = await import("./__fixtures__/names/nestedNames"); + + const output = new Set(); + getEntriesNames(input, output); + + // Sort output for consistent result + const outputSet = Array.from(output).sort(); + expect(outputSet).toEqual(expected); + }); +}); diff --git a/src/timekeep/queries.ts b/src/timekeep/queries.ts index bfa8996..62f42ca 100644 --- a/src/timekeep/queries.ts +++ b/src/timekeep/queries.ts @@ -193,3 +193,25 @@ export function getStartTime(entry: TimeEntry, newest: boolean): Moment | null { return entry.startTime; } + +/** + * Collect the names of all entries within a collection + * of entries and any nested entries + * + * @param entries The entries to extract names from + * @param names The set to store collected names in + * @returns The list of names + */ +export function getEntriesNames(entries: TimeEntry[], names: Set) { + const stack: TimeEntry[] = [...entries]; + + while (stack.length > 0) { + const entry: TimeEntry = stack.pop()!; + + names.add(entry.name); + + if (entry.subEntries !== null) { + stack.push(...entry.subEntries); + } + } +} diff --git a/src/utils/number.test.ts b/src/utils/number.test.ts new file mode 100644 index 0000000..eea57f3 --- /dev/null +++ b/src/utils/number.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; + +import { isNumberText } from "./number"; + +describe("isNumberText", () => { + it("empty string should not be treated as number text", () => { + const input = ""; + const expected = false; + + const output = isNumberText(input); + expect(output).toEqual(expected); + }); + + it("string with words should not be treated as number text", () => { + const input = "Test"; + const expected = false; + + const output = isNumberText(input); + expect(output).toEqual(expected); + }); + + it("string with words and numbers should not be treated as number text", () => { + const input = "Test 123"; + const expected = false; + + const output = isNumberText(input); + expect(output).toEqual(expected); + }); + + it("string starting with numbers and ending with words should not be treated as number text", () => { + const input = "123 Test"; + const expected = false; + + const output = isNumberText(input); + expect(output).toEqual(expected); + }); + + it("string only containing numbers should be treated as number text", () => { + const input = "123"; + const expected = true; + + const output = isNumberText(input); + expect(output).toEqual(expected); + }); +}); diff --git a/src/utils/number.ts b/src/utils/number.ts new file mode 100644 index 0000000..5473263 --- /dev/null +++ b/src/utils/number.ts @@ -0,0 +1,10 @@ +/** + * Checks if the provided string value is made up of + * only numbers + * + * @param value The value to check + * @returns Whether the value is only numbers + */ +export function isNumberText(value: string): boolean { + return /^\d+$/.test(value); +} diff --git a/src/views/timekeep-markdown-view.ts b/src/views/timekeep-markdown-view.ts index add6fcb..8d04d3c 100644 --- a/src/views/timekeep-markdown-view.ts +++ b/src/views/timekeep-markdown-view.ts @@ -5,6 +5,7 @@ import { TFile, TAbstractFile, MarkdownRenderChild, MarkdownPostProcessorContext import { Timesheet } from "@/components/timesheet"; import { TimesheetLoadError } from "@/components/timesheetLoadError"; import { CustomOutputFormat } from "@/output"; +import { TimekeepAutocomplete } from "@/service/autocomplete"; import { TimekeepSettings } from "@/settings"; import { Store, createStore } from "@/store"; import { LoadResult, replaceTimekeepCodeblock } from "@/timekeep/parser"; @@ -23,6 +24,8 @@ export class TimekeepMarkdownView extends MarkdownRenderChild { loadResult: LoadResult; /** The rendered timesheet component */ timesheet: Timesheet | TimesheetLoadError | undefined; + /** Autocomplete */ + autocomplete: TimekeepAutocomplete; // Path to the file the timekeep is within fileSourcePath: string; @@ -32,6 +35,7 @@ export class TimekeepMarkdownView extends MarkdownRenderChild { app: ObsidianApp, settingsStore: Store, customOutputFormats: Store>, + autocomplete: TimekeepAutocomplete, context: MarkdownPostProcessorContext, loadResult: LoadResult ) { @@ -39,6 +43,7 @@ export class TimekeepMarkdownView extends MarkdownRenderChild { this.app = app; this.settingsStore = settingsStore; this.customOutputFormats = customOutputFormats; + this.autocomplete = autocomplete; this.context = context; this.loadResult = loadResult; @@ -84,12 +89,12 @@ export class TimekeepMarkdownView extends MarkdownRenderChild { const timesheet = new Timesheet( this.containerEl, - this.app, timekeepStore, saveErrorStore, this.settingsStore, this.customOutputFormats, + this.autocomplete, handleSaveTimekeep ); diff --git a/src/views/timekeep-status-bar-view.ts b/src/views/timekeep-status-bar-view.ts index bc37ed6..c69b497 100644 --- a/src/views/timekeep-status-bar-view.ts +++ b/src/views/timekeep-status-bar-view.ts @@ -1,14 +1,12 @@ -import { App, MarkdownView } from "obsidian"; +import { App, Component, MarkdownView } from "obsidian"; import { TimesheetStatusBarItem } from "@/components/timesheetStatusBarItem"; import { TimekeepRegistry, TimekeepRegistryEntry } from "@/service/registry"; -import { TimekeepSettings } from "@/settings"; -import { Store } from "@/store"; import { getRunningEntry } from "@/timekeep"; import { TimekeepWithPosition } from "@/timekeep/parser"; import { TimeEntry } from "@/timekeep/schema"; -export class TimekeepStatusBarView { +export class TimekeepStatusBarView extends Component { /** Parent container element */ #containerEl: HTMLElement; @@ -16,34 +14,36 @@ export class TimekeepStatusBarView { app: App; /** Access to the app registry */ registry: TimekeepRegistry; - /** Access to the timekeep settings */ - settings: Store; /** Currently rendered items */ items: TimesheetStatusBarItem[] = []; - constructor( - containerEl: HTMLElement, - app: App, - registry: TimekeepRegistry, - settings: Store - ) { + constructor(containerEl: HTMLElement, app: App, registry: TimekeepRegistry) { + super(); + this.#containerEl = containerEl; this.app = app; this.registry = registry; - this.settings = settings; - - // Render the initial state - this.render(this.registry.entries.getState()); - - // Subscribe to changes to re-render - this.registry.entries.subscribe(() => { - this.render(this.registry.entries.getState()); - }); } - render(entries: TimekeepRegistryEntry[]) { + onload(): void { + super.onload(); + + const render = this.render.bind(this); + const unsubscribe = this.registry.entries.subscribe(render); + this.register(unsubscribe); + render(); + } + + onunload(): void { + super.onunload(); + this.#containerEl?.remove(); + } + + render() { + const entries = this.registry.entries.getState(); + // Unload the current children for (const item of this.items) { item.unload(); diff --git a/test-vault/Obsidian Timekeep.md b/test-vault/Obsidian Timekeep.md index 01a6689..10a24fe 100644 --- a/test-vault/Obsidian Timekeep.md +++ b/test-vault/Obsidian Timekeep.md @@ -29,36 +29,6 @@ dv.span(totalRunningDuration); {"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,"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 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":"Block 89","startTime":"2026-03-28T04:16:49.497Z","endTime":"2026-03-28T04:21:38.831Z","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},{"name":"Block 7","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2026-03-28T04:21:48.393Z","endTime":"2026-03-28T04:22:05.906Z","subEntries":null},{"name":"Part 2","startTime":"2026-03-28T04:27:18.175Z","endTime":"2026-03-28T04:27:19.172Z","subEntries":null}]},{"name":"Block 8","startTime":"2026-03-28T04:22:53.600Z","endTime":"2026-03-28T04:27:17.500Z","subEntries":null}]} ```