feat: sorting, options for sort order and where to put unstarted entries, replace reverse segment order option

This commit is contained in:
Jacobtread 2024-12-28 21:59:59 +13:00
parent eef3dce6be
commit e3bd533cbc
8 changed files with 601 additions and 33 deletions

View file

@ -6,6 +6,7 @@
"Roboto",
"stylesheet",
"timekeep",
"timesheet"
"timesheet",
"unstarted"
]
}

View file

@ -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]
);

View file

@ -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));

View file

@ -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<void> {
// 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(

View file

@ -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,
}));
});
});

View file

@ -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,
};

View file

@ -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);
});
});

View file

@ -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;
}
/**