From f6450c8c3ef006d1031e40042758a933b41069bb Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 16 Feb 2026 08:04:27 +1100 Subject: [PATCH] Fix time-entry timezone consistency and remove persisted durations --- docs/HTTP_API.md | 1 - .../webhook-transforms/discord-webhook.js | 9 +++- .../webhook-transforms/slack-webhook.js | 9 +++- docs/releases/unreleased.md | 20 ++++++-- src/bases/CalendarView.ts | 24 +++++---- src/bases/calendar-core.ts | 9 +++- src/main.ts | 8 ++- src/modals/TimeEntryEditorModal.ts | 14 +++--- src/services/TaskService.ts | 37 ++++++++++++-- src/types.ts | 2 +- src/utils/timeTrackingUtils.ts | 40 +++++++-------- src/views/StatsView.ts | 49 ++++++++----------- 12 files changed, 138 insertions(+), 84 deletions(-) diff --git a/docs/HTTP_API.md b/docs/HTTP_API.md index 6aba4fa6..adb74227 100644 --- a/docs/HTTP_API.md +++ b/docs/HTTP_API.md @@ -284,7 +284,6 @@ Stop the currently active time tracking session for a task. { "startTime": "2025-08-14T10:00:00.000Z", "endTime": "2025-08-14T11:30:00.000Z", - "duration": 90, "description": "Working on API endpoint implementation" } ] diff --git a/docs/examples/webhook-transforms/discord-webhook.js b/docs/examples/webhook-transforms/discord-webhook.js index 36636810..b16f96c7 100644 --- a/docs/examples/webhook-transforms/discord-webhook.js +++ b/docs/examples/webhook-transforms/discord-webhook.js @@ -51,7 +51,12 @@ function transform(payload) { } const totalMinutes = task.timeEntries.reduce((sum, entry) => { - return sum + (entry.duration || 0); + if (!entry.startTime) return sum; + const start = new Date(entry.startTime); + const end = entry.endTime ? new Date(entry.endTime) : new Date(); + const diffMs = end.getTime() - start.getTime(); + const minutes = Math.max(0, Math.floor(diffMs / (1000 * 60))); + return sum + minutes; }, 0); const hours = Math.floor(totalMinutes / 60); @@ -320,4 +325,4 @@ function transform(payload) { }] }; } -} \ No newline at end of file +} diff --git a/docs/examples/webhook-transforms/slack-webhook.js b/docs/examples/webhook-transforms/slack-webhook.js index 9ab632f5..a099bc40 100644 --- a/docs/examples/webhook-transforms/slack-webhook.js +++ b/docs/examples/webhook-transforms/slack-webhook.js @@ -49,7 +49,12 @@ function transform(payload) { } const totalMinutes = task.timeEntries.reduce((sum, entry) => { - return sum + (entry.duration || 0); + if (!entry.startTime) return sum; + const start = new Date(entry.startTime); + const end = entry.endTime ? new Date(entry.endTime) : new Date(); + const diffMs = end.getTime() - start.getTime(); + const minutes = Math.max(0, Math.floor(diffMs / (1000 * 60))); + return sum + minutes; }, 0); const hours = Math.floor(totalMinutes / 60); @@ -283,4 +288,4 @@ function transform(payload) { ] }; } -} \ No newline at end of file +} diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index bf4ff1c8..ffe352b5 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -26,14 +26,24 @@ Example: ## Added -- [Mdbase spec](https://mdbase.dev) now emits `tn_role` annotations on schema fields, allowing external tools (e.g. [mtn CLI](https://github.com/callumalpass/mdbase-tasknotes)) to discover each field's semantic role regardless of custom frontmatter names -- [Mdbase spec](https://mdbase.dev) match rules now use tag or frontmatter property matching (based on task identification settings) instead of path glob, with automatic fallback to tag matching -- [Mdbase spec](https://mdbase.dev) status field now includes `tn_completed_values` annotation, listing which status values count as completed +- [Mdbase](https://mdbase.dev) type generation now emits `tn_role` annotations on schema fields, allowing external tools (e.g. [mtn CLI](https://github.com/callumalpass/mdbase-tasknotes)) to discover each field's semantic role regardless of custom frontmatter names +- Mdbase type match rules now use tag or frontmatter property matching (based on task identification settings) instead of path glob, with automatic fallback to tag matching +- Mdbase type status field now includes `tn_completed_values` annotation, listing which status values count as completed ## Changed -- [Mdbase spec](https://mdbase.dev) no longer overwrites `mdbase.yaml` if the file already exists, preserving user customisations - +- Mdbase type generation no longer overwrites `mdbase.yaml` if the file already exists, preserving user customisations - Webhook emissions moved from API controllers and MCP layer into the service/domain layer, ensuring webhooks fire consistently regardless of entry point - Webhook runtime state now syncs automatically when plugin settings change - Extracted shared HTTP response/body-parsing utilities into a dedicated `httpUtils` module + +## Fixed + +- (#1602) Fixed time tracking statistics showing incorrect or zero values for Today/Week/Month due to UTC-anchored date range boundaries in Stats View + - Updated range calculations to use local calendar-day boundaries consistently +- (#1602) Fixed inconsistent `timeEntries` timestamp formats across create/edit/drag/resize flows + - Time entry timestamps are now written in canonical UTC ISO format (`toISOString()` with `Z`) across all write paths +- (#1602) Fixed denormalized `timeEntries.duration` drift after edits + - Time tracking calculations now derive duration from `startTime`/`endTime` + - Time entry save paths now strip legacy `duration` values instead of persisting them + - Thanks to @dy66 for reporting issue #1602 diff --git a/src/bases/CalendarView.ts b/src/bases/CalendarView.ts index 2d4d2d29..8f8bf36a 100644 --- a/src/bases/CalendarView.ts +++ b/src/bases/CalendarView.ts @@ -1364,14 +1364,16 @@ export class CalendarView extends BasesViewBase { entry.startTime = new Date(oldStartDate.getTime() + timeDiffMs).toISOString(); entry.endTime = new Date(oldEndDate.getTime() + timeDiffMs).toISOString(); + delete entry.duration; - // Recalculate duration - entry.duration = Math.round( - (new Date(entry.endTime).getTime() - new Date(entry.startTime).getTime()) / 60000 - ); + const sanitizedEntries = updatedEntries.map((timeEntry) => { + const sanitizedEntry = { ...timeEntry }; + delete sanitizedEntry.duration; + return sanitizedEntry; + }); await this.plugin.taskService.updateTask(taskInfo, { - timeEntries: updatedEntries, + timeEntries: sanitizedEntries, }); } } catch (error) { @@ -1482,14 +1484,16 @@ export class CalendarView extends BasesViewBase { // Update start and end times entry.startTime = newStart.toISOString(); entry.endTime = newEnd.toISOString(); + delete entry.duration; - // Recalculate duration - entry.duration = Math.round( - (newEnd.getTime() - newStart.getTime()) / 60000 - ); + const sanitizedEntries = updatedEntries.map((timeEntry) => { + const sanitizedEntry = { ...timeEntry }; + delete sanitizedEntry.duration; + return sanitizedEntry; + }); await this.plugin.taskService.updateTask(taskInfo, { - timeEntries: updatedEntries, + timeEntries: sanitizedEntries, }); } } catch (error) { diff --git a/src/bases/calendar-core.ts b/src/bases/calendar-core.ts index 82d6ffab..2ddf46d1 100644 --- a/src/bases/calendar-core.ts +++ b/src/bases/calendar-core.ts @@ -1134,11 +1134,16 @@ export async function handleTimeEntryCreation( startTime: start.toISOString(), endTime: end.toISOString(), description: "", - duration: durationMinutes, }; // Add to task's time entries - const updatedTimeEntries = [...(selectedTask.timeEntries || []), newEntry]; + const updatedTimeEntries = [...(selectedTask.timeEntries || []), newEntry].map( + (entry) => { + const sanitizedEntry = { ...entry }; + delete sanitizedEntry.duration; + return sanitizedEntry; + } + ); // Save to file await plugin.taskService.updateTask(selectedTask, { diff --git a/src/main.ts b/src/main.ts index 9baed395..44c9742d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2805,9 +2805,15 @@ export default class TaskNotesPlugin extends Plugin { openTimeEntryEditor(task: TaskInfo, onSave?: () => void): void { const modal = new TimeEntryEditorModal(this.app, this, task, async (updatedEntries) => { try { + const sanitizedEntries = updatedEntries.map((entry) => { + const sanitizedEntry = { ...entry }; + delete sanitizedEntry.duration; + return sanitizedEntry; + }); + // Save to file await this.taskService.updateTask(task, { - timeEntries: updatedEntries, + timeEntries: sanitizedEntries, }); // Signal immediate update before triggering data change diff --git a/src/modals/TimeEntryEditorModal.ts b/src/modals/TimeEntryEditorModal.ts index 5352137f..34869f84 100644 --- a/src/modals/TimeEntryEditorModal.ts +++ b/src/modals/TimeEntryEditorModal.ts @@ -206,7 +206,7 @@ export class TimeEntryEditorModal extends Modal { private calculateTotalMinutes(): number { return this.timeEntries.reduce((total, entry) => { - const duration = entry.duration || this.calculateDuration(entry); + const duration = this.calculateDuration(entry); return total + duration; }, 0); } @@ -259,14 +259,12 @@ export class TimeEntryEditorModal extends Modal { } } - // Calculate durations for all entries with end times - this.timeEntries.forEach(entry => { - if (entry.endTime) { - entry.duration = this.calculateDuration(entry); - } + const sanitizedEntries = this.timeEntries.map((entry) => { + const sanitizedEntry = { ...entry }; + delete sanitizedEntry.duration; + return sanitizedEntry; }); - - this.onSave(this.timeEntries); + this.onSave(sanitizedEntries); this.close(); } diff --git a/src/services/TaskService.ts b/src/services/TaskService.ts index 0cfb3fba..c0cc1418 100644 --- a/src/services/TaskService.ts +++ b/src/services/TaskService.ts @@ -1127,9 +1127,14 @@ export class TaskService { if (!updatedTask.timeEntries) { updatedTask.timeEntries = []; } + updatedTask.timeEntries = updatedTask.timeEntries.map((entry) => { + const sanitizedEntry = { ...entry }; + delete sanitizedEntry.duration; + return sanitizedEntry; + }); const newEntry: TimeEntry = { - startTime: getCurrentTimestamp(), + startTime: new Date().toISOString(), description: "Work session", }; updatedTask.timeEntries = [...updatedTask.timeEntries, newEntry]; @@ -1142,6 +1147,13 @@ export class TaskService { if (!frontmatter[timeEntriesField]) { frontmatter[timeEntriesField] = []; } + if (Array.isArray(frontmatter[timeEntriesField])) { + frontmatter[timeEntriesField] = frontmatter[timeEntriesField].map((entry: TimeEntry) => { + const sanitizedEntry = { ...entry }; + delete sanitizedEntry.duration; + return sanitizedEntry; + }); + } // Add new time entry with start time frontmatter[timeEntriesField].push(newEntry); @@ -1195,12 +1207,18 @@ export class TaskService { if (!activeSession) { throw new Error("No active time tracking session for this task"); } + const stopTimestamp = new Date().toISOString(); // Step 1: Construct new state in memory const updatedTask = { ...task }; updatedTask.dateModified = getCurrentTimestamp(); if (updatedTask.timeEntries && Array.isArray(updatedTask.timeEntries)) { + updatedTask.timeEntries = updatedTask.timeEntries.map((entry) => { + const sanitizedEntry = { ...entry }; + delete sanitizedEntry.duration; + return sanitizedEntry; + }); const entryIndex = updatedTask.timeEntries.findIndex( (entry: TimeEntry) => entry.startTime === activeSession.startTime && !entry.endTime ); @@ -1208,7 +1226,7 @@ export class TaskService { updatedTask.timeEntries = [...updatedTask.timeEntries]; updatedTask.timeEntries[entryIndex] = { ...updatedTask.timeEntries[entryIndex], - endTime: getCurrentTimestamp(), + endTime: stopTimestamp, }; } } @@ -1219,6 +1237,11 @@ export class TaskService { const dateModifiedField = this.plugin.fieldMapper.toUserField("dateModified"); if (frontmatter[timeEntriesField] && Array.isArray(frontmatter[timeEntriesField])) { + frontmatter[timeEntriesField] = frontmatter[timeEntriesField].map((entry: TimeEntry) => { + const sanitizedEntry = { ...entry }; + delete sanitizedEntry.duration; + return sanitizedEntry; + }); // Find and update the active session const entryIndex = frontmatter[timeEntriesField].findIndex( (entry: TimeEntry) => @@ -1226,7 +1249,7 @@ export class TaskService { ); if (entryIndex !== -1) { - frontmatter[timeEntriesField][entryIndex].endTime = getCurrentTimestamp(); + frontmatter[timeEntriesField][entryIndex].endTime = stopTimestamp; } } frontmatter[dateModifiedField] = updatedTask.dateModified; @@ -1280,6 +1303,14 @@ export class TaskService { throw new Error(`Cannot find task file: ${originalTask.path}`); } + if (Array.isArray(updates.timeEntries)) { + updates.timeEntries = updates.timeEntries.map((entry) => { + const sanitizedEntry = { ...entry }; + delete sanitizedEntry.duration; + return sanitizedEntry; + }); + } + const isRenameNeeded = this.plugin.settings.storeTitleInFilename && updates.title && diff --git a/src/types.ts b/src/types.ts index d1dafd42..0b534f1c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -478,7 +478,7 @@ export interface TimeEntry { startTime: string; // ISO timestamp endTime?: string; // ISO timestamp, undefined if currently running description?: string; // Optional description of what was worked on - duration?: number; // Duration in minutes (calculated or manually set) + duration?: number; // Legacy field; duration should be derived from start/end timestamps } // Reminder types diff --git a/src/utils/timeTrackingUtils.ts b/src/utils/timeTrackingUtils.ts index 912bc232..3a51f002 100644 --- a/src/utils/timeTrackingUtils.ts +++ b/src/utils/timeTrackingUtils.ts @@ -78,9 +78,7 @@ export function calculateTotalTimeSpent(timeEntries: TimeEntry[]): number { if (!timeEntries || timeEntries.length === 0) return 0; return timeEntries.reduce((total, entry) => { - if (entry.duration) { - return total + entry.duration; - } else if (entry.endTime) { + if (entry.endTime) { const durationMs = new Date(entry.endTime).getTime() - new Date(entry.startTime).getTime(); return total + Math.floor(durationMs / (1000 * 60)); @@ -194,9 +192,7 @@ export function computeTimeSummary( const entryStart = new Date(entry.startTime); if (entryStart >= startDate && entryStart <= endDate) { - if (entry.duration) { - taskMinutes += entry.duration; - } else if (!entry.endTime) { + if (!entry.endTime) { taskMinutes += Math.floor( (Date.now() - entryStart.getTime()) / (1000 * 60) ); @@ -281,12 +277,18 @@ export function computeTaskTimeData( const totalMinutes = calculateTotalTimeSpent(timeEntries); const completedSessions = timeEntries.filter((entry) => entry.endTime).length; - const completedEntries = timeEntries.filter((entry) => entry.endTime && entry.duration); + const completedEntries = timeEntries.filter((entry) => entry.endTime); const averageSessionMinutes = completedEntries.length > 0 ? Math.round( (completedEntries.reduce( - (sum, entry) => sum + (entry.duration || 0), + (sum, entry) => + sum + + Math.floor( + (new Date(entry.endTime as string).getTime() - + new Date(entry.startTime).getTime()) / + (1000 * 60) + ), 0 ) / completedEntries.length) * @@ -323,18 +325,16 @@ export function computeTaskTimeData( startTime: entry.startTime, endTime: entry.endTime || null, description: entry.description || null, - duration: - entry.duration || - (entry.endTime - ? Math.floor( - (new Date(entry.endTime).getTime() - - new Date(entry.startTime).getTime()) / - (1000 * 60) - ) - : Math.floor( - (Date.now() - new Date(entry.startTime).getTime()) / - (1000 * 60) - )), + duration: entry.endTime + ? Math.floor( + (new Date(entry.endTime).getTime() - + new Date(entry.startTime).getTime()) / + (1000 * 60) + ) + : Math.floor( + (Date.now() - new Date(entry.startTime).getTime()) / + (1000 * 60) + ), isActive: !entry.endTime, })), }; diff --git a/src/views/StatsView.ts b/src/views/StatsView.ts index 40e99bb9..5e256fe9 100644 --- a/src/views/StatsView.ts +++ b/src/views/StatsView.ts @@ -6,12 +6,13 @@ import { startOfMonth, endOfMonth, startOfDay, + endOfDay, subDays, } from "date-fns"; import TaskNotesPlugin from "../main"; import { STATS_VIEW_TYPE, TaskInfo, EVENT_TASK_UPDATED } from "../types"; import { calculateTotalTimeSpent, filterEmptyProjects } from "../utils/helpers"; -import { getTodayLocal, createUTCDateFromLocalCalendarDate } from "../utils/dateUtils"; +import { getTodayLocal } from "../utils/dateUtils"; import { createTaskCard } from "../ui/TaskCard"; import { convertInternalToUserProperties } from "../utils/propertyMapping"; import { getProjectDisplayName } from "../utils/linkUtils"; @@ -355,11 +356,11 @@ export class StatsView extends ItemView { private async updateTodayStats() { if (!this.todayStatsEl) return; - // Use UTC-anchored today for consistent timezone handling const todayLocal = getTodayLocal(); - const todayUTCAnchor = createUTCDateFromLocalCalendarDate(todayLocal); - const startOfToday = startOfDay(todayUTCAnchor); - const stats = await this.calculateStatsForRange(startOfToday, todayUTCAnchor); + const stats = await this.calculateStatsForRange( + startOfDay(todayLocal), + endOfDay(todayLocal) + ); this.renderTimeRangeStats(this.todayStatsEl, stats); } @@ -367,13 +368,11 @@ export class StatsView extends ItemView { private async updateWeekStats() { if (!this.weekStatsEl) return; - // Use UTC-anchored today for consistent timezone handling const todayLocal = getTodayLocal(); - const todayUTCAnchor = createUTCDateFromLocalCalendarDate(todayLocal); const firstDaySetting = this.plugin.settings.calendarViewSettings.firstDay || 0; const weekStartOptions = { weekStartsOn: firstDaySetting as 0 | 1 | 2 | 3 | 4 | 5 | 6 }; - const weekStart = startOfWeek(todayUTCAnchor, weekStartOptions); - const weekEnd = endOfWeek(todayUTCAnchor, weekStartOptions); + const weekStart = startOfWeek(todayLocal, weekStartOptions); + const weekEnd = endOfWeek(todayLocal, weekStartOptions); const stats = await this.calculateStatsForRange(weekStart, weekEnd); this.renderTimeRangeStats(this.weekStatsEl, stats); @@ -382,11 +381,9 @@ export class StatsView extends ItemView { private async updateMonthStats() { if (!this.monthStatsEl) return; - // Use UTC-anchored today for consistent timezone handling const todayLocal = getTodayLocal(); - const todayUTCAnchor = createUTCDateFromLocalCalendarDate(todayLocal); - const monthStart = startOfMonth(todayUTCAnchor); - const monthEnd = endOfMonth(todayUTCAnchor); + const monthStart = startOfMonth(todayLocal); + const monthEnd = endOfMonth(todayLocal); const stats = await this.calculateStatsForRange(monthStart, monthEnd); this.renderTimeRangeStats(this.monthStatsEl, stats); @@ -817,33 +814,31 @@ export class StatsView extends ItemView { * Get date range based on current filters */ private getFilterDateRange(): { start?: Date; end?: Date } { - // Use UTC-anchored today for consistent timezone handling const todayLocal = getTodayLocal(); - const todayUTCAnchor = createUTCDateFromLocalCalendarDate(todayLocal); switch (this.currentFilters.dateRange) { case "7days": return { - start: subDays(todayUTCAnchor, 7), - end: todayUTCAnchor, + start: startOfDay(subDays(todayLocal, 7)), + end: endOfDay(todayLocal), }; case "30days": return { - start: subDays(todayUTCAnchor, 30), - end: todayUTCAnchor, + start: startOfDay(subDays(todayLocal, 30)), + end: endOfDay(todayLocal), }; case "90days": return { - start: subDays(todayUTCAnchor, 90), - end: todayUTCAnchor, + start: startOfDay(subDays(todayLocal, 90)), + end: endOfDay(todayLocal), }; case "custom": return { start: this.currentFilters.customStartDate - ? new Date(this.currentFilters.customStartDate) + ? new Date(`${this.currentFilters.customStartDate}T00:00:00`) : undefined, end: this.currentFilters.customEndDate - ? new Date(this.currentFilters.customEndDate) + ? new Date(`${this.currentFilters.customEndDate}T23:59:59.999`) : undefined, }; case "all": @@ -1190,12 +1185,10 @@ export class StatsView extends ItemView { // Calculate daily time spent over last 30 days const trendData: TrendDataPoint[] = []; - // Use UTC-anchored today for consistent timezone handling const todayLocal = getTodayLocal(); - const todayUTCAnchor = createUTCDateFromLocalCalendarDate(todayLocal); for (let i = 29; i >= 0; i--) { - const date = subDays(todayUTCAnchor, i); + const date = subDays(todayLocal, i); const dateStr = format(date, "yyyy-MM-dd"); let dailyTime = 0; @@ -1424,12 +1417,10 @@ export class StatsView extends ItemView { // Calculate time by day for the last 30 days const timeByDay: TimeByDay[] = []; - // Use UTC-anchored today for consistent timezone handling const todayLocal = getTodayLocal(); - const todayUTCAnchor = createUTCDateFromLocalCalendarDate(todayLocal); for (let i = 29; i >= 0; i--) { - const date = subDays(todayUTCAnchor, i); + const date = subDays(todayLocal, i); const dateStr = format(date, "yyyy-MM-dd"); let dayTime = 0; let dayTasks = 0;