diff --git a/src/components/TimekeepName.tsx b/src/components/TimekeepName.tsx
index f2578fb..4dc9b0a 100644
--- a/src/components/TimekeepName.tsx
+++ b/src/components/TimekeepName.tsx
@@ -14,15 +14,23 @@ type Props = {
export default function TimekeepName({ name }: Props) {
const segments = parseNameSegments(name);
- return segments.map((segment, index) => {
- switch (segment.type) {
- case NameSegmentType.Text:
- return ;
+ return (
+ <>
+ {segments.map((segment, index) => {
+ switch (segment.type) {
+ case NameSegmentType.Text:
+ return (
+
+ );
- case NameSegmentType.Link:
- return ;
- }
- });
+ case NameSegmentType.Link:
+ return (
+
+ );
+ }
+ })}
+ >
+ );
}
function TimekeepNameText({ segment }: { segment: NameSegmentText }) {
diff --git a/src/components/TimesheetRow.tsx b/src/components/TimesheetRow.tsx
index ab68016..04f91a4 100644
--- a/src/components/TimesheetRow.tsx
+++ b/src/components/TimesheetRow.tsx
@@ -94,6 +94,13 @@ export default function TimesheetRow({ entry, indent }: Props) {
className="timekeep-entry-name"
title={entry.name}
onClick={handleToggleCollapsed}>
+ {entry.folder && (
+
+ )}
+
{entry.subEntries !== null && (
diff --git a/src/styles.css b/src/styles.css
index 2f04d73..670aab6 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -219,6 +219,12 @@
vertical-align: middle;
}
+.timekeep-folder-icon {
+ margin-right: 0.5rem;
+ cursor: pointer;
+ vertical-align: middle;
+}
+
.timekeep-start-note {
font-size: 0.8rem;
color: #777;
diff --git a/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderExtendSubEntries.ts b/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderExtendSubEntries.ts
new file mode 100644
index 0000000..d0635d0
--- /dev/null
+++ b/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderExtendSubEntries.ts
@@ -0,0 +1,44 @@
+import moment from "moment";
+
+export const currentTime = moment();
+
+export const input = {
+ id: "9054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "Folder",
+ startTime: null,
+ endTime: null,
+ subEntries: [
+ {
+ id: "8054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "New Entry",
+ startTime: currentTime,
+ endTime: currentTime,
+ subEntries: null,
+ },
+ ],
+ folder: true,
+};
+
+export const expected = {
+ id: "9054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "Folder",
+ startTime: null,
+ endTime: null,
+ subEntries: [
+ {
+ id: "8054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "New Entry",
+ startTime: currentTime,
+ endTime: currentTime,
+ subEntries: null,
+ },
+ {
+ id: "5054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "New Entry 2",
+ startTime: currentTime,
+ endTime: null,
+ subEntries: null,
+ },
+ ],
+ folder: true,
+};
diff --git a/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderPopulateSubEntries.ts b/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderPopulateSubEntries.ts
new file mode 100644
index 0000000..51fda0d
--- /dev/null
+++ b/src/timekeep/__fixtures__/manipulating/adding_sub_entry/addFolderPopulateSubEntries.ts
@@ -0,0 +1,29 @@
+import moment from "moment";
+
+export const currentTime = moment();
+
+export const input = {
+ id: "9054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "Folder",
+ startTime: null,
+ endTime: null,
+ subEntries: null,
+ folder: true,
+};
+
+export const expected = {
+ id: "9054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "Folder",
+ startTime: null,
+ endTime: null,
+ subEntries: [
+ {
+ id: "8054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "New Entry",
+ startTime: currentTime,
+ endTime: null,
+ subEntries: null,
+ },
+ ],
+ folder: true,
+};
diff --git a/src/timekeep/__fixtures__/manipulating/remove_entry/removeEntryFolder.ts b/src/timekeep/__fixtures__/manipulating/remove_entry/removeEntryFolder.ts
new file mode 100644
index 0000000..a14a0a8
--- /dev/null
+++ b/src/timekeep/__fixtures__/manipulating/remove_entry/removeEntryFolder.ts
@@ -0,0 +1,32 @@
+import moment from "moment";
+
+export const currentTime = moment();
+export const entryToRemove = {
+ id: "9054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "Block 1",
+ startTime: currentTime,
+ endTime: currentTime,
+ subEntries: null,
+};
+
+export const entries = [
+ {
+ id: "8054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "Block 3",
+ startTime: null,
+ endTime: null,
+ subEntries: [entryToRemove],
+ folder: true,
+ },
+];
+
+export const expectedEntries = [
+ {
+ id: "8054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "Block 3",
+ startTime: null,
+ endTime: null,
+ subEntries: [],
+ folder: true,
+ },
+];
diff --git a/src/timekeep/__fixtures__/manipulating/start_entry/startNestedFolderEntry.ts b/src/timekeep/__fixtures__/manipulating/start_entry/startNestedFolderEntry.ts
new file mode 100644
index 0000000..dcf1a7a
--- /dev/null
+++ b/src/timekeep/__fixtures__/manipulating/start_entry/startNestedFolderEntry.ts
@@ -0,0 +1,33 @@
+import moment from "moment";
+
+export const currentTime = moment();
+
+export const targetEntry = {
+ id: "9054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "Folder Entry",
+ startTime: null,
+ endTime: null,
+ subEntries: null,
+ folder: true,
+};
+
+export const input = [targetEntry];
+
+export const expected = [
+ {
+ id: "9054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "Folder Entry",
+ startTime: null,
+ endTime: null,
+ subEntries: [
+ {
+ id: "9054dee3-8c15-493b-ad31-f070e08c2699",
+ name: "Part 1",
+ startTime: currentTime,
+ endTime: null,
+ subEntries: null,
+ },
+ ],
+ folder: true,
+ },
+];
diff --git a/src/timekeep/create.test.ts b/src/timekeep/create.test.ts
index 8328558..7a829cd 100644
--- a/src/timekeep/create.test.ts
+++ b/src/timekeep/create.test.ts
@@ -51,6 +51,26 @@ describe("withSubEntry", () => {
);
});
+ it("adding first entry for folder should populate subentries", async () => {
+ const { input, currentTime, expected } = await import(
+ "./__fixtures__/manipulating/adding_sub_entry/addFolderPopulateSubEntries"
+ );
+ const output = withSubEntry(input, "New Entry", currentTime);
+ expect(stripEntryRuntimeData(output)).toEqual(
+ stripEntryRuntimeData(expected)
+ );
+ });
+
+ it("adding first entry for folder should extend subentries", async () => {
+ const { input, currentTime, expected } = await import(
+ "./__fixtures__/manipulating/adding_sub_entry/addFolderExtendSubEntries"
+ );
+ const output = withSubEntry(input, "New Entry 2", currentTime);
+ expect(stripEntryRuntimeData(output)).toEqual(
+ stripEntryRuntimeData(expected)
+ );
+ });
+
it("adding to group should extend sub entries", async () => {
const { input, currentTime, expected } = await import(
"./__fixtures__/manipulating/adding_sub_entry/addToGroupExtendSubEntries"
diff --git a/src/timekeep/create.ts b/src/timekeep/create.ts
index cfe61a8..3287513 100644
--- a/src/timekeep/create.ts
+++ b/src/timekeep/create.ts
@@ -112,6 +112,15 @@ function getSubEntryName(name: string, groupEntry: TimeEntryGroup) {
* @returns The group entry
*/
function makeGroupEntry(entry: TimeEntry): TimeEntryGroup {
+ if (entry.folder) {
+ return {
+ ...entry,
+ subEntries: entry.subEntries ?? [],
+ startTime: null,
+ endTime: null,
+ };
+ }
+
if (entry.subEntries !== null) {
return entry;
}
diff --git a/src/timekeep/schema.ts b/src/timekeep/schema.ts
index 9184cbe..0a10530 100644
--- a/src/timekeep/schema.ts
+++ b/src/timekeep/schema.ts
@@ -35,6 +35,9 @@ const TIME_ENTRY_SINGLE = z
.transform((value) => (value === null ? null : moment(value))),
// Single entries have no children
subEntries: z.null(),
+ // Optional field to indicate the entry should stay as a group when non-started and should only create
+ // sub entries when starting
+ folder: z.boolean().optional(),
})
// At runtime a unique ID is inserted
.transform((entry) => ({
@@ -49,7 +52,11 @@ const TIME_ENTRY_GROUP_BASE = z.object({
endTime: z.null(),
// Optional field to indicate the entry is collapsed
collapsed: z.boolean().optional(),
+ // Optional field to indicate the entry should stay as a group when non-started and should only create
+ // sub entries when starting
+ folder: z.boolean().optional(),
});
+
// Schema for a time entry group
const TIME_ENTRY_GROUP: z.ZodType<
TimeEntryGroup,
diff --git a/src/timekeep/start.test.ts b/src/timekeep/start.test.ts
index ab98503..4f85edb 100644
--- a/src/timekeep/start.test.ts
+++ b/src/timekeep/start.test.ts
@@ -42,6 +42,17 @@ describe("startNewNestedEntry", () => {
);
});
+ it("starting a new entry within a folder should create a subentry", async () => {
+ const { currentTime, targetEntry, input, expected } = await import(
+ "./__fixtures__/manipulating/start_entry/startNestedFolderEntry"
+ );
+
+ const output = startNewNestedEntry(currentTime, targetEntry.id, input);
+ expect(stripEntriesRuntimeData(output)).toEqual(
+ stripEntriesRuntimeData(expected)
+ );
+ });
+
it("starting a new entry should stop any running entries", async () => {
const { currentTime, targetEntry, input, expected } = await import(
"./__fixtures__/manipulating/start_entry/startNotStartedEntry"
diff --git a/src/timekeep/start.ts b/src/timekeep/start.ts
index 9560ec5..8b51020 100644
--- a/src/timekeep/start.ts
+++ b/src/timekeep/start.ts
@@ -49,8 +49,12 @@ export function startNewNestedEntry(
return entries;
}
- // If the entry has been started or is a group create a new child entry
- if (currentEntry.subEntries !== null || currentEntry.startTime !== null) {
+ // If the entry has been started, is a group, or is a folder group create a new child entry
+ if (
+ currentEntry.subEntries !== null ||
+ currentEntry.startTime !== null ||
+ currentEntry.folder
+ ) {
return updateEntry(
entries,
// Ensure the current entry is ended
diff --git a/src/timekeep/update.test.ts b/src/timekeep/update.test.ts
index 972d88d..298fb42 100644
--- a/src/timekeep/update.test.ts
+++ b/src/timekeep/update.test.ts
@@ -106,6 +106,14 @@ describe("removeEntry", () => {
const updated = removeEntry(entries, entryToRemove);
expect(updated).toEqual(expectedEntries);
});
+
+ it("should not collapse folder on empty entries", async () => {
+ const { entries, entryToRemove, expectedEntries } = await import(
+ "./__fixtures__/manipulating/remove_entry/removeEntryFolder"
+ );
+ const updated = removeEntry(entries, entryToRemove);
+ expect(updated).toEqual(expectedEntries);
+ });
});
describe("removeSubEntry", () => {
diff --git a/src/timekeep/update.ts b/src/timekeep/update.ts
index 45d1a2b..9877f42 100644
--- a/src/timekeep/update.ts
+++ b/src/timekeep/update.ts
@@ -119,7 +119,8 @@ export function removeEntry(
// Add non-empty entries to the accumulator
if (
collapsedEntry.subEntries === null ||
- collapsedEntry.subEntries.length > 0
+ collapsedEntry.subEntries.length > 0 ||
+ collapsedEntry.folder
) {
acc.push(collapsedEntry);
}
@@ -157,6 +158,11 @@ export function removeSubEntry(entry: TimeEntry, target: TimeEntry): TimeEntry {
* @returns The collapsed entry
*/
function makeEntrySingle(target: TimeEntry): TimeEntry {
+ // Cannot collapse folders
+ if (target.folder) {
+ return target;
+ }
+
// Target has no entries to collapse
if (target.subEntries === null) {
return target;