diff --git a/README.md b/README.md
index fdfaa72..3e8758f 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 in the player list page sidebar
### 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"
}
}