mirror of
https://github.com/jacobtread/obsidian-timekeep.git
synced 2026-07-22 10:10:27 +00:00
refactor: replace react with obsidian components
This commit is contained in:
parent
f6f5ebb342
commit
7ba6ffa92e
46 changed files with 2706 additions and 1807 deletions
55
README.md
55
README.md
|
|
@ -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**.
|
||||
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
58
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
70
src/App.tsx
70
src/App.tsx
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
));
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
25
src/components/obsidianIcon.ts
Normal file
25
src/components/obsidianIcon.ts
Normal 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;
|
||||
}
|
||||
86
src/components/timesheet.ts
Normal file
86
src/components/timesheet.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
95
src/components/timesheetApp.ts
Normal file
95
src/components/timesheetApp.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
151
src/components/timesheetCounters.ts
Normal file
151
src/components/timesheetCounters.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
190
src/components/timesheetExportActions.ts
Normal file
190
src/components/timesheetExportActions.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
36
src/components/timesheetLoadError.ts
Normal file
36
src/components/timesheetLoadError.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
84
src/components/timesheetName.ts
Normal file
84
src/components/timesheetName.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
114
src/components/timesheetRowContainer.ts
Normal file
114
src/components/timesheetRowContainer.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
264
src/components/timesheetRowContent.ts
Normal file
264
src/components/timesheetRowContent.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
242
src/components/timesheetRowContentEditing.ts
Normal file
242
src/components/timesheetRowContentEditing.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
76
src/components/timesheetRowDuration.ts
Normal file
76
src/components/timesheetRowDuration.ts
Normal 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);
|
||||
}
|
||||
81
src/components/timesheetSaveError.ts
Normal file
81
src/components/timesheetSaveError.ts
Normal 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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
231
src/components/timesheetStart.ts
Normal file
231
src/components/timesheetStart.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
127
src/components/timesheetStartEditing.ts
Normal file
127
src/components/timesheetStartEditing.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
174
src/components/timesheetStartRunning.ts
Normal file
174
src/components/timesheetStartRunning.ts
Normal 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),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
157
src/components/timesheetTable.ts
Normal file
157
src/components/timesheetTable.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
68
src/components/timesheetTimer.ts
Normal file
68
src/components/timesheetTimer.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
112
src/store.ts
112
src/store.ts
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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/**/*"],
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue