diff --git a/client/public/version.js b/client/public/version.js index 75ff897..f429416 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,3 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.21 R84"; +window.CHGRID_WEB_VERSION = "2026.02.21 R85"; +// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. +window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/main.ts b/client/src/main.ts index 6430646..c5b88f0 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -29,12 +29,14 @@ const AUDIO_OUTPUT_STORAGE_KEY = 'chatGridAudioOutputDeviceId'; const AUDIO_INPUT_NAME_STORAGE_KEY = 'chatGridAudioInputDeviceName'; const AUDIO_OUTPUT_NAME_STORAGE_KEY = 'chatGridAudioOutputDeviceName'; const AUDIO_OUTPUT_MODE_STORAGE_KEY = 'chatGridAudioOutputMode'; +const DEFAULT_DISPLAY_TIME_ZONE = 'America/Detroit'; const NICKNAME_STORAGE_KEY = 'spatialChatNickname'; const NICKNAME_MAX_LENGTH = 32; declare global { interface Window { CHGRID_WEB_VERSION?: string; + CHGRID_TIME_ZONE?: string; } } @@ -92,6 +94,7 @@ type ChangelogData = { }; const APP_VERSION = String(window.CHGRID_WEB_VERSION ?? '').trim(); +const DISPLAY_TIME_ZONE = resolveDisplayTimeZone(); dom.appVersion.textContent = APP_VERSION ? `Another AI experiment with Jage. Version ${APP_VERSION}` : 'Another AI experiment with Jage. Version unknown'; @@ -178,6 +181,41 @@ function requiredById(id: string): T { return found as T; } +function resolveDisplayTimeZone(): string { + const configured = String(window.CHGRID_TIME_ZONE ?? '').trim(); + if (configured) { + try { + new Intl.DateTimeFormat('en-US', { timeZone: configured }).format(new Date()); + return configured; + } catch { + // Fall back when configured timezone is invalid. + } + } + return DEFAULT_DISPLAY_TIME_ZONE; +} + +function formatTimestampMs(value: unknown): string { + const raw = Number(value); + if (!Number.isFinite(raw)) { + return String(value ?? ''); + } + const date = new Date(raw); + if (Number.isNaN(date.getTime())) { + return String(value ?? ''); + } + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: DISPLAY_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).formatToParts(date); + const pick = (type: Intl.DateTimeFormatPartTypes): string => parts.find((part) => part.type === type)?.value ?? '00'; + return `${pick('year')}-${pick('month')}-${pick('day')} ${pick('hour')}:${pick('minute')}`; +} + function setUpdatesExpanded(expanded: boolean): void { dom.updatesToggle.setAttribute('aria-expanded', expanded ? 'true' : 'false'); dom.updatesToggle.textContent = expanded ? 'Hide updates' : 'Show updates'; @@ -603,8 +641,8 @@ function getItemPropertyValue(item: WorldItem, key: string): string { if (key === 'carrierId') return item.carrierId ?? 'none'; if (key === 'version') return String(item.version); if (key === 'createdBy') return item.createdBy; - if (key === 'createdAt') return String(item.createdAt); - if (key === 'updatedAt') return String(item.updatedAt); + if (key === 'createdAt') return formatTimestampMs(item.createdAt); + if (key === 'updatedAt') return formatTimestampMs(item.updatedAt); if (key === 'capabilities') return item.capabilities.join(', ') || 'none'; if (key === 'useSound') return item.useSound ?? 'none'; if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on'; diff --git a/deploy/README.md b/deploy/README.md index 03f0776..07c6fdc 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -76,6 +76,7 @@ Logs: ```bash journalctl -u chat-grid.service -f +tail -f /home/bestmidi/chgrid/server/runtime/server.log ``` If you previously used `chgrid-signaling.service`, migrate once: diff --git a/deploy/scripts/install_service.sh b/deploy/scripts/install_service.sh index d7f470f..cecb524 100755 --- a/deploy/scripts/install_service.sh +++ b/deploy/scripts/install_service.sh @@ -12,6 +12,9 @@ if [[ ! -f "$SRC_UNIT" ]]; then fi sudo cp "$SRC_UNIT" "$DST_UNIT" +sudo install -d -m 0755 -o bestmidi -g bestmidi "$REPO_ROOT/server/runtime" +sudo touch "$REPO_ROOT/server/runtime/server.log" +sudo chown bestmidi:bestmidi "$REPO_ROOT/server/runtime/server.log" sudo systemctl daemon-reload sudo systemctl enable --now "$UNIT_NAME" sudo systemctl restart "$UNIT_NAME" diff --git a/deploy/systemd/chat-grid.service b/deploy/systemd/chat-grid.service index 53b8654..0c0fcc5 100644 --- a/deploy/systemd/chat-grid.service +++ b/deploy/systemd/chat-grid.service @@ -8,7 +8,10 @@ User=bestmidi Group=bestmidi WorkingDirectory=/home/bestmidi/chgrid/server Environment=PATH=/home/bestmidi/chgrid/server/.venv/bin:/usr/bin:/bin +ExecStartPre=/usr/bin/mkdir -p /home/bestmidi/chgrid/server/runtime ExecStart=/home/bestmidi/chgrid/server/.venv/bin/python main.py --config /home/bestmidi/chgrid/server/config.toml +StandardOutput=append:/home/bestmidi/chgrid/server/runtime/server.log +StandardError=append:/home/bestmidi/chgrid/server/runtime/server.log Restart=always RestartSec=3 diff --git a/server/app/server.py b/server/app/server.py index 018d766..0cdaef2 100644 --- a/server/app/server.py +++ b/server/app/server.py @@ -139,7 +139,12 @@ class SignalingServer: for item in self.item_service.drop_carried_items_for_disconnect(disconnected): await self._broadcast_item(item) self.item_service.save_state() - LOGGER.info("client disconnected id=%s total=%d", disconnected.id, len(self.clients)) + LOGGER.info( + "client disconnected id=%s nickname=%s total=%d", + disconnected.id, + disconnected.nickname, + len(self.clients), + ) await self._broadcast(UserLeftPacket(type="user_left", id=disconnected.id), exclude=websocket) await self._broadcast( BroadcastChatMessagePacket( @@ -249,6 +254,10 @@ class SignalingServer: ) return client.nickname = requested_nickname + if old_nickname == "user...": + LOGGER.info("user login id=%s nickname=%s", client.id, client.nickname) + else: + LOGGER.info("nickname change id=%s old=%s new=%s", client.id, old_nickname, client.nickname) await self._send( client.websocket, NicknameResultPacket( @@ -319,6 +328,15 @@ class SignalingServer: self.item_service.add_item(item) await self._broadcast_item(item) self.item_service.save_state() + LOGGER.info( + "item created by=%s item_id=%s type=%s title=%s x=%d y=%d", + client.nickname, + item.id, + item.type, + item.title, + item.x, + item.y, + ) item_text = f"{item.title} ({self._item_type_label(item)})" await self._broadcast( BroadcastChatMessagePacket( @@ -389,6 +407,13 @@ class SignalingServer: if item.carrierId is None and (item.x != client.x or item.y != client.y): await self._send_item_result(client, False, "delete", "Item is not on your square.", item.id) return + LOGGER.info( + "item deleted by=%s item_id=%s type=%s title=%s", + client.nickname, + item.id, + item.type, + item.title, + ) self.item_service.remove_item(item.id) self.item_last_use_ms.pop(item.id, None) await self._broadcast(ItemRemovePacket(type="item_remove", itemId=item.id))