Add Reporting Summary API and examples

This commit is contained in:
sno 2024-11-04 19:40:52 +01:00
parent c478ce6123
commit 40170e44b2
9 changed files with 600 additions and 30 deletions

View file

@ -47,6 +47,96 @@ 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. 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 # 🙏 Acknowledgements
If you like this plugin and want to support its development, you can do so through my website by clicking this fancy image! If you like this plugin and want to support its development, you can do so through my website by clicking this fancy image!

BIN
reporting-screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

18
src/interfaces.ts Normal file
View file

@ -0,0 +1,18 @@
// 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;
}

View file

@ -1,18 +1,38 @@
import { MarkdownRenderChild, Plugin, TFile } from "obsidian"; import { Editor, MarkdownRenderChild, moment, Plugin, TFile } from "obsidian";
import { defaultSettings, SimpleTimeTrackerSettings } from "./settings"; import { defaultSettings, SimpleTimeTrackerSettings } from "./settings";
import { SimpleTimeTrackerSettingsTab } from "./settings-tab"; import { SimpleTimeTrackerSettingsTab } from "./settings-tab";
import { displayTracker, Entry, formatDuration, formatTimestamp, getDuration, getRunningEntry, getTotalDuration, isRunning, loadAllTrackers, loadTracker, orderedEntries } from "./tracker"; import {
displayTracker,
Entry,
formatDuration,
formatTimestamp,
getDuration,
getRunningEntry,
getTotalDuration,
isRunning,
loadAllTrackers,
loadTracker,
orderedEntries,
} from "./tracker";
import { TimeTrackingSummary } from "./timeTrackingSummary";
export default class SimpleTimeTrackerPlugin extends Plugin { export default class SimpleTimeTrackerPlugin extends Plugin {
public api = { public api = {
// verbatim versions of the functions found in tracker.ts with the same parameters // 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 // modified versions of the functions found in tracker.ts, with the number of required arguments reduced
formatTimestamp: (timestamp: string) => formatTimestamp(timestamp, this.settings), formatTimestamp: (timestamp: string) =>
formatDuration: (totalTime: number) => formatDuration(totalTime, this.settings), formatTimestamp(timestamp, this.settings),
orderedEntries: (entries: Entry[]) => orderedEntries(entries, this.settings) formatDuration: (totalTime: number) =>
formatDuration(totalTime, this.settings),
orderedEntries: (entries: Entry[]) =>
orderedEntries(entries, this.settings),
}; };
public settings: SimpleTimeTrackerSettings; public settings: SimpleTimeTrackerSettings;
@ -21,37 +41,102 @@ export default class SimpleTimeTrackerPlugin extends Plugin {
this.addSettingTab(new SimpleTimeTrackerSettingsTab(this.app, this)); this.addSettingTab(new SimpleTimeTrackerSettingsTab(this.app, this));
this.registerMarkdownCodeBlockProcessor("simple-time-tracker", (s, e, i) => { this.registerMarkdownCodeBlockProcessor(
e.empty(); "simple-time-tracker",
let component = new MarkdownRenderChild(e); (s, e, i) => {
let tracker = loadTracker(s); e.empty();
let component = new MarkdownRenderChild(e);
let tracker = loadTracker(s);
// Wrap file name in a function since it can change // Wrap file name in a function since it can change
let filePath = i.sourcePath; let filePath = i.sourcePath;
const getFile = () => filePath; const getFile = () => filePath;
// Hook rename events to update the file path // Hook rename events to update the file path
component.registerEvent(this.app.vault.on("rename", (file, oldPath) => { component.registerEvent(
if (file instanceof TFile && oldPath === filePath) { this.app.vault.on("rename", (file, oldPath) => {
filePath = file.path; if (file instanceof TFile && oldPath === filePath) {
} filePath = file.path;
})); }
})
);
displayTracker(tracker, e, getFile, () => i.getSectionInfo(e), this.settings, component); displayTracker(
i.addChild(component); tracker,
}); e,
getFile,
() => i.getSectionInfo(e),
this.settings,
component
);
i.addChild(component);
}
);
this.addCommand({ this.addCommand({
id: `insert`, id: `insert`,
name: `Insert Time Tracker`, name: `Insert Time Tracker`,
editorCallback: (e, _) => { editorCallback: (e, _) => {
e.replaceSelection("```simple-time-tracker\n```\n"); 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<void> { async loadSettings(): Promise<void> {
this.settings = Object.assign({}, defaultSettings, await this.loadData()); this.settings = Object.assign(
{},
defaultSettings,
await this.loadData()
);
} }
async saveSettings(): Promise<void> { async saveSettings(): Promise<void> {

View file

@ -74,6 +74,22 @@ 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("hr");
this.containerEl.createEl("p", { text: "Need help using the plugin? Feel free to join the Discord server!" }); 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", { this.containerEl.createEl("a", { href: "https://link.ellpeck.de/discordweb" }).createEl("img", {

View file

@ -4,16 +4,52 @@ export const defaultSettings: SimpleTimeTrackerSettings = {
csvDelimiter: ",", csvDelimiter: ",",
fineGrainedDurations: true, fineGrainedDurations: true,
reverseSegmentOrder: false, reverseSegmentOrder: false,
timestampDurations: 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: []`
}; };
export interface SimpleTimeTrackerSettings { export interface SimpleTimeTrackerSettings {
timestampFormat: string; timestampFormat: string;
editableTimestampFormat: string; editableTimestampFormat: string;
csvDelimiter: string; csvDelimiter: string;
fineGrainedDurations: boolean; fineGrainedDurations: boolean;
reverseSegmentOrder: boolean; reverseSegmentOrder: boolean;
timestampDurations: boolean; timestampDurations: boolean;
tagConfigurationsYaml: string;
} }

308
src/timeTrackingSummary.ts Normal file
View file

@ -0,0 +1,308 @@
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<string>
) => {
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<string> = 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;
}
}

View file

@ -1,4 +1,4 @@
[ [
"simple-time-tracker", "dataview",
"dataview" "simple-time-tracker"
] ]

View file

@ -0,0 +1,17 @@
# 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.
```