fix(frontend): standardize server time display

This commit is contained in:
2026-08-13 15:26:53 +00:00
parent 2c23c1458a
commit dcba974aa4
23 changed files with 352 additions and 175 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();
+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);
+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}회)`;
@@ -156,7 +157,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}`),
@@ -308,7 +312,12 @@ onMounted(() => {
<LegacyGeneralProgress :general="selectedGeneral" /> <LegacyGeneralProgress :general="selectedGeneral" />
</div> </div>
<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
+3 -8
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}`);
}; };
+5 -2
View File
@@ -2,7 +2,7 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import { trpc } from '../utils/trpc'; import { trpc } from '../utils/trpc';
import { formatLog } from '../utils/formatLog'; import { formatLog } from '../utils/formatLog';
import { formatSeoulDateTime } from '../utils/legacyDateTime'; import { formatSeoulDateTime, formatSeoulHourMinute } from '../utils/legacyDateTime';
import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic'; import { isDefenceTrainPenaltyWaivedByScenarioEffect } from '@sammo-ts/logic';
import { useSessionStore } from '../stores/session'; import { useSessionStore } from '../stores/session';
import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon'; import { resolveGeneralIconUrl, useDefaultGeneralIcon } from '../utils/generalIcon';
@@ -430,7 +430,10 @@ onMounted(() => {
</div> </div>
<div> <div>
<dt>나이/다음턴</dt> <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> </div>
</dl> </dl>
</div> </div>
@@ -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]!;
@@ -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';
@@ -62,7 +63,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)
@@ -287,8 +290,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(() => {
@@ -470,6 +470,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();
@@ -524,6 +525,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 });
@@ -568,9 +570,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
); );
@@ -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';
@@ -211,21 +212,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 = () => {
@@ -945,7 +936,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"
@@ -953,7 +944,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"
@@ -961,7 +952,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"
@@ -1236,10 +1227,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('');
});
});