From 340f6292266d9f99d5948ad84fbc9854cd502c80 Mon Sep 17 00:00:00 2001 From: callumalpass Date: Mon, 18 May 2026 02:33:53 +1000 Subject: [PATCH] Add Pomodoro session deletion --- docs/releases/unreleased.md | 1 + src/i18n/resources/en.ts | 8 + src/services/PomodoroService.ts | 73 ++++++++- src/views/PomodoroStatsView.ts | 63 +++++++- styles/pomodoro-stats-view.css | 27 +++- ...issue-1147-pomodoro-session-delete.test.ts | 151 ++++++++++++++++++ 6 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 tests/unit/issues/issue-1147-pomodoro-session-delete.test.ts diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index 042b653e..4a6a1b7d 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -104,6 +104,7 @@ Example: ## Fixed +- ([#1147](https://github.com/callumalpass/tasknotes/issues/1147)) Added a delete action for recent Pomodoro statistics sessions, so erroneous Pomodoro history can be removed without editing plugin storage or daily-note frontmatter manually. Thanks to @RumiaKitinari for reporting this and @BrucePlumb for the follow-up. - ([#1150](https://github.com/callumalpass/tasknotes/issues/1150)) Kept recurring tasks' due dates visible in Calendar Bases views, and showed their scheduled date when recurrence instances are hidden. Thanks to @thomatino for reporting this and @BrucePlumb for the follow-up. - ([#1159](https://github.com/callumalpass/tasknotes/issues/1159)) Softened Calendar time-entry styling so recorded time blocks no longer visually outweigh task events. Thanks to @hatespinach for reporting this. - ([#1191](https://github.com/callumalpass/tasknotes/issues/1191)) Aligned the task-card and relationships widgets inside notes with Obsidian's editor margins, including readable-line-width layouts. Thanks to @minchinweb for reporting this. diff --git a/src/i18n/resources/en.ts b/src/i18n/resources/en.ts index 4f599dd2..7f755ded 100644 --- a/src/i18n/resources/en.ts +++ b/src/i18n/resources/en.ts @@ -421,6 +421,14 @@ export const en: TranslationTree = { recents: { empty: "No sessions recorded yet", duration: "{minutes} min", + delete: "Delete session", + deleteAria: "Delete Pomodoro session", + deleteConfirmTitle: "Delete Pomodoro session?", + deleteConfirmMessage: + "This removes the session from Pomodoro history. Existing task time entries are not changed.", + deleteConfirmButton: "Delete", + deleteSuccess: "Pomodoro session deleted", + deleteNotFound: "Pomodoro session was not found", status: { completed: "Completed", interrupted: "Interrupted", diff --git a/src/services/PomodoroService.ts b/src/services/PomodoroService.ts index 8aa68b04..691c9d64 100644 --- a/src/services/PomodoroService.ts +++ b/src/services/PomodoroService.ts @@ -1102,15 +1102,35 @@ export class PomodoroService { await this.saveHistoryToDailyNotes(history); } else { // Default plugin storage - const data = (await this.plugin.loadData()) || {}; - data.pomodoroHistory = history; - await this.plugin.saveData(data); + await this.savePluginHistory(history); } } catch (error) { console.error("Failed to save session history:", error); } } + async deleteSessionFromHistory(session: PomodoroSessionHistory): Promise { + let deleted = false; + + try { + const pluginHistory = await this.loadPluginHistory(); + const filteredPluginHistory = pluginHistory.filter((entry) => entry.id !== session.id); + + if (filteredPluginHistory.length !== pluginHistory.length) { + await this.savePluginHistory(filteredPluginHistory); + deleted = true; + } + + if (this.plugin.settings.pomodoroStorageLocation === "daily-notes") { + deleted = (await this.deleteSessionFromDailyNote(session)) || deleted; + } + } catch (error) { + console.error("Failed to delete pomodoro session:", error); + } + + return deleted; + } + async addSessionToHistory(session: PomodoroSession): Promise { if (!session.endTime) { console.warn("Cannot add session to history without end time"); @@ -1188,6 +1208,12 @@ export class PomodoroService { return Array.isArray(pluginHistory) ? pluginHistory : []; } + private async savePluginHistory(history: PomodoroSessionHistory[]): Promise { + const data = (await this.plugin.loadData()) || {}; + data.pomodoroHistory = history; + await this.plugin.saveData(data); + } + private async loadPluginHistoryForDateKey(dateKey: string): Promise { return filterPomodoroSessionsByDateKey(await this.loadPluginHistory(), dateKey); } @@ -1391,6 +1417,47 @@ export class PomodoroService { } } + private async deleteSessionFromDailyNote(session: PomodoroSessionHistory): Promise { + try { + if (!appHasDailyNotesPluginLoaded()) { + return false; + } + + const sessionDateKey = getPomodoroSessionDateKey(session); + if (!sessionDateKey) { + return false; + } + + const sessionDate = parseDateToLocal(sessionDateKey); + const dailyNoteMoment = getDailyNoteMoment(sessionDate); + const dailyNote = getDailyNote(dailyNoteMoment, getAllDailyNotes()); + + if (!dailyNote) { + return false; + } + + const pomodoroField = this.plugin.fieldMapper.toUserField("pomodoros"); + let deleted = false; + + await this.plugin.app.fileManager.processFrontMatter(dailyNote, (frontmatter) => { + const existingSessions = Array.isArray(frontmatter[pomodoroField]) + ? frontmatter[pomodoroField].filter(isPomodoroSessionHistory) + : []; + const filteredSessions = existingSessions.filter((entry) => entry.id !== session.id); + + if (filteredSessions.length !== existingSessions.length) { + frontmatter[pomodoroField] = filteredSessions; + deleted = true; + } + }); + + return deleted; + } catch (error) { + console.error(`Failed to delete pomodoro session from daily note:`, error); + return false; + } + } + /** * Update a specific daily note with pomodoro sessions */ diff --git a/src/views/PomodoroStatsView.ts b/src/views/PomodoroStatsView.ts index 060c44d3..c9f77be7 100644 --- a/src/views/PomodoroStatsView.ts +++ b/src/views/PomodoroStatsView.ts @@ -1,8 +1,9 @@ -import { ItemView, WorkspaceLeaf, Setting, setIcon, setTooltip } from "obsidian"; +import { ItemView, Menu, Notice, WorkspaceLeaf, Setting, setIcon, setTooltip } from "obsidian"; import { format, startOfWeek, endOfWeek } from "date-fns"; import type { Day } from "date-fns"; import TaskNotesPlugin from "../main"; import { POMODORO_STATS_VIEW_TYPE, PomodoroHistoryStats, PomodoroSessionHistory } from "../types"; +import { showConfirmationModal } from "../modals/ConfirmationModal"; import { getTodayLocal, createUTCDateFromLocalCalendarDate, @@ -238,6 +239,10 @@ export class PomodoroStatsView extends ItemView { const sessionEl = container.createDiv({ cls: "pomodoro-session-item pomodoro-stats-view__session-item", }); + this.registerDomEvent(sessionEl, "contextmenu", (event: MouseEvent) => { + event.preventDefault(); + this.showSessionContextMenu(event, session); + }); const dateEl = sessionEl.createSpan({ cls: "session-date pomodoro-stats-view__session-date", @@ -278,6 +283,62 @@ export class PomodoroStatsView extends ItemView { const taskName = session.taskPath.split("/").pop()?.replace(".md", "") || ""; taskEl.textContent = taskName; } + + const deleteButton = sessionEl.createEl("button", { + cls: "pomodoro-stats-view__session-delete-button", + attr: { + type: "button", + "aria-label": this.t("views.pomodoroStats.recents.deleteAria"), + }, + }); + setIcon(deleteButton, "trash-2"); + setTooltip(deleteButton, this.t("views.pomodoroStats.recents.delete"), { + placement: "top", + }); + this.registerDomEvent(deleteButton, "click", (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + void this.confirmDeleteSession(session); + }); + } + } + + private showSessionContextMenu(event: MouseEvent, session: PomodoroSessionHistory): void { + const menu = new Menu(); + menu.addItem((item) => { + item.setTitle(this.t("views.pomodoroStats.recents.delete")); + item.setIcon("trash"); + item.onClick(() => { + void this.confirmDeleteSession(session); + }); + }); + menu.showAtMouseEvent(event); + } + + private async confirmDeleteSession(session: PomodoroSessionHistory): Promise { + const confirmed = await showConfirmationModal(this.plugin.app, { + title: this.t("views.pomodoroStats.recents.deleteConfirmTitle"), + message: this.t("views.pomodoroStats.recents.deleteConfirmMessage"), + confirmText: this.t("views.pomodoroStats.recents.deleteConfirmButton"), + cancelText: this.t("common.cancel"), + isDestructive: true, + }); + + if (!confirmed || !this.plugin.pomodoroService) { + return; + } + + const deleted = await this.plugin.pomodoroService.deleteSessionFromHistory(session); + new Notice( + this.t( + deleted + ? "views.pomodoroStats.recents.deleteSuccess" + : "views.pomodoroStats.recents.deleteNotFound" + ) + ); + + if (deleted) { + await this.refreshStats(); } } diff --git a/styles/pomodoro-stats-view.css b/styles/pomodoro-stats-view.css index 45b155c0..769b611c 100644 --- a/styles/pomodoro-stats-view.css +++ b/styles/pomodoro-stats-view.css @@ -244,7 +244,7 @@ /* Session Item Container */ .tasknotes-plugin .pomodoro-stats-view__session-item { display: grid; - grid-template-columns: auto auto auto 1fr; + grid-template-columns: auto auto auto 1fr auto; gap: var(--tn-spacing-md); align-items: center; padding: var(--tn-spacing-md); @@ -308,6 +308,27 @@ white-space: nowrap; } +.tasknotes-plugin .pomodoro-stats-view__session-delete-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + color: var(--tn-text-muted); + background: transparent; + border: 1px solid transparent; + border-radius: var(--tn-radius-sm); + cursor: pointer; +} + +.tasknotes-plugin .pomodoro-stats-view__session-delete-button:hover, +.tasknotes-plugin .pomodoro-stats-view__session-delete-button:focus-visible { + color: var(--tn-color-error); + background: var(--tn-interactive-hover); + border-color: var(--tn-border-color); +} + /* ================================================ RESPONSIVE DESIGN ================================================ */ @@ -352,6 +373,10 @@ .tasknotes-plugin .pomodoro-stats-view__session-status { justify-self: center; } + + .tasknotes-plugin .pomodoro-stats-view__session-delete-button { + justify-self: center; + } } @media (max-width: 480px) { diff --git a/tests/unit/issues/issue-1147-pomodoro-session-delete.test.ts b/tests/unit/issues/issue-1147-pomodoro-session-delete.test.ts new file mode 100644 index 00000000..de59e772 --- /dev/null +++ b/tests/unit/issues/issue-1147-pomodoro-session-delete.test.ts @@ -0,0 +1,151 @@ +jest.mock("obsidian-daily-notes-interface", () => ({ + appHasDailyNotesPluginLoaded: jest.fn(), + createDailyNote: jest.fn(), + getAllDailyNotes: jest.fn(), + getDailyNote: jest.fn(), +})); + +import { + appHasDailyNotesPluginLoaded, + getAllDailyNotes, + getDailyNote, +} from "obsidian-daily-notes-interface"; +import { PomodoroService } from "../../../src/services/PomodoroService"; +import type { PomodoroSessionHistory } from "../../../src/types"; + +const mockedAppHasDailyNotesPluginLoaded = + appHasDailyNotesPluginLoaded as jest.MockedFunction; +const mockedGetAllDailyNotes = getAllDailyNotes as jest.MockedFunction; +const mockedGetDailyNote = getDailyNote as jest.MockedFunction; + +function createSession(id: string, startTime: string): PomodoroSessionHistory { + const endTime = new Date(new Date(startTime).getTime() + 25 * 60 * 1000).toISOString(); + + return { + id, + startTime, + endTime, + plannedDuration: 25, + type: "work", + taskPath: `Tasks/${id}.md`, + completed: true, + activePeriods: [{ startTime, endTime }], + }; +} + +function dateKeyFromLocalDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function createPlugin(options: { + storageLocation: "plugin" | "daily-notes"; + pluginHistory?: PomodoroSessionHistory[]; + dailyNoteSessions?: Record; +}) { + const dailyNoteSessions = options.dailyNoteSessions ?? {}; + const dailyNotes = Object.fromEntries( + Object.keys(dailyNoteSessions).map((dateKey) => [ + dateKey, + { path: `Daily/${dateKey}.md` }, + ]) + ); + let pluginData = { pomodoroHistory: options.pluginHistory ?? [] }; + + mockedGetAllDailyNotes.mockReturnValue(dailyNotes as any); + mockedGetDailyNote.mockImplementation((momentValue: any, notes: Record) => { + const date = + typeof momentValue?.toDate === "function" + ? momentValue.toDate() + : momentValue?.date instanceof Date + ? momentValue.date + : momentValue; + return notes[dateKeyFromLocalDate(date)]; + }); + + return { + settings: { + pomodoroWorkDuration: 25, + pomodoroStorageLocation: options.storageLocation, + }, + i18n: { + translate: jest.fn((key: string) => key), + }, + loadData: jest.fn(async () => pluginData), + saveData: jest.fn(async (data: typeof pluginData) => { + pluginData = data; + }), + app: { + fileManager: { + processFrontMatter: jest.fn( + async (file: { path: string }, callback: (frontmatter: any) => void) => { + const dateKey = file.path.match(/(\d{4}-\d{2}-\d{2})/)?.[1] ?? ""; + const frontmatter = { + pomodoros: [...(dailyNoteSessions[dateKey] ?? [])], + }; + callback(frontmatter); + dailyNoteSessions[dateKey] = frontmatter.pomodoros; + } + ), + }, + metadataCache: { + getFileCache: jest.fn(), + }, + }, + fieldMapper: { + toUserField: jest.fn(() => "pomodoros"), + }, + emitter: { + trigger: jest.fn(), + }, + taskService: { + startTimeTracking: jest.fn(), + stopTimeTracking: jest.fn(), + }, + _dailyNoteSessions: dailyNoteSessions, + }; +} + +describe("Issue #1147: deleting Pomodoro session history", () => { + beforeEach(() => { + mockedAppHasDailyNotesPluginLoaded.mockReturnValue(true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it("removes a session from plugin storage", async () => { + const deletedSession = createSession("delete-me", "2026-04-26T09:00:00.000Z"); + const keptSession = createSession("keep-me", "2026-04-26T10:00:00.000Z"); + const plugin = createPlugin({ + storageLocation: "plugin", + pluginHistory: [deletedSession, keptSession], + }); + const service = new PomodoroService(plugin as any); + + await expect(service.deleteSessionFromHistory(deletedSession)).resolves.toBe(true); + + expect(plugin.saveData).toHaveBeenCalledWith({ pomodoroHistory: [keptSession] }); + }); + + it("removes a session from daily-note storage and legacy plugin history", async () => { + const deletedSession = createSession("delete-me", "2026-04-26T09:00:00.000Z"); + const keptSession = createSession("keep-me", "2026-04-26T10:00:00.000Z"); + const plugin = createPlugin({ + storageLocation: "daily-notes", + pluginHistory: [deletedSession], + dailyNoteSessions: { + "2026-04-26": [deletedSession, keptSession], + }, + }); + const service = new PomodoroService(plugin as any); + + await expect(service.deleteSessionFromHistory(deletedSession)).resolves.toBe(true); + + expect(plugin.saveData).toHaveBeenCalledWith({ pomodoroHistory: [] }); + expect(plugin._dailyNoteSessions["2026-04-26"]).toEqual([keptSession]); + }); +});