From dd77c0efa377868600ec414f29d0eae63f58925c Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Sun, 4 Aug 2024 17:32:51 +1200 Subject: [PATCH 1/2] feat: allow choosing duration format, rename duration format code Allows change the format for duration when copying as CSV/markdown. Short, Long, and Decimal. Time function naming has been updated accordingly --- src/components/TimesheetCounters.tsx | 10 ++--- src/components/TimesheetRowDuration.tsx | 4 +- src/components/pdf/TimesheetPdf.tsx | 8 ++-- src/components/pdf/TimesheetPdfTable.tsx | 4 +- src/export/index.ts | 7 ++- src/export/markdown-table.ts | 15 +++++-- src/settings-tab.ts | 23 ++++++++++ src/settings.ts | 11 +++++ src/utils/time.test.ts | 55 ++++++++++++++++++++++-- src/utils/time.ts | 54 ++++++++++++++++++----- 10 files changed, 158 insertions(+), 33 deletions(-) diff --git a/src/components/TimesheetCounters.tsx b/src/components/TimesheetCounters.tsx index ff700cf..1b0fcd7 100644 --- a/src/components/TimesheetCounters.tsx +++ b/src/components/TimesheetCounters.tsx @@ -4,7 +4,7 @@ import { Timekeep } from "@/schema"; import React, { useState, useEffect } from "react"; import { useSettings } from "@/contexts/use-settings-context"; import { useTimekeepStore } from "@/contexts/use-timekeep-store"; -import { formatDuration, formatDurationHoursTrunc } from "@/utils"; +import { formatDurationLong, formatDurationShort } from "@/utils"; import { isKeepRunning, getRunningEntry, @@ -36,10 +36,10 @@ function getTimingState(timekeep: Timekeep): TimingState { return { running: runningEntry !== null, - current: formatDuration(current), - currentShort: formatDurationHoursTrunc(current), - total: formatDuration(total), - totalShort: formatDurationHoursTrunc(total), + current: formatDurationLong(current), + currentShort: formatDurationShort(current), + total: formatDurationLong(total), + totalShort: formatDurationShort(total), }; } diff --git a/src/components/TimesheetRowDuration.tsx b/src/components/TimesheetRowDuration.tsx index 3805c93..2324a21 100644 --- a/src/components/TimesheetRowDuration.tsx +++ b/src/components/TimesheetRowDuration.tsx @@ -1,6 +1,6 @@ import moment from "moment"; import { TimeEntry } from "@/schema"; -import { formatDuration } from "@/utils"; +import { formatDurationLong } from "@/utils"; import React, { useState, useEffect } from "react"; import { isEntryRunning, getEntryDuration } from "@/timekeep"; @@ -46,5 +46,5 @@ export default function TimesheetRowDuration({ entry }: Props) { function getFormattedDuration(entry: TimeEntry): string { const currentTime = moment(); const duration = getEntryDuration(entry, currentTime); - return formatDuration(duration); + return formatDurationLong(duration); } diff --git a/src/components/pdf/TimesheetPdf.tsx b/src/components/pdf/TimesheetPdf.tsx index 05264d4..dafe3bf 100644 --- a/src/components/pdf/TimesheetPdf.tsx +++ b/src/components/pdf/TimesheetPdf.tsx @@ -6,8 +6,8 @@ import { TimekeepSettings } from "@/settings"; import { Page, View, Text, Document, StyleSheet } from "@/pdf"; import { formatPdfDate, - formatDuration, - formatDurationHoursTrunc, + formatDurationLong, + formatDurationShort, } from "@/utils"; import TimesheetPdfTable from "./TimesheetPdfTable"; @@ -83,8 +83,8 @@ export default function TimesheetPdf({ const duration = getTotalDuration(data.entries, currentTime); const currentDate = formatPdfDate(currentTime, settings); - const totalDuration = formatDuration(duration); - const totalDurationShort = formatDurationHoursTrunc(duration); + const totalDuration = formatDurationLong(duration); + const totalDurationShort = formatDurationShort(duration); // Individual field within the details section const DetailField = ({ name, value }: { name: string; value: string }) => ( diff --git a/src/components/pdf/TimesheetPdfTable.tsx b/src/components/pdf/TimesheetPdfTable.tsx index a734443..29bdf40 100644 --- a/src/components/pdf/TimesheetPdfTable.tsx +++ b/src/components/pdf/TimesheetPdfTable.tsx @@ -4,7 +4,7 @@ import { getEntryDuration } from "@/timekeep"; import { TimekeepSettings } from "@/settings"; import { Timekeep, TimeEntry } from "@/schema"; import { View, Text, StyleSheet } from "@/pdf"; -import { formatDuration, formatPdfRowDate } from "@/utils"; +import { formatDurationLong, formatPdfRowDate } from "@/utils"; type Props = { data: Timekeep; @@ -142,7 +142,7 @@ type RowProps = { function TimesheetPdfTableRow({ entry, currentTime, settings }: RowProps) { const duration = getEntryDuration(entry, currentTime); - const durationFormatted = formatDuration(duration); + const durationFormatted = formatDurationLong(duration); // Render start and end timing for individual entries const renderTiming = entry.startTime !== null && ( diff --git a/src/export/index.ts b/src/export/index.ts index 5aca67e..78becb3 100644 --- a/src/export/index.ts +++ b/src/export/index.ts @@ -1,7 +1,7 @@ import { TimeEntry } from "@/schema"; import type { Moment } from "moment"; import { TimekeepSettings } from "@/settings"; -import { formatDuration, formatTimestamp } from "@/utils"; +import { formatDuration, formatDurationLong, formatTimestamp } from "@/utils"; import { getEntryDuration, getEntriesOrdered } from "@/timekeep"; export { createCSV } from "./csv"; @@ -53,7 +53,10 @@ export function createRawTableEntries( // Include duration for entries that are finished (entry.startTime !== null && entry.endTime !== null) || entry.subEntries !== null - ? formatDuration(getEntryDuration(entry, currentTime)) + ? formatDuration( + settings.exportDurationFormat, + getEntryDuration(entry, currentTime) + ) : "", ], ]; diff --git a/src/export/markdown-table.ts b/src/export/markdown-table.ts index 09c1bba..cf5a958 100644 --- a/src/export/markdown-table.ts +++ b/src/export/markdown-table.ts @@ -1,8 +1,8 @@ import type { Moment } from "moment"; import { formatDuration } from "@/utils"; import { getTotalDuration } from "@/timekeep"; -import { TimekeepSettings } from "@/settings"; import { Timekeep, TimeEntry } from "@/schema"; +import { DurationFormat, TimekeepSettings } from "@/settings"; import { RawTableRow, TOTAL_COLUMNS, createRawTable } from "@/export"; /** @@ -22,8 +22,13 @@ function createHeader(): RawTableRow { * @param currentTime The current time to use for unfinished entries * @returns The created row */ -function createFooter(entries: TimeEntry[], currentTime: Moment): RawTableRow { +function createFooter( + entries: TimeEntry[], + currentTime: Moment, + durationFormat: DurationFormat +): RawTableRow { const total: string = formatDuration( + durationFormat, getTotalDuration(entries, currentTime) ); return ["**Total**", "", "", `**${total}**`]; @@ -68,7 +73,11 @@ export function createMarkdownTable( // Markdown raw table contents ...createRawTable(timekeep.entries, settings, currentTime), // Markdown footer row - createFooter(timekeep.entries, currentTime), + createFooter( + timekeep.entries, + currentTime, + settings.exportDurationFormat + ), ]; // Array of indexes for all the columns (0 - TOTAL_COLUMNS) diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 1755044..c4f6c3c 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -5,6 +5,7 @@ import { defaultSettings, TimekeepSettings, PdfExportBehavior, + DurationFormat, } from "@/settings"; export class TimekeepSettingsTab extends PluginSettingTab { @@ -244,5 +245,27 @@ export class TimekeepSettingsTab extends PluginSettingTab { })); }); }); + + new Setting(this.containerEl) + .setName("CSV/Markdown duration format") + .setDesc("Format to show durations as when copying as CSV/Markdown") + + .addDropdown((t) => { + t.addOptions({ + [DurationFormat.LONG]: + "Long - Format including all units (1h 30m 25s)", + [DurationFormat.SHORT]: + "Short - Format just including hours (1.5h)", + [DurationFormat.DECIMAL]: + "Decimal - Short format without units (1.5)", + }); + t.setValue(String(settings.exportDurationFormat)); + t.onChange((v) => { + this.settingsStore.setState((currentValue) => ({ + ...currentValue, + exportDurationFormat: v as DurationFormat, + })); + }); + }); } } diff --git a/src/settings.ts b/src/settings.ts index 1bb32e5..436f924 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -7,6 +7,15 @@ export enum PdfExportBehavior { OPEN_FILE = "OPEN_FILE", } +export enum DurationFormat { + // Format including all units (1h 30m 25s) + LONG = "LONG", + // Format just including hours (1.5h) + SHORT = "SHORT", + // Short format without units (1.5) + DECIMAL = "DECIMAL", +} + export interface TimekeepSettings { csvDelimiter: string; csvTitle: boolean; @@ -20,6 +29,7 @@ export interface TimekeepSettings { reverseSegmentOrder: boolean; timestampFormat: string; showDecimalHours: boolean; + exportDurationFormat: DurationFormat; } export const defaultSettings: TimekeepSettings = { @@ -36,4 +46,5 @@ export const defaultSettings: TimekeepSettings = { reverseSegmentOrder: false, limitTableSize: true, showDecimalHours: true, + exportDurationFormat: DurationFormat.SHORT, }; diff --git a/src/utils/time.test.ts b/src/utils/time.test.ts index a3ccd1f..627b809 100644 --- a/src/utils/time.test.ts +++ b/src/utils/time.test.ts @@ -1,14 +1,16 @@ import moment from "moment"; -import { defaultSettings, TimekeepSettings } from "@/settings"; +import { DurationFormat, defaultSettings, TimekeepSettings } from "@/settings"; import { formatPdfDate, formatDuration, formatTimestamp, formatPdfRowDate, + formatDurationLong, + formatDurationShort, + formatDurationDecimal, parseEditableTimestamp, formatEditableTimestamp, - formatDurationHoursTrunc, } from "./time"; it("should format time", () => { @@ -59,7 +61,7 @@ describe("format duration", () => { ])( 'for duration "%s" should expected formatted "%s"', (input, expected) => { - const output = formatDuration(input); + const output = formatDurationLong(input); expect(output).toBe(expected); } @@ -75,7 +77,52 @@ describe("format duration short", () => { ])( 'for duration "%s" should expected formatted "%s"', (input, expected) => { - const output = formatDurationHoursTrunc(input); + const output = formatDurationShort(input); + + expect(output).toBe(expected); + } + ); +}); + +describe("format duration decimal", () => { + test.each([ + [1000 * 60 * 60 * 2, "2.00"], + [1000 * 60 * 60 * 25.25, "25.25"], + [1000 * 60 * 60 * 25.255, "25.25"], + [1000 * 60 * 60 * 50.5, "50.50"], + ])( + 'for duration "%s" should expected formatted "%s"', + (input, expected) => { + const output = formatDurationDecimal(input); + + expect(output).toBe(expected); + } + ); +}); + +describe("format duration with format", () => { + test.each([ + [DurationFormat.LONG, 1000, "1s"], + [DurationFormat.LONG, 1000 * 12, "12s"], + [DurationFormat.LONG, 1000 * 60, "1m 0s"], + [DurationFormat.LONG, 1000 * 60 * 2, "2m 0s"], + [DurationFormat.LONG, 1000 * 60 * 60 * 2, "2h 0s"], + [DurationFormat.LONG, 1000 * 60 * 60 * 2.5, "2h 30m 0s"], + [DurationFormat.LONG, 1000 * 60 * 60 * 2.505, "2h 30m 18s"], + + [DurationFormat.SHORT, 1000 * 60 * 60 * 2, "2.00h"], + [DurationFormat.SHORT, 1000 * 60 * 60 * 25.25, "25.25h"], + [DurationFormat.SHORT, 1000 * 60 * 60 * 25.255, "25.25h"], + [DurationFormat.SHORT, 1000 * 60 * 60 * 50.5, "50.50h"], + + [DurationFormat.DECIMAL, 1000 * 60 * 60 * 2, "2.00"], + [DurationFormat.DECIMAL, 1000 * 60 * 60 * 25.25, "25.25"], + [DurationFormat.DECIMAL, 1000 * 60 * 60 * 25.255, "25.25"], + [DurationFormat.DECIMAL, 1000 * 60 * 60 * 50.5, "50.50"], + ])( + 'for duration "%s" should expected formatted "%s"', + (format, input, expected) => { + const output = formatDuration(format, input); expect(output).toBe(expected); } diff --git a/src/utils/time.ts b/src/utils/time.ts index 497548a..3d79b56 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,5 +1,5 @@ import moment, { Moment } from "moment"; -import { TimekeepSettings } from "@/settings"; +import { DurationFormat, TimekeepSettings } from "@/settings"; /** * Formats a timestamp for tables and generated output @@ -44,16 +44,37 @@ export function parseEditableTimestamp( return moment(formatted, settings.editableTimestampFormat, true); } +/** + * Formats a duration using the provided duration format + * + * @param format The format to use + * @param durationMS The duration to format + * @returns The formatted duration + */ +export function formatDuration( + format: DurationFormat, + durationMS: number +): string { + switch (format) { + case DurationFormat.LONG: + return formatDurationLong(durationMS); + case DurationFormat.SHORT: + return formatDurationShort(durationMS); + case DurationFormat.DECIMAL: + return formatDurationDecimal(durationMS); + } +} + /** * Formats the provided duration in the form * of hours, minutes, and seconds * - * @param totalTime The duration to format + * @param durationMS The duration to format in milliseconds * @returns The formatted duration */ -export function formatDuration(totalTime: number): string { +export function formatDurationLong(durationMS: number): string { let ret = ""; - const duration = moment.duration(totalTime); + const duration = moment.duration(durationMS); const hours = Math.floor(duration.asHours()); if (hours > 0) ret += hours + "h "; @@ -64,19 +85,30 @@ export function formatDuration(totalTime: number): string { } /** - * Formats a duration in the form of hours only - * minutes will be counted a portions of an hour - * (i.e 1h 30m will be 1.5h) + * Same as {@see formatDurationDecimal} but with a "h" suffix + * indicating its hours * - * @param totalTime The duration to format + * @param durationMS The duration to format in milliseconds * @returns The formatted duration */ -export function formatDurationHoursTrunc(totalTime: number): string { - const duration = moment.duration(totalTime); +export function formatDurationShort(durationMS: number): string { + return formatDurationDecimal(durationMS) + "h"; +} + +/** + * Formats a duration in the form of hours only + * minutes will be counted a portions of an hour + * (i.e 1h 30m will be 1.5) + * + * @param durationMS The duration to format + * @returns The formatted duration + */ +export function formatDurationDecimal(durationMS: number): string { + const duration = moment.duration(durationMS); const hours = duration.asHours(); - return hours.toFixed(2) + "h"; + return hours.toFixed(2); } /** From 9dab0638f7e0e094ff913f909b2dcfad2300f526 Mon Sep 17 00:00:00 2001 From: Jacobtread Date: Sun, 4 Aug 2024 17:35:21 +1200 Subject: [PATCH 2/2] chore: lint and format --- src/components/pdf/TimesheetPdfTable.tsx | 2 +- src/export/index.ts | 2 +- src/settings-tab.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/pdf/TimesheetPdfTable.tsx b/src/components/pdf/TimesheetPdfTable.tsx index 29bdf40..5fa1f92 100644 --- a/src/components/pdf/TimesheetPdfTable.tsx +++ b/src/components/pdf/TimesheetPdfTable.tsx @@ -4,7 +4,7 @@ import { getEntryDuration } from "@/timekeep"; import { TimekeepSettings } from "@/settings"; import { Timekeep, TimeEntry } from "@/schema"; import { View, Text, StyleSheet } from "@/pdf"; -import { formatDurationLong, formatPdfRowDate } from "@/utils"; +import { formatPdfRowDate, formatDurationLong } from "@/utils"; type Props = { data: Timekeep; diff --git a/src/export/index.ts b/src/export/index.ts index 78becb3..7c99ac9 100644 --- a/src/export/index.ts +++ b/src/export/index.ts @@ -1,7 +1,7 @@ import { TimeEntry } from "@/schema"; import type { Moment } from "moment"; import { TimekeepSettings } from "@/settings"; -import { formatDuration, formatDurationLong, formatTimestamp } from "@/utils"; +import { formatDuration, formatTimestamp } from "@/utils"; import { getEntryDuration, getEntriesOrdered } from "@/timekeep"; export { createCSV } from "./csv"; diff --git a/src/settings-tab.ts b/src/settings-tab.ts index c4f6c3c..d875918 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -2,10 +2,10 @@ import { Store } from "@/store"; import TimekeepPlugin from "@/main"; import { App, Setting, PluginSettingTab } from "obsidian"; import { + DurationFormat, defaultSettings, TimekeepSettings, PdfExportBehavior, - DurationFormat, } from "@/settings"; export class TimekeepSettingsTab extends PluginSettingTab {