From ecd76bbdb4d8365ab7e9e044bd53cbe50ec24828 Mon Sep 17 00:00:00 2001 From: session <252201310+session567@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:43:17 +0200 Subject: [PATCH 1/2] Add player-upcoming-birthdays module --- README.md | 1 + .../content => }/common/styles/_icons.css | 8 ++ src/common/styles/common.css | 1 + src/common/utils/settings.ts | 23 ++-- .../content/common/styles/common.css | 1 - .../content/common/types/module.ts | 10 +- src/entrypoints/content/index.ts | 6 +- .../modules/denomination/utils.test.ts | 6 +- .../content/modules/denomination/utils.ts | 4 +- .../modules/match-go-to-matches/index.ts | 2 +- .../player-upcoming-birthdays/index.ts | 81 ++++++++++++++ .../player-upcoming-birthdays/metadata.ts | 13 +++ src/entrypoints/popup/index.css | 70 ++++++++++++ src/entrypoints/popup/index.ts | 103 +++++++++++++++--- src/locales/en.json | 3 + 15 files changed, 291 insertions(+), 41 deletions(-) rename src/{entrypoints/content => }/common/styles/_icons.css (80%) create mode 100644 src/entrypoints/content/modules/player-upcoming-birthdays/index.ts create mode 100644 src/entrypoints/content/modules/player-upcoming-birthdays/metadata.ts diff --git a/README.md b/README.md index fdfaa72..bf3e4dc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ match insights, various visual tweaks throughout the site, and time-saving short - Display the player's yearly salary next to weekly salary on the player detail page - Show the likelihood of a player receiving a yellow or red card based on their personality - Show the likelihood of a team spirit drop when buying or selling a player based on their personality +- List players close to their next birthday ### Transfer - Save and reuse transfer search filters diff --git a/src/entrypoints/content/common/styles/_icons.css b/src/common/styles/_icons.css similarity index 80% rename from src/entrypoints/content/common/styles/_icons.css rename to src/common/styles/_icons.css index 6ec7a8b..4ae3305 100644 --- a/src/entrypoints/content/common/styles/_icons.css +++ b/src/common/styles/_icons.css @@ -28,6 +28,14 @@ See https://github.com/lucide-icons/lucide/blob/main/LICENSE */ mask: url("data:image/svg+xml,") no-repeat center / contain; } +.hte-icon-chevron-up::before { + mask: url("data:image/svg+xml,") no-repeat center / contain; +} + +.hte-icon-chevron-down::before { + mask: url("data:image/svg+xml,") no-repeat center / contain; +} + .hte-icon-card-yellow, .hte-icon-card-red { &::before { diff --git a/src/common/styles/common.css b/src/common/styles/common.css index 80173bb..9a89a42 100644 --- a/src/common/styles/common.css +++ b/src/common/styles/common.css @@ -1,3 +1,4 @@ @import url('_colors.css'); +@import url('_icons.css'); @import url('_spacing.css'); @import url('_typography.css'); diff --git a/src/common/utils/settings.ts b/src/common/utils/settings.ts index 34dee83..74cc77a 100644 --- a/src/common/utils/settings.ts +++ b/src/common/utils/settings.ts @@ -2,8 +2,8 @@ import { allMetadata } from '@/common/utils/metadata' const settingKey = (moduleId: string, settingId: string) => `${moduleId}:${settingId}` -const buildDefaultSettings = (): Record => { - const defaults: Record = {} +const buildDefaultSettings = (): Record => { + const defaults: Record = {} for (const metadata of allMetadata) { defaults[settingKey(metadata.id, 'enabled')] = true @@ -18,10 +18,13 @@ const buildDefaultSettings = (): Record => { const defaultSettings = buildDefaultSettings() -const settingsStorage = storage.defineItem>('local:settings', { fallback: {}, version: 1 }) +const settingsStorage = storage.defineItem>('local:settings', { + fallback: {}, + version: 1, +}) const getSettings = (() => { - let promise: Promise> | null = null + let promise: Promise> | null = null settingsStorage.watch(() => { promise = null @@ -39,18 +42,24 @@ const getSettings = (() => { } })() -export const getSetting = async (moduleId: string, setting: string): Promise => { +const getSetting = async (moduleId: string, setting: string): Promise => { const key = settingKey(moduleId, setting) const settings = await getSettings() if (!(key in settings)) throw new Error(`Setting ${key} does not exist`) - return settings[key] + return settings[key] as T } +export const getBoolSetting = (moduleId: string, setting: string): Promise => + getSetting(moduleId, setting) + +export const getIntSetting = (moduleId: string, setting: string): Promise => + getSetting(moduleId, setting) + let writeQueue = Promise.resolve() -export const setSetting = (moduleId: string, setting: string, value: boolean): Promise => { +export const setSetting = (moduleId: string, setting: string, value: boolean | number): Promise => { const key = settingKey(moduleId, setting) if (!(key in defaultSettings)) throw new Error(`Setting ${key} does not exist`) diff --git a/src/entrypoints/content/common/styles/common.css b/src/entrypoints/content/common/styles/common.css index 59509ec..47efe1e 100644 --- a/src/entrypoints/content/common/styles/common.css +++ b/src/entrypoints/content/common/styles/common.css @@ -1,2 +1 @@ @import url('../../../../common/styles/common.css'); -@import url('_icons.css'); diff --git a/src/entrypoints/content/common/types/module.ts b/src/entrypoints/content/common/types/module.ts index be0b218..bcba8f6 100644 --- a/src/entrypoints/content/common/types/module.ts +++ b/src/entrypoints/content/common/types/module.ts @@ -3,13 +3,9 @@ import { Page } from '@/entrypoints/content/common/utils/pages' /** * Module-specific setting. */ -export type ModuleSetting = { - // Label shown in the popup UI - label: string - // Default value for the setting - // Currently only boolean settings are supported - default: boolean -} +export type ModuleSetting = + | { label: string; default: boolean } + | { label: string; default: number; min?: number; max?: number } export type ModuleGroup = 'general' | 'match' | 'player' | 'transfer' diff --git a/src/entrypoints/content/index.ts b/src/entrypoints/content/index.ts index 12dce6d..3d2d07d 100644 --- a/src/entrypoints/content/index.ts +++ b/src/entrypoints/content/index.ts @@ -2,7 +2,7 @@ import '@/entrypoints/content/common/styles/common.css' import { defineContentScript } from 'wxt/utils/define-content-script' -import { getSetting } from '@/common/utils/settings' +import { getBoolSetting } from '@/common/utils/settings' import type { Handler, Module } from '@/entrypoints/content/common/types/module' import { getCurrentPathname } from '@/entrypoints/content/common/utils/location' import { logger } from '@/entrypoints/content/common/utils/logger' @@ -18,6 +18,7 @@ import playerHtmsPoints from '@/entrypoints/content/modules/player-htms-points' import playerSalary from '@/entrypoints/content/modules/player-salary' import playerSkillBonus from '@/entrypoints/content/modules/player-skill-bonus' import playerTsDropRates from '@/entrypoints/content/modules/player-ts-drop-rates' +import playerUpcomingBirthdays from '@/entrypoints/content/modules/player-upcoming-birthdays' import transferAge from '@/entrypoints/content/modules/transfer-age' import weekNumber from '@/entrypoints/content/modules/week-number' @@ -30,6 +31,7 @@ const modules: Module[] = [ playerSalary, playerCardRates, playerTsDropRates, + playerUpcomingBirthdays, transferAge, hteVersion, matchHatstats, @@ -46,7 +48,7 @@ const getHandler = (module: Module): Handler | undefined => { } const runModule = async (module: Module): Promise => { - const enabled = await getSetting(module.metadata.id, 'enabled') + const enabled = await getBoolSetting(module.metadata.id, 'enabled') if (!enabled) { logger.debug(`Skipping disabled module: ${module.metadata.name}`) return diff --git a/src/entrypoints/content/modules/denomination/utils.test.ts b/src/entrypoints/content/modules/denomination/utils.test.ts index 5399a99..45ec760 100644 --- a/src/entrypoints/content/modules/denomination/utils.test.ts +++ b/src/entrypoints/content/modules/denomination/utils.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from 'vitest' -import { getSetting } from '@/common/utils/settings' +import { getBoolSetting } from '@/common/utils/settings' import { adjustDenominationValue, isDenominationType } from '@/entrypoints/content/modules/denomination/utils' vi.mock(import('@/common/utils/settings'), async (importOriginal) => { return { ...(await importOriginal()), - getSetting: vi.fn().mockResolvedValue(true), + getBoolSetting: vi.fn().mockResolvedValue(true), } }) @@ -30,7 +30,7 @@ describe(adjustDenominationValue, () => { }) it('returns raw aggressiveness value when reverseAggressiveness is false', async () => { - vi.mocked(getSetting).mockResolvedValueOnce(false) + vi.mocked(getBoolSetting).mockResolvedValueOnce(false) await expect(adjustDenominationValue('aggressiveness', 3)).resolves.toBe(3) }) diff --git a/src/entrypoints/content/modules/denomination/utils.ts b/src/entrypoints/content/modules/denomination/utils.ts index 77d8a07..0e735e3 100644 --- a/src/entrypoints/content/modules/denomination/utils.ts +++ b/src/entrypoints/content/modules/denomination/utils.ts @@ -1,4 +1,4 @@ -import { getSetting } from '@/common/utils/settings' +import { getBoolSetting } from '@/common/utils/settings' import { DENOMINATION_TYPES, MAX_VALUES } from '@/entrypoints/content/modules/denomination/constants' import { DenominationType } from '@/entrypoints/content/modules/denomination/types' @@ -15,7 +15,7 @@ const getDenominationConfig = async (lt: DenominationType): Promise => { + run: async () => { const headerRight = await waitForElement('ht-matchorder .mo-topbar .header-right') if (!headerRight) return diff --git a/src/entrypoints/content/modules/player-upcoming-birthdays/index.ts b/src/entrypoints/content/modules/player-upcoming-birthdays/index.ts new file mode 100644 index 0000000..9006580 --- /dev/null +++ b/src/entrypoints/content/modules/player-upcoming-birthdays/index.ts @@ -0,0 +1,81 @@ +import { el } from '@/common/utils/dom' +import { getIntSetting } from '@/common/utils/settings' +import type { Module } from '@/entrypoints/content/common/types/module' +import { querySelector, querySelectorAll, querySelectorIn } from '@/entrypoints/content/common/utils/dom' +import { pages } from '@/entrypoints/content/common/utils/pages' +import { PlayerAge } from '@/entrypoints/content/common/utils/player/constants' +import { parsePlayerAge } from '@/entrypoints/content/common/utils/player/utils' +import { createSidebarBox } from '@/entrypoints/content/common/utils/sidebar/box' +import metadata from '@/entrypoints/content/modules/player-upcoming-birthdays/metadata' + +type Player = { + name: string + href: string + age: string + parsedAge: PlayerAge +} + +const getPlayersWithUpcomingBirthdays = (threshold: number): Player[] => { + const players: Player[] = [] + + querySelectorAll('#mainBody > .playerList > .teamphoto-player').forEach((el) => { + const nameLink = querySelectorIn(el, ':scope > h3 a') + if (!nameLink) return + + const ageCell = querySelectorIn(el, '.transferPlayerInformation table tbody tr:first-child td:nth-child(2)') + if (!ageCell) return + + const parsedAge = parsePlayerAge(ageCell) + if (!parsedAge) return + + players.push({ + name: nameLink.textContent.trim(), + href: nameLink.href, + age: ageCell.textContent.trim(), + parsedAge, + }) + }) + + return players + .filter((player) => player.parsedAge.days > threshold) + .sort((a, b) => b.parsedAge.days - a.parsedAge.days) +} + +const renderSidebar = (sidebar: HTMLDivElement, players: Player[]): void => { + const table = el('table') + const tbody = el('tbody') + table.append(tbody) + + players.forEach((player) => { + const row = el('tr') + + const nameCell = el('td') + nameCell.append(el('a', { href: player.href, textContent: player.name })) + + const ageCell = el('td', { textContent: player.age, className: 'right' }) + + row.append(nameCell, ageCell) + tbody.append(row) + }) + + const { box, boxBody } = createSidebarBox(i18n.t('player_upcoming_birthdays_title')) + boxBody.append(table) + sidebar.append(box) +} + +const playerUpcomingBirthdays: Module = { + metadata, + pages: [pages.playerList.senior.own], + run: async () => { + const sidebar = querySelector('#sidebar') + if (!sidebar) return + + const threshold = await getIntSetting(metadata.id, 'threshold') + const players = getPlayersWithUpcomingBirthdays(threshold) + if (players.length === 0) return + + renderSidebar(sidebar, players) + }, +} + +export default playerUpcomingBirthdays diff --git a/src/entrypoints/content/modules/player-upcoming-birthdays/metadata.ts b/src/entrypoints/content/modules/player-upcoming-birthdays/metadata.ts new file mode 100644 index 0000000..929f50a --- /dev/null +++ b/src/entrypoints/content/modules/player-upcoming-birthdays/metadata.ts @@ -0,0 +1,13 @@ +import type { ModuleMetadata } from '@/entrypoints/content/common/types/module' + +const metadata = { + id: 'player-upcoming-birthdays', + group: 'player', + name: 'Upcoming Birthdays', + description: 'Show a sidebar box listing players close to their birthday.', + settings: { + threshold: { label: 'Show players older than N days', default: 90, min: 0, max: 111 }, + }, +} as const satisfies ModuleMetadata + +export default metadata diff --git a/src/entrypoints/popup/index.css b/src/entrypoints/popup/index.css index 1729197..1e6a0e2 100644 --- a/src/entrypoints/popup/index.css +++ b/src/entrypoints/popup/index.css @@ -101,6 +101,75 @@ body { accent-color: var(--hte-color-indigo-5); flex-shrink: 0; } + + & .number-input-wrapper { + display: flex; + height: 27px; + border: 1px solid var(--hte-color-gray-3); + border-radius: 4px; + overflow: hidden; + font-size: 13px; + color: var(--hte-color-gray-9); + + &:focus-within { + border-color: var(--hte-color-indigo-5); + } + + & input[type="number"] { + width: 32px; + padding: 4px; + border: none; + outline: none; + color: inherit; + font-size: inherit; + background: white; + appearance: textfield; + + &::-webkit-inner-spin-button, + &::-webkit-outer-spin-button { + appearance: none; + } + } + + & .number-input-controls { + display: flex; + flex-direction: column; + border-left: 1px solid var(--hte-color-gray-3); + } + + & .number-input-up, + & .number-input-down { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + flex: 1; + padding: 0; + background: white; + border: none; + outline: none; + cursor: pointer; + color: var(--hte-color-gray-6); + + &:hover { + background: var(--hte-color-gray-1); + color: var(--hte-color-gray-9); + } + + & [class*="hte-icon-"] { + display: flex; + + &::before { + width: 12px; + height: 12px; + } + } + } + + & .number-input-up { + border-bottom: 1px solid var(--hte-color-gray-3); + } + } } & .setting { @@ -114,6 +183,7 @@ body { } /* Enabled toggle — hide native checkbox, style label as pill */ + & input[id$="-enabled"] { position: absolute; opacity: 0; diff --git a/src/entrypoints/popup/index.ts b/src/entrypoints/popup/index.ts index cb3cd55..30ede12 100644 --- a/src/entrypoints/popup/index.ts +++ b/src/entrypoints/popup/index.ts @@ -1,17 +1,83 @@ import { el } from '@/common/utils/dom' import { allMetadata } from '@/common/utils/metadata' -import { getSetting, setSetting } from '@/common/utils/settings' -import type { ModuleMetadata } from '@/entrypoints/content/common/types/module' +import { getBoolSetting, getIntSetting, setSetting } from '@/common/utils/settings' +import type { ModuleMetadata, ModuleSetting } from '@/entrypoints/content/common/types/module' const modules = allMetadata.filter(({ excludeFromPopup }) => !excludeFromPopup) +/** + * Number input with custom increment/decrement buttons replacing the native browser controls, which are hidden via CSS. + */ +const renderNumberInput = ( + id: string, + dataset: Record, + value: number, + min?: number, + max?: number, +): HTMLElement => { + const input = el('input', { type: 'number', id, dataset, value: String(value) }) + if (min !== undefined) input.min = String(min) + if (max !== undefined) input.max = String(max) + + const clamp = (v: number) => Math.min(max ?? Infinity, Math.max(min ?? -Infinity, v)) + + const step = (delta: number) => { + input.value = String(clamp((input.valueAsNumber || 0) + delta)) + input.dispatchEvent(new Event('change', { bubbles: true })) + } + + const makeStepBtn = (direction: 'up' | 'down', delta: number) => { + const btn = el('button', { type: 'button', className: `number-input-${direction}` }) + btn.append(el('span', { className: `hte-icon-chevron-${direction}` })) + btn.addEventListener('click', () => { + step(delta) + }) + return btn + } + + const controls = el('div', { className: 'number-input-controls' }) + controls.append(makeStepBtn('up', 1), makeStepBtn('down', -1)) + + const wrapper = el('div', { className: 'number-input-wrapper' }) + wrapper.append(input, controls) + + return wrapper +} + +const renderSettingRow = ( + moduleId: string, + settingId: string, + setting: ModuleSetting, + value: boolean | number, +): HTMLElement => { + const row = el('div', { className: 'setting' }) + const inputId = `module-${moduleId}-${settingId}` + const dataset = { moduleId, settingId } + + if (typeof setting.default === 'number') { + row.append( + renderNumberInput(inputId, dataset, value as number, setting.min, setting.max), + el('label', { htmlFor: inputId, textContent: setting.label }), + ) + } else { + row.append( + el('input', { type: 'checkbox', id: inputId, checked: value as boolean, dataset }), + el('label', { htmlFor: inputId, textContent: setting.label }), + ) + } + + return row +} + const renderModule = async ({ id: moduleId, name, description, settings }: ModuleMetadata): Promise => { const toggleId = `module-${moduleId}-enabled` const extraSettings = Object.entries(settings ?? {}) const [isEnabled, ...settingValues] = await Promise.all([ - getSetting(moduleId, 'enabled'), - ...extraSettings.map(([settingId]) => getSetting(moduleId, settingId)), + getBoolSetting(moduleId, 'enabled'), + ...extraSettings.map(([settingId, setting]) => + typeof setting.default === 'number' ? getIntSetting(moduleId, settingId) : getBoolSetting(moduleId, settingId), + ), ]) const header = el('div', { className: 'module-header hte-mb-1' }) @@ -26,17 +92,9 @@ const renderModule = async ({ id: moduleId, name, description, settings }: Modul card.append(header, el('div', { className: 'description', textContent: description })) if (extraSettings.length > 0) { - const extraSettingsRows = extraSettings.map(([settingId, { label }], i) => { - const row = el('div', { className: 'setting' }) - const checkboxId = `module-${moduleId}-${settingId}` - const dataset = { moduleId, settingId } - row.append( - el('input', { type: 'checkbox', id: checkboxId, checked: settingValues[i], dataset }), - el('label', { htmlFor: checkboxId, textContent: label }), - ) - - return row - }) + const extraSettingsRows = extraSettings.map(([settingId, setting], i) => + renderSettingRow(moduleId, settingId, setting, settingValues[i]), + ) const extraSettingsSection = el('div', { className: 'settings hte-mt-2' }) extraSettingsSection.append(...extraSettingsRows) @@ -70,12 +128,21 @@ if (moduleList) { moduleList.addEventListener('change', (e) => { const target = e.target as HTMLInputElement - if (target.type !== 'checkbox') return - const { moduleId, settingId } = target.dataset if (!moduleId || !settingId) return - setSetting(moduleId, settingId, target.checked).catch((err: unknown) => { + let value: boolean | number = target.type === 'checkbox' ? target.checked : target.valueAsNumber + if (typeof value === 'number' && isNaN(value)) return + if (typeof value === 'number') { + // HTML min/max attributes only constrain the spinner buttons; values typed directly into the field are not + // clamped by the browser. Without this, a user typing 999 into a field with max=111 would persist an out-of-range + // value. + const min = target.min ? Number(target.min) : -Infinity + const max = target.max ? Number(target.max) : Infinity + value = Math.min(max, Math.max(min, value)) + } + + setSetting(moduleId, settingId, value).catch((err: unknown) => { console.error('Failed to save setting', err) }) }) diff --git a/src/locales/en.json b/src/locales/en.json index dc2f053..c50b920 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -55,5 +55,8 @@ }, "player_ts_drop_rates_sell_title": { "message": "Probability of a team spirit drop when selling this player, based on their personality." + }, + "player_upcoming_birthdays_title": { + "message": "Upcoming birthdays" } } From 0d6aa0e58112dd0fe2b23af8ac7913b8faf174b9 Mon Sep 17 00:00:00 2001 From: session <252201310+session567@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:49:57 +0200 Subject: [PATCH 2/2] Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bf3e4dc..3e8758f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ match insights, various visual tweaks throughout the site, and time-saving short - Display the player's yearly salary next to weekly salary on the player detail page - Show the likelihood of a player receiving a yellow or red card based on their personality - Show the likelihood of a team spirit drop when buying or selling a player based on their personality -- List players close to their next birthday +- List players close to their next birthday in the player list page sidebar ### Transfer - Save and reuse transfer search filters