diff --git a/README.md b/README.md index d53cda0..9d36f85 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ PWA standalone으로 진입한 경우에는 설치형 앱 사용성을 우선해 ## 히스토리와 지도 -실시간 화면은 현재 위치와 상태를 조회하고, 기본 기간 입력값에 해당하는 최근 하루 이동 기록도 지도에 함께 표시합니다. 자동 위치 이동이 켜져 있어도 새 위치를 받을 때마다 경로선, 종료 지점, 기록 요약을 같은 히스토리 기준으로 다시 그립니다. 기간 입력을 변경하면 해당 범위의 이동 기록을 `history=1` 옵션으로 다시 요청합니다. 서버는 Home Assistant 히스토리 API를 실행한 뒤 시간과 좌표가 같은 중복 지점만 제거하고, 설정 시간 안의 병합 지점을 전부 응답합니다. +실시간 화면은 현재 위치와 상태를 조회하고, 기본 기간 입력값에 해당하는 최근 하루 이동 기록도 지도에 함께 표시합니다. 라이브 모드의 기본 기간은 현재 시각 기준 24시간 전 정각부터 다음 정각까지이며, 정시가 지나면 입력 구간도 한 시간씩 같이 이동해 새 시간대 기록이 경로선에 계속 포함됩니다. 자동 위치 이동이 켜져 있어도 새 위치를 받을 때마다 경로선과 기록 요약을 같은 히스토리 기준으로 다시 그립니다. 기간 입력을 변경하면 해당 범위의 이동 기록을 `history=1` 옵션으로 다시 요청합니다. 서버는 Home Assistant 히스토리 API를 실행한 뒤 시간과 좌표가 같은 중복 지점만 제거하고, 설정 시간 안의 병합 지점을 전부 응답합니다. 상태 조회는 `seoul`, `main` Home Assistant의 `/states`를 모두 읽어 entity별로 병합합니다. 한쪽에만 의미 있는 값이 있으면 해당 값을 사용하고, 양쪽 모두 값이 있으면 더 최신 `last_updated` 또는 `last_changed` 값을 사용합니다. 선택된 상태의 attributes에 빈 값이 있으면 다른 쪽 attributes로 채워 빈칸을 줄입니다. diff --git a/js/main.js b/js/main.js index 38941df..9b992d6 100644 --- a/js/main.js +++ b/js/main.js @@ -461,6 +461,7 @@ let langCode = localStorage.getItem("currentLang") || deviceLang, lastAutoSwitchChecked, lastUpdateSwitchChecked, lastHash, + lastLiveHistoryRangeKey, lastLat, lastLng, lastUpdateISOTime, @@ -475,6 +476,7 @@ let langCode = localStorage.getItem("currentLang") || deviceLang, const PLAYBACK_SPEEDS = [2, 5, 10, 20, 50, 100, 500]; const PLAYBACK_MIN_VISIBLE_STEPS = 80; const PLAYBACK_TICK_MS = 160; +const STAY_MIN_SECONDS = 10 * 60; const FALLBACK_BACK_TARGET = 'https://chaegeon.com/'; const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1'; @@ -489,6 +491,58 @@ function isStandalonePwa() { } let findmydeviceAuthenticated = !FINDMYDEVICE_AUTH_REQUIRED || isStandalonePwa(); +function pad2(value) { + return String(value).padStart(2, '0'); +} + +function toLocalHourValue(date) { + return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:00`; +} + +function toServerTimeValue(value) { + return value + ? (value.includes('T') ? value.replace('T', ' ') : value) + (value.length === 16 ? ':00' : '') + : null; +} + +function liveHistoryRangeValues(now = new Date()) { + const start = new Date(now.getTime() - 24 * 3600 * 1000); + start.setMinutes(0, 0, 0); + + const end = new Date(now); + end.setMinutes(0, 0, 0); + if (now.getMinutes() > 0 || now.getSeconds() > 0 || now.getMilliseconds() > 0) { + end.setHours(end.getHours() + 1); + } + + const startValue = toLocalHourValue(start); + const endValue = toLocalHourValue(end); + return { + startValue, + endValue, + startTime: toServerTimeValue(startValue), + endTime: toServerTimeValue(endValue), + key: `${startValue}|${endValue}`, + }; +} + +function syncLiveHistoryRangeInputs(force = false) { + if (_overrideRange) return false; + const startEl = document.getElementById('rangeStart'); + const endEl = document.getElementById('rangeEnd'); + if (!startEl || !endEl) return false; + if (!force && (document.activeElement === startEl || document.activeElement === endEl)) return false; + + const range = liveHistoryRangeValues(); + const changed = lastLiveHistoryRangeKey !== range.key || startEl.value !== range.startValue || endEl.value !== range.endValue; + if (force || changed) { + startEl.value = range.startValue; + endEl.value = range.endValue; + lastLiveHistoryRangeKey = range.key; + } + return changed; +} + function renderAuthErrorPage() { document.body.innerHTML = `
@@ -882,26 +936,7 @@ document.addEventListener('DOMContentLoaded', () => { const startEl = document.getElementById('rangeStart'); const endEl = document.getElementById('rangeEnd'); if (startEl && endEl) { - const pad = n => String(n).padStart(2, '0'); - const toLocalHour = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:00`; - - // 기준 시각(현재) - const current = new Date(); - - // 시작: 24시간 전 → 정각 버림 - const start24 = new Date(current.getTime() - 24 * 3600 * 1000); - start24.setMinutes(0, 0, 0); - - // 종료: 현재 시각의 다음 정각 올림 - const endRoundUp = new Date(current); - endRoundUp.setMinutes(0, 0, 0); - if (current.getMinutes() > 0 || current.getSeconds() > 0 || current.getMilliseconds() > 0) { - endRoundUp.setHours(endRoundUp.getHours() + 1); - } - - // 입력값 세팅 - startEl.value = toLocalHour(start24); - endEl.value = toLocalHour(endRoundUp); + syncLiveHistoryRangeInputs(true); } // 3) 지도 히스토리 초기화 & 라이브 데이터 재요청 + 현재 위치로 이동 @@ -920,35 +955,23 @@ document.addEventListener('DOMContentLoaded', () => { const endEl = document.getElementById('rangeEnd'); if (!startEl || !endEl) return; - const pad = n => String(n).padStart(2, '0'); - const toLocalHour = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:00`; - const toServerTime = v => - v ? (v.includes('T') ? v.replace('T', ' ') : v) + (v.length === 16 ? ':00' : '') : null; - - const now = new Date(); - - // 시작: 현재 시각에서 24시간 전, 정각 버림 - const start24 = new Date(now.getTime() - 24 * 3600 * 1000); - start24.setMinutes(0, 0, 0); - - // 종료: 현재 시각, 다음 정각 올림 - const endRoundUp = new Date(now); - endRoundUp.setMinutes(0, 0, 0); - if (now.getMinutes() > 0 || now.getSeconds() > 0 || now.getMilliseconds() > 0) { - endRoundUp.setHours(endRoundUp.getHours() + 1); + const qs = new URLSearchParams(window.location.search); + const hasQS = qs.has('startTime') || qs.has('endTime'); + if (hasQS) { + startEl.value = qs.get('startTime') + ? qs.get('startTime').replace(' ', 'T').slice(0, 16) + : liveHistoryRangeValues().startValue; + endEl.value = qs.get('endTime') + ? qs.get('endTime').replace(' ', 'T').slice(0, 16) + : liveHistoryRangeValues().endValue; + } else { + syncLiveHistoryRangeInputs(true); } - const qs = new URLSearchParams(window.location.search); - startEl.value = qs.get('startTime') - ? qs.get('startTime').replace(' ', 'T').slice(0, 16) - : toLocalHour(start24); - endEl.value = qs.get('endTime') - ? qs.get('endTime').replace(' ', 'T').slice(0, 16) - : toLocalHour(endRoundUp); - - const hasQS = qs.has('startTime') || qs.has('endTime'); - const fire = debounce(async (userTriggered = false) => { + if (!userTriggered && !hasQS) { + syncLiveHistoryRangeInputs(false); + } const start = startEl.value; const end = endEl.value; if (!start || !end) return; @@ -956,8 +979,8 @@ document.addEventListener('DOMContentLoaded', () => { if (start > end) endEl.value = start; const range = { - startTime: toServerTime(startEl.value), - endTime: toServerTime(endEl.value) + startTime: toServerTimeValue(startEl.value), + endTime: toServerTimeValue(endEl.value) }; const shouldUseHistory = userTriggered || hasQS; @@ -1065,8 +1088,9 @@ setInterval(() => { // 히스토리 범위가 활성화된 경우: hash 체크 생략하고 같은 범위로 재요청(낭비 방지로 5초 권장) if (_overrideRange) return; // 간단히 폴링 정지 (원하면 5초 주기로 같은 범위 재요청하도록 변경 가능) + const rangeChanged = syncLiveHistoryRangeInputs(false); fetchDeviceData(false).then(({ hash }) => { - if (!hash || hash === lastHash) return; + if (!rangeChanged && (!hash || hash === lastHash)) return; lastHash = hash; fetchDeviceData(true).then(data => updateDOMWithData(data)); }); @@ -1676,7 +1700,6 @@ function buildStayPoints(points) { const stays = []; let start = 0; const radius = 80; - const minSeconds = 10 * 60; for (let i = 1; i <= points.length; i++) { const base = points[start]; @@ -1686,7 +1709,7 @@ function buildStayPoints(points) { const end = points[Math.max(start, i - 1)]; const duration = (toTimestamp(end.time) - toTimestamp(base.time)) / 1000; - if (duration >= minSeconds) { + if (duration >= STAY_MIN_SECONDS) { const slice = points.slice(start, i); const lat = slice.reduce((sum, p) => sum + Number(p.lat), 0) / slice.length; const lng = slice.reduce((sum, p) => sum + Number(p.lng), 0) / slice.length; @@ -2517,14 +2540,11 @@ function syncUpdateBtn() { } function readHistoryRangeInputs() { - const toServerTime = value => value - ? (value.includes('T') ? value.replace('T', ' ') : value) + (value.length === 16 ? ':00' : '') - : null; const start = document.getElementById('rangeStart')?.value || ''; const end = document.getElementById('rangeEnd')?.value || ''; return { - startTime: toServerTime(start), - endTime: toServerTime(end), + startTime: toServerTimeValue(start), + endTime: toServerTimeValue(end), }; }