diff --git a/src/common/utils/dom.test.ts b/src/common/utils/dom.test.ts index f4ab21b..c949de3 100644 --- a/src/common/utils/dom.test.ts +++ b/src/common/utils/dom.test.ts @@ -1,13 +1,15 @@ +import { createElement } from '@common/test/utils' import { getElementById, getElementsByName, + observeElement, querySelector, querySelectorAll, querySelectorAllIn, querySelectorIn, } from '@common/utils/dom' import { logger } from '@common/utils/logger' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' describe(getElementById, () => { beforeEach(() => { @@ -207,3 +209,41 @@ describe(querySelectorAllIn, () => { expect(logger.warn).not.toHaveBeenCalled() }) }) + +describe(observeElement, () => { + beforeEach(() => { + document.body.innerHTML = ` +
+
Initial content
+
+ ` + }) + + it('calls callback when element children change', async () => { + const container = document.getElementById('container')! + const callback = vi.fn<() => void>() + + observeElement(container, callback) + + container.appendChild(createElement('New content')) + + await vi.waitFor(() => { + expect(callback).toHaveBeenCalledTimes(1) + }) + }) + + it('calls callback when subtree changes', async () => { + const container = document.getElementById('container')! + const callback = vi.fn<() => void>() + + observeElement(container, callback) + + // Modify a nested element + const content = container.querySelector('.content')! + content.textContent = 'Modified content' + + await vi.waitFor(() => { + expect(callback).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/src/common/utils/dom.ts b/src/common/utils/dom.ts index 27cfc5e..a72b27c 100644 --- a/src/common/utils/dom.ts +++ b/src/common/utils/dom.ts @@ -117,3 +117,19 @@ export const querySelectorAllIn = ( return elements } + +/** + * Observe an element for DOM changes and run a callback when mutations occur. + * + * @param element - The element to observe for changes + * @param callback - Function to execute when the element's children change + */ +export const observeElement = (element: Element, callback: () => void) => { + const observer = new MutationObserver(() => { + observer.disconnect() + callback() + observer.observe(element, { childList: true, subtree: true }) + }) + + observer.observe(element, { childList: true, subtree: true }) +} diff --git a/src/modules/week-number/index.test.ts b/src/modules/week-number/index.test.ts index 6272b60..e54dbab 100644 --- a/src/modules/week-number/index.test.ts +++ b/src/modules/week-number/index.test.ts @@ -1,5 +1,15 @@ +import { isPage, pages } from '@common/utils/pages' import weekNumber from '@modules/week-number' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +vi.mock(import('@common/utils/pages'), async () => { + const originalModule = await vi.importActual('@common/utils/pages') + + return { + ...originalModule, + isPage: vi.fn(), + } +}) describe('week-number module', () => { it.each([ @@ -19,6 +29,30 @@ describe('week-number module', () => { expect(span?.textContent).toBe(expected) }) + it.each([ + { desc: 'own team', page: pages.playerDetailOwnTeam }, + { desc: 'other team', page: pages.playerDetailOtherTeam }, + ])('observes player tabs and adds week numbers on content change', async ({ page }) => { + vi.mocked(isPage).mockImplementation((p) => p === page) + + document.body.innerHTML = ` +
+
+
+ ` + weekNumber.run() + + // Simulate content update + const playerTabs = document.getElementById('ctl00_ctl00_CPContent_CPMain_updPlayerTabs')! + playerTabs.innerHTML = '
15.05.2025
' + + await vi.waitFor(() => { + const newWeekNumber = playerTabs.querySelector('span.hte-week-number') + + expect(newWeekNumber?.textContent).toBe(' (W3)') + }) + }) + it("doesn't add week number for invalid date", () => { document.body.innerHTML = `
diff --git a/src/modules/week-number/index.ts b/src/modules/week-number/index.ts index 88da593..bc82fe2 100644 --- a/src/modules/week-number/index.ts +++ b/src/modules/week-number/index.ts @@ -1,10 +1,32 @@ import '@modules/week-number/index.css' import type { Module } from '@common/types/module' -import { querySelectorAll } from '@common/utils/dom' -import { pages } from '@common/utils/pages' +import { getElementById, observeElement, querySelectorAllIn } from '@common/utils/dom' +import { isPage, pages } from '@common/utils/pages' import { calcWeekNumber, parseDate } from '@modules/week-number/utils' +/** + * Add week numbers to all date elements within the given root element. + * + * @param root - The parent element to search for date elements within + */ +const addWeekNumbers = (root: Element) => { + const elements = querySelectorAllIn(root, '.date', false) + + elements.forEach((element) => { + const date = parseDate(element) + if (!date) return + + const weekNumber = calcWeekNumber(date) + + const span = document.createElement('span') + span.className = 'hte-week-number shy' + span.textContent = ` (W${weekNumber})` + + element.appendChild(span) + }) +} + /** * Display week numbers next to dates throughout Hattrick. */ @@ -13,20 +35,21 @@ const weekNumber: Module = { pages: [pages.all], excludePages: [pages.forum], run: () => { - const elements = querySelectorAll('#mainBody .date', false) - - elements.forEach((element) => { - const date = parseDate(element) - if (!date) return + const mainBody = getElementById('mainBody') + if (!mainBody) return - const weekNumber = calcWeekNumber(date) + addWeekNumbers(mainBody) - const span = document.createElement('span') - span.className = 'hte-week-number shy' - span.textContent = ` (W${weekNumber})` + // Watch for tab changes on the player detail page. Tab content is loaded asynchronously when clicked, so we need to + // re-apply week numbers after each update. + if (isPage(pages.playerDetailOwnTeam) || isPage(pages.playerDetailOtherTeam)) { + const playerTabs = getElementById('ctl00_ctl00_CPContent_CPMain_updPlayerTabs') + if (!playerTabs) return - element.appendChild(span) - }) + observeElement(playerTabs, () => { + addWeekNumbers(playerTabs) + }) + } }, }