refactor: move timekeep state to its own store, move dialogs to its own hook, move error screen, don't store collapsed state when false, add note about rendering limitation

This commit is contained in:
Jacobtread 2024-07-21 19:26:45 +12:00
parent f0b881e714
commit 9de817f1fc
18 changed files with 392 additions and 164 deletions

View file

@ -99,6 +99,17 @@ If you have frequently used entry names you can define them in your template by
This will create an entry that is not yet started which you can click the play button on and start without having to type out the name.
## Known issues
### Jumpy rendering behavior on modification
If your lists become longer you will likely see some jumpy/flickery behavior with timekeep when making modifications (add/save/delete/collapse/expand), this is a limitation of how Obsidian re-renders the app.
I am unable to take advantage of the React virtual DOM to only update the changed DOM elements for the entries, because Obsidian re-creates the entire app when the code block changes (Since the timekeep data is stored in the codeblock, modifications cause this to happen. Thus the virtual DOM and real DOM are thrown away causing a full re-render). This issue also means local state will all be lost on modification (Which is why the collapsed state must be persisted to the timekeep)
I do not believe this can be fixed but PRs are welcome if you are aware of a way to fix this.
## 📄 License
This project is licensed under the [MIT License](./LICENSE.md)

View file

@ -1,122 +1,53 @@
import { Timekeep } from "@/schema";
import { isKeepRunning } from "@/timekeep";
import React from "react";
import { App as ObsidianApp } from "obsidian";
import TimesheetStart from "@/components/TimesheetStart";
import TimesheetTable from "@/components/TimesheetTable";
import TimesheetCounters from "@/components/TimesheetCounters";
import { SettingsContext } from "@/contexts/use-settings-context";
import { TimekeepContext } from "@/contexts/use-timekeep-context";
import React, { useState, useCallback, SetStateAction } from "react";
import TimesheetExportActions from "@/components/TimesheetExportActions";
import { ConfirmModal } from "./utils/confirm-modal";
import { AppContext } from "./contexts/use-app-context";
import TimesheetSaveError from "./components/TimesheetSaveError";
import { SettingsContext } from "./contexts/use-settings-context";
import { SettingsStore, useSettingsStore } from "./store/settings-store";
import {
useSaveError,
TimekeepStore,
TimekeepStoreContext,
} from "./store/timekeep-store";
export type AppProps = {
// Obsidian app for creating modals
app: ObsidianApp;
// Initial state loaded from the document
initialState: Timekeep;
// The timekeep settings
// Timekeep state store
timekeepStore: TimekeepStore;
// Timekeep settings store
settingsStore: SettingsStore;
// Function to save the timekeep data
save: (timekeep: Timekeep) => Promise<boolean>;
};
/**
* Main app component, handles managing the app state and
* providing the contexts.
*
* Wraps the state updates for `setTimekeep` with logic that
* saves the changes to the vault
*/
export default function App({
app,
initialState,
save,
settingsStore,
}: AppProps) {
const [timekeep, setTimekeep] = useState(initialState);
const [saveError, setSaveError] = useState(false);
export default function App({ app, timekeepStore, settingsStore }: AppProps) {
const settings = useSettingsStore(settingsStore);
const trySave = (timekeep: Timekeep) => {
save(timekeep).then((isSaved) => {
setSaveError(!isSaved);
});
};
// Wrapper around setTimekeep state to save the file on changes
const setTimekeepWrapper = useCallback(
(value: SetStateAction<Timekeep>) => {
setTimekeep((storedValue) => {
const updatedValue =
value instanceof Function ? value(storedValue) : value;
trySave(updatedValue);
return updatedValue;
});
},
[setTimekeep]
);
const onRetrySave = () => {
trySave(timekeep);
};
const onClickCopy = () => {
navigator.clipboard.writeText(JSON.stringify(timekeep));
};
const showConfirm = useCallback(
(title: string, message: string): Promise<boolean> => {
return new Promise((resolve) => {
const modal = new ConfirmModal(app, message, resolve);
modal.setTitle(title);
modal.open();
});
},
[app]
);
// Saving error fallback screen
if (saveError) {
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>
);
}
const saveError = useSaveError(timekeepStore);
return (
<AppContext.Provider value={app}>
<SettingsContext.Provider value={settings}>
<TimekeepContext.Provider
value={{
timekeep,
setTimekeep: setTimekeepWrapper,
isTimekeepRunning: isKeepRunning(timekeep),
showConfirm,
}}>
<div className="timekeep-container">
<TimesheetCounters />
<TimesheetStart />
<TimesheetTable />
<TimesheetExportActions />
</div>
</TimekeepContext.Provider>
<TimekeepStoreContext.Provider value={timekeepStore}>
{saveError ? (
// Error page when saving fails
<TimesheetSaveError />
) : (
<div className="timekeep-container">
<TimesheetCounters />
<TimesheetStart />
<TimesheetTable />
<TimesheetExportActions />
</div>
)}
</TimekeepStoreContext.Provider>
</SettingsContext.Provider>
</AppContext.Provider>
);

View file

@ -1,10 +1,11 @@
import moment from "moment";
import { Timekeep } from "@/schema";
import React, { useState, useEffect } from "react";
import { useTimekeep } from "@/contexts/use-timekeep-context";
import { useSettings } from "@/contexts/use-settings-context";
import { formatDuration, formatDurationHoursTrunc } from "@/utils";
import { useTimekeep, useTimekeepStore } from "@/store/timekeep-store";
import {
isKeepRunning,
getRunningEntry,
getTotalDuration,
getEntryDuration,
@ -42,9 +43,11 @@ function getTimingState(timekeep: Timekeep): TimingState {
}
export default function TimesheetCounters() {
const { timekeep, isTimekeepRunning } = useTimekeep();
const [timing, setTiming] = useState<TimingState>(getTimingState(timekeep));
const settings = useSettings();
const store = useTimekeepStore();
const timekeep = useTimekeep(store);
const [timing, setTiming] = useState<TimingState>(getTimingState(timekeep));
// Update the current timings every second
useEffect(() => {
@ -54,14 +57,14 @@ export default function TimesheetCounters() {
updateTiming();
// Only schedule further updates if we are running
if (isTimekeepRunning) {
if (isKeepRunning(timekeep)) {
const intervalID = window.setInterval(updateTiming, 1000);
return () => {
clearInterval(intervalID);
};
}
}, [timekeep, isTimekeepRunning]);
}, [timekeep]);
return (
<div className="timekeep-timers">

View file

@ -7,14 +7,15 @@ import { Platform } from "obsidian";
import { mkdir, writeFile } from "fs/promises";
import { PdfExportBehavior } from "@/settings";
import { createCSV, createMarkdownTable } from "@/export";
import { useTimekeepStore } from "@/store/timekeep-store";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeep } from "@/contexts/use-timekeep-context";
export default function TimekeepExportActions() {
const { timekeep } = useTimekeep();
const settings = useSettings();
const store = useTimekeepStore();
const onCopyMarkdown = () => {
const timekeep = store.getTimekeep();
const currentTime = moment();
const output = createMarkdownTable(timekeep, settings, currentTime);
@ -25,6 +26,7 @@ export default function TimekeepExportActions() {
};
const onCopyCSV = () => {
const timekeep = store.getTimekeep();
const currentTime = moment();
const output = createCSV(timekeep, settings, currentTime);
@ -35,6 +37,7 @@ export default function TimekeepExportActions() {
};
const onCopyJSON = () => {
const timekeep = store.getTimekeep();
const output = JSON.stringify(timekeep);
navigator.clipboard
@ -44,6 +47,8 @@ export default function TimekeepExportActions() {
};
const onSavePDF = async () => {
const timekeep = store.getTimekeep();
// Pdf exports don't work in mobile mode
if (Platform.isMobileApp) return;

View file

@ -1,8 +1,8 @@
import moment from "moment";
import { TimeEntry } from "@/schema";
import React, { useState, useCallback } from "react";
import React, { useMemo, useState } from "react";
import { useTimekeepStore } from "@/store/timekeep-store";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeep } from "@/contexts/use-timekeep-context";
import TimesheetRowEditing from "@/components/TimesheetRowEditing";
import TimesheetRowDuration from "@/components/TimesheetRowDuration";
import {
@ -21,17 +21,26 @@ import ObsidianIcon from "./ObsidianIcon";
type Props = {
entry: TimeEntry;
indent: number;
isTimekeepRunning: boolean;
};
export default function TimesheetRow({ entry, indent }: Props) {
const isSelfRunning = entry.subEntries === null && isEntryRunning(entry);
export default function TimesheetRow({
entry,
indent,
isTimekeepRunning,
}: Props) {
const settings = useSettings();
const { setTimekeep, isTimekeepRunning } = useTimekeep();
const store = useTimekeepStore();
const [editing, setEditing] = useState(false);
const isSelfRunning = useMemo(
() => entry.subEntries === null && isEntryRunning(entry),
[entry]
);
const onClickStart = () => {
setTimekeep((timekeep) => {
store.setTimekeep((timekeep) => {
// Don't start if already running
if (isKeepRunning(timekeep)) {
return timekeep;
@ -65,15 +74,15 @@ export default function TimesheetRow({ entry, indent }: Props) {
};
// Handles toggling the collapsed state for an entry
const handleToggleCollapsed = useCallback(() => {
const handleToggleCollapsed = () => {
if (entry.subEntries === null) return;
setTimekeep((timekeep) => {
store.setTimekeep((timekeep) => {
const newEntry = setEntryCollapsed(entry, !entry.collapsed);
const entries = updateEntry(timekeep.entries, entry, newEntry);
return { ...timekeep, entries };
});
}, [setTimekeep, entry]);
};
if (editing) {
return (

View file

@ -4,18 +4,6 @@ import { formatDuration } from "@/utils";
import React, { useState, useEffect } from "react";
import { isEntryRunning, getEntryDuration } from "@/timekeep";
/**
* Obtains the formatted duration string for an entry
*
* @param entry The entry
* @returns The formatted duration
*/
function getDuration(entry: TimeEntry): string {
const currentTime = moment();
const duration = getEntryDuration(entry, currentTime);
return formatDuration(duration);
}
type Props = {
entry: TimeEntry;
};
@ -26,11 +14,11 @@ type Props = {
* latest duration.
*/
export default function TimesheetRowDuration({ entry }: Props) {
const [duration, setDuration] = useState(getDuration(entry));
const [duration, setDuration] = useState(getFormattedDuration(entry));
useEffect(() => {
const isRunning = isEntryRunning(entry);
const updateTiming = () => setDuration(getDuration(entry));
const updateTiming = () => setDuration(getFormattedDuration(entry));
// Initial update
updateTiming();
@ -48,3 +36,15 @@ export default function TimesheetRowDuration({ entry }: Props) {
return <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 formatDuration(duration);
}

View file

@ -1,8 +1,9 @@
import { TimeEntry } from "@/schema";
import { useDialog } from "@/contexts/use-dialog";
import { removeEntry, updateEntry } from "@/timekeep";
import React, { useState, useEffect, FormEvent } from "react";
import { useTimekeepStore } from "@/store/timekeep-store";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeep } from "@/contexts/use-timekeep-context";
import React, { useState, useEffect, FormEvent } from "react";
import { parseEditableTimestamp, formatEditableTimestamp } from "@/utils";
import ObsidianIcon from "./ObsidianIcon";
@ -14,7 +15,8 @@ type Props = {
export default function TimesheetRowEditing({ entry, onFinishEditing }: Props) {
const settings = useSettings();
const { setTimekeep, showConfirm } = useTimekeep();
const { showConfirm } = useDialog();
const store = useTimekeepStore();
const [name, setName] = useState(entry.name);
const [startTime, setStartTime] = useState("");
@ -44,7 +46,7 @@ export default function TimesheetRowEditing({ entry, onFinishEditing }: Props) {
return;
}
setTimekeep((timekeep) => ({
store.setTimekeep((timekeep) => ({
entries: removeEntry(timekeep.entries, entry),
}));
};
@ -76,7 +78,7 @@ export default function TimesheetRowEditing({ entry, onFinishEditing }: Props) {
}
// Save the updated entry
setTimekeep((timekeep) => ({
store.setTimekeep((timekeep) => ({
entries: updateEntry(timekeep.entries, entry, newEntry),
}));

View file

@ -9,13 +9,19 @@ type Props = {
entries: TimeEntry[];
// Indentation for the entries
indent: number;
// Whether the timekeep is currently running
isTimekeepRunning: boolean;
};
/**
* Renders a collection of timesheet entries ordered based
* on the current settings
*/
export default function TimesheetRows({ entries, indent }: Props) {
export default function TimesheetRows({
entries,
indent,
isTimekeepRunning,
}: Props) {
const settings = useSettings();
// Memoized sub entries
const entriesOrdered = useMemo(
@ -25,10 +31,18 @@ export default function TimesheetRows({ entries, indent }: Props) {
return entriesOrdered.map((entry) => (
<Fragment key={getUniqueEntryHash(entry)}>
<TimesheetRow entry={entry} indent={indent} />
<TimesheetRow
entry={entry}
indent={indent}
isTimekeepRunning={isTimekeepRunning}
/>
{entry.subEntries !== null && !entry.collapsed && (
<TimesheetRows entries={entry.subEntries} indent={indent + 1} />
<TimesheetRows
entries={entry.subEntries}
indent={indent + 1}
isTimekeepRunning={isTimekeepRunning}
/>
)}
</Fragment>
));

View file

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

View file

@ -1,8 +1,8 @@
import moment from "moment";
import { formatTimestamp } from "@/utils";
import React, { useMemo, useState, FormEvent } from "react";
import { useTimekeep } from "@/contexts/use-timekeep-context";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeep, useTimekeepStore } from "@/store/timekeep-store";
import {
withEntry,
isKeepRunning,
@ -18,14 +18,18 @@ import ObsidianIcon from "./ObsidianIcon";
* name field
*/
export default function TimekeepStart() {
const { timekeep, setTimekeep, isTimekeepRunning } = useTimekeep();
const store = useTimekeepStore();
const timekeep = useTimekeep(store);
const [name, setName] = useState("");
const settings = useSettings();
const currentEntry = useMemo(
() => getRunningEntry(timekeep.entries),
[timekeep]
);
const isTimekeepRunning = currentEntry !== null;
/**
* Handles the click of the start/stop button
*/
@ -34,7 +38,7 @@ export default function TimekeepStart() {
event.preventDefault();
event.stopPropagation();
setTimekeep((timekeep) => {
store.setTimekeep((timekeep) => {
const currentTime = moment();
let entries;

View file

@ -1,12 +1,20 @@
import React from "react";
import React, { useMemo } from "react";
import { isKeepRunning } from "@/timekeep";
import TimesheetRows from "@/components/TimesheetRows";
import { useSettings } from "@/contexts/use-settings-context";
import { useTimekeep } from "@/contexts/use-timekeep-context";
import { useTimekeep, useTimekeepStore } from "@/store/timekeep-store";
export default function TimesheetTable() {
const { timekeep } = useTimekeep();
const store = useTimekeepStore();
const timekeep = useTimekeep(store);
const settings = useSettings();
// Determine whether the keep is running
const isTimekeepRunning = useMemo(
() => isKeepRunning(timekeep),
[timekeep]
);
// Max size and scroll for table with "Limit table size" option
const limitedSize: React.CSSProperties = settings.limitTableSize
? { maxHeight: 600, overflowY: "auto" }
@ -25,7 +33,11 @@ export default function TimesheetTable() {
</tr>
</thead>
<tbody>
<TimesheetRows entries={timekeep.entries} indent={0} />
<TimesheetRows
entries={timekeep.entries}
indent={0}
isTimekeepRunning={isTimekeepRunning}
/>
</tbody>
</table>
</div>

View file

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

View file

@ -1,18 +0,0 @@
import { Timekeep } from "@/schema";
import React, { Dispatch, useContext, SetStateAction } from "react";
type TimekeepContext = {
timekeep: Timekeep;
setTimekeep: Dispatch<SetStateAction<Timekeep>>;
isTimekeepRunning: boolean;
// Show a confirmation dialog, provides a promise that resolves
// to the users choice
showConfirm: (title: string, message: string) => Promise<boolean>;
};
export const TimekeepContext = React.createContext<TimekeepContext>(null!);
export function useTimekeep(): TimekeepContext {
return useContext(TimekeepContext);
}

View file

@ -25,6 +25,7 @@ import {
} from "@/timekeep";
import { Timekeep, TimeEntry } from "./schema";
import { createTimekeepStore } from "./store/timekeep-store";
import { SettingsStore, createSettingsStore } from "./store/settings-store";
export default class TimekeepPlugin extends Plugin {
@ -160,15 +161,20 @@ class TimekeepComponent extends MarkdownRenderChild {
if (this.loadResult.success) {
const timekeep = this.loadResult.timekeep;
// Create a store for the timekeep state
const timekeepStore = createTimekeepStore(
timekeep,
this.trySave.bind(this)
);
this.root.render(
React.createElement(
StrictMode,
{},
React.createElement(App, {
app: this.app,
initialState: timekeep,
timekeepStore,
settingsStore: this.settingsStore,
save: this.trySave.bind(this),
})
)
);

156
src/store/timekeep-store.ts Normal file
View file

@ -0,0 +1,156 @@
import { Timekeep } from "@/schema";
import {
useState,
useEffect,
useContext,
createContext,
SetStateAction,
} from "react";
export const TimekeepStoreContext = createContext<TimekeepStore>(null!);
export function useTimekeepStore(): TimekeepStore {
return useContext(TimekeepStoreContext);
}
/**
* Simple reactive store for storing the current settings
* and allowing the app to subscribe to changes
*/
export type TimekeepStore = {
// Getter for the current value
getTimekeep: () => Timekeep;
// Sets the current settings value and updates all subscribers
setTimekeep: (value: SetStateAction<Timekeep>) => void;
// Saves the provided timekeep state to the file
saveTimekeep: (value: Timekeep) => void;
// Save error state
getSaveError: () => boolean;
// Allows subscribing to changes using a callback
subscribe: (callback: VoidFunction) => void;
// Allows unsubscribing from changes for the provided callback
unsubscribe: (callback: VoidFunction) => void;
};
type SaveFunction = (timekeep: Timekeep) => Promise<boolean>;
type TimekeepState = {
timekeep: Timekeep;
saveError: boolean;
};
/**
* Creates a new timekeep store with the provided initial value
*
* @param initialValue The initial value
* @returns The created store
*/
export function createTimekeepStore(
initialValue: Timekeep, // Function to save the timekeep data
save: SaveFunction
): TimekeepStore {
const eventTarget = new EventTarget();
const object: TimekeepState = { timekeep: initialValue, saveError: false };
const getTimekeep = () => object.timekeep;
const setTimekeep = (value: SetStateAction<Timekeep>) => {
const newValue =
value instanceof Function ? value(object.timekeep) : value;
object.timekeep = newValue;
eventTarget.dispatchEvent(new Event("stateChange"));
// Save the updated timekeep
saveTimekeep(newValue);
};
const getSaveError = () => object.saveError;
const saveTimekeep = (value: Timekeep) => {
// Try save save the timekeep
save(value).then((isSaved) => {
const saveError = !isSaved;
if (object.saveError !== saveError) {
object.saveError = saveError;
// Dispatch the state change
eventTarget.dispatchEvent(new Event("stateChange"));
}
});
};
const subscribe = (callback: VoidFunction) =>
eventTarget.addEventListener("stateChange", callback);
const unsubscribe = (callback: VoidFunction) =>
eventTarget.removeEventListener("stateChange", callback);
return {
getTimekeep,
setTimekeep,
saveTimekeep,
getSaveError,
subscribe,
unsubscribe,
};
}
/**
* React hook to subscribe to the timekeep store. Will
* trigger state updates and re-render when the timekeep
* changes
*
* @param store The store to subscribe to
* @returns The current timekeep value in the store
*/
export function useTimekeep(store: TimekeepStore) {
const [state, setState] = useState(store.getTimekeep());
// Effect to handle state updates
useEffect(() => {
const handleUpdate = () => {
const value = store.getTimekeep();
setState(value);
};
store.subscribe(handleUpdate);
return () => {
store.unsubscribe(handleUpdate);
};
}, [setState, store]);
return state;
}
/**
* React hook to subscribe to the timekeep store save
* error state. Will trigger state updates and re-render
* when the save error state changes
*
* @param store The store to subscribe to
* @returns The current save error value in the store
*/
export function useSaveError(store: TimekeepStore) {
const [saveError, setSaveError] = useState(store.getSaveError());
// Effect to track the save error
useEffect(() => {
const handleUpdate = () => {
const value = store.getSaveError();
setSaveError(value);
};
store.subscribe(handleUpdate);
return () => {
store.unsubscribe(handleUpdate);
};
}, [setSaveError, store]);
return saveError;
}

View file

@ -1526,7 +1526,7 @@ describe("extracting code blocks", () => {
});
describe("collapse state", () => {
it("should update group collapse state", () => {
it("should update group collapse state when set to true", () => {
const currentTime = moment();
const input: TimeEntryGroup = {
@ -1550,6 +1550,29 @@ describe("collapse state", () => {
expect((collapsed as TimeEntryGroup).collapsed).toBe(true);
});
it("collapsed state should be undefined when false", () => {
const currentTime = moment();
const input: TimeEntryGroup = {
name: "Test",
startTime: null,
endTime: null,
subEntries: [
{
name: "Test",
startTime: currentTime,
endTime: currentTime,
subEntries: null,
},
],
};
const collapsed = setEntryCollapsed(input, false);
expect(collapsed.subEntries).not.toBeNull();
expect((collapsed as TimeEntryGroup).collapsed).toBeUndefined();
});
it("should not set collapse state on single entry", () => {
const currentTime = moment();

View file

@ -188,7 +188,14 @@ export function setEntryCollapsed(
// Entry cannot be collapsed
if (entry.subEntries === null) return entry;
return { ...entry, collapsed };
const newEntry: TimeEntry = { ...entry, collapsed };
// Delete the collapsed field if not collapsed
if (!collapsed) {
delete newEntry.collapsed;
}
return newEntry;
}
/**

File diff suppressed because one or more lines are too long