Merge pull request #64 from jacobtread/feat-folders

feat: Folder entries
This commit is contained in:
Jacob 2026-01-21 11:52:46 +13:00 committed by GitHub
commit 68d82b2c2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 235 additions and 11 deletions

View file

@ -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 <TimekeepNameText key={index} segment={segment} />;
return (
<>
{segments.map((segment, index) => {
switch (segment.type) {
case NameSegmentType.Text:
return (
<TimekeepNameText key={index} segment={segment} />
);
case NameSegmentType.Link:
return <TimekeepNameLink key={index} segment={segment} />;
}
});
case NameSegmentType.Link:
return (
<TimekeepNameLink key={index} segment={segment} />
);
}
})}
</>
);
}
function TimekeepNameText({ segment }: { segment: NameSegmentText }) {

View file

@ -94,6 +94,13 @@ export default function TimesheetRow({ entry, indent }: Props) {
className="timekeep-entry-name"
title={entry.name}
onClick={handleToggleCollapsed}>
{entry.folder && (
<ObsidianIcon
icon="folder"
className="timekeep-folder-icon"
/>
)}
<TimekeepName name={entry.name} />
{entry.subEntries !== null && (

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"

View file

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

View file

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

View file

@ -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"

View file

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

View file

@ -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", () => {

View file

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