add time
This commit is contained in:
@@ -1,5 +1,39 @@
|
|||||||
# Завершённые задачи приложения площадки
|
# Завершённые задачи приложения площадки
|
||||||
|
|
||||||
|
## SCREEN-025 — Длительность игры в пульте
|
||||||
|
|
||||||
|
- Статус: `DONE`.
|
||||||
|
- Дата: 2026-10-27.
|
||||||
|
- Результат: рядом со статусом игры отображается длительность в формате сайта,
|
||||||
|
обновляемая раз в секунду. Пауза не исключается из времени; завершение фиксирует
|
||||||
|
длительность, сброс возвращает «Игра ещё не началась». Даты берутся из текущего
|
||||||
|
GetGame; некорректные даты показывают явное
|
||||||
|
сообщение. Отсутствующие поля допустимы. При потере связи сохраняются последние
|
||||||
|
даты и существующее предупреждение, при смене выбора старый счётчик скрывается.
|
||||||
|
- Проверки: `npm test` — 211/211; `npm run type-check`, `npm run lint`,
|
||||||
|
`npm run lint:css`, сборка и `git diff --check` — PASS. Ручное ревью diff
|
||||||
|
по применимым правилам кода выполнено. Новые тесты проверяют границы единиц,
|
||||||
|
UTC/смещение, некорректные даты, завершение, обновление и сброс серверных дат.
|
||||||
|
- Electron: отдельный прогон на базе существующего smoke подтвердил отображение,
|
||||||
|
ход счётчика, продолжение на паузе, завершение, сброс и снятие выбора игры
|
||||||
|
(`SCREEN025_DURATION_UI_PASS`; сценарии и лог — `/tmp/screen025-*`).
|
||||||
|
Общий `npm run smoke` остановился до новой проверки на существующем сценарии
|
||||||
|
громкости (`Timeout: volume slider keyboard update`); полный smoke не подтверждён.
|
||||||
|
- Ограничения: установочные архивы не пересобирались; живой сервер и физический
|
||||||
|
проектор не проверялись. Сервер, сайт, protobuf и зависимости не менялись.
|
||||||
|
Commit/push не выполнялись.
|
||||||
|
- Исправление по обратной связи (2026-10-27): при статусе «Идёт» отображалось
|
||||||
|
«Игра ещё не началась». Первоначальный UTC-расчёт не повторял GamePage:
|
||||||
|
started_at/ended_at хранятся в TIMESTAMP без часового пояса, PostgreSQL настроен
|
||||||
|
на Asia/Barnaul, а сайт сравнивает их с местными часами текущего компьютера.
|
||||||
|
Расчёт текущего времени приведён к GamePage без изменения сервера и данных.
|
||||||
|
Добавлен регрессионный тест для Asia/Barnaul и UTC. 212/212 тестов, типы,
|
||||||
|
линтеры, сборка, ревью diff и diff --check — PASS. Отдельная проверка Electron
|
||||||
|
на датах с местными часами подтвердила ход, паузу, завершение, сброс и снятие
|
||||||
|
выбора (`SCREEN025_DURATION_UI_PASS`, `/tmp/screen025-timezone-ui.log`).
|
||||||
|
Сохраняется поведение сайта: расчёт идущей игры зависит от часового пояса
|
||||||
|
компьютера; миграция серверных дат в эту задачу не входит.
|
||||||
|
|
||||||
## SCREEN-002 — Собрать описание пакета через существующий API
|
## SCREEN-002 — Собрать описание пакета через существующий API
|
||||||
|
|
||||||
- Статус: `DONE`.
|
- Статус: `DONE`.
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { formatGameDuration } from '../src/utils/gameDuration.js'
|
||||||
|
|
||||||
|
const start = '2026-10-27T08:00:00Z'
|
||||||
|
const now = Date.parse(start)
|
||||||
|
const actualNow = now + new Date(now).getTimezoneOffset() * 60000
|
||||||
|
|
||||||
|
test('длительность игры форматируется на границах единиц времени', () => {
|
||||||
|
for (const [seconds, expected] of [
|
||||||
|
[0, '0с'], [59, '59с'], [60, '1м 0с'], [3599, '59м 59с'],
|
||||||
|
[3600, '1ч 0м'], [86399, '23ч 59м'], [86400, '1д 0ч'],
|
||||||
|
[30 * 86400, '1мес 0д'], [365 * 86400, '1г 0д'],
|
||||||
|
] as const) {
|
||||||
|
assert.equal(formatGameDuration(start, '', actualNow + seconds * 1000), expected)
|
||||||
|
}
|
||||||
|
assert.equal(formatGameDuration('2026-10-27T11:00:00+03:00', '', actualNow + 60000), '1м 0с')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('начало, завершение и некорректные даты обрабатываются явно', () => {
|
||||||
|
assert.equal(formatGameDuration('', '', now), 'Игра ещё не началась')
|
||||||
|
assert.equal(formatGameDuration(start, '', actualNow - 1), 'Игра ещё не началась')
|
||||||
|
assert.equal(formatGameDuration(start, '2026-10-27T09:12:00Z', now + 86400000), '1ч 12м')
|
||||||
|
assert.equal(formatGameDuration('invalid', '', now), 'Время игры недоступно')
|
||||||
|
assert.equal(formatGameDuration(start, 'invalid', now), 'Время игры недоступно')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('идущая игра считает местные часы сервера как вкладка «Игра»', (t) => {
|
||||||
|
const previousTimezone = process.env.TZ
|
||||||
|
t.after(() => {
|
||||||
|
if (previousTimezone === undefined) delete process.env.TZ
|
||||||
|
else process.env.TZ = previousTimezone
|
||||||
|
})
|
||||||
|
process.env.TZ = 'Asia/Barnaul'
|
||||||
|
const now = Date.parse('2026-10-27T08:12:00Z')
|
||||||
|
// В Барнауле игра началась в 15:00; API возвращает эти часы с суффиксом Z.
|
||||||
|
assert.equal(formatGameDuration('2026-10-27T15:00:00Z', '', now), '12м 0с')
|
||||||
|
assert.equal(formatGameDuration('2026-10-27T15:00:00Z', '', now + 1000), '12м 1с')
|
||||||
|
assert.equal(formatGameDuration('2026-10-27T15:00:00Z', '2026-10-27T15:05:00Z', now), '5м 0с')
|
||||||
|
process.env.TZ = 'UTC'
|
||||||
|
assert.equal(formatGameDuration('2026-10-27T08:00:00Z', '', now), '12м 0с')
|
||||||
|
})
|
||||||
@@ -72,6 +72,8 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }],
|
screenTeams: [{ ID: '2', name: 'Ястребы' }, { ID: '1', name: 'Агенты' }],
|
||||||
title: 'Дело',
|
title: 'Дело',
|
||||||
gameStatus: 'finish',
|
gameStatus: 'finish',
|
||||||
|
startedAt: '',
|
||||||
|
endedAt: '',
|
||||||
gameError: 0,
|
gameError: 0,
|
||||||
gameResponse: null as (() => Promise<Response>) | null,
|
gameResponse: null as (() => Promise<Response>) | null,
|
||||||
gameCommands: [] as string[],
|
gameCommands: [] as string[],
|
||||||
@@ -190,6 +192,8 @@ async function fixture(t: TestContext, secure = true) {
|
|||||||
ID: Number(path.split('/').at(-1)),
|
ID: Number(path.split('/').at(-1)),
|
||||||
name: model.title,
|
name: model.title,
|
||||||
status: model.gameStatus,
|
status: model.gameStatus,
|
||||||
|
startedAt: model.startedAt,
|
||||||
|
endedAt: model.endedAt,
|
||||||
scenario: model.omitScenario
|
scenario: model.omitScenario
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
@@ -2101,6 +2105,29 @@ test('управление игрой работает без привязанн
|
|||||||
assert.equal(f.requests.some(r => r.path === '/api/screens'), false)
|
assert.equal(f.requests.some(r => r.path === '/api/screens'), false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('даты длительности обновляются с сервером и очищаются после сброса', async (t) => {
|
||||||
|
const f = await fixture(t)
|
||||||
|
await f.login()
|
||||||
|
await f.online.act({ action: 'select-game', gameId: 7 })
|
||||||
|
assert.equal(f.online.snapshot().game?.startedAt, '')
|
||||||
|
f.model.startedAt = '2026-10-27T08:00:00Z'
|
||||||
|
await f.online.refreshGameState(true)
|
||||||
|
assert.equal(f.online.snapshot().game?.startedAt, f.model.startedAt)
|
||||||
|
assert.equal(f.online.snapshot().game?.endedAt, '')
|
||||||
|
f.model.endedAt = '2026-10-27T09:12:00Z'
|
||||||
|
await f.online.refreshGameState(true)
|
||||||
|
assert.equal(f.online.snapshot().game?.endedAt, f.model.endedAt)
|
||||||
|
f.model.offline = true
|
||||||
|
await f.online.refreshGameState(true)
|
||||||
|
assert.equal(f.online.snapshot().game?.endedAt, f.model.endedAt)
|
||||||
|
f.model.offline = false
|
||||||
|
f.model.startedAt = ''
|
||||||
|
f.model.endedAt = ''
|
||||||
|
await f.online.act({ action: 'game-command', command: 'reset', key: f.controller.state.catalog.key! })
|
||||||
|
assert.equal(f.online.snapshot().game?.startedAt, '')
|
||||||
|
assert.equal(f.online.snapshot().game?.endedAt, '')
|
||||||
|
})
|
||||||
|
|
||||||
test('статус игры обновляется с сайта; неизвестный статус и ошибки сохраняют последнее состояние', async (t) => {
|
test('статус игры обновляется с сайта; неизвестный статус и ошибки сохраняют последнее состояние', async (t) => {
|
||||||
const f = await fixture(t)
|
const f = await fixture(t)
|
||||||
await f.login()
|
await f.login()
|
||||||
|
|||||||
+4
-1
@@ -155,7 +155,10 @@ export class Online {
|
|||||||
const status = text(game.status)
|
const status = text(game.status)
|
||||||
if (id !== pkg.package.game.id || !Object.hasOwn(gameStatuses, status))
|
if (id !== pkg.package.game.id || !Object.hasOwn(gameStatuses, status))
|
||||||
throw new Error('Сервер вернул неизвестное состояние игры')
|
throw new Error('Сервер вернул неизвестное состояние игры')
|
||||||
this.state.game = { key: pkg.key, id, name: text(game.name), status: status as GameStatus }
|
this.state.game = {
|
||||||
|
key: pkg.key, id, name: text(game.name), status: status as GameStatus,
|
||||||
|
startedAt: text(game.startedAt ?? ''), endedAt: text(game.endedAt ?? ''),
|
||||||
|
}
|
||||||
this.state.gameError = ''
|
this.state.gameError = ''
|
||||||
const listed = this.state.games.find(g => g.id === id)
|
const listed = this.state.games.find(g => g.id === id)
|
||||||
if (listed) listed.status = status
|
if (listed) listed.status = status
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ module.exports = async function onlineSmoke({
|
|||||||
ID: 6,
|
ID: 6,
|
||||||
name: 'HTTP smoke',
|
name: 'HTTP smoke',
|
||||||
status: 'finish',
|
status: 'finish',
|
||||||
|
startedAt: '2026-10-27T08:00:00Z',
|
||||||
|
endedAt: '2026-10-27T09:12:00Z',
|
||||||
scenario: { story: invalidStory ? [] : null },
|
scenario: { story: invalidStory ? [] : null },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -426,6 +428,10 @@ module.exports = async function onlineSmoke({
|
|||||||
},
|
},
|
||||||
'game prepared without screen connection',
|
'game prepared without screen connection',
|
||||||
)
|
)
|
||||||
|
await poll(
|
||||||
|
() => evaluate(`document.querySelector('[data-testid="game-duration"]')?.textContent.trim() === '1ч 12м'`),
|
||||||
|
'completed game duration in controller',
|
||||||
|
)
|
||||||
await poll(
|
await poll(
|
||||||
() =>
|
() =>
|
||||||
evaluate(`(() => {
|
evaluate(`(() => {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
import { NAlert, NButton, NCard, NSpace, NTag, useDialog } from 'naive-ui'
|
import { NAlert, NButton, NCard, NSpace, NTag, useDialog } from 'naive-ui'
|
||||||
import type { GameCommand, OnlineIntent, OnlineState } from '../types/online'
|
import type { GameCommand, OnlineIntent, OnlineState } from '../types/online'
|
||||||
import { gameCommands, gameStatuses } from '../utils/game-status'
|
import { gameCommands, gameStatuses } from '../utils/game-status'
|
||||||
|
import { formatGameDuration } from '../utils/gameDuration'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
state: OnlineState
|
state: OnlineState
|
||||||
@@ -13,6 +14,12 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
|
const emit = defineEmits<{ action: [intent: OnlineIntent] }>()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const game = computed(() => props.state.game?.key === props.selectedKey ? props.state.game : null)
|
const game = computed(() => props.state.game?.key === props.selectedKey ? props.state.game : null)
|
||||||
|
const now = ref(Date.now())
|
||||||
|
const duration = computed(() => game.value
|
||||||
|
? formatGameDuration(game.value.startedAt, game.value.endedAt, now.value)
|
||||||
|
: '')
|
||||||
|
const tick = setInterval(() => { now.value = Date.now() }, 1000)
|
||||||
|
onUnmounted(() => clearInterval(tick))
|
||||||
const status = computed(() => game.value ? gameStatuses[game.value.status] : null)
|
const status = computed(() => game.value ? gameStatuses[game.value.status] : null)
|
||||||
const disabled = computed(() => props.isBusy || props.state.busy || !props.state.authenticated || !!props.state.gameError)
|
const disabled = computed(() => props.isBusy || props.state.busy || !props.state.authenticated || !!props.state.gameError)
|
||||||
const availableCommands = computed(() => game.value && props.selectedKey ? gameCommands[game.value.status] : [])
|
const availableCommands = computed(() => game.value && props.selectedKey ? gameCommands[game.value.status] : [])
|
||||||
@@ -52,6 +59,7 @@ function run(command: GameCommand) {
|
|||||||
<n-tag role="status" data-testid="game-state" :type="state.gameError ? 'warning' : status?.type ?? 'info'">
|
<n-tag role="status" data-testid="game-state" :type="state.gameError ? 'warning' : status?.type ?? 'info'">
|
||||||
{{ !selectedKey ? 'Игра не выбрана' : status?.label ?? (!state.authenticated ? 'Требуется вход' : state.gameError ? 'Нет данных' : 'Загрузка…') }}
|
{{ !selectedKey ? 'Игра не выбрана' : status?.label ?? (!state.authenticated ? 'Требуется вход' : state.gameError ? 'Нет данных' : 'Загрузка…') }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
|
<n-tag v-if="game" type="info" data-testid="game-duration">{{ duration }}</n-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #header-extra>
|
<template #header-extra>
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@ export type OnlineIntent =
|
|||||||
| { action: 'select-game' | 'download' | 'check'; gameId: number }
|
| { action: 'select-game' | 'download' | 'check'; gameId: number }
|
||||||
| { action: 'remove'; key: string }
|
| { action: 'remove'; key: string }
|
||||||
export interface OnlineState {
|
export interface OnlineState {
|
||||||
game: { key: string; id: number; name: string; status: GameStatus } | null
|
game: { key: string; id: number; name: string; status: GameStatus; startedAt: string; endedAt: string } | null
|
||||||
gameError: string
|
gameError: string
|
||||||
server: string
|
server: string
|
||||||
currentGameId: number | null
|
currentGameId: number | null
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export function formatGameDuration(startedAt: string, endedAt: string, now: number): string {
|
||||||
|
if (!startedAt) return 'Игра ещё не началась'
|
||||||
|
const start = Date.parse(startedAt)
|
||||||
|
// Совместимость с GamePage: серверные TIMESTAMP содержат местные часы как UTC.
|
||||||
|
const localNow = now - new Date(now).getTimezoneOffset() * 60000
|
||||||
|
const end = endedAt ? Date.parse(endedAt) : localNow
|
||||||
|
const diff = end - start
|
||||||
|
if (!Number.isFinite(diff)) return 'Время игры недоступно'
|
||||||
|
if (diff < 0) return 'Игра ещё не началась'
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
const months = Math.floor(days / 30)
|
||||||
|
const years = Math.floor(days / 365)
|
||||||
|
|
||||||
|
if (years > 0) return `${years}г ${days % 365}д`
|
||||||
|
if (months > 0) return `${months}мес ${days % 30}д`
|
||||||
|
if (days > 0) return `${days}д ${hours % 24}ч`
|
||||||
|
if (hours > 0) return `${hours}ч ${minutes % 60}м`
|
||||||
|
if (minutes > 0) return `${minutes}м ${seconds % 60}с`
|
||||||
|
return `${seconds}с`
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user