fix(frontend): standardize server time display
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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('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();
|
||||
|
||||
@@ -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('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 });
|
||||
@@ -568,9 +570,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
|
||||
|
||||
@@ -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 ?? '-' }}
|
||||
|
||||
@@ -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';
|
||||
@@ -211,21 +212,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 = () => {
|
||||
@@ -945,7 +936,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"
|
||||
@@ -953,7 +944,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</label>
|
||||
<label class="text-xs text-zinc-400"
|
||||
>가오픈
|
||||
>가오픈 (서버 시간 UTC+9)
|
||||
<input
|
||||
v-model="form.preopenAt"
|
||||
type="datetime-local"
|
||||
@@ -961,7 +952,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
</label>
|
||||
<label class="text-xs text-zinc-400"
|
||||
>정식 오픈
|
||||
>정식 오픈 (서버 시간 UTC+9)
|
||||
<input
|
||||
v-model="form.openAt"
|
||||
type="datetime-local"
|
||||
@@ -1236,10 +1227,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,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';
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user