diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..359d6d4 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Spring +SPRING_PROFILES_ACTIVE=local +SERVER_PORT=8081 + +# Backend API +SAEROK_API_BASE_URL=http://localhost:8080 +SAEROK_API_PREFIX=/api/v1 + +# OAuth +KAKAO_CLIENT_ID=your-kakao-client-id +KAKAO_REDIRECT_URI=http://localhost:8081/auth/kakao/callback +APPLE_CLIENT_ID=your-apple-client-id +APPLE_REDIRECT_URI=http://localhost:8081/auth/apple/callback + +# Unsplash +UNSPLASH_ACCESS_KEY=your-unsplash-access-key +UNSPLASH_APP_NAME=your-unsplash-app-name diff --git a/.gitignore b/.gitignore index 3ee1300..7d80e4f 100644 --- a/.gitignore +++ b/.gitignore @@ -36,5 +36,32 @@ out/ ### VS Code ### .vscode/ +.codex/ + .env -context/ \ No newline at end of file +.env.local +.env.development +.env.test +.env.production +.env.*.local +!.env.example +context/ + +### OS ### +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes + +### Logs ### +logs/ +*.log +*.log.* + +### JVM ### +hs_err_pid* +replay_pid* + +### Spring Boot ### +/.spring-boot-devtools.properties diff --git a/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java index 1406844..cab2b87 100644 --- a/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java +++ b/src/main/java/apu/saerok_admin/infra/stat/StatMetric.java @@ -96,6 +96,22 @@ public enum StatMetric { false, Map.of(), false + ), + USER_SIGNUP_SOURCE_TOTAL( + "가입 경로별 누적 사용자", + "가입 경로(소셜 로그인 종류)별 누적 가입자 수", + MetricUnit.COUNT, + true, + Map.of(), + false + ), + USER_DEVICE_PLATFORM_TOTAL( + "기기 플랫폼별 누적 사용자", + "기기 플랫폼(iOS/Android/Web 등)별 누적 사용자 수", + MetricUnit.COUNT, + true, + Map.of(), + false ); private final String label; diff --git a/src/main/resources/static/js/service-insight.js b/src/main/resources/static/js/service-insight.js index 771d1e2..0c08f66 100644 --- a/src/main/resources/static/js/service-insight.js +++ b/src/main/resources/static/js/service-insight.js @@ -73,6 +73,8 @@ USER_DAU: 'USER_DAU', USER_WAU: 'USER_WAU', USER_MAU: 'USER_MAU', + USER_SIGNUP_SOURCE_TOTAL: 'USER_SIGNUP_SOURCE_TOTAL', + USER_DEVICE_PLATFORM_TOTAL: 'USER_DEVICE_PLATFORM_TOTAL', }; // (아래부터는 기존 로직 그대로입니다. 색상/그룹/플롯/툴팁/박스플롯 렌더링 등 전체 원본 유지) @@ -167,7 +169,8 @@ { key:'collection', name:'새록', metrics:[METRICS.TOTAL_COUNT, METRICS.PRIVATE_RATIO] }, { key:'user', name:'유저', metrics:[ METRICS.USER_COMPLETED_TOTAL, METRICS.USER_SIGNUP_DAILY, METRICS.USER_WITHDRAWAL_DAILY, - METRICS.USER_DAU, METRICS.USER_WAU, METRICS.USER_MAU + METRICS.USER_DAU, METRICS.USER_WAU, METRICS.USER_MAU, + METRICS.USER_SIGNUP_SOURCE_TOTAL, METRICS.USER_DEVICE_PLATFORM_TOTAL ] }, { key:'id', name:'동정 요청', metrics:[METRICS.PENDING_COUNT, METRICS.RESOLVED_COUNT, METRICS.RESOLUTION_STATS] }, ]; @@ -683,6 +686,50 @@ groupSet.add(id); changed = true; } + } else if (opt.multiSeries) { + // 일반 다중 시리즈: 컴포넌트별 라인 렌더링 + const series = seriesMap.get(metric); + const comps = Array.isArray(series?.components) ? series.components : []; + const labelsMap = (componentLabels[metric] && typeof componentLabels[metric] === 'object') ? componentLabels[metric] : {}; + const unit = String(opt.unit || '').toUpperCase(); + const yAxis = axisByUnit(unit); + + comps.forEach(comp => { + if (!comp || !comp.key) return; + const compKey = comp.key; + const id = datasetIdFor(metric, compKey); + if (p.datasets.has(id)) return; + + const compColor = colorForMetric(id); + const compLabel = labelsMap[compKey] || compKey; + const points = (Array.isArray(comp.points) ? comp.points : []) + .map(pt => { + const x = toDateKST(pt.date); + const y = normalizeValue(pt.value, unit); + return (x && y != null) ? { x, y } : null; + }) + .filter(Boolean); + + const ds = { + _id: id, + label: `${opt.label || metric} (${compLabel})`, + data: points, + parsing: { xAxisKey: 'x', yAxisKey: 'y' }, + borderColor: compColor, + backgroundColor: compColor, + tension: 0.25, + pointRadius: 0, + pointHoverRadius: 4, + fill: false, + spanGaps: true, + yAxisID: yAxis, + _saUnit: unit + }; + p.chart.data.datasets.push(ds); + p.datasets.add(id); + groupSet.add(id); + changed = true; + }); } else { const unit = String(optionMap.get(metric)?.unit || '').toUpperCase(); const s = seriesMap.get(metric);