라이브 기록 구간 정시 자동 이동

This commit is contained in:
seo
2026-07-12 07:11:30 +09:00
parent d1322fbb71
commit 49779d566f
2 changed files with 77 additions and 57 deletions
+1 -1
View File
@@ -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로 채워 빈칸을 줄입니다. 상태 조회는 `seoul`, `main` Home Assistant의 `/states`를 모두 읽어 entity별로 병합합니다. 한쪽에만 의미 있는 값이 있으면 해당 값을 사용하고, 양쪽 모두 값이 있으면 더 최신 `last_updated` 또는 `last_changed` 값을 사용합니다. 선택된 상태의 attributes에 빈 값이 있으면 다른 쪽 attributes로 채워 빈칸을 줄입니다.
+72 -52
View File
@@ -461,6 +461,7 @@ let langCode = localStorage.getItem("currentLang") || deviceLang,
lastAutoSwitchChecked, lastAutoSwitchChecked,
lastUpdateSwitchChecked, lastUpdateSwitchChecked,
lastHash, lastHash,
lastLiveHistoryRangeKey,
lastLat, lastLat,
lastLng, lastLng,
lastUpdateISOTime, lastUpdateISOTime,
@@ -475,6 +476,7 @@ let langCode = localStorage.getItem("currentLang") || deviceLang,
const PLAYBACK_SPEEDS = [2, 5, 10, 20, 50, 100, 500]; const PLAYBACK_SPEEDS = [2, 5, 10, 20, 50, 100, 500];
const PLAYBACK_MIN_VISIBLE_STEPS = 80; const PLAYBACK_MIN_VISIBLE_STEPS = 80;
const PLAYBACK_TICK_MS = 160; const PLAYBACK_TICK_MS = 160;
const STAY_MIN_SECONDS = 10 * 60;
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/'; const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1'; const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1';
@@ -489,6 +491,58 @@ function isStandalonePwa() {
} }
let findmydeviceAuthenticated = !FINDMYDEVICE_AUTH_REQUIRED || 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() { function renderAuthErrorPage() {
document.body.innerHTML = ` document.body.innerHTML = `
<main class="find-auth-page"> <main class="find-auth-page">
@@ -882,26 +936,7 @@ document.addEventListener('DOMContentLoaded', () => {
const startEl = document.getElementById('rangeStart'); const startEl = document.getElementById('rangeStart');
const endEl = document.getElementById('rangeEnd'); const endEl = document.getElementById('rangeEnd');
if (startEl && endEl) { if (startEl && endEl) {
const pad = n => String(n).padStart(2, '0'); syncLiveHistoryRangeInputs(true);
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);
} }
// 3) 지도 히스토리 초기화 & 라이브 데이터 재요청 + 현재 위치로 이동 // 3) 지도 히스토리 초기화 & 라이브 데이터 재요청 + 현재 위치로 이동
@@ -920,35 +955,23 @@ document.addEventListener('DOMContentLoaded', () => {
const endEl = document.getElementById('rangeEnd'); const endEl = document.getElementById('rangeEnd');
if (!startEl || !endEl) return; 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 qs = new URLSearchParams(window.location.search);
const hasQS = qs.has('startTime') || qs.has('endTime');
if (hasQS) {
startEl.value = qs.get('startTime') startEl.value = qs.get('startTime')
? qs.get('startTime').replace(' ', 'T').slice(0, 16) ? qs.get('startTime').replace(' ', 'T').slice(0, 16)
: toLocalHour(start24); : liveHistoryRangeValues().startValue;
endEl.value = qs.get('endTime') endEl.value = qs.get('endTime')
? qs.get('endTime').replace(' ', 'T').slice(0, 16) ? qs.get('endTime').replace(' ', 'T').slice(0, 16)
: toLocalHour(endRoundUp); : liveHistoryRangeValues().endValue;
} else {
const hasQS = qs.has('startTime') || qs.has('endTime'); syncLiveHistoryRangeInputs(true);
}
const fire = debounce(async (userTriggered = false) => { const fire = debounce(async (userTriggered = false) => {
if (!userTriggered && !hasQS) {
syncLiveHistoryRangeInputs(false);
}
const start = startEl.value; const start = startEl.value;
const end = endEl.value; const end = endEl.value;
if (!start || !end) return; if (!start || !end) return;
@@ -956,8 +979,8 @@ document.addEventListener('DOMContentLoaded', () => {
if (start > end) endEl.value = start; if (start > end) endEl.value = start;
const range = { const range = {
startTime: toServerTime(startEl.value), startTime: toServerTimeValue(startEl.value),
endTime: toServerTime(endEl.value) endTime: toServerTimeValue(endEl.value)
}; };
const shouldUseHistory = userTriggered || hasQS; const shouldUseHistory = userTriggered || hasQS;
@@ -1065,8 +1088,9 @@ setInterval(() => {
// 히스토리 범위가 활성화된 경우: hash 체크 생략하고 같은 범위로 재요청(낭비 방지로 5초 권장) // 히스토리 범위가 활성화된 경우: hash 체크 생략하고 같은 범위로 재요청(낭비 방지로 5초 권장)
if (_overrideRange) return; // 간단히 폴링 정지 (원하면 5초 주기로 같은 범위 재요청하도록 변경 가능) if (_overrideRange) return; // 간단히 폴링 정지 (원하면 5초 주기로 같은 범위 재요청하도록 변경 가능)
const rangeChanged = syncLiveHistoryRangeInputs(false);
fetchDeviceData(false).then(({ hash }) => { fetchDeviceData(false).then(({ hash }) => {
if (!hash || hash === lastHash) return; if (!rangeChanged && (!hash || hash === lastHash)) return;
lastHash = hash; lastHash = hash;
fetchDeviceData(true).then(data => updateDOMWithData(data)); fetchDeviceData(true).then(data => updateDOMWithData(data));
}); });
@@ -1676,7 +1700,6 @@ function buildStayPoints(points) {
const stays = []; const stays = [];
let start = 0; let start = 0;
const radius = 80; const radius = 80;
const minSeconds = 10 * 60;
for (let i = 1; i <= points.length; i++) { for (let i = 1; i <= points.length; i++) {
const base = points[start]; const base = points[start];
@@ -1686,7 +1709,7 @@ function buildStayPoints(points) {
const end = points[Math.max(start, i - 1)]; const end = points[Math.max(start, i - 1)];
const duration = (toTimestamp(end.time) - toTimestamp(base.time)) / 1000; const duration = (toTimestamp(end.time) - toTimestamp(base.time)) / 1000;
if (duration >= minSeconds) { if (duration >= STAY_MIN_SECONDS) {
const slice = points.slice(start, i); const slice = points.slice(start, i);
const lat = slice.reduce((sum, p) => sum + Number(p.lat), 0) / slice.length; 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; const lng = slice.reduce((sum, p) => sum + Number(p.lng), 0) / slice.length;
@@ -2517,14 +2540,11 @@ function syncUpdateBtn() {
} }
function readHistoryRangeInputs() { 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 start = document.getElementById('rangeStart')?.value || '';
const end = document.getElementById('rangeEnd')?.value || ''; const end = document.getElementById('rangeEnd')?.value || '';
return { return {
startTime: toServerTime(start), startTime: toServerTimeValue(start),
endTime: toServerTime(end), endTime: toServerTimeValue(end),
}; };
} }