From e3bd533cbcd15cb4d863ad257d3fd7fef37a1bcc Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Sat, 28 Dec 2024 21:59:59 +1300 Subject: [PATCH] feat: sorting, options for sort order and where to put unstarted entries, replace reverse segment order option --- cspell.json | 3 +- src/components/TimesheetRows.tsx | 4 +- src/export/index.ts | 4 +- src/main.ts | 18 +- src/settings-tab.ts | 45 +++- src/settings.ts | 30 ++- src/timekeep.test.ts | 355 ++++++++++++++++++++++++++++++- src/timekeep.ts | 175 ++++++++++++++- 8 files changed, 601 insertions(+), 33 deletions(-) diff --git a/cspell.json b/cspell.json index b360c73..c9da895 100644 --- a/cspell.json +++ b/cspell.json @@ -6,6 +6,7 @@ "Roboto", "stylesheet", "timekeep", - "timesheet" + "timesheet", + "unstarted" ] } diff --git a/src/components/TimesheetRows.tsx b/src/components/TimesheetRows.tsx index 02873ab..83a42d4 100644 --- a/src/components/TimesheetRows.tsx +++ b/src/components/TimesheetRows.tsx @@ -2,7 +2,7 @@ import { TimeEntry } from "@/schema"; import React, { useMemo, Fragment } from "react"; import TimesheetRow from "@/components/TimesheetRow"; import { useSettings } from "@/contexts/use-settings-context"; -import { getEntriesOrdered, getUniqueEntryHash } from "@/timekeep"; +import { getEntriesSorted, getUniqueEntryHash } from "@/timekeep"; type Props = { // Collection of entries @@ -25,7 +25,7 @@ export default function TimesheetRows({ const settings = useSettings(); // Memoized sub entries const entriesOrdered = useMemo( - () => getEntriesOrdered(entries, settings), + () => getEntriesSorted(entries, settings), [entries, settings] ); diff --git a/src/export/index.ts b/src/export/index.ts index 7c99ac9..b1b7475 100644 --- a/src/export/index.ts +++ b/src/export/index.ts @@ -2,7 +2,7 @@ import { TimeEntry } from "@/schema"; import type { Moment } from "moment"; import { TimekeepSettings } from "@/settings"; import { formatDuration, formatTimestamp } from "@/utils"; -import { getEntryDuration, getEntriesOrdered } from "@/timekeep"; +import { getEntryDuration, getEntriesSorted } from "@/timekeep"; export { createCSV } from "./csv"; export { createMarkdownTable } from "./markdown-table"; @@ -62,7 +62,7 @@ export function createRawTableEntries( ]; if (entry.subEntries) { - const entries = getEntriesOrdered(entry.subEntries, settings); + const entries = getEntriesSorted(entry.subEntries, settings); for (const entry of entries) { rows.push(...createRawTableEntries(entry, settings, currentTime)); diff --git a/src/main.ts b/src/main.ts index c94a1f1..c33b601 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,8 +2,8 @@ import { Moment } from "moment"; import { Store, createStore } from "@/store"; import { TimekeepSettingsTab } from "@/settings-tab"; import { PluginManifest, App as ObsidianApp } from "obsidian"; -import { defaultSettings, TimekeepSettings } from "@/settings"; import { Plugin, MarkdownPostProcessorContext } from "obsidian"; +import { SortOrder, defaultSettings, TimekeepSettings } from "@/settings"; import { load, isKeepRunning, @@ -51,11 +51,21 @@ export default class TimekeepPlugin extends Plugin { } async onload(): Promise { - // Load saved settings and combine with defaults - this.settingsStore.setState( - Object.assign({}, defaultSettings, await this.loadData()) + const loadedSettings: TimekeepSettings = Object.assign( + {}, + defaultSettings, + await this.loadData() ); + // Compatibility with old reverse segment order + if (loadedSettings.reverseSegmentOrder) { + delete loadedSettings.reverseSegmentOrder; + loadedSettings.sortOrder = SortOrder.REVERSE_INSERTION; + } + + // Load saved settings and combine with defaults + this.settingsStore.setState(loadedSettings); + this.addSettingTab(new TimekeepSettingsTab(this.app, this)); this.registerMarkdownCodeBlockProcessor( diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 96d10ed..1466c15 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -2,7 +2,9 @@ import { Store } from "@/store"; import TimekeepPlugin from "@/main"; import { App, Setting, PluginSettingTab } from "obsidian"; import { + SortOrder, DurationFormat, + UnstartedOrder, defaultSettings, TimekeepSettings, PdfExportBehavior, @@ -217,16 +219,49 @@ export class TimekeepSettingsTab extends PluginSettingTab { }); new Setting(this.containerEl) - .setName("Display segments in reverse order") + .setName("Sort order") .setDesc( - "Whether older tracker segments should be displayed towards the bottom of the tracker, rather than the top." + "How entries should be sorted both when viewing and exporting" ) - .addToggle((t) => { - t.setValue(settings.reverseSegmentOrder); + + .addDropdown((t) => { + t.addOptions({ + [SortOrder.INSERTION]: + "Insertion - Don't sort, leave entries in the order they were created", + [SortOrder.REVERSE_INSERTION]: + "Reverse Insertion - Opposite order to how entries were inserted", + [SortOrder.NEWEST_START]: + "Newest First - Sort most recently started entries to the start", + [SortOrder.OLDEST_START]: + "Newest Last - Sort most recently started entries to the end", + }); + t.setValue(String(settings.sortOrder)); t.onChange((v) => { this.settingsStore.setState((currentValue) => ({ ...currentValue, - reverseSegmentOrder: v, + sortOrder: v as SortOrder, + })); + }); + }); + + new Setting(this.containerEl) + .setName("Unstarted Sort order") + .setDesc( + "Where in the order should unstarted entries be put (Only applied when using 'Newest First' or 'Newest Last' sort order)" + ) + + .addDropdown((t) => { + t.addOptions({ + [UnstartedOrder.FIRST]: + "First - Put non started entries at the top of the list", + [UnstartedOrder.LAST]: + "Last - Put non started entries at the bottom of the list", + }); + t.setValue(String(settings.unstartedOrder)); + t.onChange((v) => { + this.settingsStore.setState((currentValue) => ({ + ...currentValue, + unstartedOrder: v as UnstartedOrder, })); }); }); diff --git a/src/settings.ts b/src/settings.ts index 4c0633f..314f733 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -16,6 +16,27 @@ export enum DurationFormat { DECIMAL = "DECIMAL", } +export enum SortOrder { + // Don't sort the the list, maintain insertion order + INSERTION = "INSERTION", + + // Flip the insertion order + REVERSE_INSERTION = "REVERSE_INSERTION", + + // Sort by the most recently started + NEWEST_START = "NEWEST_START", + + // Sort by the oldest started entry + OLDEST_START = "OLDEST_START", +} + +export enum UnstartedOrder { + // Sort unstarted items to the start + FIRST = "FIRST", + // Sort unstarted items to the end + LAST = "LAST", +} + export interface TimekeepSettings { csvDelimiter: string; csvTitle: boolean; @@ -26,11 +47,14 @@ export interface TimekeepSettings { pdfExportBehavior: PdfExportBehavior; pdfDateFormat: string; pdfRowDateFormat: string; - reverseSegmentOrder: boolean; + reverseSegmentOrder?: boolean; timestampFormat: string; showDecimalHours: boolean; exportDurationFormat: DurationFormat; formatCopiedJSON: boolean; + + sortOrder: SortOrder; + unstartedOrder: UnstartedOrder; } export const defaultSettings: TimekeepSettings = { @@ -44,9 +68,11 @@ export const defaultSettings: TimekeepSettings = { editableTimestampFormat: "YYYY-MM-DD HH:mm:ss", csvTitle: true, csvDelimiter: ",", - reverseSegmentOrder: false, limitTableSize: true, showDecimalHours: true, exportDurationFormat: DurationFormat.SHORT, formatCopiedJSON: false, + + sortOrder: SortOrder.INSERTION, + unstartedOrder: UnstartedOrder.LAST, }; diff --git a/src/timekeep.test.ts b/src/timekeep.test.ts index 7fcc525..044d4d5 100644 --- a/src/timekeep.test.ts +++ b/src/timekeep.test.ts @@ -1,7 +1,12 @@ import moment from "moment"; import { Timekeep, TimeEntry, TimeEntryGroup } from "./schema"; -import { defaultSettings, TimekeepSettings } from "./settings"; +import { + SortOrder, + UnstartedOrder, + defaultSettings, + TimekeepSettings, +} from "./settings"; import { load, LoadError, @@ -16,7 +21,7 @@ import { getRunningEntry, getEntryDuration, getTotalDuration, - getEntriesOrdered, + getEntriesSorted, setEntryCollapsed, getUniqueEntryHash, stopRunningEntries, @@ -1149,9 +1154,47 @@ describe("ordering entries", () => { endTime: currentTime, subEntries: null, }, + { + name: "Part 3", + startTime: null, + endTime: null, + subEntries: [ + { + name: "Part 3 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 3 2", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + ], + }, ]; const expected = [ + { + name: "Part 3", + startTime: null, + endTime: null, + subEntries: [ + { + name: "Part 3 2", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 3 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + ], + }, { name: "Part 2", startTime: currentTime, @@ -1167,9 +1210,309 @@ describe("ordering entries", () => { ]; const settings: TimekeepSettings = defaultSettings; - settings.reverseSegmentOrder = true; + settings.sortOrder = SortOrder.REVERSE_INSERTION; - const output = getEntriesOrdered(input, settings); + const output = getEntriesSorted(input, settings); + expect(output).toEqual(expected); + }); + + it("should be in newest first order", () => { + const currentTime = moment(); + + const futureStartTime = currentTime.clone().add(5000, "ms"); + + const input = [ + { + name: "Part 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 1 null", + startTime: null, + endTime: null, + subEntries: null, + }, + { + name: "Part 2 null", + startTime: null, + endTime: null, + subEntries: null, + }, + { + name: "Part 2", + startTime: futureStartTime, + endTime: futureStartTime, + subEntries: null, + }, + { + name: "Part 3", + startTime: null, + endTime: null, + subEntries: [ + { + name: "Part 3 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 3 2", + startTime: futureStartTime, + endTime: futureStartTime, + subEntries: null, + }, + ], + }, + ]; + + const expected = [ + { + name: "Part 2", + startTime: futureStartTime, + endTime: futureStartTime, + subEntries: null, + }, + { + name: "Part 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 3", + startTime: null, + endTime: null, + subEntries: [ + { + name: "Part 3 2", + startTime: futureStartTime, + endTime: futureStartTime, + subEntries: null, + }, + { + name: "Part 3 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + ], + }, + { + name: "Part 1 null", + startTime: null, + endTime: null, + subEntries: null, + }, + { + name: "Part 2 null", + startTime: null, + endTime: null, + subEntries: null, + }, + ]; + + const settings: TimekeepSettings = defaultSettings; + settings.sortOrder = SortOrder.NEWEST_START; + + const output = getEntriesSorted(input, settings); + expect(output).toEqual(expected); + }); + + it("should be in newest last order", () => { + const currentTime = moment(); + + const futureStartTime = currentTime.clone().add(5000, "ms"); + + const input = [ + { + name: "Part 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 2", + startTime: futureStartTime, + endTime: futureStartTime, + subEntries: null, + }, + { + name: "Part 1 null", + startTime: null, + endTime: null, + subEntries: null, + }, + { + name: "Part 3", + startTime: null, + endTime: null, + subEntries: [ + { + name: "Part 3 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 3 2", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + ], + }, + ]; + + const expected = [ + { + name: "Part 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 3", + startTime: null, + endTime: null, + subEntries: [ + { + name: "Part 3 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 3 2", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + ], + }, + { + name: "Part 2", + startTime: futureStartTime, + endTime: futureStartTime, + subEntries: null, + }, + { + name: "Part 1 null", + startTime: null, + endTime: null, + subEntries: null, + }, + ]; + + const settings: TimekeepSettings = defaultSettings; + settings.sortOrder = SortOrder.OLDEST_START; + + const output = getEntriesSorted(input, settings); + expect(output).toEqual(expected); + }); + it("should be in newest last order with nulls first", () => { + const currentTime = moment(); + + const futureStartTime = currentTime.clone().add(5000, "ms"); + + const input = [ + { + name: "Part 2 null", + startTime: null, + endTime: null, + subEntries: null, + }, + { + name: "Part 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 2", + startTime: futureStartTime, + endTime: futureStartTime, + subEntries: null, + }, + { + name: "Part 1 null", + startTime: null, + endTime: null, + subEntries: null, + }, + { + name: "Part 3", + startTime: null, + endTime: null, + subEntries: [ + { + name: "Part 3 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 3 2", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + ], + }, + ]; + + const expected = [ + { + name: "Part 2 null", + startTime: null, + endTime: null, + subEntries: null, + }, + { + name: "Part 1 null", + startTime: null, + endTime: null, + subEntries: null, + }, + { + name: "Part 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 3", + startTime: null, + endTime: null, + subEntries: [ + { + name: "Part 3 1", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + { + name: "Part 3 2", + startTime: currentTime, + endTime: currentTime, + subEntries: null, + }, + ], + }, + { + name: "Part 2", + startTime: futureStartTime, + endTime: futureStartTime, + subEntries: null, + }, + ]; + + const settings: TimekeepSettings = defaultSettings; + settings.sortOrder = SortOrder.OLDEST_START; + settings.unstartedOrder = UnstartedOrder.FIRST; + + const output = getEntriesSorted(input, settings); expect(output).toEqual(expected); }); @@ -1207,9 +1550,9 @@ describe("ordering entries", () => { ]; const settings: TimekeepSettings = defaultSettings; - settings.reverseSegmentOrder = false; + settings.sortOrder = SortOrder.INSERTION; - const output = getEntriesOrdered(input, settings); + const output = getEntriesSorted(input, settings); expect(output).toEqual(expected); }); }); diff --git a/src/timekeep.ts b/src/timekeep.ts index a6ce028..57ce310 100644 --- a/src/timekeep.ts +++ b/src/timekeep.ts @@ -1,7 +1,7 @@ import type { Moment } from "moment"; import { strHash } from "@/utils/text"; import { isEmptyString } from "@/utils"; -import { TimekeepSettings } from "@/settings"; +import { SortOrder, UnstartedOrder, TimekeepSettings } from "@/settings"; import { TIMEKEEP, Timekeep, TimeEntry, TimeEntryGroup } from "@/schema"; export type LoadResult = LoadSuccess | LoadError; @@ -475,23 +475,176 @@ export function getTotalDuration( } /** - * Provides a new list of time entires re-ordered to match - * the current timekeep settings ordering + * Provides a sorted copy of the provided entries list. * - * @param entries The collection of entries - * @param settings The timekeep settings - * @returns The entries in the timekeep order + * Recursively sorts the groups and sorts groups based + * on the time entries within the group + * + * @param entries The list of entries + * @param settings The timekeep settings for which order to use + * @returns The sorted entries list */ -export function getEntriesOrdered( +export function getEntriesSorted( entries: TimeEntry[], settings: TimekeepSettings ): TimeEntry[] { - // Reverse ordered entries - if (settings.reverseSegmentOrder) { - return entries.slice().reverse(); + // List order should be unchanged + if (settings.sortOrder === SortOrder.INSERTION) { + return entries; } - return entries; + // Reverse insertion is just .reverse on all the arrays + if (settings.sortOrder === SortOrder.REVERSE_INSERTION) { + return entries + .map((entry): TimeEntry => { + if (entry.subEntries !== null) { + return { + ...entry, + subEntries: getEntriesSorted( + entry.subEntries, + settings + ), + }; + } + + return entry; + }) + .reverse(); + } + + return stripEntriesIndex( + entries + // Map entries to recursively sort the subEntries + .map((entry, index): TimeEntry & { index: number } => { + if (entry.subEntries !== null) { + return { + ...entry, + subEntries: getEntriesSorted( + entry.subEntries, + settings + ), + index, + }; + } + + return { ...entry, index }; + }) + // Sort by comparator + .sort( + createEntriesComparator( + settings.sortOrder === SortOrder.NEWEST_START, + settings.unstartedOrder + ) + ) + ); +} + +/** + * Strips the "index" property from items, this property + * is only used for sorting and needs to be removed after + * + * @param entries The entries to strip the index from + * @returns The entries without the inmdex + */ +function stripEntriesIndex( + entries: (TimeEntry & { + index: number; + })[] +) { + return ( + entries + // Map entries to recursively sort the subEntries + .map(({ index: _, ...entry }): TimeEntry => { + if (entry.subEntries !== null) { + return { + ...entry, + subEntries: stripEntriesIndex( + entry.subEntries as (TimeEntry & { + index: number; + })[] + ), + }; + } + + return entry; + }) + ); +} + +/** + * Creates a comparator function for stable sorting a list + * of entries + * + * Entries are sorted in newest/oldest order based on the value + * provided + * + * Any entries without a start time are sorted based on their + * original order + * + * @param newest Whether to sort based on newest or oldest entries + * @returns The comparator function + */ +function createEntriesComparator( + newest: boolean, + unstartedOrder: UnstartedOrder +) { + return ( + a: TimeEntry & { index: number }, + b: TimeEntry & { index: number } + ): number => { + // Get the start time for both + const aStartTime = getStartTime(a, newest); + const bStartTime = getStartTime(b, newest); + + // Sort newest when both have a start time + if (aStartTime && bStartTime) { + return newest + ? bStartTime.diff(aStartTime) + : aStartTime.diff(bStartTime); + } + + if (aStartTime) return unstartedOrder === UnstartedOrder.FIRST ? 1 : -1; + if (bStartTime) return unstartedOrder === UnstartedOrder.FIRST ? -1 : 1; + + // Fallback to stable sort using the original index + return a.index - b.index; + }; +} + +/** + * Gets either the newest or oldest start time from a entry + * + * @param entry The entry to get the start time from + * @param newest Whether to get the newest or oldest + * @returns The start time or null if none were available + */ +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; + } + + // 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; + } + } + + return previousValue; + }, + null as Moment | null + ); + } + + return entry.startTime; } /**