diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml new file mode 100644 index 0000000..13ab091 --- /dev/null +++ b/.github/workflows/code-style.yml @@ -0,0 +1,80 @@ +name: Code Style Check + +on: + pull_request: + branches: + - develop + - main + push: + branches: + - develop + - main + +jobs: + backend-style: + name: Backend Ruff + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install backend dev dependencies + working-directory: backend + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements-dev.txt + + - name: Run Ruff lint + working-directory: backend + run: python -m ruff check . + + - name: Run Ruff format check + working-directory: backend + run: python -m ruff format --check . + + frontend-style: + name: Frontend Prettier + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + + - name: Run Prettier check + working-directory: frontend + run: npm run format:check + + embedded-style: + name: Embedded clang-format + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install clang-format + run: | + sudo apt-get update + sudo apt-get install -y clang-format + + - name: Run clang-format check + run: | + git ls-files 'embedded/ssccam/**/*.c' 'embedded/ssccam/**/*.h' \ + | xargs clang-format --dry-run -Werror diff --git a/.gitignore b/.gitignore index 07c32e8..4c264db 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ uploaded_images/ .devcontainer/ .clangd Kconfig.projbuild +node_modules/ # ======================================================================== # Python diff --git a/backend/main.py b/backend/main.py index 97c9f86..65266b5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,18 +1,19 @@ -from fastapi import FastAPI, File, UploadFile -from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles import os +import time +from contextlib import asynccontextmanager from datetime import datetime +from decimal import Decimal +from typing import Annotated + import cv2 import numpy as np -from ultralytics import YOLO -import time -from decimal import Decimal -import psycopg from dotenv import load_dotenv -from contextlib import asynccontextmanager +from fastapi import FastAPI, File, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles from psycopg_pool import ConnectionPool +from ultralytics import YOLO load_dotenv() @@ -20,13 +21,14 @@ db_pool = None + @asynccontextmanager -async def lifespan(app:FastAPI): +async def lifespan(app: FastAPI): global db_pool if not DATABASE_URL: raise RuntimeError("DATABASE_URL is not set") - + db_pool = ConnectionPool( conninfo=DATABASE_URL, min_size=1, @@ -39,6 +41,7 @@ async def lifespan(app:FastAPI): finally: db_pool.close() + app = FastAPI(title="SSCCounter API", version="2.1", lifespan=lifespan) app.mount("/data", StaticFiles(directory="../frontend/data"), name="data") @@ -48,7 +51,8 @@ async def lifespan(app:FastAPI): # --------------------------------------------- app.add_middleware( CORSMiddleware, - allow_origins=["*"], # 모든 도메인 허용 (개발 단계에서는 편리하지만, 배포 시에는 보안을 위해 특정 도메인만 허용하는 것이 좋습니다) + # 개발 단계에서는 모든 도메인을 허용합니다. + allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], @@ -59,17 +63,23 @@ async def lifespan(app:FastAPI): model = YOLO("yolov8n.pt") print("YOLO model loaded successfully.") + def infer_people_count(frame): - results = model.predict(source=frame, conf=0.3, classes=[0], imgsz=416, verbose=False) + results = model.predict( + source=frame, conf=0.3, classes=[0], imgsz=416, verbose=False + ) return (results[0].boxes.cls == 0).sum().item() + # --------------------------------------------- + def get_db_connection(): if db_pool is None: raise RuntimeError("Database connection pool is not initialized") return db_pool.connection() + def to_float(value): if value is None: return 0.0 @@ -77,51 +87,66 @@ def to_float(value): return float(value) return value + def get_current_status_from_db(): with get_db_connection() as conn: with conn.cursor() as cur: cur.execute( """ - SELECT current_people_count, updated_at, today_max_people_count, today_avg_people_count + SELECT + current_people_count, + updated_at, + today_max_people_count, + today_avg_people_count FROM dashboard.current_status WHERE id = 1 """ ) row = cur.fetchone() - + if row is None: return { "count": 0, "updated_at": None, "today_max_count": 0, - "today_avg_count": 0.0 + "today_avg_count": 0.0, } - + return { "count": row[0], "updated_at": row[1].isoformat() if row[1] else None, "today_max_count": row[2], - "today_avg_count": to_float(row[3]) + "today_avg_count": to_float(row[3]), } -def save_failed_inference(error_message: str, trigger_type: str = "scheduled", inference_time_ms: int | None = None): + +def save_failed_inference( + error_message: str, + trigger_type: str = "scheduled", + inference_time_ms: int | None = None, +): with get_db_connection() as conn: with conn.cursor() as cur: cur.execute( """ - INSERT INTO admin.capture_logs (people_count, status, trigger_type, inference_time_ms, error_message) - VALUES (%s, %s, %s, %s, %s) - """, - ( - None, - "failed", + INSERT INTO admin.capture_logs ( + people_count, + status, trigger_type, inference_time_ms, error_message ) + VALUES (%s, %s, %s, %s, %s) + """, + (None, "failed", trigger_type, inference_time_ms, error_message), ) -def save_successful_inference(people_count: int, trigger_type: str = "scheduled", inference_time_ms: int | None = None): + +def save_successful_inference( + people_count: int, + trigger_type: str = "scheduled", + inference_time_ms: int | None = None, +): """ YOLO 추론 성공 결과를 admin raw log와 dashboard summary tables에 반영합니다. @@ -137,16 +162,16 @@ def save_successful_inference(people_count: int, trigger_type: str = "scheduled" # 1. 관리자용 raw log 저장 cur.execute( """ - INSERT INTO admin.capture_logs (people_count, status, trigger_type, inference_time_ms) - VALUES (%s, %s, %s, %s) - RETURNING measured_at - """, - ( + INSERT INTO admin.capture_logs ( people_count, - "success", + status, trigger_type, inference_time_ms ) + VALUES (%s, %s, %s, %s) + RETURNING measured_at + """, + (people_count, "success", trigger_type, inference_time_ms), ) measured_at = cur.fetchone()[0] @@ -156,7 +181,10 @@ def save_successful_inference(people_count: int, trigger_type: str = "scheduled" WITH today_summary AS ( SELECT COALESCE(MAX(people_count), 0) AS today_max_people_count, - COALESCE(ROUND(AVG(people_count)::numeric, 2), 0) AS today_avg_people_count + COALESCE( + ROUND(AVG(people_count)::numeric, 2), + 0 + ) AS today_avg_people_count FROM admin.capture_logs WHERE status = 'success' AND measured_at >= date_trunc('day', now()) @@ -183,10 +211,7 @@ def save_successful_inference(people_count: int, trigger_type: str = "scheduled" today_max_people_count = EXCLUDED.today_max_people_count, today_avg_people_count = EXCLUDED.today_avg_people_count """, - ( - people_count, - measured_at - ) + (people_count, measured_at), ) # 3. dashboard.today_hourly_stats 갱신 @@ -254,7 +279,10 @@ def save_successful_inference(people_count: int, trigger_type: str = "scheduled" summary AS ( SELECT current_date AS stat_date, - COALESCE(ROUND(AVG(people_count)::numeric, 2), 0) AS avg_people_count, + COALESCE( + ROUND(AVG(people_count)::numeric, 2), + 0 + ) AS avg_people_count, COALESCE(MAX(people_count), 0) AS max_people_count, COALESCE(MIN(people_count), 0) AS min_people_count, COUNT(*) AS sample_count @@ -348,6 +376,7 @@ def save_successful_inference(people_count: int, trigger_type: str = "scheduled" """ ) + def get_today_stats_from_db(): with get_db_connection() as conn: with conn.cursor() as cur: @@ -363,29 +392,18 @@ def get_today_stats_from_db(): ) rows = cur.fetchall() - return [ - { - "hour": row[0], - "count": row[1] - } - for row in rows - ] + return [{"hour": row[0], "count": row[1]} for row in rows] + def get_weekly_stats_from_db(): - day_key_map = { - 1: "mon", - 2: "tue", - 3: "wed", - 4: "thu", - 5: "fri" - } + day_key_map = {1: "mon", 2: "tue", 3: "wed", 4: "thu", 5: "fri"} weekly_stats = { "mon": [0] * 14, "tue": [0] * 14, "wed": [0] * 14, "thu": [0] * 14, - "fri": [0] * 14 + "fri": [0] * 14, } with get_db_connection() as conn: @@ -407,17 +425,19 @@ def get_weekly_stats_from_db(): day_key = day_key_map.get(weekday) if not day_key: continue - + index = hour - 9 if 0 <= index < 14: weekly_stats[day_key][index] = to_float(avg_people_count) return weekly_stats + # --------------------------------------------- SHOULD_CAPTURE = False + @app.post("/api/v2/test/trigger") async def trigger_capture(): """ @@ -427,6 +447,7 @@ async def trigger_capture(): SHOULD_CAPTURE = True return {"status": "success", "message": "Capture command issued to ESP32."} + @app.get("/api/v2/device/command") async def get_device_command(): """ @@ -434,12 +455,14 @@ async def get_device_command(): """ global SHOULD_CAPTURE if SHOULD_CAPTURE: - SHOULD_CAPTURE = False # 명령을 전달했으므로 다시 대기 상태로 변경 + SHOULD_CAPTURE = False # 명령을 전달했으므로 다시 대기 상태로 변경 return {"command": "capture"} return {"command": "idle"} + # -------------------------------------------- + @app.get("/") async def root(): """ @@ -447,6 +470,7 @@ async def root(): """ return FileResponse("../frontend/index.html") + @app.get("/api/v2/count/current") async def get_current_count(): """ @@ -454,6 +478,7 @@ async def get_current_count(): """ return get_current_status_from_db() + @app.get("/api/v2/stats/today") async def get_today_stats(): """ @@ -461,6 +486,7 @@ async def get_today_stats(): """ return get_today_stats_from_db() + @app.get("/api/v2/stats/weekly") async def get_weekly_stats(): """ @@ -468,10 +494,12 @@ async def get_weekly_stats(): """ return get_weekly_stats_from_db() + # -------------------------------------------- + @app.post("/api/v2/device/upload") -async def upload_image(file: UploadFile = File(...)): +async def upload_image(file: Annotated[UploadFile, File(...)]): """ [ESP32용] ESP32-CAM이 업로드한 이미지를 서버 메모리에서만 처리합니다. @@ -498,31 +526,36 @@ async def upload_image(file: UploadFile = File(...)): save_failed_inference(error_message=error_message, trigger_type="scheduled") - return { - "status": "error", - "message": error_message - } + return {"status": "error", "message": error_message} inference_start = time.perf_counter() people_count = infer_people_count(frame) inference_time_ms = int((time.perf_counter() - inference_start) * 1000) - save_successful_inference(people_count=people_count, trigger_type="scheduled", inference_time_ms=inference_time_ms) + save_successful_inference( + people_count=people_count, + trigger_type="scheduled", + inference_time_ms=inference_time_ms, + ) - print(f"[{measured_at}] Inference Result: {people_count} people detected. Inference Time: {inference_time_ms} ms") + print( + f"[{measured_at}] Inference Result: " + f"{people_count} people detected. " + f"Inference Time: {inference_time_ms} ms" + ) return { "status": "success", "message": "Image processed successfully", - "detected_count": people_count + "detected_count": people_count, } except Exception as e: error_message = str(e) print(f"Error during upload/inference: {error_message}") - try: + try: save_failed_inference(error_message=error_message, trigger_type="scheduled") except Exception as db_error: print(f"Failed to save error log to DB: {str(db_error)}") - return {"status": "error", "message": error_message} \ No newline at end of file + return {"status": "error", "message": error_message} diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..804a5a1 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,18 @@ +[tool.ruff] +line-length = 88 +target-version = "py312" + +[tool.ruff.lint] +select = [ + "E", + "F", + "I", + "B", + "UP", +] +ignore = [] + +[tool.ruff.format] +quote-style = "double" +indent-style = "space" +line-ending = "auto" diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..af3ee57 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1 @@ +ruff diff --git a/docs/code-style.md b/docs/code-style.md new file mode 100644 index 0000000..dbe730a --- /dev/null +++ b/docs/code-style.md @@ -0,0 +1,64 @@ +## Code Style Check + +SSCCounter는 monorepo 구조로 FE, BE, Embedded 코드를 함께 관리합니다. +PR 생성 시 GitHub Actions에서 각 영역의 코드 스타일 검사를 자동으로 수행합니다. + +### Backend + +Backend는 Ruff를 사용합니다. + +```bash +cd backend +python -m pip install -r requirements-dev.txt +python -m ruff check . +python -m ruff format --check . +``` + +자동 수정: +```bash +python -m ruff check. --fix +python -m ruff format . +``` + +### Frontend + +Frontend는 Prettier를 사용합니다. +```bash +cd frontend +npm install +npm run format:check +``` + +자동 수정: +``` +npm run format +``` + +### Embedded + +Embedded는 clang-format을 사용합니다. +```bash +git ls-files 'embedded/ssccam/**/*.c' 'embedded/ssccam/**/*.h' \ + | xargs clang-format --dry-run -Werror +``` + +자동 수정: +```bash +git ls-files 'embedded/ssccam/**/*.c' 'embedded/ssccam/**/*.h' \ + | xargs clang-format -i +``` + +### PR 전 확인 + +```bash +cd backend +python -m ruff check . +python -m ruff format --check . + +cd ../frontend +npm run format:check + +cd .. +git ls-files 'embedded/ssccam/**/*.c' 'embedded/ssccam/**/*.h' \ + | xargs clang-format --dry-run -Werror +``` \ No newline at end of file diff --git a/embedded/ssccam/.clang-format b/embedded/ssccam/.clang-format new file mode 100644 index 0000000..5292dab --- /dev/null +++ b/embedded/ssccam/.clang-format @@ -0,0 +1,10 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +TabWidth: 4 +UseTab: Never +ColumnLimit: 100 +BreakBeforeBraces: Attach +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +AllowShortFunctionsOnASingleLine: Empty +SortIncludes: false diff --git a/embedded/ssccam/main/camera_manager.c b/embedded/ssccam/main/camera_manager.c index 15909e8..cb3fbbf 100644 --- a/embedded/ssccam/main/camera_manager.c +++ b/embedded/ssccam/main/camera_manager.c @@ -43,10 +43,10 @@ esp_err_t camera_manager_init(void) { .xclk_freq_hz = 20000000, .ledc_timer = LEDC_TIMER_0, .ledc_channel = LEDC_CHANNEL_0, - .pixel_format = PIXFORMAT_JPEG, - .frame_size = FRAMESIZE_VGA, - .jpeg_quality = 12, - .fb_count = 2, + .pixel_format = PIXFORMAT_JPEG, + .frame_size = FRAMESIZE_VGA, + .jpeg_quality = 12, + .fb_count = 2, .grab_mode = CAMERA_GRAB_LATEST, }; @@ -55,7 +55,7 @@ esp_err_t camera_manager_init(void) { ESP_LOGE(TAG, "Camera Init Failed: 0x%x", err); return err; } - + ESP_LOGI(TAG, "Camera Init Success"); return ESP_OK; } \ No newline at end of file diff --git a/embedded/ssccam/main/main.c b/embedded/ssccam/main/main.c index acb8d44..4a4216d 100644 --- a/embedded/ssccam/main/main.c +++ b/embedded/ssccam/main/main.c @@ -8,24 +8,24 @@ #include "esp_camera.h" #include "esp_http_client.h" -#include "wifi_manager.h" // Wi-Fi 매니저 헤더 포함 -#include "camera_manager.h" // 카메라 매니저 헤더 포함 +#include "wifi_manager.h" // Wi-Fi 매니저 헤더 포함 +#include "camera_manager.h" // 카메라 매니저 헤더 포함 #include "sdkconfig.h" static const char *TAG = "SSCCam_Main"; -#define WIFI_SSID CONFIG_WIFI_SSID -#define WIFI_PASS CONFIG_WIFI_PASSWORD -#define SERVER_URL CONFIG_SERVER_URL -#define COMMAND_URL CONFIG_COMMAND_URL -#define MAXIMUM_RETRY 5 +#define WIFI_SSID CONFIG_WIFI_SSID +#define WIFI_PASS CONFIG_WIFI_PASSWORD +#define SERVER_URL CONFIG_SERVER_URL +#define COMMAND_URL CONFIG_COMMAND_URL +#define MAXIMUM_RETRY 5 // 서버로 이미지를 전송하는 함수 static esp_err_t send_image_to_server(camera_fb_t *fb) { esp_http_client_config_t config = { .url = SERVER_URL, .method = HTTP_METHOD_POST, - .timeout_ms = 5000, + .timeout_ms = 5000, }; esp_http_client_handle_t client = esp_http_client_init(&config); @@ -34,11 +34,11 @@ static esp_err_t send_image_to_server(camera_fb_t *fb) { snprintf(content_type, sizeof(content_type), "multipart/form-data; boundary=%s", boundary); esp_http_client_set_header(client, "Content-Type", content_type); - const char *body_start = + const char *body_start = "------ESP32BoundarySSCCounter\r\n" "Content-Disposition: form-data; name=\"file\"; filename=\"esp32_cam.jpg\"\r\n" "Content-Type: image/jpeg\r\n\r\n"; - + const char *body_end = "\r\n------ESP32BoundarySSCCounter--\r\n"; int content_length = strlen(body_start) + fb->len + strlen(body_end); @@ -59,8 +59,8 @@ void app_main(void) { // 1. NVS 초기화 esp_err_t ret = nvs_flash_init(); if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { - ESP_ERROR_CHECK(nvs_flash_erase()); - ret = nvs_flash_init(); + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); } ESP_ERROR_CHECK(ret); @@ -97,13 +97,13 @@ void app_main(void) { // .timeout_ms = 2000, // }; // esp_http_client_handle_t cmd_client = esp_http_client_init(&cmd_config); - + // char response_buffer[128] = {0}; // esp_err_t err = esp_http_client_open(cmd_client, 0); // if (err == ESP_OK) { // esp_http_client_fetch_headers(cmd_client); // esp_http_client_read(cmd_client, response_buffer, sizeof(response_buffer)); - + // // 서버 응답에 "capture"라는 단어가 있으면 사진 촬영 및 전송! // if (strstr(response_buffer, "capture") != NULL) { // ESP_LOGI(TAG, "Capture command received from server!"); diff --git a/embedded/ssccam/main/wifi_manager.c b/embedded/ssccam/main/wifi_manager.c index fb7dfd4..a60369d 100644 --- a/embedded/ssccam/main/wifi_manager.c +++ b/embedded/ssccam/main/wifi_manager.c @@ -12,13 +12,14 @@ static const char *TAG = "WiFi_Mgr"; static EventGroupHandle_t s_wifi_event_group; #define WIFI_CONNECTED_BIT BIT0 -#define WIFI_FAIL_BIT BIT1 +#define WIFI_FAIL_BIT BIT1 static int s_retry_num = 0; static int s_max_retry = 0; // Wi-Fi 이벤트 핸들러 -static void event_handler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) { +static void event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, + void *event_data) { if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) { esp_wifi_connect(); } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) { @@ -31,7 +32,7 @@ static void event_handler(void* arg, esp_event_base_t event_base, int32_t event_ } ESP_LOGI(TAG, "Connect to the AP fail"); } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) { - ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data; + ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data; ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip)); s_retry_num = 0; xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT); @@ -52,13 +53,16 @@ esp_err_t wifi_init_sta(const char *ssid, const char *pass, int max_retry) { esp_event_handler_instance_t instance_any_id; esp_event_handler_instance_t instance_got_ip; - ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, NULL, &instance_any_id)); - ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &event_handler, NULL, &instance_got_ip)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &event_handler, NULL, &instance_any_id)); + ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, + &event_handler, NULL, &instance_got_ip)); wifi_config_t wifi_config = { - .sta = { - .threshold.authmode = WIFI_AUTH_WPA2_PSK, - }, + .sta = + { + .threshold.authmode = WIFI_AUTH_WPA2_PSK, + }, }; // 매개변수로 받은 SSID와 Pass를 구조체에 복사 strncpy((char *)wifi_config.sta.ssid, ssid, sizeof(wifi_config.sta.ssid)); @@ -70,7 +74,8 @@ esp_err_t wifi_init_sta(const char *ssid, const char *pass, int max_retry) { ESP_LOGI(TAG, "wifi_init_sta finished."); - EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, pdFALSE, pdFALSE, portMAX_DELAY); + EventBits_t bits = xEventGroupWaitBits(s_wifi_event_group, WIFI_CONNECTED_BIT | WIFI_FAIL_BIT, + pdFALSE, pdFALSE, portMAX_DELAY); if (bits & WIFI_CONNECTED_BIT) { ESP_LOGI(TAG, "Connected to AP SSID:%s", ssid); diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 0000000..ee3a689 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +build/ +coverage/ diff --git a/frontend/.prettierrc b/frontend/.prettierrc new file mode 100644 index 0000000..8d40c54 --- /dev/null +++ b/frontend/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "singleQuote": true, + "semi": true, + "trailingComma": "es5", + "bracketSpacing": true, + "htmlWhitespaceSensitivity": "css" +} diff --git a/frontend/css/base.css b/frontend/css/base.css index fdb75ce..f43f9e8 100644 --- a/frontend/css/base.css +++ b/frontend/css/base.css @@ -1,33 +1,75 @@ /* base.css */ -* { margin: 0; padding: 0; box-sizing: border-box; } -body { font-family: 'Inter', sans-serif; } +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} +body { + font-family: 'Inter', sans-serif; +} -::-webkit-scrollbar { width: 6px; } -::-webkit-scrollbar-track { background: #f1f5f9; } -::-webkit-scrollbar-thumb { background: #c7d2fe; border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: #a5b4fc; } +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: #f1f5f9; +} +::-webkit-scrollbar-thumb { + background: #c7d2fe; + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: #a5b4fc; +} -.page { display: none; } -.page.active { display: block; } +.page { + display: none; +} +.page.active { + display: block; +} /* Animations */ @keyframes pulseRing { - 0% { transform: scale(1); opacity: 0.6; } - 100% { transform: scale(1.8); opacity: 0; } + 0% { + transform: scale(1); + opacity: 0.6; + } + 100% { + transform: scale(1.8); + opacity: 0; + } } @keyframes pulseDot { - 0% { transform: scale(1); } - 50% { transform: scale(1.1); } - 100% { transform: scale(1); } + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } } @keyframes fadeIn { - from { opacity: 0; transform: translateY(12px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } } @keyframes statusBlink { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} \ No newline at end of file + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} diff --git a/frontend/css/components.css b/frontend/css/components.css index 3f6fd76..d183bd8 100644 --- a/frontend/css/components.css +++ b/frontend/css/components.css @@ -10,13 +10,23 @@ filter: drop-shadow(0 4px 20px rgba(99, 102, 241, 0.3)); } -.pulse-ring { animation: pulseRing 2s ease-out infinite; } -.pulse-dot { animation: pulseDot 2s ease-out infinite; } -.fade-in { animation: fadeIn 0.4s ease-out; } -.status-badge { animation: statusBlink 3s ease-in-out infinite; } +.pulse-ring { + animation: pulseRing 2s ease-out infinite; +} +.pulse-dot { + animation: pulseDot 2s ease-out infinite; +} +.fade-in { + animation: fadeIn 0.4s ease-out; +} +.status-badge { + animation: statusBlink 3s ease-in-out infinite; +} .card-hover { - transition: transform 0.25s ease, box-shadow 0.25s ease; + transition: + transform 0.25s ease, + box-shadow 0.25s ease; } .card-hover:hover { transform: translateY(-4px); @@ -28,5 +38,9 @@ border-bottom: 2px solid #6366f1; } -#dark-toggle { transition: transform 0.3s ease; } -#dark-toggle:active { transform: rotate(25deg); } \ No newline at end of file +#dark-toggle { + transition: transform 0.3s ease; +} +#dark-toggle:active { + transform: rotate(25deg); +} diff --git a/frontend/css/darkmode.css b/frontend/css/darkmode.css index 2cd5c3f..bdd810c 100644 --- a/frontend/css/darkmode.css +++ b/frontend/css/darkmode.css @@ -1,57 +1,148 @@ /* darkmode.css */ -.dark body { background-color: #0f172a; } -.dark { color-scheme: dark; } +.dark body { + background-color: #0f172a; +} +.dark { + color-scheme: dark; +} -.dark header { background: rgba(15, 23, 42, 0.85); border-bottom-color: #1e293b; } +.dark header { + background: rgba(15, 23, 42, 0.85); + border-bottom-color: #1e293b; +} .dark .counter-number { background: linear-gradient(135deg, #818cf8, #a78bfa, #c4b5fd); -webkit-background-clip: text; background-clip: text; filter: drop-shadow(0 4px 30px rgba(129, 140, 248, 0.4)); } -.dark .pulse-ring { background: rgba(99, 102, 241, 0.15); } +.dark .pulse-ring { + background: rgba(99, 102, 241, 0.15); +} /* Tailwind overriding in dark mode */ -.dark .bg-white { background-color: #1e293b !important; } -.dark .bg-gray-50 { background-color: #0f172a !important; } -.dark .border-gray-100, .dark .border-gray-200 { border-color: #334155 !important; } -.dark .text-gray-900 { color: #f1f5f9 !important; } -.dark .text-gray-800 { color: #e2e8f0 !important; } -.dark .text-gray-500, .dark .text-gray-600 { color: #94a3b8 !important; } -.dark .text-gray-400 { color: #64748b !important; } +.dark .bg-white { + background-color: #1e293b !important; +} +.dark .bg-gray-50 { + background-color: #0f172a !important; +} +.dark .border-gray-100, +.dark .border-gray-200 { + border-color: #334155 !important; +} +.dark .text-gray-900 { + color: #f1f5f9 !important; +} +.dark .text-gray-800 { + color: #e2e8f0 !important; +} +.dark .text-gray-500, +.dark .text-gray-600 { + color: #94a3b8 !important; +} +.dark .text-gray-400 { + color: #64748b !important; +} /* Custom Background colors */ -.dark .bg-blue-50 { background-color: rgba(59, 130, 246, 0.15) !important; } -.dark .bg-amber-50 { background-color: rgba(245, 158, 11, 0.15) !important; } -.dark .bg-emerald-50 { background-color: rgba(16, 185, 129, 0.15) !important; } -.dark .bg-violet-50 { background-color: rgba(139, 92, 246, 0.15) !important; } -.dark .bg-brand-50 { background-color: rgba(99, 102, 241, 0.15) !important; } +.dark .bg-blue-50 { + background-color: rgba(59, 130, 246, 0.15) !important; +} +.dark .bg-amber-50 { + background-color: rgba(245, 158, 11, 0.15) !important; +} +.dark .bg-emerald-50 { + background-color: rgba(16, 185, 129, 0.15) !important; +} +.dark .bg-violet-50 { + background-color: rgba(139, 92, 246, 0.15) !important; +} +.dark .bg-brand-50 { + background-color: rgba(99, 102, 241, 0.15) !important; +} /* Shadow overriding */ -.dark .shadow-xl { box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important; } -.dark .shadow-sm { box-shadow: none !important; } -.dark .shadow-lg { box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3) !important; } -.dark .card-hover:hover { box-shadow: 0 12px 40px rgba(99, 102, 241, 0.2); } +.dark .shadow-xl { + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important; +} +.dark .shadow-sm { + box-shadow: none !important; +} +.dark .shadow-lg { + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3) !important; +} +.dark .card-hover:hover { + box-shadow: 0 12px 40px rgba(99, 102, 241, 0.2); +} /* Custom Text and Border Colors */ -.dark .nav-btn.active { color: #818cf8; border-bottom-color: #818cf8; } -.dark .nav-btn { color: #64748b; } -.dark .day-btn:not(.bg-brand-600) { background-color: #1e293b !important; color: #94a3b8 !important; border-color: #334155 !important; } -.dark footer { background-color: #1e293b !important; border-top-color: #334155 !important; } -.dark #current-time { background: rgba(255,255,255,0.08); } -.dark .dev-card-header { background: linear-gradient(to bottom right, #4338ca, #3730a3) !important; } -.dark .home-hero-gradient { background: linear-gradient(to bottom right, #4338ca, #3730a3, #312e81) !important; } -.dark .text-emerald-600, .dark .text-emerald-500 { color: #34d399 !important; } -.dark .text-brand-600 { color: #a5b4fc !important; } -.dark .text-brand-700, .dark .text-brand-200 { color: #c7d2fe !important; } -.dark .text-blue-500 { color: #60a5fa !important; } -.dark .text-amber-500 { color: #fbbf24 !important; } -.dark .text-violet-500 { color: #a78bfa !important; } -.dark .text-brand-500 { color: #818cf8 !important; } -.dark .text-brand-100 { color: #e0e7ff !important; } -.dark .bg-white\/10 { background: rgba(255,255,255,0.06) !important; } -.dark .bg-white\/80 { background: rgba(15, 23, 42, 0.85) !important; } -.dark .border-white { border-color: #e2e8f0 !important; } -.dark ::-webkit-scrollbar-track { background: #1e293b; } -.dark ::-webkit-scrollbar-thumb { background: #4338ca; } -.dark ::-webkit-scrollbar-thumb:hover { background: #6366f1; } \ No newline at end of file +.dark .nav-btn.active { + color: #818cf8; + border-bottom-color: #818cf8; +} +.dark .nav-btn { + color: #64748b; +} +.dark .day-btn:not(.bg-brand-600) { + background-color: #1e293b !important; + color: #94a3b8 !important; + border-color: #334155 !important; +} +.dark footer { + background-color: #1e293b !important; + border-top-color: #334155 !important; +} +.dark #current-time { + background: rgba(255, 255, 255, 0.08); +} +.dark .dev-card-header { + background: linear-gradient(to bottom right, #4338ca, #3730a3) !important; +} +.dark .home-hero-gradient { + background: linear-gradient(to bottom right, #4338ca, #3730a3, #312e81) !important; +} +.dark .text-emerald-600, +.dark .text-emerald-500 { + color: #34d399 !important; +} +.dark .text-brand-600 { + color: #a5b4fc !important; +} +.dark .text-brand-700, +.dark .text-brand-200 { + color: #c7d2fe !important; +} +.dark .text-blue-500 { + color: #60a5fa !important; +} +.dark .text-amber-500 { + color: #fbbf24 !important; +} +.dark .text-violet-500 { + color: #a78bfa !important; +} +.dark .text-brand-500 { + color: #818cf8 !important; +} +.dark .text-brand-100 { + color: #e0e7ff !important; +} +.dark .bg-white\/10 { + background: rgba(255, 255, 255, 0.06) !important; +} +.dark .bg-white\/80 { + background: rgba(15, 23, 42, 0.85) !important; +} +.dark .border-white { + border-color: #e2e8f0 !important; +} +.dark ::-webkit-scrollbar-track { + background: #1e293b; +} +.dark ::-webkit-scrollbar-thumb { + background: #4338ca; +} +.dark ::-webkit-scrollbar-thumb:hover { + background: #6366f1; +} diff --git a/frontend/data/developers.json b/frontend/data/developers.json index 390d6f5..329d84a 100644 --- a/frontend/data/developers.json +++ b/frontend/data/developers.json @@ -74,4 +74,4 @@ } ] } -] \ No newline at end of file +] diff --git a/frontend/index.html b/frontend/index.html index 522d292..e11dd94 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,222 +1,332 @@ - + - - - - SSCCounter + + + + SSCCounter - - - - + + + + - - - - - + + + + + - - -
-
-
-
-
- + + +
+
+
+
+
+ +
+ SSCCounter
- SSCCounter -
-
- -
- - +
+ +
+ + +
-
- - -
-
+ + +
+
- -
-
- -
-
-
-

