refactor: replace react with obsidian components

This commit is contained in:
Jacobtread 2026-03-26 12:13:06 +13:00
parent f6f5ebb342
commit 7ba6ffa92e
46 changed files with 2706 additions and 1807 deletions

View file

@ -11,7 +11,6 @@
This plugin provides a simple and easy way to track time spent on various tasks. After tracking your time, you can export the tracked time as a **Markdown Table**, **CSV**, **JSON**, or **PDF**.
![Tracker](images/tracker.png)
This plugin provides a command for inserting time trackers: `Timekeep: Insert Tracker`. Alternatively, a timekeep can be created by creating a codeblock like the following:
@ -37,23 +36,29 @@ The time block start and stop times are stored as timestamps, making it possible
Below is an example of how this is stored:
```json
{"entries":[{"name":"Example Time Block","startTime":"2024-03-17T06:32:36.118Z","endTime":"2024-03-17T06:32:37.012Z","subEntries":null}]}
{
"entries": [
{
"name": "Example Time Block",
"startTime": "2024-03-17T06:32:36.118Z",
"endTime": "2024-03-17T06:32:37.012Z",
"subEntries": null
}
]
}
```
## 📝 Export Formats
Below are the various formats that timekeeping data can be exported to:
### Markdown Table
| Block | Start time | End time | Duration |
| ------------------ | ----------------- | ----------------- | -------- |
| Example Time Block | 24-03-17 19:32:36 | 24-03-17 19:32:37 | 0s |
| **Total** | | | **0s** |
```md
| Block | Start time | End time | Duration |
| ------------------ | ----------------- | ----------------- | -------- |
@ -71,18 +76,26 @@ Example Time Block,24-03-17 19:32:36,24-03-17 19:32:37,0s
> [!NOTE]
> In the plugin settings, you can choose to omit the first line of the CSV containing the column names:
### JSON
The JSON export format simply copies the JSON stored inside the timekeep:
```json
{"entries":[{"name":"Example Time Block","startTime":"2024-03-17T06:32:36.118Z","endTime":"2024-03-17T06:32:37.012Z","subEntries":null}]}
{
"entries": [
{
"name": "Example Time Block",
"startTime": "2024-03-17T06:32:36.118Z",
"endTime": "2024-03-17T06:32:37.012Z",
"subEntries": null
}
]
}
```
### Generated PDFs
Below is an example of a PDF generated by Timekeep. These PDFs are generated using react-pdf locally.
Below is an example of a PDF generated by Timekeep. These PDFs are generated using pdfmake locally.
![Generated PDF](images/pdf.png)
@ -91,12 +104,21 @@ Below is an example of a PDF generated by Timekeep. These PDFs are generated usi
## 🔣 Using with templates
If you would like to create a timekeep through a template plugin, you can do so by using the JSON for a timekeep directly.
If you would like to create a timekeep through a template plugin, you can do so by using the JSON for a timekeep directly.
If you have frequently used entry names you can define them in your template by specifying `null` for both the `startTime` and `endTime`:
```json
{"entries":[{"name":"Non started block","startTime":null,"endTime":null,"subEntries":null}]}
{
"entries": [
{
"name": "Non started block",
"startTime": null,
"endTime": null,
"subEntries": null
}
]
}
```
This will create an entry that is not yet started; you can start it by clicking the play button without having to type out the name.
@ -121,10 +143,10 @@ Below is a Dataview example for showing the total elapsed time for all timekeeps
```dataviewjs
// Get the currently open file
const activeFile = this.app.workspace.getActiveFile();
if(!activeFile || !activeFile.name) return;
if(!activeFile || !activeFile.name) return;
// Read the file
const text = await this.app.vault.read(activeFile);
const text = await this.app.vault.read(activeFile);
// Get the timekeep plugin API
const timekeepPlugin = this.app.plugins.plugins.timekeep;
@ -146,19 +168,16 @@ dv.span(totalRunningDuration);
```
````
## Known issues
### Jumpy rendering behavior on modification
If your lists become longer you will likely see some jumpy/flickery behavior with timekeep when making modifications (add/save/delete/collapse/expand), this is a limitation of how Obsidian re-renders the app.
I am unable to take advantage of the React virtual DOM to only update the changed DOM elements for the entries, because Obsidian re-creates the entire app when the code block changes (Since the timekeep data is stored in the codeblock, modifications cause this to happen. Thus the virtual DOM and real DOM are thrown away causing a full re-render). This issue also means local state will all be lost on modification (This is why the collapsed state must be persisted to the timekeep.)
Because Obsidian re-creates the entire app when the code block changes (Since the timekeep data is stored in the codeblock, modifications cause this to happen. Thus the DOM is thrown away causing a full re-render). This issue also means local state will all be lost on modification (This is why the collapsed state must be persisted to the timekeep.)
I do not believe this can be fixed but PRs are welcome if you are aware of a way to fix this.
I do not believe this can be fixed but PRs are welcome if you are aware of a way to fix this.
## 📄 License
This project is licensed under the [MIT License](./LICENSE.md)
This project is licensed under the [MIT License](./LICENSE.md)

View file

@ -1,12 +1,11 @@
const { pathsToModuleNameMapper, JestConfigWithTsJest } = require("ts-jest");
const { compilerOptions } = require("./tsconfig.json");
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
preset: "ts-jest",
testEnvironment: "node",
moduleFileExtensions: ["ts", "js", "json", "node"],
moduleDirectories: ["node_modules", "<rootDir>"],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths)
};
extensionsToTreatAsEsm: [".ts"],
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
};

58
package-lock.json generated
View file

@ -11,8 +11,6 @@
"dependencies": {
"moment": "^2.30.1",
"pdfmake": "^0.3.7",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"uuid": "^11.0.5",
"zod": "^4.3.6"
},
@ -20,8 +18,6 @@
"@types/jest": "^30.0.0",
"@types/node": "^16.11.6",
"@types/pdfmake": "^0.3.2",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"builtin-modules": "^3.3.0",
@ -1959,26 +1955,6 @@
"@types/pdfkit": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"dev": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
@ -3157,13 +3133,6 @@
"node": ">= 8"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"dev": true,
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -5537,33 +5506,6 @@
],
"license": "MIT"
},
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.4"
}
},
"node_modules/react-dom/node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT"
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",

View file

@ -18,8 +18,6 @@
"@types/jest": "^30.0.0",
"@types/node": "^16.11.6",
"@types/pdfmake": "^0.3.2",
"@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "8.57.2",
"@typescript-eslint/parser": "8.57.2",
"builtin-modules": "^3.3.0",
@ -36,8 +34,6 @@
"dependencies": {
"moment": "^2.30.1",
"pdfmake": "^0.3.7",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"uuid": "^11.0.5",
"zod": "^4.3.6"
}

View file

@ -1,70 +0,0 @@
import React from "react";
import { Store, useStore } from "@/store";
import { Timekeep } from "@/timekeep/schema";
import { App as ObsidianApp } from "obsidian";
import { TimekeepSettings } from "@/settings";
import { CustomOutputFormat } from "@/output";
import { AppContext } from "@/contexts/use-app-context";
import TimesheetStart from "@/components/TimesheetStart";
import TimesheetTable from "@/components/TimesheetTable";
import TimesheetCounters from "@/components/TimesheetCounters";
import TimesheetSaveError from "@/components/TimesheetSaveError";
import { SettingsContext } from "@/contexts/use-settings-context";
import { TimekeepStoreContext } from "@/contexts/use-timekeep-store";
import TimesheetExportActions from "@/components/TimesheetExportActions";
export type AppProps = {
// Obsidian app for creating modals
app: ObsidianApp;
// Timekeep state store
timekeepStore: Store<Timekeep>;
// Store for save error state
saveErrorStore: Store<boolean>;
// Timekeep settings store
settingsStore: Store<TimekeepSettings>;
// Custom output formats store
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
// Callback to save the timekeep
handleSaveTimekeep: (value: Timekeep) => Promise<void>;
};
/**
* Main app component, handles managing the app state and
* providing the contexts.
*/
export default function App({
app,
timekeepStore,
saveErrorStore,
settingsStore,
customOutputFormats,
handleSaveTimekeep,
}: AppProps) {
const settings = useStore(settingsStore);
const customOutputFormatsState = useStore(customOutputFormats);
const saveError = useStore(saveErrorStore);
return (
<AppContext.Provider value={app}>
<SettingsContext.Provider value={settings}>
<TimekeepStoreContext.Provider value={timekeepStore}>
{saveError ? (
// Error page when saving fails
<TimesheetSaveError
handleSaveTimekeep={handleSaveTimekeep}
/>
) : (
<div className="timekeep-container">
<TimesheetCounters />
<TimesheetStart />
<TimesheetTable />
<TimesheetExportActions
customOutputFormats={customOutputFormatsState}
/>
</div>
)}
</TimekeepStoreContext.Provider>
</SettingsContext.Provider>
</AppContext.Provider>
);
}

View file

@ -1,49 +0,0 @@
import { setIcon } from "obsidian";
import React, { useRef, useEffect, DOMAttributes } from "react";
type Props = {
// Obsidian icon
icon: string;
// Class name to apply to the icon
className?: string;
} & DOMAttributes<HTMLDivElement>;
/**
* Wrapper around the Obsidian setIcon function to use
* built-in lucide icons from Obsidian
*/
export default function ObsidianIcon({ icon, className, ...props }: Props) {
const wrapperRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const wrapper = wrapperRef.current;
if (wrapper === null) return;
setIcon(wrapper, icon);
// Get the created icon child element
const firstChild = wrapper.firstElementChild;
// Assign the custom class if available
if (firstChild && className) firstChild.addClass(className);
return () => {
// Clear the icon element
wrapper.innerHTML = "";
};
}, [icon, className, wrapperRef.current]);
return (
<div
{...props}
style={{
display: "inline-block",
lineHeight: 1,
padding: 0,
margin: 0,
}}
ref={wrapperRef}
/>
);
}

View file

@ -1,66 +0,0 @@
import React, { MouseEvent } from "react";
import { useApp } from "@/contexts/use-app-context";
import {
NameSegmentLink,
NameSegmentText,
NameSegmentType,
parseNameSegments,
} from "@/utils/name";
type Props = {
name: string;
};
export default function TimekeepName({ name }: Props) {
const segments = parseNameSegments(name);
return (
<>
{segments.map((segment, index) => {
switch (segment.type) {
case NameSegmentType.Text:
return (
<TimekeepNameText key={index} segment={segment} />
);
case NameSegmentType.Link:
return (
<TimekeepNameLink key={index} segment={segment} />
);
}
})}
</>
);
}
function TimekeepNameText({ segment }: { segment: NameSegmentText }) {
return <span>{segment.text}</span>;
}
function TimekeepNameLink({ segment }: { segment: NameSegmentLink }) {
const app = useApp();
const link = segment.url;
const onOpenLink = (event: MouseEvent) => {
// Allow default behavior for external links
if (link.startsWith("http://") || link.startsWith("https://")) {
return;
}
// Prevent the parent collapse toggle click and default behavior
event.stopPropagation();
event.preventDefault();
const activeFile = app.workspace.getActiveFile();
if (activeFile === null) return;
// Open internal link
app.workspace.openLinkText(link, activeFile.path);
};
return (
<a href={link} onClick={onOpenLink}>
{segment.text}
</a>
);
}

View file

@ -1,109 +0,0 @@
import moment from "moment";
import { useStore } from "@/store";
import { formatDuration } from "@/utils";
import { Timekeep } from "@/timekeep/schema";
import { TimekeepSettings } from "@/settings";
import React, { useState, useEffect } from "react";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";
import {
isKeepRunning,
getRunningEntry,
getTotalDuration,
getEntryDuration,
} from "@/timekeep";
type TimingState = {
running: boolean;
currentPrimary: string;
currentSecondary: string;
totalPrimary: string;
totalSecondary: string;
};
/**
* Gets the timing state for the provided timekeep
*
* @param timekeep The timekeep to get the state for
* @returns The timing state
*/
function getTimingState(
timekeep: Timekeep,
settings: TimekeepSettings
): TimingState {
const currentTime = moment();
const total = getTotalDuration(timekeep.entries, currentTime);
const runningEntry = getRunningEntry(timekeep.entries);
const current = runningEntry
? getEntryDuration(runningEntry, currentTime)
: 0;
return {
running: runningEntry !== null,
currentPrimary: formatDuration(settings.primaryDurationFormat, current),
currentSecondary: formatDuration(
settings.secondaryDurationFormat,
current
),
totalPrimary: formatDuration(settings.primaryDurationFormat, total),
totalSecondary: formatDuration(settings.secondaryDurationFormat, total),
};
}
export default function TimesheetCounters() {
const settings = useSettings();
const timekeepStore = useTimekeepStore();
const timekeep = useStore(timekeepStore);
const [timing, setTiming] = useState<TimingState>(
getTimingState(timekeep, settings)
);
// Update the current timings every second
useEffect(() => {
const updateTiming = () =>
setTiming(getTimingState(timekeep, settings));
// Initial update
updateTiming();
// Only schedule further updates if we are running
if (isKeepRunning(timekeep)) {
const intervalID = window.setInterval(updateTiming, 1000);
return () => {
clearInterval(intervalID);
};
}
}, [timekeep, settings]);
return (
<div className="timekeep-timers">
{timing.running && (
<div className="timekeep-timer">
<span className="timekeep-timer-value">
{timing.currentPrimary}
</span>
{timing.currentSecondary !== "" && (
<span className="timekeep-timer-value-small">
{timing.currentSecondary}
</span>
)}
<span>Current</span>
</div>
)}
<div className="timekeep-timer">
<span className="timekeep-timer-value">
{timing.totalPrimary}
</span>
{timing.totalSecondary !== "" && (
<span className="timekeep-timer-value-small">
{timing.totalSecondary}
</span>
)}
<span>Total</span>
</div>
</div>
);
}

View file

@ -1,86 +0,0 @@
import React from "react";
import moment from "moment";
import { Notice } from "obsidian";
import { Platform } from "obsidian";
import { exportPdf } from "@/export/pdf";
import { CustomOutputFormat } from "@/output";
import { createCSV, createMarkdownTable } from "@/export";
import { stripTimekeepRuntimeData } from "@/timekeep/schema";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";
type Props = {
/**
* Additional custom output formats
*/
customOutputFormats: Record<string, CustomOutputFormat>;
};
export default function TimekeepExportActions({ customOutputFormats }: Props) {
const settings = useSettings();
const timekeepStore = useTimekeepStore();
const onCopyMarkdown = () => {
const timekeep = timekeepStore.getState();
const currentTime = moment();
const output = createMarkdownTable(timekeep, settings, currentTime);
navigator.clipboard
.writeText(output)
.then(() => new Notice("Copied markdown to clipboard", 1500))
.catch((error) => console.error("Failed to copy export", error));
};
const onCopyCSV = () => {
const timekeep = timekeepStore.getState();
const currentTime = moment();
const output = createCSV(timekeep, settings, currentTime);
navigator.clipboard
.writeText(output)
.then(() => new Notice("Copied CSV to clipboard", 1500))
.catch((error) => console.error("Failed to copy export", error));
};
const onCopyJSON = () => {
const timekeep = timekeepStore.getState();
const output = JSON.stringify(
stripTimekeepRuntimeData(timekeep),
undefined,
settings.formatCopiedJSON ? 4 : undefined
);
navigator.clipboard
.writeText(output)
.then(() => new Notice("Copied JSON to clipboard", 1500))
.catch((error) => console.error("Failed to copy export", error));
};
const onSavePDF = async () => {
const timekeep = timekeepStore.getState();
exportPdf(timekeep, settings);
};
return (
<div className="timekeep-actions">
<button onClick={onCopyMarkdown}>Copy Markdown</button>
<button onClick={onCopyCSV}>Copy CSV</button>
<button onClick={onCopyJSON}>Copy JSON</button>
{!Platform.isMobileApp && (
<button onClick={onSavePDF}>Save PDF</button>
)}
{Object.entries(customOutputFormats).map(([key, outputFormat]) => (
<button
key={key}
onClick={() => {
const timekeep = timekeepStore.getState();
const currentTime = moment();
outputFormat.onExport(timekeep, settings, currentTime);
}}>
{outputFormat.getButtonLabel()}
</button>
))}
</div>
);
}

View file

@ -1,147 +0,0 @@
import moment from "moment";
import { TimeEntry } from "@/timekeep/schema";
import React, { useMemo, useState } from "react";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";
import TimesheetRowEditing from "@/components/TimesheetRowEditing";
import TimesheetRowDuration from "@/components/TimesheetRowDuration";
import {
updateEntry,
isEntryRunning,
getRunningEntry,
setEntryCollapsed,
startNewNestedEntry,
} from "@/timekeep";
import { formatTimestamp } from "src/utils";
import ObsidianIcon from "./ObsidianIcon";
import TimekeepName from "./TimekeepName";
type Props = {
entry: TimeEntry;
indent: number;
};
export default function TimesheetRow({ entry, indent }: Props) {
const settings = useSettings();
const timekeepStore = useTimekeepStore();
const [editing, setEditing] = useState(false);
const { isSelfRunning, isRunningWithin, isInvalidEntry } = useMemo(() => {
const isSelfRunning =
entry.subEntries === null && isEntryRunning(entry);
const isRunningWithin =
entry.subEntries !== null &&
getRunningEntry(entry.subEntries) !== null;
const isInvalidEntry =
entry.startTime !== null &&
entry.endTime !== null &&
entry.endTime.isBefore(entry.startTime);
return { isSelfRunning, isRunningWithin, isInvalidEntry };
}, [entry]);
const onClickStart = () => {
timekeepStore.setState((timekeep) => {
const currentTime = moment();
const entries = startNewNestedEntry(
currentTime,
entry.id,
timekeep.entries
);
return {
...timekeep,
entries,
};
});
};
// Handles toggling the collapsed state for an entry
const handleToggleCollapsed = () => {
if (entry.subEntries === null) return;
timekeepStore.setState((timekeep) => {
const newEntry = setEntryCollapsed(entry, !entry.collapsed);
const entries = updateEntry(timekeep.entries, entry.id, newEntry);
return { ...timekeep, entries };
});
};
if (editing) {
return (
<TimesheetRowEditing
entry={entry}
onFinishEditing={() => setEditing(false)}
/>
);
}
return (
<tr
className="timekeep-row"
data-running={isSelfRunning}
data-running-within={isRunningWithin}
data-sub-entires={entry.subEntries !== null}
data-invalid={isInvalidEntry}>
<td
className="timekeep-col timekeep-col--name"
style={{ paddingLeft: `${(indent + 1) * 15}px` }}>
<span
className="timekeep-entry-name"
title={entry.name}
onClick={handleToggleCollapsed}>
{entry.subEntries !== null && entry.folder && (
<ObsidianIcon
icon="folder"
className="timekeep-folder-icon"
/>
)}
<TimekeepName name={entry.name} />
{entry.subEntries !== null && (
<ObsidianIcon
icon={
entry.collapsed ? "chevron-down" : "chevron-up"
}
className="timekeep-collapse-icon"
/>
)}
</span>
</td>
<td className="timekeep-col timekeep-col--time">
{entry.startTime && (
<span className="timekeep-time">
{formatTimestamp(entry.startTime, settings)}
</span>
)}
</td>
<td className="timekeep-col timekeep-col--time">
{entry.endTime && (
<span className="timekeep-time">
{formatTimestamp(entry.endTime, settings)}
</span>
)}
</td>
<td className="timekeep-col timekeep-col--duration">
<TimesheetRowDuration entry={entry} />
</td>
<td className="timekeep-col timekeep-col--actions">
<div className="timekeep-actions-wrapper">
<button onClick={onClickStart} className="timekeep-action">
<ObsidianIcon icon="play" className="button-icon" />
</button>
<button
onClick={() => setEditing(true)}
className="timekeep-action">
<ObsidianIcon icon="edit" className="button-icon" />
</button>
</div>
</td>
</tr>
);
}

View file

@ -1,50 +0,0 @@
import moment from "moment";
import { formatDurationLong } from "@/utils";
import { TimeEntry } from "@/timekeep/schema";
import React, { useState, useEffect } from "react";
import { isEntryRunning, getEntryDuration } from "@/timekeep";
type Props = {
entry: TimeEntry;
};
/**
* Component for rendering the duration of a timesheet row, this is separate from
* the row because actively running durations will update every second to get the
* latest duration.
*/
export default function TimesheetRowDuration({ entry }: Props) {
const [duration, setDuration] = useState(getFormattedDuration(entry));
useEffect(() => {
const isRunning = isEntryRunning(entry);
const updateTiming = () => setDuration(getFormattedDuration(entry));
// Initial update
updateTiming();
// Only schedule further updates if we are running
if (isRunning) {
// Update the current timings every second
const intervalID = window.setInterval(updateTiming, 1000);
return () => {
clearInterval(intervalID);
};
}
}, [entry]);
return <span className="timekeep-time">{duration}</span>;
}
/**
* Obtains the formatted duration string for an entry
*
* @param entry The entry
* @returns The formatted duration
*/
function getFormattedDuration(entry: TimeEntry): string {
const currentTime = moment();
const duration = getEntryDuration(entry, currentTime);
return formatDurationLong(duration);
}

View file

@ -1,160 +0,0 @@
import { TimeEntry } from "@/timekeep/schema";
import { useDialog } from "@/contexts/use-dialog";
import { removeEntry, updateEntry } from "@/timekeep";
import { useSettings } from "@/contexts/use-settings-context";
import React, { useState, useEffect, FormEvent } from "react";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";
import { parseEditableTimestamp, formatEditableTimestamp } from "@/utils";
import ObsidianIcon from "./ObsidianIcon";
type Props = {
entry: TimeEntry;
onFinishEditing: VoidFunction;
};
export default function TimesheetRowEditing({ entry, onFinishEditing }: Props) {
const settings = useSettings();
const { showConfirm } = useDialog();
const timekeepStore = useTimekeepStore();
const [name, setName] = useState(entry.name);
const [startTime, setStartTime] = useState("");
const [endTime, setEndTime] = useState("");
useEffect(() => {
setName(entry.name);
setStartTime(
entry.startTime
? formatEditableTimestamp(entry.startTime, settings)
: ""
);
setEndTime(
entry.endTime
? formatEditableTimestamp(entry.endTime, settings)
: ""
);
}, [entry]);
const onClickDelete = async () => {
const confirmDelete = await showConfirm(
"Confirm Delete",
"Are you sure you want to delete this entry?"
);
if (!confirmDelete) {
return;
}
timekeepStore.setState((timekeep) => ({
entries: removeEntry(timekeep.entries, entry),
}));
};
const onSubmit = (event: FormEvent) => {
event.preventDefault();
event.stopPropagation();
const newEntry = { ...entry, name };
// Update the start and end times for non groups
if (newEntry.subEntries === null) {
if (entry.startTime !== null) {
const startTimeValue = parseEditableTimestamp(
startTime,
settings
);
if (startTimeValue.isValid()) {
newEntry.startTime = startTimeValue;
}
}
if (entry.endTime !== null) {
const endTimeValue = parseEditableTimestamp(endTime, settings);
if (endTimeValue.isValid()) {
newEntry.endTime = endTimeValue;
}
}
}
// Save the updated entry
timekeepStore.setState((timekeep) => ({
entries: updateEntry(timekeep.entries, entry.id, newEntry),
}));
onFinishEditing();
};
return (
<tr>
<td colSpan={5}>
<form className="timesheet-editing" onSubmitCapture={onSubmit}>
<label>
Name
<input
className="timekeep-input"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</label>
{entry.startTime && (
<label>
Start Time
<input
className="timekeep-input"
type="text"
value={startTime}
onChange={(event) =>
setStartTime(event.target.value)
}
/>
</label>
)}
{entry.endTime && (
<label>
End Time
<input
className="timekeep-input"
type="text"
value={endTime}
onChange={(event) =>
setEndTime(event.target.value)
}
/>
</label>
)}
<div className="timesheet-editing-actions">
<button type="submit" className="timekeep-action">
<ObsidianIcon
icon="edit"
className="text-button-icon"
/>
Save
</button>
<button
type="button"
onClick={onFinishEditing}
className="timekeep-action">
<ObsidianIcon
icon="x"
className="text-button-icon"
/>
Cancel
</button>
<button
type="button"
onClick={onClickDelete}
className="timekeep-action timekeep-action--end">
<ObsidianIcon
icon="trash"
className="text-button-icon"
/>
Delete
</button>
</div>
</form>
</td>
</tr>
);
}

View file

@ -1,35 +0,0 @@
import { TimeEntry } from "@/timekeep/schema";
import React, { useMemo, Fragment } from "react";
import TimesheetRow from "@/components/TimesheetRow";
import { useSettings } from "@/contexts/use-settings-context";
import { getEntriesSorted, getUniqueEntryHash } from "@/timekeep";
type Props = {
// Collection of entries
entries: TimeEntry[];
// Indentation for the entries
indent: number;
};
/**
* Renders a collection of timesheet entries ordered based
* on the current settings
*/
export default function TimesheetRows({ entries, indent }: Props) {
const settings = useSettings();
// Memoized sub entries
const entriesOrdered = useMemo(
() => getEntriesSorted(entries, settings),
[entries, settings]
);
return entriesOrdered.map((entry) => (
<Fragment key={getUniqueEntryHash(entry)}>
<TimesheetRow entry={entry} indent={indent} />
{entry.subEntries !== null && !entry.collapsed && (
<TimesheetRows entries={entry.subEntries} indent={indent + 1} />
)}
</Fragment>
));
}

View file

@ -1,44 +0,0 @@
import React from "react";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";
import { Timekeep, stripTimekeepRuntimeData } from "@/timekeep/schema";
type Props = {
// Callback to save the timekeep
handleSaveTimekeep: (value: Timekeep) => Promise<void>;
};
export default function TimesheetSaveError({ handleSaveTimekeep }: Props) {
const timekeepStore = useTimekeepStore();
// Attempts to re-save the current timekeep
const onRetrySave = () => {
// Attempt to save the current timekeep
handleSaveTimekeep(timekeepStore.getState());
};
// Copies the current timekeep state as JSON to clipboard
const onClickCopy = () => {
navigator.clipboard.writeText(
JSON.stringify(stripTimekeepRuntimeData(timekeepStore.getState()))
);
};
return (
<div className="timekeep-container">
<div className="timekeep-error">
<h1>Warning</h1>
<p>Failed to save current timekeep</p>
<p>
Press "Retry" to try again or "Copy Timekeep" to copy a
backup to clipboard, an automated backup JSON file will be
generated in the root of this vault
</p>
</div>
<div className="timekeep-actions">
<button onClick={onRetrySave}>Retry</button>
<button onClick={onClickCopy}>Copy Timekeep</button>
</div>
</div>
);
}

View file

@ -1,247 +0,0 @@
import moment from "moment";
import { useStore } from "@/store";
import { formatTimestamp } from "@/utils";
import React, { useMemo, useState, FormEvent } from "react";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";
import {
updateEntry,
startNewEntry,
getPathToEntry,
getRunningEntry,
stopRunningEntries,
} from "@/timekeep";
import ObsidianIcon from "./ObsidianIcon";
/**
* Component for the timekeep start button and "name" field isolating
* them to prevent needing to re-render the table when typing out the
* name field
*/
export default function TimekeepStart() {
const store = useTimekeepStore();
const timekeep = useStore(store);
const settings = useSettings();
const [name, setName] = useState("");
const [editing, setEditing] = useState(false);
const [editingName, setEditingName] = useState("");
const { currentEntry, pathToEntry } = useMemo(() => {
const currentEntry = getRunningEntry(timekeep.entries);
const pathToEntry = currentEntry
? getPathToEntry(timekeep.entries, currentEntry)
: null;
return { currentEntry, pathToEntry };
}, [timekeep]);
const isTimekeepRunning = currentEntry !== null;
const onStart = (event: FormEvent) => {
// Prevent form submission from reloading Obsidian
event.preventDefault();
event.stopPropagation();
store.setState((timekeep) => {
const currentTime = moment();
const entries = startNewEntry(name, currentTime, timekeep.entries);
// Reset name input
setName("");
return {
...timekeep,
entries,
};
});
};
const onSave = (event: FormEvent) => {
// Prevent form submission from reloading Obsidian
event.preventDefault();
event.stopPropagation();
const entry = currentEntry;
if (!entry) return;
store.setState((timekeep) => {
const entries = updateEntry(timekeep.entries, entry.id, {
...entry,
name: editingName,
});
return {
...timekeep,
entries,
};
});
};
const onStop = (event: FormEvent) => {
// Prevent form submission from reloading Obsidian
event.preventDefault();
event.stopPropagation();
store.setState((timekeep) => {
const currentTime = moment();
return {
...timekeep,
entries: stopRunningEntries(timekeep.entries, currentTime),
};
});
};
return (
<div>
{editing ? (
/* Edit the name of the current entry */
<form
className="timekeep-start-area"
data-area="start"
onSubmitCapture={onSave}>
<div className="timekeep-name-wrapper">
<label htmlFor="timekeepBlockName">Edit Name:</label>
<input
id="timekeepBlockName"
className="timekeep-name"
placeholder="Example Block"
type="text"
value={editingName}
onChange={(event) =>
setEditingName(event.target.value)
}
/>
</div>
<button
type="submit"
title="Save"
className="timekeep-start timekeep-start--save">
<ObsidianIcon icon="save" className="button-icon" />
</button>
<button
type="button"
onClick={() => {
setEditing(false);
}}
title="Cancel"
className="timekeep-start timekeep-start--close">
<ObsidianIcon icon="x" className="button-icon" />
</button>
</form>
) : (
<>
{/* Currently running entry */}
{currentEntry !== null &&
currentEntry.startTime !== null && (
<form
className="timekeep-start-area"
data-area="running"
onSubmitCapture={onStop}>
<div className="active-entry timekeep-name-wrapper">
<span>
<b>Currently Running:</b>
</span>
<div className="active-entry__details">
<span className="active-entry__name">
<b>Name: </b>{" "}
{pathToEntry &&
pathToEntry.length > 0 && (
<span className="timekeep-path-to-entry">
{pathToEntry.map(
(path, index) => (
<span
key={
path.id
}>
{path.name}
{index <
pathToEntry.length -
1 &&
" >"}
</span>
)
)}
</span>
)}
</span>
<span className="active-entry__time">
<b>{"Started at: "}</b>
{formatTimestamp(
currentEntry.startTime,
settings
)}
</span>
</div>
</div>
<button
type="button"
onClick={() => {
setEditing(true);
setEditingName(currentEntry.name);
}}
className="timekeep-start timekeep-start--edit">
<ObsidianIcon
icon="edit"
className="button-icon"
/>
</button>
<button
type="submit"
title="Stop"
className="timekeep-start timekeep-start--stop">
<ObsidianIcon
icon="stop-circle"
className="button-icon"
/>
</button>
</form>
)}
</>
)}
{/* Start new entry */}
<form
className="timekeep-start-area"
data-area="start"
onSubmitCapture={onStart}>
<div className="timekeep-name-wrapper">
<label htmlFor="timekeepBlockName">
Block Name:
{currentEntry !== null &&
currentEntry.startTime !== null && (
<span className="timekeep-start-note">
Starting a new task will pause the previous
one
</span>
)}
</label>
<input
id="timekeepBlockName"
className="timekeep-name"
placeholder="Example Block"
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
/>
</div>
<button
type="submit"
title={isTimekeepRunning ? "Stop and start" : "Start"}
className="timekeep-start">
<ObsidianIcon icon="play" className="button-icon" />
</button>
</form>
</div>
);
}

View file

@ -1,35 +0,0 @@
import React from "react";
import { useStore } from "@/store";
import TimesheetRows from "@/components/TimesheetRows";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeepStore } from "@/contexts/use-timekeep-store";
export default function TimesheetTable() {
const store = useTimekeepStore();
const timekeep = useStore(store);
const settings = useSettings();
// Max size and scroll for table with "Limit table size" option
const limitedSize: React.CSSProperties = settings.limitTableSize
? { maxHeight: 600, overflowY: "auto" }
: {};
return (
<div style={limitedSize}>
<table className="timekeep-table">
<thead className="timekeep-table-head">
<tr>
<th>Block</th>
<th>Start time</th>
<th>End time</th>
<th>Duration</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<TimesheetRows entries={timekeep.entries} indent={0} />
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,25 @@
import { setIcon } from "obsidian";
export function createObsidianIcon(
containerEl: HTMLElement,
icon: string,
className?: string
): HTMLDivElement {
const wrapperEl = containerEl.createDiv();
wrapperEl.setCssStyles({
display: "inline-block",
lineHeight: "1",
padding: "0",
margin: "0",
});
setIcon(wrapperEl, icon);
// Get the created icon child element
const firstChild = wrapperEl.firstElementChild;
// Assign the custom class if available
if (firstChild && className) firstChild.addClass(className);
return wrapperEl;
}

View file

@ -0,0 +1,86 @@
import { CustomOutputFormat } from "@/output";
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { Timekeep } from "@/timekeep/schema";
import { App, Component } from "obsidian";
import { TimesheetApp } from "./timesheetApp";
import { TimesheetSaveError } from "./timesheetSaveError";
export class Timesheet extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Store for save error state */
saveError: Store<boolean>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Access to custom output formats */
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
/** Callback to save the timekeep */
handleSaveTimekeep: (value: Timekeep) => Promise<void>;
/** Current rendered app content */
content: TimesheetApp | TimesheetSaveError | undefined;
constructor(
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
saveError: Store<boolean>,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
handleSaveTimekeep: (value: Timekeep) => Promise<void>
) {
super();
this.#containerEl = containerEl;
this.app = app;
this.timekeep = timekeep;
this.saveError = saveError;
this.settings = settings;
this.customOutputFormats = customOutputFormats;
this.handleSaveTimekeep = handleSaveTimekeep;
}
onload(): void {
super.onload();
const render = this.render.bind(this);
const unsubscribeSaveError = this.saveError.subscribe(render);
this.register(unsubscribeSaveError);
render();
}
render() {
// Destroy the existing content
if (this.content) {
this.removeChild(this.content);
}
const saveError = this.saveError.getState();
if (saveError) {
this.content = new TimesheetSaveError(
this.#containerEl,
this.timekeep,
this.handleSaveTimekeep
);
} else {
this.content = new TimesheetApp(
this.#containerEl,
this.app,
this.timekeep,
this.settings,
this.customOutputFormats,
this.handleSaveTimekeep
);
}
this.addChild(this.content);
}
}

View file

@ -0,0 +1,95 @@
import { CustomOutputFormat } from "@/output";
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { Timekeep } from "@/timekeep/schema";
import { App, Component } from "obsidian";
import { TimesheetCounters } from "./timesheetCounters";
import { TimesheetStart } from "./timesheetStart";
import { TimesheetTable } from "./timesheetTable";
import { TimesheetExportActions } from "./timesheetExportActions";
export class TimesheetApp extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Access to custom output formats */
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
/** Callback to save the timekeep */
handleSaveTimekeep: (value: Timekeep) => Promise<void>;
/** Wrapper element containing the component content */
#wrapperEl: HTMLElement | undefined;
constructor(
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
handleSaveTimekeep: (value: Timekeep) => Promise<void>
) {
super();
this.#containerEl = containerEl;
this.app = app;
this.timekeep = timekeep;
this.settings = settings;
this.customOutputFormats = customOutputFormats;
this.handleSaveTimekeep = handleSaveTimekeep;
}
onunload(): void {
super.onunload();
this.#wrapperEl?.remove();
}
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 start = new TimesheetStart(
wrapperEl,
this.app,
this.timekeep,
this.settings
);
const table = new TimesheetTable(
wrapperEl,
this.app,
this.timekeep,
this.settings
);
const exportActions = new TimesheetExportActions(
wrapperEl,
this.timekeep,
this.settings,
this.customOutputFormats
);
this.addChild(counters);
this.addChild(start);
this.addChild(table);
this.addChild(exportActions);
}
}

View file

@ -0,0 +1,151 @@
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { Timekeep } from "@/timekeep/schema";
import { Component } from "obsidian";
import moment from "moment";
import {
getEntryDuration,
getRunningEntry,
getTotalDuration,
isKeepRunning,
} from "@/timekeep";
import { formatDuration } from "@/utils";
import { TimesheetTimer } from "./timesheetTimer";
export class TimesheetCounters extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Timer for the current entry */
currentTimer: TimesheetTimer;
/** Timer for the total time */
totalTimer: TimesheetTimer;
/** Currently tracked background interval for content */
currentContentInterval: number | undefined;
/** Wrapper container element */
#wrapperEl: HTMLElement | undefined;
constructor(
containerEl: HTMLElement,
settings: Store<TimekeepSettings>,
timekeep: Store<Timekeep>
) {
super();
this.#containerEl = containerEl;
this.settings = settings;
this.timekeep = timekeep;
}
onload(): void {
super.onload();
const wrapperEl = this.#containerEl.createDiv({
cls: "timekeep-timers",
});
this.#wrapperEl = wrapperEl;
this.currentTimer = new TimesheetTimer(wrapperEl, "Current");
this.totalTimer = new TimesheetTimer(wrapperEl, "Total");
this.addChild(this.currentTimer);
this.addChild(this.totalTimer);
const onContentUpdate = this.onContentUpdate.bind(this);
const unsubscribeTimekeep = this.timekeep.subscribe(onContentUpdate);
const unsubscribeSettings = this.settings.subscribe(onContentUpdate);
this.register(() => {
unsubscribeTimekeep();
unsubscribeSettings();
});
onContentUpdate();
}
onunload(): void {
super.onunload();
this.#wrapperEl?.remove();
}
onContentUpdate() {
// Initial update
this.updateTimers();
if (this.currentContentInterval) {
clearInterval(this.currentContentInterval);
}
// Only schedule further updates if we are running
const timekeep = this.timekeep.getState();
if (isKeepRunning(timekeep)) {
const intervalID = window.setInterval(
this.updateTimers.bind(this),
1000
);
this.currentContentInterval = intervalID;
this.registerInterval(intervalID);
}
}
updateTimers() {
const timekeep = this.timekeep.getState();
const settings = this.settings.getState();
const timing = getTimingState(timekeep, settings);
this.currentTimer.setHidden(!timing.running);
this.currentTimer.setValues(
timing.currentPrimary,
timing.currentSecondary
);
this.totalTimer.setValues(timing.totalPrimary, timing.totalSecondary);
}
}
type TimingState = {
running: boolean;
currentPrimary: string;
currentSecondary: string;
totalPrimary: string;
totalSecondary: string;
};
/**
* Gets the timing state for the provided timekeep
*
* @param timekeep The timekeep to get the state for
* @returns The timing state
*/
function getTimingState(
timekeep: Timekeep,
settings: TimekeepSettings
): TimingState {
const currentTime = moment();
const total = getTotalDuration(timekeep.entries, currentTime);
const runningEntry = getRunningEntry(timekeep.entries);
const current = runningEntry
? getEntryDuration(runningEntry, currentTime)
: 0;
return {
running: runningEntry !== null,
currentPrimary: formatDuration(settings.primaryDurationFormat, current),
currentSecondary: formatDuration(
settings.secondaryDurationFormat,
current
),
totalPrimary: formatDuration(settings.primaryDurationFormat, total),
totalSecondary: formatDuration(settings.secondaryDurationFormat, total),
};
}

View file

@ -0,0 +1,190 @@
import { CustomOutputFormat } from "@/output";
import { TimekeepSettings } from "@/settings";
import { Store, Unsubscribe } from "@/store";
import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema";
import { Component, Notice, Platform } from "obsidian";
import moment from "moment";
import { createCSV, createMarkdownTable } from "@/export";
import { exportPdf } from "@/export/pdf";
export class TimesheetExportActions extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Additional custom output formats */
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
/** Actions container element */
#actionsEl: HTMLElement | undefined;
/** Current loaded collection of custom output format buttons */
#customOutputFormatButtons: HTMLButtonElement[] = [];
constructor(
containerEl: HTMLElement,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>
) {
super();
this.#containerEl = containerEl;
this.timekeep = timekeep;
this.settings = settings;
this.customOutputFormats = customOutputFormats;
}
onload(): void {
super.onload();
const actionsEl = this.#containerEl.createEl("div", {
cls: "timekeep-actions",
});
this.#actionsEl = actionsEl;
const copyMarkdownButton = actionsEl.createEl("button", {
text: "Copy Markdown",
});
const copyCSVButton = actionsEl.createEl("button", {
text: "Copy CSV",
});
const copyJSONButton = actionsEl.createEl("button", {
text: "Copy JSON",
});
const savePdfButton = actionsEl.createEl("button", {
text: "Save PDF",
});
this.registerDomEvent(
copyMarkdownButton,
"click",
this.onCopyMarkdown.bind(this)
);
this.registerDomEvent(
copyCSVButton,
"click",
this.onCopyCSV.bind(this)
);
this.registerDomEvent(
copyJSONButton,
"click",
this.onCopyJSON.bind(this)
);
this.registerDomEvent(
savePdfButton,
"click",
this.onSavePDF.bind(this)
);
// Disable and hide the save as PDF button on mobile
if (Platform.isMobileApp) {
savePdfButton.disabled = true;
savePdfButton.hidden = true;
}
const createCustomButtons =
this.createCustomOutputFormatButtons.bind(this);
const unsubscribeCustomFormats =
this.customOutputFormats.subscribe(createCustomButtons);
this.register(unsubscribeCustomFormats);
createCustomButtons();
}
onunload(): void {
super.onunload();
this.#actionsEl?.remove();
}
removeCustomFormatButtons() {
for (const button of this.#customOutputFormatButtons) {
button.remove();
}
}
createCustomOutputFormatButtons() {
if (!this.#actionsEl) {
return;
}
// Remove existing buttons
this.removeCustomFormatButtons();
const outputFormats = this.customOutputFormats.getState();
for (const outputFormat of Object.values(outputFormats)) {
const customFormatButton = this.#actionsEl.createEl("button", {
text: outputFormat.getButtonLabel(),
});
this.registerDomEvent(customFormatButton, "click", () => {
const timekeep = this.timekeep.getState();
const settings = this.settings.getState();
const currentTime = moment();
outputFormat.onExport(timekeep, settings, currentTime);
});
this.#customOutputFormatButtons.push(customFormatButton);
}
}
onCopyMarkdown() {
const timekeep = this.timekeep.getState();
const settings = this.settings.getState();
const currentTime = moment();
const output = createMarkdownTable(timekeep, settings, currentTime);
navigator.clipboard
.writeText(output)
.then(() => new Notice("Copied markdown to clipboard", 1500))
.catch((error) => console.error("Failed to copy export", error));
}
onCopyCSV() {
const timekeep = this.timekeep.getState();
const settings = this.settings.getState();
const currentTime = moment();
const output = createCSV(timekeep, settings, currentTime);
navigator.clipboard
.writeText(output)
.then(() => new Notice("Copied CSV to clipboard", 1500))
.catch((error) => console.error("Failed to copy export", error));
}
onCopyJSON() {
const timekeep = this.timekeep.getState();
const settings = this.settings.getState();
const output = JSON.stringify(
stripTimekeepRuntimeData(timekeep),
undefined,
settings.formatCopiedJSON ? 4 : undefined
);
navigator.clipboard
.writeText(output)
.then(() => new Notice("Copied JSON to clipboard", 1500))
.catch((error) => console.error("Failed to copy export", error));
}
onSavePDF() {
const timekeep = this.timekeep.getState();
const settings = this.settings.getState();
exportPdf(timekeep, settings);
}
}

View file

@ -0,0 +1,36 @@
import { Component } from "obsidian";
export class TimesheetLoadError extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Wrapper container element */
#wrapperEl: HTMLElement | undefined;
/** The load error message */
error: string;
constructor(containerEl: HTMLElement, error: string) {
super();
this.#containerEl = containerEl;
this.error = error;
}
onload(): void {
super.onload();
const wrapperEl = this.#containerEl.createDiv({
cls: "timekeep-container",
});
wrapperEl.createEl("p", {
text: `Failed to load timekeep: ${this.error}`,
});
}
onunload(): void {
super.onunload();
this.#wrapperEl?.remove();
}
}

View file

@ -0,0 +1,84 @@
import {
NameSegment,
NameSegmentLink,
NameSegmentText,
NameSegmentType,
parseNameSegments,
} from "@/utils/name";
import { App, Component } from "obsidian";
export class TimekeepName extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the obsidian app */
app: App;
/** Parsed segments of the name */
segments: NameSegment[];
constructor(containerEl: HTMLElement, app: App, name: string) {
super();
const segments = parseNameSegments(name);
this.#containerEl = containerEl;
this.app = app;
this.segments = segments;
}
onload(): void {
super.onload();
for (const segment of this.segments) {
switch (segment.type) {
case NameSegmentType.Text:
this.createTimekeepText(segment);
break;
case NameSegmentType.Link:
this.createTimekeepLink(segment);
break;
}
}
}
onunload(): void {
super.onunload();
this.#containerEl.empty();
}
createTimekeepText(segment: NameSegmentText) {
this.#containerEl.createSpan({ text: segment.text });
}
createTimekeepLink(segment: NameSegmentLink) {
const url = segment.url;
const linkEl = this.#containerEl.createEl("a", {
text: segment.text,
href: url,
});
// Allow default behavior for external links
if (url.startsWith("http://") || url.startsWith("https://")) {
return;
}
// Register click handler for internal app links
this.registerDomEvent(linkEl, "click", (event) => {
this.onOpenLink(event, segment.url);
});
}
onOpenLink(event: Event, url: string) {
// Prevent the parent collapse toggle click and default behavior
event.stopPropagation();
event.preventDefault();
const activeFile = this.app.workspace.getActiveFile();
if (activeFile === null) return;
// Open internal link
this.app.workspace.openLinkText(url, activeFile.path);
}
}

View file

@ -0,0 +1,114 @@
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { TimeEntry, Timekeep } from "@/timekeep/schema";
import { App, Component } from "obsidian";
import { TimesheetRowContentEditing } from "./timesheetRowContentEditing";
import { TimesheetRowContent } from "./timesheetRowContent";
/**
* This container allows a entry to switch between the default and
* editable views without re-rendering the entire table or having a
* large single component
*/
export class TimesheetRowContainer extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** The entry for this row */
entry: TimeEntry;
/** Indentation level for the entry */
indent: number;
/** The row element */
#rowEl: HTMLTableRowElement | undefined;
/** Current rendered row content */
#content: TimesheetRowContent | TimesheetRowContentEditing | undefined;
constructor(
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
entry: TimeEntry,
indent: number
) {
super();
this.#containerEl = containerEl;
this.app = app;
this.timekeep = timekeep;
this.settings = settings;
this.entry = entry;
this.indent = indent;
}
onload(): void {
super.onload();
const rowEl = this.#containerEl.createEl("tr", { cls: "timekeep-row" });
this.#rowEl = rowEl;
this.onViewContent();
}
onViewEditing() {
const rowEl = this.#rowEl;
if (!rowEl) return;
this.swapContent(
new TimesheetRowContentEditing(
rowEl,
this.app,
this.timekeep,
this.settings,
this.entry,
this.onViewContent.bind(this)
)
);
}
onViewContent() {
const rowEl = this.#rowEl;
if (!rowEl) return;
this.swapContent(
new TimesheetRowContent(
rowEl,
this.app,
this.timekeep,
this.settings,
this.entry,
this.indent,
this.onViewEditing.bind(this)
)
);
}
/**
* Swaps the active content view with the provided content
*
* @param content The new content to show
*/
private swapContent(
content: TimesheetRowContent | TimesheetRowContentEditing | undefined
) {
if (this.#content) {
this.removeChild(this.#content);
}
this.#content = content;
if (this.#content !== undefined) {
this.addChild(this.#content);
}
}
}

View file

@ -0,0 +1,264 @@
import {
getRunningEntry,
isEntryRunning,
setEntryCollapsed,
startNewNestedEntry,
updateEntry,
} from "@/timekeep";
import { TimeEntry, Timekeep } from "@/timekeep/schema";
import { App, Component } from "obsidian";
import { createObsidianIcon } from "./obsidianIcon";
import { TimekeepName } from "./timesheetName";
import { formatTimestamp } from "@/utils";
import { Store } from "@/store";
import { TimekeepSettings } from "@/settings";
import { TimesheetRowDurationComponent } from "./timesheetRowDuration";
import moment from "moment";
export class TimesheetRowContent extends Component {
/** The row element */
#rowEl: HTMLTableRowElement;
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** The entry for this row */
entry: TimeEntry;
/** Indentation level for the entry */
indent: number;
/** Element for displaying the start time */
#startTimeEl: HTMLSpanElement | undefined;
/** Element for displaying the end time */
#endTimeEl: HTMLSpanElement | undefined;
/** Loaded column elements */
#columns: HTMLTableCellElement[] = [];
/** Callback to begin editing the row */
onBeginEditing: VoidFunction;
constructor(
rowEl: HTMLTableRowElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
entry: TimeEntry,
indent: number,
onBeginEditing: VoidFunction
) {
super();
this.#rowEl = rowEl;
this.app = app;
this.timekeep = timekeep;
this.settings = settings;
this.entry = entry;
this.indent = indent;
this.onBeginEditing = onBeginEditing;
}
onload(): void {
super.onload();
const entry = this.entry;
const rowEl = this.#rowEl;
const nameColEl = rowEl.createEl("td", {
cls: ["timekeep-col", "timekeep-col--name"],
});
nameColEl.style.paddingLeft = `${(this.indent + 1) * 15}px`;
const nameEl = nameColEl.createSpan({
cls: "timekeep-entry-name",
title: entry.name,
});
this.registerDomEvent(
nameEl,
"click",
this.onToggleCollapsed.bind(this)
);
if (entry.subEntries !== null && entry.folder) {
createObsidianIcon(nameEl, "folder", "timekeep-folder-icon");
}
const name = new TimekeepName(nameEl, this.app, entry.name);
this.addChild(name);
if (entry.subEntries !== null) {
createObsidianIcon(
nameEl,
entry.collapsed ? "chevron-down" : "chevron-up",
"timekeep-collapse-icon"
);
}
const startTimeColEl = rowEl.createEl("td", {
cls: ["timekeep-col", "timekeep-col--time"],
});
const startTimeEl = startTimeColEl.createSpan({
cls: "timekeep-time",
text: "",
});
this.#startTimeEl = startTimeEl;
const endTimeColEl = rowEl.createEl("td", {
cls: ["timekeep-col", "timekeep-col--time"],
});
const endTimeEl = endTimeColEl.createSpan({
cls: "timekeep-time",
text: "",
});
this.#endTimeEl = endTimeEl;
const durationColEl = rowEl.createEl("td", {
cls: ["timekeep-col", "timekeep-col--duration"],
});
const duration = new TimesheetRowDurationComponent(
durationColEl,
entry
);
this.addChild(duration);
const actionsColEl = rowEl.createEl("td", {
cls: ["timekeep-col", "timekeep-col-actions"],
});
const actionsWrapper = actionsColEl.createDiv({
cls: "timekeep-actions-wrapper",
});
const startButton = actionsWrapper.createEl("button", {
cls: "timekeep-action",
});
createObsidianIcon(startButton, "play", "button-icon");
const editButton = actionsWrapper.createEl("button", {
cls: "timekeep-action",
});
createObsidianIcon(editButton, "edit", "button-icon");
this.registerDomEvent(
startButton,
"click",
this.onClickStart.bind(this)
);
this.registerDomEvent(editButton, "click", this.onBeginEditing);
this.updateTimes();
this.updateState();
const unsubscribeSettings = this.settings.subscribe(
this.updateTimes.bind(this)
);
this.register(unsubscribeSettings);
this.#columns.push(
nameColEl,
startTimeColEl,
endTimeColEl,
durationColEl,
actionsColEl
);
}
onunload(): void {
super.onunload();
// Remove the loaded columns
for (const column of this.#columns) {
column.remove();
}
}
updateTimes() {
if (!this.#startTimeEl || !this.#endTimeEl) return;
const entry = this.entry;
const settings = this.settings.getState();
this.#startTimeEl.textContent = entry.startTime
? formatTimestamp(entry.startTime, settings)
: "";
this.#endTimeEl.textContent = entry.endTime
? formatTimestamp(entry.endTime, settings)
: "";
}
updateState() {
if (!this.#rowEl) return;
const entry = this.entry;
const isSelfRunning =
entry.subEntries === null && isEntryRunning(entry);
const isRunningWithin =
entry.subEntries !== null &&
getRunningEntry(entry.subEntries) !== null;
const isInvalidEntry =
entry.startTime !== null &&
entry.endTime !== null &&
entry.endTime.isBefore(entry.startTime);
const rowEl = this.#rowEl;
rowEl.setAttribute("data-running", String(isSelfRunning));
rowEl.setAttribute("data-running-within", String(isRunningWithin));
rowEl.setAttribute(
"data-sub-entires",
String(this.entry.subEntries !== null)
);
rowEl.setAttribute("data-invalid", String(isInvalidEntry));
}
onToggleCollapsed() {
const entry = this.entry;
if (entry.subEntries === null) return;
this.timekeep.setState((timekeep) => {
const newEntry = setEntryCollapsed(entry, !entry.collapsed);
const entries = updateEntry(timekeep.entries, entry.id, newEntry);
return { ...timekeep, entries };
});
}
onClickStart() {
const entry = this.entry;
this.timekeep.setState((timekeep) => {
const currentTime = moment();
const entries = startNewNestedEntry(
currentTime,
entry.id,
timekeep.entries
);
return {
...timekeep,
entries,
};
});
}
}

View file

@ -0,0 +1,242 @@
import { removeEntry, updateEntry } from "@/timekeep";
import { TimeEntry, Timekeep } from "@/timekeep/schema";
import { App, Component } from "obsidian";
import { createObsidianIcon } from "./obsidianIcon";
import { formatEditableTimestamp, parseEditableTimestamp } from "@/utils";
import { Store } from "@/store";
import { TimekeepSettings } from "@/settings";
import { ConfirmModal } from "@/utils/confirm-modal";
export class TimesheetRowContentEditing extends Component {
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** The entry for this row */
entry: TimeEntry;
/** The row element */
#rowEl: HTMLTableRowElement;
/** Column element containing the content */
#colEl: HTMLTableCellElement | undefined;
/** Label container for the start time */
#startTimeLabelEl: HTMLLabelElement | undefined;
/** Label container for the end time */
#endTimeLabelEl: HTMLLabelElement | undefined;
/** Input for the entry name */
#nameInputEl: HTMLInputElement | undefined;
/** Input for the start time */
#startTimeInputEl: HTMLInputElement | undefined;
/** Input for the end time */
#endTimeInputEl: HTMLInputElement | undefined;
/** Callback for editing finished / cancelled */
onFinishEditing: VoidFunction;
constructor(
rowEl: HTMLTableRowElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
entry: TimeEntry,
onFinishEditing: VoidFunction
) {
super();
this.#rowEl = rowEl;
this.app = app;
this.timekeep = timekeep;
this.settings = settings;
this.entry = entry;
this.onFinishEditing = onFinishEditing;
}
onload(): void {
super.onload();
const colEl = this.#rowEl.createEl("td");
colEl.colSpan = 5;
this.#colEl = colEl;
const formEl = colEl.createEl("form", { cls: "timesheet-editing" });
this.registerDomEvent(formEl, "submit", this.onSubmit.bind(this));
const nameLabelEl = formEl.createEl("label", { text: " Name" });
const nameInputEl = nameLabelEl.createEl("input", {
cls: "timekeep-input",
type: "text",
});
this.#nameInputEl = nameInputEl;
const startTimeLabelEl = formEl.createEl("label", {
text: "Start Time",
});
this.#startTimeLabelEl = startTimeLabelEl;
const startTimeInputEl = startTimeLabelEl.createEl("input", {
cls: "timekeep-input",
type: "text",
});
this.#startTimeInputEl = startTimeInputEl;
const endTimeLabelEl = formEl.createEl("label", { text: "End Time" });
this.#endTimeLabelEl = endTimeLabelEl;
const endTimeInputEl = endTimeLabelEl.createEl("input", {
cls: "timekeep-input",
type: "text",
});
this.#endTimeInputEl = endTimeInputEl;
const actionsEl = formEl.createDiv({
cls: "timesheet-editing-actions",
});
const saveButton = actionsEl.createEl("button", {
cls: "timekeep-action",
type: "submit",
});
createObsidianIcon(saveButton, "edit", "text-button-icon");
saveButton.appendText("Save");
const cancelButton = actionsEl.createEl("button", {
cls: "timekeep-action",
type: "button",
});
createObsidianIcon(cancelButton, "x", "text-button-icon");
this.registerDomEvent(cancelButton, "click", this.onFinishEditing);
cancelButton.appendText("Cancel");
const deleteButton = actionsEl.createEl("button", {
cls: "timekeep-action",
type: "button",
});
createObsidianIcon(deleteButton, "trash", "text-button-icon");
deleteButton.appendText("Delete");
this.registerDomEvent(
deleteButton,
"click",
this.onConfirmDelete.bind(this)
);
const onUpdateState = this.onUpdateState.bind(this);
const unsubscribeSettings = this.settings.subscribe(onUpdateState);
this.register(unsubscribeSettings);
onUpdateState();
}
onunload(): void {
super.onunload();
this.#colEl?.remove();
}
onUpdateState() {
if (
!this.#nameInputEl ||
!this.#startTimeInputEl ||
!this.#startTimeLabelEl ||
!this.#endTimeInputEl ||
!this.#endTimeLabelEl
) {
return;
}
const settings = this.settings.getState();
const entry = this.entry;
this.#nameInputEl.value = entry.name;
this.#startTimeLabelEl.hidden = entry.startTime === null;
this.#startTimeInputEl.value = entry.startTime
? formatEditableTimestamp(entry.startTime, settings)
: "";
this.#endTimeLabelEl.hidden = entry.endTime === null;
this.#endTimeInputEl.value = entry.endTime
? formatEditableTimestamp(entry.endTime, settings)
: "";
}
onConfirmDelete() {
const modal = new ConfirmModal(
this.app,
"Are you sure you want to delete this entry?",
this.onConfirmedDelete.bind(this)
);
modal.setTitle("Confirm Delete");
modal.open();
}
onConfirmedDelete(confirmed: boolean) {
if (!confirmed) {
return;
}
const entry = this.entry;
this.timekeep.setState((timekeep) => ({
entries: removeEntry(timekeep.entries, entry),
}));
}
onSubmit(event: Event) {
if (
!this.#nameInputEl ||
!this.#startTimeInputEl ||
!this.#endTimeInputEl
) {
return;
}
event.preventDefault();
event.stopPropagation();
const name = this.#nameInputEl.value;
const startTime = this.#startTimeInputEl.value;
const endTime = this.#endTimeInputEl.value;
const settings = this.settings.getState();
const entry = this.entry;
const newEntry = { ...entry, name };
// Update the start and end times for non groups
if (newEntry.subEntries === null) {
if (entry.startTime !== null) {
const startTimeValue = parseEditableTimestamp(
startTime,
settings
);
if (startTimeValue.isValid()) {
newEntry.startTime = startTimeValue;
}
}
if (entry.endTime !== null) {
const endTimeValue = parseEditableTimestamp(endTime, settings);
if (endTimeValue.isValid()) {
newEntry.endTime = endTimeValue;
}
}
}
// Save the updated entry
this.timekeep.setState((timekeep) => ({
entries: updateEntry(timekeep.entries, entry.id, newEntry),
}));
this.onFinishEditing();
}
}

View file

@ -0,0 +1,76 @@
import { TimeEntry } from "@/timekeep/schema";
import { Component } from "obsidian";
import moment from "moment";
import { getEntryDuration, isEntryRunning } from "@/timekeep";
import { formatDurationLong } from "@/utils";
export class TimesheetRowDurationComponent extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** The entry this duration belongs to */
entry: TimeEntry;
/** The time span element */
#timeEl: HTMLSpanElement | undefined;
/** Currently tracked background interval for content */
currentContentInterval: number | undefined;
constructor(containerEl: HTMLElement, entry: TimeEntry) {
super();
this.#containerEl = containerEl;
this.entry = entry;
}
onload(): void {
super.onload();
const timeEl = this.#containerEl.createSpan({ cls: "timekeep-time" });
this.#timeEl = timeEl;
this.onContentUpdate();
}
onContentUpdate() {
// Initial update
this.updateTimer();
const isRunning = isEntryRunning(this.entry);
if (this.currentContentInterval) {
clearInterval(this.currentContentInterval);
}
// Only schedule further updates if we are running
if (isRunning) {
const intervalID = window.setInterval(
this.updateTimer.bind(this),
1000
);
this.currentContentInterval = intervalID;
this.registerInterval(intervalID);
}
}
updateTimer() {
if (!this.#timeEl) return;
const value = getFormattedDuration(this.entry);
this.#timeEl.textContent = value;
}
}
/**
* Obtains the formatted duration string for an entry
*
* @param entry The entry
* @returns The formatted duration
*/
function getFormattedDuration(entry: TimeEntry): string {
const currentTime = moment();
const duration = getEntryDuration(entry, currentTime);
return formatDurationLong(duration);
}

View file

@ -0,0 +1,81 @@
import { Store } from "@/store";
import { stripTimekeepRuntimeData, Timekeep } from "@/timekeep/schema";
import { Component } from "obsidian";
type HandleSaveTimekeep = (value: Timekeep) => Promise<void>;
export class TimesheetSaveError extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Callback to save the timekeep */
handleSaveTimekeep: HandleSaveTimekeep;
/** Container created by this component */
#wrapperEl: HTMLElement | undefined;
constructor(
containerEl: HTMLElement,
timekeep: Store<Timekeep>,
handleSaveTimekeep: HandleSaveTimekeep
) {
super();
this.#containerEl = containerEl;
this.timekeep = timekeep;
this.handleSaveTimekeep = handleSaveTimekeep;
}
onload(): void {
super.onload();
const wrapperEl = this.#containerEl.createDiv({
cls: "timekeep-container",
});
this.#wrapperEl = wrapperEl;
const errorEl = wrapperEl.createDiv({ cls: "timekeep-error" });
errorEl.createEl("h1", { text: "Warning" });
errorEl.createEl("p", { text: "Failed to save current timekeep" });
errorEl.createEl("p", {
text:
`Press "Retry" to try again or "Copy Timekeep" to copy a\n ` +
`backup to clipboard, an automated backup JSON file will be\n ` +
`generated in the root of this vault`,
});
const actions = wrapperEl.createDiv({ cls: "timekeep-actions" });
const retryButton = actions.createEl("button", { text: "Retry" });
const copyButton = actions.createEl("button", {
text: "Copy Timekeep",
});
this.registerDomEvent(
retryButton,
"click",
this.onRetrySave.bind(this)
);
this.registerDomEvent(copyButton, "click", this.onCopy.bind(this));
}
onunload(): void {
super.onunload();
this.#wrapperEl?.remove();
}
onRetrySave() {
// Attempt to save the current timekeep
this.handleSaveTimekeep(this.timekeep.getState());
}
onCopy() {
navigator.clipboard.writeText(
JSON.stringify(stripTimekeepRuntimeData(this.timekeep.getState()))
);
}
}

View file

@ -0,0 +1,231 @@
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { getEntryById, getRunningEntry, startNewEntry } from "@/timekeep";
import { App, Component } from "obsidian";
import moment from "moment";
import { createObsidianIcon } from "./obsidianIcon";
import { Timekeep } from "@/timekeep/schema";
import { TimekeepStartEditing } from "./timesheetStartEditing";
import { TimesheetStartRunning } from "./timesheetStartRunning";
export class TimesheetStart extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Wrapper for the component content */
#wrapperEl: HTMLElement | undefined;
/** Content container element */
#contentEl: HTMLElement | undefined;
/** Name input for starting entries */
#nameInputEl: HTMLInputElement | undefined;
/** Warning message element */
#blockPauseWarningEl: HTMLElement | undefined;
/** Start button element */
#startButtonEl: HTMLButtonElement | undefined;
/** The currently displayed start content */
#content: TimesheetStartRunning | TimekeepStartEditing | undefined;
constructor(
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>
) {
super();
this.app = app;
this.timekeep = timekeep;
this.settings = settings;
this.#containerEl = containerEl;
}
onload(): void {
super.onload();
const wrapperEl = this.#containerEl.createDiv();
this.#wrapperEl = wrapperEl;
const contentEl = wrapperEl.createDiv();
this.#contentEl = contentEl;
this.setCurrentView();
const formEl = wrapperEl.createEl("form", {
cls: "timekeep-start-area",
});
formEl.setAttribute("data-area", "start");
const nameWrapperEl = formEl.createDiv({
cls: "timekeep-name-wrapper",
});
const blockNameEl = nameWrapperEl.createEl("label", {
text: "Block Name: ",
});
blockNameEl.htmlFor = "timekeepBlockName";
const blockPauseWarningEl = blockNameEl.createSpan({
cls: "timekeep-start-node",
text: "Starting a new task will pause the previous one",
});
blockPauseWarningEl.hidden = true;
const nameInputEl = nameWrapperEl.createEl("input", {
cls: "timekeep-name",
placeholder: "Example Block",
type: "text",
});
nameInputEl.id = "timekeepBlockName";
const startButton = formEl.createEl("button", {
cls: "timekeep-start",
type: "submit",
title: "Start",
});
createObsidianIcon(startButton, "play", "button-icon");
const onUpdate = this.onUpdate.bind(this);
const unsubscribeTimekeep = this.timekeep.subscribe(onUpdate);
onUpdate();
this.register(unsubscribeTimekeep);
}
onunload(): void {
super.onunload();
this.#wrapperEl?.remove();
}
/**
* Swaps the active content view with the provided content
*
* @param content The new content to show
*/
private swapContent(
content: TimesheetStartRunning | TimekeepStartEditing | undefined
) {
if (this.#content) {
this.removeChild(this.#content);
}
this.#content = content;
if (this.#content !== undefined) {
this.addChild(this.#content);
}
}
onUpdate() {
this.updateRunning();
if (this.#content instanceof TimekeepStartEditing) {
this.setEditingView();
return;
}
this.setCurrentView();
}
updateRunning() {
if (!this.#blockPauseWarningEl || !this.#startButtonEl) return;
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
const isTimekeepRunning = currentEntry !== null;
this.#blockPauseWarningEl.hidden =
currentEntry === null || currentEntry.startTime === null;
this.#startButtonEl.title = isTimekeepRunning
? "Stop and start"
: "Start";
}
setEmptyView() {
this.swapContent(undefined);
}
/**
* Switch to the editing view
*/
setEditingView() {
if (!this.#contentEl) return;
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
if (!currentEntry) {
return this.setEmptyView();
}
this.swapContent(
new TimekeepStartEditing(
this.#contentEl,
this.app,
this.timekeep,
this.settings,
currentEntry.name,
this.setCurrentView.bind(this)
)
);
}
/**
* Switch to the default creation view
*/
setCurrentView() {
if (!this.#contentEl) return;
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
if (currentEntry === null || currentEntry.startTime === null) {
return this.setEmptyView();
}
this.swapContent(
new TimesheetStartRunning(
this.#contentEl,
this.app,
this.timekeep,
this.settings,
currentEntry,
this.setEditingView.bind(this)
)
);
}
onStart(event: Event) {
// Prevent form submission from reloading Obsidian
event.preventDefault();
event.stopPropagation();
const nameInputEl = this.#nameInputEl;
if (!nameInputEl) return;
const name = nameInputEl.value;
this.timekeep.setState((timekeep) => {
const currentTime = moment();
const entries = startNewEntry(name, currentTime, timekeep.entries);
// Reset name input
nameInputEl.value = "";
return {
...timekeep,
entries,
};
});
}
}

View file

@ -0,0 +1,127 @@
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { getRunningEntry, updateEntry } from "@/timekeep";
import { Timekeep } from "@/timekeep/schema";
import { App, Component } from "obsidian";
import { createObsidianIcon } from "./obsidianIcon";
export class TimekeepStartEditing extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Form container element */
#formEl: HTMLElement | undefined;
/** Name editing input element */
#nameInputEl: HTMLInputElement | undefined;
/** Default value for the name being edited */
#editingName: string;
/** Callback for when editing is finished */
onFinishEditing: VoidFunction;
constructor(
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
editingName: string,
onFinishEditing: VoidFunction
) {
super();
this.#containerEl = containerEl;
this.app = app;
this.timekeep = timekeep;
this.settings = settings;
this.#editingName = editingName;
this.onFinishEditing = onFinishEditing;
}
onload(): void {
super.onload();
const formEl = this.#containerEl.createEl("form", {
cls: "timekeep-start-area",
});
formEl.setAttribute("data-area", "start");
this.registerDomEvent(formEl, "submit", this.onSave.bind(this));
this.#formEl = formEl;
const nameWrapperEl = this.#formEl.createDiv({
cls: "timekeep-name-wrapper",
});
const nameLabelEl = nameWrapperEl.createEl("label", {
text: "Edit Name:",
});
nameLabelEl.htmlFor = "timekeepBlockName";
const nameInputEl = nameWrapperEl.createEl("input", {
cls: "timekeep-name",
placeholder: "Example Block",
type: "text",
value: this.#editingName,
});
nameInputEl.id = "timekeepBlockName";
this.#nameInputEl = nameInputEl;
const saveButton = formEl.createEl("button", {
cls: ["timekeep-start", "timekeep-start--save"],
type: "submit",
title: "Save ",
});
createObsidianIcon(saveButton, "save", "button-icon");
const cancelButton = formEl.createEl("button", {
cls: ["timekeep-start", "timekeep-start--close"],
type: "button",
title: "Cancel ",
});
createObsidianIcon(cancelButton, "x", "button-icon");
this.registerDomEvent(cancelButton, "click", this.onFinishEditing);
}
onunload(): void {
super.onunload();
this.#formEl?.remove();
}
onSave(event: Event) {
// Prevent form submission from reloading Obsidian
event.preventDefault();
event.stopPropagation();
if (!this.#nameInputEl) return;
const timekeep = this.timekeep.getState();
const currentEntry = getRunningEntry(timekeep.entries);
if (!currentEntry) return;
const editingName = this.#nameInputEl.value;
this.timekeep.setState((timekeep) => {
const entries = updateEntry(timekeep.entries, currentEntry.id, {
...currentEntry,
name: editingName,
});
return {
...timekeep,
entries,
};
});
}
}

View file

@ -0,0 +1,174 @@
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import {
getPathToEntry,
getRunningEntry,
startNewEntry,
stopRunningEntries,
updateEntry,
} from "@/timekeep";
import { TimeEntry, Timekeep } from "@/timekeep/schema";
import { App, Component } from "obsidian";
import moment from "moment";
import { createObsidianIcon } from "./obsidianIcon";
import { formatTimestamp } from "@/utils";
export class TimesheetStartRunning extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Form container element */
#formEl: HTMLElement | undefined;
/** Element to display the formatted start time */
#timeValueEl: HTMLSpanElement | undefined;
/** Element to render the entry path within */
#pathEl: HTMLSpanElement | undefined;
/** The current running entry */
entry: TimeEntry;
/** Callback to start editing the current entry */
onStartEditing: VoidFunction;
constructor(
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>,
entry: TimeEntry,
onStartEditing: VoidFunction
) {
super();
this.app = app;
this.timekeep = timekeep;
this.settings = settings;
this.#containerEl = containerEl;
this.entry = entry;
this.onStartEditing = onStartEditing;
}
onload(): void {
super.onload();
const formEl = this.#containerEl.createEl("form", {
cls: "timekeep-start-area",
});
formEl.setAttribute("data-area", "running");
this.#formEl = formEl;
this.registerDomEvent(formEl, "submit", this.onStop.bind(this));
const wrapperEl = formEl.createDiv({
cls: ["active-entry", "timekeep-name-wrapper"],
});
const runningSpanEl = wrapperEl.createSpan();
runningSpanEl.createEl("b", { text: "Currently Running: " });
const detailsEl = wrapperEl.createDiv({ cls: "active-entry__details" });
const detailsNameEl = detailsEl.createSpan({
cls: "active-entry__name",
});
detailsNameEl.createEl("b", { text: "Name: " });
detailsNameEl.appendText(" ");
const pathEl = detailsNameEl.createSpan({
cls: "timekeep-path-to-entry",
});
this.#pathEl = pathEl;
const timeEl = detailsEl.createSpan({ cls: "active-entry__name" });
timeEl.createEl("b", { text: "Started at: " });
const timeValueEl = timeEl.createSpan();
this.#timeValueEl = timeValueEl;
const editButton = formEl.createEl("button", {
cls: ["timekeep-start", "timekeep-start--edit"],
title: "Edit",
type: "button",
});
createObsidianIcon(editButton, "edit", "button-icon");
this.registerDomEvent(editButton, "click", this.onStartEditing);
const stopButton = formEl.createEl("button", {
cls: ["timekeep-start", "timekeep-start--stop"],
title: "Stop",
type: "submit",
});
createObsidianIcon(stopButton, "stop-circle", "button-icon");
const onUpdate = this.onUpdate.bind(this);
const unsubscribeTimekeep = this.timekeep.subscribe(onUpdate);
const unsubscribeSettings = this.settings.subscribe(onUpdate);
onUpdate();
this.register(() => {
unsubscribeSettings();
unsubscribeTimekeep();
});
}
onunload(): void {
super.onunload();
this.#formEl?.remove();
}
onUpdate() {
if (!this.#timeValueEl || !this.#pathEl) return;
const currentEntry = this.entry;
if (!currentEntry.startTime) return;
const timekeep = this.timekeep.getState();
const settings = this.settings.getState();
this.#timeValueEl.textContent = formatTimestamp(
currentEntry.startTime,
settings
);
// Clear existing path
this.#pathEl.empty();
const pathToEntry = getPathToEntry(timekeep.entries, currentEntry);
if (pathToEntry && pathToEntry.length > 0) {
for (let i = 0; i < pathToEntry.length; i++) {
const path = pathToEntry[i];
const text = `${path.name} ${
i < pathToEntry.length - 1 ? " >" : ""
}`;
this.#pathEl.createSpan({ text });
}
}
}
onStop(event: Event) {
// Prevent form submission from reloading Obsidian
event.preventDefault();
event.stopPropagation();
this.timekeep.setState((timekeep) => {
const currentTime = moment();
return {
...timekeep,
entries: stopRunningEntries(timekeep.entries, currentTime),
};
});
}
}

View file

@ -0,0 +1,157 @@
import { TimekeepSettings } from "@/settings";
import { Store } from "@/store";
import { TimeEntry, Timekeep } from "@/timekeep/schema";
import { App, Component } from "obsidian";
import { TimesheetRowContainer } from "./timesheetRowContainer";
import { getEntriesSorted } from "@/timekeep";
export class TimesheetTable extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Access to the app instance */
app: App;
/** Access to the timekeep */
timekeep: Store<Timekeep>;
/** Access to the timekeep settings */
settings: Store<TimekeepSettings>;
/** Wrapper container */
#wrapperEl: HTMLDivElement | undefined;
/** Table body for row content */
#bodyEl: HTMLElement | undefined;
/** Currently mounted row children */
#rows: TimesheetRowContainer[] = [];
constructor(
containerEl: HTMLElement,
app: App,
timekeep: Store<Timekeep>,
settings: Store<TimekeepSettings>
) {
super();
this.#containerEl = containerEl;
this.app = app;
this.timekeep = timekeep;
this.settings = settings;
}
onload(): void {
super.onload();
const wrapperEl = this.#containerEl.createDiv();
this.#wrapperEl = wrapperEl;
const tableEl = wrapperEl.createEl("table", { cls: "timekeep-table" });
const tableHeadEl = tableEl.createEl("thead", {
cls: "timekeep-table-head",
});
const tableHeadRowEl = tableHeadEl.createEl("tr");
tableHeadRowEl.createEl("th", { text: "Block" });
tableHeadRowEl.createEl("th", { text: "Start time" });
tableHeadRowEl.createEl("th", { text: "End time" });
tableHeadRowEl.createEl("th", { text: "Duration" });
tableHeadRowEl.createEl("th", { text: "Actions" });
const bodyEl = tableEl.createEl("tbody");
this.#bodyEl = bodyEl;
const onUpdate = this.onUpdate.bind(this);
const unsubscribeSettings = this.settings.subscribe(onUpdate);
const unsubscribeTimekeep = this.timekeep.subscribe(onUpdate);
this.register(unsubscribeSettings);
this.register(unsubscribeTimekeep);
onUpdate();
}
onunload(): void {
super.onunload();
this.#wrapperEl?.remove();
}
onUpdate() {
this.updateWrapperSize();
this.renderRows();
}
updateWrapperSize() {
if (!this.#wrapperEl) return;
const settings = this.settings.getState();
if (settings.limitTableSize) {
this.#wrapperEl.style.maxHeight = "600px";
this.#wrapperEl.style.overflowY = "auto";
} else {
this.#wrapperEl.style.removeProperty("maxHeight");
this.#wrapperEl.style.removeProperty("overflowY");
}
}
clearRows() {
// Unload existing children and reset the rows list
for (const row of this.#rows) {
this.removeChild(row);
}
this.#rows = [];
}
renderRows() {
const bodyEl = this.#bodyEl;
if (!bodyEl) return;
type StackEntry = { entry: TimeEntry; depth: number };
const timekeep = this.timekeep.getState();
const settings = this.settings.getState();
const stack: StackEntry[] = getEntriesSorted(timekeep.entries, settings)
//
.map((entry) => ({
entry,
depth: 0,
}));
while (stack.length > 0) {
const { entry, depth } = stack.pop()!;
const row = new TimesheetRowContainer(
bodyEl,
this.app,
this.timekeep,
this.settings,
entry,
depth
);
this.addChild(row);
this.#rows.push(row);
if (
entry.subEntries &&
!entry.collapsed &&
entry.subEntries.length > 0
) {
const sortedEntries = getEntriesSorted(
entry.subEntries,
settings
);
for (let i = sortedEntries.length - 1; i >= 0; i--) {
stack.push({
entry: sortedEntries[i],
depth: depth + 1,
});
}
}
}
}
}

View file

@ -0,0 +1,68 @@
import { Component } from "obsidian";
export class TimesheetTimer extends Component {
/** Parent container element */
#containerEl: HTMLElement;
/** Label to display on the timer */
#label: string;
/** Timer container element */
#timerEl: HTMLDivElement | undefined;
/** Primary value display element */
#primaryValueEl: HTMLSpanElement | undefined;
/** Secondary value display element */
#secondaryValueEl: HTMLSpanElement | undefined;
constructor(containerEl: HTMLElement, label: string) {
super();
this.#containerEl = containerEl;
this.#label = label;
}
onload(): void {
super.onload();
const timerEl = this.#containerEl.createDiv({
cls: "timekeep-timer",
});
this.#timerEl = timerEl;
const primaryValueEl = timerEl.createDiv({
cls: "timekeep-timer-value",
});
const secondaryValueEl = timerEl.createDiv({
cls: "timekeep-timer-value-small",
});
timerEl.createSpan({ text: this.#label });
this.#primaryValueEl = primaryValueEl;
this.#secondaryValueEl = secondaryValueEl;
this.setValues("", " ");
}
onunload(): void {
super.onunload();
this.#timerEl?.remove();
}
setHidden(value: boolean) {
if (!this.#timerEl) return;
this.#timerEl.hidden = value;
}
setValues(primary: string, secondary: string) {
if (!this.#primaryValueEl || !this.#secondaryValueEl) return;
this.#primaryValueEl.textContent = primary;
this.#secondaryValueEl.textContent = secondary;
this.#secondaryValueEl.hidden = secondary.length < 1;
}
}

View file

@ -1,8 +0,0 @@
import { App } from "obsidian";
import React, { useContext } from "react";
export const AppContext = React.createContext<App>(null!);
export function useApp(): App {
return useContext(AppContext);
}

View file

@ -1,27 +0,0 @@
import { useCallback } from "react";
import { ConfirmModal } from "@/utils/confirm-modal";
import { useApp } from "./use-app-context";
type UseDialog = {
// Show a confirmation dialog, provides a promise that resolves
// to the users choice
showConfirm: (title: string, message: string) => Promise<boolean>;
};
export function useDialog(): UseDialog {
const app = useApp();
const showConfirm = useCallback(
(title: string, message: string): Promise<boolean> => {
return new Promise((resolve) => {
const modal = new ConfirmModal(app, message, resolve);
modal.setTitle(title);
modal.open();
});
},
[app]
);
return { showConfirm };
}

View file

@ -1,9 +0,0 @@
import React, { useContext } from "react";
import { defaultSettings, TimekeepSettings } from "@/settings";
export const SettingsContext =
React.createContext<TimekeepSettings>(defaultSettings);
export function useSettings(): TimekeepSettings {
return useContext(SettingsContext);
}

View file

@ -1,11 +0,0 @@
import { Store } from "@/store";
import { Timekeep } from "@/timekeep/schema";
import { useContext, createContext } from "react";
export type TimekeepStore = Store<Timekeep>;
export const TimekeepStoreContext = createContext<TimekeepStore>(null!);
export function useTimekeepStore(): TimekeepStore {
return useContext(TimekeepStoreContext);
}

View file

@ -1,22 +1,24 @@
import { useState, useEffect, SetStateAction } from "react";
export type Store<T> = {
// Getter for the current value
getState: () => T;
// Sets the current store value and updates all subscribers
setState: (value: SetStateAction<T>) => void;
// Getter for the current value
getState: () => T;
// Sets the current store value and updates all subscribers
setState: (value: StateUpdate<T>) => void;
// Subscribe to state changes on this store
subscribe: (callback: VoidFunction) => void;
// Unsubscribe from a specific callback
unsubscribe: (callback: VoidFunction) => void;
// Subscribe to state changes on this store
subscribe: (callback: VoidFunction) => Unsubscribe;
// Unsubscribe from a specific callback
unsubscribe: (callback: VoidFunction) => void;
};
type StateUpdate<T> = T | ((value: T) => T);
export type Unsubscribe = () => void;
type StoreState<T> = {
// The current state
state: T;
// Listeners to invoke when state changes
listeners: VoidFunction[];
// The current state
state: T;
// Listeners to invoke when state changes
listeners: VoidFunction[];
};
/**
@ -26,64 +28,40 @@ type StoreState<T> = {
* @returns The created store
*/
export function createStore<T>(initial: T): Store<T> {
const store: StoreState<T> = { state: initial, listeners: [] };
const store: StoreState<T> = { state: initial, listeners: [] };
const getState = () => store.state;
const getState = () => store.state;
const setState = (value: SetStateAction<T>) => {
const newValue = value instanceof Function ? value(store.state) : value;
const setState = (value: StateUpdate<T>) => {
const newValue = value instanceof Function ? value(store.state) : value;
store.state = newValue;
store.state = newValue;
// Execute all the listeners
store.listeners.forEach((listener) => {
listener();
});
};
// Execute all the listeners
store.listeners.forEach((listener) => {
listener();
});
};
const subscribe = (callback: VoidFunction) => {
store.listeners.push(callback);
};
const subscribe = (callback: VoidFunction) => {
store.listeners.push(callback);
const unsubscribe = (callback: VoidFunction) => {
const index = store.listeners.indexOf(callback);
if (index !== -1) {
store.listeners.splice(index, 1);
}
};
return () => {
unsubscribe(callback);
};
};
return {
getState,
setState,
subscribe,
unsubscribe,
};
}
/**
* Hook to use the value of a store, subscribes
* to the store and updates the UI when the store
* changes
*
* @param store The store to subscribe to
* @returns The current value of the store
*/
export function useStore<T>(store: Store<T>) {
const [state, setState] = useState(store.getState());
// Effect to handle state updates
useEffect(() => {
const handleUpdate = () => {
const value = store.getState();
setState(value);
};
store.subscribe(handleUpdate);
return () => {
store.unsubscribe(handleUpdate);
};
}, [setState, store]);
return state;
const unsubscribe = (callback: VoidFunction) => {
const index = store.listeners.indexOf(callback);
if (index !== -1) {
store.listeners.splice(index, 1);
}
};
return {
getState,
setState,
subscribe,
unsubscribe,
};
}

View file

@ -3,21 +3,20 @@ export { startNewEntry, startNewNestedEntry } from "./start";
export { withEntry, createEntry, withSubEntry } from "./create";
export {
removeEntry,
updateEntry,
removeSubEntry,
setEntryCollapsed,
stopRunningEntries,
removeEntry,
updateEntry,
removeSubEntry,
setEntryCollapsed,
stopRunningEntries,
} from "./update";
export {
getEntryById,
getStartTime,
isKeepRunning,
getPathToEntry,
isEntryRunning,
getRunningEntry,
getEntryDuration,
getTotalDuration,
getUniqueEntryHash,
getEntryById,
getStartTime,
isKeepRunning,
getPathToEntry,
isEntryRunning,
getRunningEntry,
getEntryDuration,
getTotalDuration,
} from "./queries";

View file

@ -1,185 +1,168 @@
import {
getEntryById,
isKeepRunning,
getPathToEntry,
isEntryRunning,
getRunningEntry,
getEntryDuration,
getTotalDuration,
getUniqueEntryHash,
getEntryById,
isKeepRunning,
getPathToEntry,
isEntryRunning,
getRunningEntry,
getEntryDuration,
getTotalDuration,
} from "./queries";
describe("getEntryById", () => {
it("find top level entry", async () => {
const { input, targetEntry, targetEntryId } =
await import("./__fixtures__/checking/findEntryById");
it("find top level entry", async () => {
const { input, targetEntry, targetEntryId } =
await import("./__fixtures__/checking/findEntryById");
const output = getEntryById(targetEntryId, input);
expect(output).toEqual(targetEntry);
});
const output = getEntryById(targetEntryId, input);
expect(output).toEqual(targetEntry);
});
it("find nested entry", async () => {
const { input, targetEntry, targetEntryId } =
await import("./__fixtures__/checking/findEntryByIdNested");
it("find nested entry", async () => {
const { input, targetEntry, targetEntryId } =
await import("./__fixtures__/checking/findEntryByIdNested");
const output = getEntryById(targetEntryId, input);
expect(output).toEqual(targetEntry);
});
const output = getEntryById(targetEntryId, input);
expect(output).toEqual(targetEntry);
});
it("find entry non existent", async () => {
const { input, targetEntryId } =
await import("./__fixtures__/checking/findEntryByIdMissing");
it("find entry non existent", async () => {
const { input, targetEntryId } =
await import("./__fixtures__/checking/findEntryByIdMissing");
const output = getEntryById(targetEntryId, input);
expect(output).toBeUndefined();
});
const output = getEntryById(targetEntryId, input);
expect(output).toBeUndefined();
});
});
describe("getPathToEntry", () => {
it("path not found", async () => {
const { targetEntry, entries, expected } =
await import("./__fixtures__/path/pathNotFound");
const output = getPathToEntry(entries, targetEntry);
expect(output).toEqual(expected);
});
it("path not found", async () => {
const { targetEntry, entries, expected } =
await import("./__fixtures__/path/pathNotFound");
const output = getPathToEntry(entries, targetEntry);
expect(output).toEqual(expected);
});
it("top level path found", () => {});
it("top level path found", () => {});
it("deep path found", () => {});
it("deep path found", () => {});
it("should find running entry path", async () => {
const { input, runningEntry, path } =
await import("./__fixtures__/checking/findRunningEntryPath");
it("should find running entry path", async () => {
const { input, runningEntry, path } =
await import("./__fixtures__/checking/findRunningEntryPath");
const output = getPathToEntry(input, runningEntry);
expect(output).toEqual(path);
});
const output = getPathToEntry(input, runningEntry);
expect(output).toEqual(path);
});
});
describe("isEntryRunning", () => {
it("should determine entry running state", async () => {
const { running, notRunning } =
await import("./__fixtures__/checking/runningState");
it("should determine entry running state", async () => {
const { running, notRunning } =
await import("./__fixtures__/checking/runningState");
expect(isEntryRunning(running)).toBe(true);
expect(isEntryRunning(notRunning)).toBe(false);
});
expect(isEntryRunning(running)).toBe(true);
expect(isEntryRunning(notRunning)).toBe(false);
});
it("should determine entry running state (nested)", async () => {
const { runningNested, stoppedNested } =
await import("./__fixtures__/checking/runningState");
it("should determine entry running state (nested)", async () => {
const { runningNested, stoppedNested } =
await import("./__fixtures__/checking/runningState");
expect(isEntryRunning(runningNested)).toBe(true);
expect(isEntryRunning(stoppedNested)).toBe(false);
});
expect(isEntryRunning(runningNested)).toBe(true);
expect(isEntryRunning(stoppedNested)).toBe(false);
});
});
describe("getRunningEntry", () => {
it("should find running entry", async () => {
const { input, runningEntry } =
await import("./__fixtures__/checking/shouldFindRunningEntry");
it("should find running entry", async () => {
const { input, runningEntry } =
await import("./__fixtures__/checking/shouldFindRunningEntry");
const output = getRunningEntry(input);
const output = getRunningEntry(input);
expect(output).toBe(runningEntry);
});
expect(output).toBe(runningEntry);
});
it("should find nested running entry", async () => {
const { input, runningEntry } =
await import("./__fixtures__/checking/shouldFindRunningEntryNested");
it("should find nested running entry", async () => {
const { input, runningEntry } =
await import("./__fixtures__/checking/shouldFindRunningEntryNested");
const output = getRunningEntry(input);
const output = getRunningEntry(input);
expect(output).toBe(runningEntry);
});
expect(output).toBe(runningEntry);
});
it("should not find running entry", async () => {
const { input } =
await import("./__fixtures__/checking/shouldNotFindRunningEntry");
it("should not find running entry", async () => {
const { input } =
await import("./__fixtures__/checking/shouldNotFindRunningEntry");
const output = getRunningEntry(input);
const output = getRunningEntry(input);
expect(output).toBe(null);
});
expect(output).toBe(null);
});
});
describe("isKeepRunning", () => {
it("should show keep running", async () => {
const { input } =
await import("./__fixtures__/checking/shouldBeRunning");
it("should show keep running", async () => {
const { input } =
await import("./__fixtures__/checking/shouldBeRunning");
expect(isKeepRunning(input)).toBe(true);
});
expect(isKeepRunning(input)).toBe(true);
});
it("should show keep not running", async () => {
const { input } =
await import("./__fixtures__/checking/shouldNotBeRunning");
it("should show keep not running", async () => {
const { input } =
await import("./__fixtures__/checking/shouldNotBeRunning");
expect(isKeepRunning(input)).toBe(false);
});
expect(isKeepRunning(input)).toBe(false);
});
});
describe("getEntryDuration", () => {
it("should get entry duration", async () => {
const { input, currentTime, durationMs } =
await import("./__fixtures__/duration/shouldGetEntryDuration");
it("should get entry duration", async () => {
const { input, currentTime, durationMs } =
await import("./__fixtures__/duration/shouldGetEntryDuration");
const output = getEntryDuration(input, currentTime);
const output = getEntryDuration(input, currentTime);
expect(output).toBe(durationMs);
});
expect(output).toBe(durationMs);
});
it("duration of non started entry should be zero", async () => {
const { input, currentTime, durationMs } =
await import("./__fixtures__/duration/nonStartedZeroDuration");
it("duration of non started entry should be zero", async () => {
const { input, currentTime, durationMs } =
await import("./__fixtures__/duration/nonStartedZeroDuration");
const output = getEntryDuration(input, currentTime);
const output = getEntryDuration(input, currentTime);
expect(output).toBe(durationMs);
});
expect(output).toBe(durationMs);
});
it("duration should include children", async () => {
const { input, currentTime, expected } =
await import("./__fixtures__/duration/durationIncludeChildren");
it("duration should include children", async () => {
const { input, currentTime, expected } =
await import("./__fixtures__/duration/durationIncludeChildren");
const output = getEntryDuration(input, currentTime);
const output = getEntryDuration(input, currentTime);
expect(output).toBe(expected);
});
expect(output).toBe(expected);
});
it("duration should use current as end for unfinished entries", async () => {
const { input, endTime, durationMs } =
await import("./__fixtures__/duration/currentEndUnfinished");
it("duration should use current as end for unfinished entries", async () => {
const { input, endTime, durationMs } =
await import("./__fixtures__/duration/currentEndUnfinished");
const output = getEntryDuration(input, endTime);
const output = getEntryDuration(input, endTime);
expect(output).toBe(durationMs);
});
expect(output).toBe(durationMs);
});
});
describe("getTotalDuration", () => {
it("should get total duration", async () => {
const { input, currentTime, expected } =
await import("./__fixtures__/duration/totalDuration");
it("should get total duration", async () => {
const { input, currentTime, expected } =
await import("./__fixtures__/duration/totalDuration");
const output = getTotalDuration(input, currentTime);
const output = getTotalDuration(input, currentTime);
expect(output).toBe(expected);
});
});
describe("getUniqueEntryHash", () => {
it("hash should match when content matches", async () => {
const { left, right } =
await import("./__fixtures__/hashing/hashMatches");
expect(getUniqueEntryHash(left)).toBe(getUniqueEntryHash(right));
});
it("hash shouldn't match when content is different", async () => {
const { left, right } =
await import("./__fixtures__/hashing/hashDoesNotMatch");
expect(getUniqueEntryHash(left)).not.toBe(getUniqueEntryHash(right));
});
expect(output).toBe(expected);
});
});

View file

@ -1,5 +1,4 @@
import type { Moment } from "moment";
import { strHash } from "@/utils/text";
import { Timekeep, TimeEntry } from "@/timekeep/schema";
/**
@ -10,23 +9,23 @@ import { Timekeep, TimeEntry } from "@/timekeep/schema";
* @returns The found entry or undefined
*/
export function getEntryById(
entryId: string,
entries: TimeEntry[]
entryId: string,
entries: TimeEntry[]
): TimeEntry | undefined {
for (const entry of entries) {
if (entry.id === entryId) {
return entry;
}
for (const entry of entries) {
if (entry.id === entryId) {
return entry;
}
if (entry.subEntries) {
const nestedEntry = getEntryById(entryId, entry.subEntries);
if (nestedEntry) {
return nestedEntry;
}
}
}
if (entry.subEntries) {
const nestedEntry = getEntryById(entryId, entry.subEntries);
if (nestedEntry) {
return nestedEntry;
}
}
}
return undefined;
return undefined;
}
/**
@ -37,37 +36,37 @@ export function getEntryById(
* @returns The path to the entry
*/
export function getPathToEntry(
entries: TimeEntry[],
target: TimeEntry
entries: TimeEntry[],
target: TimeEntry
): { id: string; name: string }[] {
const path: { id: string; name: string }[] = [];
const path: { id: string; name: string }[] = [];
function dfs(entry: TimeEntry): boolean {
path.push({ id: entry.id, name: entry.name });
function dfs(entry: TimeEntry): boolean {
path.push({ id: entry.id, name: entry.name });
if (entry.id === target.id) {
return true;
}
if (entry.id === target.id) {
return true;
}
if (entry.subEntries) {
for (const sub of entry.subEntries) {
if (dfs(sub)) {
return true;
}
}
}
if (entry.subEntries) {
for (const sub of entry.subEntries) {
if (dfs(sub)) {
return true;
}
}
}
path.pop();
return false;
}
path.pop();
return false;
}
for (const entry of entries) {
if (dfs(entry)) {
return path;
}
}
for (const entry of entries) {
if (dfs(entry)) {
return path;
}
}
return [];
return [];
}
/**
@ -79,19 +78,19 @@ export function getPathToEntry(
* @return The running entry if found or null
*/
export function getRunningEntry(entries: TimeEntry[]): TimeEntry | null {
const stack: TimeEntry[] = [...entries];
const stack: TimeEntry[] = [...entries];
while (stack.length > 0) {
const entry: TimeEntry = stack.pop()!;
while (stack.length > 0) {
const entry: TimeEntry = stack.pop()!;
if (entry.subEntries !== null) {
stack.push(...entry.subEntries);
} else if (entry.startTime !== null && entry.endTime === null) {
return entry;
}
}
if (entry.subEntries !== null) {
stack.push(...entry.subEntries);
} else if (entry.startTime !== null && entry.endTime === null) {
return entry;
}
}
return null;
return null;
}
/**
@ -102,11 +101,11 @@ export function getRunningEntry(entries: TimeEntry[]): TimeEntry | null {
* @returns Whether the entry or any sub-entries are running
*/
export function isEntryRunning(entry: TimeEntry) {
if (entry.subEntries !== null) {
return getRunningEntry(entry.subEntries) !== null;
}
if (entry.subEntries !== null) {
return getRunningEntry(entry.subEntries) !== null;
}
return entry.startTime !== null && entry.endTime === null;
return entry.startTime !== null && entry.endTime === null;
}
/**
@ -117,7 +116,7 @@ export function isEntryRunning(entry: TimeEntry) {
* @returns Whether the timekeep is running
*/
export function isKeepRunning(timekeep: Timekeep): boolean {
return getRunningEntry(timekeep.entries) !== null;
return getRunningEntry(timekeep.entries) !== null;
}
/**
@ -129,21 +128,21 @@ export function isKeepRunning(timekeep: Timekeep): boolean {
* @returns The duration in milliseconds
*/
export function getEntryDuration(
entry: TimeEntry,
currentTime: Moment
entry: TimeEntry,
currentTime: Moment
): number {
if (entry.subEntries !== null) {
return getTotalDuration(entry.subEntries, currentTime);
}
if (entry.subEntries !== null) {
return getTotalDuration(entry.subEntries, currentTime);
}
// Entry is not started
if (entry.startTime === null) {
return 0;
}
// Entry is not started
if (entry.startTime === null) {
return 0;
}
// Get the end time or use current time if not ended
const endTime = entry.endTime ?? currentTime;
return endTime.diff(entry.startTime);
// Get the end time or use current time if not ended
const endTime = entry.endTime ?? currentTime;
return endTime.diff(entry.startTime);
}
/**
@ -155,37 +154,14 @@ export function getEntryDuration(
* @returns The total duration in milliseconds
*/
export function getTotalDuration(
entries: TimeEntry[],
currentTime: Moment
entries: TimeEntry[],
currentTime: Moment
): number {
return entries.reduce(
(totalDuration, entry) =>
totalDuration + getEntryDuration(entry, currentTime),
0
);
}
/**
* Creates a semi-unique hash for the provided `entry` used on
* the React side as keys to reduce re-rendering for entries
* that haven't changed
*
* @param entry The entry to hash
* @returns The hash value
*/
export function getUniqueEntryHash(entry: TimeEntry): number {
if (entry.subEntries === null) {
return strHash(
`${entry.name}${entry.startTime?.valueOf()}${entry.endTime?.valueOf()}`
);
}
const subEntriesHash = entry.subEntries.reduce(
(acc, subEntry) => acc + getUniqueEntryHash(subEntry),
0
);
return strHash(`${entry.name}${subEntriesHash}`);
return entries.reduce(
(totalDuration, entry) =>
totalDuration + getEntryDuration(entry, currentTime),
0
);
}
/**
@ -198,30 +174,30 @@ export function getUniqueEntryHash(entry: TimeEntry): number {
* @returns The start time or null if none were available
*/
export function getStartTime(entry: TimeEntry, newest: boolean): Moment | null {
// Find the latest start time from entry
if (entry.subEntries !== null) {
return entry.subEntries.reduce(
(previousValue, currentValue) => {
if (previousValue === null) {
return currentValue.startTime;
}
// Find the latest start time from entry
if (entry.subEntries !== null) {
return entry.subEntries.reduce(
(previousValue, currentValue) => {
if (previousValue === null) {
return currentValue.startTime;
}
// Use the current value if its newer
if (currentValue.startTime !== null) {
const timeDiff = newest
? previousValue.diff(currentValue.startTime)
: currentValue.startTime.diff(previousValue);
// Use the current value if its newer
if (currentValue.startTime !== null) {
const timeDiff = newest
? previousValue.diff(currentValue.startTime)
: currentValue.startTime.diff(previousValue);
if (timeDiff > 0) {
return currentValue.startTime;
}
}
if (timeDiff > 0) {
return currentValue.startTime;
}
}
return previousValue;
},
null as Moment | null
);
}
return previousValue;
},
null as Moment | null
);
}
return entry.startTime;
return entry.startTime;
}

View file

@ -1,13 +0,0 @@
import { strHash } from "./text";
it("empty string should have hash of zero", () => {
const input = "";
const output = strHash(input);
expect(output).toEqual(0);
});
it("Hashes should be equal for same string", () => {
const left = "Test string";
const right = "Test string";
expect(strHash(left)).toBe(strHash(right));
});

View file

@ -1,23 +1,3 @@
/**
* Simple hashing function for creating a number hash
* for a string
*
* @param str The string to hash
* @returns The hash result
*/
export function strHash(str: string): number {
let hash = 0;
if (str.length === 0) return hash;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return hash;
}
/**
* Checks if the provided string is empty
*
@ -25,5 +5,5 @@ export function strHash(str: string): number {
* @returns Whether the string is empty
*/
export function isEmptyString(value: string): boolean {
return value.trim().length === 0;
return value.trim().length === 0;
}

View file

@ -1,228 +1,221 @@
import App from "@/App";
import moment from "moment";
import React, { StrictMode } from "react";
import { Store, createStore } from "@/store";
import { App as ObsidianApp } from "obsidian";
import { TimekeepSettings } from "@/settings";
import { CustomOutputFormat } from "@/output";
import { Root, createRoot } from "react-dom/client";
import { Timekeep, stripTimekeepRuntimeData } from "@/timekeep/schema";
import { LoadResult, replaceTimekeepCodeblock } from "@/timekeep/parser";
import {
TFile,
TAbstractFile,
MarkdownRenderChild,
MarkdownPostProcessorContext,
TFile,
TAbstractFile,
MarkdownRenderChild,
MarkdownPostProcessorContext,
} from "obsidian";
import { Timesheet } from "@/components/timesheet";
import { TimesheetLoadError } from "@/components/timesheetLoadError";
export class TimekeepMarkdownView extends MarkdownRenderChild {
// Obsidian app instance
app: ObsidianApp;
// Timekeep settings store
settingsStore: Store<TimekeepSettings>;
// Custom output formats store
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
// Markdown context for the current markdown block
context: MarkdownPostProcessorContext;
// Timekeep load result
loadResult: LoadResult;
// React root
root: Root;
// Path to the file the timekeep is within
fileSourcePath: string;
// Obsidian app instance
app: ObsidianApp;
// Timekeep settings store
settingsStore: Store<TimekeepSettings>;
// Custom output formats store
customOutputFormats: Store<Record<string, CustomOutputFormat>>;
// Markdown context for the current markdown block
context: MarkdownPostProcessorContext;
// Timekeep load result
loadResult: LoadResult;
/** The rendered timesheet component */
timesheet: Timesheet | TimesheetLoadError | undefined;
constructor(
containerEl: HTMLElement,
app: ObsidianApp,
settingsStore: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
context: MarkdownPostProcessorContext,
loadResult: LoadResult
) {
super(containerEl);
this.app = app;
this.settingsStore = settingsStore;
this.customOutputFormats = customOutputFormats;
this.context = context;
this.loadResult = loadResult;
this.root = createRoot(containerEl);
// Path to the file the timekeep is within
fileSourcePath: string;
// Set initial file path
this.fileSourcePath = context.sourcePath;
}
constructor(
containerEl: HTMLElement,
app: ObsidianApp,
settingsStore: Store<TimekeepSettings>,
customOutputFormats: Store<Record<string, CustomOutputFormat>>,
context: MarkdownPostProcessorContext,
loadResult: LoadResult
) {
super(containerEl);
this.app = app;
this.settingsStore = settingsStore;
this.customOutputFormats = customOutputFormats;
this.context = context;
this.loadResult = loadResult;
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;
}
}
)
);
// Set initial file path
this.fileSourcePath = context.sourcePath;
}
// Render the react content
if (this.loadResult.success) {
const timekeep = this.loadResult.timekeep;
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;
}
}
)
);
const timekeepStore = createStore(timekeep);
const saveErrorStore = createStore(false);
// Render the content
if (this.loadResult.success) {
const timekeep = this.loadResult.timekeep;
const trySave = this.trySave.bind(this);
const timekeepStore = createStore(timekeep);
const saveErrorStore = createStore(false);
const handleSaveTimekeep = async (timekeep: Timekeep) => {
// Attempt to save the timekeep changes
const result = await trySave(timekeep);
const trySave = this.trySave.bind(this);
const saveError = !result;
const handleSaveTimekeep = async (timekeep: Timekeep) => {
// Attempt to save the timekeep changes
const result = await trySave(timekeep);
// Update the save error state
if (saveErrorStore.getState() !== saveError) {
saveErrorStore.setState(saveError);
}
};
const saveError = !result;
// Subscribe to save when timekeep changes
timekeepStore.subscribe(() => {
handleSaveTimekeep(timekeepStore.getState());
});
// Update the save error state
if (saveErrorStore.getState() !== saveError) {
saveErrorStore.setState(saveError);
}
};
this.root.render(
React.createElement(
StrictMode,
{},
React.createElement(App, {
app: this.app,
timekeepStore,
saveErrorStore,
settingsStore: this.settingsStore,
customOutputFormats: this.customOutputFormats,
handleSaveTimekeep,
})
)
);
} else {
this.root.render(
React.createElement(
"p",
{ className: "timekeep-container" },
"Failed to load timekeep: " + this.loadResult.error
)
);
}
}
// Subscribe to save when timekeep changes
timekeepStore.subscribe(() => {
handleSaveTimekeep(timekeepStore.getState());
});
onunload(): void {
this.root.unmount();
}
const timesheet = new Timesheet(
this.containerEl,
restoreScrollTimeout: ReturnType<typeof setTimeout> | null = null;
restoreScrollInfo: { top: number; left: number } | null = null;
this.app,
timekeepStore,
saveErrorStore,
this.settingsStore,
this.customOutputFormats,
handleSaveTimekeep
);
saveEditorScroll() {
const activeEditor = this.app.workspace.activeEditor;
if (!activeEditor) return;
const editor = activeEditor.editor;
if (!editor) return;
this.restoreScrollInfo = editor.getScrollInfo();
}
this.addChild(timesheet);
this.timesheet = timesheet;
} else {
const timesheet = new TimesheetLoadError(
this.containerEl,
this.loadResult.error
);
this.addChild(timesheet);
this.timesheet = timesheet;
}
}
restoreEditorScroll() {
if (this.restoreScrollInfo === null) return;
const activeEditor = this.app.workspace.activeEditor;
restoreScrollTimeout: ReturnType<typeof setTimeout> | null = null;
restoreScrollInfo: { top: number; left: number } | null = null;
if (!activeEditor) return;
const editor = activeEditor.editor;
saveEditorScroll() {
const activeEditor = this.app.workspace.activeEditor;
if (!activeEditor) return;
const editor = activeEditor.editor;
if (!editor) return;
this.restoreScrollInfo = editor.getScrollInfo();
}
if (!editor) return;
editor.scrollTo(
this.restoreScrollInfo.left,
this.restoreScrollInfo.top
);
}
restoreEditorScroll() {
if (this.restoreScrollInfo === null) return;
const activeEditor = this.app.workspace.activeEditor;
/**
* 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<boolean> {
this.saveEditorScroll();
if (!activeEditor) return;
const editor = activeEditor.editor;
try {
await this.save(timekeep);
if (!editor) return;
editor.scrollTo(
this.restoreScrollInfo.left,
this.restoreScrollInfo.top
);
}
return true;
} catch (e) {
console.error("Failed to save timekeep", e);
/**
* 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<boolean> {
this.saveEditorScroll();
try {
this.saveFallback(timekeep);
} catch (e) {
console.error("Couldn't save timekeep fallback", e);
}
try {
await this.save(timekeep);
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
);
}
}
return true;
} catch (e) {
console.error("Failed to save timekeep", e);
/**
* 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);
try {
this.saveFallback(timekeep);
} catch (e) {
console.error("Couldn't save timekeep fallback", e);
}
// Ensure we actually have a section to write to
if (sectionInfo === null)
throw new Error("Section to write did not exist");
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
);
}
}
const file = this.app.vault.getFileByPath(this.fileSourcePath);
/**
* 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 the file still exists
if (file === null) throw new Error("File no longer exists");
// Ensure we actually have a section to write to
if (sectionInfo === null)
throw new Error("Section to write did not exist");
// Replace the stored timekeep block with the new one
await this.app.vault.process(file, (data) => {
return replaceTimekeepCodeblock(
timekeep,
data,
sectionInfo.lineStart,
sectionInfo.lineEnd
);
});
}
const file = this.app.vault.getFileByPath(this.fileSourcePath);
/**
* Fallback saving incase 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 incase of write failure, attempt to write to another file
const backupFileName = `timekeep-write-backup-${moment().format("YYYY-MM-DD HH-mm-ss")}.json`;
// Ensure the file still exists
if (file === null) throw new Error("File no longer exists");
// Write to the backup file
this.app.vault.create(
backupFileName,
JSON.stringify(stripTimekeepRuntimeData(timekeep))
);
}
// 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
this.app.vault.create(
backupFileName,
JSON.stringify(stripTimekeepRuntimeData(timekeep))
);
}
}

File diff suppressed because one or more lines are too long

View file

@ -1,22 +1,21 @@
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ESNext",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"isolatedModules": true,
"jsx": "react",
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6"],
"allowSyntheticDefaultImports": true,
"paths": {
"@/*": ["src/*"]
},
"esModuleInterop": true
},
"include": ["src/**/*", "types/**/*"]
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ESNext",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6"],
"allowSyntheticDefaultImports": true,
"paths": {
"@/*": ["src/*"],
},
"esModuleInterop": true,
},
"include": ["src/**/*", "types/**/*"],
}