Merge remote-tracking branch 'origin/main' into fix/turn-selector-divider-20260813

This commit is contained in:
2026-08-13 15:33:34 +00:00
25 changed files with 472 additions and 176 deletions
+21 -5
View File
@@ -274,6 +274,8 @@ test('matches the ref meeting-room geometry, typography, textures, and controls'
'src',
'https://sam-image.hided.net/icons/22.jpg'
);
await expect(page.locator('.article-header .date')).toHaveText('07-26 19:20');
await expect(page.locator('.comment-row .date')).toHaveText('07-26 19:25');
if (artifactRoot) {
await page.screenshot({
path: resolve(artifactRoot, 'board-core-desktop.png'),
@@ -392,7 +394,9 @@ test('uses the ref 500px responsive form widths', async ({ page }) => {
await expect(page.getByRole('heading', { name: '기밀실' })).toBeVisible();
});
test('retains article and comment input after a failed mutation, then reloads after success', async ({ page }, testInfo) => {
test('retains article and comment input after a failed mutation, then reloads after success', async ({
page,
}, testInfo) => {
const state: BoardFixture = {
permission: 2,
canMeeting: true,
@@ -408,7 +412,9 @@ test('retains article and comment input after a failed mutation, then reloads af
await page.locator('#board-title').fill('새 제목');
await page.locator('#board-content').fill('새 내용');
await page.locator('#submitArticle').click();
const articleToast = page.getByTestId('game-toast').filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' });
const articleToast = page
.getByTestId('game-toast')
.filter({ hasText: '게시물 등록에 실패했습니다: 접속 제한입니다.' });
await expect(articleToast).toHaveAttribute('data-feedback-kind', 'error');
await expect(articleToast).toHaveAttribute('role', 'alert');
await expect(page.locator('#board-title')).toHaveValue('새 제목');
@@ -416,7 +422,13 @@ test('retains article and comment input after a failed mutation, then reloads af
const desktopToastGeometry = await articleToast.evaluate((element) => {
const rect = element.getBoundingClientRect();
return { left: rect.left, right: rect.right, top: rect.top, width: rect.width, viewportWidth: window.innerWidth };
return {
left: rect.left,
right: rect.right,
top: rect.top,
width: rect.width,
viewportWidth: window.innerWidth,
};
});
expect(desktopToastGeometry.left).toBeGreaterThanOrEqual(0);
expect(desktopToastGeometry.right).toBeLessThanOrEqual(desktopToastGeometry.viewportWidth);
@@ -435,10 +447,14 @@ test('retains article and comment input after a failed mutation, then reloads af
await page.setViewportSize({ width: 390, height: 844 });
const documentWidthBeforeToast = await page.evaluate(() => document.documentElement.scrollWidth);
await commentInput.press('Enter');
const commentToast = page.getByTestId('game-toast').filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' });
const commentToast = page
.getByTestId('game-toast')
.filter({ hasText: '댓글 등록에 실패했습니다: 접속 제한입니다.' });
await expect(commentToast).toBeVisible();
await expect
.poll(async () => commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom))
.poll(async () =>
commentToast.evaluate((element) => window.innerHeight - element.getBoundingClientRect().bottom)
)
.toBeGreaterThanOrEqual(0);
const mobileToastGeometry = await commentToast.evaluate((element) => {
const rect = element.getBoundingClientRect();
@@ -1224,6 +1224,125 @@ test('the 939/940 boundary switches to the Ref-style 500px single document', asy
await persistArtifact(page, `${basePath.slice(1)}-mobile-500`);
});
test('real mobile devices initially fit the complete 500px game canvas', async ({ browser }, testInfo) => {
test.setTimeout(60_000);
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the mobile viewport contract');
}
const deviceWidths = [360, 390, 480];
const measurements: Record<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 }) => {
const state: NavigationFixture = {
officerLevel: 1,
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="ko">
<head>
<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>
</head>
<body class="bg-black text-white">
+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 => {
if (
typeof value === 'string' &&
!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())
) {
return value.trim().replace('T', ' ').slice(0, 19);
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return typeof value === 'string' ? value.slice(0, 19) : '';
}
const koreaTime = new Date(date.getTime() + KOREA_TIME_OFFSET_MS);
return `${koreaTime.getUTCFullYear()}-${pad(koreaTime.getUTCMonth() + 1)}-${pad(
koreaTime.getUTCDate()
)} ${pad(koreaTime.getUTCHours())}:${pad(koreaTime.getUTCMinutes())}:${pad(
koreaTime.getUTCSeconds()
)}`;
};
export const formatSeoulHourMinute = (value: string | Date): string => formatSeoulDateTime(value).slice(11, 16);
export const formatSeoulHourMinute = (value: string | Date): string =>
formatServerDateTime(value, { format: 'hourMinute' });
+5 -18
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue';
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 =>
!value || /^\d+$/u.test(value) ? '-' : value.replace(/^che_(?:event_)?/u, '');
const cutDateTime = (value: string | null | undefined, showSecond = false): string => {
if (!value) {
return '-';
}
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')}` : ''}`;
return formatServerDateTime(value, {
format: showSecond ? 'monthDayTimeSeconds' : 'monthDayTime',
fallback: '-',
});
};
const buyRice = computed(() =>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import PanelCard from '../components/ui/PanelCard.vue';
@@ -126,9 +127,9 @@ const selectedGeneral = computed(() => {
const formatGeneralLabel = (general: GeneralEntry): string => {
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') {
return `${name} (${general.recentWar ? general.recentWar.slice(-5) : '--:--'})`;
return `${name} (${formatServerDateTime(general.recentWar, { format: 'hourMinute', fallback: '--:--' })})`;
}
if (orderBy.value === 'warnum') {
return `${name} (${general.warnum}회)`;
@@ -156,7 +157,10 @@ const loadLogs = async (generalId: number) => {
}
for (const response of responses) {
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 {
id: entry.id,
html: formatLog(`${entry.text}${eventTime}`),
@@ -308,7 +312,12 @@ onMounted(() => {
<LegacyGeneralProgress :general="selectedGeneral" />
</div>
<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.warnum }}</div>
</div>
+5 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import { trpc } from '../utils/trpc';
@@ -68,7 +69,9 @@ const ratio = (id: number) => {
const amount = totals?.[id] ?? 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 myTotals = summary.value?.myTotals as Record<number, number> | undefined;
const current = myTotals?.[id] ?? 0;
@@ -248,8 +251,7 @@ const placeBet = async (targetId: number) => {
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
</small>
</footer>
</main>
+13 -3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -40,7 +41,7 @@ const resizeTextArea = (element: HTMLTextAreaElement | null) => {
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 =>
resolveGeneralIconUrl({
@@ -160,7 +161,14 @@ onMounted(() => {
</div>
<div class="article-submit-row">
<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>
</section>
@@ -244,7 +252,9 @@ onMounted(() => {
padding: 8px;
color: #000;
background: #fff;
font: 16px/normal 'Times New Roman', serif;
font:
16px/normal 'Times New Roman',
serif;
}
.legacy-board-page {
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -47,12 +48,7 @@ const loadDetail = async (): Promise<void> => {
}
};
const formatArchiveDate = (value: string): string =>
new Intl.DateTimeFormat('sv-SE', {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: 'UTC',
}).format(new Date(value));
const formatArchiveDate = (value: string): string => formatServerDateTime(value);
watch(emperorId, loadDetail);
onMounted(loadDetail);
@@ -67,7 +63,9 @@ onMounted(loadDetail);
<br />
<button class="native-button" type="button" @click="closePage"> 닫기</button>
<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>
</td>
</tr>
@@ -202,7 +200,11 @@ onMounted(loadDetail);
<td colspan="5">
<!-- 레거시 색상 tag를 동일한 span 구조로 변환한다. -->
<!-- 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>
</tr>
</tbody>
@@ -283,14 +285,10 @@ onMounted(loadDetail);
<table class="legacy-table legacy-bg0 footer-table">
<tbody>
<tr>
<td>
<button class="native-button" type="button" @click="closePage"> 닫기</button><br />
</td>
<td><button class="native-button" type="button" @click="closePage"> 닫기</button><br /></td>
</tr>
<tr>
<td class="banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD
</td>
<td class="banner">삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD</td>
</tr>
</tbody>
</table>
+3 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, reactive, ref } from 'vue';
import { trpc } from '../utils/trpc';
@@ -169,11 +170,7 @@ const turnTimeLabel = computed(() => {
if (!turnTimeResult.value) {
return null;
}
const parsed = new Date(turnTimeResult.value);
if (Number.isNaN(parsed.getTime())) {
return turnTimeResult.value;
}
return parsed.toLocaleString();
return formatServerDateTime(turnTimeResult.value);
});
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-else-if="logs.length === 0" class="log-empty">기록이 없습니다.</div>
<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>
</div>
<button
+3 -8
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useMediaQuery } from '@vueuse/core';
@@ -68,14 +69,8 @@ const nationColor = computed(() => nation.value?.color ?? '#000000');
const voteActive = computed(() => Boolean(frontStatus.value?.latestVote));
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);
const parsed = entry.createdAt ? new Date(entry.createdAt) : null;
if (!parsed || Number.isNaN(parsed.getTime())) return formatLog(entry.text);
const time = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(parsed);
const time = formatServerDateTime(entry.createdAt, { format: 'hourMinute', fallback: '' });
if (!time) return formatLog(entry.text);
return formatLog(`${entry.text} ${time}`);
};
+5 -2
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime';
import { formatSeoulDateTime, formatSeoulHourMinute } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
@@ -430,7 +430,10 @@ onMounted(() => {
</div>
<div>
<dt>나이/다음턴</dt>
<dd>{{ data.general.age ?? '-' }} / {{ data.general.turnTime?.slice(11, 16) ?? '-' }}</dd>
<dd>
{{ data.general.age ?? '-' }} /
{{ data.general.turnTime ? formatSeoulHourMinute(data.general.turnTime) : '-' }}
</dd>
</div>
</dl>
</div>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import { trpc } from '../utils/trpc';
type Result = Awaited<ReturnType<typeof trpc.nation.getSecretGeneralList.query>>;
@@ -147,7 +148,7 @@ onMounted(load);
>
</td>
<td>{{ general.killTurn }}</td>
<td>{{ general.turnTime.slice(14, 19) }}</td>
<td>{{ formatServerDateTime(general.turnTime, { format: 'minuteSecond' }) }}</td>
</tr>
</tbody>
</table>
@@ -159,8 +160,8 @@ onMounted(load);
</tr>
<tr>
<td class="legacy-banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
/
<a href="https://github.com/hided/SamK" target="_blank" rel="noopener noreferrer">Credit</a>
</td>
</tr>
+3 -9
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
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 formatStartDate = (value: string): string => value.slice(0, 10);
const formatStartDate = (value: string): string => formatServerDateTime(value, { format: 'date' });
const formatCommentDate = (value: string): string => {
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 formatCommentDate = (value: string): string => formatServerDateTime(value, { format: 'monthDayTime' });
const voteColor = (index: number): string =>
['#ff0000', '#ffa500', '#ffff00', '#008000', '#0000ff', '#000080', '#800080'][index % 7]!;
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import TournamentBracket from '../components/tournament/TournamentBracket.vue';
import { trpc } from '../utils/trpc';
@@ -62,7 +63,9 @@ const matchesAt = (stage: number) =>
.sort((a, b) => a.roundIndex - b.roundIndex);
const nameOf = (id?: number) => (id ? (participantsById.value.get(id)?.name ?? `#${id}`) : '-');
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 isParticipant = computed(() =>
(snapshot.value?.participants ?? []).some((participant) => participant.id === myGeneralId.value)
@@ -287,8 +290,7 @@ const start = async () => {
<button class="close-button" type="button" @click="navigate"> 닫기</button>
</RouterLink>
<small>
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) / Credit
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com) / Credit
</small>
</footer>
+4 -6
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -50,10 +51,7 @@ const onlineRows = computed(() =>
}))
);
const timeLabel = (value: string): string => {
const timePart = value.includes('T') ? value.split('T')[1] : value.slice(11);
return (timePart ?? '').slice(0, 5);
};
const timeLabel = (value: string): string => formatServerDateTime(value, { format: 'hourMinute' });
const trafficColor = (percentage: number): string => {
const channel = (value: number): string =>
@@ -204,8 +202,8 @@ onMounted(() => {
</tr>
<tr>
<td class="banner">
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 :
HideD(hided62@gmail.com) /
삼국지 모의전투 HiDCHe / KOEI의 이미지를 사용, 응용하였습니다 / 제작 : HideD(hided62@gmail.com)
/
<a href="https://sam.hided.net/wiki/hidche/credit" target="_blank" rel="noreferrer">Credit</a>
</td>
</tr>
+2 -4
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
@@ -166,10 +167,7 @@ const hideMemberPopup = () => {
const iconPath = (troop: Troop): string => resolveGeneralIconUrl(troop.leader ?? {});
const formatTurn = (turnTime: string | null): string => {
if (!turnTime) {
return '--:--';
}
return turnTime.slice(14, 19);
return formatServerDateTime(turnTime, { format: 'minuteSecond', fallback: '--:--' });
};
onMounted(() => {
@@ -490,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('load-scenarios').click();
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').click();
@@ -544,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('0123456789abcdef0123456789abcdef01234567');
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.setViewportSize({ width: 390, height: 844 });
@@ -588,9 +590,9 @@ test('separates branch and commit semantics and submits a reset from the dedicat
mobileOperationTableGeometry.scrollerWidth
);
expect(mobileOperationTableGeometry.scrollerX).toBeGreaterThanOrEqual(0);
expect(
mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth
).toBeLessThanOrEqual(mobileOperationTableGeometry.viewportWidth);
expect(mobileOperationTableGeometry.scrollerX + mobileOperationTableGeometry.scrollerWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.viewportWidth
);
expect(mobileOperationTableGeometry.documentScrollWidth).toBeLessThanOrEqual(
mobileOperationTableGeometry.viewportWidth
);
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
@@ -130,7 +131,7 @@ const scheduleDeletion = async (): Promise<void> => {
currentCredential,
});
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('/');
});
};
@@ -411,7 +412,7 @@ onBeforeUnmount(() => {
</tr>
<tr>
<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">
개인정보 3 제공 동의 : {{ account.thirdPartyUse ? '○' : '×' }}
<button
+29 -38
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso, toServerDateTimeInputValue } from '@sammo-ts/common';
import { computed, onMounted, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.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 =>
status === 'APPLIED' || status === 'FAILED' || status === 'IGNORED';
const formatRuntimeActionTime = (value: string | null): string =>
value ? new Date(value).toLocaleString('ko-KR') : '';
const formatRuntimeActionTime = (value: string | null): string => formatServerDateTime(value);
const userLookupMode = ref<'username' | 'id' | 'email'>('username');
const userLookupValue = ref('');
@@ -630,14 +630,7 @@ const ensureProfileBuffers = (profile: AdminProfile) => {
}
};
const toLocalInputValue = (value: string): string => {
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 toLocalInputValue = (value: string): string => toServerDateTimeInputValue(value);
const loadProfiles = async () => {
profilesLoading.value = true;
@@ -795,7 +788,7 @@ const requestProfileAction = async (profileName: string, action: AdminAction) =>
const durationValue = durationMinutes && validDuration(profileName) ? durationMinutes : undefined;
const scheduledAt =
action === 'RESET_SCHEDULED' && actionState?.scheduledAt
? new Date(actionState.scheduledAt).toISOString()
? serverDateTimeInputToIso(actionState.scheduledAt)
: undefined;
const reason = actionState?.reason.trim() || undefined;
let runtimeActionId: string | undefined;
@@ -952,7 +945,7 @@ const updateKakaoGrace = async (clear = false) => {
try {
const result = await adminClient.users.updateKakaoGrace.mutate({
userId: userResult.value.id,
until: clear || !kakaoGraceUntil.value ? null : new Date(kakaoGraceUntil.value).toISOString(),
until: clear || !kakaoGraceUntil.value ? null : (serverDateTimeInputToIso(kakaoGraceUntil.value) ?? null),
reason,
});
userResult.value = {
@@ -983,7 +976,9 @@ const grantSpecialAccess = async () => {
.map((profile) => profile.trim())
.filter(Boolean),
allowsGeneralCreation: specialAccessAllowsGeneralCreation.value,
expiresAt: specialAccessExpiresAt.value ? new Date(specialAccessExpiresAt.value).toISOString() : null,
expiresAt: specialAccessExpiresAt.value
? (serverDateTimeInputToIso(specialAccessExpiresAt.value) ?? null)
: null,
reason,
});
const policy = await adminClient.users.getKakaoGracePolicies.query({ userId: userResult.value.id });
@@ -1071,7 +1066,7 @@ const applyBan = async () => {
}
const reason = requireUserActionReason();
if (!reason) return;
const until = banUntil.value ? new Date(banUntil.value).toISOString() : null;
const until = banUntil.value ? (serverDateTimeInputToIso(banUntil.value) ?? null) : null;
const patch = {
bannedUntil: until,
notes: banReason.value.trim() || undefined,
@@ -1150,7 +1145,7 @@ const applyRestriction = async () => {
.filter(Boolean);
const restriction = {
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,
notes: restrictionNotes.value.trim() || undefined,
};
@@ -1213,7 +1208,7 @@ const scheduleDeleteUser = async () => {
reason,
});
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()]);
} catch (error) {
forceDeleteStatus.value = '탈퇴 예약 실패';
@@ -1357,7 +1352,7 @@ onMounted(() => {
<span class="block truncate">{{ user.email || '이메일 없음' }}</span>
<span
>{{ user.oauthType }} ·
{{ new Date(user.createdAt).toLocaleDateString('ko-KR') }}</span
{{ formatServerDateTime(user.createdAt, { format: 'date' }) }}</span
>
</span>
<span class="flex flex-wrap gap-1 md:justify-end">
@@ -1436,15 +1431,17 @@ onMounted(() => {
</div>
<div class="text-xs text-zinc-500">
Kakao 인증: {{ userResult.kakaoVerifiedAt ? '완료' : '미완료' }} · 유예 시작:
{{ new Date(userResult.kakaoGraceStartedAt).toLocaleString('ko-KR') }}
{{ formatServerDateTime(userResult.kakaoGraceStartedAt) }}
</div>
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
관리자 유예: {{ new Date(userResult.kakaoGraceUntil).toLocaleString('ko-KR') }}까지
관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
</div>
<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 class="text-xs text-zinc-500">가입일: {{ userResult.createdAt }}</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"
>{{ JSON.stringify(userResult.sanctions, null, 2) }}
@@ -1632,7 +1629,8 @@ onMounted(() => {
<h4 class="text-base font-semibold">Kakao 없는 특수 계정 접근</h4>
<div class="text-xs text-zinc-400">
운영자 role은 자동으로 모든 서버에 접근합니다. 테스트·복구·기타 계정은 아래에서 서버 범위와
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다.
만료를 명시해 부여합니다. 복구 자격은 만료가 필수이며 최대 90일입니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<select
@@ -1696,11 +1694,11 @@ onMounted(() => {
</div>
<div>
장수 생성 {{ grant.allowsGeneralCreation ? '허용' : '차단' }} · 만료
{{ grant.expiresAt ? new Date(grant.expiresAt).toLocaleString('ko-KR') : '없음' }}
{{ formatServerDateTime(grant.expiresAt, { fallback: '없음' }) }}
</div>
<div class="text-zinc-500">부여 사유: {{ grant.reason }}</div>
<div v-if="grant.revokedAt" class="text-red-300">
해제됨: {{ new Date(grant.revokedAt).toLocaleString('ko-KR') }} ·
해제됨: {{ formatServerDateTime(grant.revokedAt) }} ·
{{ grant.revokedReason }}
</div>
</div>
@@ -1713,7 +1711,8 @@ onMounted(() => {
>
<h4 class="text-base font-semibold">Kakao 인증 유예</h4>
<div class="text-xs text-zinc-500">
기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다.
기본·서버별 유예가 끝난 사용자를 예외적으로 허용할 사용합니다. 시각 입력은 서버 시간
UTC+9 기준입니다.
</div>
<div class="flex flex-col md:flex-row gap-2">
<input
@@ -1762,11 +1761,7 @@ onMounted(() => {
<td class="text-center">{{ policy.accessGraceDays }}</td>
<td class="text-center">{{ policy.specialAccess?.kind ?? '-' }}</td>
<td class="text-center">
{{
policy.graceEndsAt
? new Date(policy.graceEndsAt).toLocaleString('ko-KR')
: '-'
}}
{{ policy.graceEndsAt ? formatServerDateTime(policy.graceEndsAt) : '-' }}
</td>
</tr>
</tbody>
@@ -1778,7 +1773,7 @@ onMounted(() => {
v-if="userWorkspaceSection === 'restrictions'"
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">
<input
v-model="banUntil"
@@ -1817,7 +1812,7 @@ onMounted(() => {
v-if="userWorkspaceSection === 'restrictions'"
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">
<input
v-model="restrictionProfile"
@@ -1936,9 +1931,7 @@ onMounted(() => {
>
{{ event.outcome }} · {{ event.action }}
</span>
<span class="text-zinc-500">{{
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
</div>
<div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.reason ?? '사유 없음' }}
@@ -1988,9 +1981,7 @@ onMounted(() => {
>
{{ event.outcome }} · {{ event.action }}
</span>
<span class="text-zinc-500">{{
new Date(event.createdAt).toLocaleString('ko-KR')
}}</span>
<span class="text-zinc-500">{{ formatServerDateTime(event.createdAt) }}</span>
</div>
<div class="text-zinc-400">
{{ event.actorUsername }} · {{ event.targetType ?? '-' }}
+2 -2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime } from '@sammo-ts/common';
import { computed, ref, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { inferRouterOutputs } from '@trpc/server';
@@ -95,8 +96,7 @@ const handleMapTabKeydown = (event: KeyboardEvent, profileName: string): void =>
tabButtons?.[nextIndex]?.focus();
};
const formatGraceEndsAt = (value: string | null | undefined): string =>
value ? new Date(value).toLocaleString('ko-KR') : '';
const formatGraceEndsAt = (value: string | null | undefined): string => formatServerDateTime(value);
const serverSeasonStatus = (info: LobbyInfo) => resolveServerSeasonStatus(info);
const encodeLegacyIconPath = (value: string): string =>
value
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { formatServerDateTime, serverDateTimeInputToIso } from '@sammo-ts/common';
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import ServerProfileTabs from '../components/ServerProfileTabs.vue';
@@ -213,21 +214,11 @@ const sourceHelp = computed(() =>
);
const toIso = (value: string): string | undefined => {
if (!value) {
return undefined;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
return serverDateTimeInputToIso(value);
};
const formatTime = (value?: string): string => (value ? new Date(value).toLocaleString('ko-KR') : '-');
const formatLogTime = (value: string): string =>
new Date(value).toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
const formatTime = (value?: string): string => formatServerDateTime(value, { fallback: '-' });
const formatLogTime = (value: string): string => formatServerDateTime(value, { format: 'timeSeconds' });
const shortSha = (value?: string): string => (value ? value.slice(0, 12) : '-');
const clearStatus = () => {
@@ -959,7 +950,7 @@ onBeforeUnmount(() => {
<div v-if="mode === 'scenario'" class="grid gap-4 md:grid-cols-3">
<label class="text-xs text-zinc-400"
>작업 예약
>작업 예약 (서버 시간 UTC+9)
<input
v-model="form.scheduledAt"
type="datetime-local"
@@ -967,7 +958,7 @@ onBeforeUnmount(() => {
/>
</label>
<label class="text-xs text-zinc-400"
>가오픈
>가오픈 (서버 시간 UTC+9)
<input
v-model="form.preopenAt"
type="datetime-local"
@@ -975,7 +966,7 @@ onBeforeUnmount(() => {
/>
</label>
<label class="text-xs text-zinc-400"
>정식 오픈
>정식 오픈 (서버 시간 UTC+9)
<input
v-model="form.openAt"
type="datetime-local"
@@ -1302,10 +1293,7 @@ onBeforeUnmount(() => {
<span class="text-xs text-zinc-500">3초마다 상태 갱신</span>
</div>
<div class="overflow-x-auto">
<table
class="w-full min-w-[1300px] table-fixed text-left text-sm"
data-testid="operations-table"
>
<table class="w-full min-w-[1300px] table-fixed text-left text-sm" data-testid="operations-table">
<colgroup>
<col style="width: 160px" />
<col style="width: 264px" />
+1
View File
@@ -1,6 +1,7 @@
export * from './rng.js';
export * from './time/Clock.js';
export * from './time/GameClock.js';
export * from './time/ServerDateTime.js';
export * from './util/BytesLike.js';
export * from './util/convertBytesLikeToArrayBuffer.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('');
});
});