Fix time-entry timezone consistency and remove persisted durations

This commit is contained in:
callumalpass 2026-02-16 08:04:27 +11:00
parent 206b240ed1
commit f6450c8c3e
12 changed files with 138 additions and 84 deletions

View file

@ -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"
}
]

View file

@ -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) {
}]
};
}
}
}

View file

@ -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) {
]
};
}
}
}

View file

@ -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

View file

@ -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) {

View file

@ -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, {

View file

@ -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

View file

@ -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();
}

View file

@ -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 &&

View file

@ -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

View file

@ -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,
})),
};

View file

@ -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;