현재 동아리방 인원

- -
-
-
- -
-
-
0
-

+ +
+
+ +
+
+
+

+ 현재 동아리방 인원 +

+ +
-
- - 실시간 업데이트 중 +
+ +
+
+
0
+

+
+
+ + 실시간 업데이트 중 +
-
- -
-
-
-
- + +
+
+
+
+ +
+ 최근 업데이트
- 최근 업데이트 +

--:--

-

--:--

-
-
-
-
- +
+
+
+ +
+ 오늘 최다 인원
- 오늘 최다 인원 +

0명

-

0명

-
-
-
-
- +
+
+
+ +
+ 오늘 평균
- 오늘 평균 +

0명

-

0명

-
-
- - -
-

- - 오늘 시간대별 인원 -

-
-
-
-
-
- -
-
-
-
-

시간대별 통계

-

동아리방 이용 패턴을 한눈에 확인하세요

+ +
+

+ + 오늘 시간대별 인원 +

+
+ +
+
+
- -
- - - - - -
+ +
+
+
+
+

시간대별 통계

+

동아리방 이용 패턴을 한눈에 확인하세요

+
- -
-

시간대별 평균 인원

-

최근 4주 평균 기준

-
- + +
+ + + + +
-
- -
-
-
- + +
+

시간대별 평균 인원

