mirror of
https://github.com/jacobtread/obsidian-timekeep.git
synced 2026-07-22 10:10:27 +00:00
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:
parent
f0b881e714
commit
9de817f1fc
18 changed files with 392 additions and 164 deletions
11
README.md
11
README.md
|
|
@ -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)
|
||||
123
src/App.tsx
123
src/App.tsx
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
));
|
||||
|
|
|
|||
36
src/components/TimesheetSaveError.tsx
Normal file
36
src/components/TimesheetSaveError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
27
src/contexts/use-dialog.ts
Normal file
27
src/contexts/use-dialog.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
10
src/main.ts
10
src/main.ts
|
|
@ -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
156
src/store/timekeep-store.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue