From 5d862a829f853b17fc7543dd447b3714ac0c843c Mon Sep 17 00:00:00 2001 From: thequotient Date: Thu, 18 Sep 2025 04:21:29 +0200 Subject: [PATCH 1/4] First implementation of Daily Statistics --- src/main.ts | 19 ++++++ src/statistics.ts | 165 ++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 36 ++++++++++ 3 files changed, 220 insertions(+) create mode 100644 src/statistics.ts diff --git a/src/main.ts b/src/main.ts index 49f5289..e9fa7ec 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ import { MarkdownRenderChild, Plugin, TFile } from "obsidian"; import { defaultSettings, SimpleTimeTrackerSettings } from "./settings"; import { SimpleTimeTrackerSettingsTab } from "./settings-tab"; +import { displayStatistics } from "./statistics"; import { displayTracker, Entry, formatDuration, formatTimestamp, getDuration, getDurationToday, getRunningEntry, getTotalDuration, getTotalDurationToday, isRunning, loadAllTrackers, loadTracker, orderedEntries } from "./tracker"; export default class SimpleTimeTrackerPlugin extends Plugin { @@ -41,13 +42,31 @@ export default class SimpleTimeTrackerPlugin extends Plugin { i.addChild(component); }); + this.registerMarkdownCodeBlockProcessor("simple-time-tracker-statistics", (s, e, i) => { + e.empty(); + const component = new MarkdownRenderChild(e); + + displayStatistics(e, this, i.sourcePath); + + i.addChild(component); + }); + this.addCommand({ id: `insert`, name: `Insert Time Tracker`, editorCallback: (e, _) => { e.replaceSelection("```simple-time-tracker\n```\n"); + } + }); + + this.addCommand({ + id: `insert-stats`, + name: `Insert Time Tracker Statistics`, + editorCallback: (e, _) => { + e.replaceSelection("```simple-time-tracker-statistics\n```\n"); } }); + } async loadSettings(): Promise { diff --git a/src/statistics.ts b/src/statistics.ts new file mode 100644 index 0000000..fe17bbe --- /dev/null +++ b/src/statistics.ts @@ -0,0 +1,165 @@ +import { MarkdownRenderer, setIcon, TFile } from "obsidian"; +import { DataviewApi } from "obsidian-dataview"; +import SimpleTimeTrackerPlugin from "./main"; +import { Entry, formatDuration, getDuration, getTotalDuration, isRunning, loadAllTrackers } from "./tracker"; + +// Helper to extract a date (YYYY-MM-DD) from a string +function extractDate(input: string): string | null { + if (!input) return null; + const match = input.match(/^\d{4}-\d{2}-\d{2}/); + return match ? match[0] : null; +} + +// Gathers and processes all time tracking entries for a specific day +async function getWorkingTimeOfDay(dataviewApi: DataviewApi, plugin: SimpleTimeTrackerPlugin, date: string) { + const fileTags: string[] = []; + const pageNames: string[] = []; + const entryNames: string[] = []; + const entryDurations: number[] = []; + const filteredEntries: Entry[] = []; + + // Recursively processes entries and their sub-entries + function processEntries(entries: Entry[], page: TFile, isWork: boolean, parentName = '') { + entries.forEach(entry => { + + if (extractDate(entry.startTime) === date) { + filteredEntries.push(entry); + fileTags.push(isWork ? "#work" : "other"); + pageNames.push(page.basename); + const fullName = parentName ? `${parentName} -> ${entry.name}` : entry.name; + entryNames.push(fullName); + entryDurations.push(getDuration(entry)); + } + + if (entry.subEntries) { + const newParentName = parentName ? `${parentName} -> ${entry.name}` : entry.name; + processEntries(entry.subEntries, page, isWork, newParentName); + } + }); + } + + // Iterate over all markdown files in the vault + for (const page of dataviewApi.pages('""') as any[]) { + if (!page.file?.path) continue; + + const file = plugin.app.vault.getAbstractFileByPath(page.file.path); + if (!(file instanceof TFile)) { + continue; + } + + const trackers = await loadAllTrackers(file.path); + const isWork = page.file.tags?.includes("#work"); + + for (const { tracker } of trackers) { + processEntries(tracker.entries, file, isWork); + } + } + + return { + totalDuration: getTotalDuration(filteredEntries), + fileTags, + pageNames, + entryNames, + entryDurations + }; +} + +// Finds and formats a markdown link for any currently running tracker +async function getRunningTrackerMarkdown(dataviewApi: DataviewApi): Promise { + for (const page of dataviewApi.pages('""') as any[]) { + if (!page.file?.path) continue; + const trackers = await loadAllTrackers(page.file.path); + for (const { tracker } of trackers) { + if (isRunning(tracker)) { + return `**Currently running:** [[${page.file.path}|${page.file.name}]]\n\n---\n`; + } + } + } + return "_No tracker is currently running._\n"; +} + +// Main function to be called by the code block processor +export async function displayStatistics(container: HTMLElement, plugin: SimpleTimeTrackerPlugin, sourcePath: string): Promise { + const app = plugin.app; + + // This function contains the core logic to generate and render the report. + const renderReport = async (contentContainer: HTMLElement) => { + const dataviewApi = app.plugins.plugins.dataview?.api; + if (!dataviewApi) { + contentContainer.empty(); + contentContainer.createEl("p", { text: "Error: Dataview plugin is not enabled..." }); + return; + } + + const fileName = sourcePath.split('/').pop() || ''; + const date = extractDate(fileName); + if (!date) { + contentContainer.empty(); + contentContainer.createEl("p", { text: `Error: Could not extract date (YYYY-MM-DD) from file name: "${fileName}"` }); + return; + } + + try { + contentContainer.empty(); + contentContainer.createEl("p", { text: "Loading statistics..." }); + + const runningTrackerMd = await getRunningTrackerMarkdown(dataviewApi); + const workingTime = await getWorkingTimeOfDay(dataviewApi, plugin, date); + + let dailyReportMd = ""; + if (workingTime.totalDuration === 0) { + dailyReportMd = "_No tracked time found for this day._"; + } else { + let workMs = 0; + workingTime.entryDurations.forEach((dur, i) => { + if (workingTime.fileTags[i] === "#work") workMs += dur; + }); + const otherMs = workingTime.totalDuration - workMs; + + const totalsTable = `| Category | Duration |\n|:---|:---|\n| **Work** | ${formatDuration(workMs, plugin.settings)} |\n| **Other** | ${formatDuration(otherMs, plugin.settings)} |\n| **Total** | **${formatDuration(workingTime.totalDuration, plugin.settings)}** |`; + let breakdownTable = `| Type | Entry | Duration |\n|:---|:---|:---|\n`; + workingTime.fileTags.forEach((tag, i) => { + const type = tag === "#work" ? "Work" : "Other"; + const entryKey = `[[${workingTime.pageNames[i]}]] - ${workingTime.entryNames[i]}`; + const durStr = formatDuration(workingTime.entryDurations[i], plugin.settings); + breakdownTable += `| ${type} | ${entryKey} | ${durStr} |\n`; + }); + dailyReportMd = `#### Totals\n\n${totalsTable}\n\n#### Entries Breakdown\n\n${breakdownTable}`; + } + + const finalMarkdown = `${runningTrackerMd}\n${dailyReportMd}`; + contentContainer.empty(); + await MarkdownRenderer.render(app, finalMarkdown, contentContainer, sourcePath, plugin); + + } catch (error) { + console.error("Simple Time Tracker (Statistics) Error:", error); + contentContainer.empty(); + contentContainer.createEl("p", { text: "An error occurred while generating the report. Check the developer console for details." }); + } + }; + + container.empty(); + container.addClass("simple-time-tracker-stats-container"); + + const header = container.createDiv({ cls: "simple-time-tracker-stats-header" }); + + const titleGroup = header.createDiv({ attr: { style: "display: flex; align-items: center; gap: 0.5em;" } }); + titleGroup.createEl("h4", { text: "Daily Statistics" }); + const refreshButton = titleGroup.createEl("button", { cls: "clickable-icon", attr: { "aria-label": "Refresh" } }); + setIcon(refreshButton, "refresh-cw"); + + const contentContainer = container.createDiv({ cls: "simple-time-tracker-stats-content" }); + + refreshButton.addEventListener("click", () => { + setIcon(refreshButton, "loader"); + refreshButton.disabled = true; + renderReport(contentContainer).finally(() => { + setIcon(refreshButton, "refresh-cw"); + refreshButton.disabled = false; + }); + }); + + renderReport(contentContainer); +} + + diff --git a/styles.css b/styles.css index e066c1f..64cc653 100644 --- a/styles.css +++ b/styles.css @@ -84,3 +84,39 @@ .simple-time-tracker-table tr:hover { background-color: var(--background-modifier-hover); } + +.simple-time-tracker-stats-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5em; +} + +.simple-time-tracker-stats-header h4 { + margin: 0; +} + +.simple-time-tracker-stats-header .clickable-icon { + background-color: transparent; + border: none; + cursor: pointer; + padding: 4px; + border-radius: 5px; + color: var(--text-muted); +} + +.simple-time-tracker-stats-header .clickable-icon:hover { + background-color: var(--background-modifier-hover); + color: var(--text-normal); +} + +/* Spinning animation for loader icon */ +@keyframes simple-time-tracker-spin { + to { + transform: rotate(360deg); + } +} + +.simple-time-tracker-stats-header .clickable-icon svg.lucide-loader { + animation: simple-time-tracker-spin 1s linear infinite; +} From d46cbdac5352045bc40f868e9c4bdd4137ece49d Mon Sep 17 00:00:00 2001 From: thequotient Date: Sat, 20 Sep 2025 21:48:29 +0200 Subject: [PATCH 2/4] Added tag categories and target time --- src/settings-tab.ts | 43 ++++++++++++++++++++++ src/settings.ts | 19 ++++++++++ src/statistics.ts | 88 +++++++++++++++++++++++++++++++++++---------- 3 files changed, 132 insertions(+), 18 deletions(-) diff --git a/src/settings-tab.ts b/src/settings-tab.ts index eec6639..088a123 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -15,6 +15,49 @@ export class SimpleTimeTrackerSettingsTab extends PluginSettingTab { this.containerEl.empty(); this.containerEl.createEl("h2", { text: "Super Simple Time Tracker Settings" }); + this.containerEl.createEl("h5", { text: "Statistic Categories" }); + + this.plugin.settings.categories.forEach((category, index) => { + const setting = new Setting(this.containerEl) + .addText(text => text + .setPlaceholder("Category name") + .setValue(category.name) + .onChange(async (value) => { + category.name = value; + await this.plugin.saveSettings(); + })) + .addText(text => text + .setPlaceholder("Tags (comma-separated)") + .setValue(category.tags.join(", ")) + .onChange(async (value) => { + category.tags = value.split(",").map(tag => tag.trim()).filter(tag => tag.length > 0); + await this.plugin.saveSettings(); + })) + .addText(text => text + .setPlaceholder("Target time (HH:mm:ss)") + .setValue(category.target) + .onChange(async (value) => { + category.target = value ? value : "00:00:00" + await this.plugin.saveSettings(); + })) + .addButton(button => button + .setButtonText("Remove") + .onClick(async () => { + this.plugin.settings.categories.splice(index, 1); + await this.plugin.saveSettings(); + this.display(); + })); + }); + + new Setting(this.containerEl) + .addButton(button => button + .setButtonText("Add New Category") + .onClick(async () => { + this.plugin.settings.categories.push({ name: "", tags: [] }); + await this.plugin.saveSettings(); + this.display(); + })); + new Setting(this.containerEl) .setName("Timestamp Display Format") .setDesc(createFragment(f => { diff --git a/src/settings.ts b/src/settings.ts index 19ee4ec..001c0e8 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,3 +1,9 @@ +export interface Category { + name: string; + tags: string[]; + target: string; +} + export const defaultSettings: SimpleTimeTrackerSettings = { timestampFormat: "YY-MM-DD HH:mm:ss", editableTimestampFormat: "YYYY-MM-DD HH:mm:ss", @@ -6,6 +12,18 @@ export const defaultSettings: SimpleTimeTrackerSettings = { reverseSegmentOrder: false, timestampDurations: false, showToday: false, + categories : [ + { + name: "Work", + tags: ['#work'], + target: "08:00:00" + }, + { + name: "Leisure", + tags: ['#leisure'], + target: "00:00:00" + } + ] }; export interface SimpleTimeTrackerSettings { @@ -17,4 +35,5 @@ export interface SimpleTimeTrackerSettings { reverseSegmentOrder: boolean; timestampDurations: boolean; showToday: boolean; + categories: Category[]; } diff --git a/src/statistics.ts b/src/statistics.ts index fe17bbe..b97fc11 100644 --- a/src/statistics.ts +++ b/src/statistics.ts @@ -1,5 +1,6 @@ import { MarkdownRenderer, setIcon, TFile } from "obsidian"; import { DataviewApi } from "obsidian-dataview"; +import moment from "moment"; import SimpleTimeTrackerPlugin from "./main"; import { Entry, formatDuration, getDuration, getTotalDuration, isRunning, loadAllTrackers } from "./tracker"; @@ -10,21 +11,26 @@ function extractDate(input: string): string | null { return match ? match[0] : null; } +// Helper to parse target time from HH:mm:ss string to milliseconds +function parseTargetTime(target: string): number { + if (!target) return 0; + return moment.duration(target).asMilliseconds(); +} + // Gathers and processes all time tracking entries for a specific day async function getWorkingTimeOfDay(dataviewApi: DataviewApi, plugin: SimpleTimeTrackerPlugin, date: string) { - const fileTags: string[] = []; + const fileCategories: string[] = []; const pageNames: string[] = []; const entryNames: string[] = []; const entryDurations: number[] = []; const filteredEntries: Entry[] = []; // Recursively processes entries and their sub-entries - function processEntries(entries: Entry[], page: TFile, isWork: boolean, parentName = '') { + function processEntries(entries: Entry[], page: TFile, category: string, parentName = '') { entries.forEach(entry => { - if (extractDate(entry.startTime) === date) { filteredEntries.push(entry); - fileTags.push(isWork ? "#work" : "other"); + fileCategories.push(category); pageNames.push(page.basename); const fullName = parentName ? `${parentName} -> ${entry.name}` : entry.name; entryNames.push(fullName); @@ -33,7 +39,7 @@ async function getWorkingTimeOfDay(dataviewApi: DataviewApi, plugin: SimpleTimeT if (entry.subEntries) { const newParentName = parentName ? `${parentName} -> ${entry.name}` : entry.name; - processEntries(entry.subEntries, page, isWork, newParentName); + processEntries(entry.subEntries, page, category, newParentName); } }); } @@ -48,16 +54,24 @@ async function getWorkingTimeOfDay(dataviewApi: DataviewApi, plugin: SimpleTimeT } const trackers = await loadAllTrackers(file.path); - const isWork = page.file.tags?.includes("#work"); + const pageTags = new Set(page.file.tags || []); + + let category = "Other"; + for (const cat of plugin.settings.categories) { + if (cat.tags.some(tag => pageTags.has(tag))) { + category = cat.name; + break; + } + } for (const { tracker } of trackers) { - processEntries(tracker.entries, file, isWork); + processEntries(tracker.entries, file, category); } } return { totalDuration: getTotalDuration(filteredEntries), - fileTags, + fileCategories, pageNames, entryNames, entryDurations @@ -110,19 +124,59 @@ export async function displayStatistics(container: HTMLElement, plugin: SimpleTi if (workingTime.totalDuration === 0) { dailyReportMd = "_No tracked time found for this day._"; } else { - let workMs = 0; + const categoryTotals: { [key: string]: number } = {}; workingTime.entryDurations.forEach((dur, i) => { - if (workingTime.fileTags[i] === "#work") workMs += dur; + const category = workingTime.fileCategories[i]; + if (!categoryTotals[category]) { + categoryTotals[category] = 0; + } + categoryTotals[category] += dur; }); - const otherMs = workingTime.totalDuration - workMs; - const totalsTable = `| Category | Duration |\n|:---|:---|\n| **Work** | ${formatDuration(workMs, plugin.settings)} |\n| **Other** | ${formatDuration(otherMs, plugin.settings)} |\n| **Total** | **${formatDuration(workingTime.totalDuration, plugin.settings)}** |`; - let breakdownTable = `| Type | Entry | Duration |\n|:---|:---|:---|\n`; - workingTime.fileTags.forEach((tag, i) => { - const type = tag === "#work" ? "Work" : "Other"; + const showTargetColumns = plugin.settings.categories.some(c => c.target); + + let totalsTable = `| Category | Duration |`; + if (showTargetColumns) { + totalsTable += ` Remaining | Overtime |\n|:---|:---|:---|:---|\n`; + } else { + totalsTable += `\n|:---|:---|\n`; + } + + for (const categoryName in categoryTotals) { + const category = plugin.settings.categories.find(c => c.name === categoryName); + const trackedDuration = categoryTotals[categoryName]; + let remainingStr = ""; + let overtimeStr = ""; + + if (category && category.target) { + const targetMs = parseTargetTime(category.target); + if (targetMs > 0) { + const diffMs = trackedDuration - targetMs; + if (diffMs < 0) { + remainingStr = formatDuration(-diffMs, plugin.settings); + } else { + overtimeStr = formatDuration(diffMs, plugin.settings); + } + } + } + + totalsTable += `| **${categoryName}** | ${formatDuration(trackedDuration, plugin.settings)} |`; + if (showTargetColumns) { + totalsTable += ` ${remainingStr} | ${overtimeStr} |\n`; + } else { + totalsTable += `\n`; + } + } + totalsTable += `| **Total** | **${formatDuration(workingTime.totalDuration, plugin.settings)}** |`; + if (showTargetColumns) { + totalsTable += ` | |`; + } + + let breakdownTable = `| Category | Entry | Duration |\n|:---|:---|:---|\n`; + workingTime.fileCategories.forEach((category, i) => { const entryKey = `[[${workingTime.pageNames[i]}]] - ${workingTime.entryNames[i]}`; const durStr = formatDuration(workingTime.entryDurations[i], plugin.settings); - breakdownTable += `| ${type} | ${entryKey} | ${durStr} |\n`; + breakdownTable += `| ${category} | ${entryKey} | ${durStr} |\n`; }); dailyReportMd = `#### Totals\n\n${totalsTable}\n\n#### Entries Breakdown\n\n${breakdownTable}`; } @@ -161,5 +215,3 @@ export async function displayStatistics(container: HTMLElement, plugin: SimpleTi renderReport(contentContainer); } - - From 2398c35d976eefe763191ef594afa1a7749eeb0b Mon Sep 17 00:00:00 2001 From: thequotient Date: Sat, 20 Sep 2025 21:49:17 +0200 Subject: [PATCH 3/4] Added example daily note --- test-vault/2025-09-20.md | 2 ++ test-vault/track-note-test.md | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 test-vault/2025-09-20.md diff --git a/test-vault/2025-09-20.md b/test-vault/2025-09-20.md new file mode 100644 index 0000000..e4fcb40 --- /dev/null +++ b/test-vault/2025-09-20.md @@ -0,0 +1,2 @@ +```simple-time-tracker-statistics +``` diff --git a/test-vault/track-note-test.md b/test-vault/track-note-test.md index 15fb6a3..01145f7 100644 --- a/test-vault/track-note-test.md +++ b/test-vault/track-note-test.md @@ -1,7 +1,11 @@ +--- +tags: + - work +--- This is a time tracker: ```simple-time-tracker -{"entries":[{"name":"Segment 1","startTime":1664306406,"endTime":1664306408},{"name":"Segment 2","startTime":1664306409,"endTime":1664306410},{"name":"Segment 3","startTime":1664306411,"endTime":1664306412},{"name":"Segment 4","startTime":1664306413,"endTime":1664306422},{"name":"Segment 5","startTime":1664306455,"endTime":1664306458},{"name":"Segment 6","startTime":1664306543,"endTime":1664306545},{"name":"Segment 7","startTime":1664306581,"endTime":1664306599},{"name":"Segment 8","startTime":1664306956,"endTime":1664306959},{"name":"Segment 9","startTime":1664306962,"endTime":1664306965},{"name":"Segment 10","startTime":1664307015,"endTime":1664307018},{"name":"Segment 11","startTime":1664307036,"endTime":1664307039},{"name":"Segment 12","startTime":1664307055,"endTime":1664307149},{"name":"Segment 13","startTime":1664307152,"endTime":1664307159},{"name":"Segment 14","startTime":1664307169,"endTime":1664307198},{"name":"Segment 15","startTime":1664307254,"endTime":1664307270},{"name":"Segment 16","startTime":1664307272,"endTime":1664307279},{"name":"Working on stuff","startTime":1664307284,"endTime":1664307290},{"name":"Segment 18","startTime":1664307593,"endTime":1664307611},{"name":"Segment 19","startTime":1664307842,"endTime":1664307851}]} +{"entries":[{"name":"Segment 1","startTime":"2022-09-27T19:20:06.000Z","endTime":"2022-09-27T19:20:08.000Z"},{"name":"Segment 2","startTime":"2022-09-27T19:20:09.000Z","endTime":"2022-09-27T19:20:10.000Z"},{"name":"Segment 3","startTime":"2022-09-27T19:20:11.000Z","endTime":"2022-09-27T19:20:12.000Z"},{"name":"Segment 4","startTime":"2022-09-27T19:20:13.000Z","endTime":"2022-09-27T19:20:22.000Z"},{"name":"Segment 5","startTime":"2022-09-27T19:20:55.000Z","endTime":"2022-09-27T19:20:58.000Z"},{"name":"Segment 6","startTime":"2022-09-27T19:22:23.000Z","endTime":"2022-09-27T19:22:25.000Z"},{"name":"Segment 7","startTime":"2022-09-27T19:23:01.000Z","endTime":"2022-09-27T19:23:19.000Z"},{"name":"Segment 8","startTime":"2022-09-27T19:29:16.000Z","endTime":"2022-09-27T19:29:19.000Z"},{"name":"Segment 9","startTime":"2022-09-27T19:29:22.000Z","endTime":"2022-09-27T19:29:25.000Z"},{"name":"Segment 10","startTime":"2022-09-27T19:30:15.000Z","endTime":"2022-09-27T19:30:18.000Z"},{"name":"Segment 11","startTime":"2022-09-27T19:30:36.000Z","endTime":"2022-09-27T19:30:39.000Z"},{"name":"Segment 12","startTime":"2022-09-27T19:30:55.000Z","endTime":"2022-09-27T19:32:29.000Z"},{"name":"Segment 13","startTime":"2022-09-27T19:32:32.000Z","endTime":"2022-09-27T19:32:39.000Z"},{"name":"Segment 14","startTime":"2022-09-27T19:32:49.000Z","endTime":"2022-09-27T19:33:18.000Z"},{"name":"Segment 15","startTime":"2022-09-27T19:34:14.000Z","endTime":"2022-09-27T19:34:30.000Z"},{"name":"Segment 16","startTime":"2022-09-27T19:34:32.000Z","endTime":"2022-09-27T19:34:39.000Z"},{"name":"Working on stuff","startTime":"2022-09-27T19:34:44.000Z","endTime":"2022-09-27T19:34:50.000Z"},{"name":"Segment 18","startTime":"2022-09-27T19:39:53.000Z","endTime":"2022-09-27T19:40:11.000Z"},{"name":"Segment 19","startTime":"2022-09-27T19:44:02.000Z","endTime":"2022-09-27T19:44:11.000Z"},{"name":"Segment 20","startTime":"2025-09-20T19:14:46.107Z","endTime":"2025-09-20T19:14:49.734Z"}]} ``` From bb858b6b33b8fb50dd272c245651d32fa8c8830f Mon Sep 17 00:00:00 2001 From: thequotient Date: Sun, 21 Sep 2025 14:05:00 +0200 Subject: [PATCH 4/4] Implement Monthly Statistics --- src/main.ts | 37 ++++- src/settings-tab.ts | 18 ++- src/settings.ts | 2 + src/statistics.ts | 262 ++++++++++++++++++++++++++++++++++-- styles.css | 7 - test-vault/2025-09-20.md | 2 - test-vault/2025-09-21.md | 18 +++ test-vault/test-markdown.md | 2 +- test-vault/test2.md | 2 +- 9 files changed, 322 insertions(+), 28 deletions(-) delete mode 100644 test-vault/2025-09-20.md create mode 100644 test-vault/2025-09-21.md diff --git a/src/main.ts b/src/main.ts index e9fa7ec..d25111b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { MarkdownRenderChild, Plugin, TFile } from "obsidian"; import { defaultSettings, SimpleTimeTrackerSettings } from "./settings"; import { SimpleTimeTrackerSettingsTab } from "./settings-tab"; -import { displayStatistics } from "./statistics"; +import { displayStatisticsDay, displayStatisticsMonth } from "./statistics"; import { displayTracker, Entry, formatDuration, formatTimestamp, getDuration, getDurationToday, getRunningEntry, getTotalDuration, getTotalDurationToday, isRunning, loadAllTrackers, loadTracker, orderedEntries } from "./tracker"; export default class SimpleTimeTrackerPlugin extends Plugin { @@ -42,11 +42,20 @@ export default class SimpleTimeTrackerPlugin extends Plugin { i.addChild(component); }); - this.registerMarkdownCodeBlockProcessor("simple-time-tracker-statistics", (s, e, i) => { + this.registerMarkdownCodeBlockProcessor("simple-time-tracker-statistics-day", (s, e, i) => { e.empty(); const component = new MarkdownRenderChild(e); - displayStatistics(e, this, i.sourcePath); + displayStatisticsDay(e, this, i.sourcePath, s); + + i.addChild(component); + }); + + this.registerMarkdownCodeBlockProcessor("simple-time-tracker-statistics-month", (s, e, i) => { + e.empty(); + const component = new MarkdownRenderChild(e); + + displayStatisticsMonth(e, this, i.sourcePath, s); i.addChild(component); }); @@ -60,13 +69,29 @@ export default class SimpleTimeTrackerPlugin extends Plugin { }); this.addCommand({ - id: `insert-stats`, - name: `Insert Time Tracker Statistics`, + id: `insert-stats-day`, + name: `Insert Time Tracker Statistics Day`, editorCallback: (e, _) => { - e.replaceSelection("```simple-time-tracker-statistics\n```\n"); + e.replaceSelection("```simple-time-tracker-statistics-day\n```\n"); } }); + this.addCommand({ + id: `insert-stats-month`, + name: `Insert Time Tracker Statistics Month`, + editorCallback: (e, _) => { + const block = `\`\`\`simple-time-tracker-statistics-month +deviation = 0 +vacationDays = [] +sickDays = [] +daysOff = [] +\`\`\` +`; + e.replaceSelection(block); + } + }); + + } async loadSettings(): Promise { diff --git a/src/settings-tab.ts b/src/settings-tab.ts index 088a123..c865da0 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -15,7 +15,7 @@ export class SimpleTimeTrackerSettingsTab extends PluginSettingTab { this.containerEl.empty(); this.containerEl.createEl("h2", { text: "Super Simple Time Tracker Settings" }); - this.containerEl.createEl("h5", { text: "Statistic Categories" }); + this.containerEl.createEl("h5", { text: "Statistics" }); this.plugin.settings.categories.forEach((category, index) => { const setting = new Setting(this.containerEl) @@ -58,6 +58,22 @@ export class SimpleTimeTrackerSettingsTab extends PluginSettingTab { this.display(); })); + new Setting(this.containerEl) + .setName('First Day of Week') + .setDesc('Set the first day of the week for statistics calculation.') + .addDropdown(dropdown => { + dropdown + .addOption('0', 'Sunday') + .addOption('1', 'Monday') + .setValue(String(this.plugin.settings.firstDayOfWeek)) + .onChange(async (value) => { + this.plugin.settings.firstDayOfWeek = Number(value); + await this.plugin.saveSettings(); + }); + }); + + this.containerEl.createEl("h5", { text: "General" }); + new Setting(this.containerEl) .setName("Timestamp Display Format") .setDesc(createFragment(f => { diff --git a/src/settings.ts b/src/settings.ts index 001c0e8..42b7465 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -12,6 +12,7 @@ export const defaultSettings: SimpleTimeTrackerSettings = { reverseSegmentOrder: false, timestampDurations: false, showToday: false, + firstDayOfWeek: 1, //Monday categories : [ { name: "Work", @@ -35,5 +36,6 @@ export interface SimpleTimeTrackerSettings { reverseSegmentOrder: boolean; timestampDurations: boolean; showToday: boolean; + firstDayOfWeek: number; categories: Category[]; } diff --git a/src/statistics.ts b/src/statistics.ts index b97fc11..0330d2e 100644 --- a/src/statistics.ts +++ b/src/statistics.ts @@ -55,7 +55,7 @@ async function getWorkingTimeOfDay(dataviewApi: DataviewApi, plugin: SimpleTimeT const trackers = await loadAllTrackers(file.path); const pageTags = new Set(page.file.tags || []); - + let category = "Other"; for (const cat of plugin.settings.categories) { if (cat.tags.some(tag => pageTags.has(tag))) { @@ -92,11 +92,9 @@ async function getRunningTrackerMarkdown(dataviewApi: DataviewApi): Promise { +export async function displayStatisticsDay(container: HTMLElement, plugin: SimpleTimeTrackerPlugin, sourcePath: string): Promise { const app = plugin.app; - // This function contains the core logic to generate and render the report. const renderReport = async (contentContainer: HTMLElement) => { const dataviewApi = app.plugins.plugins.dataview?.api; if (!dataviewApi) { @@ -115,8 +113,6 @@ export async function displayStatistics(container: HTMLElement, plugin: SimpleTi try { contentContainer.empty(); - contentContainer.createEl("p", { text: "Loading statistics..." }); - const runningTrackerMd = await getRunningTrackerMarkdown(dataviewApi); const workingTime = await getWorkingTimeOfDay(dataviewApi, plugin, date); @@ -134,14 +130,14 @@ export async function displayStatistics(container: HTMLElement, plugin: SimpleTi }); const showTargetColumns = plugin.settings.categories.some(c => c.target); - + let totalsTable = `| Category | Duration |`; if (showTargetColumns) { totalsTable += ` Remaining | Overtime |\n|:---|:---|:---|:---|\n`; } else { totalsTable += `\n|:---|:---|\n`; } - + for (const categoryName in categoryTotals) { const category = plugin.settings.categories.find(c => c.name === categoryName); const trackedDuration = categoryTotals[categoryName]; @@ -159,7 +155,7 @@ export async function displayStatistics(container: HTMLElement, plugin: SimpleTi } } } - + totalsTable += `| **${categoryName}** | ${formatDuration(trackedDuration, plugin.settings)} |`; if (showTargetColumns) { totalsTable += ` ${remainingStr} | ${overtimeStr} |\n`; @@ -174,7 +170,7 @@ export async function displayStatistics(container: HTMLElement, plugin: SimpleTi let breakdownTable = `| Category | Entry | Duration |\n|:---|:---|:---|\n`; workingTime.fileCategories.forEach((category, i) => { - const entryKey = `[[${workingTime.pageNames[i]}]] - ${workingTime.entryNames[i]}`; + const entryKey = `**${workingTime.pageNames[i].toUpperCase()}-${workingTime.entryNames[i]}**`; const durStr = formatDuration(workingTime.entryDurations[i], plugin.settings); breakdownTable += `| ${category} | ${entryKey} | ${durStr} |\n`; }); @@ -215,3 +211,249 @@ export async function displayStatistics(container: HTMLElement, plugin: SimpleTi renderReport(contentContainer); } + +export async function displayStatisticsMonth(container: HTMLElement, plugin: SimpleTimeTrackerPlugin, sourcePath: string, blockContent: string): Promise { + const app = plugin.app; + + const renderReport = async (contentContainer: HTMLElement) => { + const dataviewApi = app.plugins.plugins.dataview?.api; + if (!dataviewApi) { + contentContainer.empty(); + contentContainer.createEl("p", { text: "Error: Dataview plugin is not enabled..." }); + return; + } + + const settings: any = {}; + blockContent.split('\n').forEach(line => { + const parts = line.split('='); + if (parts.length === 2) { + const key = parts[0].trim(); + const value = parts[1].trim(); + try { + settings[key] = JSON.parse(value); + } catch (e) { + settings[key] = value; + } + } + }); + + const deviation = settings.deviation || 0; + const daysOff = settings.daysOff || []; + const vacationDays = settings.vacationDays || []; + const sickDays = settings.sickDays || []; + + + const fileName = sourcePath.split('/').pop() || ''; + const year = extractYear(fileName); + const monthIndex = extractMonth(fileName); + + if (!year || !monthIndex) { + contentContainer.empty(); + contentContainer.createEl("p", { text: `Error: Could not extract year and month from file name: "${fileName}"` }); + return; + } + + try { + contentContainer.empty(); + await printWorkingTimeOfMonth(contentContainer, dataviewApi, plugin, year, monthIndex, deviation, daysOff, vacationDays, sickDays); + + } catch (error) { + console.error("Simple Time Tracker (Monthly Statistics) Error:", error); + contentContainer.empty(); + contentContainer.createEl("p", { text: "An error occurred while generating the monthly report. Check the developer console for details." }); + } + }; + + container.empty(); + container.addClass("simple-time-tracker-stats-container"); + + const header = container.createDiv({ cls: "simple-time-tracker-stats-header" }); + + const titleGroup = header.createDiv({ attr: { style: "display: flex; align-items: center; gap: 0.5em;" } }); + titleGroup.createEl("h4", { text: "Monthly Statistics" }); + const refreshButton = titleGroup.createEl("button", { cls: "clickable-icon", attr: { "aria-label": "Refresh" } }); + setIcon(refreshButton, "refresh-cw"); + + const contentContainer = container.createDiv({ cls: "simple-time-tracker-stats-content" }); + + refreshButton.addEventListener("click", () => { + setIcon(refreshButton, "loader"); + refreshButton.disabled = true; + renderReport(contentContainer).finally(() => { + setIcon(refreshButton, "refresh-cw"); + refreshButton.disabled = false; + }); + }); + + renderReport(contentContainer); +} + +async function printWorkingTimeOfMonth(container: HTMLElement, dataviewApi: DataviewApi, plugin: SimpleTimeTrackerPlugin, year: number, monthIndex: number, deviation: number, daysOff: number[], vacationDays: number[], sickDays: number[]) { + // Configure moment to use the user's setting for the first day of the week + moment.updateLocale('en', { week: { dow: plugin.settings.firstDayOfWeek } }); + + const monthLookupTable = [ + { name: "January", days: 31 }, { name: "February", days: 28 }, { name: "March", days: 31 }, + { name: "April", days: 30 }, { name: "May", days: 31 }, { name: "June", days: 30 }, + { name: "July", days: 31 }, { name: "August", days: 31 }, { name: "September", days: 30 }, + { name: "October", days: 31 }, { name: "November", days: 30 }, { name: "December", days: 31 } + ]; + const HOURS_PER_DAY_OFF = 8 * 60 * 60 * 1000; + const allDaysOff = new Set([...daysOff, ...vacationDays, ...sickDays]); + + + const getMonthDetails = (year: number, monthIndex: number) => { + if (monthIndex < 1 || monthIndex > 12) return null; + let monthDetails = monthLookupTable[monthIndex - 1]; + if (monthIndex === 2 && isLeapYear(year)) { + monthDetails = { ...monthDetails, days: 29 }; + } + return monthDetails; + }; + + const isLeapYear = (year: number) => (year % 4 === 0 && year % 100 !== 0) || (year % 400 === 0); + + const monthDetails = getMonthDetails(year, monthIndex); + if (!monthDetails) throw new Error("Invalid month index"); + + container.createEl("h4", { text: monthDetails.name }); + + let promises = []; + for (let i = 1; i <= monthDetails.days; i++) { + let day = i < 10 ? "0" + i : String(i); + let month = monthIndex < 10 ? "0" + monthIndex : String(monthIndex); + let date = `${year}-${month}-${day}`; + promises.push(getWorkingTimeOfDay(dataviewApi, plugin, date)); + } + let results = await Promise.all(promises); + + let weekRows: any[][] = []; + let weeklyWorkTotal = 0; + let weeklyOtherTotal = 0; + let accumulatedDeviation = deviation; + + let weekStartDay = 1; + + results.forEach((workingTime, i) => { + let day = i + 1; + let currentMoment = moment({ year: year, month: monthIndex - 1, day: day }); + let dayOfWeek = currentMoment.format("dd"); + let weekNumber = currentMoment.week(); + + let workDuration = 0, otherDuration = 0; + + workingTime.fileCategories.forEach((category, index) => { + const isWork = plugin.settings.categories.find(c => c.name === category)?.tags.includes("#work"); + if (isWork) { + workDuration += workingTime.entryDurations[index]; + } else { + otherDuration += workingTime.entryDurations[index]; + } + }); + + weeklyWorkTotal += workDuration; + weeklyOtherTotal += otherDuration; + + let dayLabel = `${day} (${dayOfWeek})`; + if (daysOff.includes(day)) { + dayLabel = `*${day} (${dayOfWeek}) - Day Off*`; + } else if (vacationDays.includes(day)) { + dayLabel = `*${day} (${dayOfWeek}) - Vacation*`; + } else if (sickDays.includes(day)) { + dayLabel = `*${day} (${dayOfWeek}) - Sick*`; + } + + + weekRows.push([ + dayLabel, + formatDuration(workDuration, plugin.settings), + formatDuration(otherDuration, plugin.settings), + printBreakdown(workingTime, plugin) + ]); + + // Use the locale-aware .weekday() function. The last day of the week is always 6. + const dayOfWeekIndex = currentMoment.weekday(); + const isLastDayOfMonth = day === monthDetails.days; + + // Check if the current day is the last day of the configured week, or the last day of the month. + if (dayOfWeekIndex === 6 || isLastDayOfMonth) { + const targetTimeForWeek = calculateTargetTime(weekStartDay, day, allDaysOff, HOURS_PER_DAY_OFF); + accumulatedDeviation = renderWeekTable(container, plugin, weekRows, weeklyWorkTotal, weeklyOtherTotal, targetTimeForWeek, accumulatedDeviation, weekNumber); + weeklyWorkTotal = 0; + weeklyOtherTotal = 0; + weekRows = []; + weekStartDay = day + 1; + } + }); + + container.createEl("h4", { text: "End of Month Summary" }); + renderEndOfMonthSummary(container, plugin, accumulatedDeviation, daysOff, vacationDays, sickDays); +} + +function renderEndOfMonthSummary(container: HTMLElement, plugin: SimpleTimeTrackerPlugin, accumulatedDeviation: number, daysOff: number[], vacationDays: number[], sickDays: number[]) { + let headers = ["Metric", "Value"]; + let table = `| ${headers[0]} | ${headers[1]} |\n| --- | --- |\n`; + let accumulatedDeviationFormatted = `${(accumulatedDeviation >= 0 ? "+" : "-")}${formatDuration(Math.abs(accumulatedDeviation), plugin.settings)}`; + table += `| **Total Accumulated Deviation** | **${accumulatedDeviationFormatted}** |\n`; + table += `| **Total Accumulated Deviation (ms)** | **${accumulatedDeviation}** |\n`; + table += `| **Number of Days Off** | **${daysOff.length}** |\n`; + table += `| **Number of Vacation Days** | **${vacationDays.length}** |\n`; + table += `| **Number of Sick Days** | **${sickDays.length}** |\n`; + + + MarkdownRenderer.render(plugin.app, table, container, "", plugin); +} + +function calculateTargetTime(weekStartDay: number, weekEndDay: number, daysOff: Set, HOURS_PER_DAY_OFF: number): number { + let daysInWeek = weekEndDay - weekStartDay + 1; + let totalTarget = daysInWeek * 8 * 60 * 60 * 1000; + daysOff.forEach(day => { + if (day >= weekStartDay && day <= weekEndDay) { + totalTarget -= HOURS_PER_DAY_OFF; + } + }); + return totalTarget; +} + +function renderWeekTable(container: HTMLElement, plugin: SimpleTimeTrackerPlugin, rows: any[][], weeklyWorkTotal: number, weeklyOtherTotal: number, targetTimeForWeek: number, accumulatedDeviation: number, weekNumber: number): number { + container.createEl("h5", {text: `Week ${weekNumber}`}) + let headers = ["Day", "Work Duration", "Other Duration", "Entries"]; + let table = `| ${headers[0]} | ${headers[1]} | ${headers[2]} | ${headers[3]} |\n| --- | --- | --- | --- |\n`; + rows.forEach(row => { table += `| ${row[0]} | ${row[1]} | ${row[2]} | ${row[3]} |\n`; }); + + let workTotalFormatted = formatDuration(weeklyWorkTotal, plugin.settings); + let otherTotalFormatted = formatDuration(weeklyOtherTotal, plugin.settings); + + let weeklyDeviation = weeklyWorkTotal - targetTimeForWeek; + accumulatedDeviation += weeklyDeviation; + + let weeklyDeviationFormatted = formatDuration(Math.abs(weeklyDeviation), plugin.settings); + weeklyDeviationFormatted = (weeklyDeviation >= 0 ? "+" : "-") + weeklyDeviationFormatted; + + let accumulatedDeviationFormatted = formatDuration(Math.abs(accumulatedDeviation), plugin.settings); + accumulatedDeviationFormatted = (accumulatedDeviation >= 0 ? "+" : "-") + accumulatedDeviationFormatted; + + table += `| **Total** | **${workTotalFormatted}** | **${otherTotalFormatted}** | |\n`; + table += `| **Weekly Deviation** | **${weeklyDeviationFormatted}** | | |\n`; + table += `| **Accumulated Deviation** | **${accumulatedDeviationFormatted}** | | |\n`; + + MarkdownRenderer.render(plugin.app, table, container, "", plugin); + return accumulatedDeviation; +} + +function printBreakdown(workingTime: any, plugin: SimpleTimeTrackerPlugin): string { + let { pageNames, entryNames, entryDurations } = workingTime; + return pageNames.map((pageName: string, i: number) => + `${pageName}-${entryNames[i]}: ${formatDuration(entryDurations[i], plugin.settings)}` + ).join('
'); +} + +function extractYear(inputString: string): number | null { + const yearMatch = String(inputString).match(/\b\d{4}\b/); + return yearMatch ? Number(yearMatch[0]) : null; +} + +function extractMonth(inputString: string): number | null { + const monthMatch = String(inputString).match(/\b-\d{2}\b/); + return monthMatch ? Number(monthMatch[0].replace("-", "")) : null; +} diff --git a/styles.css b/styles.css index 64cc653..7150458 100644 --- a/styles.css +++ b/styles.css @@ -110,13 +110,6 @@ color: var(--text-normal); } -/* Spinning animation for loader icon */ -@keyframes simple-time-tracker-spin { - to { - transform: rotate(360deg); - } -} - .simple-time-tracker-stats-header .clickable-icon svg.lucide-loader { animation: simple-time-tracker-spin 1s linear infinite; } diff --git a/test-vault/2025-09-20.md b/test-vault/2025-09-20.md deleted file mode 100644 index e4fcb40..0000000 --- a/test-vault/2025-09-20.md +++ /dev/null @@ -1,2 +0,0 @@ -```simple-time-tracker-statistics -``` diff --git a/test-vault/2025-09-21.md b/test-vault/2025-09-21.md new file mode 100644 index 0000000..1b42a33 --- /dev/null +++ b/test-vault/2025-09-21.md @@ -0,0 +1,18 @@ + +```simple-time-tracker-statistics-day +``` + +```simple-time-tracker-statistics-month +deviation = 1000 +vacationDays = [2,3,4] +sickDays = [] +daysOff = [6,7,13,14,20,21,27,28] +``` + + +```simple-time-tracker-statistics-month +deviation = 0 +vacationDays = [] +sickDays = [] +daysOff = [] +``` diff --git a/test-vault/test-markdown.md b/test-vault/test-markdown.md index cca48ec..7a1f4de 100644 --- a/test-vault/test-markdown.md +++ b/test-vault/test-markdown.md @@ -1,5 +1,5 @@ Tested for #tag, *italic*, [link](test2), etc: ```simple-time-tracker -{"entries":[{"name":"`Segment 1`","startTime":"2022-09-27T19:51:18.000Z","endTime":"2022-09-27T19:51:24.000Z"},{"name":"Segment 2","startTime":"2022-09-27T19:51:25.000Z","endTime":"2022-09-27T19:51:26.000Z"},{"name":"#tag Seqment 3 *add* #tag1 text","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1 #tagp1","startTime":"2024-03-17T11:16:00.382Z","endTime":"2024-03-17T11:16:15.966Z"},{"name":"Part 3","startTime":"2024-03-17T11:17:08.000Z","endTime":"2024-03-17T11:17:24.000Z"}]},{"name":"#tag3 Segment 4","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1 #tag4","startTime":"2024-03-17T12:22:04.000Z","endTime":"2024-03-17T12:22:16.000Z"},{"name":"#tag5 Part 2 *italic*","startTime":"2024-03-17T12:22:20.000Z","endTime":"2024-03-17T12:22:24.000Z"}]},{"name":"*italic* Segment 5 #tag6 [test2](test2)","startTime":"2024-03-17T12:40:37.000Z","endTime":"2024-03-17T12:40:45.000Z"},{"name":"Segment 6","startTime":"2024-03-27T13:20:56.000Z","endTime":"2024-08-09T16:27:18.029Z"}]} +{"entries":[{"name":"`Segment 1`","startTime":"2022-09-27T19:51:18.000Z","endTime":"2022-09-27T19:51:24.000Z"},{"name":"Segment 2","startTime":"2022-09-27T19:51:25.000Z","endTime":"2022-09-27T19:51:26.000Z"},{"name":"#tag Seqment 3 *add* #tag1 text","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1 #tagp1","startTime":"2024-03-17T11:16:00.382Z","endTime":"2024-03-17T11:16:15.966Z"},{"name":"Part 3","startTime":"2024-03-17T11:17:08.000Z","endTime":"2024-03-17T11:17:24.000Z"}]},{"name":"#tag3 Segment 4","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1 #tag4","startTime":"2024-03-17T12:22:04.000Z","endTime":"2024-03-17T12:22:16.000Z"},{"name":"#tag5 Part 2 *italic*","startTime":"2024-03-17T12:22:20.000Z","endTime":"2024-03-17T12:22:24.000Z"},{"name":"Part 3","startTime":"2025-09-21T11:37:00.643Z","endTime":"2025-09-21T11:37:05.070Z"}]},{"name":"*italic* Segment 5 #tag6 [test2](test2)","startTime":"2024-03-17T12:40:37.000Z","endTime":"2024-03-17T12:40:45.000Z"},{"name":"Segment 6","startTime":null,"endTime":null,"subEntries":[{"name":"Part 1","startTime":"2024-03-27T13:20:56.000Z","endTime":"2024-08-09T16:27:18.029Z"},{"name":"Part 2","startTime":"2025-09-21T11:37:07.460Z","endTime":"2025-09-21T11:37:12.310Z"}]},{"name":"Segment 7","startTime":"2025-09-21T11:36:56.203Z","endTime":"2025-09-21T11:36:59.116Z"}]} ``` diff --git a/test-vault/test2.md b/test-vault/test2.md index 4f614db..2e37405 100644 --- a/test-vault/test2.md +++ b/test-vault/test2.md @@ -1,3 +1,3 @@ ```simple-time-tracker -{"entries":[{"name":"Segment 1","startTime":1664308278,"endTime":1664308284},{"name":"Segment 2","startTime":1664308285,"endTime":1664308286},{"name":"Segment 3","startTime":1664308299,"endTime":1664308312}]} +{"entries":[{"name":"Segment 1","startTime":"2022-09-27T19:51:18.000Z","endTime":"2022-09-27T19:51:24.000Z"},{"name":"Segment 2","startTime":"2022-09-27T19:51:25.000Z","endTime":"2022-09-27T19:51:26.000Z"},{"name":"Segment 3","startTime":"2022-09-27T19:51:39.000Z","endTime":"2022-09-27T19:51:52.000Z"},{"name":"Segment 4","startTime":"2025-09-21T11:36:49.614Z","endTime":"2025-09-21T11:36:51.978Z"}]} ```