diff --git a/__tests__/groups/group.test.js b/__tests__/groups/group.test.js index 97f49624..440494dd 100644 --- a/__tests__/groups/group.test.js +++ b/__tests__/groups/group.test.js @@ -21,6 +21,19 @@ describe('Discord Groups Page', () => { let page; jest.setTimeout(60000); + // Shared constants for date formatting tests + const TEST_TIMESTAMP = Date.UTC(2024, 10, 6, 10, 15); + const FULL_DATE_FORMAT_OPTIONS = { + weekday: 'long', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + hour12: true, + }; + beforeAll(async () => { browser = await puppeteer.launch({ headless: 'new', @@ -422,4 +435,116 @@ describe('Discord Groups Page', () => { 'Group deleted successfully', ); }); + test('Should display last used label and tooltip when lastUsedOn is present', async () => { + await page.goto(`${LOCAL_TEST_PAGE_URL}/groups`); + await page.waitForNetworkIdle(); + + const groupCardHandle = await page.evaluateHandle(() => { + const cards = Array.from(document.querySelectorAll('.card')); + return ( + cards.find((card) => { + const el = card.querySelector('.card__last-used'); + return el && getComputedStyle(el).display !== 'none'; + }) || null + ); + }); + + expect(groupCardHandle).toBeTruthy(); + + const lastUsedText = await page.evaluate((card) => { + const el = card.querySelector('.card__last-used'); + if (!el) return ''; + const tooltipElement = el.querySelector('.tooltip'); + if (tooltipElement) { + const fullText = el.textContent.trim(); + const tooltipText = tooltipElement.textContent.trim(); + return fullText.replace(tooltipText, '').trim(); + } + const firstChildNode = el.childNodes[0]; + if (firstChildNode && firstChildNode.nodeType === Node.TEXT_NODE) + return firstChildNode.textContent.trim(); + return el.textContent.trim(); + }, groupCardHandle); + + expect(lastUsedText).toMatch(/^Last used on: \d{1,2} [A-Za-z]{3} \d{4}$/); + + const tooltipText = await page.evaluate((card) => { + const tooltipElement = card.querySelector('.card__last-used .tooltip'); + return tooltipElement ? tooltipElement.textContent.trim() : ''; + }, groupCardHandle); + + expect(tooltipText.length).toBeGreaterThan(0); + }); + + test('Should hide last used label when lastUsedOn is absent', async () => { + await page.goto(`${LOCAL_TEST_PAGE_URL}/groups`); + await page.waitForNetworkIdle(); + + const isAnyCardHidden = await page.evaluate(() => { + const cards = Array.from(document.querySelectorAll('.card')); + return cards.some((card) => { + const el = card.querySelector('.card__last-used'); + return el && getComputedStyle(el).display === 'none'; + }); + }); + + expect(isAnyCardHidden).toBe(true); + }); + + test('Should return correctly formatted full date string and N/A for invalid inputs', async () => { + const evaluationResult = await page.evaluate( + async (timestamp, fullDateFormatOptions) => { + const utilsModule = await import('/groups/utils.js'); + const formatted = utilsModule.fullDateString(timestamp); + const expected = new Intl.DateTimeFormat( + 'en-US', + fullDateFormatOptions, + ).format(new Date(timestamp)); + const invalidString = utilsModule.fullDateString('abc'); + const invalidNotANumber = utilsModule.fullDateString(NaN); + const invalidObject = utilsModule.fullDateString({}); + return { + formatted, + expected, + invalidString, + invalidNotANumber, + invalidObject, + }; + }, + TEST_TIMESTAMP, + FULL_DATE_FORMAT_OPTIONS, + ); + + expect(evaluationResult.formatted).toBe(evaluationResult.expected); + expect(evaluationResult.invalidString).toBe('N/A'); + expect(evaluationResult.invalidNotANumber).toBe('N/A'); + expect(evaluationResult.invalidObject).toBe('N/A'); + }); + + test('Should return correctly formatted short date string and N/A for invalid inputs', async () => { + const evaluationResult = await page.evaluate(async (timestamp) => { + const utilsModule = await import('/groups/utils.js'); + const formatted = utilsModule.shortDateString(timestamp); + const dateInstance = new Date(timestamp); + const month = new Intl.DateTimeFormat('en-US', { month: 'short' }).format( + dateInstance, + ); + const expected = `${dateInstance.getDate()} ${month} ${dateInstance.getFullYear()}`; + const invalidUndefined = utilsModule.shortDateString(undefined); + const invalidStringInput = utilsModule.shortDateString('bad'); + const invalidNotANumber = utilsModule.shortDateString(NaN); + return { + formatted, + expected, + invalidUndefined, + invalidStringInput, + invalidNotANumber, + }; + }, TEST_TIMESTAMP); + + expect(evaluationResult.formatted).toBe(evaluationResult.expected); + expect(evaluationResult.invalidUndefined).toBe('N/A'); + expect(evaluationResult.invalidStringInput).toBe('N/A'); + expect(evaluationResult.invalidNotANumber).toBe('N/A'); + }); }); diff --git a/groups/createElements.js b/groups/createElements.js index 18c7c8e6..f13c6b23 100644 --- a/groups/createElements.js +++ b/groups/createElements.js @@ -1,3 +1,5 @@ +import { fullDateString, shortDateString } from './utils.js'; + const createCard = ( rawGroup, onClick = () => {}, @@ -27,6 +29,7 @@ const createCard = ( }

+

@@ -41,6 +44,19 @@ const createCard = ( cardElement.querySelector('.card__title').textContent = group.title; cardElement.querySelector('.card__description').textContent = group.description; + const lastUsedElement = cardElement.querySelector('.card__last-used'); + if (group.lastUsedTimestamp) { + const shortFormatted = shortDateString(group.lastUsedTimestamp); + lastUsedElement.classList.add('tooltip-container'); + lastUsedElement.textContent = `Last used on: ${shortFormatted}`; + + const tooltip = document.createElement('span'); + tooltip.className = 'tooltip'; + tooltip.innerText = fullDateString(group.lastUsedTimestamp); + lastUsedElement.appendChild(tooltip); + } else { + lastUsedElement.style.display = 'none'; + } cardElement.querySelector('.card__btn').textContent = group.isMember ? 'Remove me' : 'Add me'; diff --git a/groups/script.js b/groups/script.js index d4075405..72d4672b 100644 --- a/groups/script.js +++ b/groups/script.js @@ -196,6 +196,12 @@ const afterAuthentication = async () => { .split('-') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' '); + const lastUsedTimestamp = + group?.lastUsedOn?.seconds !== undefined + ? group.lastUsedOn.seconds * 1000 + : group?.lastUsedOn?._seconds !== undefined + ? group.lastUsedOn._seconds * 1000 + : undefined; acc[group.id] = { id: group.id, title: title, @@ -203,6 +209,7 @@ const afterAuthentication = async () => { isMember: group.isMember, roleId: group.roleid, description: group.description, + lastUsedTimestamp, isUpdating: false, }; return acc; diff --git a/groups/style.css b/groups/style.css index 08c9a89e..e5f05613 100644 --- a/groups/style.css +++ b/groups/style.css @@ -390,6 +390,37 @@ body { line-height: 18px; } +.card__last-used { + margin-top: 0.4rem; + color: var(--color-text-secondary); + font-size: 0.75rem; +} + +.tooltip-container { + position: relative; +} + +.tooltip { + background-color: var(--black-color, #000); + color: var(--white, #fff); + visibility: hidden; + text-align: center; + border-radius: 4px; + padding: 0.5rem; + position: absolute; + opacity: 0.9; + font-size: 0.7rem; + width: 10rem; + bottom: 100%; + left: 50%; + margin-left: -5rem; +} + +.tooltip-container:hover .tooltip { + visibility: visible; + transition-delay: 400ms; +} + .card__action { display: flex; justify-content: space-between; diff --git a/groups/utils.js b/groups/utils.js index f97112f4..2c218bef 100644 --- a/groups/utils.js +++ b/groups/utils.js @@ -178,6 +178,42 @@ function setParamValueInURL(paramKey, paramValue) { ); } +const fullDateString = (timestamp) => { + if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { + return 'N/A'; + } + const dateObj = new Date(timestamp); + if (isNaN(dateObj.getTime())) { + return 'N/A'; + } + const options = { + weekday: 'long', + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + timeZoneName: 'short', + hour12: true, + }; + return new Intl.DateTimeFormat('en-US', options).format(dateObj); +}; + +const shortDateString = (timestamp) => { + if (typeof timestamp !== 'number' || !Number.isFinite(timestamp)) { + return 'N/A'; + } + const date = new Date(timestamp); + if (isNaN(date.getTime())) { + return 'N/A'; + } + const year = date.getFullYear(); + const month = new Intl.DateTimeFormat('en-US', { month: 'short' }).format( + date, + ); + return `${date.getDate()} ${month} ${year}`; +}; + export { getUserGroupRoles, getMembers, @@ -191,4 +227,6 @@ export { getDiscordGroupIdsFromSearch, getParamValueFromURL, setParamValueInURL, + fullDateString, + shortDateString, };