Merge remote-tracking branch 'origin/main' into refactor/shared-general-panel-20260813

# Conflicts:
#	app/game-frontend/src/views/MyPageView.vue
This commit is contained in:
2026-08-13 15:49:13 +00:00
29 changed files with 907 additions and 245 deletions
+21 -5
View File
@@ -274,6 +274,8 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
'src', 'src',
'https://sam-image.hided.net/icons/22.jpg' '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) { if (artifactRoot) {
await page.screenshot({ await page.screenshot({
path: resolve(artifactRoot, 'board-core-desktop.png'), 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(); 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 = { const state: BoardFixture = {
permission: 2, permission: 2,
canMeeting: true, 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-title').fill('새 제목');
await page.locator('#board-content').fill('새 내용'); await page.locator('#board-content').fill('새 내용');
await page.locator('#submitArticle').click(); 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('data-feedback-kind', 'error');
await expect(articleToast).toHaveAttribute('role', 'alert'); await expect(articleToast).toHaveAttribute('role', 'alert');
await expect(page.locator('#board-title')).toHaveValue('새 제목'); 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 desktopToastGeometry = await articleToast.evaluate((element) => {
const rect = element.getBoundingClientRect(); 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.left).toBeGreaterThanOrEqual(0);
expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth); 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 }); await page.setViewportSize({ width: 390, height: 844 });
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth); const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
await commentInput.press('Enter'); 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(commentToast).toBeVisible();
await expect await expect
.poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)) .poll(async () =>
commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)
)
.toBeGreaterThanOrEqual(0); .toBeGreaterThanOrEqual(0);
const mobileToastGeometry = await commentToast.evaluate((element) => { const mobileToastGeometry = await commentToast.evaluate((element) => {
const rect = element.getBoundingClientRect(); const rect = element.getBoundingClientRect();
+240 -3
View File
@@ -34,6 +34,8 @@ type NavigationFixture = {
largeCommandTable?: boolean; largeCommandTable?: boolean;
currentYear?: number; currentYear?: number;
currentMonth?: number; currentMonth?: number;
scenarioTitle?: string;
latestVote?: { id: number; title: string; hasVoted: boolean } | null;
globalRecords?: Array<{ id: number; text: string }>; globalRecords?: Array<{ id: number; text: string }>;
generalRecords?: Array<{ id: number; text: string }>; generalRecords?: Array<{ id: number; text: string }>;
worldHistory?: Array<{ id: number; text: string }>; worldHistory?: Array<{ id: number; text: string }>;
@@ -309,6 +311,7 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
year: state.currentYear ?? 185, year: state.currentYear ?? 185,
month: state.currentMonth ?? 1, month: state.currentMonth ?? 1,
turnTerm: 10, turnTerm: 10,
scenarioTitle: state.scenarioTitle ?? '',
}); });
} }
if (operation === 'dashboard.getContextBundleDelta') { if (operation === 'dashboard.getContextBundleDelta') {
@@ -421,7 +424,10 @@ const installFixture = async (page: Page, state: NavigationFixture) => {
onlineGenerals: '메뉴검증장수', onlineGenerals: '메뉴검증장수',
nationNotice: '<p>국가 방침</p>', nationNotice: '<p>국가 방침</p>',
lastExecuted: null, lastExecuted: null,
latestVote: { id: 9, title: '메뉴 설문', hasVoted: false }, latestVote:
state.latestVote === undefined
? { id: 9, title: '메뉴 설문', hasVoted: false }
: state.latestVote,
}); });
} }
if (operation === 'board.getAccess') { if (operation === 'board.getAccess') {
@@ -508,7 +514,7 @@ const installRealtimeHarness = async (page: Page) => {
const waitForMain = async (page: Page) => { const waitForMain = async (page: Page) => {
await page.goto('./'); await page.goto('./');
await expect(page.getByRole('heading', { name: '전장 현황' })).toBeVisible(); await expect(page.locator('.game-shell__title')).toBeVisible();
await expect(page.locator('.main-global-menu').first()).toBeVisible(); await expect(page.locator('.main-global-menu').first()).toBeVisible();
await expect(page.locator('.main-nation-menu')).toBeVisible(); await expect(page.locator('.main-nation-menu')).toBeVisible();
await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3); await expect(page.locator('[data-navigation-id="npc-list"]')).toHaveCount(3);
@@ -561,8 +567,24 @@ const persistArtifact = async (page: Page, name: string) => {
globalPopup: describe('#mobile-global-menu'), globalPopup: describe('#mobile-global-menu'),
nationPopup: describe('#mobile-nation-menu'), nationPopup: describe('#mobile-nation-menu'),
quickPopup: describe('#mobile-quick-menu'), quickPopup: describe('#mobile-quick-menu'),
commandMenu: describe('.reserved-command-editor details[open] .menu-items'),
commandDividers: [...document.querySelectorAll<HTMLElement>('.reserved-command-editor details[open] .menu-divider')].map(
(element) => {
const rect = element.getBoundingClientRect();
const style = getComputedStyle(element);
return {
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
borderTop: style.borderTop,
margin: style.margin,
};
}
),
}; };
}); });
const commandMenu = page.locator('.reserved-command-editor details[open] .menu-items').first();
if (await commandMenu.isVisible()) {
await commandMenu.screenshot({ path: resolve(target, `${name}-menu.png`) });
}
await Promise.all([ await Promise.all([
page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }), page.screenshot({ path: resolve(target, `${name}.png`), fullPage: true }),
writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`), writeFile(resolve(target, `${name}.json`), `${JSON.stringify(geometry, null, 2)}\n`),
@@ -576,6 +598,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
nationLevel: 3, nationLevel: 3,
stage: 1, stage: 1,
npcMode: 1, npcMode: 1,
scenarioTitle: '메인 화면 검증 시나리오',
generalMeCalls: 0, generalMeCalls: 0,
operations: [], operations: [],
}; };
@@ -589,6 +612,45 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(page.locator('.main-mobile-bottom')).toBeHidden(); await expect(page.locator('.main-mobile-bottom')).toBeHidden();
await expect(page.locator('.layout-desktop')).toBeVisible(); await expect(page.locator('.layout-desktop')).toBeVisible();
await expect(page.locator('.layout-mobile')).toHaveCount(0); await expect(page.locator('.layout-mobile')).toHaveCount(0);
await expect(page.getByRole('heading', { name: '메인 화면 검증 시나리오', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
await expect(page.locator('.game-shell__subtitle')).not.toContainText('메인 화면 검증 시나리오');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 참가 모집중');
await expect(page.locator('.vote-status')).toHaveText('설문: 메뉴 설문');
const headerStatusGeometry = await page.locator('.main-page').evaluate((element) => {
const title = element.querySelector<HTMLElement>('.game-shell__title');
const subtitle = element.querySelector<HTMLElement>('.game-shell__subtitle');
const activity = element.querySelector<HTMLElement>('.activity-status');
const tournament = element.querySelector<HTMLElement>('.tournament-status');
const survey = element.querySelector<HTMLElement>('.vote-status');
if (!title || !subtitle || !activity || !tournament || !survey) {
throw new Error('main header status geometry is incomplete');
}
return {
title: title.getBoundingClientRect().toJSON(),
subtitle: subtitle.getBoundingClientRect().toJSON(),
activity: activity.getBoundingClientRect().toJSON(),
tournament: tournament.getBoundingClientRect().toJSON(),
survey: survey.getBoundingClientRect().toJSON(),
activityColumns: getComputedStyle(activity).gridTemplateColumns,
};
});
expect(headerStatusGeometry.subtitle.y).toBeGreaterThanOrEqual(headerStatusGeometry.title.bottom);
expect(headerStatusGeometry.activity.width).toBeCloseTo(666.67, 0);
expect(headerStatusGeometry.tournament.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.survey.width).toBeCloseTo(333.33, 0);
expect(headerStatusGeometry.activityColumns.split(' ')).toHaveLength(2);
const tournamentStatusLink = page.locator('.tournament-status a');
const surveyStatusLink = page.locator('.vote-status a');
await tournamentStatusLink.hover();
await expect
.poll(() => tournamentStatusLink.evaluate((element) => getComputedStyle(element).cursor))
.toBe('pointer');
await surveyStatusLink.focus();
await expect(surveyStatusLink).toBeFocused();
await expect
.poll(() => surveyStatusLink.evaluate((element) => getComputedStyle(element).textDecorationLine))
.toContain('underline');
const contentOrder = await page const contentOrder = await page
.locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]') .locator('.record-zone, [data-menu-position="middle"], .desktop-message-panel, [data-menu-position="bottom"]')
.evaluateAll((elements) => .evaluateAll((elements) =>
@@ -642,7 +704,7 @@ test('desktop menus preserve ref columns, prefix-safe routes, and controlled dro
await expect(gameInfoButton).toBeFocused(); await expect(gameInfoButton).toBeFocused();
await gameInfoButton.click(); await gameInfoButton.click();
await page.getByRole('heading', { name: '전장 현황' }).click(); await page.getByRole('heading', { name: '메인 화면 검증 시나리오' }).click();
await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false'); await expect(gameInfoButton).toHaveAttribute('aria-expanded', 'false');
await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`); await persistArtifact(page, `${basePath.slice(1)}-desktop-1200`);
}); });
@@ -882,6 +944,10 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
await tenthTurnButton.click(); await tenthTurnButton.click();
const quickPicker = page.getByTestId('command-picker'); const quickPicker = page.getByTestId('command-picker');
await expect(quickPicker).toBeVisible(); await expect(quickPicker).toBeVisible();
await tenthTurnButton.click();
await expect(quickPicker).toBeHidden();
await tenthTurnButton.click();
await expect(quickPicker).toBeVisible();
const quickPickerAlignment = await quickPicker.evaluate((element) => { const quickPickerAlignment = await quickPicker.evaluate((element) => {
const row = element const row = element
.closest('.reserved-command-editor') .closest('.reserved-command-editor')
@@ -923,6 +989,30 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop); expect(advancedControlGeometry.rangeTop).toBe(advancedControlGeometry.recentTop);
expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop); expect(advancedControlGeometry.advancedTop).toBeGreaterThan(advancedControlGeometry.rangeTop);
expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop); expect(advancedControlGeometry.advancedBottom).toBeLessThanOrEqual(advancedControlGeometry.queueTop);
const rangeMenu = page.locator('[data-main-target="commands"] .range-menu');
await rangeMenu.locator('summary').click();
const rangeDividers = rangeMenu.locator('.menu-divider');
await expect(rangeDividers).toHaveCount(1);
await expect(rangeDividers.first()).toBeVisible();
expect(await rangeDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
'1px solid rgb(68, 68, 68)'
);
await persistArtifact(page, `${basePath.slice(1)}-command-range-divider-desktop-1200`);
await rangeMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await expect(rangeMenu).not.toHaveAttribute('open', '');
const selectedMenu = page.locator('[data-main-target="commands"] .selected-menu');
await selectedMenu.locator('summary').click();
const selectedMenuDividers = selectedMenu.locator('.menu-divider');
await expect(selectedMenuDividers).toHaveCount(3);
await expect(selectedMenuDividers.first()).toBeVisible();
expect(await selectedMenuDividers.first().evaluate((element) => getComputedStyle(element).borderTop)).toBe(
'1px solid rgb(68, 68, 68)'
);
await persistArtifact(page, `${basePath.slice(1)}-command-selected-dividers-desktop-1200`);
await selectedMenu.evaluate((element) => ((element as HTMLDetailsElement).open = false));
await expect(selectedMenu).not.toHaveAttribute('open', '');
await page.locator('[data-main-target="commands"] .select-command').click(); await page.locator('[data-main-target="commands"] .select-command').click();
const picker = page.getByTestId('command-picker'); const picker = page.getByTestId('command-picker');
await expect(picker).toBeVisible(); await expect(picker).toBeVisible();
@@ -1111,6 +1201,11 @@ test('main cards and command input stay inside their Ref-sized grid slots', asyn
expect(mobileGeometry.controlBoxes).toHaveLength(3); expect(mobileGeometry.controlBoxes).toHaveLength(3);
expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1); expect(new Set(mobileGeometry.controlBoxes.map(({ y }) => y)).size).toBe(1);
await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30); await expect(page.locator('[data-main-target="commands"] .edit-column button')).toHaveCount(30);
const mobileTurnButton = page.getByRole('button', { name: '10턴 명령 입력' });
await mobileTurnButton.click();
await expect(page.getByTestId('command-picker')).toBeVisible();
await mobileTurnButton.click();
await expect(page.getByTestId('command-picker')).toBeHidden();
await captureProgress('mobile-500'); await captureProgress('mobile-500');
}); });
@@ -1121,6 +1216,8 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
nationLevel: 3, nationLevel: 3,
stage: 6, stage: 6,
npcMode: 1, npcMode: 1,
scenarioTitle: '모바일 검증 시나리오',
latestVote: null,
generalMeCalls: 0, generalMeCalls: 0,
operations: [], operations: [],
}; };
@@ -1139,6 +1236,27 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
await expect(page.locator('.main-mobile-bottom')).toBeVisible(); await expect(page.locator('.main-mobile-bottom')).toBeVisible();
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
await expect(page.getByRole('heading', { name: '모바일 검증 시나리오', exact: true })).toHaveCount(1);
await expect(page.locator('.game-shell__subtitle')).toHaveText('185년 1월 · 턴 10분');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 베팅 진행중');
await expect(page.locator('.vote-status')).toHaveText('설문: 진행 중인 설문 없음');
const activityGeometry = await page.locator('.activity-status').evaluate((element) => {
const tournament = element.querySelector<HTMLElement>('.tournament-status');
const survey = element.querySelector<HTMLElement>('.vote-status');
if (!tournament || !survey) throw new Error('activity status is incomplete');
return {
width: element.getBoundingClientRect().width,
tournamentWidth: tournament.getBoundingClientRect().width,
surveyWidth: survey.getBoundingClientRect().width,
columns: getComputedStyle(element).gridTemplateColumns,
};
});
expect(activityGeometry).toMatchObject({
width: 500,
tournamentWidth: 250,
surveyWidth: 250,
columns: '250px 250px',
});
await expect await expect
.poll(() => .poll(() =>
page page
@@ -1175,6 +1293,125 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`); 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<string, unknown> = {};
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<HTMLMetaElement>('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<string, unknown> = {};
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<HTMLMetaElement>('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 }) => { test('nation menu presentation follows the server-derived permission matrix', async ({ page }) => {
const state: NavigationFixture = { const state: NavigationFixture = {
officerLevel: 1, officerLevel: 1,
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=500" />
<title>Sammo HiDCHe - Game</title> <title>Sammo HiDCHe - Game</title>
</head> </head>
<body class="bg-black text-white"> <body class="bg-black text-white">
@@ -149,6 +149,14 @@ const closePicker = () => {
quickTarget.value = null; quickTarget.value = null;
selectedCommand.value = null; selectedCommand.value = null;
}; };
const togglePicker = (turnIndex?: number) => {
const target = turnIndex ?? null;
if (pickerOpen.value && quickTarget.value === target) {
closePicker();
return;
}
openPicker(turnIndex);
};
const selectCommand = (commandKey: string) => { const selectCommand = (commandKey: string) => {
const command = props.commandTable?.[props.scope] const command = props.commandTable?.[props.scope]
.flatMap((group) => group.values) .flatMap((group) => group.values)
@@ -305,6 +313,7 @@ const clickOutsideMenu = (event: Event) => {
> >
짝수턴 짝수턴
</button> </button>
<hr class="menu-divider" />
<template v-for="step in [3, 4, 5, 6, 7]" :key="step"> <template v-for="step in [3, 4, 5, 6, 7]" :key="step">
<small>{{ step }} 간격</small> <small>{{ step }} 간격</small>
<div class="step-buttons"> <div class="step-buttons">
@@ -429,6 +438,7 @@ const clickOutsideMenu = (event: Event) => {
> >
붙여넣기 붙여넣기
</button> </button>
<hr class="menu-divider" />
<button <button
@click=" @click="
textCopy(); textCopy();
@@ -437,6 +447,7 @@ const clickOutsideMenu = (event: Event) => {
> >
텍스트 복사 텍스트 복사
</button> </button>
<hr class="menu-divider" />
<button <button
@click=" @click="
saveTemplate(); saveTemplate();
@@ -453,6 +464,7 @@ const clickOutsideMenu = (event: Event) => {
> >
반복하기 반복하기
</button> </button>
<hr class="menu-divider" />
<button <button
@click=" @click="
clearSelection(); clearSelection();
@@ -479,7 +491,7 @@ const clickOutsideMenu = (event: Event) => {
</button> </button>
</div> </div>
</details> </details>
<button type="button" class="select-command" @click="openPicker()">명령 선택 </button> <button type="button" class="select-command" @click="togglePicker()">명령 선택 </button>
</div> </div>
<div class="queue-area"> <div class="queue-area">
@@ -542,7 +554,7 @@ const clickOutsideMenu = (event: Event) => {
:key="row.index" :key="row.index"
type="button" type="button"
:aria-label="`${row.index + 1} 명령 입력`" :aria-label="`${row.index + 1} 명령 입력`"
@click="openPicker(row.index)" @click="togglePicker(row.index)"
> >
</button> </button>
@@ -708,6 +720,14 @@ const clickOutsideMenu = (event: Event) => {
padding: 5px 8px; padding: 5px 8px;
color: #bbb; color: #bbb;
} }
.menu-divider {
width: 100%;
height: 0;
margin: 4px 0;
border: 0;
border-top: 1px solid #444;
opacity: 1;
}
.step-buttons, .step-buttons,
.template-row { .template-row {
display: flex; display: flex;
@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ import { computed } from 'vue';
import { resolveTournamentStageName } from '../../utils/tournamentStatus';
const props = defineProps<{
tournamentStage: number;
status: { status: {
onlineUserCount: number; onlineUserCount: number;
onlineNations: string; onlineNations: string;
@@ -13,15 +17,24 @@ defineProps<{
} | null; } | null;
} | null; } | null;
}>(); }>();
const tournamentStatus = computed(() => resolveTournamentStageName(props.tournamentStage));
</script> </script>
<template> <template>
<section class="front-status" aria-label="접속 현황과 국가 방침"> <section class="front-status" aria-label="접속 현황과 국가 방침">
<div class="status-row vote-status"> <div class="activity-status" aria-label="설문과 토너먼트 진행 현황">
<RouterLink v-if="status?.latestVote" to="/survey"> <div class="status-row tournament-status">
<span class="vote-label">설문 진행 : </span>{{ status.latestVote.title }} <RouterLink to="/tournament">
</RouterLink> <span class="tournament-label">토너먼트: </span>{{ tournamentStatus }}
<span v-else class="vote-empty">진행중인 설문 없음</span> </RouterLink>
</div>
<div class="status-row vote-status">
<RouterLink v-if="status?.latestVote" to="/survey">
<span class="vote-label">설문: </span>{{ status.latestVote.title }}
</RouterLink>
<span v-else class="vote-empty">설문: 진행 중인 설문 없음</span>
</div>
</div> </div>
<div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div> <div class="status-row online-nations">접속중인 국가: {{ status?.onlineNations ?? '' }}</div>
<div class="status-row online-users"> 접속자 {{ status?.onlineGenerals ?? '' }}</div> <div class="status-row online-users"> 접속자 {{ status?.onlineGenerals ?? '' }}</div>
@@ -71,19 +84,28 @@ defineProps<{
margin: 0; margin: 0;
} }
.vote-status { .activity-status {
width: 33.333333%; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: 66.666667%;
margin-left: auto; margin-left: auto;
}
.activity-status .status-row {
padding-right: 0; padding-right: 0;
padding-left: 0; padding-left: 0;
text-align: center; text-align: center;
} }
.vote-status a { .activity-status a {
color: #fff; color: #fff;
text-decoration: gray underline; text-decoration: gray underline;
} }
.tournament-label {
color: #ffc107;
}
.vote-label { .vote-label {
color: cyan; color: cyan;
} }
@@ -93,8 +115,8 @@ defineProps<{
} }
@media (max-width: 991px) { @media (max-width: 991px) {
.vote-status { .activity-status {
width: 50%; width: 100%;
} }
} }
</style> </style>
+4 -22
View File
@@ -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 => { export const formatSeoulHourMinute = (value: string | Date): string =>
if ( formatServerDateTime(value, { format: 'hourMinute' });
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);
@@ -0,0 +1,15 @@
export const tournamentStageNames = [
'경기 없음',
'참가 모집중',
'예선 진행중',
'본선 추첨중',
'본선 진행중',
'16강 배정중',
'베팅 진행중',
'16강 진행중',
'8강 진행중',
'4강 진행중',
'결승 진행중',
] as const;
export const resolveTournamentStageName = (stage: number): string => tournamentStageNames[stage] ?? '상태 확인 중';
+5 -18
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
@@ -41,24 +42,10 @@ const formatNumber = (value: number | null | undefined): string => (value ?? 0).
const displayCode = (value: string | null | undefined): string => const displayCode = (value: string | null | undefined): string =>
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, ''); !value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
const cutDateTime = (value: string | null | undefined, showSecond = false): string => { const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
if (!value) { return formatServerDateTime(value, {
return '-'; format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
} fallback: '-',
const date = new Date(value); });
if (Number.isNaN(date.getTime())) {
return value.slice(5, showSecond ? 19 : 16);
}
const parts = new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
...(showSecond ? { second: '2-digit' } : {}),
hour12: false,
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes): string =>
parts.find((entry) => entry.type === type)?.value ?? '';
return `${part('month')}-${part('day')} ${part('hour')}:${part('minute')}${showSecond ? `:${part('second')}` : ''}`;
}; };
const buyRice = computed(() => const buyRice = computed(() =>
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue'; import PanelCard from '../components/ui/PanelCard.vue';
@@ -126,9 +127,9 @@ const selectedGeneral = computed(() => {
const formatGeneralLabel = (general: GeneralEntry): string => { const formatGeneralLabel = (general: GeneralEntry): string => {
const name = general.officerLevel > 4 ? `*${general.name}*` : general.name; const name = general.officerLevel > 4 ? `*${general.name}*` : general.name;
const time = general.turnTime ? general.turnTime.slice(-5) : '--:--'; const time = formatServerDateTime(general.turnTime, { format: 'hourMinute', fallback: '--:--' });
if (orderBy.value === 'recentWar') { if (orderBy.value === 'recentWar') {
return `${name} (${general.recentWar ? general.recentWar.slice(-5) : '--:--'})`; return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
} }
if (orderBy.value === 'warnum') { if (orderBy.value === 'warnum') {
return `${name} (${general.warnum}회)`; return `${name} (${general.warnum}회)`;
@@ -154,7 +155,10 @@ const loadLogs = async (generalId: number) => {
} }
for (const response of responses) { for (const response of responses) {
const formatted = response.logs.map((entry) => { const formatted = response.logs.map((entry) => {
const eventTime = response.type === 'generalAction' ? ` ${entry.createdAt.slice(-8, -3)}` : ''; const eventTime =
response.type === 'generalAction'
? ` ${formatServerDateTime(entry.createdAt, { format: 'hourMinute' })}`
: '';
return { return {
id: entry.id, id: entry.id,
html: formatLog(`${entry.text}${eventTime}`), html: formatLog(`${entry.text}${eventTime}`),
@@ -288,7 +292,12 @@ onMounted(() => {
</template> </template>
</GeneralBasicCard> </GeneralBasicCard>
<div v-if="selectedGeneral" class="general-meta"> <div v-if="selectedGeneral" class="general-meta">
<div>최근 : {{ selectedGeneral.turnTime ? selectedGeneral.turnTime.slice(-5) : '-' }}</div> <div>
최근 :
{{
formatServerDateTime(selectedGeneral.turnTime, { format: 'hourMinute', fallback: '-' })
}}
</div>
<div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div> <div>최근 전투: {{ selectedGeneral.recentWar || '-' }}</div>
<div>전투 횟수: {{ selectedGeneral.warnum }}</div> <div>전투 횟수: {{ selectedGeneral.warnum }}</div>
</div> </div>
+5 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -68,7 +69,9 @@ const ratio = (id: number) => {
const amount = totals?.[id] ?? 0; const amount = totals?.[id] ?? 0;
return amount ? (totalAmount.value / amount).toFixed(2) : '0'; return amount ? (totalAmount.value / amount).toFixed(2) : '0';
}; };
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--'); const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
);
const expected = (id: number) => { const expected = (id: number) => {
const myTotals = summary.value?.myTotals as Record<number, number> | undefined; const myTotals = summary.value?.myTotals as Record<number, number> | undefined;
const current = myTotals?.[id] ?? 0; const current = myTotals?.[id] ?? 0;
@@ -248,8 +251,7 @@ const placeBet = async (targetId: number) => {
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink> </RouterLink>
<small> <small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
HideD(hided62@gmail.com) / Credit
</small> </small>
</footer> </footer>
</main> </main>
+13 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'; import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@@ -40,7 +41,7 @@ const resizeTextArea = (element: HTMLTextAreaElement | null) => {
element.style.height = `${Math.max(element.scrollHeight, 42)}px`; element.style.height = `${Math.max(element.scrollHeight, 42)}px`;
}; };
const formatDate = (value: string): string => value.slice(5, 16).replace('T', ' '); const formatDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
const iconPath = (article: BoardArticle): string => const iconPath = (article: BoardArticle): string =>
resolveGeneralIconUrl({ resolveGeneralIconUrl({
@@ -160,7 +161,14 @@ onMounted(() => {
</div> </div>
<div class="article-submit-row"> <div class="article-submit-row">
<div></div> <div></div>
<button id="submitArticle" class="legacy-button legacy-button--secondary" type="button" @click="submitArticle">등록</button> <button
id="submitArticle"
class="legacy-button legacy-button--secondary"
type="button"
@click="submitArticle"
>
등록
</button>
</div> </div>
</section> </section>
@@ -244,7 +252,9 @@ onMounted(() => {
padding: 8px; padding: 8px;
color: #000; color: #000;
background: #fff; background: #fff;
font: 16px/normal 'Times New Roman', serif; font:
16px/normal 'Times New Roman',
serif;
} }
.legacy-board-page { .legacy-board-page {
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
@@ -47,12 +48,7 @@ const loadDetail = async (): Promise<void> => {
} }
}; };
const formatArchiveDate = (value: string): string => const formatArchiveDate = (value: string): string => formatServerDateTime(value);
new Intl.DateTimeFormat('sv-SE', {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
watch(emperorId, loadDetail); watch(emperorId, loadDetail);
onMounted(loadDetail); onMounted(loadDetail);
@@ -67,7 +63,9 @@ onMounted(loadDetail);
<br /> <br />
<button class="native-button" type="button" @click="closePage"> 닫기</button> <button class="native-button" type="button" @click="closePage"> 닫기</button>
<span class="all-link"> <span class="all-link">
<RouterLink to="/dynasty"><button class="native-button" type="button">전체보기</button></RouterLink> <RouterLink to="/dynasty"
><button class="native-button" type="button">전체보기</button></RouterLink
>
</span> </span>
</td> </td>
</tr> </tr>
@@ -202,7 +200,11 @@ onMounted(loadDetail);
<td colspan="5"> <td colspan="5">
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. --> <!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-for="(entry, index) in data.emperor.history" :key="index" v-html="formatLog(entry)" /> <div
v-for="(entry, index) in data.emperor.history"
:key="index"
v-html="formatLog(entry)"
/>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -283,14 +285,10 @@ onMounted(loadDetail);
<table class="legacy-table legacy-bg0 footer-table"> <table class="legacy-table legacy-bg0 footer-table">
<tbody> <tbody>
<tr> <tr>
<td> <td><button class="native-button" type="button" @click="closePage"> 닫기</button><br /></td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
</tr> </tr>
<tr> <tr>
<td class="banner"> <td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
+3 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
@@ -169,11 +170,7 @@ const turnTimeLabel = computed(() => {
if (!turnTimeResult.value) { if (!turnTimeResult.value) {
return null; return null;
} }
const parsed = new Date(turnTimeResult.value); return formatServerDateTime(turnTimeResult.value);
if (Number.isNaN(parsed.getTime())) {
return turnTimeResult.value;
}
return parsed.toLocaleString();
}); });
const isUnited = computed(() => status.value?.isUnited ?? false); const isUnited = computed(() => status.value?.isUnited ?? false);
@@ -735,7 +732,7 @@ onMounted(() => {
<div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div> <div v-if="logLoading && logs.length === 0" class="log-empty">불러오는 중...</div>
<div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div> <div v-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
<div v-for="entry in logs" v-else :key="entry.id" class="log-row"> <div v-for="entry in logs" v-else :key="entry.id" class="log-row">
<small>[{{ new Date(entry.createdAt).toLocaleString('ko-KR') }}]</small> <small>[{{ formatServerDateTime(entry.createdAt) }}]</small>
<span>{{ entry.text }}</span> <span>{{ entry.text }}</span>
</div> </div>
<button <button
+6 -15
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useMediaQuery } from '@vueuse/core'; import { useMediaQuery } from '@vueuse/core';
@@ -68,14 +69,8 @@ const nationColor = computed(() => nation.value?.color ?? '#000000');
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote)); const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => { const formatRecord = (entry: { text: string; createdAt?: string | Date }, appendTime = false): string => {
if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text); if (!appendTime || /\d{2}:\d{2}\s*$/u.test(entry.text)) return formatLog(entry.text);
const parsed = entry.createdAt ? new Date(entry.createdAt) : null; const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
if (!parsed || Number.isNaN(parsed.getTime())) return formatLog(entry.text); if (!time) return formatLog(entry.text);
const time = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(parsed);
return formatLog(`${entry.text} ${time}`); return formatLog(`${entry.text} ${time}`);
}; };
@@ -148,13 +143,9 @@ watch(
<header class="game-shell__header"> <header class="game-shell__header">
<div> <div>
<h1 class="game-shell__title"> <h1 class="game-shell__title">
{{ isMobile ? '전장 현황' : lobbyInfo?.scenarioTitle || '전장 현황' }} {{ lobbyInfo?.scenarioTitle || '전장 현황' }}
</h1> </h1>
<p class="game-shell__subtitle"> <p class="game-shell__subtitle">{{ statusLine }}</p>
{{
!isMobile && lobbyInfo?.scenarioTitle ? `${lobbyInfo.scenarioTitle} ${statusLine}` : statusLine
}}
</p>
</div> </div>
<div class="game-shell__actions desktop-action-controls"> <div class="game-shell__actions desktop-action-controls">
<button <button
@@ -197,7 +188,7 @@ watch(
</div> </div>
<div data-main-target="policy"> <div data-main-target="policy">
<MainFrontStatus :status="frontStatus" /> <MainFrontStatus :status="frontStatus" :tournament-stage="tournamentStage" />
</div> </div>
<aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite"> <aside v-if="surveyNotice" class="survey-notice" role="status" aria-live="polite">
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>; type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
@@ -147,7 +148,7 @@ onMounted(load);
> >
</td> </td>
<td>{{ general.killTurn }}</td> <td>{{ general.killTurn }}</td>
<td>{{ general.turnTime.slice(14, 19) }}</td> <td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -159,8 +160,8 @@ onMounted(load);
</tr> </tr>
<tr> <tr>
<td class="legacy-banner"> <td class="legacy-banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
HideD(hided62@gmail.com) / /
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a> <a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td> </td>
</tr> </tr>
+3 -9
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -67,16 +68,9 @@ const newVoteOptions = computed(() => newVoteOptionsText.value.split('\n').filte
const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1); const percentage = (count: number, total: number): string => ((count / Math.max(1, total)) * 100).toFixed(1);
const formatStartDate = (value: string): string => value.slice(0, 10); const formatStartDate = (value: string): string => formatServerDateTime(value, { format: 'date' });
const formatCommentDate = (value: string): string => { const formatCommentDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const pad = (part: number) => String(part).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
};
const voteColor = (index: number): string => const voteColor = (index: number): string =>
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!; ['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
+7 -18
View File
@@ -1,7 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue'; import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { resolveTournamentStageName } from '../utils/tournamentStatus';
type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>; type Snapshot = Awaited<ReturnType<typeof trpc.tournament.getSnapshot.query>>;
@@ -14,20 +16,6 @@ const actionMessage = ref<string | null>(null);
const adminEnabled = ref(false); const adminEnabled = ref(false);
const typeNames = ['전력전', '통솔전', '일기토', '설전']; const typeNames = ['전력전', '통솔전', '일기토', '설전'];
const stageNames = [
'경기 없음',
'참가 모집중',
'예선 진행중',
'본선 추첨중',
'본선 진행중',
'16강 배정중',
'베팅 진행중',
'16강 진행중',
'8강 진행중',
'4강 진행중',
'결승 진행중',
];
const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value)); const errorText = (value: unknown) => (value instanceof Error ? value.message : String(value));
const load = async () => { const load = async () => {
@@ -62,7 +50,9 @@ const matchesAt = (stage: number) =>
.sort((a, b) => a.roundIndex - b.roundIndex); .sort((a, b) => a.roundIndex - b.roundIndex);
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-'); const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
const totalBet = computed(() => betting.value?.totalAmount ?? 0); const totalBet = computed(() => betting.value?.totalAmount ?? 0);
const openingTime = computed(() => snapshot.value?.state?.nextAt?.slice(11, 16) ?? '--:--'); const openingTime = computed(() =>
formatServerDateTime(snapshot.value?.state?.nextAt, { format: 'hourMinute', fallback: '--:--' })
);
const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined); const betTotals = computed(() => betting.value?.totals as Record<number, number> | undefined);
const isParticipant = computed(() => const isParticipant = computed(() =>
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value) (snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
@@ -152,7 +142,7 @@ const start = async () => {
<section class="operator-row bg0">운영자 메세지 : <span></span></section> <section class="operator-row bg0">운영자 메세지 : <span></span></section>
<section class="state-row bg0"> <section class="state-row bg0">
<span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span> <span class="type">{{ typeNames[snapshot?.state?.type ?? 0] }}</span>
({{ stageNames[snapshot?.state?.stage ?? 0] ?? '상태 확인 중' }}, 개막시간 {{ openingTime }}, 경기당 ({{ resolveTournamentStageName(snapshot?.state?.stage ?? 0) }}, 개막시간 {{ openingTime }}, 경기당
{{ snapshot?.state?.termSeconds ?? '-' }}) {{ snapshot?.state?.termSeconds ?? '-' }})
</section> </section>
<section class="section-title bg2">16 승자전</section> <section class="section-title bg2">16 승자전</section>
@@ -287,8 +277,7 @@ const start = async () => {
<button class="close-button" type="button" @click="navigate"> 닫기</button> <button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink> </RouterLink>
<small> <small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
HideD(hided62@gmail.com) / Credit
</small> </small>
</footer> </footer>
+4 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -50,10 +51,7 @@ const onlineRows = computed(() =>
})) }))
); );
const timeLabel = (value: string): string => { const timeLabel = (value: string): string => formatServerDateTime(value, { format: 'hourMinute' });
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const trafficColor = (percentage: number): string => { const trafficColor = (percentage: number): string => {
const channel = (value: number): string => const channel = (value: number): string =>
@@ -204,8 +202,8 @@ onMounted(() => {
</tr> </tr>
<tr> <tr>
<td class="banner"> <td class="banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : 삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
HideD(hided62@gmail.com) / /
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a> <a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td> </td>
</tr> </tr>
+2 -4
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -166,10 +167,7 @@ const hideMemberPopup = () => {
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {}); const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
const formatTurn = (turnTime: string | null): string => { const formatTurn = (turnTime: string | null): string => {
if (!turnTime) { return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
return '--:--';
}
return turnTime.slice(14, 19);
}; };
onMounted(() => { onMounted(() => {
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { resolveTournamentStageName } from '../src/utils/tournamentStatus.ts';
void describe('tournament status labels', () => {
void it('describes inactive and active tournament stages', () => {
assert.equal(resolveTournamentStageName(0), '경기 없음');
assert.equal(resolveTournamentStageName(1), '참가 모집중');
assert.equal(resolveTournamentStageName(6), '베팅 진행중');
assert.equal(resolveTournamentStageName(10), '결승 진행중');
});
void it('uses a safe fallback for an unknown stage', () => {
assert.equal(resolveTournamentStageName(11), '상태 확인 중');
assert.equal(resolveTournamentStageName(-1), '상태 확인 중');
});
});
@@ -26,6 +26,9 @@ type FixtureState = {
status: OperationStatus; status: OperationStatus;
sourceMode?: 'BRANCH' | 'COMMIT'; sourceMode?: 'BRANCH' | 'COMMIT';
sourceRef?: string; sourceRef?: string;
resolvedCommitSha?: string;
completedAt?: string;
error?: string;
payload: Record<string, unknown>; payload: Record<string, unknown>;
requestedBy: string; requestedBy: string;
createdAt: string; createdAt: string;
@@ -487,6 +490,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567'); await page.getByTestId('source-ref').fill('0123456789abcdef0123456789abcdef01234567');
await page.getByTestId('load-scenarios').click(); await page.getByTestId('load-scenarios').click();
await page.getByTestId('scenario-select').selectOption('5'); await page.getByTestId('scenario-select').selectOption('5');
await page.getByLabel('작업 예약 (서버 시간 UTC+9)').fill('2026-08-13T09:30');
await page.getByTestId('request-reset').hover(); await page.getByTestId('request-reset').hover();
await page.getByTestId('request-reset').click(); await page.getByTestId('request-reset').click();
@@ -541,6 +545,7 @@ test('separates branch and commit semantics and submits a reset from the dedicat
expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"'); expect(JSON.stringify(resetRequest?.body)).toContain('"sourceMode":"COMMIT"');
expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567'); expect(JSON.stringify(resetRequest?.body)).toContain('0123456789abcdef0123456789abcdef01234567');
expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5'); expect(JSON.stringify(resetRequest?.body)).toContain('"scenarioId":5');
expect(JSON.stringify(resetRequest?.body)).toContain('"scheduledAt":"2026-08-13T00:30:00.000Z"');
await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true }); await page.screenshot({ path: testInfo.outputPath('reset-operation-log-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 }); await page.setViewportSize({ width: 390, height: 844 });
@@ -585,9 +590,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
mobileOperationTableGeometry.scrollerWidth mobileOperationTableGeometry.scrollerWidth
); );
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0); expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect( expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth mobileOperationTableGeometry.viewportWidth
).toBeLessThanOrEqual(mobileOperationTableGeometry.viewportWidth); );
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual( expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.viewportWidth mobileOperationTableGeometry.viewportWidth
); );
@@ -877,6 +882,131 @@ test('controls gateway deployment and rollback through the external controller q
expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayRollback')).toBe(true); expect(state.requestBodies.some((entry) => entry.operation === 'admin.releases.requestGatewayRollback')).toBe(true);
}); });
test('moves long Gateway release errors out of the table column into an expandable detail row', async ({
page,
}, testInfo) => {
const longError = [
'Gateway release did not become ready before the timeout.',
'Error: gateway-frontend readiness check failed after 30 attempts',
' at waitForGatewayReadiness (/srv/core/release-controller/dist/releaseController.js:842:19)',
'controller-output-without-breaks-'.repeat(12),
].join('\n');
const operationId = '88888888-8888-4888-8888-888888888888';
const state: FixtureState = {
operations: [],
gatewayOperations: [
{
id: operationId,
type: 'DEPLOY',
status: 'FAILED',
sourceMode: 'COMMIT',
sourceRef: 'cccccccccccccccccccccccccccccccccccccccc',
resolvedCommitSha: 'cccccccccccccccccccccccccccccccccccccccc',
completedAt: '2026-08-01T02:03:00.000Z',
error: longError,
payload: {},
requestedBy: 'admin',
createdAt: '2026-08-01T02:00:00.000Z',
updatedAt: '2026-08-01T02:03:00.000Z',
},
],
gatewayLogsEmpty: true,
runtimeRunning: true,
requestBodies: [],
};
await installFixture(page, state);
await page.goto('admin/releases');
const table = page.getByTestId('gateway-release-table');
await expect(table.getByRole('columnheader')).toHaveCount(6);
await expect(table.getByRole('columnheader', { name: '오류', exact: true })).toHaveCount(0);
await expect(table.getByRole('columnheader', { name: '상세', exact: true })).toBeVisible();
const errorToggle = page.getByTestId('gateway-release-error-toggle');
await expect(errorToggle).toHaveText('오류 보기');
await expect(errorToggle).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByTestId('gateway-release-error-detail')).toBeHidden();
await errorToggle.focus();
const focusedToggleStyle = await errorToggle.evaluate((element) => {
const style = getComputedStyle(element);
return {
outlineStyle: style.outlineStyle,
outlineWidth: style.outlineWidth,
color: style.color,
};
});
expect(focusedToggleStyle.outlineStyle).not.toBe('none');
expect(parseFloat(focusedToggleStyle.outlineWidth)).toBeGreaterThanOrEqual(2);
await errorToggle.hover();
await errorToggle.click();
await expect(errorToggle).toHaveText('오류 닫기');
await expect(errorToggle).toHaveAttribute('aria-expanded', 'true');
const errorDetail = page.getByTestId('gateway-release-error-detail');
await expect(errorDetail).toContainText('Gateway release did not become ready');
await expect(errorDetail).toContainText('controller-output-without-breaks');
const desktopGeometry = await table.evaluate((element) => {
const headings = Array.from(element.querySelectorAll('thead th'));
const detail = element.querySelector('[data-testid="gateway-release-error-detail"]');
const detailCell = detail?.querySelector('td');
const errorText = detail?.querySelector('pre');
return {
tableWidth: element.getBoundingClientRect().width,
scrollerWidth: element.parentElement?.getBoundingClientRect().width ?? 0,
tableLayout: getComputedStyle(element).tableLayout,
columnCount: headings.length,
detailColSpan: detailCell?.getAttribute('colspan'),
detailWidth: detailCell?.getBoundingClientRect().width ?? 0,
errorWhiteSpace: errorText ? getComputedStyle(errorText).whiteSpace : '',
errorOverflowWrap: errorText ? getComputedStyle(errorText).overflowWrap : '',
};
});
expect(desktopGeometry).toMatchObject({
tableLayout: 'fixed',
columnCount: 6,
detailColSpan: '6',
errorWhiteSpace: 'pre-wrap',
});
expect(desktopGeometry.tableWidth).toBeGreaterThanOrEqual(680);
expect(desktopGeometry.detailWidth).toBeGreaterThanOrEqual(desktopGeometry.tableWidth - 1);
await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await expect(table.getByRole('columnheader')).toHaveCount(4);
await expect(table.getByRole('columnheader', { name: '소스', exact: true })).toHaveCount(0);
await expect(table.getByRole('columnheader', { name: '해석 커밋', exact: true })).toHaveCount(0);
const mobileGeometry = await table.evaluate((element) => {
const scroller = element.parentElement!;
const scrollerRect = scroller.getBoundingClientRect();
const detailRect = element
.querySelector('[data-testid="gateway-release-error-detail"]')!
.getBoundingClientRect();
return {
tableWidth: element.getBoundingClientRect().width,
scrollerX: scrollerRect.x,
scrollerWidth: scrollerRect.width,
scrollerScrollWidth: scroller.scrollWidth,
detailWidth: detailRect.width,
viewportWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
};
});
expect(mobileGeometry.tableWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1);
expect(mobileGeometry.detailWidth).toBeGreaterThanOrEqual(mobileGeometry.tableWidth - 1);
expect(mobileGeometry.scrollerScrollWidth).toBeLessThanOrEqual(mobileGeometry.scrollerWidth + 1);
expect(mobileGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect(mobileGeometry.scrollerX + mobileGeometry.scrollerWidth).toBeLessThanOrEqual(
mobileGeometry.viewportWidth
);
expect(mobileGeometry.documentScrollWidth).toBeLessThanOrEqual(mobileGeometry.viewportWidth);
await page.screenshot({ path: testInfo.outputPath('gateway-release-error-expanded-mobile.png'), fullPage: true });
await errorToggle.click();
await expect(errorToggle).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByTestId('gateway-release-error-detail')).toBeHidden();
});
test('explains terminal releases created before controller progress logging', async ({ page }) => { test('explains terminal releases created before controller progress logging', async ({ page }) => {
const state: FixtureState = { const state: FixtureState = {
operations: [], operations: [],
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
@@ -130,7 +131,7 @@ const scheduleDeletion = async (): Promise<void> => {
currentCredential, currentCredential,
}); });
window.localStorage.removeItem('sammo-session-token'); window.localStorage.removeItem('sammo-session-token');
successMessage.value = `${new Date(result.deleteAfter).toLocaleDateString('ko-KR')}까지 정보가 보존됩니다.`; successMessage.value = `${formatServerDateTime(result.deleteAfter, { format: 'date' })}까지 정보가 보존됩니다.`;
await router.replace('/'); await router.replace('/');
}); });
}; };
@@ -411,7 +412,7 @@ onBeforeUnmount(() => {
</tr> </tr>
<tr> <tr>
<th class="legacy-bg1">가입일시</th> <th class="legacy-bg1">가입일시</th>
<td colspan="2">{{ new Date(account.createdAt).toLocaleString('ko-KR') }}</td> <td colspan="2">{{ formatServerDateTime(account.createdAt) }}</td>
<td colspan="3"> <td colspan="3">
개인정보 3 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }} 개인정보 3 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
<button <button
+29 -38
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso, toServerDateTimeInputValue } from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue'; import ServerProfileTabs from '../components/ServerProfileTabs.vue';
import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue'; import AdminConsoleLayout from '../layouts/AdminConsoleLayout.vue';
@@ -435,8 +436,7 @@ const runtimeActionStatusClass = (status: AdminProfile['runtimeActions'][number]
const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean => const isRuntimeActionTerminal = (status: AdminProfile['runtimeActions'][number]['status']): boolean =>
status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED'; status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED';
const formatRuntimeActionTime = (value: string | null): string => const formatRuntimeActionTime = (value: string | null): string => formatServerDateTime(value);
value ? new Date(value).toLocaleString('ko-KR') : '';
const userLookupMode = ref<'username' | 'id' | 'email'>('username'); const userLookupMode = ref<'username' | 'id' | 'email'>('username');
const userLookupValue = ref(''); const userLookupValue = ref('');
@@ -630,14 +630,7 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
} }
}; };
const toLocalInputValue = (value: string): string => { const toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value);
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const pad = (part: number): string => String(part).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(
date.getMinutes()
)}`;
};
const loadProfiles = async () => { const loadProfiles = async () => {
profilesLoading.value = true; profilesLoading.value = true;
@@ -795,7 +788,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined; const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined;
const scheduledAt = const scheduledAt =
action === 'RESET_SCHEDULED' && actionState?.scheduledAt action === 'RESET_SCHEDULED' && actionState?.scheduledAt
? new Date(actionState.scheduledAt).toISOString() ? serverDateTimeInputToIso(actionState.scheduledAt)
: undefined; : undefined;
const reason = actionState?.reason.trim() || undefined; const reason = actionState?.reason.trim() || undefined;
let runtimeActionId: string | undefined; let runtimeActionId: string | undefined;
@@ -952,7 +945,7 @@ const updateKakaoGrace = async (clear = false) => {
try { try {
const result = await adminClient.users.updateKakaoGrace.mutate({ const result = await adminClient.users.updateKakaoGrace.mutate({
userId: userResult.value.id, userId: userResult.value.id,
until: clear || !kakaoGraceUntil.value ? null : new Date(kakaoGraceUntil.value).toISOString(), until: clear || !kakaoGraceUntil.value ? null : (serverDateTimeInputToIso(kakaoGraceUntil.value) ?? null),
reason, reason,
}); });
userResult.value = { userResult.value = {
@@ -983,7 +976,9 @@ const grantSpecialAccess = async () => {
.map((profile) => profile.trim()) .map((profile) => profile.trim())
.filter(Boolean), .filter(Boolean),
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value, allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
expiresAt: specialAccessExpiresAt.value ? new Date(specialAccessExpiresAt.value).toISOString() : null, expiresAt: specialAccessExpiresAt.value
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
: null,
reason, reason,
}); });
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id }); const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
@@ -1071,7 +1066,7 @@ const applyBan = async () => {
} }
const reason = requireUserActionReason(); const reason = requireUserActionReason();
if (!reason) return; if (!reason) return;
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null; const until = banUntil.value ? (serverDateTimeInputToIso(banUntil.value) ?? null) : null;
const patch = { const patch = {
bannedUntil: until, bannedUntil: until,
notes: banReason.value.trim() || undefined, notes: banReason.value.trim() || undefined,
@@ -1150,7 +1145,7 @@ const applyRestriction = async () => {
.filter(Boolean); .filter(Boolean);
const restriction = { const restriction = {
blockedFeatures: features.length ? features : undefined, blockedFeatures: features.length ? features : undefined,
until: restrictionUntil.value ? new Date(restrictionUntil.value).toISOString() : undefined, until: restrictionUntil.value ? serverDateTimeInputToIso(restrictionUntil.value) : undefined,
reason: restrictionReason.value.trim() || undefined, reason: restrictionReason.value.trim() || undefined,
notes: restrictionNotes.value.trim() || undefined, notes: restrictionNotes.value.trim() || undefined,
}; };
@@ -1213,7 +1208,7 @@ const scheduleDeleteUser = async () => {
reason, reason,
}); });
userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter }; userResult.value = { ...userResult.value, deleteAfter: result.deleteAfter };
forceDeleteStatus.value = `탈퇴 예약 완료: ${new Date(result.deleteAfter).toLocaleString('ko-KR')}`; forceDeleteStatus.value = `탈퇴 예약 완료: ${formatServerDateTime(result.deleteAfter)}`;
await Promise.all([refreshUserHistory(), loadUserDirectory()]); await Promise.all([refreshUserHistory(), loadUserDirectory()]);
} catch (error) { } catch (error) {
forceDeleteStatus.value = '탈퇴 예약 실패'; forceDeleteStatus.value = '탈퇴 예약 실패';
@@ -1357,7 +1352,7 @@ onMounted(() => {
<span class="block truncate">{{ user.email || '이메일 없음' }}</span> <span class="block truncate">{{ user.email || '이메일 없음' }}</span>
<span <span
>{{ user.oauthType }} · >{{ user.oauthType }} ·
{{ new Date(user.createdAt).toLocaleDateString('ko-KR') }}</span {{ formatServerDateTime(user.createdAt, { format: 'date' }) }}</span
> >
</span> </span>
<span class="flex flex-wrap gap-1 md:justify-end"> <span class="flex flex-wrap gap-1 md:justify-end">
@@ -1436,15 +1431,17 @@ onMounted(() => {
</div> </div>
<div class="text-xs text-zinc-500"> <div class="text-xs text-zinc-500">
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작: Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
{{ new Date(userResult.kakaoGraceStartedAt).toLocaleString('ko-KR') }} {{ formatServerDateTime(userResult.kakaoGraceStartedAt) }}
</div> </div>
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300"> <div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
관리자 유예: {{ new Date(userResult.kakaoGraceUntil).toLocaleString('ko-KR') }}까지 관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
</div> </div>
<div v-if="userResult.deleteAfter" class="text-xs text-red-300"> <div v-if="userResult.deleteAfter" class="text-xs text-red-300">
탈퇴 예약: {{ new Date(userResult.deleteAfter).toLocaleString('ko-KR') }} 탈퇴 예약: {{ formatServerDateTime(userResult.deleteAfter) }}
</div>
<div class="text-xs text-zinc-500">
가입일: {{ formatServerDateTime(userResult.createdAt) }}
</div> </div>
<div class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</div>
<div class="text-xs text-zinc-400 mt-2">제재 상태</div> <div class="text-xs text-zinc-400 mt-2">제재 상태</div>
<pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap" <pre class="text-[11px] text-zinc-400 bg-black/50 p-2 rounded whitespace-pre-wrap"
>{{ JSON.stringify(userResult.sanctions, null, 2) }} >{{ JSON.stringify(userResult.sanctions, null, 2) }}
@@ -1632,7 +1629,8 @@ onMounted(() => {
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4> <h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
<div class="text-xs text-zinc-400"> <div class="text-xs text-zinc-400">
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와 운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. 만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div> </div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2"> <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<select <select
@@ -1696,11 +1694,11 @@ onMounted(() => {
</div> </div>
<div> <div>
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료 장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
{{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }} {{ formatServerDateTime(grant.expiresAt, { fallback: '없음' }) }}
</div> </div>
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div> <div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
<div v-if="grant.revokedAt" class="text-red-300"> <div v-if="grant.revokedAt" class="text-red-300">
해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} · 해제됨: {{ formatServerDateTime(grant.revokedAt) }} ·
{{ grant.revokedReason }} {{ grant.revokedReason }}
</div> </div>
</div> </div>
@@ -1713,7 +1711,8 @@ onMounted(() => {
> >
<h4 class="text-base font-semibold">Kakao 인증 유예</h4> <h4 class="text-base font-semibold">Kakao 인증 유예</h4>
<div class="text-xs text-zinc-500"> <div class="text-xs text-zinc-500">
기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다. 기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div> </div>
<div class="flex flex-col md:flex-row gap-2"> <div class="flex flex-col md:flex-row gap-2">
<input <input
@@ -1762,11 +1761,7 @@ onMounted(() => {
<td class="text-center">{{ policy.accessGraceDays }}</td> <td class="text-center">{{ policy.accessGraceDays }}</td>
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td> <td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
<td class="text-center"> <td class="text-center">
{{ {{ policy.graceEndsAt ? formatServerDateTime(policy.graceEndsAt) : '-' }}
policy.graceEndsAt
? new Date(policy.graceEndsAt).toLocaleString('ko-KR')
: '-'
}}
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -1778,7 +1773,7 @@ onMounted(() => {
v-if="userWorkspaceSection === 'restrictions'" v-if="userWorkspaceSection === 'restrictions'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4" class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
> >
<h4 class="text-base font-semibold">유저 차단</h4> <h4 class="text-base font-semibold">유저 차단 (서버 시간 UTC+9)</h4>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<input <input
v-model="banUntil" v-model="banUntil"
@@ -1817,7 +1812,7 @@ onMounted(() => {
v-if="userWorkspaceSection === 'restrictions'" v-if="userWorkspaceSection === 'restrictions'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4" class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
> >
<h4 class="text-base font-semibold">서버별 기능 제재</h4> <h4 class="text-base font-semibold">서버별 기능 제재 (서버 시간 UTC+9)</h4>
<div class="grid gap-2"> <div class="grid gap-2">
<input <input
v-model="restrictionProfile" v-model="restrictionProfile"
@@ -1936,9 +1931,7 @@ onMounted(() => {
> >
{{ event.outcome }} · {{ event.action }} {{ event.outcome }} · {{ event.action }}
</span> </span>
<span class="text-zinc-500">{{ <span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
</div> </div>
<div class="text-zinc-400"> <div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }} {{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
@@ -1988,9 +1981,7 @@ onMounted(() => {
> >
{{ event.outcome }} · {{ event.action }} {{ event.outcome }} · {{ event.action }}
</span> </span>
<span class="text-zinc-500">{{ <span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
</div> </div>
<div class="text-zinc-400"> <div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.targetType ?? '-' }} {{ event.actorUsername }} · {{ event.targetType ?? '-' }}
+2 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, ref, onMounted, watch } from 'vue'; import { computed, ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import type { inferRouterOutputs } from '@trpc/server'; import type { inferRouterOutputs } from '@trpc/server';
@@ -95,8 +96,7 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
tabButtons?.[nextIndex]?.focus(); tabButtons?.[nextIndex]?.focus();
}; };
const formatGraceEndsAt = (value: string | null | undefined): string => const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
value ? new Date(value).toLocaleString('ko-KR') : '';
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info); const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
const encodeLegacyIconPath = (value: string): string => const encodeLegacyIconPath = (value: string): string =>
value value
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'; import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue'; import ServerProfileTabs from '../components/ServerProfileTabs.vue';
@@ -88,6 +89,7 @@ const profileOperationLogViewport = ref<HTMLElement>();
const gatewayReleaseState = ref<GatewayReleaseState | null>(null); const gatewayReleaseState = ref<GatewayReleaseState | null>(null);
const gatewayReleaseOperations = ref<GatewayReleaseOperation[]>([]); const gatewayReleaseOperations = ref<GatewayReleaseOperation[]>([]);
const selectedGatewayOperationId = ref(''); const selectedGatewayOperationId = ref('');
const expandedGatewayErrorOperationId = ref('');
const gatewayReleaseLogs = ref<GatewayReleaseLog[]>([]); const gatewayReleaseLogs = ref<GatewayReleaseLog[]>([]);
const gatewayReleaseLogCursor = ref<string>(); const gatewayReleaseLogCursor = ref<string>();
const gatewayReleaseLogStatus = ref(''); const gatewayReleaseLogStatus = ref('');
@@ -169,7 +171,7 @@ const gatewayReleaseLogEmptyMessage = computed(() => {
return 'controller 로그를 기다리고 있습니다…'; return 'controller 로그를 기다리고 있습니다…';
} }
if (operation.error) { if (operation.error) {
return `이 작업에는 controller 로그가 기록되지 않았습니다. 작업 오류: ${operation.error}`; return '이 작업에는 controller 로그가 기록되지 않았습니다. 작업 이력의 오류 상세를 확인하세요.';
} }
return '이 작업에는 controller 로그가 기록되지 않았습니다. 로그 지원 controller 적용 전 작업일 수 있습니다.'; return '이 작업에는 controller 로그가 기록되지 않았습니다. 로그 지원 controller 적용 전 작업일 수 있습니다.';
}); });
@@ -212,21 +214,11 @@ const sourceHelp = computed(() =>
); );
const toIso = (value: string): string | undefined => { const toIso = (value: string): string | undefined => {
if (!value) { return serverDateTimeInputToIso(value);
return undefined;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
}; };
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-'); const formatTime = (value?: string): string => formatServerDateTime(value, { fallback: '-' });
const formatLogTime = (value: string): string => const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
new Date(value).toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-'); const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const clearStatus = () => { const clearStatus = () => {
@@ -444,6 +436,11 @@ const selectGatewayReleaseOperation = (operationId: string) => {
selectedGatewayOperationId.value = operationId; selectedGatewayOperationId.value = operationId;
}; };
const toggleGatewayReleaseError = (operationId: string) => {
expandedGatewayErrorOperationId.value =
expandedGatewayErrorOperationId.value === operationId ? '' : operationId;
};
const requestDeploy = async () => { const requestDeploy = async () => {
clearStatus(); clearStatus();
if ( if (
@@ -953,7 +950,7 @@ onBeforeUnmount(() => {
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3"> <div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
<label class="text-xs text-zinc-400" <label class="text-xs text-zinc-400"
>작업 예약 >작업 예약 (서버 시간 UTC+9)
<input <input
v-model="form.scheduledAt" v-model="form.scheduledAt"
type="datetime-local" type="datetime-local"
@@ -961,7 +958,7 @@ onBeforeUnmount(() => {
/> />
</label> </label>
<label class="text-xs text-zinc-400" <label class="text-xs text-zinc-400"
>가오픈 >가오픈 (서버 시간 UTC+9)
<input <input
v-model="form.preopenAt" v-model="form.preopenAt"
type="datetime-local" type="datetime-local"
@@ -969,7 +966,7 @@ onBeforeUnmount(() => {
/> />
</label> </label>
<label class="text-xs text-zinc-400" <label class="text-xs text-zinc-400"
>정식 오픈 >정식 오픈 (서버 시간 UTC+9)
<input <input
v-model="form.openAt" v-model="form.openAt"
type="datetime-local" type="datetime-local"
@@ -1136,45 +1133,97 @@ onBeforeUnmount(() => {
</div> </div>
</section> </section>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="w-full min-w-[760px] text-left text-xs" data-testid="gateway-release-table"> <table
class="w-full table-fixed text-left text-xs sm:min-w-[680px]"
data-testid="gateway-release-table"
>
<colgroup>
<col class="w-[32%] sm:w-[144px]" />
<col class="w-[17%] sm:w-[68px]" />
<col class="w-[20%] sm:w-[88px]" />
<col class="hidden sm:table-column sm:w-[128px]" />
<col class="hidden sm:table-column sm:w-[112px]" />
<col class="w-[31%] sm:w-[140px]" />
</colgroup>
<thead class="border-b border-zinc-700 text-zinc-500"> <thead class="border-b border-zinc-700 text-zinc-500">
<tr> <tr>
<th class="p-2">시각</th> <th class="p-2">시각</th>
<th class="p-2">작업</th> <th class="p-2">작업</th>
<th class="p-2">상태</th> <th class="p-2">상태</th>
<th class="p-2">소스</th> <th class="hidden p-2 sm:table-cell">소스</th>
<th class="p-2">해석 커밋</th> <th class="hidden p-2 sm:table-cell">해석 커밋</th>
<th class="p-2">오류</th> <th class="p-2">상세</th>
<th class="p-2">로그</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <template v-for="operation in gatewayReleaseOperations" :key="operation.id">
v-for="operation in gatewayReleaseOperations" <tr class="border-b border-zinc-800 align-top">
:key="operation.id" <td class="p-2">{{ formatTime(operation.createdAt) }}</td>
class="border-b border-zinc-800" <td class="p-2">
> <div>{{ operation.type }}</div>
<td class="p-2">{{ formatTime(operation.createdAt) }}</td> <div
<td class="p-2">{{ operation.type }}</td> class="mt-1 truncate font-mono text-[10px] text-zinc-500 sm:hidden"
<td class="p-2">{{ operation.status }}</td> :title="operation.sourceRef"
<td class="p-2 font-mono">{{ operation.sourceRef }}</td> >
<td class="p-2 font-mono">{{ shortSha(operation.resolvedCommitSha) }}</td> {{ operation.sourceRef ?? '-' }}
<td class="max-w-xs p-2 text-red-300">{{ operation.error }}</td> </div>
<td class="p-2"> </td>
<button <td class="p-2 font-semibold">{{ operation.status }}</td>
type="button" <td class="hidden p-2 font-mono sm:table-cell">
class="rounded border border-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-800" <div class="truncate" :title="operation.sourceRef">{{ operation.sourceRef }}</div>
:class=" </td>
operation.id === selectedGatewayOperationId <td class="hidden p-2 font-mono sm:table-cell">
? 'border-violet-500 text-violet-200' {{ shortSha(operation.resolvedCommitSha) }}
: '' </td>
" <td class="p-2">
@click="selectGatewayReleaseOperation(operation.id)" <div class="flex flex-wrap gap-2">
> <button
보기 type="button"
</button> class="rounded border border-zinc-700 px-2 py-1 text-zinc-300 hover:bg-zinc-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-violet-400"
</td> :class="
</tr> operation.id === selectedGatewayOperationId
? 'border-violet-500 text-violet-200'
: ''
"
@click="selectGatewayReleaseOperation(operation.id)"
>
로그
</button>
<button
v-if="operation.error"
type="button"
class="rounded border border-red-800 px-2 py-1 text-red-300 hover:bg-red-950 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-400"
:aria-expanded="expandedGatewayErrorOperationId === operation.id"
:aria-controls="`gateway-release-error-${operation.id}`"
data-testid="gateway-release-error-toggle"
@click="toggleGatewayReleaseError(operation.id)"
>
{{ expandedGatewayErrorOperationId === operation.id ? '오류 닫기' : '오류 보기' }}
</button>
</div>
</td>
</tr>
<tr
v-if="operation.error"
v-show="expandedGatewayErrorOperationId === operation.id"
:id="`gateway-release-error-${operation.id}`"
class="border-b border-red-900/70 bg-red-950/30"
data-testid="gateway-release-error-detail"
>
<td colspan="6" class="p-4">
<div
class="rounded border border-red-900/70 bg-zinc-950 px-4 py-3"
role="region"
:aria-label="`${operation.type} 릴리스 오류 상세`"
>
<div class="mb-2 text-xs font-semibold text-red-300">오류 상세</div>
<pre
class="whitespace-pre-wrap break-all font-mono text-xs leading-5 text-red-200"
>{{ operation.error }}</pre>
</div>
</td>
</tr>
</template>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -1244,10 +1293,7 @@ onBeforeUnmount(() => {
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span> <span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table <table class="w-full min-w-[1300px] table-fixed text-left text-sm" data-testid="operations-table">
class="w-full min-w-[1300px] table-fixed text-left text-sm"
data-testid="operations-table"
>
<colgroup> <colgroup>
<col style="width: 160px" /> <col style="width: 160px" />
<col style="width: 264px" /> <col style="width: 264px" />
+1
View File
@@ -1,6 +1,7 @@
export * from './rng.js'; export * from './rng.js';
export * from './time/Clock.js'; export * from './time/Clock.js';
export * from './time/GameClock.js'; export * from './time/GameClock.js';
export * from './time/ServerDateTime.js';
export * from './util/BytesLike.js'; export * from './util/BytesLike.js';
export * from './util/convertBytesLikeToArrayBuffer.js'; export * from './util/convertBytesLikeToArrayBuffer.js';
export * from './util/convertBytesLikeToUint8Array.js'; export * from './util/convertBytesLikeToUint8Array.js';
+158
View File
@@ -0,0 +1,158 @@
const SERVER_UTC_OFFSET_MINUTES = 9 * 60;
const SERVER_UTC_OFFSET_MS = SERVER_UTC_OFFSET_MINUTES * 60_000;
export type ServerDateTimeFormat =
| 'dateTimeSeconds'
| 'dateTimeMinutes'
| 'date'
| 'timeSeconds'
| 'hourMinute'
| 'minuteSecond'
| 'monthDayTime'
| 'monthDayTimeSeconds';
export type ServerDateTimeOptions = {
format?: ServerDateTimeFormat;
fallback?: string;
};
type DateTimeParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
millisecond: number;
};
const SERVER_WALL_TIME_PATTERN = /^(\d{4,6})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?$/u;
const pad = (value: number, length = 2): string => String(value).padStart(length, '0');
const isValidParts = (parts: DateTimeParts): boolean => {
const candidate = new Date(0);
candidate.setUTCFullYear(parts.year, parts.month - 1, parts.day);
candidate.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
return (
candidate.getUTCFullYear() === parts.year &&
candidate.getUTCMonth() + 1 === parts.month &&
candidate.getUTCDate() === parts.day &&
candidate.getUTCHours() === parts.hour &&
candidate.getUTCMinutes() === parts.minute &&
candidate.getUTCSeconds() === parts.second &&
candidate.getUTCMilliseconds() === parts.millisecond
);
};
const parseServerWallTime = (value: string): DateTimeParts | null => {
const match = SERVER_WALL_TIME_PATTERN.exec(value.trim());
if (!match) {
return null;
}
const millisecondText = match[7] ?? '';
const parts: DateTimeParts = {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3]),
hour: Number(match[4] ?? 0),
minute: Number(match[5] ?? 0),
second: Number(match[6] ?? 0),
millisecond: Number(millisecondText.padEnd(3, '0')),
};
return isValidParts(parts) ? parts : null;
};
const partsFromInstant = (value: string | Date): DateTimeParts | null => {
const date = value instanceof Date ? new Date(value.getTime()) : new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
const shifted = new Date(date.getTime() + SERVER_UTC_OFFSET_MS);
return {
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth() + 1,
day: shifted.getUTCDate(),
hour: shifted.getUTCHours(),
minute: shifted.getUTCMinutes(),
second: shifted.getUTCSeconds(),
millisecond: shifted.getUTCMilliseconds(),
};
};
const resolveParts = (value: string | Date): DateTimeParts | null => {
if (typeof value === 'string') {
const wallTime = parseServerWallTime(value);
if (wallTime) {
return wallTime;
}
}
return partsFromInstant(value);
};
const formatParts = (parts: DateTimeParts, format: ServerDateTimeFormat): string => {
const year = pad(parts.year, 4);
const month = pad(parts.month);
const day = pad(parts.day);
const hour = pad(parts.hour);
const minute = pad(parts.minute);
const second = pad(parts.second);
switch (format) {
case 'dateTimeMinutes':
return `${year}-${month}-${day} ${hour}:${minute}`;
case 'date':
return `${year}-${month}-${day}`;
case 'timeSeconds':
return `${hour}:${minute}:${second}`;
case 'hourMinute':
return `${hour}:${minute}`;
case 'minuteSecond':
return `${minute}:${second}`;
case 'monthDayTime':
return `${month}-${day} ${hour}:${minute}`;
case 'monthDayTimeSeconds':
return `${month}-${day} ${hour}:${minute}:${second}`;
case 'dateTimeSeconds':
default:
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
}
};
/**
* Formats an instant in the service's fixed UTC+9 wall clock.
*
* Timezone-less legacy DATETIME strings are already server wall-clock values and
* therefore keep their components. This deliberate fixed offset also avoids
* historical IANA timezone rules changing ancient in-game years.
*/
export const formatServerDateTime = (
value: string | Date | null | undefined,
options: ServerDateTimeOptions = {}
): string => {
if (value === null || value === undefined || value === '') {
return options.fallback ?? '';
}
const parts = resolveParts(value);
if (!parts) {
return options.fallback ?? String(value);
}
return formatParts(parts, options.format ?? 'dateTimeSeconds');
};
export const toServerDateTimeInputValue = (value: string | Date | null | undefined): string => {
const formatted = formatServerDateTime(value, { format: 'dateTimeMinutes', fallback: '' });
return formatted ? formatted.replace(' ', 'T') : '';
};
/** Converts an HTML datetime-local value, interpreted as UTC+9 server wall time, to ISO UTC. */
export const serverDateTimeInputToIso = (value: string): string | undefined => {
const parts = parseServerWallTime(value);
if (!parts) {
return undefined;
}
const wallTime = new Date(0);
wallTime.setUTCFullYear(parts.year, parts.month - 1, parts.day);
wallTime.setUTCHours(parts.hour, parts.minute, parts.second, parts.millisecond);
return new Date(wallTime.getTime() - SERVER_UTC_OFFSET_MS).toISOString();
};
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
formatServerDateTime,
serverDateTimeInputToIso,
toServerDateTimeInputValue,
} from '../src/time/ServerDateTime.js';
describe('formatServerDateTime', () => {
it('formats ISO instants with the fixed UTC+9 service offset', () => {
expect(formatServerDateTime('2026-08-13T00:05:06.000Z')).toBe('2026-08-13 09:05:06');
expect(formatServerDateTime('0185-01-02T00:04:05.000Z')).toBe('0185-01-02 09:04:05');
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'date' })).toBe('2026-08-14');
expect(formatServerDateTime('2026-08-13T18:05:06.000Z', { format: 'hourMinute' })).toBe('03:05');
});
it('preserves timezone-less legacy wall-clock values', () => {
expect(formatServerDateTime('0185-01-02 03:04:05')).toBe('0185-01-02 03:04:05');
expect(formatServerDateTime('0185-01-02T03:04:05', { format: 'monthDayTime' })).toBe('01-02 03:04');
expect(formatServerDateTime('2026-08-13 09:05:06', { format: 'minuteSecond' })).toBe('05:06');
});
it('offers explicit shapes and predictable fallbacks', () => {
const value = '2026-08-13T00:05:06.000Z';
expect(formatServerDateTime(value, { format: 'dateTimeMinutes' })).toBe('2026-08-13 09:05');
expect(formatServerDateTime(value, { format: 'timeSeconds' })).toBe('09:05:06');
expect(formatServerDateTime(value, { format: 'monthDayTimeSeconds' })).toBe('08-13 09:05:06');
expect(formatServerDateTime(undefined, { fallback: '-' })).toBe('-');
expect(formatServerDateTime('not-a-date')).toBe('not-a-date');
});
});
describe('server datetime-local conversion', () => {
it('does not depend on the browser or process timezone', () => {
expect(serverDateTimeInputToIso('2026-08-13T09:05')).toBe('2026-08-13T00:05:00.000Z');
expect(toServerDateTimeInputValue('2026-08-13T00:05:00.000Z')).toBe('2026-08-13T09:05');
});
it('rejects invalid local input', () => {
expect(serverDateTimeInputToIso('2026-02-30T09:05')).toBeUndefined();
expect(serverDateTimeInputToIso('')).toBeUndefined();
expect(toServerDateTimeInputValue('not-a-date')).toBe('');
});
});
@@ -293,11 +293,14 @@ test('renders actual online, nation policy, and survey data with ref geometry an
await expect(status).toContainText(marker); await expect(status).toContainText(marker);
await expect(page.locator('.online-users')).toContainText('현황검증장수'); await expect(page.locator('.online-users')).toContainText('현황검증장수');
await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.'); await expect(page.locator('.survey-notice')).toContainText('새로운 설문조사가 있습니다.');
await expect(page.locator('.tournament-status')).toHaveText('토너먼트: 경기 없음');
await expect(page.locator('.vote-status')).toHaveText('설문: 검증 설문');
const desktop = await status.evaluate((element) => { const desktop = await status.evaluate((element) => {
const style = getComputedStyle(element); const style = getComputedStyle(element);
const onlineRow = element.querySelector<HTMLElement>('.online-nations'); const onlineRow = element.querySelector<HTMLElement>('.online-nations');
const voteRow = element.querySelector<HTMLElement>('.vote-status'); const voteRow = element.querySelector<HTMLElement>('.vote-status');
if (!onlineRow || !voteRow) throw new Error('status row missing'); const tournamentRow = element.querySelector<HTMLElement>('.tournament-status');
if (!onlineRow || !voteRow || !tournamentRow) throw new Error('status row missing');
return { return {
rect: element.getBoundingClientRect().toJSON(), rect: element.getBoundingClientRect().toJSON(),
fontSize: style.fontSize, fontSize: style.fontSize,
@@ -308,6 +311,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
borderTop: getComputedStyle(onlineRow).borderTop, borderTop: getComputedStyle(onlineRow).borderTop,
padding: getComputedStyle(onlineRow).padding, padding: getComputedStyle(onlineRow).padding,
}, },
tournamentRow: tournamentRow.getBoundingClientRect().toJSON(),
voteRow: voteRow.getBoundingClientRect().toJSON(), voteRow: voteRow.getBoundingClientRect().toJSON(),
}; };
}); });
@@ -321,6 +325,9 @@ test('renders actual online, nation policy, and survey data with ref geometry an
}, },
}); });
expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0); expect(desktop.onlineRow.rect.height).toBeCloseTo(36, 0);
expect(desktop.tournamentRow.x).toBeCloseTo(333.33, 0);
expect(desktop.tournamentRow.width).toBeCloseTo(333.33, 0);
expect(desktop.tournamentRow.height).toBeCloseTo(36, 0);
expect(desktop.voteRow.x).toBeCloseTo(666.67, 0); expect(desktop.voteRow.x).toBeCloseTo(666.67, 0);
expect(desktop.voteRow.width).toBeCloseTo(333.33, 0); expect(desktop.voteRow.width).toBeCloseTo(333.33, 0);
expect(desktop.voteRow.height).toBeCloseTo(36, 0); expect(desktop.voteRow.height).toBeCloseTo(36, 0);
@@ -328,6 +335,7 @@ test('renders actual online, nation policy, and survey data with ref geometry an
await page.setViewportSize({ width: 500, height: 900 }); await page.setViewportSize({ width: 500, height: 900 });
await expect(status).toHaveCSS('width', '500px'); await expect(status).toHaveCSS('width', '500px');
await expect(page.locator('.tournament-status')).toHaveCSS('width', '250px');
await expect(page.locator('.vote-status')).toHaveCSS('width', '250px'); await expect(page.locator('.vote-status')).toHaveCSS('width', '250px');
failStatus = true; failStatus = true;