diff --git a/README.md b/README.md index 760bdc2..89eff88 100644 --- a/README.md +++ b/README.md @@ -47,96 +47,6 @@ A time tracker is really just a special code block that stores information about The tracker's information is stored in the code block as JSON data. The names, start times and end times of each segment are stored. They're displayed neatly in the code block in preview or reading mode. -# Tim Tracking Summary / Reporting - -1. **Time Tracking Entries with Tags**: Track work sessions with tags to categorize different activities. - - - Example of an entry: `#tt_dev #tt_client_a #tt_frontend` represents time spent working on the development (frontend) for a specific client. - -2. **Enhanced Reporting Functionality**: Generate time tracking reports for specific time periods, allowing detailed insight into how time was allocated. - - - **Stream-based Reports**: View summaries of time based on specific streams such as Development, Accounting, etc. - - - **Client-based Reports**: Track hours spent working for specific clients. - - -![alt text](reporting-screenshot.png) - -The output within Obsidian will render detailed information for each time segment, as shown in the first image. - -## Example Report - -Call command `Ctrl+P` select `Insert Time Tracking Summary` - -The reporting capability allows generating summaries for specific time ranges and topics: - -- **Streams Report**: A summary of all topics (e.g., Development, Accounting) over a selected period. - - ``` - time-tracking-summary - "2024-11-01", "2024-11-30" - ``` - -- **Clients Report**: A summary for individual topic over a given time range. - - ``` - time-tracking-summary - "2024-11-01", "2024-11-30", clients - ``` - - -These examples help demonstrate how you can leverage the new tracking and reporting capabilities. - -## How to Use - -1. **Tag Configuration** - - Configure your tags, icons, and sub tags using YAML in the settings of the plugin. - - Example configuration can be found in the settings: -2. **Tag your records with one or more tags / sub tags** -3. **Inserting Time Tracking Summary** - - Use the newly added command to insert the time tracking summary snippet into a markdown file. - - This will generate a report for a given period, optionally filtered by a specific topic. - -```yaml -# You can have as many 'sections' as you want to track different domains separately or in parallel - -# Example secction / topic 1 -streams: - name: "๐ŸŒŠ Streams" - items: - - topic: "Accounting" - icon: "๐Ÿงฎ" - tag: "#tt_accounting" - subTags: [] - - - topic: "Development" - icon: "๐Ÿ’—" - tag: "#tt_dev" - subTags: - - topic: "Frontend" - tag: "#tt_frontend" - subTags: [] - - - topic: "Backend" - tag: "#tt_backend" - subTags: [] - -# Example section / topic 2 -clients: - name: "๐Ÿ‘จ๐Ÿผโ€๐Ÿ’ผ Clients" - items: - - topic: "Client A" - tag: "#tt_client_a" - subTags: [] - - - topic: "Client B" - tag: "#tt_client_b" - subTags: []` - -``` - - - # ๐Ÿ™ Acknowledgements If you like this plugin and want to support its development, you can do so through my website by clicking this fancy image! diff --git a/reporting-screenshot.png b/reporting-screenshot.png deleted file mode 100644 index 4ab32b4..0000000 Binary files a/reporting-screenshot.png and /dev/null differ diff --git a/src/interfaces.ts b/src/interfaces.ts deleted file mode 100644 index a110604..0000000 --- a/src/interfaces.ts +++ /dev/null @@ -1,18 +0,0 @@ -// interfaces.ts -export interface SubTag { - topic: string; - tag: string; - icon?: string; - subTags: SubTag[]; - } - - export interface Item extends SubTag {} - - export interface Section { - name: string; - items: Item[]; - } - - export interface Configuration { - [sectionKey: string]: Section; - } diff --git a/src/main.ts b/src/main.ts index 81b6b23..329b37a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,38 +1,18 @@ -import { Editor, MarkdownRenderChild, moment, Plugin, TFile } from "obsidian"; +import { MarkdownRenderChild, Plugin, TFile } from "obsidian"; import { defaultSettings, SimpleTimeTrackerSettings } from "./settings"; import { SimpleTimeTrackerSettingsTab } from "./settings-tab"; -import { - displayTracker, - Entry, - formatDuration, - formatTimestamp, - getDuration, - getRunningEntry, - getTotalDuration, - isRunning, - loadAllTrackers, - loadTracker, - orderedEntries, -} from "./tracker"; -import { TimeTrackingSummary } from "./timeTrackingSummary"; +import { displayTracker, Entry, formatDuration, formatTimestamp, getDuration, getRunningEntry, getTotalDuration, isRunning, loadAllTrackers, loadTracker, orderedEntries } from "./tracker"; export default class SimpleTimeTrackerPlugin extends Plugin { + public api = { // verbatim versions of the functions found in tracker.ts with the same parameters - loadTracker, - loadAllTrackers, - getDuration, - getTotalDuration, - getRunningEntry, - isRunning, + loadTracker, loadAllTrackers, getDuration, getTotalDuration, getRunningEntry, isRunning, // modified versions of the functions found in tracker.ts, with the number of required arguments reduced - formatTimestamp: (timestamp: string) => - formatTimestamp(timestamp, this.settings), - formatDuration: (totalTime: number) => - formatDuration(totalTime, this.settings), - orderedEntries: (entries: Entry[]) => - orderedEntries(entries, this.settings), + formatTimestamp: (timestamp: string) => formatTimestamp(timestamp, this.settings), + formatDuration: (totalTime: number) => formatDuration(totalTime, this.settings), + orderedEntries: (entries: Entry[]) => orderedEntries(entries, this.settings) }; public settings: SimpleTimeTrackerSettings; @@ -41,102 +21,37 @@ export default class SimpleTimeTrackerPlugin extends Plugin { this.addSettingTab(new SimpleTimeTrackerSettingsTab(this.app, this)); - this.registerMarkdownCodeBlockProcessor( - "simple-time-tracker", - (s, e, i) => { - e.empty(); - let component = new MarkdownRenderChild(e); - let tracker = loadTracker(s); + this.registerMarkdownCodeBlockProcessor("simple-time-tracker", (s, e, i) => { + e.empty(); + let component = new MarkdownRenderChild(e); + let tracker = loadTracker(s); - // Wrap file name in a function since it can change - let filePath = i.sourcePath; - const getFile = () => filePath; + // Wrap file name in a function since it can change + let filePath = i.sourcePath; + const getFile = () => filePath; - // Hook rename events to update the file path - component.registerEvent( - this.app.vault.on("rename", (file, oldPath) => { - if (file instanceof TFile && oldPath === filePath) { - filePath = file.path; - } - }) - ); + // Hook rename events to update the file path + component.registerEvent(this.app.vault.on("rename", (file, oldPath) => { + if (file instanceof TFile && oldPath === filePath) { + filePath = file.path; + } + })); - displayTracker( - tracker, - e, - getFile, - () => i.getSectionInfo(e), - this.settings, - component - ); - i.addChild(component); - } - ); + displayTracker(tracker, e, getFile, () => i.getSectionInfo(e), this.settings, component); + i.addChild(component); + }); this.addCommand({ id: `insert`, name: `Insert Time Tracker`, editorCallback: (e, _) => { e.replaceSelection("```simple-time-tracker\n```\n"); - }, - }); - - this.addCommand({ - id: `insert-time-tracking-summary`, - name: `Insert Time Tracking Summary`, - editorCallback: (editor) => this.insertTimeTrackingSummarySummary(editor), - }); - - this.registerMarkdownCodeBlockProcessor( - "time-tracking-summary", - async (source, el, ctx) => { - const sourceWithoutComments = source - .split("\n")[0] - .replace(/\/\/.*$/g, ''); // Remove everything after // - - const params = sourceWithoutComments - .split(",") - .map((s) => s.trim()); - - const [startDateStr, endDateStr, streamKey] = params; - - const timeTracking = new TimeTrackingSummary( - this.app, - this.settings, - this.api - ); - await timeTracking.timeTrackingSummaryForPeriod( - el, - startDateStr, - endDateStr, - streamKey // Optional parameter - ); } - ); - } - - - insertTimeTrackingSummarySummary(editor: Editor) { - const now = moment(); - // First day of the current month - const firstDay = now.clone().startOf('month').format('YYYY-MM-DD'); - - // Last day of the current month - const lastDay = now.clone().endOf('month').format('YYYY-MM-DD'); - - const snippet = ` -\`\`\`time-tracking-summary - "${firstDay}", "${lastDay}" // Optional: add ", stream_name" to filter by streams section. By default will print all. -\`\`\``; - editor.replaceSelection(snippet); + }); } async loadSettings(): Promise { - this.settings = Object.assign( - {}, - defaultSettings, - await this.loadData() - ); + this.settings = Object.assign({}, defaultSettings, await this.loadData()); } async saveSettings(): Promise { diff --git a/src/settings-tab.ts b/src/settings-tab.ts index d683096..06bae5c 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -74,22 +74,6 @@ export class SimpleTimeTrackerSettingsTab extends PluginSettingTab { }); }); - new Setting(this.containerEl) - .setName("Tag Configurations") - .setDesc("Configure your tags, icons, and sub-tags using YAML.") - .addTextArea((text) => { - text - .setPlaceholder("Enter tag configurations in YAML format") - .setValue(this.plugin.settings.tagConfigurationsYaml) - .onChange(async (value) => { - this.plugin.settings.tagConfigurationsYaml = value; - await this.plugin.saveSettings(); - }); - text.inputEl.rows = 15; - text.inputEl.style.width = "100%"; - }); - - this.containerEl.createEl("hr"); this.containerEl.createEl("p", { text: "Need help using the plugin? Feel free to join the Discord server!" }); this.containerEl.createEl("a", { href: "https://link.ellpeck.de/discordweb" }).createEl("img", { diff --git a/src/settings.ts b/src/settings.ts index 73295fb..a70e817 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -4,52 +4,16 @@ export const defaultSettings: SimpleTimeTrackerSettings = { csvDelimiter: ",", fineGrainedDurations: true, reverseSegmentOrder: false, - timestampDurations: false, - tagConfigurationsYaml: ` -# This is a sample configuration file for the tag configurations - -# You can have as many 'sections' as you want to track different domains separately or in parallel - -# Example section 1 -streams: - name: "๐ŸŒŠ Streams" - items: - - topic: "Accounting" - icon: "๐Ÿงฎ" - tag: "#tt_accounting" - subTags: [] - - - topic: "Development" - icon: "๐Ÿ’—" - tag: "#tt_dev" - subTags: - - topic: "Frontend" - tag: "#tt_frontend" - subTags: [] - - - topic: "Backend" - tag: "#tt_backend" - subTags: [] - -# Example section 2 -clients: - name: "๐Ÿ‘จ๐Ÿผโ€๐Ÿ’ผ Clients" - items: - - topic: "Client A" - tag: "#tt_client_a" - subTags: [] - - - topic: "Client B" - tag: "#tt_client_b" - subTags: []` + timestampDurations: false }; export interface SimpleTimeTrackerSettings { + timestampFormat: string; editableTimestampFormat: string; csvDelimiter: string; fineGrainedDurations: boolean; reverseSegmentOrder: boolean; timestampDurations: boolean; - tagConfigurationsYaml: string; + } diff --git a/src/timeTrackingSummary.ts b/src/timeTrackingSummary.ts deleted file mode 100644 index 0c10535..0000000 --- a/src/timeTrackingSummary.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { SimpleTimeTrackerSettings } from "./settings"; -import { Configuration, Section, Item, SubTag } from "./interfaces"; -import { parseYaml, moment } from "obsidian"; - - -export class TimeTrackingSummary { - settings: SimpleTimeTrackerSettings; - api: any; - app: any; // Reference to the Obsidian app - - constructor(app: any, settings: SimpleTimeTrackerSettings, api: any) { - this.app = app; - this.settings = settings; - this.api = api; - } - - async timeTrackingSummaryForPeriod( - containerEl: HTMLElement, - startDateStr: string, - endDateStr: string, - streamKey?: string - ) { - // Define the time interval (inclusive) - const startDate = moment(startDateStr).startOf("day"); - const endDate = moment(endDateStr).endOf("day"); - - // Initialize an object to hold total durations per dimension - let dimensionDurations: { - [dimension: string]: { [tag: string]: number }; - } = {}; - const untrackedSectionName = "Other"; - - // Parse the configuration from settings - let config: Configuration; - try { - config = this.parseConfiguration(); - } catch (error: any) { - containerEl.createEl("p", { - text: `Error parsing configuration: ${error.message}`, - }); - return; - } - - const api = this.api; - - // Load all trackers from all markdown files - const files = this.app.vault.getMarkdownFiles(); - - for (const file of files) { - const trackers = await api.loadAllTrackers(file.path); - - for (let { tracker } of trackers) { - for (let entry of tracker.entries) { - let entryDate = moment(entry.startTime); - if ( - !entryDate.isValid() || - !entryDate.isBetween(startDate, endDate, null, "[]") - ) { - continue; - } - - let tags = entry.name.match(/#\w+/g) || []; - let duration = api.getDuration(entry); // Use API duration calculation - duration = duration / (1000 * 60 * 60); // Convert ms to hours - - let mappedDimensions = this.mapTagsToDimensions( - tags, - config - ); - - if (Object.keys(mappedDimensions).length === 0) { - // Handle untracked time - if (!dimensionDurations[untrackedSectionName]) { - dimensionDurations[untrackedSectionName] = {}; - } - if ( - !dimensionDurations[untrackedSectionName][ - untrackedSectionName - ] - ) { - dimensionDurations[untrackedSectionName][ - untrackedSectionName - ] = 0; - } - dimensionDurations[untrackedSectionName][ - untrackedSectionName - ] += duration; - } else { - // Accumulate tracked time - for (let dimension in mappedDimensions) { - if (!dimensionDurations[dimension]) { - dimensionDurations[dimension] = {}; - } - for (let topic of mappedDimensions[dimension]) { - if (!dimensionDurations[dimension][topic]) { - dimensionDurations[dimension][topic] = 0; - } - dimensionDurations[dimension][topic] += - duration; - } - } - } - } - } - } - - streamKey = streamKey?.replace(/^["']|["']$/g, '').trim().toLowerCase() // remove any quotes and trim - - - if (streamKey && streamKey.trim() !== "") { - console.log(`streamKey: ${streamKey} is not empty, proceeding to filter dimensions`); - - // Create mapping of section keys and names to dimensions - const sectionMapping: { [key: string]: string } = {}; - for (const [key, section] of Object.entries(config)) { - sectionMapping[key.toLowerCase()] = section.name; - sectionMapping[section.name.toLowerCase()] = section.name; - } - - // Find matching section - const filteredDimensions: typeof dimensionDurations = {}; - for (const dimension in dimensionDurations) { - console.log(`dimension: ${dimension}, checking match with ${streamKey}`); - // Check if streamKey matches either section key or display name - if (dimension.toLowerCase() === streamKey || - Object.keys(sectionMapping).includes(streamKey)) { - const matchedDimension = sectionMapping[streamKey] || dimension; - if (dimensionDurations[matchedDimension]) { - filteredDimensions[matchedDimension] = dimensionDurations[matchedDimension]; - break; - } - } - } - - // Show message if no matching stream found - if (Object.keys(filteredDimensions).length === 0) { - containerEl.createEl("p", { - text: `No results found for stream "${streamKey}" in the selected period [${startDate.format( - "YYYY-MM-DD" - )} โ†’ ${endDate.format("YYYY-MM-DD")}]`, - }); - return; - } - - // Replace original dimensions with filtered - dimensionDurations = filteredDimensions; - } - - - // Display the results - for (let dimension in dimensionDurations) { - console.log(`dimension: ${dimension}`); - let totalDuration = Object.values( - dimensionDurations[dimension] - ).reduce((sum, hours) => sum + hours, 0); - let totalMs = totalDuration * 60 * 60 * 1000; // Convert hours to ms - let formattedTotalDuration = api.formatDuration(totalMs); - - containerEl.createEl("h2", { - text: `${dimension} ๐Ÿ“… [${startDate.format( - "YYYY-MM-DD" - )} โ†’ ${endDate.format( - "YYYY-MM-DD" - )}] โณTotal: ${formattedTotalDuration}`, - }); - - let tableEl = containerEl.createEl("table"); - let headerRow = tableEl.createEl("tr"); - ["Icon", "Topic", "Total Hours", "Formatted Duration"].forEach( - (text) => { - headerRow.createEl("th", { text }); - } - ); - - let tableData = Object.entries(dimensionDurations[dimension]).map( - ([topic, hours]) => { - let durationMs = hours * 60 * 60 * 1000; - let formattedHours = api.formatDuration(durationMs); - let icon = this.getIconForTag(topic, config); - return { - icon, - topic, - hours: hours.toFixed(2), - formattedHours, - }; - } - ); - - // Sort table data by time spent (hours) in descending order - tableData.sort((a, b) => parseFloat(b.hours) - parseFloat(a.hours)); - - tableData.forEach((row) => { - let rowEl = tableEl.createEl("tr"); - ["icon", "topic", "hours", "formattedHours"].forEach((key) => { - rowEl.createEl("td", { text: (row as any)[key] }); - }); - }); - } - } - - parseConfiguration(): Configuration { - const yamlContent = this.settings.tagConfigurationsYaml; - console.log(`received config:\n ${yamlContent}`); - const config = parseYaml(yamlContent); - - console.log("Parsed configuration:", config); - - if (!config || typeof config !== "object") { - throw new Error("Invalid configuration format."); - } - - // Validate and transform the configuration - for (const [sectionKey, sectionValue] of Object.entries(config)) { - console.log(`sectionKey: ${sectionKey}`); - if ( - !sectionValue.hasOwnProperty("name") || - !sectionValue.hasOwnProperty("items") - ) { - throw new Error( - `Section ${sectionKey} is missing 'name' or 'items' properties.` - ); - } - - // Recursively validate items and subTags - const validateItems = (items: any[]) => { - console.log(`items: ${items}`); - for (const item of items) { - if ( - !item.hasOwnProperty("topic") || - !item.hasOwnProperty("tag") - ) { - throw new Error( - `Item ${JSON.stringify( - item - )} is missing 'topic' or 'tag' properties.` - ); - } - item.subTags = item.subTags || []; - validateItems(item.subTags); - } - }; - - validateItems((sectionValue as Section).items); - } - - return config as Configuration; - } - - mapTagsToDimensions( - tags: string[], - config: Configuration - ): { [dimension: string]: string[] } { - const mappedDimensions: { [dimension: string]: string[] } = {}; - - const processItems = ( - items: (Item | SubTag)[], - dimensionName: string, - tagsInDimension: Set - ) => { - for (const item of items) { - if (tags.includes(item.tag)) { - tagsInDimension.add(item.topic); - } - if (item.subTags && item.subTags.length > 0) { - processItems(item.subTags, dimensionName, tagsInDimension); - } - } - }; - - for (const section of Object.values(config)) { - const dimensionName = section.name; - const tagsInDimension: Set = new Set(); - processItems(section.items, dimensionName, tagsInDimension); - if (tagsInDimension.size > 0) { - mappedDimensions[dimensionName] = Array.from(tagsInDimension); - } - } - - return mappedDimensions; - } - - getIconForTag(topic: string, config: Configuration): string { - let foundIcon = ""; - - const searchItems = (items: (Item | SubTag)[]): boolean => { - for (const item of items) { - if (item.topic === topic) { - foundIcon = item.icon || ""; - return true; - } - if (item.subTags && item.subTags.length > 0) { - if (searchItems(item.subTags)) { - return true; - } - } - } - return false; - }; - - for (const section of Object.values(config)) { - if (searchItems(section.items)) { - break; - } - } - - return foundIcon; - } -} diff --git a/test-vault/.obsidian/community-plugins.json b/test-vault/.obsidian/community-plugins.json index b1228e8..88886e9 100644 --- a/test-vault/.obsidian/community-plugins.json +++ b/test-vault/.obsidian/community-plugins.json @@ -1,4 +1,4 @@ [ - "dataview", - "simple-time-tracker" + "simple-time-tracker", + "dataview" ] \ No newline at end of file diff --git a/test-vault/Time summary reporting test.md b/test-vault/Time summary reporting test.md deleted file mode 100644 index 545d09a..0000000 --- a/test-vault/Time summary reporting test.md +++ /dev/null @@ -1,17 +0,0 @@ - -# Example data -```simple-time-tracker -{"entries":[{"name":"#tt_accounting #tt_client_a bills processing","startTime":"2024-11-01T09:25:42.000Z","endTime":"2024-11-01T17:25:56.000Z"},{"name":"#tt_dev #tt_client2 ","startTime":"2024-11-02T13:26:21.000Z","endTime":"2024-11-02T17:27:18.000Z"},{"name":"#tt_dev #tt_client_a #tt_frontend did this","startTime":"2024-11-03T08:27:20.000Z","endTime":"2024-11-03T17:27:39.000Z"},{"name":"#tt_dev #tt_client_b #tt_backend did that","startTime":"2024-11-04T14:27:40.000Z","endTime":"2024-11-04T17:27:53.000Z"}]} -``` - -# Example report -## Report only one stream - -```time-tracking-summary - "2024-11-01", "2024-11-30" // Optional: add ", stream_name" to filter by streams section. By default will print all. -``` -## Report only one stream - -```time-tracking-summary - "2024-11-01", "2024-11-30", clients // Optional: add ", stream_name" to filter by streams section. By default will print all. -``` \ No newline at end of file