From 66d80a829d07153d9a27c5b52d6d2f94e39cc3ab Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 13 Aug 2026 15:24:29 +0000 Subject: [PATCH 1/2] fix(game-ui): fit 500px canvas on mobile entry --- app/game-frontend/e2e/mainNavigation.spec.ts | 119 +++++++++++++++++++ app/game-frontend/index.html | 2 +- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/app/game-frontend/e2e/mainNavigation.spec.ts b/app/game-frontend/e2e/mainNavigation.spec.ts index 0a2e49e3..60c918ea 100644 --- a/app/game-frontend/e2e/mainNavigation.spec.ts +++ b/app/game-frontend/e2e/mainNavigation.spec.ts @@ -1175,6 +1175,125 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); }); +test('real mobile devices initially fit the complete 500px game canvas', async ({ browser }, testInfo) => { + test.setTimeout(60_000); + const configuredBaseUrl = testInfo.project.use.baseURL; + if (typeof configuredBaseUrl !== 'string') { + throw new Error('Playwright baseURL is required for the mobile viewport contract'); + } + + const deviceWidths = [360, 390, 480]; + const measurements: Record = {}; + + for (const deviceWidth of deviceWidths) { + const context = await browser.newContext({ + baseURL: configuredBaseUrl, + viewport: { width: deviceWidth, height: 844 }, + screen: { width: deviceWidth, height: 844 }, + deviceScaleFactor: 1, + isMobile: true, + hasTouch: true, + colorScheme: 'dark', + }); + const mobilePage = await context.newPage(); + const state: NavigationFixture = { + officerLevel: 5, + permission: 2, + nationLevel: 3, + stage: 6, + npcMode: 1, + generalMeCalls: 0, + operations: [], + }; + await installFixture(mobilePage, state); + await waitForMain(mobilePage); + + const mainGeometry = await mobilePage.locator('.main-page').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + screenWidth: screen.availWidth, + innerWidth: window.innerWidth, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + visualViewportScale: window.visualViewport?.scale ?? null, + documentScrollWidth: document.documentElement.scrollWidth, + canvas: { + left: rect.left, + right: rect.right, + width: rect.width, + }, + }; + }); + + expect(mainGeometry.viewportMeta).toBe('width=500'); + expect(mainGeometry.screenWidth).toBe(deviceWidth); + expect(mainGeometry.layoutViewportWidth).toBe(500); + expect(mainGeometry.visualViewportWidth).toBeCloseTo(500, 2); + expect(mainGeometry.visualViewportScale).toBeCloseTo(deviceWidth / 500, 2); + expect(mainGeometry.documentScrollWidth).toBeLessThanOrEqual(mainGeometry.innerWidth); + expect(mainGeometry.canvas).toEqual({ left: 0, right: 500, width: 500 }); + expect(mainGeometry.canvas.right).toBeLessThanOrEqual((mainGeometry.visualViewportWidth ?? 0) + 0.01); + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await mobilePage.screenshot({ + path: resolve(artifactRoot, `initial-mobile-fit-${deviceWidth}.png`), + fullPage: true, + }); + } + + const routeGeometry: Record = {}; + if (deviceWidth === 390) { + for (const target of [ + 'chief-center', + 'battle-center', + 'inherit', + 'nation-betting', + ]) { + await mobilePage.goto(target); + await expect + .poll(() => mobilePage.locator('#app').evaluate((element) => getComputedStyle(element).minWidth)) + .toBe('500px'); + routeGeometry[target] = await mobilePage.locator('#app').evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { + viewportMeta: document.querySelector('meta[name="viewport"]')?.content, + layoutViewportWidth: document.documentElement.clientWidth, + visualViewportWidth: window.visualViewport?.width ?? null, + left: rect.left, + right: rect.right, + width: rect.width, + }; + }); + const geometry = routeGeometry[target] as { + viewportMeta: string; + layoutViewportWidth: number; + visualViewportWidth: number; + left: number; + right: number; + width: number; + }; + expect(geometry.viewportMeta).toBe('width=500'); + expect(geometry.layoutViewportWidth).toBe(500); + expect(geometry.visualViewportWidth).toBeCloseTo(500, 2); + expect(geometry.left).toBeCloseTo(0, 2); + expect(geometry.right).toBeCloseTo(500, 2); + expect(geometry.width).toBeCloseTo(500, 2); + } + } + + measurements[String(deviceWidth)] = { main: mainGeometry, routes: routeGeometry }; + await context.close(); + } + + if (artifactRoot) { + await writeFile( + resolve(artifactRoot, 'initial-mobile-fit-computed-dom.json'), + `${JSON.stringify(measurements, null, 2)}\n` + ); + } +}); + test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => { const state: NavigationFixture = { officerLevel: 1, diff --git a/app/game-frontend/index.html b/app/game-frontend/index.html index 7e6e4af1..45033591 100644 --- a/app/game-frontend/index.html +++ b/app/game-frontend/index.html @@ -2,7 +2,7 @@ - + Sammo HiDCHe - Game From dcba974aa45f3fcf8ba900589cb6dfb1ce0c5354 Mon Sep 17 00:00:00 2001 From: hided62 Date: Thu, 13 Aug 2026 15:26:53 +0000 Subject: [PATCH 2/2] fix(frontend): standardize server time display --- app/game-frontend/e2e/board.spec.ts | 26 ++- app/game-frontend/src/utils/legacyDateTime.ts | 26 +-- app/game-frontend/src/views/AuctionView.vue | 23 +-- .../src/views/BattleCenterView.vue | 17 +- app/game-frontend/src/views/BettingView.vue | 8 +- app/game-frontend/src/views/BoardView.vue | 16 +- .../src/views/DynastyDetailView.vue | 26 ++- app/game-frontend/src/views/InheritView.vue | 9 +- app/game-frontend/src/views/MainView.vue | 11 +- app/game-frontend/src/views/MyPageView.vue | 7 +- .../src/views/NationSecretView.vue | 7 +- app/game-frontend/src/views/SurveyView.vue | 12 +- .../src/views/TournamentView.vue | 8 +- app/game-frontend/src/views/TrafficView.vue | 10 +- app/game-frontend/src/views/TroopView.vue | 6 +- .../e2e/server-operations.spec.ts | 8 +- .../src/views/AccountView.vue | 5 +- app/gateway-frontend/src/views/AdminView.vue | 67 ++++---- app/gateway-frontend/src/views/LobbyView.vue | 4 +- .../src/views/ServerOperationsView.vue | 28 +--- packages/common/src/index.ts | 1 + packages/common/src/time/ServerDateTime.ts | 158 ++++++++++++++++++ packages/common/test/server-date-time.test.ts | 44 +++++ 23 files changed, 352 insertions(+), 175 deletions(-) create mode 100644 packages/common/src/time/ServerDateTime.ts create mode 100644 packages/common/test/server-date-time.test.ts diff --git a/app/game-frontend/e2e/board.spec.ts b/app/game-frontend/e2e/board.spec.ts index 0a027abf..183f2aa3 100644 --- a/app/game-frontend/e2e/board.spec.ts +++ b/app/game-frontend/e2e/board.spec.ts @@ -274,6 +274,8 @@ test('matches the ref meeting-room geometry, typography, textures, and controls' 'src', 'https://sam-image.hided.net/icons/22.jpg' ); + await expect(page.locator('.article-header .date')).toHaveText('07-26 19:20'); + await expect(page.locator('.comment-row .date')).toHaveText('07-26 19:25'); if (artifactRoot) { await page.screenshot({ path: resolve(artifactRoot, 'board-core-desktop.png'), @@ -392,7 +394,9 @@ test('uses the ref 500px responsive form widths', async ({ page }) => { await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible(); }); -test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }, testInfo) => { +test('retains article and comment input after a failed mutation, then reloads after success', async ({ + page, +}, testInfo) => { const state: BoardFixture = { permission: 2, canMeeting: true, @@ -408,7 +412,9 @@ test('retains article and comment input after a failed mutation, then reloads af await page.locator('#board-title').fill('새 제목'); await page.locator('#board-content').fill('새 내용'); await page.locator('#submitArticle').click(); - const articleToast = page.getByTestId('game-toast').filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' }); + const articleToast = page + .getByTestId('game-toast') + .filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' }); await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error'); await expect(articleToast).toHaveAttribute('role', 'alert'); await expect(page.locator('#board-title')).toHaveValue('새 제목'); @@ -416,7 +422,13 @@ test('retains article and comment input after a failed mutation, then reloads af const desktopToastGeometry = await articleToast.evaluate((element) => { const rect = element.getBoundingClientRect(); - return { left: rect.left, right: rect.right, top: rect.top, width: rect.width, viewportWidth: window.innerWidth }; + return { + left: rect.left, + right: rect.right, + top: rect.top, + width: rect.width, + viewportWidth: window.innerWidth, + }; }); expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0); expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth); @@ -435,10 +447,14 @@ test('retains article and comment input after a failed mutation, then reloads af await page.setViewportSize({ width: 390, height: 844 }); const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth); await commentInput.press('Enter'); - const commentToast = page.getByTestId('game-toast').filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' }); + const commentToast = page + .getByTestId('game-toast') + .filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' }); await expect(commentToast).toBeVisible(); await expect - .poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)) + .poll(async () => + commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom) + ) .toBeGreaterThanOrEqual(0); const mobileToastGeometry = await commentToast.evaluate((element) => { const rect = element.getBoundingClientRect(); diff --git a/app/game-frontend/src/utils/legacyDateTime.ts b/app/game-frontend/src/utils/legacyDateTime.ts index 3b89fd7e..38ea3b55 100644 --- a/app/game-frontend/src/utils/legacyDateTime.ts +++ b/app/game-frontend/src/utils/legacyDateTime.ts @@ -1,24 +1,6 @@ -const KOREA_TIME_OFFSET_MS = 9 * 60 * 60 * 1000; +import { formatServerDateTime } from '@sammo-ts/common'; -const pad = (value: number): string => String(value).padStart(2, '0'); +export const formatSeoulDateTime = (value: string | Date): string => formatServerDateTime(value); -export const formatSeoulDateTime = (value: string | Date): string => { - if ( - typeof value === 'string' && - !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim()) - ) { - return value.trim().replace('T', ' ').slice(0, 19); - } - const date = value instanceof Date ? value : new Date(value); - if (Number.isNaN(date.getTime())) { - return typeof value === 'string' ? value.slice(0, 19) : ''; - } - const koreaTime = new Date(date.getTime() + KOREA_TIME_OFFSET_MS); - return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad( - koreaTime.getUTCDate() - )} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad( - koreaTime.getUTCSeconds() - )}`; -}; - -export const formatSeoulHourMinute = (value: string | Date): string => formatSeoulDateTime(value).slice(11, 16); +export const formatSeoulHourMinute = (value: string | Date): string => + formatServerDateTime(value, { format: 'hourMinute' }); diff --git a/app/game-frontend/src/views/AuctionView.vue b/app/game-frontend/src/views/AuctionView.vue index dfb03396..fa3c4c58 100644 --- a/app/game-frontend/src/views/AuctionView.vue +++ b/app/game-frontend/src/views/AuctionView.vue @@ -1,4 +1,5 @@