@clazic/urban 0.2.5 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,231 @@
1
+ import { getJson } from "../api.js";
2
+ import { qs, escapeHtml } from "../dom.js";
3
+ import { renderStat } from "../components/stat-cards.js";
4
+ import { renderPipelineBar } from "../components/pipeline-strip.js";
5
+ import { openFileViewer } from "../components/file-viewer.js";
6
+ import { store } from "../store.js";
7
+ import { subscribe } from "../sse.js";
8
+
9
+ let _unsubPipelineStats = null;
10
+
11
+ const SOURCE_COLORS = {
12
+ prism: "oklch(58% 0.14 255)",
13
+ nanet: "oklch(65% 0.14 50)",
14
+ 국토연구원: "oklch(62% 0.14 145)",
15
+ "lh연구원": "oklch(62% 0.12 20)",
16
+ };
17
+
18
+ function relativeTime(dateStr) {
19
+ if (!dateStr) return "";
20
+ const diff = Date.now() - new Date(dateStr).getTime();
21
+ const mins = Math.floor(diff / 60000);
22
+ if (mins < 60) return `${mins}분 전`;
23
+ const hours = Math.floor(mins / 60);
24
+ if (hours < 24) return `${hours}시간 전`;
25
+ return `${Math.floor(hours / 24)}일 전`;
26
+ }
27
+
28
+ function sourceChipClass(source) {
29
+ const s = (source || "").toLowerCase();
30
+ if (s === "prism") return "prism";
31
+ if (s === "nanet") return "nanet";
32
+ return "";
33
+ }
34
+
35
+ export async function mount() {
36
+ const eyebrow = document.getElementById("home-eyebrow");
37
+ if (eyebrow) {
38
+ const now = new Date();
39
+ const days = ["일요일", "월요일", "화요일", "수요일", "목요일", "금요일", "토요일"];
40
+ const y = now.getFullYear(), m = now.getMonth() + 1, d = now.getDate();
41
+ const wd = days[now.getDay()];
42
+ eyebrow.textContent = `🗓️ 오늘의 개요 · ${y}년 ${m}월 ${d}일 (${wd})`;
43
+ }
44
+ document.querySelector('[data-action="refresh-home"]')?.addEventListener("click", loadHome);
45
+ await loadHome();
46
+
47
+ _unsubPipelineStats = subscribe("pipeline_stats", (d) => {
48
+ if (!d?.byStatus) return;
49
+ applyPipelineStats(d.byStatus, d.byStatus.failed ?? null);
50
+ });
51
+ }
52
+
53
+ export async function unmount() {
54
+ _unsubPipelineStats?.();
55
+ _unsubPipelineStats = null;
56
+ }
57
+
58
+ function applyPipelineStats(b, failCount) {
59
+ // "다운로드 대기" = 승인 대기 + 큐 등록 단계만
60
+ renderStat("stat-pending", (b.waiting || 0) + (b.queued || 0));
61
+ // "처리 중" = 다운로드 이후 모든 활성 단계
62
+ renderStat("stat-queue", (b.downloading || 0) + (b.converting || 0) +
63
+ (b.extracting || 0) + (b.graph || 0) + (b.wiki || 0));
64
+ if (failCount != null) renderStat("stat-failures", failCount);
65
+ renderPipelineBar(qs("#home-pipeline"), b, failCount ?? b.failed ?? 0);
66
+ }
67
+
68
+ async function loadHome() {
69
+ try {
70
+ const [stats, health, failures, pipelineStats] = await Promise.all([
71
+ getJson("/api/stats"),
72
+ getJson("/api/health"),
73
+ getJson("/api/failures/count"),
74
+ getJson("/api/pipeline/stats"),
75
+ ]);
76
+
77
+ renderStat("stat-total", stats.total_reports);
78
+ const failureCount = failures.count || 0;
79
+ applyPipelineStats(pipelineStats.byStatus || {}, failureCount);
80
+
81
+ const weekDelta = qs("#stat-week-delta");
82
+ if (weekDelta && stats.week_collected != null) {
83
+ weekDelta.textContent = `↑${stats.week_collected}`;
84
+ }
85
+
86
+ const db = qs("#db-status");
87
+ const llm = qs("#llm-status");
88
+ const recent = qs("#recent-reports");
89
+ const failBanner = qs("#failures-banner");
90
+ const failCount = qs("#failures-count");
91
+ const homeSse = qs("#home-sse-status");
92
+
93
+ if (db) db.textContent = health.db?.ok ? `${health.db.size_mb}MB` : "오류";
94
+ if (llm) llm.textContent = `${health.llm_cache?.entries ?? 0} · 적중률 ${health.llm_cache?.hit_rate_pct ?? 0}%`;
95
+
96
+ const diskEl = qs("#disk-status");
97
+ const uptimeEl = qs("#uptime-status");
98
+ if (diskEl) diskEl.textContent = health.disk ? `${health.disk.free_gb}GB 여유` : "-";
99
+ if (uptimeEl && health.uptime_s != null) {
100
+ const h = Math.floor(health.uptime_s / 3600);
101
+ const m = Math.floor((health.uptime_s % 3600) / 60);
102
+ uptimeEl.textContent = h > 0 ? `${h}h ${m}m` : `${m}m`;
103
+ }
104
+
105
+ const footerDot = document.getElementById("footer-dot");
106
+ const footerStatus = document.getElementById("footer-status");
107
+ const footerUptime = document.getElementById("footer-uptime");
108
+ if (footerDot) footerDot.className = `dot${health.status === "ok" ? "" : health.status === "degraded" ? " warn" : " err"}`;
109
+ if (footerStatus) footerStatus.textContent = health.status === "ok" ? "LIVE" : health.status === "degraded" ? "DEGRADED" : "ERROR";
110
+ if (footerUptime && health.uptime_s != null) {
111
+ const h = Math.floor(health.uptime_s / 3600);
112
+ const m = Math.floor((health.uptime_s % 3600) / 60);
113
+ footerUptime.textContent = `uptime ${h > 0 ? `${h}h ${m}m` : `${m}m`}`;
114
+ }
115
+
116
+ if (recent) {
117
+ recent.onclick = (e) => {
118
+ const row = e.target.closest('.home-recent-row');
119
+ if (!row) return;
120
+ const clickedT2 = e.target.closest('.home-recent-t2');
121
+ const clickedT3 = e.target.closest('.home-recent-t3');
122
+ if (clickedT2) {
123
+ openFileViewer(row.dataset.hash, row.dataset.rawPath, null);
124
+ } else if (clickedT3) {
125
+ openFileViewer(row.dataset.hash, null, row.dataset.mdPath);
126
+ } else {
127
+ openFileViewer(row.dataset.hash, row.dataset.rawPath, row.dataset.mdPath);
128
+ }
129
+ };
130
+ recent.innerHTML = (stats.recent_reports || []).map((item) => {
131
+ const srcClass = sourceChipClass(item.source);
132
+ const time = relativeTime(item.created_at);
133
+ const rawFile = (item.file_path || "").split(/[\\/]/).pop() || null;
134
+ const mdFile = (item.md_path || "").split(/[\\/]/).pop() || null;
135
+ const metaParts = [
136
+ item.year ? `${item.year}년` : null,
137
+ rawFile || null,
138
+ ].filter(Boolean);
139
+ const effectiveStatus = (item.status === 'indexed' && !item.md_path) ? 'partial' : item.status;
140
+ const statusClass = effectiveStatus ? effectiveStatus.toLowerCase() : "";
141
+ const statusLabel = effectiveStatus === 'partial' ? 'PARTIAL' : escapeHtml(item.status || '');
142
+ return `<div class="home-recent-row" data-hash="${escapeHtml(item.hash || "")}" data-raw-path="${escapeHtml(item.file_path || "")}" data-md-path="${escapeHtml(item.md_path || "")}">
143
+ <div class="home-recent-body">
144
+ <div class="home-recent-t1">${escapeHtml(item.title || "제목 없음")}</div>
145
+ ${metaParts.length ? `<div class="home-recent-t2">${metaParts.map(escapeHtml).join(" · ")}</div>` : ""}
146
+ ${mdFile ? `<div class="home-recent-t3"><i class="fa-solid fa-arrow-right-long"></i> ${escapeHtml(mdFile)}</div>` : ""}
147
+ </div>
148
+ <div class="home-recent-badges">
149
+ ${item.source ? `<span class="home-src-badge ${srcClass}">${escapeHtml(item.source.toUpperCase())}</span>` : ""}
150
+ ${effectiveStatus ? `<span class="home-status-badge ${statusClass}">${statusLabel}</span>` : ""}
151
+ </div>
152
+ ${time ? `<span class="home-recent-time mono">${time}</span>` : ""}
153
+ <span class="home-recent-chev"><i class="fa-solid fa-chevron-right"></i></span>
154
+ </div>`;
155
+ }).join("") || '<div class="empty-state">최근 보고서 없음</div>';
156
+ }
157
+
158
+ renderSourceDistribution(stats.recent_reports || [], stats.total_by_source || {});
159
+ renderWeeklyChart(stats.daily_counts || []);
160
+
161
+ if (failBanner && failCount) {
162
+ failCount.textContent = failureCount;
163
+ failBanner.hidden = !(failureCount > 0);
164
+ }
165
+ if (homeSse) homeSse.textContent = store.sseConnected ? "정상" : "끊김";
166
+ } catch (error) {
167
+ console.error("home load failed", error);
168
+ }
169
+ }
170
+
171
+ function renderSourceDistribution(recentReports, totalBySource) {
172
+ const el = qs("#source-distribution");
173
+ if (!el) return;
174
+
175
+ let sources = {};
176
+ if (Object.keys(totalBySource).length > 0) {
177
+ sources = totalBySource;
178
+ } else {
179
+ for (const item of recentReports) {
180
+ const s = item.source || "기타";
181
+ sources[s] = (sources[s] || 0) + 1;
182
+ }
183
+ }
184
+
185
+ const total = Object.values(sources).reduce((a, b) => a + b, 0) || 1;
186
+ const sorted = Object.entries(sources).sort((a, b) => b[1] - a[1]);
187
+
188
+ const colors = ["oklch(58% 0.14 255)", "oklch(55% 0.16 145)", "oklch(62% 0.15 50)", "oklch(58% 0.12 20)", "oklch(55% 0.08 280)"];
189
+
190
+ el.innerHTML = sorted.map(([name, count], i) => {
191
+ const pct = Math.round((count / total) * 100);
192
+ const color = SOURCE_COLORS[name.toLowerCase()] || colors[i % colors.length];
193
+ return `<div class="home-src-row">
194
+ <span class="home-src-sw" style="background:${color}"></span>
195
+ <span class="home-src-n">${escapeHtml(name)}</span>
196
+ <span class="home-src-c mono">${count.toLocaleString()}</span>
197
+ <span class="home-src-pct">${pct}%</span>
198
+ </div>`;
199
+ }).join("") || '<div class="empty-state">데이터 없음</div>';
200
+ }
201
+
202
+ function renderWeeklyChart(dailyCounts) {
203
+ const el = qs("#home-chart");
204
+ if (!el) return;
205
+
206
+ const dayNames = ["일", "월", "화", "수", "목", "금", "토"];
207
+ const today = new Date();
208
+ const days = Array.from({ length: 7 }, (_, i) => {
209
+ const d = new Date(today);
210
+ d.setDate(today.getDate() - (6 - i));
211
+ const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
212
+ return { key, label: dayNames[d.getDay()], count: 0 };
213
+ });
214
+
215
+ for (const { day, cnt } of dailyCounts) {
216
+ const slot = days.find((d) => d.key === day);
217
+ if (slot) slot.count = cnt;
218
+ }
219
+
220
+ const max = Math.max(...days.map((d) => d.count), 1);
221
+
222
+ el.innerHTML = days.map((d) => {
223
+ const h = Math.max(Math.round((d.count / max) * 100), 4);
224
+ return `<div class="home-chart-col">
225
+ <div class="home-chart-bar" style="height:${h}%" title="${d.count}건"></div>
226
+ <span class="home-chart-lbl">${d.label}</span>
227
+ </div>`;
228
+ }).join("");
229
+ }
230
+
231
+ export default { mount, unmount };