Fix date filtering bug in heatmap/calendar and sync selection state

This commit is contained in:
leeyj
2026-04-16 11:27:25 +09:00
parent 175a30325b
commit df8ae62b0e
8 changed files with 75 additions and 4 deletions
+2
View File
@@ -17,6 +17,8 @@ def get_memos():
group = request.args.get('group', 'all') group = request.args.get('group', 'all')
query = request.args.get('query', '') query = request.args.get('query', '')
date = request.args.get('date', '') date = request.args.get('date', '')
if date in ('null', 'undefined'):
date = ''
conn = get_db() conn = get_db()
c = conn.cursor() c = conn.cursor()
+21
View File
@@ -0,0 +1,21 @@
# 버그 리포트: 히트맵 및 달력 날짜 필터링 실패
## 버그 내용
- **현상**: 히트맵이나 달력에서 특정 날짜를 클릭했을 때, 해당 날짜의 메모만 필터링되어야 하나 전체 메모가 그대로 노출되는 현상.
- **원인**:
1. 프론트엔드 API 호출 시 `date` 파라미터가 누락됨 (`static/js/api.js`).
2. 히트맵 컴포넌트에 클릭 이벤트 리스너가 구현되지 않음 (`static/js/components/HeatmapManager.js`).
3. 달력과 히트맵 간의 선택 상태 동기화 로직 부재.
## 조치 사항
1. **API 수정**: `static/js/api.js``fetchMemos` 함수가 `filters.date`를 지원하도록 수정하고, `null` 값이 `"null"` 문자열로 전송되지 않도록 빈 문자열 처리 추가.
2. **백엔드 보완**: `app/routes/memo.py`에서 `date` 값이 `"null"` 또는 `"undefined"`로 들어올 경우 예외 처리 추가.
3. **히트맵 개선**:
- 각 날짜 셀에 클릭 이벤트 추가.
- `setSelectedDate` 메서드를 추가하여 외부에서 선택 상태를 주입할 수 있도록 함.
- 선택된 날짜에 대한 시각적 강조 스타일 추가 (`static/css/components/heatmap.css`).
4. **상태 동기화**: `AppService.js``setFilter` 로직에서 날짜가 변경될 때 달력과 히트맵의 선택 상태를 동시에 업데이트하도록 수정.
## 향후 주의사항
- 필터링 기능을 추가하거나 수정할 때는 `AppService.state`와 실제 UI(달력, 히트맵, 사이드바 등) 간의 데이터 흐름이 일치하는지 확인해야 함.
- 새로운 API 요청 파라미터를 추가할 때는 `api.js` 모듈에서 해당 파라미터가 올바르게 인코딩되어 전달되는지 반드시 검증할 것.
+4 -1
View File
@@ -22,7 +22,10 @@ document.addEventListener('DOMContentLoaded', async () => {
// 작성기 초기화 (저장 성공 시 데이터 새로고침 콜백 등록) // 작성기 초기화 (저장 성공 시 데이터 새로고침 콜백 등록)
ComposerManager.init(() => AppService.refreshData(updateSidebarCallback)); ComposerManager.init(() => AppService.refreshData(updateSidebarCallback));
HeatmapManager.init('heatmapContainer'); // 히트맵 초기화 // 히트맵 초기화
HeatmapManager.init('heatmapContainer', (date) => {
AppService.setFilter({ date }, updateSidebarCallback);
});
DrawerManager.init(); DrawerManager.init();
Visualizer.init('graphContainer'); Visualizer.init('graphContainer');
UI.initSidebarToggle(); UI.initSidebarToggle();
+9
View File
@@ -77,6 +77,15 @@
cursor: pointer; cursor: pointer;
} }
.heatmap-cell.selected {
transform: scale(1.4);
z-index: 15;
outline: 2px solid white;
box-shadow: 0 0 10px var(--accent);
filter: brightness(1.5);
}
.heatmap-cell.out { .heatmap-cell.out {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
+6
View File
@@ -99,6 +99,12 @@ export const AppService = {
if (date !== undefined && this.state.currentFilterDate !== date) { if (date !== undefined && this.state.currentFilterDate !== date) {
this.state.currentFilterDate = date; this.state.currentFilterDate = date;
changed = true; changed = true;
// UI 동기화
CalendarManager.setSelectedDate(date);
if (HeatmapManager.setSelectedDate) {
HeatmapManager.setSelectedDate(date);
}
} }
if (query !== undefined && this.state.currentSearchQuery !== query) { if (query !== undefined && this.state.currentSearchQuery !== query) {
this.state.currentSearchQuery = query; this.state.currentSearchQuery = query;
+2 -1
View File
@@ -18,7 +18,8 @@ export const API = {
async fetchMemos(filters = {}) { async fetchMemos(filters = {}) {
const { limit = 20, offset = 0, group = 'all', query = '' } = filters; const { limit = 20, offset = 0, group = 'all', query = '' } = filters;
const params = new URLSearchParams({ limit, offset, group, query }); const date = filters.date || ''; // null이나 undefined를 빈 문자열로 변환
const params = new URLSearchParams({ limit, offset, group, query, date });
return await this.request(`/api/memos?${params.toString()}`); return await this.request(`/api/memos?${params.toString()}`);
}, },
async fetchHeatmapData(days = 365) { async fetchHeatmapData(days = 365) {
+5
View File
@@ -34,6 +34,11 @@ export const CalendarManager = {
this.render(); this.render();
}, },
setSelectedDate(date) {
this.selectedDate = date;
this.render();
},
bindEvents() { bindEvents() {
const header = document.getElementById('calendarHeader'); const header = document.getElementById('calendarHeader');
if (header) { if (header) {
+26 -2
View File
@@ -8,9 +8,13 @@ export const HeatmapManager = {
container: null, container: null,
data: [], // [{date: 'YYYY-MM-DD', count: N}, ...] data: [], // [{date: 'YYYY-MM-DD', count: N}, ...]
currentRange: 365, // 기본 365일 currentRange: 365, // 기본 365일
selectedDate: null,
onDateSelect: null,
init(containerId) { init(containerId, onDateSelect) {
this.container = document.getElementById(containerId); this.container = document.getElementById(containerId);
this.onDateSelect = onDateSelect;
if (!this.container) { if (!this.container) {
console.warn('[Heatmap] Container not found:', containerId); console.warn('[Heatmap] Container not found:', containerId);
return; return;
@@ -23,6 +27,11 @@ export const HeatmapManager = {
} }
}, },
setSelectedDate(date) {
this.selectedDate = date;
this.render();
},
/** /**
* 데이터를 서버에서 가져와 렌더링합니다. * 데이터를 서버에서 가져와 렌더링합니다.
*/ */
@@ -95,13 +104,14 @@ export const HeatmapManager = {
const level = this.calculateLevel(count); const level = this.calculateLevel(count);
const isOutOfRange = currentDate < startDate || currentDate > today; const isOutOfRange = currentDate < startDate || currentDate > today;
const isSelected = this.selectedDate === dateStr;
const tooltip = I18nManager.t('tooltip_heatmap_stat') const tooltip = I18nManager.t('tooltip_heatmap_stat')
.replace('{date}', dateStr) .replace('{date}', dateStr)
.replace('{count}', count); .replace('{count}', count);
html += ` html += `
<div class="heatmap-cell ${isOutOfRange ? 'out' : `lvl-${level}`}" <div class="heatmap-cell ${isOutOfRange ? 'out' : `lvl-${level}`} ${isSelected ? 'selected' : ''}"
data-date="${dateStr}" data-date="${dateStr}"
data-count="${count}" data-count="${count}"
title="${tooltip}"> title="${tooltip}">
@@ -144,5 +154,19 @@ export const HeatmapManager = {
this.refresh(); this.refresh();
}; };
} }
// 날짜 셀 클릭 이벤트 추가
this.container.querySelectorAll('.heatmap-cell[data-date]').forEach(cell => {
cell.onclick = (e) => {
const date = cell.dataset.date;
if (this.selectedDate === date) {
this.selectedDate = null; // 해제
} else {
this.selectedDate = date; // 선택
}
this.render(); // 다시 그려서 선택 효과 표시
if (this.onDateSelect) this.onDateSelect(this.selectedDate);
};
});
} }
}; };