+

최근 4주 평균 기준

+
+
-

0

-

최고 시간대 평균

-
-
- + + +
+
+
+ +
+

0

+

최고 시간대 평균

-

0

-

일일 평균 인원

-
-
-
- +
+
+ +
+

0

+

일일 평균 인원

-

--

-

가장 붐비는 시간

-
-
-
- +
+
+ +
+

--

+

가장 붐비는 시간

+
+
+
+ +
+

--

+

가장 한산한 시간

-

--

-

가장 한산한 시간

-
-
-
+
+
- -
-
-
-
-
-

개발자 정보

-

SSCCounter를 만든 사람들

+ +
+
+
+
+
+

개발자 정보

+

SSCCounter를 만든 사람들

+
+ + + + + + + SSCC GitHub +
- - - - SSCC GitHub - -
- -
- + +
+ +
-
-
-
- - - - - - - - \ No newline at end of file + + + + + + + + diff --git a/frontend/js/app.js b/frontend/js/app.js index 1ff89b4..72b3a9b 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -9,18 +9,18 @@ async function fetchCurrentCount() { const newCount = Number(data.count ?? 0); const newUpdatedAt = data.updated_at ?? null; const shouldRefreshStats = - lastAnalysisAt !== null && - newUpdatedAt !== null && - newUpdatedAt !== lastAnalysisAt; + lastAnalysisAt !== null && newUpdatedAt !== null && newUpdatedAt !== lastAnalysisAt; updateLastAnalyzedTime(newUpdatedAt); - + if (data.today_max_count !== undefined) { - document.getElementById('today-max').textContent = `${Math.round(Number(data.today_max_count ?? 0 ))}명`; + document.getElementById('today-max').textContent = + `${Math.round(Number(data.today_max_count ?? 0))}명`; } if (data.today_avg_count !== undefined) { const avgCount = Number(data.today_avg_count ?? 0); - document.getElementById('today-avg').textContent = `${avgCount.toFixed(1).replace('.0', '')}명`; + document.getElementById('today-avg').textContent = + `${avgCount.toFixed(1).replace('.0', '')}명`; } /* ToDo: 인원수가 변할 때마다 fetchInitialData()가 호출되어 /stats/today와 /stats/weekly를 모두 재요청함. @@ -31,7 +31,7 @@ async function fetchCurrentCount() { animateCounter(currentCount); } if (shouldRefreshStats) { - fetchInitialData(); + fetchInitialData(); } lastAnalysisAt = newUpdatedAt; @@ -69,9 +69,9 @@ async function fetchDeveloperData() { } function switchTab(tab) { - document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); - document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); - + document.querySelectorAll('.page').forEach((p) => p.classList.remove('active')); + document.querySelectorAll('.nav-btn').forEach((b) => b.classList.remove('active')); + document.getElementById('page-' + tab).classList.add('active'); document.getElementById('tab-' + tab).classList.add('active'); @@ -82,11 +82,13 @@ function switchTab(tab) { function selectDay(day) { selectedDay = day; - document.querySelectorAll('.day-btn').forEach(btn => { + document.querySelectorAll('.day-btn').forEach((btn) => { if (btn.dataset.day === day) { - btn.className = 'day-btn px-4 py-2 rounded-xl text-sm font-semibold bg-brand-600 text-white transition-all'; + btn.className = + 'day-btn px-4 py-2 rounded-xl text-sm font-semibold bg-brand-600 text-white transition-all'; } else { - btn.className = 'day-btn px-4 py-2 rounded-xl text-sm font-semibold bg-white text-gray-600 border border-gray-200 hover:border-brand-300 transition-all'; + btn.className = + 'day-btn px-4 py-2 rounded-xl text-sm font-semibold bg-white text-gray-600 border border-gray-200 hover:border-brand-300 transition-all'; } }); updateStatsChart(day); @@ -96,8 +98,11 @@ function animateCounter(target) { const el = document.getElementById('counter-value'); const current = parseInt(el.textContent) || 0; const diff = target - current; - if (diff === 0) { el.textContent = target; return; } - + if (diff === 0) { + el.textContent = target; + return; + } + const steps = 30; const stepVal = diff / steps; let step = 0; @@ -115,7 +120,11 @@ function animateCounter(target) { function updateCurrentTime() { const now = new Date(); - const timeStr = now.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + const timeStr = now.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); document.getElementById('current-time').textContent = timeStr; } @@ -130,23 +139,27 @@ function updateLastAnalyzedTime(updatedAt) { const date = new Date(updatedAt); if (Number.isNaN(date.getTime())) { - el.textContent='--:--'; + el.textContent = '--:--'; return; } - el.textContent = date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + el.textContent = date.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); } // XSS 방어용 문자열 변환 함수 function escapeHTML(str) { if (!str) return ''; - return String(str).replace(/[&<>"']/g, function(match) { + return String(str).replace(/[&<>"']/g, function (match) { return { '&': '&', '<': '<', '>': '>', '"': '"', - "'": ''' + "'": ''', }[match]; }); } @@ -155,12 +168,12 @@ function escapeHTML(str) { function getSafeUrl(url) { if (!url) return '#'; const trimmedUrl = String(url).trim(); - + // URL이 http:// 또는 https:// 로 시작하는지 정규식으로 검사 if (/^https?:\/\//i.test(trimmedUrl)) { return escapeHTML(trimmedUrl); // 안전하면 기존처럼 이스케이프 후 반환 } - + return '#'; // 이상한 스킴(javascript: 등)이면 링크 무효화 } @@ -168,14 +181,16 @@ function getSafeUrl(url) { function getFallbackImage(name) { const initial = escapeHTML(name).charAt(0); const svg = `${initial}`; - + // 브라우저 호환성을 위해 안전하게 인코딩 return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`; } function renderDevCards() { const container = document.getElementById('dev-sections'); - container.innerHTML = versionData.map(ver => ` + container.innerHTML = versionData + .map( + (ver) => `
@@ -191,7 +206,9 @@ function renderDevCards() {
- ${ver.developers.map(dev => ` + ${ver.developers + .map( + (dev) => `
@@ -205,7 +222,15 @@ function renderDevCards() {
- `).join(''); + ` + ) + .join(''); lucide.createIcons(); } function initDarkMode() { - if (localStorage.getItem('theme') === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { + if ( + localStorage.getItem('theme') === 'dark' || + (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) + ) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); @@ -260,7 +292,7 @@ function toggleDark() { function init() { lucide.createIcons(); initDarkMode(); - + const currentDayNum = new Date().getDay(); const dayMapping = { 1: 'mon', 2: 'tue', 3: 'wed', 4: 'thu', 5: 'fri' }; @@ -277,4 +309,4 @@ function init() { setInterval(updateCurrentTime, 1000); } -document.addEventListener('DOMContentLoaded', init); \ No newline at end of file +document.addEventListener('DOMContentLoaded', init); diff --git a/frontend/js/charts.js b/frontend/js/charts.js index 246d15d..5cbd1a5 100644 --- a/frontend/js/charts.js +++ b/frontend/js/charts.js @@ -19,10 +19,10 @@ function getChartColors() { function renderMiniChart() { if (todayHours.length === 0) return; - + const ctx = document.getElementById('miniChart').getContext('2d'); - const labels = todayHours.map(h => h.hour + '시'); - const data = todayHours.map(h => h.count); + const labels = todayHours.map((h) => h.hour + '시'); + const data = todayHours.map((h) => h.count); const c = getChartColors(); if (miniChartInstance) miniChartInstance.destroy(); @@ -31,24 +31,39 @@ function renderMiniChart() { type: 'bar', data: { labels, - datasets: [{ - data, - backgroundColor: data.map((_, i) => i === data.length - 1 ? c.barActive : c.barFill), - borderRadius: 6, - borderSkipped: false, - }] + datasets: [ + { + data, + backgroundColor: data.map((_, i) => (i === data.length - 1 ? c.barActive : c.barFill)), + borderRadius: 6, + borderSkipped: false, + }, + ], }, options: { - responsive: true, maintainAspectRatio: false, - plugins: { legend: { display: false }, tooltip: { - backgroundColor: c.tooltipBg, titleFont: { family: 'Inter' }, bodyFont: { family: 'Inter' }, - callbacks: { label: (ctx) => ctx.parsed.y + '명' } - }}, + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: c.tooltipBg, + titleFont: { family: 'Inter' }, + bodyFont: { family: 'Inter' }, + callbacks: { label: (ctx) => ctx.parsed.y + '명' }, + }, + }, scales: { - x: { grid: { display: false }, ticks: { font: { family: 'Inter', size: 11 }, color: c.tick } }, - y: { grid: { color: c.grid }, ticks: { font: { family: 'Inter' }, color: c.tick }, beginAtZero: true } - } - } + x: { + grid: { display: false }, + ticks: { font: { family: 'Inter', size: 11 }, color: c.tick }, + }, + y: { + grid: { color: c.grid }, + ticks: { font: { family: 'Inter' }, color: c.tick }, + beginAtZero: true, + }, + }, + }, }); } @@ -73,24 +88,50 @@ function updateStatsChart(day) { type: 'line', data: { labels: timeLabels, - datasets: [{ - label: '평균 인원', data, borderColor: c.lineColor, backgroundColor: gradient, - fill: true, tension: 0.4, pointBackgroundColor: c.pointBg, - pointBorderColor: dark ? '#1e293b' : '#fff', - pointBorderWidth: 2, pointRadius: 5, pointHoverRadius: 8, - }] + datasets: [ + { + label: '평균 인원', + data, + borderColor: c.lineColor, + backgroundColor: gradient, + fill: true, + tension: 0.4, + pointBackgroundColor: c.pointBg, + pointBorderColor: dark ? '#1e293b' : '#fff', + pointBorderWidth: 2, + pointRadius: 5, + pointHoverRadius: 8, + }, + ], }, options: { - responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, plugins: { legend: { display: false }, - tooltip: { backgroundColor: c.tooltipBg, titleFont: { family: 'Inter', size: 13 }, bodyFont: { family: 'Inter', size: 13 }, padding: 12, cornerRadius: 10, callbacks: { label: (ctx) => '평균 ' + ctx.parsed.y + '명' } } + tooltip: { + backgroundColor: c.tooltipBg, + titleFont: { family: 'Inter', size: 13 }, + bodyFont: { family: 'Inter', size: 13 }, + padding: 12, + cornerRadius: 10, + callbacks: { label: (ctx) => '평균 ' + ctx.parsed.y + '명' }, + }, }, scales: { - x: { grid: { color: c.grid }, ticks: { font: { family: 'Inter', size: 12 }, color: c.tick } }, - y: { grid: { color: c.grid }, ticks: { font: { family: 'Inter', size: 12 }, color: c.tick }, beginAtZero: true, suggestedMax: 10 } - } - } + x: { + grid: { color: c.grid }, + ticks: { font: { family: 'Inter', size: 12 }, color: c.tick }, + }, + y: { + grid: { color: c.grid }, + ticks: { font: { family: 'Inter', size: 12 }, color: c.tick }, + beginAtZero: true, + suggestedMax: 10, + }, + }, + }, }); // Update stat cards @@ -103,4 +144,4 @@ function updateStatsChart(day) { document.getElementById('stat-daily-avg').textContent = dailyAvg + '명'; document.getElementById('stat-peak-hour').textContent = timeLabels[peakIdx]; document.getElementById('stat-quiet-hour').textContent = timeLabels[quietIdx]; -} \ No newline at end of file +} diff --git a/frontend/js/data.js b/frontend/js/data.js index f457ac4..79b7c03 100644 --- a/frontend/js/data.js +++ b/frontend/js/data.js @@ -4,12 +4,31 @@ let currentCount = 0; let todayHours = []; let weeklyStats = { - mon: [], tue: [], wed: [], thu: [], fri: [] + mon: [], + tue: [], + wed: [], + thu: [], + fri: [], }; -const timeLabels = ['9시','10시','11시','12시','13시','14시','15시','16시','17시','18시','19시','20시','21시','22시']; +const timeLabels = [ + '9시', + '10시', + '11시', + '12시', + '13시', + '14시', + '15시', + '16시', + '17시', + '18시', + '19시', + '20시', + '21시', + '22시', +]; let selectedDay = 'mon'; // Developer info let versionData = []; -let lastAnalysisAt = null; \ No newline at end of file +let lastAnalysisAt = null; diff --git a/frontend/js/tailwind.config.js b/frontend/js/tailwind.config.js index eb397db..bc262b0 100644 --- a/frontend/js/tailwind.config.js +++ b/frontend/js/tailwind.config.js @@ -6,12 +6,18 @@ tailwind.config = { fontFamily: { sans: ['Inter', 'sans-serif'] }, colors: { brand: { - 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', - 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', - 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', + 50: '#eef2ff', + 100: '#e0e7ff', + 200: '#c7d2fe', + 300: '#a5b4fc', + 400: '#818cf8', + 500: '#6366f1', + 600: '#4f46e5', + 700: '#4338ca', + 800: '#3730a3', 900: '#312e81', - } - } - } - } -} \ No newline at end of file + }, + }, + }, + }, +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..7647aed --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "prettier": "^3.4.2" + } + }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..53a2bee --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,9 @@ +{ + "scripts": { + "format": "prettier . --write", + "format:check": "prettier . --check" + }, + "devDependencies": { + "prettier": "^3.4.2" + } +}