diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..75be082 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,20 @@ +name: Deploy Production +on: + push: + branches: [main] + +jobs: + test: + runs-on: test + steps: + - uses: actions/checkout@v4 + - name: Build check + run: cd frontend && npm ci && npm run build + + deploy: + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Deploy Production + run: python3 scripts/deploy-preview.py deploy-prod diff --git a/.gitea/workflows/preview.yml b/.gitea/workflows/preview.yml new file mode 100644 index 0000000..f9baee9 --- /dev/null +++ b/.gitea/workflows/preview.yml @@ -0,0 +1,50 @@ +name: PR Preview +on: + pull_request: + types: [opened, synchronize, reopened, closed] + +jobs: + test: + if: github.event.action != 'closed' + runs-on: test + steps: + - uses: actions/checkout@v4 + - name: Build check + run: cd frontend && npm ci && npm run build + + deploy-preview: + if: github.event.action != 'closed' + needs: test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Deploy Preview + run: python3 scripts/deploy-preview.py deploy ${{ github.event.pull_request.number }} + - name: Comment PR + env: + GIT_TOKEN: ${{ secrets.GIT_TOKEN }} + run: | + PR_ID="${{ github.event.pull_request.number }}" + curl -sf -X POST \ + "https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \ + -H "Authorization: token ${GIT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"body\": \"🚀 **Preview**: https://pr-${PR_ID}.planner.oci.euphon.net\n\nDB is a copy of production.\"}" || true + + teardown-preview: + if: github.event.action == 'closed' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Teardown + run: python3 scripts/deploy-preview.py teardown ${{ github.event.pull_request.number }} + - name: Comment PR + env: + GIT_TOKEN: ${{ secrets.GIT_TOKEN }} + run: | + PR_ID="${{ github.event.pull_request.number }}" + curl -sf -X POST \ + "https://git.euphon.cloud/api/v1/repos/${{ github.repository }}/issues/${PR_ID}/comments" \ + -H "Authorization: token ${GIT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"body\": \"🗑️ Preview torn down.\"}" || true diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml new file mode 100644 index 0000000..d9307a3 --- /dev/null +++ b/.gitea/workflows/test.yml @@ -0,0 +1,62 @@ +name: Test +on: [push] + +jobs: + build-check: + runs-on: test + steps: + - uses: actions/checkout@v4 + - name: Build frontend + run: cd frontend && npm ci && npm run build + + e2e-test: + runs-on: test + needs: build-check + steps: + - uses: actions/checkout@v4 + + - name: Install frontend deps + run: cd frontend && npm ci + + - name: Install backend deps + run: python3 -m venv /tmp/ci-venv && /tmp/ci-venv/bin/pip install -q -r backend/requirements.txt + + - name: E2E tests + run: | + # Start backend + DB_PATH=/tmp/ci_planner_test.db FRONTEND_DIR=/dev/null DATA_DIR=/tmp/ci_planner_data \ + /tmp/ci-venv/bin/uvicorn backend.main:app --port 8000 & + + # Start frontend + (cd frontend && npx vite --port 5173) & + + # Wait for both servers + for i in $(seq 1 30); do + if curl -sf http://localhost:8000/api/backups > /dev/null 2>&1 && \ + curl -sf http://localhost:5173/ > /dev/null 2>&1; then + echo "Both servers ready" + break + fi + sleep 1 + done + + # Run core cypress specs + cd frontend + npx cypress run --spec "\ + cypress/e2e/app-load.cy.js,\ + cypress/e2e/auth-flow.cy.js,\ + cypress/e2e/navigation.cy.js,\ + cypress/e2e/api-health.cy.js,\ + cypress/e2e/api-crud.cy.js,\ + cypress/e2e/notes-flow.cy.js,\ + cypress/e2e/tasks-flow.cy.js,\ + cypress/e2e/reminders-flow.cy.js\ + " --config video=false + EXIT_CODE=$? + + # Cleanup + pkill -f "uvicorn backend" || true + pkill -f "node.*vite" || true + rm -f /tmp/ci_planner_test.db + rm -rf /tmp/ci_planner_data + exit $EXIT_CODE diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcd7e45 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules +__pycache__ +frontend/node_modules +frontend/dist +*.pyc diff --git a/Dockerfile b/Dockerfile index 6aeb1e3..8e47757 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,25 @@ -FROM python:3.12-alpine +FROM node:20-slim AS frontend-build + +WORKDIR /build +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm install +COPY frontend/ ./ +RUN npm run build + +FROM python:3.12-slim + WORKDIR /app -COPY server.py . -COPY index.html sleep-buddy.html favicon.svg icon-180.png notebook.jpg manifest.json sw.js /app/static/ -ENV DATA_DIR=/data STATIC_DIR=/app/static PORT=8080 -EXPOSE 8080 -CMD ["python3", "server.py"] + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ ./backend/ +COPY --from=frontend-build /build/dist ./frontend/ + +ENV DB_PATH=/data/planner.db +ENV FRONTEND_DIR=/app/frontend +ENV DATA_DIR=/data + +EXPOSE 8000 + +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..d8398cc --- /dev/null +++ b/backend/database.py @@ -0,0 +1,218 @@ +import sqlite3 +import os + +DB_PATH = os.environ.get("DB_PATH", "/data/planner.db") + + +def get_db(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + return conn + + +def init_db(): + os.makedirs(os.path.dirname(DB_PATH) or ".", exist_ok=True) + conn = get_db() + c = conn.cursor() + c.executescript(""" + -- 用户认证(单用户 planner 密码) + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + -- 随手记 + CREATE TABLE IF NOT EXISTS notes ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + tag TEXT DEFAULT '灵感', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + -- 待办事项(四象限) + CREATE TABLE IF NOT EXISTS todos ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + quadrant TEXT NOT NULL DEFAULT 'q1', + done INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ); + + -- 收集箱 + CREATE TABLE IF NOT EXISTS inbox ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + ); + + -- 提醒 + CREATE TABLE IF NOT EXISTS reminders ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + time TEXT, + repeat TEXT DEFAULT 'none', + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); + + -- 目标 + CREATE TABLE IF NOT EXISTS goals ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + month TEXT, + checks TEXT DEFAULT '{}', + created_at TEXT DEFAULT (datetime('now')) + ); + + -- 清单 + CREATE TABLE IF NOT EXISTS checklists ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + items TEXT DEFAULT '[]', + archived INTEGER DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')) + ); + + -- 健康打卡 - 项目池 + CREATE TABLE IF NOT EXISTS health_items ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'health', + created_at TEXT DEFAULT (datetime('now')) + ); + + -- 健康打卡 - 月计划 + CREATE TABLE IF NOT EXISTS health_plans ( + month TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'health', + item_ids TEXT DEFAULT '[]', + PRIMARY KEY (month, type) + ); + + -- 健康打卡 - 记录 + CREATE TABLE IF NOT EXISTS health_checks ( + date TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'health', + item_id TEXT NOT NULL, + checked INTEGER DEFAULT 1, + PRIMARY KEY (date, type, item_id) + ); + + -- 睡眠记录 + CREATE TABLE IF NOT EXISTS sleep_records ( + date TEXT PRIMARY KEY, + time TEXT NOT NULL, + minutes REAL, + created_at TEXT DEFAULT (datetime('now')) + ); + + -- 健身记录 + CREATE TABLE IF NOT EXISTS gym_records ( + id TEXT PRIMARY KEY, + date TEXT NOT NULL, + type TEXT DEFAULT '', + duration TEXT DEFAULT '', + note TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + + -- 经期记录 + CREATE TABLE IF NOT EXISTS period_records ( + id TEXT PRIMARY KEY, + start_date TEXT NOT NULL, + end_date TEXT, + note TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + ); + + -- 文档 + CREATE TABLE IF NOT EXISTS docs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + icon TEXT DEFAULT '📄', + keywords TEXT DEFAULT '', + extract_rule TEXT DEFAULT 'none', + created_at TEXT DEFAULT (datetime('now')) + ); + + -- 文档条目 + CREATE TABLE IF NOT EXISTS doc_entries ( + id TEXT PRIMARY KEY, + doc_id TEXT NOT NULL REFERENCES docs(id) ON DELETE CASCADE, + text TEXT NOT NULL, + note_id TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + + -- 日程模块 + CREATE TABLE IF NOT EXISTS schedule_modules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + emoji TEXT DEFAULT '📌', + color TEXT DEFAULT '#667eea', + sort_order INTEGER DEFAULT 0 + ); + + -- 日程安排(每天的时间块) + CREATE TABLE IF NOT EXISTS schedule_slots ( + date TEXT NOT NULL, + time_slot TEXT NOT NULL, + module_id TEXT NOT NULL REFERENCES schedule_modules(id) ON DELETE CASCADE, + PRIMARY KEY (date, time_slot, module_id) + ); + + -- 每周模板 + CREATE TABLE IF NOT EXISTS weekly_template ( + day INTEGER NOT NULL, + data TEXT DEFAULT '[]', + PRIMARY KEY (day) + ); + + -- 周回顾 + CREATE TABLE IF NOT EXISTS reviews ( + week TEXT PRIMARY KEY, + data TEXT DEFAULT '{}' + ); + + -- Bug 追踪 + CREATE TABLE IF NOT EXISTS bugs ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + status TEXT DEFAULT 'open', + created_at TEXT DEFAULT (datetime('now')) + ); + + -- Sleep Buddy 用户 + CREATE TABLE IF NOT EXISTS buddy_users ( + username TEXT PRIMARY KEY, + password_hash TEXT NOT NULL, + target_time TEXT DEFAULT '22:00', + created_at TEXT DEFAULT (datetime('now')) + ); + + -- Sleep Buddy 记录 + CREATE TABLE IF NOT EXISTS buddy_records ( + username TEXT NOT NULL, + date TEXT NOT NULL, + time TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (username, date) + ); + + -- Sleep Buddy 通知 + CREATE TABLE IF NOT EXISTS buddy_notifications ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user TEXT NOT NULL, + message TEXT NOT NULL, + time TEXT NOT NULL, + date TEXT NOT NULL, + created_at REAL NOT NULL + ); + """) + + conn.commit() + conn.close() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..e566b3b --- /dev/null +++ b/backend/main.py @@ -0,0 +1,1071 @@ +"""Hera Planner - FastAPI Backend""" +import json +import os +import hashlib +import time +import shutil +import threading +from datetime import datetime +from pathlib import Path +from contextlib import contextmanager + +from fastapi import FastAPI, HTTPException, Request +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse +from pydantic import BaseModel +from typing import Optional + +from .database import get_db, init_db, DB_PATH + +app = FastAPI(title="Hera's Planner") + +DATA_DIR = os.environ.get("DATA_DIR", "/data") +BACKUP_DIR = os.path.join(DATA_DIR, "backups") +FRONTEND_DIR = os.environ.get("FRONTEND_DIR", "/app/frontend") + +DEFAULT_HASH = "8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92" + + +# ============================================================ +# Helpers +# ============================================================ + +def _ensure_dirs(): + Path(DATA_DIR).mkdir(parents=True, exist_ok=True) + Path(BACKUP_DIR).mkdir(parents=True, exist_ok=True) + + +def _get_config(key, default=None): + conn = get_db() + row = conn.execute("SELECT value FROM config WHERE key = ?", (key,)).fetchone() + conn.close() + return row["value"] if row else default + + +def _set_config(key, value): + conn = get_db() + conn.execute( + "INSERT INTO config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = ?", + (key, value, value), + ) + conn.commit() + conn.close() + + +def _do_backup(): + """Backup the SQLite database.""" + if not os.path.exists(DB_PATH): + return + _ensure_dirs() + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + dest = os.path.join(BACKUP_DIR, f"planner_{ts}.db") + # Use SQLite backup API for consistency + import sqlite3 + src_conn = sqlite3.connect(DB_PATH) + dst_conn = sqlite3.connect(dest) + src_conn.backup(dst_conn) + dst_conn.close() + src_conn.close() + # Keep only last 30 backups + backups = sorted(Path(BACKUP_DIR).glob("planner_*.db")) + for old in backups[:-30]: + old.unlink() + + +# WAL checkpoint thread +def _wal_checkpoint_loop(): + while True: + time.sleep(300) + try: + conn = get_db() + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.close() + except Exception: + pass + + +# ============================================================ +# Startup +# ============================================================ + +@app.on_event("startup") +def startup(): + _ensure_dirs() + init_db() + # Start WAL checkpoint thread + t = threading.Thread(target=_wal_checkpoint_loop, daemon=True) + t.start() + # Mount frontend static files + dist_assets = os.path.join(FRONTEND_DIR, "assets") + if os.path.isdir(dist_assets): + app.mount("/assets", StaticFiles(directory=dist_assets), name="assets") + + +# ============================================================ +# Auth +# ============================================================ + +class LoginRequest(BaseModel): + hash: str + +class ChangePasswordRequest(BaseModel): + oldHash: str + newHash: str + + +@app.post("/api/login") +def login(req: LoginRequest): + stored = _get_config("password_hash", DEFAULT_HASH) + if req.hash != stored: + raise HTTPException(401, "密码不正确") + return {"ok": True} + + +@app.post("/api/change-password") +def change_password(req: ChangePasswordRequest): + stored = _get_config("password_hash", DEFAULT_HASH) + if req.oldHash != stored: + raise HTTPException(401, "当前密码不正确") + _set_config("password_hash", req.newHash) + return {"ok": True} + + +# ============================================================ +# Notes (随手记) +# ============================================================ + +@app.get("/api/notes") +def list_notes(): + conn = get_db() + rows = conn.execute("SELECT * FROM notes ORDER BY created_at DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class NoteIn(BaseModel): + id: str + text: str + tag: str = "灵感" + +@app.post("/api/notes") +def create_note(note: NoteIn): + conn = get_db() + conn.execute( + "INSERT INTO notes (id, text, tag) VALUES (?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET text=?, tag=?, updated_at=datetime('now')", + (note.id, note.text, note.tag, note.text, note.tag), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/notes/{note_id}") +def delete_note(note_id: str): + conn = get_db() + conn.execute("DELETE FROM notes WHERE id = ?", (note_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Todos (待办四象限) +# ============================================================ + +@app.get("/api/todos") +def list_todos(): + conn = get_db() + rows = conn.execute("SELECT * FROM todos ORDER BY created_at DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class TodoIn(BaseModel): + id: str + text: str + quadrant: str = "q1" + done: int = 0 + +@app.post("/api/todos") +def upsert_todo(todo: TodoIn): + conn = get_db() + conn.execute( + "INSERT INTO todos (id, text, quadrant, done) VALUES (?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET text=?, quadrant=?, done=?, updated_at=datetime('now')", + (todo.id, todo.text, todo.quadrant, todo.done, todo.text, todo.quadrant, todo.done), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/todos/{todo_id}") +def delete_todo(todo_id: str): + conn = get_db() + conn.execute("DELETE FROM todos WHERE id = ?", (todo_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Inbox (收集箱) +# ============================================================ + +@app.get("/api/inbox") +def list_inbox(): + conn = get_db() + rows = conn.execute("SELECT * FROM inbox ORDER BY created_at DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class InboxIn(BaseModel): + id: str + text: str + +@app.post("/api/inbox") +def add_inbox(item: InboxIn): + conn = get_db() + conn.execute( + "INSERT OR REPLACE INTO inbox (id, text) VALUES (?, ?)", + (item.id, item.text), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/inbox/{item_id}") +def delete_inbox(item_id: str): + conn = get_db() + conn.execute("DELETE FROM inbox WHERE id = ?", (item_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/inbox") +def clear_inbox(): + conn = get_db() + conn.execute("DELETE FROM inbox") + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Reminders (提醒) +# ============================================================ + +@app.get("/api/reminders") +def list_reminders(): + conn = get_db() + rows = conn.execute("SELECT * FROM reminders ORDER BY time").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class ReminderIn(BaseModel): + id: str + text: str + time: Optional[str] = None + repeat: str = "none" + enabled: int = 1 + +@app.post("/api/reminders") +def upsert_reminder(r: ReminderIn): + conn = get_db() + conn.execute( + "INSERT INTO reminders (id, text, time, repeat, enabled) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET text=?, time=?, repeat=?, enabled=?", + (r.id, r.text, r.time, r.repeat, r.enabled, r.text, r.time, r.repeat, r.enabled), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/reminders/{reminder_id}") +def delete_reminder(reminder_id: str): + conn = get_db() + conn.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Goals (目标) +# ============================================================ + +@app.get("/api/goals") +def list_goals(): + conn = get_db() + rows = conn.execute("SELECT * FROM goals ORDER BY created_at DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class GoalIn(BaseModel): + id: str + name: str + month: Optional[str] = None + checks: str = "{}" + +@app.post("/api/goals") +def upsert_goal(g: GoalIn): + conn = get_db() + conn.execute( + "INSERT INTO goals (id, name, month, checks) VALUES (?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET name=?, month=?, checks=?", + (g.id, g.name, g.month, g.checks, g.name, g.month, g.checks), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/goals/{goal_id}") +def delete_goal(goal_id: str): + conn = get_db() + conn.execute("DELETE FROM goals WHERE id = ?", (goal_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Checklists (清单) +# ============================================================ + +@app.get("/api/checklists") +def list_checklists(): + conn = get_db() + rows = conn.execute("SELECT * FROM checklists ORDER BY created_at DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class ChecklistIn(BaseModel): + id: str + title: str + items: str = "[]" + archived: int = 0 + +@app.post("/api/checklists") +def upsert_checklist(cl: ChecklistIn): + conn = get_db() + conn.execute( + "INSERT INTO checklists (id, title, items, archived) VALUES (?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET title=?, items=?, archived=?", + (cl.id, cl.title, cl.items, cl.archived, cl.title, cl.items, cl.archived), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/checklists/{cl_id}") +def delete_checklist(cl_id: str): + conn = get_db() + conn.execute("DELETE FROM checklists WHERE id = ?", (cl_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Health / Music Check-in (打卡) +# ============================================================ + +@app.get("/api/health-items") +def list_health_items(type: str = "health"): + conn = get_db() + rows = conn.execute("SELECT * FROM health_items WHERE type = ?", (type,)).fetchall() + conn.close() + return [dict(r) for r in rows] + + +class HealthItemIn(BaseModel): + id: str + name: str + type: str = "health" + +@app.post("/api/health-items") +def upsert_health_item(item: HealthItemIn): + conn = get_db() + conn.execute( + "INSERT OR REPLACE INTO health_items (id, name, type) VALUES (?, ?, ?)", + (item.id, item.name, item.type), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/health-items/{item_id}") +def delete_health_item(item_id: str): + conn = get_db() + conn.execute("DELETE FROM health_items WHERE id = ?", (item_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.get("/api/health-plans") +def get_health_plans(type: str = "health"): + conn = get_db() + rows = conn.execute("SELECT * FROM health_plans WHERE type = ?", (type,)).fetchall() + conn.close() + return [dict(r) for r in rows] + + +class HealthPlanIn(BaseModel): + month: str + type: str = "health" + item_ids: str = "[]" + +@app.post("/api/health-plans") +def upsert_health_plan(plan: HealthPlanIn): + conn = get_db() + conn.execute( + "INSERT INTO health_plans (month, type, item_ids) VALUES (?, ?, ?) " + "ON CONFLICT(month, type) DO UPDATE SET item_ids=?", + (plan.month, plan.type, plan.item_ids, plan.item_ids), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.get("/api/health-checks") +def list_health_checks(type: str = "health", month: Optional[str] = None): + conn = get_db() + if month: + rows = conn.execute( + "SELECT * FROM health_checks WHERE type = ? AND date LIKE ?", + (type, f"{month}%"), + ).fetchall() + else: + rows = conn.execute("SELECT * FROM health_checks WHERE type = ?", (type,)).fetchall() + conn.close() + return [dict(r) for r in rows] + + +class HealthCheckIn(BaseModel): + date: str + type: str = "health" + item_id: str + checked: int = 1 + +@app.post("/api/health-checks") +def upsert_health_check(check: HealthCheckIn): + conn = get_db() + if check.checked: + conn.execute( + "INSERT OR REPLACE INTO health_checks (date, type, item_id, checked) VALUES (?, ?, ?, ?)", + (check.date, check.type, check.item_id, check.checked), + ) + else: + conn.execute( + "DELETE FROM health_checks WHERE date = ? AND type = ? AND item_id = ?", + (check.date, check.type, check.item_id), + ) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Sleep Records (睡眠) +# ============================================================ + +@app.get("/api/sleep") +def list_sleep(): + conn = get_db() + rows = conn.execute("SELECT * FROM sleep_records ORDER BY date DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class SleepIn(BaseModel): + date: str + time: str + minutes: Optional[float] = None + +@app.post("/api/sleep") +def upsert_sleep(record: SleepIn): + conn = get_db() + conn.execute( + "INSERT INTO sleep_records (date, time, minutes) VALUES (?, ?, ?) " + "ON CONFLICT(date) DO UPDATE SET time=?, minutes=?", + (record.date, record.time, record.minutes, record.time, record.minutes), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/sleep/{date}") +def delete_sleep(date: str): + conn = get_db() + conn.execute("DELETE FROM sleep_records WHERE date = ?", (date,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Gym Records (健身) +# ============================================================ + +@app.get("/api/gym") +def list_gym(): + conn = get_db() + rows = conn.execute("SELECT * FROM gym_records ORDER BY date DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class GymIn(BaseModel): + id: str + date: str + type: str = "" + duration: str = "" + note: str = "" + +@app.post("/api/gym") +def upsert_gym(record: GymIn): + conn = get_db() + conn.execute( + "INSERT INTO gym_records (id, date, type, duration, note) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET date=?, type=?, duration=?, note=?", + (record.id, record.date, record.type, record.duration, record.note, + record.date, record.type, record.duration, record.note), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/gym/{record_id}") +def delete_gym(record_id: str): + conn = get_db() + conn.execute("DELETE FROM gym_records WHERE id = ?", (record_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Period Records (经期) +# ============================================================ + +@app.get("/api/period") +def list_period(): + conn = get_db() + rows = conn.execute("SELECT * FROM period_records ORDER BY start_date DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class PeriodIn(BaseModel): + id: str + start_date: str + end_date: Optional[str] = None + note: str = "" + +@app.post("/api/period") +def upsert_period(record: PeriodIn): + conn = get_db() + conn.execute( + "INSERT INTO period_records (id, start_date, end_date, note) VALUES (?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET start_date=?, end_date=?, note=?", + (record.id, record.start_date, record.end_date, record.note, + record.start_date, record.end_date, record.note), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/period/{record_id}") +def delete_period(record_id: str): + conn = get_db() + conn.execute("DELETE FROM period_records WHERE id = ?", (record_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Docs (文档) +# ============================================================ + +@app.get("/api/docs") +def list_docs(): + conn = get_db() + docs = conn.execute("SELECT * FROM docs ORDER BY created_at").fetchall() + result = [] + for d in docs: + doc = dict(d) + entries = conn.execute( + "SELECT * FROM doc_entries WHERE doc_id = ? ORDER BY created_at DESC", + (d["id"],), + ).fetchall() + doc["entries"] = [dict(e) for e in entries] + result.append(doc) + conn.close() + return result + + +class DocIn(BaseModel): + id: str + name: str + icon: str = "📄" + keywords: str = "" + extract_rule: str = "none" + +@app.post("/api/docs") +def upsert_doc(doc: DocIn): + conn = get_db() + conn.execute( + "INSERT INTO docs (id, name, icon, keywords, extract_rule) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET name=?, icon=?, keywords=?, extract_rule=?", + (doc.id, doc.name, doc.icon, doc.keywords, doc.extract_rule, + doc.name, doc.icon, doc.keywords, doc.extract_rule), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/docs/{doc_id}") +def delete_doc(doc_id: str): + conn = get_db() + conn.execute("DELETE FROM docs WHERE id = ?", (doc_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +class DocEntryIn(BaseModel): + id: str + doc_id: str + text: str + note_id: Optional[str] = None + +@app.post("/api/doc-entries") +def add_doc_entry(entry: DocEntryIn): + conn = get_db() + conn.execute( + "INSERT OR REPLACE INTO doc_entries (id, doc_id, text, note_id) VALUES (?, ?, ?, ?)", + (entry.id, entry.doc_id, entry.text, entry.note_id), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/doc-entries/{entry_id}") +def delete_doc_entry(entry_id: str): + conn = get_db() + conn.execute("DELETE FROM doc_entries WHERE id = ?", (entry_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Schedule (日程) +# ============================================================ + +@app.get("/api/schedule-modules") +def list_schedule_modules(): + conn = get_db() + rows = conn.execute("SELECT * FROM schedule_modules ORDER BY sort_order").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class ScheduleModuleIn(BaseModel): + id: str + name: str + emoji: str = "📌" + color: str = "#667eea" + sort_order: int = 0 + +@app.post("/api/schedule-modules") +def upsert_schedule_module(m: ScheduleModuleIn): + conn = get_db() + conn.execute( + "INSERT INTO schedule_modules (id, name, emoji, color, sort_order) VALUES (?, ?, ?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET name=?, emoji=?, color=?, sort_order=?", + (m.id, m.name, m.emoji, m.color, m.sort_order, m.name, m.emoji, m.color, m.sort_order), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/schedule-modules/{module_id}") +def delete_schedule_module(module_id: str): + conn = get_db() + conn.execute("DELETE FROM schedule_modules WHERE id = ?", (module_id,)) + conn.execute("DELETE FROM schedule_slots WHERE module_id = ?", (module_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.get("/api/schedule-slots") +def list_schedule_slots(date: Optional[str] = None): + conn = get_db() + if date: + rows = conn.execute("SELECT * FROM schedule_slots WHERE date = ?", (date,)).fetchall() + else: + rows = conn.execute("SELECT * FROM schedule_slots").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class ScheduleSlotIn(BaseModel): + date: str + time_slot: str + module_id: str + +@app.post("/api/schedule-slots") +def add_schedule_slot(slot: ScheduleSlotIn): + conn = get_db() + conn.execute( + "INSERT OR REPLACE INTO schedule_slots (date, time_slot, module_id) VALUES (?, ?, ?)", + (slot.date, slot.time_slot, slot.module_id), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/schedule-slots") +def delete_schedule_slots(date: str, time_slot: Optional[str] = None, module_id: Optional[str] = None): + conn = get_db() + if time_slot and module_id: + conn.execute( + "DELETE FROM schedule_slots WHERE date = ? AND time_slot = ? AND module_id = ?", + (date, time_slot, module_id), + ) + elif time_slot: + conn.execute("DELETE FROM schedule_slots WHERE date = ? AND time_slot = ?", (date, time_slot)) + else: + conn.execute("DELETE FROM schedule_slots WHERE date = ?", (date,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Weekly Template (每周模板) +# ============================================================ + +@app.get("/api/weekly-template") +def get_weekly_template(): + conn = get_db() + rows = conn.execute("SELECT * FROM weekly_template ORDER BY day").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class WeeklyTemplateIn(BaseModel): + day: int + data: str = "[]" + +@app.post("/api/weekly-template") +def upsert_weekly_template(t: WeeklyTemplateIn): + conn = get_db() + conn.execute( + "INSERT INTO weekly_template (day, data) VALUES (?, ?) " + "ON CONFLICT(day) DO UPDATE SET data=?", + (t.day, t.data, t.data), + ) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Reviews (周回顾) +# ============================================================ + +@app.get("/api/reviews") +def list_reviews(): + conn = get_db() + rows = conn.execute("SELECT * FROM reviews ORDER BY week DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class ReviewIn(BaseModel): + week: str + data: str = "{}" + +@app.post("/api/reviews") +def upsert_review(r: ReviewIn): + conn = get_db() + conn.execute( + "INSERT INTO reviews (week, data) VALUES (?, ?) " + "ON CONFLICT(week) DO UPDATE SET data=?", + (r.week, r.data, r.data), + ) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Bugs +# ============================================================ + +@app.get("/api/bugs") +def list_bugs(): + conn = get_db() + rows = conn.execute("SELECT * FROM bugs ORDER BY created_at DESC").fetchall() + conn.close() + return [dict(r) for r in rows] + + +class BugIn(BaseModel): + id: str + text: str + status: str = "open" + +@app.post("/api/bugs") +def upsert_bug(bug: BugIn): + conn = get_db() + conn.execute( + "INSERT INTO bugs (id, text, status) VALUES (?, ?, ?) " + "ON CONFLICT(id) DO UPDATE SET text=?, status=?", + (bug.id, bug.text, bug.status, bug.text, bug.status), + ) + conn.commit() + conn.close() + return {"ok": True} + + +@app.delete("/api/bugs/{bug_id}") +def delete_bug(bug_id: str): + conn = get_db() + conn.execute("DELETE FROM bugs WHERE id = ?", (bug_id,)) + conn.commit() + conn.close() + return {"ok": True} + + +# ============================================================ +# Sleep Buddy (独立的社交睡眠打卡) +# ============================================================ + +class BuddyRegister(BaseModel): + username: str + hash: str + +@app.post("/api/buddy-register") +def buddy_register(req: BuddyRegister): + username = req.username.strip() + if not username or not req.hash: + raise HTTPException(400, "请输入用户名和密码") + conn = get_db() + existing = conn.execute("SELECT username FROM buddy_users WHERE username = ?", (username,)).fetchone() + if existing: + conn.close() + raise HTTPException(400, "用户名已存在") + conn.execute( + "INSERT INTO buddy_users (username, password_hash) VALUES (?, ?)", + (username, req.hash), + ) + conn.commit() + conn.close() + return {"ok": True} + + +class BuddyLogin(BaseModel): + username: str + hash: str + +@app.post("/api/buddy-login") +def buddy_login(req: BuddyLogin): + username = req.username.strip() + conn = get_db() + user = conn.execute( + "SELECT * FROM buddy_users WHERE username = ? AND password_hash = ?", + (username, req.hash), + ).fetchone() + conn.close() + if not user: + raise HTTPException(401, "用户名或密码不正确") + return {"ok": True, "username": username} + + +class BuddyDeleteUser(BaseModel): + adminHash: str + username: str + +@app.post("/api/buddy-delete-user") +def buddy_delete_user(req: BuddyDeleteUser): + stored = _get_config("password_hash", DEFAULT_HASH) + if req.adminHash != stored: + raise HTTPException(401, "需要管理员密码") + conn = get_db() + conn.execute("DELETE FROM buddy_users WHERE username = ?", (req.username,)) + conn.execute("DELETE FROM buddy_records WHERE username = ?", (req.username,)) + conn.commit() + conn.close() + return {"ok": True} + + +@app.get("/api/sleep-buddy") +def get_buddy_data(): + conn = get_db() + users_rows = conn.execute("SELECT * FROM buddy_users").fetchall() + users = {} + for u in users_rows: + records = conn.execute( + "SELECT date, time FROM buddy_records WHERE username = ? ORDER BY date DESC LIMIT 60", + (u["username"],), + ).fetchall() + users[u["username"]] = [dict(r) for r in records] + + targets = {} + for u in users_rows: + targets[u["username"]] = u["target_time"] + + notifs = conn.execute( + "SELECT * FROM buddy_notifications ORDER BY created_at DESC LIMIT 20" + ).fetchall() + conn.close() + + return { + "users": users, + "targets": targets, + "notifications": [dict(n) for n in notifs], + } + + +class BuddyAction(BaseModel): + user: str + action: str + record: Optional[dict] = None + date: Optional[str] = None + target: Optional[str] = None + +@app.post("/api/sleep-buddy") +def buddy_action(req: BuddyAction): + conn = get_db() + + if req.action == "record": + record = req.record or {} + conn.execute( + "INSERT INTO buddy_records (username, date, time) VALUES (?, ?, ?) " + "ON CONFLICT(username, date) DO UPDATE SET time=?", + (req.user, record.get("date", ""), record.get("time", ""), record.get("time", "")), + ) + conn.commit() + conn.close() + return {"ok": True} + + elif req.action == "delete-record": + conn.execute( + "DELETE FROM buddy_records WHERE username = ? AND date = ?", + (req.user, req.date), + ) + conn.commit() + conn.close() + return {"ok": True} + + elif req.action == "sleep-now": + now = datetime.now() + conn.execute( + "INSERT INTO buddy_notifications (from_user, message, time, date, created_at) " + "VALUES (?, ?, ?, ?, ?)", + ( + req.user, + f"{req.user} 去睡觉啦,你也早点休息!", + now.strftime("%H:%M"), + now.strftime("%Y-%m-%d"), + time.time(), + ), + ) + # Keep only last 20 notifications + conn.execute(""" + DELETE FROM buddy_notifications WHERE id NOT IN ( + SELECT id FROM buddy_notifications ORDER BY created_at DESC LIMIT 20 + ) + """) + conn.commit() + conn.close() + return {"ok": True} + + elif req.action == "get-notifications": + cutoff = time.time() - 86400 + rows = conn.execute( + "SELECT * FROM buddy_notifications WHERE from_user != ? AND created_at > ? ORDER BY created_at DESC", + (req.user, cutoff), + ).fetchall() + conn.close() + return {"notifications": [dict(r) for r in rows]} + + elif req.action == "set-target": + conn.execute( + "UPDATE buddy_users SET target_time = ? WHERE username = ?", + (req.target or "22:00", req.user), + ) + conn.commit() + conn.close() + return {"ok": True} + + conn.close() + return {"ok": True} + + +# ============================================================ +# Backups +# ============================================================ + +@app.get("/api/backups") +def list_backups(): + _ensure_dirs() + backups = sorted(Path(BACKUP_DIR).glob("planner_*.db"), reverse=True) + items = [] + for b in backups[:20]: + items.append({ + "name": b.name, + "size": b.stat().st_size, + "time": b.stem.split("_", 1)[1], + }) + return items + + +@app.post("/api/backup") +def trigger_backup(): + _do_backup() + return {"ok": True} + + +# ============================================================ +# SPA Fallback +# ============================================================ + +@app.get("/{path:path}") +async def spa_fallback(path: str): + # Try static file first + file_path = os.path.join(FRONTEND_DIR, path) + if path and os.path.isfile(file_path): + return FileResponse(file_path) + # SPA fallback + index = os.path.join(FRONTEND_DIR, "index.html") + if os.path.isfile(index): + return FileResponse(index, headers={ + "Cache-Control": "no-cache, no-store, must-revalidate", + }) + return JSONResponse({"error": "Not found"}, status_code=404) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..3df3349 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +aiosqlite==0.20.0 diff --git a/deploy/backup-cronjob.yaml b/deploy/backup-cronjob.yaml new file mode 100644 index 0000000..ef13892 --- /dev/null +++ b/deploy/backup-cronjob.yaml @@ -0,0 +1,50 @@ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: planner-backup-minio + namespace: planner +spec: + schedule: "0 */6 * * *" + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + template: + spec: + containers: + - name: backup + image: python:3.12-alpine + command: + - /bin/sh + - -c + - | + apk add --no-cache curl sqlite >/dev/null 2>&1 + curl -sL https://dl.min.io/client/mc/release/linux-arm64/mc -o /usr/local/bin/mc + chmod +x /usr/local/bin/mc + mc alias set s3 http://minio.minio.svc:9000 admin HpYMIVH0WN79VkzF4L4z8Zx1 + TS=$(date +%Y%m%d_%H%M%S) + # SQLite safe backup + sqlite3 /data/planner.db ".backup /tmp/planner_${TS}.db" + mc cp "/tmp/planner_${TS}.db" "s3/planner-backups/planner_${TS}.db" + # Keep only last 60 backups + mc ls s3/planner-backups/ --json | python3 -c " + import sys, json + files = [] + for line in sys.stdin: + d = json.loads(line) + if d.get('key','').startswith('planner_'): + files.append(d['key']) + files.sort() + for f in files[:-60]: + print(f) + " | while read f; do mc rm "s3/planner-backups/$f"; done + echo "Backup done: ${TS}" + volumeMounts: + - name: data + mountPath: /data + readOnly: true + volumes: + - name: data + persistentVolumeClaim: + claimName: planner-data + restartPolicy: OnFailure diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..31f9a29 --- /dev/null +++ b/deploy/deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: planner + namespace: planner +spec: + replicas: 1 + selector: + matchLabels: + app: planner + template: + metadata: + labels: + app: planner + spec: + containers: + - name: planner + image: planner:latest + ports: + - containerPort: 8000 + env: + - name: DB_PATH + value: /data/planner.db + - name: FRONTEND_DIR + value: /app/frontend + - name: DATA_DIR + value: /data + volumeMounts: + - name: data + mountPath: /data + resources: + requests: + memory: "128Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "500m" + readinessProbe: + httpGet: + path: /api/backups + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + volumes: + - name: data + persistentVolumeClaim: + claimName: planner-data diff --git a/deploy/ingress.yaml b/deploy/ingress.yaml new file mode 100644 index 0000000..62bb1cb --- /dev/null +++ b/deploy/ingress.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: planner + namespace: planner + annotations: + traefik.ingress.kubernetes.io/router.tls.certresolver: le +spec: + ingressClassName: traefik + rules: + - host: planner.oci.euphon.net + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: planner + port: + number: 80 + tls: + - hosts: + - planner.oci.euphon.net diff --git a/deploy/namespace.yaml b/deploy/namespace.yaml new file mode 100644 index 0000000..83e4005 --- /dev/null +++ b/deploy/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: planner diff --git a/deploy/pvc.yaml b/deploy/pvc.yaml new file mode 100644 index 0000000..e8d25f6 --- /dev/null +++ b/deploy/pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: planner-data + namespace: planner +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/deploy/service.yaml b/deploy/service.yaml new file mode 100644 index 0000000..8d1a4e1 --- /dev/null +++ b/deploy/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: planner + namespace: planner +spec: + selector: + app: planner + ports: + - port: 80 + targetPort: 8000 diff --git a/deploy/setup-kubeconfig.sh b/deploy/setup-kubeconfig.sh new file mode 100644 index 0000000..4deb1d3 --- /dev/null +++ b/deploy/setup-kubeconfig.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Creates a restricted kubeconfig for the planner namespace only. +# Run on the k8s server as a user with cluster-admin access. +set -e + +NAMESPACE=planner +SA_NAME=planner-deployer + +echo "Creating ServiceAccount, Role, and RoleBinding..." + +kubectl apply -f - < kubeconfig < 基于 Vue 3 + Vite + Pinia 重构后的前端,对照原始 vanilla JS 单文件实现的所有功能点。 + +## 测试类型说明 + +- **e2e** = Cypress E2E 测试 (真实浏览器 + 后端 API) +- **none** = 尚未覆盖 + +--- + +## 1. 应用加载 (App) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 未登录显示登录遮罩 | e2e | app-load.cy.js | +| 登录框有密码输入和按钮 | e2e | app-load.cy.js | +| 错误密码显示错误提示 | e2e | app-load.cy.js | +| 登录后显示主界面 | e2e | app-load.cy.js | +| 显示 7 个导航 Tab | e2e | app-load.cy.js | +| 菜单按钮打开下拉菜单 | e2e | app-load.cy.js | +| 下拉菜单包含导出/改密/备份/退出 | e2e | app-load.cy.js | + +## 2. 认证与登录 (Auth) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 过期 session 跳转登录 | e2e | auth-flow.cy.js | +| 回车键提交登录 | e2e | auth-flow.cy.js | +| 正确密码登录并存储 session | e2e | auth-flow.cy.js | +| 退出登录清除 session | e2e | auth-flow.cy.js | +| Session 刷新后保持 | e2e | auth-flow.cy.js | +| 修改密码 | none | — | + +## 3. 导航与路由 (Navigation) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 默认 Tab 为随手记 | e2e | navigation.cy.js | +| 点击 Tab 跳转正确路由 | e2e | navigation.cy.js | +| 直接 URL 访问各路由 | e2e | navigation.cy.js | +| 浏览器后退按钮 | e2e | navigation.cy.js | +| 未知路由 SPA 回退 | e2e | navigation.cy.js | +| Sleep Buddy 路由 | e2e | navigation.cy.js | + +## 4. 随手记 (Notes) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示输入区和发送按钮 | e2e | notes-flow.cy.js | +| 显示 8 个标签按钮 | e2e | notes-flow.cy.js | +| 切换标签选中状态 | e2e | notes-flow.cy.js | +| 创建笔记(点击按钮) | e2e | notes-flow.cy.js | +| 创建带标签的笔记 | e2e | notes-flow.cy.js | +| 回车键创建笔记 | e2e | notes-flow.cy.js | +| 创建后清空输入框 | e2e | notes-flow.cy.js | +| 不创建空笔记 | e2e | notes-flow.cy.js | +| 搜索过滤笔记 | e2e | notes-flow.cy.js | +| 按标签筛选笔记 | e2e | notes-flow.cy.js | +| 编辑笔记 | e2e | notes-flow.cy.js | +| 删除笔记 | e2e | notes-flow.cy.js | +| 显示空状态提示 | e2e | notes-flow.cy.js | +| 显示笔记时间 | e2e | notes-flow.cy.js | +| 自动识别内容归档文档 | none | — | + +## 5. 待办事项 (Tasks) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示 3 个子 Tab(待办/目标/清单) | e2e | tasks-flow.cy.js | +| 默认子 Tab 为待办 | e2e | tasks-flow.cy.js | +| 子 Tab 切换 | e2e | tasks-flow.cy.js | +| 收集箱输入 | e2e | tasks-flow.cy.js | +| 添加收集箱条目 | e2e | tasks-flow.cy.js | +| 收集箱分配到象限按钮 | e2e | tasks-flow.cy.js | +| 移入四象限 | e2e | tasks-flow.cy.js | +| 显示 4 个象限 | e2e | tasks-flow.cy.js | +| 直接添加到象限 | e2e | tasks-flow.cy.js | +| 切换完成状态 | e2e | tasks-flow.cy.js | +| 删除待办 | e2e | tasks-flow.cy.js | +| 搜索过滤待办 | e2e | tasks-flow.cy.js | +| 创建目标 | e2e | tasks-flow.cy.js | +| 删除目标 | e2e | tasks-flow.cy.js | +| 创建清单 | e2e | tasks-flow.cy.js | +| 添加清单项目 | e2e | tasks-flow.cy.js | +| 切换清单项完成 | e2e | tasks-flow.cy.js | +| 删除清单 | e2e | tasks-flow.cy.js | +| 目标打卡日历 | none | — | + +## 6. 提醒 (Reminders) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示页面和新增按钮 | e2e | reminders-flow.cy.js | +| 打开新增表单 | e2e | reminders-flow.cy.js | +| 创建带时间的提醒 | e2e | reminders-flow.cy.js | +| 不同重复选项 | e2e | reminders-flow.cy.js | +| 切换启用/禁用 | e2e | reminders-flow.cy.js | +| 删除提醒 | e2e | reminders-flow.cy.js | +| 取消按钮关闭表单 | e2e | reminders-flow.cy.js | +| 空状态提示 | e2e | reminders-flow.cy.js | +| 浏览器通知推送 | none | — | +| Service Worker 后台通知 | none | — | + +## 7. 健康打卡 (Body - Health) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示 4 个子 Tab | e2e | body-health.cy.js | +| 默认健康打卡 Tab | e2e | body-health.cy.js | +| 显示今日日期 | e2e | body-health.cy.js | +| 添加健康项目到池 | e2e | body-health.cy.js | +| 切换项目到月计划 | e2e | body-health.cy.js | +| 打卡项目 | e2e | body-health.cy.js | +| 取消打卡 | e2e | body-health.cy.js | +| 删除池中项目 | e2e | body-health.cy.js | +| 空状态提示 | e2e | body-health.cy.js | +| 月度日历视图 | none | — | +| 年度热力图 | none | — | +| 健康日记 | none | — | + +## 8. 睡眠记录 (Body - Sleep) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示输入框 | e2e | body-sleep.cy.js | +| HH:MM 格式记录 | e2e | body-sleep.cy.js | +| 中文格式记录(10点半) | e2e | body-sleep.cy.js | +| 无法识别时显示错误 | e2e | body-sleep.cy.js | +| 删除睡眠记录 | e2e | body-sleep.cy.js | +| 显示记录明细表 | e2e | body-sleep.cy.js | +| 空状态提示 | e2e | body-sleep.cy.js | +| 睡眠趋势图表 (Canvas) | none | — | +| 月度切换 | none | — | +| 年度热力图 | none | — | +| 目标入睡时间 | none | — | +| "我去睡觉啦"按钮 | none | — | + +## 9. 健身记录 (Body - Gym) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示页面和新增按钮 | e2e | body-gym.cy.js | +| 打开新增表单 | e2e | body-gym.cy.js | +| 创建健身记录 | e2e | body-gym.cy.js | +| 删除健身记录 | e2e | body-gym.cy.js | +| 取消关闭表单 | e2e | body-gym.cy.js | + +## 10. 经期记录 (Body - Period) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示页面和新增按钮 | e2e | body-period.cy.js | +| 打开新增表单 | e2e | body-period.cy.js | +| 创建经期记录(有结束日期) | e2e | body-period.cy.js | +| 创建进行中记录(无结束日期) | e2e | body-period.cy.js | +| 删除经期记录 | e2e | body-period.cy.js | +| 经期周期预测 | none | — | + +## 11. 音乐打卡 (Music) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示今日日期 | e2e | music-flow.cy.js | +| 显示练习项目区 | e2e | music-flow.cy.js | +| 添加音乐项目 | e2e | music-flow.cy.js | +| 切换到月计划 | e2e | music-flow.cy.js | +| 打卡练习 | e2e | music-flow.cy.js | +| 取消打卡 | e2e | music-flow.cy.js | +| 删除项目 | e2e | music-flow.cy.js | +| 空状态提示 | e2e | music-flow.cy.js | +| 月度日历视图 | none | — | +| 年度热力图 | none | — | + +## 12. 个人文档 (Docs) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示页面头部和描述 | e2e | docs-flow.cy.js | +| 显示新建文档按钮 | e2e | docs-flow.cy.js | +| 打开新建表单 | e2e | docs-flow.cy.js | +| 创建文档 | e2e | docs-flow.cy.js | +| 新文档显示 0 条 | e2e | docs-flow.cy.js | +| 点击打开文档详情 | e2e | docs-flow.cy.js | +| 关闭文档详情 | e2e | docs-flow.cy.js | +| 编辑文档信息 | e2e | docs-flow.cy.js | +| 删除文档 | e2e | docs-flow.cy.js | +| 取消不保存 | e2e | docs-flow.cy.js | +| 图标选择器 | e2e | docs-flow.cy.js | +| 提取规则下拉 | e2e | docs-flow.cy.js | +| 文档条目添加 | none | — (API 层已覆盖) | +| 文档条目删除 | none | — (API 层已覆盖) | + +## 13. 日程规划 (Planning - Schedule) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示 3 个子 Tab | e2e | planning-schedule.cy.js | +| 默认日程子 Tab | e2e | planning-schedule.cy.js | +| 显示模块池和时间线 | e2e | planning-schedule.cy.js | +| 显示 18 个时间段 (6-23) | e2e | planning-schedule.cy.js | +| 日期导航 | e2e | planning-schedule.cy.js | +| 切换上/下一天 | e2e | planning-schedule.cy.js | +| 添加活动模块 | e2e | planning-schedule.cy.js | +| 颜色选择器 | e2e | planning-schedule.cy.js | +| 删除活动模块 | e2e | planning-schedule.cy.js | +| 清空当天日程 | e2e | planning-schedule.cy.js | +| 模块可拖拽 | e2e | planning-schedule.cy.js | +| 拖拽放置到时间段 | none | — (Cypress 不支持原生 drag) | +| 导出日程为文本 | none | — | + +## 14. 每周模板 (Planning - Template) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示 7 天按钮 | e2e | planning-template.cy.js | +| 默认选中天 | e2e | planning-template.cy.js | +| 切换不同天 | e2e | planning-template.cy.js | +| 显示模板提示 | e2e | planning-template.cy.js | +| 时间线渲染 | none | — | +| 模板编辑保存 | none | — | + +## 15. 周回顾 (Planning - Review) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 显示回顾表单 | e2e | planning-review.cy.js | +| 表单有 3 个区域 | e2e | planning-review.cy.js | +| 保存回顾 | e2e | planning-review.cy.js | +| 历史回顾标题 | e2e | planning-review.cy.js | +| 历史展开/折叠 | e2e | planning-review.cy.js | + +## 16. 睡眠打卡 (Sleep Buddy) + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 未登录显示登录表单 | e2e | sleep-buddy.cy.js | +| 登录表单有用户名密码 | e2e | sleep-buddy.cy.js | +| 登录/注册模式切换 | e2e | sleep-buddy.cy.js | +| 注册显示确认密码 | e2e | sleep-buddy.cy.js | +| 密码不一致报错 | e2e | sleep-buddy.cy.js | +| 注册后自动登录 | e2e | sleep-buddy.cy.js | +| 登录后显示主界面 | e2e | sleep-buddy.cy.js | +| 显示目标时间 | e2e | sleep-buddy.cy.js | +| 显示记录输入 | e2e | sleep-buddy.cy.js | +| 记录睡眠时间 | e2e | sleep-buddy.cy.js | +| 无法识别报错 | e2e | sleep-buddy.cy.js | +| "我去睡觉啦"按钮 | e2e | sleep-buddy.cy.js | +| 用户菜单退出 | e2e | sleep-buddy.cy.js | +| 退出返回登录 | e2e | sleep-buddy.cy.js | +| 数据对比统计 | none | — | +| 睡眠趋势图 (Canvas) | none | — | +| 修改目标时间 | none | — | +| 删除记录 | none | — (API 已覆盖) | + +## 17. API 健康检查 + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| GET /api/notes 返回数组 | e2e | api-health.cy.js | +| GET /api/todos 返回数组 | e2e | api-health.cy.js | +| GET /api/inbox 返回数组 | e2e | api-health.cy.js | +| GET /api/reminders 返回数组 | e2e | api-health.cy.js | +| GET /api/goals 返回数组 | e2e | api-health.cy.js | +| GET /api/checklists 返回数组 | e2e | api-health.cy.js | +| GET /api/sleep 返回数组 | e2e | api-health.cy.js | +| GET /api/gym 返回数组 | e2e | api-health.cy.js | +| GET /api/period 返回数组 | e2e | api-health.cy.js | +| GET /api/docs 返回数组 | e2e | api-health.cy.js | +| GET /api/bugs 返回数组 | e2e | api-health.cy.js | +| GET /api/reviews 返回数组 | e2e | api-health.cy.js | +| GET /api/schedule-modules 返回数组 | e2e | api-health.cy.js | +| GET /api/schedule-slots 返回数组 | e2e | api-health.cy.js | +| GET /api/weekly-template 返回数组 | e2e | api-health.cy.js | +| GET /api/health-items 返回数组 | e2e | api-health.cy.js | +| GET /api/health-plans 返回数组 | e2e | api-health.cy.js | +| GET /api/health-checks 返回数组 | e2e | api-health.cy.js | +| GET /api/backups 返回数组 | e2e | api-health.cy.js | +| GET /api/sleep-buddy 返回对象 | e2e | api-health.cy.js | +| POST /api/login 错误密码 401 | e2e | api-health.cy.js | + +## 18. API CRUD 操作 + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| Notes: 创建/删除 | e2e | api-crud.cy.js | +| Todos: 创建/更新(upsert)/删除 | e2e | api-crud.cy.js | +| Inbox: 创建/清空 | e2e | api-crud.cy.js | +| Reminders: 创建/删除 | e2e | api-crud.cy.js | +| Goals: 创建/更新 | e2e | api-crud.cy.js | +| Checklists: 创建 | e2e | api-crud.cy.js | +| Sleep: 创建/Upsert/删除 | e2e | api-crud.cy.js | +| Gym: 创建 | e2e | api-crud.cy.js | +| Period: 创建 | e2e | api-crud.cy.js | +| Docs: 创建 + 创建条目 + 验证嵌套 | e2e | api-crud.cy.js | +| Bugs: 创建/删除 | e2e | api-crud.cy.js | +| Schedule Modules: 创建 | e2e | api-crud.cy.js | +| Schedule Slots: 创建 | e2e | api-crud.cy.js | +| Reviews: 创建 | e2e | api-crud.cy.js | +| Health Items: 创建 | e2e | api-crud.cy.js | +| Health Plans: 保存 | e2e | api-crud.cy.js | +| Health Checks: 切换 | e2e | api-crud.cy.js | +| Backup: 触发备份 | e2e | api-crud.cy.js | + +## 19. 响应式布局 + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 手机端 (375px) 渲染 | e2e | responsive.cy.js | +| 平板端 (768px) 渲染 | e2e | responsive.cy.js | +| 桌面端 (1280px) 渲染 | e2e | responsive.cy.js | +| 宽屏 (1920px) 渲染 | e2e | responsive.cy.js | +| 手机端 Tab 可滚动 | e2e | responsive.cy.js | +| 手机端四象限垂直堆叠 | e2e | responsive.cy.js | +| 手机端日程垂直堆叠 | e2e | responsive.cy.js | + +## 20. 性能 + +| 功能点 | 测试 | 文件 | +|--------|------|------| +| 页面加载 < 5s | e2e | performance.cy.js | +| API 响应 < 1s | e2e | performance.cy.js | +| Tab 切换瞬时 | e2e | performance.cy.js | +| 批量数据不崩溃 | e2e | performance.cy.js | + +--- + +## 覆盖统计 + +| 指标 | 数量 | +|------|------| +| **功能点总数** | ~148 | +| **已覆盖功能点** | ~126 | +| **Cypress E2E 测试** | **196** | +| **测试文件数** | **20** | +| **功能点覆盖率** | **~85%** | + +### 未覆盖的高风险功能 + +| 优先级 | 功能 | 风险 | 说明 | +|--------|------|------|------| +| P0 | Canvas 图表渲染 (睡眠趋势) | HIGH | 原版有 Canvas 图表,新版尚未实现 | +| P0 | 拖拽放置到时间段 | HIGH | Cypress 不支持原生 HTML5 DnD | +| P1 | 浏览器通知推送 | MED | Service Worker + 权限 API | +| P1 | 修改密码功能 | MED | 涉及安全敏感操作 | +| P1 | 健康/音乐月度日历 | MED | 日历视图尚未完整实现 | +| P1 | 年度热力图 | MED | 健康/音乐/睡眠的年度视图 | +| P2 | 经期周期预测 | MED | 统计计算逻辑 | +| P2 | Sleep Buddy 数据对比 | MED-LOW | 多用户对比统计 | +| P2 | 自动识别内容归档文档 | MED-LOW | 关键词匹配 + 提取规则 | +| P3 | 导出日程为文本 | LOW | 简单格式化 | +| P3 | 健康日记 | LOW | 额外输入区域 | + +### 覆盖最充分的功能 + +1. **API CRUD 全覆盖** — 27 tests 覆盖所有 18 个资源的增删改查 +2. **API 健康检查** — 21 tests 验证全部 20+ 端点返回正确格式 +3. **随手记 (Notes)** — 14 tests 覆盖创建/编辑/删除/搜索/筛选 +4. **待办系统 (Tasks)** — 18 tests 覆盖收集箱/四象限/目标/清单 +5. **睡眠打卡 (Sleep Buddy)** — 14 tests 覆盖注册/登录/记录/通知 +6. **文档管理 (Docs)** — 12 tests 覆盖创建/编辑/删除/详情/图标 +7. **响应式布局** — 7 tests 覆盖 4 种视口 diff --git a/frontend/cypress.config.js b/frontend/cypress.config.js new file mode 100644 index 0000000..3732a66 --- /dev/null +++ b/frontend/cypress.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'cypress' + +export default defineConfig({ + e2e: { + baseUrl: 'http://localhost:5173', + supportFile: 'cypress/support/e2e.js', + specPattern: 'cypress/e2e/**/*.cy.{js,ts}', + viewportWidth: 1280, + viewportHeight: 800, + video: true, + videoCompression: false, + }, +}) diff --git a/frontend/cypress/e2e/api-crud.cy.js b/frontend/cypress/e2e/api-crud.cy.js new file mode 100644 index 0000000..3c7e777 --- /dev/null +++ b/frontend/cypress/e2e/api-crud.cy.js @@ -0,0 +1,231 @@ +describe('API CRUD Operations', () => { + const uid = () => 'cy_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + + // ---- Notes ---- + it('POST /api/notes creates a note', () => { + const id = uid() + cy.request('POST', '/api/notes', { id, text: 'E2E test note', tag: '灵感' }).then(res => { + expect(res.status).to.eq(200) + expect(res.body.ok).to.be.true + }) + }) + + it('DELETE /api/notes/:id deletes a note', () => { + const id = uid() + cy.request('POST', '/api/notes', { id, text: 'to delete', tag: '灵感' }) + cy.request('DELETE', `/api/notes/${id}`).then(res => { + expect(res.status).to.eq(200) + }) + }) + + // ---- Todos ---- + it('POST /api/todos creates a todo', () => { + const id = uid() + cy.request('POST', '/api/todos', { id, text: 'E2E todo', quadrant: 'q1', done: 0 }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + it('POST /api/todos updates a todo (upsert)', () => { + const id = uid() + cy.request('POST', '/api/todos', { id, text: 'before', quadrant: 'q1', done: 0 }) + cy.request('POST', '/api/todos', { id, text: 'after', quadrant: 'q2', done: 1 }).then(res => { + expect(res.body.ok).to.be.true + }) + cy.request('/api/todos').then(res => { + const todo = res.body.find(t => t.id === id) + expect(todo.text).to.eq('after') + expect(todo.quadrant).to.eq('q2') + expect(todo.done).to.eq(1) + }) + }) + + it('DELETE /api/todos/:id deletes a todo', () => { + const id = uid() + cy.request('POST', '/api/todos', { id, text: 'to delete', quadrant: 'q1', done: 0 }) + cy.request('DELETE', `/api/todos/${id}`).then(res => { + expect(res.status).to.eq(200) + }) + }) + + // ---- Inbox ---- + it('POST /api/inbox creates inbox item', () => { + const id = uid() + cy.request('POST', '/api/inbox', { id, text: 'inbox test' }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + it('DELETE /api/inbox clears all inbox', () => { + cy.request('DELETE', '/api/inbox').then(res => { + expect(res.body.ok).to.be.true + }) + cy.request('/api/inbox').then(res => { + expect(res.body).to.have.length(0) + }) + }) + + // ---- Reminders ---- + it('POST /api/reminders creates a reminder', () => { + const id = uid() + cy.request('POST', '/api/reminders', { id, text: 'test reminder', time: '09:00', repeat: 'daily', enabled: 1 }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + it('DELETE /api/reminders/:id deletes a reminder', () => { + const id = uid() + cy.request('POST', '/api/reminders', { id, text: 'to delete', repeat: 'none', enabled: 1 }) + cy.request('DELETE', `/api/reminders/${id}`).then(res => { + expect(res.status).to.eq(200) + }) + }) + + // ---- Goals ---- + it('POST /api/goals creates and updates a goal', () => { + const id = uid() + cy.request('POST', '/api/goals', { id, name: 'test goal', month: '2026-06', checks: '{}' }).then(res => { + expect(res.body.ok).to.be.true + }) + cy.request('POST', '/api/goals', { id, name: 'updated goal', month: '2026-07', checks: '{"2026-07-01":true}' }) + cy.request('/api/goals').then(res => { + const goal = res.body.find(g => g.id === id) + expect(goal.name).to.eq('updated goal') + }) + }) + + // ---- Checklists ---- + it('POST /api/checklists creates a checklist', () => { + const id = uid() + cy.request('POST', '/api/checklists', { id, title: 'test list', items: '[]', archived: 0 }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + // ---- Sleep ---- + it('POST /api/sleep creates a record', () => { + cy.request('POST', '/api/sleep', { date: '2026-01-01', time: '22:30' }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + it('POST /api/sleep upserts on same date', () => { + cy.request('POST', '/api/sleep', { date: '2026-01-02', time: '22:00' }) + cy.request('POST', '/api/sleep', { date: '2026-01-02', time: '23:00' }) + cy.request('/api/sleep').then(res => { + const rec = res.body.find(r => r.date === '2026-01-02') + expect(rec.time).to.eq('23:00') + }) + }) + + it('DELETE /api/sleep/:date deletes a record', () => { + cy.request('POST', '/api/sleep', { date: '2026-01-03', time: '21:00' }) + cy.request('DELETE', '/api/sleep/2026-01-03').then(res => { + expect(res.status).to.eq(200) + }) + }) + + // ---- Gym ---- + it('POST /api/gym creates a record', () => { + const id = uid() + cy.request('POST', '/api/gym', { id, date: '2026-04-07', type: '跑步', duration: '30min', note: '5km' }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + // ---- Period ---- + it('POST /api/period creates a record', () => { + const id = uid() + cy.request('POST', '/api/period', { id, start_date: '2026-04-01', end_date: '2026-04-05', note: '' }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + // ---- Docs ---- + it('POST /api/docs creates a doc', () => { + const id = uid() + cy.request('POST', '/api/docs', { id, name: 'test doc', icon: '📖', keywords: 'test', extract_rule: 'none' }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + it('POST /api/doc-entries creates an entry', () => { + const docId = uid() + const entryId = uid() + cy.request('POST', '/api/docs', { id: docId, name: 'doc for entry', icon: '📄', keywords: '', extract_rule: 'none' }) + cy.request('POST', '/api/doc-entries', { id: entryId, doc_id: docId, text: 'entry text' }).then(res => { + expect(res.body.ok).to.be.true + }) + cy.request('/api/docs').then(res => { + const doc = res.body.find(d => d.id === docId) + expect(doc.entries).to.have.length(1) + expect(doc.entries[0].text).to.eq('entry text') + }) + }) + + // ---- Bugs ---- + it('POST /api/bugs creates a bug', () => { + const id = uid() + cy.request('POST', '/api/bugs', { id, text: 'test bug', status: 'open' }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + it('DELETE /api/bugs/:id deletes a bug', () => { + const id = uid() + cy.request('POST', '/api/bugs', { id, text: 'to delete', status: 'open' }) + cy.request('DELETE', `/api/bugs/${id}`).then(res => { + expect(res.status).to.eq(200) + }) + }) + + // ---- Schedule ---- + it('POST /api/schedule-modules creates a module', () => { + const id = uid() + cy.request('POST', '/api/schedule-modules', { id, name: 'work', emoji: '💼', color: '#667eea', sort_order: 0 }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + it('POST /api/schedule-slots creates a slot', () => { + const modId = uid() + cy.request('POST', '/api/schedule-modules', { id: modId, name: 'slot test', emoji: '📌', color: '#333', sort_order: 0 }) + cy.request('POST', '/api/schedule-slots', { date: '2026-04-07', time_slot: '09:00', module_id: modId }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + // ---- Reviews ---- + it('POST /api/reviews creates a review', () => { + cy.request('POST', '/api/reviews', { week: '2026-W15', data: '{"wins":"test"}' }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + // ---- Health check-in ---- + it('POST /api/health-items creates an item', () => { + const id = uid() + cy.request('POST', '/api/health-items', { id, name: 'vitamin C', type: 'health' }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + it('POST /api/health-plans saves a plan', () => { + cy.request('POST', '/api/health-plans', { month: '2026-04', type: 'health', item_ids: '["item1"]' }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + it('POST /api/health-checks toggles a check', () => { + cy.request('POST', '/api/health-checks', { date: '2026-04-07', type: 'health', item_id: 'item1', checked: 1 }).then(res => { + expect(res.body.ok).to.be.true + }) + }) + + // ---- Backup ---- + it('POST /api/backup triggers backup', () => { + cy.request('POST', '/api/backup').then(res => { + expect(res.body.ok).to.be.true + }) + }) +}) diff --git a/frontend/cypress/e2e/api-health.cy.js b/frontend/cypress/e2e/api-health.cy.js new file mode 100644 index 0000000..a678d15 --- /dev/null +++ b/frontend/cypress/e2e/api-health.cy.js @@ -0,0 +1,153 @@ +describe('API Health', () => { + it('GET /api/notes returns array', () => { + cy.request('/api/notes').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/todos returns array', () => { + cy.request('/api/todos').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/inbox returns array', () => { + cy.request('/api/inbox').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/reminders returns array', () => { + cy.request('/api/reminders').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/goals returns array', () => { + cy.request('/api/goals').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/checklists returns array', () => { + cy.request('/api/checklists').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/sleep returns array', () => { + cy.request('/api/sleep').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/gym returns array', () => { + cy.request('/api/gym').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/period returns array', () => { + cy.request('/api/period').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/docs returns array', () => { + cy.request('/api/docs').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/bugs returns array', () => { + cy.request('/api/bugs').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/reviews returns array', () => { + cy.request('/api/reviews').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/schedule-modules returns array', () => { + cy.request('/api/schedule-modules').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/schedule-slots returns array', () => { + cy.request('/api/schedule-slots').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/weekly-template returns array', () => { + cy.request('/api/weekly-template').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/health-items returns array', () => { + cy.request('/api/health-items?type=health').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/health-plans returns array', () => { + cy.request('/api/health-plans?type=health').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/health-checks returns array', () => { + cy.request('/api/health-checks?type=health').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/backups returns array', () => { + cy.request('/api/backups').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.be.an('array') + }) + }) + + it('GET /api/sleep-buddy returns buddy data', () => { + cy.request('/api/sleep-buddy').then(res => { + expect(res.status).to.eq(200) + expect(res.body).to.have.property('users') + expect(res.body).to.have.property('notifications') + }) + }) + + it('POST /api/login rejects wrong password', () => { + cy.request({ + method: 'POST', + url: '/api/login', + body: { hash: 'wrong_hash' }, + failOnStatusCode: false, + }).then(res => { + expect(res.status).to.eq(401) + }) + }) +}) diff --git a/frontend/cypress/e2e/app-load.cy.js b/frontend/cypress/e2e/app-load.cy.js new file mode 100644 index 0000000..a49d9af --- /dev/null +++ b/frontend/cypress/e2e/app-load.cy.js @@ -0,0 +1,61 @@ +describe('App Loading', () => { + it('shows login overlay when not authenticated', () => { + cy.visit('/') + cy.get('.login-overlay').should('be.visible') + cy.contains('Hera\'s Planner').should('be.visible') + }) + + it('login overlay has password input and submit button', () => { + cy.visit('/') + cy.get('.login-input[type="password"]').should('be.visible') + cy.get('.login-btn').should('be.visible') + }) + + it('shows login error for wrong password', () => { + cy.visit('/') + cy.get('.login-input').type('wrongpassword') + cy.get('.login-btn').click() + cy.get('.login-error').should('not.be.empty') + }) + + it('loads main app after successful login', () => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('.login-overlay').should('not.exist') + cy.get('header').should('be.visible') + cy.contains("Hera's Planner").should('be.visible') + }) + + it('shows all 7 navigation tabs', () => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('.tab-btn').should('have.length', 7) + cy.get('.tab-btn').eq(0).should('contain', '随手记') + cy.get('.tab-btn').eq(1).should('contain', '待办') + cy.get('.tab-btn').eq(2).should('contain', '提醒') + cy.get('.tab-btn').eq(3).should('contain', '身体') + cy.get('.tab-btn').eq(4).should('contain', '音乐') + cy.get('.tab-btn').eq(5).should('contain', '文档') + cy.get('.tab-btn').eq(6).should('contain', '日程') + }) + + it('header menu button opens dropdown', () => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('.header-menu-btn').click() + cy.get('.header-dropdown.open').should('be.visible') + cy.contains('退出登录').should('be.visible') + cy.contains('修改密码').should('be.visible') + cy.contains('导出数据').should('be.visible') + cy.contains('手动备份').should('be.visible') + }) +}) diff --git a/frontend/cypress/e2e/auth-flow.cy.js b/frontend/cypress/e2e/auth-flow.cy.js new file mode 100644 index 0000000..181098e --- /dev/null +++ b/frontend/cypress/e2e/auth-flow.cy.js @@ -0,0 +1,55 @@ +describe('Authentication Flow', () => { + it('redirects to login when session expired', () => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() - 1000)) + } + }) + cy.get('.login-overlay').should('be.visible') + }) + + it('login form accepts Enter key', () => { + cy.visit('/') + cy.get('.login-input').type('123456{enter}') + // Should attempt login (success or fail depends on backend) + cy.wait(500) + }) + + it('valid login stores session and shows app', () => { + cy.visit('/') + cy.get('.login-input').type('123456') + cy.get('.login-btn').click() + // If default password matches, should show main app + cy.get('header', { timeout: 5000 }).should('be.visible') + cy.window().then(win => { + expect(win.localStorage.getItem('sp_login_expires')).to.not.be.null + }) + }) + + it('logout clears session and shows login', () => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + cy.get('.header-menu-btn').click() + cy.contains('退出登录').click() + cy.get('.login-overlay').should('be.visible') + cy.window().then(win => { + expect(win.localStorage.getItem('sp_login_expires')).to.be.null + }) + }) + + it('session persists across page reloads', () => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + cy.reload() + cy.get('header').should('be.visible') + cy.get('.login-overlay').should('not.exist') + }) +}) diff --git a/frontend/cypress/e2e/body-gym.cy.js b/frontend/cypress/e2e/body-gym.cy.js new file mode 100644 index 0000000..fed0be3 --- /dev/null +++ b/frontend/cypress/e2e/body-gym.cy.js @@ -0,0 +1,45 @@ +describe('Body - Gym (身体-健身)', () => { + beforeEach(() => { + cy.visit('/body', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('.sub-tab').contains('健身').click() + }) + + it('shows gym section with add button', () => { + cy.contains('健身记录').should('be.visible') + cy.get('.btn-accent').should('contain', '记录') + }) + + it('opens add gym form', () => { + cy.get('.btn-accent').contains('记录').click() + cy.get('.edit-form').should('be.visible') + cy.get('.edit-form input[type="date"]').should('exist') + }) + + it('creates a gym record', () => { + cy.get('.btn-accent').contains('记录').click() + cy.get('.edit-form input').eq(1).type('跑步') + cy.get('.edit-form input').eq(2).type('30分钟') + cy.get('.edit-form input').eq(3).type('5公里') + cy.get('.btn-accent').contains('保存').click() + cy.get('.record-card').should('contain', '跑步') + cy.get('.record-card').should('contain', '30分钟') + }) + + it('deletes a gym record', () => { + cy.get('.btn-accent').contains('记录').click() + cy.get('.edit-form input').eq(1).type('待删除运动') + cy.get('.btn-accent').contains('保存').click() + cy.get('.record-card').contains('待删除运动').parent().find('.remove-btn').click() + cy.get('.record-card').should('not.contain', '待删除运动') + }) + + it('cancel button closes form', () => { + cy.get('.btn-accent').contains('记录').click() + cy.get('.btn-close').contains('取消').click() + cy.get('.edit-form').should('not.exist') + }) +}) diff --git a/frontend/cypress/e2e/body-health.cy.js b/frontend/cypress/e2e/body-health.cy.js new file mode 100644 index 0000000..bd68f59 --- /dev/null +++ b/frontend/cypress/e2e/body-health.cy.js @@ -0,0 +1,70 @@ +describe('Body - Health Check-in (身体-健康打卡)', () => { + beforeEach(() => { + cy.visit('/body', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + }) + + it('shows 4 sub tabs', () => { + cy.get('.sub-tab').should('have.length', 4) + cy.get('.sub-tab').eq(0).should('contain', '健康打卡') + cy.get('.sub-tab').eq(1).should('contain', '睡眠') + cy.get('.sub-tab').eq(2).should('contain', '健身') + cy.get('.sub-tab').eq(3).should('contain', '经期') + }) + + it('defaults to health check-in tab', () => { + cy.get('.sub-tab').contains('健康打卡').should('have.class', 'active') + }) + + it('shows today date and check-in section', () => { + cy.get('.section-header').should('contain', '今日打卡') + cy.get('.date-label').should('not.be.empty') + }) + + it('adds a health item to pool', () => { + cy.get('.add-row input').type('维生素D') + cy.get('.add-row .btn-accent').click() + cy.get('.pool-item').should('contain', '维生素D') + }) + + it('toggles item into/out of monthly plan', () => { + cy.get('.add-row input').type('益生菌') + cy.get('.add-row .btn-accent').click() + cy.get('.pool-item').contains('益生菌').click() + // Item should now appear in today checkin grid + cy.get('.checkin-item').should('contain', '益生菌') + }) + + it('checks in a health item', () => { + cy.get('.add-row input').type('打卡测试项') + cy.get('.add-row .btn-accent').click() + cy.get('.pool-item').contains('打卡测试项').click() + cy.get('.checkin-item').contains('打卡测试项').click() + cy.get('.checkin-item').contains('打卡测试项').should('have.class', 'checked') + }) + + it('unchecks a health item', () => { + cy.get('.add-row input').type('取消打卡项') + cy.get('.add-row .btn-accent').click() + cy.get('.pool-item').contains('取消打卡项').click() + cy.get('.checkin-item').contains('取消打卡项').click() + cy.get('.checkin-item').contains('取消打卡项').should('have.class', 'checked') + cy.get('.checkin-item').contains('取消打卡项').click() + cy.get('.checkin-item').contains('取消打卡项').should('not.have.class', 'checked') + }) + + it('deletes a health item from pool', () => { + cy.get('.add-row input').type('删除测试项') + cy.get('.add-row .btn-accent').click() + cy.get('.pool-item').contains('删除测试项').parent().find('.remove-btn').click() + cy.get('.pool-item').should('not.contain', '删除测试项') + }) + + it('shows empty hint when no plan items', () => { + cy.get('.body-layout').should('be.visible') + }) +}) diff --git a/frontend/cypress/e2e/body-period.cy.js b/frontend/cypress/e2e/body-period.cy.js new file mode 100644 index 0000000..98bca81 --- /dev/null +++ b/frontend/cypress/e2e/body-period.cy.js @@ -0,0 +1,45 @@ +describe('Body - Period (身体-经期)', () => { + beforeEach(() => { + cy.visit('/body', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('.sub-tab').contains('经期').click() + }) + + it('shows period section with add button', () => { + cy.contains('经期记录').should('be.visible') + cy.get('.btn-accent').should('contain', '记录') + }) + + it('opens add period form', () => { + cy.get('.btn-accent').contains('记录').click() + cy.get('.edit-form').should('be.visible') + cy.get('.edit-form input[type="date"]').should('have.length', 2) + }) + + it('creates a period record', () => { + cy.get('.btn-accent').contains('记录').click() + cy.get('.edit-form input[type="date"]').first().type('2026-04-01') + cy.get('.edit-form input[type="date"]').eq(1).type('2026-04-05') + cy.get('.btn-accent').contains('保存').click() + cy.get('.record-card').should('contain', '2026-04-01') + cy.get('.record-card').should('contain', '2026-04-05') + }) + + it('creates period record without end date', () => { + cy.get('.btn-accent').contains('记录').click() + cy.get('.edit-form input[type="date"]').first().type('2026-04-07') + cy.get('.btn-accent').contains('保存').click() + cy.get('.record-card').should('contain', '进行中') + }) + + it('deletes a period record', () => { + cy.get('.btn-accent').contains('记录').click() + cy.get('.edit-form input[type="date"]').first().type('2026-03-01') + cy.get('.btn-accent').contains('保存').click() + cy.get('.record-card').contains('2026-03-01').parent().find('.remove-btn').click() + cy.get('.record-card').should('not.contain', '2026-03-01') + }) +}) diff --git a/frontend/cypress/e2e/body-sleep.cy.js b/frontend/cypress/e2e/body-sleep.cy.js new file mode 100644 index 0000000..8e41a96 --- /dev/null +++ b/frontend/cypress/e2e/body-sleep.cy.js @@ -0,0 +1,53 @@ +describe('Body - Sleep (身体-睡眠)', () => { + beforeEach(() => { + cy.visit('/body', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('.sub-tab').contains('睡眠').click() + }) + + it('shows sleep record input', () => { + cy.get('.capture-input').should('be.visible') + cy.get('.btn-accent').should('be.visible') + }) + + it('records sleep time with HH:MM format', () => { + cy.get('.capture-input').type('22:30') + cy.get('.btn-accent').contains('记录').click() + cy.get('.sleep-hint').should('contain', '已记录') + cy.get('.data-table').should('contain', '22:30') + }) + + it('records sleep time with Chinese format', () => { + cy.get('.capture-input').type('10点半') + cy.get('.btn-accent').contains('记录').click() + cy.get('.sleep-hint').should('contain', '已记录') + cy.get('.data-table').should('contain', '10:30') + }) + + it('shows error for unrecognized time', () => { + cy.get('.capture-input').type('随便写写') + cy.get('.btn-accent').contains('记录').click() + cy.get('.sleep-hint').should('contain', '无法识别') + }) + + it('deletes a sleep record', () => { + cy.get('.capture-input').type('23:00') + cy.get('.btn-accent').contains('记录').click() + cy.get('.data-table .remove-btn').first().click() + }) + + it('shows record detail table', () => { + cy.get('.capture-input').type('21:45') + cy.get('.btn-accent').contains('记录').click() + cy.get('.data-table th').should('contain', '日期') + cy.get('.data-table th').should('contain', '入睡时间') + }) + + it('shows empty hint when no records', () => { + // Component handles both states + cy.get('.sleep-section').should('be.visible') + }) +}) diff --git a/frontend/cypress/e2e/docs-flow.cy.js b/frontend/cypress/e2e/docs-flow.cy.js new file mode 100644 index 0000000..242ed58 --- /dev/null +++ b/frontend/cypress/e2e/docs-flow.cy.js @@ -0,0 +1,105 @@ +describe('Docs (文档)', () => { + beforeEach(() => { + cy.visit('/docs', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + }) + + it('shows docs page with header', () => { + cy.contains('个人文档').should('be.visible') + cy.contains('随手记会自动识别内容').should('be.visible') + }) + + it('shows add document button', () => { + cy.get('.btn-accent').should('contain', '新建文档') + }) + + it('opens new document form', () => { + cy.get('.btn-accent').contains('新建文档').click() + cy.get('.edit-panel').should('be.visible') + cy.contains('文档名称').should('be.visible') + cy.contains('图标').should('be.visible') + cy.contains('关键词').should('be.visible') + cy.contains('提取规则').should('be.visible') + }) + + it('creates a document', () => { + cy.get('.btn-accent').contains('新建文档').click() + cy.get('.edit-panel input').first().type('读书记录') + cy.get('.emoji-pick').contains('📖').click() + cy.get('.edit-panel input').eq(1).type('读完,看完') + cy.get('.btn-accent').contains('保存').click() + cy.get('.doc-card').should('contain', '读书记录') + cy.get('.doc-card').should('contain', '📖') + }) + + it('shows 0 entries for new document', () => { + cy.get('.btn-accent').contains('新建文档').click() + cy.get('.edit-panel input').first().type('空文档') + cy.get('.btn-accent').contains('保存').click() + cy.get('.doc-card').contains('空文档').parent().should('contain', '0 条') + }) + + it('opens document detail on click', () => { + cy.get('.btn-accent').contains('新建文档').click() + cy.get('.edit-panel input').first().type('详情测试') + cy.get('.btn-accent').contains('保存').click() + cy.get('.doc-card').contains('详情测试').click() + cy.get('.overlay.open').should('be.visible') + cy.get('.panel').should('contain', '详情测试') + }) + + it('closes document detail', () => { + cy.get('.btn-accent').contains('新建文档').click() + cy.get('.edit-panel input').first().type('关闭测试') + cy.get('.btn-accent').contains('保存').click() + cy.get('.doc-card').contains('关闭测试').click() + cy.get('.btn-close').contains('关闭').click() + cy.get('.overlay.open').should('not.exist') + }) + + it('edits a document from detail view', () => { + cy.get('.btn-accent').contains('新建文档').click() + cy.get('.edit-panel input').first().type('编辑前') + cy.get('.btn-accent').contains('保存').click() + cy.get('.doc-card').contains('编辑前').click() + cy.get('.btn-close').contains('编辑').click() + cy.get('.edit-panel input').first().clear().type('编辑后') + cy.get('.btn-accent').contains('保存').click() + cy.get('.doc-card').should('contain', '编辑后') + }) + + it('deletes a document from detail view', () => { + cy.get('.btn-accent').contains('新建文档').click() + cy.get('.edit-panel input').first().type('待删除文档') + cy.get('.btn-accent').contains('保存').click() + cy.get('.doc-card').contains('待删除文档').click() + cy.get('.btn-close').contains('删除').click() + cy.get('.doc-card').should('not.contain', '待删除文档') + }) + + it('cancel button closes form without saving', () => { + cy.get('.btn-accent').contains('新建文档').click() + cy.get('.edit-panel input').first().type('取消测试') + cy.get('.btn-close').contains('取消').click() + cy.get('.edit-panel').should('not.exist') + cy.get('.doc-card').should('not.contain', '取消测试') + }) + + it('emoji picker works', () => { + cy.get('.btn-accent').contains('新建文档').click() + cy.get('.emoji-pick').should('have.length.gte', 10) + cy.get('.emoji-pick').contains('🌙').click() + cy.get('.emoji-pick').contains('🌙').should('have.class', 'active') + }) + + it('extract rule dropdown has options', () => { + cy.get('.btn-accent').contains('新建文档').click() + cy.get('.edit-panel select option').should('have.length', 3) + cy.get('.edit-panel select').select('sleep') + cy.get('.edit-panel select').should('have.value', 'sleep') + }) +}) diff --git a/frontend/cypress/e2e/music-flow.cy.js b/frontend/cypress/e2e/music-flow.cy.js new file mode 100644 index 0000000..0a2c86d --- /dev/null +++ b/frontend/cypress/e2e/music-flow.cy.js @@ -0,0 +1,61 @@ +describe('Music (音乐打卡)', () => { + beforeEach(() => { + cy.visit('/music', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + }) + + it('shows music page with today date', () => { + cy.get('.section-header').should('contain', '今日练习') + cy.get('.date-label').should('not.be.empty') + }) + + it('shows practice items section', () => { + cy.contains('练习项目').should('be.visible') + }) + + it('adds a music item to pool', () => { + cy.get('.add-row input').type('尤克里里') + cy.get('.add-row .btn-accent').click() + cy.get('.pool-item').should('contain', '尤克里里') + }) + + it('adds item to monthly plan', () => { + cy.get('.add-row input').type('钢琴') + cy.get('.add-row .btn-accent').click() + cy.get('.pool-item').contains('钢琴').click() + cy.get('.checkin-item').should('contain', '钢琴') + }) + + it('checks in a music practice', () => { + cy.get('.add-row input').type('吉他') + cy.get('.add-row .btn-accent').click() + cy.get('.pool-item').contains('吉他').click() + cy.get('.checkin-item').contains('吉他').click() + cy.get('.checkin-item').contains('吉他').should('have.class', 'checked') + }) + + it('unchecks a music practice', () => { + cy.get('.add-row input').type('架子鼓') + cy.get('.add-row .btn-accent').click() + cy.get('.pool-item').contains('架子鼓').click() + cy.get('.checkin-item').contains('架子鼓').click() + cy.get('.checkin-item').contains('架子鼓').should('have.class', 'checked') + cy.get('.checkin-item').contains('架子鼓').click() + cy.get('.checkin-item').contains('架子鼓').should('not.have.class', 'checked') + }) + + it('deletes a music item', () => { + cy.get('.add-row input').type('待删除乐器') + cy.get('.add-row .btn-accent').click() + cy.get('.pool-item').contains('待删除乐器').parent().find('.remove-btn').click() + cy.get('.pool-item').should('not.contain', '待删除乐器') + }) + + it('empty state shows hint', () => { + cy.get('.music-layout').should('be.visible') + }) +}) diff --git a/frontend/cypress/e2e/navigation.cy.js b/frontend/cypress/e2e/navigation.cy.js new file mode 100644 index 0000000..a38346f --- /dev/null +++ b/frontend/cypress/e2e/navigation.cy.js @@ -0,0 +1,71 @@ +describe('Navigation & Routing', () => { + beforeEach(() => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + }) + + it('default tab is 随手记', () => { + cy.get('.tab-btn').contains('随手记').should('have.class', 'active') + cy.url().should('eq', Cypress.config('baseUrl') + '/') + }) + + it('clicking tab navigates to correct route', () => { + const tabs = [ + { label: '待办', path: '/tasks' }, + { label: '提醒', path: '/reminders' }, + { label: '身体', path: '/body' }, + { label: '音乐', path: '/music' }, + { label: '文档', path: '/docs' }, + { label: '日程', path: '/planning' }, + { label: '随手记', path: '/' }, + ] + tabs.forEach(({ label, path }) => { + cy.get('.tab-btn').contains(label).click() + cy.url().should('include', path === '/' ? Cypress.config('baseUrl') : path) + cy.get('.tab-btn').contains(label).should('have.class', 'active') + }) + }) + + it('direct URL access works for each route', () => { + const routes = ['/tasks', '/reminders', '/body', '/music', '/docs', '/planning'] + routes.forEach(route => { + cy.visit(route, { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + }) + }) + + it('back button works between tabs', () => { + cy.get('.tab-btn').contains('待办').click() + cy.url().should('include', '/tasks') + cy.get('.tab-btn').contains('提醒').click() + cy.url().should('include', '/reminders') + cy.go('back') + cy.url().should('include', '/tasks') + }) + + it('unknown route still renders the app (SPA fallback)', () => { + cy.visit('/nonexistent', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + }) + + it('sleep buddy route works', () => { + cy.visit('/sleep-buddy', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.contains('睡眠打卡').should('be.visible') + }) +}) diff --git a/frontend/cypress/e2e/notes-flow.cy.js b/frontend/cypress/e2e/notes-flow.cy.js new file mode 100644 index 0000000..ffcbe48 --- /dev/null +++ b/frontend/cypress/e2e/notes-flow.cy.js @@ -0,0 +1,115 @@ +describe('Notes (随手记)', () => { + beforeEach(() => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + cy.get('.tab-btn').contains('随手记').click() + }) + + it('shows capture input area', () => { + cy.get('.capture-input').should('be.visible') + cy.get('.capture-btn').should('be.visible') + }) + + it('shows tag buttons', () => { + cy.get('.tag-btn').should('have.length.gte', 8) + cy.get('.tag-btn').first().should('contain', '💡') + }) + + it('can select different tags', () => { + cy.get('.tag-btn').contains('✅').click() + cy.get('.tag-btn').contains('✅').should('have.class', 'active') + cy.get('.tag-btn').contains('💡').should('not.have.class', 'active') + }) + + it('creates a note via input', () => { + cy.get('.capture-input').type('测试笔记内容') + cy.get('.capture-btn').click() + cy.get('.note-card').should('contain', '测试笔记内容') + }) + + it('creates a note with specific tag', () => { + cy.get('.tag-btn').contains('📖').click() + cy.get('.capture-input').type('读书笔记测试') + cy.get('.capture-btn').click() + cy.get('.note-card').first().should('contain', '读书笔记测试') + cy.get('.note-tag').first().should('contain', '读书') + }) + + it('creates note via Enter key', () => { + cy.get('.capture-input').type('回车创建笔记{enter}') + cy.get('.note-card').should('contain', '回车创建笔记') + }) + + it('clears input after creating note', () => { + cy.get('.capture-input').type('清空测试') + cy.get('.capture-btn').click() + cy.get('.capture-input').should('have.value', '') + }) + + it('does not create empty notes', () => { + cy.get('.note-card').then($cards => { + const count = $cards.length + cy.get('.capture-btn').click() + cy.get('.note-card').should('have.length', count) + }) + }) + + it('can search/filter notes', () => { + // Create 2 notes + cy.get('.capture-input').type('苹果笔记') + cy.get('.capture-btn').click() + cy.get('.capture-input').type('香蕉笔记') + cy.get('.capture-btn').click() + // Search + cy.get('.search-input').type('苹果') + cy.get('.note-card').should('have.length', 1) + cy.get('.note-card').should('contain', '苹果') + }) + + it('can filter by tag', () => { + cy.get('.tag-btn').contains('💡').click() + cy.get('.capture-input').type('灵感笔记') + cy.get('.capture-btn').click() + cy.get('.tag-btn').contains('⏰').click() + cy.get('.capture-input').type('提醒笔记') + cy.get('.capture-btn').click() + // Filter by 灵感 + cy.get('.filter-btn').contains('灵感').click() + cy.get('.note-card').each($card => { + cy.wrap($card).find('.note-tag').should('contain', '灵感') + }) + }) + + it('can edit a note', () => { + cy.get('.capture-input').type('待编辑笔记') + cy.get('.capture-btn').click() + cy.get('.note-action-btn').contains('编辑').first().click() + cy.get('.edit-textarea').clear().type('已编辑笔记') + cy.get('.btn-accent').contains('保存').click() + cy.get('.note-card').first().should('contain', '已编辑笔记') + }) + + it('can delete a note', () => { + cy.get('.capture-input').type('待删除笔记') + cy.get('.capture-btn').click() + cy.get('.note-card').should('contain', '待删除笔记') + cy.get('.note-action-btn.danger').first().click() + cy.get('.note-card').should('not.contain', '待删除笔记') + }) + + it('shows empty hint when no notes', () => { + // This depends on initial state — may or may not be empty + // Just verify the component handles both states + cy.get('.notes-layout').should('be.visible') + }) + + it('displays time for each note', () => { + cy.get('.capture-input').type('带时间的笔记') + cy.get('.capture-btn').click() + cy.get('.note-time').first().should('not.be.empty') + }) +}) diff --git a/frontend/cypress/e2e/performance.cy.js b/frontend/cypress/e2e/performance.cy.js new file mode 100644 index 0000000..7c3bd2e --- /dev/null +++ b/frontend/cypress/e2e/performance.cy.js @@ -0,0 +1,59 @@ +describe('Performance', () => { + it('page loads within 5 seconds', () => { + const start = Date.now() + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible').then(() => { + const elapsed = Date.now() - start + expect(elapsed).to.be.lessThan(5000) + }) + }) + + it('API responses are under 1 second', () => { + const apis = ['/api/notes', '/api/todos', '/api/reminders', '/api/sleep', '/api/bugs'] + apis.forEach(api => { + const start = Date.now() + cy.request(api).then(() => { + const elapsed = Date.now() - start + expect(elapsed).to.be.lessThan(1000) + }) + }) + }) + + it('tab switching is instantaneous', () => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + const tabs = ['待办', '提醒', '身体', '音乐', '文档', '日程', '随手记'] + tabs.forEach(tab => { + cy.get('.tab-btn').contains(tab).click() + cy.get('.tab-btn').contains(tab).should('have.class', 'active') + }) + }) + + it('creating many notes does not degrade', () => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + // Create 10 notes rapidly + for (let i = 0; i < 10; i++) { + cy.request('POST', '/api/notes', { + id: `perf_${i}_${Date.now()}`, + text: `Performance test note ${i}`, + tag: '灵感' + }) + } + // Reload and verify it still loads + cy.reload() + cy.get('header', { timeout: 5000 }).should('be.visible') + }) +}) diff --git a/frontend/cypress/e2e/planning-review.cy.js b/frontend/cypress/e2e/planning-review.cy.js new file mode 100644 index 0000000..d8739de --- /dev/null +++ b/frontend/cypress/e2e/planning-review.cy.js @@ -0,0 +1,40 @@ +describe('Planning - Review (日程-回顾)', () => { + beforeEach(() => { + cy.visit('/planning', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('.sub-tab').contains('回顾').click() + }) + + it('shows review form', () => { + cy.contains('本周回顾').should('be.visible') + cy.get('.review-form textarea').should('have.length', 3) + }) + + it('review form has 3 sections', () => { + cy.contains('本周做得好的').should('be.visible') + cy.contains('需要改进的').should('be.visible') + cy.contains('下周计划').should('be.visible') + }) + + it('saves a review', () => { + cy.get('.review-form textarea').eq(0).type('完成了重构') + cy.get('.review-form textarea').eq(1).type('睡眠不够') + cy.get('.review-form textarea').eq(2).type('早睡早起') + cy.get('.btn-accent').contains('保存回顾').click() + // Should save without error + cy.get('.review-form').should('be.visible') + }) + + it('shows history section toggle', () => { + cy.contains('历史回顾').should('be.visible') + }) + + it('toggles history visibility', () => { + cy.contains('历史回顾').click() + // After click, should toggle visibility + cy.contains('历史回顾').should('be.visible') + }) +}) diff --git a/frontend/cypress/e2e/planning-schedule.cy.js b/frontend/cypress/e2e/planning-schedule.cy.js new file mode 100644 index 0000000..dff2700 --- /dev/null +++ b/frontend/cypress/e2e/planning-schedule.cy.js @@ -0,0 +1,75 @@ +describe('Planning - Schedule (日程)', () => { + beforeEach(() => { + cy.visit('/planning', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + }) + + it('shows 3 sub tabs', () => { + cy.get('.sub-tab').should('have.length', 3) + cy.get('.sub-tab').eq(0).should('contain', '日程') + cy.get('.sub-tab').eq(1).should('contain', '模板') + cy.get('.sub-tab').eq(2).should('contain', '回顾') + }) + + it('defaults to schedule sub tab', () => { + cy.get('.sub-tab').contains('日程').should('have.class', 'active') + }) + + it('shows module pool and timeline', () => { + cy.get('.module-pool').should('be.visible') + cy.get('.timeline').should('be.visible') + }) + + it('shows time slots from 6:00 to 23:00', () => { + cy.get('.time-slot').should('have.length', 18) + cy.get('.time-label').first().should('contain', '06:00') + cy.get('.time-label').last().should('contain', '23:00') + }) + + it('shows date navigation', () => { + cy.get('.date-nav').should('be.visible') + cy.get('.date-label-main').should('not.be.empty') + }) + + it('navigates to next/previous day', () => { + cy.get('.date-label-main').invoke('text').then(today => { + cy.get('.date-nav button').first().click() + cy.get('.date-label-main').invoke('text').should('not.eq', today) + }) + }) + + it('adds a schedule module', () => { + cy.get('.module-pool .add-row input').type('深度工作') + cy.get('.module-pool .add-row button').click() + cy.get('.module-item').should('contain', '深度工作') + }) + + it('color picker works', () => { + cy.get('.color-dot').should('have.length.gte', 10) + cy.get('.color-dot').eq(3).click() + cy.get('.color-dot').eq(3).should('have.class', 'active') + }) + + it('deletes a schedule module', () => { + cy.get('.module-pool .add-row input').type('待删除模块') + cy.get('.module-pool .add-row button').click() + cy.get('.module-item').contains('待删除模块').parent().find('.remove-btn').click({ force: true }) + cy.get('.module-item').should('not.contain', '待删除模块') + }) + + it('clears all slots for the day', () => { + cy.get('.btn-light').contains('清空').click() + // Just verify it doesn't crash + cy.get('.timeline').should('be.visible') + }) + + it('module items are draggable', () => { + cy.get('.module-pool .add-row input').type('拖拽测试') + cy.get('.module-pool .add-row button').click() + cy.get('.module-item').contains('拖拽测试').should('have.attr', 'draggable', 'true') + }) +}) diff --git a/frontend/cypress/e2e/planning-template.cy.js b/frontend/cypress/e2e/planning-template.cy.js new file mode 100644 index 0000000..cd36162 --- /dev/null +++ b/frontend/cypress/e2e/planning-template.cy.js @@ -0,0 +1,30 @@ +describe('Planning - Template (日程-模板)', () => { + beforeEach(() => { + cy.visit('/planning', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('.sub-tab').contains('模板').click() + }) + + it('shows 7 day buttons', () => { + cy.get('.day-btn').should('have.length', 7) + cy.get('.day-btn').eq(0).should('contain', '周一') + cy.get('.day-btn').eq(6).should('contain', '周日') + }) + + it('defaults to 周二 (index 1) as selected', () => { + cy.get('.day-btn').eq(1).should('have.class', 'active') + }) + + it('switches between days', () => { + cy.get('.day-btn').contains('周五').click() + cy.get('.day-btn').contains('周五').should('have.class', 'active') + cy.get('.day-btn').contains('周二').should('not.have.class', 'active') + }) + + it('shows template hint', () => { + cy.get('.template-hint').should('be.visible') + }) +}) diff --git a/frontend/cypress/e2e/reminders-flow.cy.js b/frontend/cypress/e2e/reminders-flow.cy.js new file mode 100644 index 0000000..a373565 --- /dev/null +++ b/frontend/cypress/e2e/reminders-flow.cy.js @@ -0,0 +1,73 @@ +describe('Reminders (提醒)', () => { + beforeEach(() => { + cy.visit('/reminders', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + }) + + it('shows reminders page with add button', () => { + cy.get('.section-header').should('contain', '提醒') + cy.get('.btn-accent').should('contain', '新提醒') + }) + + it('opens add reminder form', () => { + cy.get('.btn-accent').contains('新提醒').click() + cy.get('.edit-form').should('be.visible') + cy.get('.edit-form input').should('have.length.gte', 2) + cy.get('.edit-form select').should('exist') + }) + + it('creates a reminder', () => { + cy.get('.btn-accent').contains('新提醒').click() + cy.get('.edit-form input').first().type('喝水提醒') + cy.get('.edit-form input[type="time"]').type('14:00') + cy.get('.edit-form select').select('daily') + cy.get('.btn-accent').contains('保存').click() + cy.get('.reminder-card').should('contain', '喝水提醒') + cy.get('.reminder-meta').should('contain', '14:00') + cy.get('.reminder-meta').should('contain', '每天') + }) + + it('creates reminder with different repeat options', () => { + cy.get('.btn-accent').contains('新提醒').click() + cy.get('.edit-form input').first().type('周报提醒') + cy.get('.edit-form select').select('weekly') + cy.get('.btn-accent').contains('保存').click() + cy.get('.reminder-card').should('contain', '周报提醒') + cy.get('.reminder-meta').should('contain', '每周') + }) + + it('toggles reminder enabled/disabled', () => { + cy.get('.btn-accent').contains('新提醒').click() + cy.get('.edit-form input').first().type('开关测试') + cy.get('.btn-accent').contains('保存').click() + cy.get('.reminder-toggle').first().click() + cy.get('.reminder-toggle').first().should('contain', '🔕') + cy.get('.reminder-toggle').first().click() + cy.get('.reminder-toggle').first().should('contain', '🔔') + }) + + it('deletes a reminder', () => { + cy.get('.btn-accent').contains('新提醒').click() + cy.get('.edit-form input').first().type('待删除提醒') + cy.get('.btn-accent').contains('保存').click() + cy.get('.reminder-card').contains('待删除提醒').parent().find('.remove-btn').click() + cy.get('.reminder-card').should('not.contain', '待删除提醒') + }) + + it('cancel button closes form without saving', () => { + cy.get('.btn-accent').contains('新提醒').click() + cy.get('.edit-form input').first().type('取消测试') + cy.get('.btn-close').contains('取消').click() + cy.get('.edit-form').should('not.exist') + cy.get('.reminder-card').should('not.contain', '取消测试') + }) + + it('shows empty hint when no reminders', () => { + // Just verify component handles both states gracefully + cy.get('.reminders-layout').should('be.visible') + }) +}) diff --git a/frontend/cypress/e2e/responsive.cy.js b/frontend/cypress/e2e/responsive.cy.js new file mode 100644 index 0000000..0354c54 --- /dev/null +++ b/frontend/cypress/e2e/responsive.cy.js @@ -0,0 +1,52 @@ +describe('Responsive Layout', () => { + beforeEach(() => { + cy.visit('/', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + }) + + it('mobile viewport (375px) renders correctly', () => { + cy.viewport(375, 812) + cy.get('header').should('be.visible') + cy.get('.tab-btn').should('be.visible') + cy.get('.tab-btn').first().should('be.visible') + }) + + it('tablet viewport (768px) renders correctly', () => { + cy.viewport(768, 1024) + cy.get('header').should('be.visible') + cy.get('main').should('be.visible') + }) + + it('desktop viewport (1280px) renders correctly', () => { + cy.viewport(1280, 800) + cy.get('header').should('be.visible') + cy.get('main').should('be.visible') + }) + + it('mobile: tabs are scrollable', () => { + cy.viewport(375, 812) + cy.get('.tabs').should('have.css', 'overflow-x', 'auto') + }) + + it('mobile: quadrant grid stacks vertically', () => { + cy.viewport(375, 812) + cy.get('.tab-btn').contains('待办').click() + cy.get('.quadrant-grid').should('be.visible') + }) + + it('mobile: schedule layout stacks vertically', () => { + cy.viewport(375, 812) + cy.get('.tab-btn').contains('日程').click() + cy.get('.planning-layout').should('be.visible') + }) + + it('wide viewport (1920px) renders correctly', () => { + cy.viewport(1920, 1080) + cy.get('header').should('be.visible') + cy.get('main').should('be.visible') + }) +}) diff --git a/frontend/cypress/e2e/sleep-buddy.cy.js b/frontend/cypress/e2e/sleep-buddy.cy.js new file mode 100644 index 0000000..83bc50f --- /dev/null +++ b/frontend/cypress/e2e/sleep-buddy.cy.js @@ -0,0 +1,128 @@ +describe('Sleep Buddy (睡眠打卡)', () => { + beforeEach(() => { + cy.visit('/sleep-buddy', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + }) + + it('shows login form when not logged in as buddy', () => { + cy.get('.buddy-login').should('be.visible') + cy.get('.buddy-login-logo').should('contain', '🌙') + cy.contains('睡眠打卡').should('be.visible') + cy.contains('和好友一起早睡').should('be.visible') + }) + + it('has username and password fields', () => { + cy.get('.buddy-login-card input').should('have.length.gte', 2) + cy.get('.buddy-login-card input[type="password"]').should('exist') + }) + + it('toggle between login and register mode', () => { + cy.get('.buddy-toggle-btn').should('contain', '没有账号?注册') + cy.get('.buddy-toggle-btn').click() + cy.get('.buddy-main-btn').should('contain', '注册') + cy.get('.buddy-login-card input').should('have.length', 3) // username, password, confirm + cy.get('.buddy-toggle-btn').should('contain', '已有账号?登录') + }) + + it('register mode shows confirm password', () => { + cy.get('.buddy-toggle-btn').click() + cy.get('.buddy-login-card input[type="password"]').should('have.length', 2) + }) + + it('shows error for mismatched passwords during register', () => { + cy.get('.buddy-toggle-btn').click() + cy.get('.buddy-login-card input').eq(0).type('testuser') + cy.get('.buddy-login-card input').eq(1).type('pass1') + cy.get('.buddy-login-card input').eq(2).type('pass2') + cy.get('.buddy-main-btn').click() + cy.get('.buddy-error').should('contain', '密码不一致') + }) + + it('register then login flow', () => { + const user = 'testuser_' + Date.now() + // Register + cy.get('.buddy-toggle-btn').click() + cy.get('.buddy-login-card input').eq(0).type(user) + cy.get('.buddy-login-card input').eq(1).type('testpass') + cy.get('.buddy-login-card input').eq(2).type('testpass') + cy.get('.buddy-main-btn').click() + // Should be logged in + cy.get('.buddy-main', { timeout: 5000 }).should('be.visible') + cy.contains(user).should('be.visible') + }) + + // Tests that require buddy login + describe('when logged in', () => { + const user = 'cy_test_' + Math.random().toString(36).slice(2, 8) + + beforeEach(() => { + cy.visit('/sleep-buddy', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + win.localStorage.setItem('buddy_session', JSON.stringify({ + username: user, + exp: Date.now() + 86400000 + })) + } + }) + // Register the user via API first + cy.window().then(async (win) => { + const buf = await win.crypto.subtle.digest('SHA-256', new TextEncoder().encode('testpass')) + const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('') + try { + await fetch('/api/buddy-register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: user, hash }) + }) + } catch {} + }) + }) + + it('shows main buddy interface', () => { + cy.get('.buddy-main', { timeout: 5000 }).should('be.visible') + cy.get('.sleep-btn').should('contain', '我去睡觉啦') + }) + + it('shows target time card', () => { + cy.get('.target-card').should('be.visible') + cy.get('.target-time').should('not.be.empty') + }) + + it('shows record input', () => { + cy.get('.record-card').should('be.visible') + cy.get('.capture-row input').should('be.visible') + }) + + it('records sleep time', () => { + cy.get('.capture-row input').type('22:30') + cy.get('.capture-row button').click() + cy.get('.buddy-hint').should('contain', '已记录') + }) + + it('shows error for unrecognized input', () => { + cy.get('.capture-row input').type('乱七八糟') + cy.get('.capture-row button').click() + cy.get('.buddy-hint').should('contain', '无法识别') + }) + + it('go sleep button sends notification', () => { + cy.get('.sleep-btn').click() + cy.get('.buddy-hint').should('contain', '晚安') + }) + + it('user menu shows logout', () => { + cy.get('.user-chip').click() + cy.get('.user-menu button').should('contain', '退出登录') + }) + + it('logout returns to login form', () => { + cy.get('.user-chip').click() + cy.contains('退出登录').click() + cy.get('.buddy-login').should('be.visible') + }) + }) +}) diff --git a/frontend/cypress/e2e/tasks-flow.cy.js b/frontend/cypress/e2e/tasks-flow.cy.js new file mode 100644 index 0000000..a298afc --- /dev/null +++ b/frontend/cypress/e2e/tasks-flow.cy.js @@ -0,0 +1,140 @@ +describe('Tasks (待办)', () => { + beforeEach(() => { + cy.visit('/tasks', { + onBeforeLoad(win) { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 86400000)) + } + }) + cy.get('header').should('be.visible') + }) + + // ---- Sub tabs ---- + it('shows sub tabs: 待办, 目标, 清单', () => { + cy.get('.sub-tab').should('have.length', 3) + cy.get('.sub-tab').eq(0).should('contain', '待办') + cy.get('.sub-tab').eq(1).should('contain', '目标') + cy.get('.sub-tab').eq(2).should('contain', '清单') + }) + + it('defaults to 待办 sub tab', () => { + cy.get('.sub-tab').contains('待办').should('have.class', 'active') + }) + + it('switches between sub tabs', () => { + cy.get('.sub-tab').contains('目标').click() + cy.get('.sub-tab').contains('目标').should('have.class', 'active') + cy.get('.sub-tab').contains('清单').click() + cy.get('.sub-tab').contains('清单').should('have.class', 'active') + }) + + // ---- Inbox ---- + it('shows inbox input', () => { + cy.get('.inbox-card').should('be.visible') + cy.get('.inbox-card .capture-input').should('be.visible') + }) + + it('adds item to inbox', () => { + cy.get('.inbox-card .capture-input').type('收集箱测试') + cy.get('.inbox-card .capture-btn').click() + cy.get('.inbox-item').should('contain', '收集箱测试') + }) + + it('inbox item has quadrant assignment buttons', () => { + cy.get('.inbox-card .capture-input').type('分类测试') + cy.get('.inbox-card .capture-btn').click() + cy.get('.inbox-item-actions button').should('have.length.gte', 4) + }) + + it('moves inbox item to quadrant', () => { + cy.get('.inbox-card .capture-input').type('移入q1') + cy.get('.inbox-card .capture-btn').click() + // Click 🔴 (q1 - urgent important) + cy.get('.inbox-item').contains('移入q1').parent().find('.inbox-item-actions button').first().click() + cy.get('.inbox-item').should('not.contain', '移入q1') + cy.get('.todo-item').should('contain', '移入q1') + }) + + // ---- Quadrants ---- + it('shows 4 quadrants', () => { + cy.get('.quadrant').should('have.length', 4) + cy.get('.q-urgent-important').should('contain', '紧急且重要') + cy.get('.q-important').should('contain', '重要不紧急') + cy.get('.q-urgent').should('contain', '紧急不重要') + cy.get('.q-neither').should('contain', '不紧急不重要') + }) + + it('adds todo directly to a quadrant', () => { + cy.get('.q-urgent-important .add-todo-row input').type('直接添加任务{enter}') + cy.get('.q-urgent-important .todo-item').should('contain', '直接添加任务') + }) + + it('toggles todo completion', () => { + cy.get('.q-important .add-todo-row input').type('完成测试{enter}') + cy.get('.q-important .todo-item').contains('完成测试').parent().find('input[type="checkbox"]').check() + // Enable "show done" to verify + cy.get('#todoShowDone, .toggle-label input').check() + cy.get('.todo-item').contains('完成测试').parent().find('span.done').should('exist') + }) + + it('deletes a todo', () => { + cy.get('.q-neither .add-todo-row input').type('待删除todo{enter}') + cy.get('.todo-item').contains('待删除todo').parent().find('.remove-btn').click() + cy.get('.todo-item').should('not.contain', '待删除todo') + }) + + it('search filters todos', () => { + cy.get('.q-urgent-important .add-todo-row input').type('搜索目标A{enter}') + cy.get('.q-important .add-todo-row input').type('搜索目标B{enter}') + cy.get('.search-input').type('目标A') + cy.get('.todo-item').should('have.length', 1) + cy.get('.todo-item').should('contain', '搜索目标A') + }) + + // ---- Goals ---- + it('creates a goal', () => { + cy.get('.sub-tab').contains('目标').click() + cy.get('.btn-accent').contains('新目标').click() + cy.get('.edit-form input').first().type('减肥5斤') + cy.get('.edit-form input[type="month"]').type('2026-06') + cy.get('.btn-accent').contains('保存').click() + cy.get('.goal-card').should('contain', '减肥5斤') + }) + + it('deletes a goal', () => { + cy.get('.sub-tab').contains('目标').click() + cy.get('.btn-accent').contains('新目标').click() + cy.get('.edit-form input').first().type('待删除目标') + cy.get('.btn-accent').contains('保存').click() + cy.get('.goal-card').contains('待删除目标').parent().find('.remove-btn').click() + cy.get('.goal-card').should('not.contain', '待删除目标') + }) + + // ---- Checklists ---- + it('creates a checklist', () => { + cy.get('.sub-tab').contains('清单').click() + cy.get('.btn-accent').contains('新清单').click() + cy.get('.checklist-card').should('exist') + }) + + it('adds items to checklist', () => { + cy.get('.sub-tab').contains('清单').click() + cy.get('.btn-accent').contains('新清单').click() + cy.get('.checklist-card .add-todo-row input').first().type('清单项目1{enter}') + cy.get('.checklist-item').should('contain', '清单项目1') + }) + + it('toggles checklist item', () => { + cy.get('.sub-tab').contains('清单').click() + cy.get('.btn-accent').contains('新清单').click() + cy.get('.checklist-card .add-todo-row input').first().type('打勾测试{enter}') + cy.get('.checklist-item').contains('打勾测试').parent().find('input[type="checkbox"]').check() + cy.get('.checklist-item').contains('打勾测试').should('have.class', 'done') + }) + + it('deletes a checklist', () => { + cy.get('.sub-tab').contains('清单').click() + cy.get('.btn-accent').contains('新清单').click() + cy.get('.checklist-card').should('exist') + cy.get('.checklist-header .remove-btn').first().click() + }) +}) diff --git a/frontend/cypress/support/e2e.js b/frontend/cypress/support/e2e.js new file mode 100644 index 0000000..77675df --- /dev/null +++ b/frontend/cypress/support/e2e.js @@ -0,0 +1,31 @@ +// Ignore uncaught exceptions from the Vue app during E2E tests. +Cypress.on('uncaught:exception', () => false) + +// Login as planner user by injecting session into localStorage +Cypress.Commands.add('loginAsPlanner', (password = '123456') => { + // Hash the password and call login API + cy.window().then(async (win) => { + const buf = await win.crypto.subtle.digest('SHA-256', new TextEncoder().encode(password)) + const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('') + cy.request('POST', '/api/login', { hash }).then(() => { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 7 * 86400000)) + }) + }) +}) + +// Inject planner login session directly (skip API call) +Cypress.Commands.add('injectSession', () => { + cy.window().then(win => { + win.localStorage.setItem('sp_login_expires', String(Date.now() + 7 * 86400000)) + }) +}) + +// Navigate via tab button +Cypress.Commands.add('goToTab', (label) => { + cy.get('.tab-btn').contains(label).click() +}) + +// Verify toast message appears +Cypress.Commands.add('expectToast', (text) => { + cy.get('.toast').should('contain', text) +}) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f91bb56 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + + Hera's Planner + + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..17535a8 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3361 @@ +{ + "name": "planner-frontend", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "planner-frontend", + "version": "2.0.0", + "dependencies": { + "pinia": "^2.3.1", + "vue": "^3.5.32", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.5", + "cypress": "^15.13.1", + "vite": "^8.0.4" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cypress/request": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.10.tgz", + "integrity": "sha512-hauBrOdvu08vOsagkZ/Aju5XuiZx6ldsLfByg1htFeldhex+PeMrYauANzFsMJeAA0+dyPLbDoX2OYuvVoLDkQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~4.0.4", + "http-signature": "~1.4.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "performance-now": "^2.1.0", + "qs": "~6.14.1", + "safe-buffer": "^5.1.2", + "tough-cookie": "^5.0.0", + "tunnel-agent": "^0.6.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@cypress/xvfb": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz", + "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.1.0", + "lodash.once": "^4.1.1" + } + }, + "node_modules/@cypress/xvfb/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.123.0.tgz", + "integrity": "sha512-YtECP/y8Mj1lSHiUWGSRzy/C6teUKlS87dEfuVKT09LgQbUsBW1rNg+MiJ4buGu3yuADV60gbIvo9/HplA56Ew==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-5ZiiecKH2DXAVJTNN13gNMUcCDg4Jy8ZjbXEsPnqa248wgOVeYRX0iqXXD5Jz4bI9BFHgKsI2qmyJynstbmr+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-tz/v/8G77seu8zAB3A5sK3UFoOl06zcshEzhUO62sAEtrEuW/H1CcyoupOrD+NbQJytYgA4CppXPzlrmp4JZKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-8DakphqOz8JrMYWTJmWA+vDJxut6LijZ8Xcdc4flOlAhU7PNVwo2MaWBF9iXjJAPo5rC/IxEFZDhJ3GC7NHvug==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.13.tgz", + "integrity": "sha512-4wBQFfjDuXYN/SVI8inBF3Aa+isq40rc6VMFbk5jcpolUBTe5cYnMsHZ51nFWsx3PVyyNN3vgoESki0Hmr/4BA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.13.tgz", + "integrity": "sha512-JW/e4yPIXLms+jmnbwwy5LA/LxVwZUWLN8xug+V200wzaVi5TEGIWQlh8o91gWYFxW609euI98OCCemmWGuPrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-ZfKWpXiUymDnavepCaM6KG/uGydJ4l2nBmMxg60Ci4CbeefpqjPWpfaZM7PThOhk2dssqBAcwLc6rAyr0uTdXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-bmRg3O6Z0gq9yodKKWCIpnlH051sEfdVwt+6m5UDffAQMUUqU0xjnQqqAUm+Gu7ofAAly9DqiQDtKu2nPDEABA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-8Wtnbw4k7pMYN9B/mOEAsQ8HOiq7AZ31Ig4M9BKn2So4xRaFEhtCSa4ZJaOutOWq50zpgR4N5+L/opnlaCx8wQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-D/0Nlo8mQuxSMohNJUF2lDXWRsFDsHldfRRgD9bRgktj+EndGPj4DOV37LqDKPYS+osdyhZEH7fTakTAEcW7qg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.13.tgz", + "integrity": "sha512-eRrPvat2YaVQcwwKi/JzOP6MKf1WRnOCr+VaI3cTWz3ZoLcP/654z90lVCJ4dAuMEpPdke0n+qyAqXDZdIC4rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.13.tgz", + "integrity": "sha512-PsdONiFRp8hR8KgVjTWjZ9s7uA3uueWL0t74/cKHfM4dR5zXYv4AjB8BvA+QDToqxAFg4ZkcVEqeu5F7inoz5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.13.tgz", + "integrity": "sha512-hCNXgC5dI3TVOLrPT++PKFNZ+1EtS0mLQwfXXXSUD/+rGlB65gZDwN/IDuxLpQP4x8RYYHqGomlUXzpO8aVI2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.13.tgz", + "integrity": "sha512-viLS5C5et8NFtLWw9Sw3M/w4vvnVkbWkO7wSNh3C+7G1+uCkGpr6PcjNDSFcNtmXY/4trjPBqUfcOL+P3sWy/g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.1", + "@emnapi/runtime": "1.9.1", + "@napi-rs/wasm-runtime": "^1.1.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-Fqa3Tlt1xL4wzmAYxGNFV36Hb+VfPc9PYU+E25DAnswXv3ODDu/yyWjQDbXMo5AGWkQVjLgQExuVu8I/UaZhPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.13.tgz", + "integrity": "sha512-/pLI5kPkGEi44TDlnbio3St/5gUFeN51YWNAk/Gnv6mEQBOahRBh52qVFVBpmrnU01n2yysvBML9Ynu7K4kGAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/sinonjs__fake-timers": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.1.tgz", + "integrity": "sha512-0kSuKjAS0TrGLJ0M/+8MaFkGsQhZpB6pxOmvS3K8FYI72K//YmdfoW9X2qPsAKh1mkwxGD5zib9s1FIFed6E8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/sizzle": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", + "integrity": "sha512-TC0dmN0K8YcWEAEfiPi5gJP14eJe30TTGjkvek3iM/1NdHHsdCA/Td6GvNndMOo/iSnIsZ4HuuhrYPDAmbxzww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tmp": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", + "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yauzl": { + "version": "2.10.3", + "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", + "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arch": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz", + "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/astral-regex": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", + "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/at-least-node": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz", + "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/blob-util": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", + "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/bluebird": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/cachedir": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.4.0.tgz", + "integrity": "sha512-9EtFOZR8g22CL7BWjJ9BUx1+A/djkofnyW3aOXZORNW2kxoUpx2h+uN2cOqwPmFhnpVmxg+KW2OjOSgChTEvsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-table3": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.1.tgz", + "integrity": "sha512-w0q/enDHhPLq44ovMGdQeeDLvwxwavsJX7oQGYt/LrBlYsyaxyDnp6z3QzFut/6kLLKnlcUVJLrpB7KBfgG/RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "colors": "1.4.0" + } + }, + "node_modules/cli-truncate": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz", + "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^3.0.0", + "string-width": "^4.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/colors": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", + "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", + "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/common-tags": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz", + "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/cypress": { + "version": "15.13.1", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-15.13.1.tgz", + "integrity": "sha512-jLkgo75zlwo7PhXp0XJot+zIfFSDzN1SvTml6Xf3ETM1XHRWnH3Q4LAR3orCo/BsnxPnhjG3m5HYSvn9DAtwBg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cypress/request": "^3.0.10", + "@cypress/xvfb": "^1.2.4", + "@types/sinonjs__fake-timers": "8.1.1", + "@types/sizzle": "^2.3.2", + "@types/tmp": "^0.2.3", + "arch": "^2.2.0", + "blob-util": "^2.0.2", + "bluebird": "^3.7.2", + "buffer": "^5.7.1", + "cachedir": "^2.3.0", + "chalk": "^4.1.0", + "ci-info": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-table3": "0.6.1", + "commander": "^6.2.1", + "common-tags": "^1.8.0", + "dayjs": "^1.10.4", + "debug": "^4.3.4", + "enquirer": "^2.3.6", + "eventemitter2": "6.4.7", + "execa": "4.1.0", + "executable": "^4.1.1", + "extract-zip": "2.0.1", + "figures": "^3.2.0", + "fs-extra": "^9.1.0", + "hasha": "5.2.2", + "is-installed-globally": "~0.4.0", + "listr2": "^3.8.3", + "lodash": "^4.17.23", + "log-symbols": "^4.0.0", + "minimist": "^1.2.8", + "ospath": "^1.2.2", + "pretty-bytes": "^5.6.0", + "process": "^0.11.10", + "proxy-from-env": "1.0.0", + "request-progress": "^3.0.0", + "supports-color": "^8.1.1", + "systeminformation": "^5.31.1", + "tmp": "~0.2.4", + "tree-kill": "1.2.2", + "tslib": "1.14.1", + "untildify": "^4.0.0", + "yauzl": "^2.10.0" + }, + "bin": { + "cypress": "bin/cypress" + }, + "engines": { + "node": "^20.1.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/cypress/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/enquirer": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", + "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^4.1.1", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/eventemitter2": { + "version": "6.4.7", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.7.tgz", + "integrity": "sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/execa": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", + "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.0", + "get-stream": "^5.0.0", + "human-signals": "^1.1.1", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.0", + "onetime": "^5.1.0", + "signal-exit": "^3.0.2", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/executable": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz", + "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.2.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/extract-zip": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", + "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "debug": "^4.1.1", + "get-stream": "^5.1.0", + "yauzl": "^2.10.0" + }, + "bin": { + "extract-zip": "cli.js" + }, + "engines": { + "node": ">= 10.17.0" + }, + "optionalDependencies": { + "@types/yauzl": "^2.9.1" + } + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/figures": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^1.0.5" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasha": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", + "integrity": "sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^2.0.0", + "type-fest": "^0.8.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-signature": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.4.0.tgz", + "integrity": "sha512-G5akfn7eKbpDN+8nPS/cb57YeA1jLTVxjpCj7tmm3QKPdyDy7T+qSC40e9ptydSWvkwjSXw1VbkpyEm39ukeAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^2.0.2", + "sshpk": "^1.18.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/human-signals": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", + "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.12.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsprim": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz", + "integrity": "sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/listr2": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz", + "integrity": "sha512-TyWI8G99GX9GjE54cJ+RrNMcIFBfwMPxc3XTFiAYGN4s10hWROGtOg7+O6u6LE3mNkyld7RSLE6nrKBvTfcs3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^2.1.0", + "colorette": "^2.0.16", + "log-update": "^4.0.0", + "p-map": "^4.0.0", + "rfdc": "^1.3.0", + "rxjs": "^7.5.1", + "through": "^2.3.8", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "enquirer": ">= 2.3.0 < 3" + }, + "peerDependenciesMeta": { + "enquirer": { + "optional": true + } + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz", + "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.0", + "cli-cursor": "^3.1.0", + "slice-ansi": "^4.0.0", + "wrap-ansi": "^6.2.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz", + "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ospath": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", + "integrity": "sha512-o6E5qJV5zkAbIDNhGSIlyOhScKXgQrSRMilfph0clDfM0nEnBOlKlH4sWDmG95BW/CvwNz0vmm7dJVtU2KlMiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/p-map": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-bytes": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", + "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.0.0.tgz", + "integrity": "sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/request-progress": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz", + "integrity": "sha512-MnWzEHHaxHO2iWiQuHrUPBi/1WeBf5PkxQqNyNvLl9VAYSdXkP8tQ3pBSeCPD+yw0v0Aq1zosWLz0BdeXpWwZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "throttleit": "^1.0.0" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.13.tgz", + "integrity": "sha512-bvVj8YJmf0rq4pSFmH7laLa6pYrhghv3PRzrCdRAr23g66zOKVJ4wkvFtgohtPLWmthgg8/rkaqRHrpUEh0Zbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.123.0", + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.13", + "@rolldown/binding-darwin-x64": "1.0.0-rc.13", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.13", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.13", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.13", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.13", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.13", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.13", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.13" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz", + "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "astral-regex": "^2.0.0", + "is-fullwidth-code-point": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/systeminformation": { + "version": "5.31.5", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.5.tgz", + "integrity": "sha512-5SyLdip4/3alxD4Kh+63bUQTJmu7YMfYQTC+koZy7X73HgNqZSD2P4wOZQWtUncvPvcEmnfIjCoygN4MRoEejQ==", + "dev": true, + "license": "MIT", + "os": [ + "darwin", + "linux", + "win32", + "freebsd", + "openbsd", + "netbsd", + "sunos", + "android" + ], + "bin": { + "systeminformation": "lib/cli.js" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "Buy me a coffee", + "url": "https://www.buymeacoffee.com/systeminfo" + } + }, + "node_modules/throttleit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.1.tgz", + "integrity": "sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/untildify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", + "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/vite": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.7.tgz", + "integrity": "sha512-P1PbweD+2/udplnThz3btF4cf6AgPky7kk23RtHUkJIU5BIxwPprhRGmOAHs6FTI7UiGbTNrgNP6jSYD6JaRnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.13", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..56cee3c --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "planner-frontend", + "private": true, + "version": "2.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "cy:open": "cypress open", + "cy:run": "cypress run", + "test:e2e": "cypress run" + }, + "dependencies": { + "pinia": "^2.3.1", + "vue": "^3.5.32", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.5", + "cypress": "^15.13.1", + "vite": "^8.0.4" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..ce8ba22 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/public/icon-180.png b/frontend/public/icon-180.png new file mode 100644 index 0000000..56327f9 Binary files /dev/null and b/frontend/public/icon-180.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..a67f651 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,11 @@ +{ + "name": "Hera's Planner", + "short_name": "Planner", + "start_url": "/", + "display": "standalone", + "background_color": "#f0f2f5", + "theme_color": "#667eea", + "icons": [ + { "src": "icon-180.png", "sizes": "180x180", "type": "image/png" } + ] +} diff --git a/frontend/public/notebook.jpg b/frontend/public/notebook.jpg new file mode 100644 index 0000000..0dba23c Binary files /dev/null and b/frontend/public/notebook.jpg differ diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..b8b4074 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,25 @@ +// Service Worker for Hera's Planner — 后台提醒通知 +self.addEventListener('install', () => self.skipWaiting()); +self.addEventListener('activate', () => self.clients.claim()); + +self.addEventListener('message', event => { + if (event.data && event.data.type === 'SHOW_NOTIFICATION') { + self.registration.showNotification(event.data.title, { + body: event.data.body, + icon: 'icon-180.png', + badge: 'icon-180.png', + requireInteraction: true, + tag: event.data.tag || 'planner-reminder', + }); + } +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + event.waitUntil( + self.clients.matchAll({ type: 'window' }).then(clients => { + if (clients.length > 0) { clients[0].focus(); } + else { self.clients.openWindow('/'); } + }) + ); +}); diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..5e52098 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,158 @@ + + + diff --git a/frontend/src/assets/styles.css b/frontend/src/assets/styles.css new file mode 100644 index 0000000..8569b56 --- /dev/null +++ b/frontend/src/assets/styles.css @@ -0,0 +1,455 @@ +:root { + --primary: #667eea; + --primary-dark: #5a6fd6; + --primary-light: #f0f0ff; + --accent: #764ba2; + --danger: #ef4444; + --bg: #f0f2f5; + --card: #ffffff; + --text: #333333; + --text-light: #888888; + --text-muted: #cccccc; + --border: #e0e0e0; + --shadow: 0 2px 12px rgba(0,0,0,0.06); +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +/* ===== Login ===== */ +.login-overlay { + position: fixed; inset: 0; z-index: 9999; + background: linear-gradient(135deg, var(--primary), var(--accent)); + display: flex; align-items: center; justify-content: center; flex-direction: column; +} +.login-banner { position: absolute; inset: 0; overflow: hidden; pointer-events: none; } +.login-banner .circle { position: absolute; border-radius: 50%; background: rgba(255,255,255,0.06); } +.login-banner .c1 { width: 400px; height: 400px; top: -100px; right: -80px; } +.login-banner .c2 { width: 250px; height: 250px; bottom: -60px; left: -40px; } +.login-banner .c3 { width: 150px; height: 150px; top: 40%; left: 60%; } +.login-card { position: relative; z-index: 1; text-align: center; color: white; padding: 40px; } +.login-title { font-size: 32px; font-weight: 700; margin-bottom: 6px; } +.login-subtitle { font-size: 14px; color: rgba(255,255,255,0.6); margin-bottom: 32px; } +.login-input-wrap { display: flex; gap: 10px; justify-content: center; } +.login-input { + padding: 12px 20px; border: 2px solid rgba(255,255,255,0.25); border-radius: 14px; + background: rgba(255,255,255,0.1); color: white; font-size: 16px; outline: none; width: 220px; + backdrop-filter: blur(8px); +} +.login-input:focus { border-color: rgba(255,255,255,0.6); } +.login-input::placeholder { color: rgba(255,255,255,0.4); } +.login-btn { + padding: 12px 28px; border: none; border-radius: 14px; background: white; color: var(--primary); + font-size: 16px; font-weight: 600; cursor: pointer; +} +.login-btn:hover { transform: scale(1.03); box-shadow: 0 4px 16px rgba(0,0,0,0.15); } +.login-error { color: #fca5a5; font-size: 13px; margin-top: 12px; min-height: 20px; } + +/* ===== Header ===== */ +header { position: sticky; top: 0; z-index: 100; background: linear-gradient(135deg, var(--primary), var(--accent)); } +.header-main { padding: 14px 24px; } +.header-top { display: flex; align-items: center; justify-content: space-between; } +header h1 { font-size: 20px; font-weight: 700; color: white; } +.header-subtitle { font-size: 11px; color: rgba(255,255,255,0.6); margin-left: 8px; font-weight: 400; } +.header-actions { position: relative; } +.header-menu-btn { + width: 36px; height: 36px; border: none; border-radius: 8px; background: transparent; + color: rgba(255,255,255,0.8); font-size: 20px; cursor: pointer; +} +.header-menu-btn:hover { background: rgba(255,255,255,0.15); } +.header-dropdown { + display: none; position: fixed; top: 48px; right: 12px; background: white; + border-radius: 12px; box-shadow: 0 8px 32px rgba(0,0,0,0.25); min-width: 160px; overflow: hidden; z-index: 10001; +} +.header-dropdown.open { display: block; } +.dropdown-mask { display: none; position: fixed; inset: 0; z-index: 10000; background: rgba(0,0,0,0.1); } +.dropdown-mask.open { display: block; } +.header-dropdown button { + display: block; width: 100%; padding: 12px 18px; border: none; background: none; + text-align: left; font-size: 14px; color: #555; cursor: pointer; +} +.header-dropdown button:hover { background: #f5f5f5; } +.dd-danger { color: var(--danger) !important; } +.dd-danger:hover { background: #fef2f2 !important; } + +/* ===== Tabs ===== */ +.tabs { + display: flex; overflow-x: auto; padding: 0 16px; gap: 0; + scrollbar-width: none; background: rgba(255,255,255,0.1); +} +.tabs::-webkit-scrollbar { display: none; } +.tab-btn { + padding: 8px 14px; margin: 6px 3px; background: rgba(255,255,255,0.2); + border: none; border-radius: 8px; color: rgba(255,255,255,0.7); + font-size: 13px; white-space: nowrap; font-weight: 500; cursor: pointer; transition: all 0.2s; +} +.tab-btn:hover { background: rgba(255,255,255,0.35); color: white; } +.tab-btn.active { background: rgba(255,255,255,0.5); color: white; font-weight: 600; } + +/* ===== Sub tabs ===== */ +.sub-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; } +.sub-tab { + padding: 8px 18px; border-radius: 20px; border: 1.5px solid var(--border); background: white; + font-size: 13px; cursor: pointer; transition: all 0.2s; +} +.sub-tab:hover { border-color: var(--primary); color: var(--primary); } +.sub-tab.active { background: var(--primary); color: white; border-color: var(--primary); } + +/* ===== Main content ===== */ +main { padding: 24px; max-width: 900px; margin: 0 auto; } + +/* ===== Common ===== */ +.btn { padding: 8px 18px; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: 500; transition: all 0.2s; } +.btn-accent { background: var(--primary); color: white; } +.btn-accent:hover { background: var(--primary-dark); } +.btn-light { background: #f5f5f5; color: var(--text-light); } +.btn-light:hover { background: #eee; } +.btn-close { background: #eee; color: #666; } +.btn-close:hover { background: #ddd; } + +.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.section-header h3 { font-size: 16px; color: #444; } + +.empty-hint { text-align: center; color: var(--text-muted); padding: 30px; font-size: 13px; } + +.remove-btn { + width: 20px; height: 20px; border-radius: 50%; border: none; background: rgba(0,0,0,0.06); + color: #999; font-size: 11px; cursor: pointer; display: flex; align-items: center; justify-content: center; flex-shrink: 0; +} +.remove-btn:hover { background: var(--danger); color: white; } + +.date-label { font-size: 13px; color: var(--text-light); } + +/* ===== Capture / Input ===== */ +.capture-card { background: white; border-radius: 14px; padding: 16px; box-shadow: var(--shadow); margin-bottom: 16px; } +.capture-row { display: flex; gap: 10px; align-items: flex-start; } +.capture-input { + flex: 1; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px; + font-size: 14px; outline: none; resize: none; min-height: 40px; font-family: inherit; +} +.capture-input:focus { border-color: var(--primary); } +.capture-btn { + width: 40px; height: 40px; border: none; border-radius: 10px; background: var(--primary); + color: white; font-size: 18px; cursor: pointer; flex-shrink: 0; +} + +.tag-btns { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; } +.tag-btn { + padding: 4px 10px; border-radius: 8px; border: 1.5px solid var(--border); background: white; + font-size: 14px; cursor: pointer; +} +.tag-btn.active { border-color: var(--primary); background: var(--primary-light); } + +/* ===== Toolbar ===== */ +.toolbar { display: flex; gap: 10px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; } +.search-input { + flex: 1; min-width: 120px; padding: 8px 14px; border: 1.5px solid var(--border); + border-radius: 10px; font-size: 13px; outline: none; +} +.search-input:focus { border-color: var(--primary); } +.toggle-label { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text-light); cursor: pointer; } +.filter-row { display: flex; gap: 4px; flex-wrap: wrap; width: 100%; } +.filter-btn { + padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border); background: white; + font-size: 12px; cursor: pointer; +} +.filter-btn.active { background: var(--primary); color: white; border-color: var(--primary); } + +/* ===== Notes ===== */ +.notes-layout { } +.note-card { background: white; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; box-shadow: var(--shadow); } +.note-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.note-tag { padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 500; } +.note-time { font-size: 11px; color: var(--text-muted); } +.note-text { font-size: 14px; line-height: 1.6; white-space: pre-wrap; cursor: pointer; } +.note-text:hover { color: var(--primary); } +.note-actions { display: flex; gap: 8px; margin-top: 8px; justify-content: flex-end; } +.note-action-btn { background: none; border: none; font-size: 12px; color: var(--text-light); cursor: pointer; } +.note-action-btn:hover { color: var(--primary); } +.note-action-btn.danger:hover { color: var(--danger); } +.note-edit { margin-top: 8px; } +.edit-textarea { width: 100%; padding: 10px; border: 1.5px solid var(--border); border-radius: 8px; font-size: 14px; outline: none; font-family: inherit; } +.edit-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; } + +/* ===== Tasks / Todos ===== */ +.tasks-layout { } +.inbox-card { background: white; border-radius: 12px; padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 16px; } +.inbox-item { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; } +.inbox-item-actions { display: flex; gap: 4px; } +.inbox-item-actions button { background: none; border: none; font-size: 14px; cursor: pointer; padding: 2px 4px; } + +.quadrant-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.quadrant { + background: white; border-radius: 14px; padding: 16px; min-height: 150px; + box-shadow: var(--shadow); border-top: 4px solid; display: flex; flex-direction: column; +} +.quadrant-title { font-size: 14px; font-weight: 600; margin-bottom: 4px; } +.quadrant-desc { font-size: 11px; color: #aaa; margin-bottom: 12px; } +.q-urgent-important { border-top-color: #ef4444; background: #fef2f2; } +.q-urgent-important .quadrant-title { color: #dc2626; } +.q-important { border-top-color: #f59e0b; background: #fffbeb; } +.q-important .quadrant-title { color: #d97706; } +.q-urgent { border-top-color: #3b82f6; background: #eff6ff; } +.q-urgent .quadrant-title { color: #2563eb; } +.q-neither { border-top-color: #94a3b8; background: #f8fafc; } +.q-neither .quadrant-title { color: #64748b; } + +.todo-item { display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: 13px; } +.todo-item .done { text-decoration: line-through; color: #ccc; } +.add-todo-row { margin-top: 8px; } +.add-todo-row input { + width: 100%; padding: 8px 12px; border: 1.5px dashed var(--border); border-radius: 8px; + font-size: 13px; outline: none; background: transparent; +} +.add-todo-row input:focus { border-color: var(--primary); border-style: solid; } + +/* ===== Reminders ===== */ +.reminders-layout { } +.reminder-card { background: white; border-radius: 12px; padding: 14px; margin-bottom: 10px; box-shadow: var(--shadow); } +.reminder-main { display: flex; align-items: center; gap: 12px; } +.reminder-toggle { font-size: 20px; cursor: pointer; } +.reminder-content { flex: 1; } +.reminder-text { font-size: 14px; } +.reminder-meta { font-size: 12px; color: var(--text-light); margin-top: 2px; } + +/* ===== Health check-in ===== */ +.checkin-grid { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; } +.checkin-item { + padding: 10px 16px; border-radius: 10px; background: white; border: 1.5px solid var(--border); + cursor: pointer; font-size: 14px; display: flex; align-items: center; gap: 6px; transition: all 0.2s; +} +.checkin-item.checked { border-color: #10b981; background: #ecfdf5; } +.checkin-check { font-size: 16px; } + +.pool-items { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; } +.pool-item { + display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 8px; + background: white; border: 1px solid var(--border); font-size: 13px; cursor: pointer; +} +.add-row { display: flex; gap: 8px; } +.add-row input { flex: 1; padding: 8px 12px; border: 1.5px solid var(--border); border-radius: 8px; font-size: 13px; outline: none; } +.add-row input:focus { border-color: var(--primary); } + +/* ===== Data table ===== */ +.data-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.data-table th { text-align: left; padding: 8px; color: var(--text-light); font-weight: 500; border-bottom: 1px solid var(--border); } +.data-table td { padding: 8px; border-bottom: 1px solid #f0f0f0; } + +/* ===== Records ===== */ +.record-card { background: white; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; box-shadow: var(--shadow); position: relative; } +.record-card .remove-btn { position: absolute; top: 10px; right: 10px; } +.record-note { font-size: 12px; color: var(--text-light); margin-top: 4px; } + +/* ===== Edit form ===== */ +.edit-form { + background: white; border-radius: 14px; padding: 20px; box-shadow: var(--shadow); margin-top: 16px; + display: flex; flex-direction: column; gap: 10px; +} +.edit-form label { font-size: 13px; color: var(--text-light); font-weight: 500; } +.edit-form input, .edit-form select, .edit-form textarea { + padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px; + font-size: 14px; outline: none; font-family: inherit; +} +.edit-form input:focus, .edit-form select:focus, .edit-form textarea:focus { border-color: var(--primary); } + +/* ===== Docs ===== */ +.doc-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; } +.doc-card { + background: white; border-radius: 14px; padding: 20px 16px; text-align: center; + box-shadow: var(--shadow); cursor: pointer; transition: all 0.2s; +} +.doc-card:hover { transform: translateY(-2px); box-shadow: 0 4px 20px rgba(0,0,0,0.1); } +.doc-icon { font-size: 32px; margin-bottom: 8px; } +.doc-name { font-size: 14px; font-weight: 600; } +.doc-count { font-size: 12px; color: var(--text-light); margin-top: 4px; } +.doc-entry { padding: 10px 0; border-bottom: 1px solid #f0f0f0; } +.doc-entry-text { font-size: 14px; line-height: 1.5; } +.doc-entry-meta { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; font-size: 11px; color: var(--text-muted); } +.emoji-row { display: flex; gap: 6px; flex-wrap: wrap; } +.emoji-pick { font-size: 20px; cursor: pointer; padding: 4px; border-radius: 6px; } +.emoji-pick.active { background: var(--primary-light); } + +/* ===== Planning / Schedule ===== */ +.schedule-flex { display: flex; gap: 24px; } +.module-pool { width: 240px; flex-shrink: 0; } +.pool-card { background: white; border-radius: 14px; padding: 18px; box-shadow: var(--shadow); } +.pool-card h3 { font-size: 15px; color: var(--text-light); margin-bottom: 14px; } +.module-item { + display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 10px; + cursor: grab; margin-bottom: 6px; font-size: 13px; font-weight: 500; position: relative; +} +.module-item:active { cursor: grabbing; } +.module-item .emoji { font-size: 16px; } +.module-item .remove-btn { position: absolute; right: 6px; display: none; } +.module-item:hover .remove-btn { display: flex; } +.color-picker { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; } +.color-dot { + width: 22px; height: 22px; border-radius: 50%; cursor: pointer; + border: 2.5px solid transparent; transition: all 0.15s; +} +.color-dot:hover { transform: scale(1.15); } +.color-dot.active { border-color: #333; } + +.timeline { flex: 1; min-width: 0; } +.date-nav { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; } +.date-nav button:not(.btn) { + width: 34px; height: 34px; border-radius: 50%; border: none; background: white; + cursor: pointer; font-size: 16px; box-shadow: var(--shadow); +} +.date-nav button:not(.btn):hover { background: var(--primary); color: white; } +.date-label-main { font-size: 18px; font-weight: 600; color: #444; } + +.time-slot { display: flex; gap: 0; margin-bottom: 4px; min-height: 56px; } +.time-label { + width: 56px; flex-shrink: 0; padding-top: 10px; font-size: 13px; font-weight: 600; + color: #999; text-align: right; padding-right: 14px; +} +.slot-drop { + flex: 1; background: white; border-radius: 10px; min-height: 48px; padding: 6px 10px; + display: flex; flex-wrap: wrap; gap: 6px; align-items: flex-start; border: 2px solid transparent; + box-shadow: 0 1px 4px rgba(0,0,0,0.04); transition: all 0.2s; +} +.slot-drop.drag-over { border-color: var(--primary); background: var(--primary-light); } +.placed-item { + display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; + border-radius: 8px; font-size: 12px; font-weight: 500; position: relative; +} +.remove-placed { + width: 14px; height: 14px; border-radius: 50%; border: none; background: rgba(0,0,0,0.15); + color: white; font-size: 9px; cursor: pointer; display: none; align-items: center; justify-content: center; margin-left: 4px; +} +.placed-item:hover .remove-placed { display: flex; } + +/* Day tabs */ +.day-tabs { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; } +.day-btn { + padding: 8px 18px; border-radius: 20px; border: 1.5px solid #ddd; background: white; + font-size: 13px; cursor: pointer; +} +.day-btn:hover { border-color: var(--primary); color: var(--primary); } +.day-btn.active { background: var(--primary); color: white; border-color: var(--primary); } + +/* Review */ +.review-form textarea { width: 100%; } +.review-card { background: white; border-radius: 12px; padding: 14px; margin-bottom: 10px; box-shadow: var(--shadow); } +.review-content { font-size: 13px; white-space: pre-wrap; margin-top: 8px; color: var(--text-light); } +.template-hint { text-align: center; color: #aaa; font-size: 12px; margin-top: 20px; padding: 12px; border: 1.5px dashed #ddd; border-radius: 10px; } + +/* ===== Overlay / Panel ===== */ +.overlay { + display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(0,0,0,0.4); + align-items: center; justify-content: center; +} +.overlay.open { display: flex; } +.panel { background: white; border-radius: 16px; padding: 24px; box-shadow: 0 16px 48px rgba(0,0,0,0.2); } +.edit-panel { display: flex; flex-direction: column; gap: 10px; } +.edit-panel label { font-size: 13px; color: var(--text-light); font-weight: 500; } +.edit-panel input, .edit-panel select { + padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px; font-size: 14px; outline: none; +} +.edit-panel input:focus, .edit-panel select:focus { border-color: var(--primary); } + +/* ===== Dialog ===== */ +.dialog-overlay { + display: none; position: fixed; inset: 0; z-index: 2000; background: rgba(0,0,0,0.5); + align-items: center; justify-content: center; +} +.dialog-overlay.open { display: flex; } +.dialog-box { background: white; border-radius: 16px; padding: 24px; min-width: 300px; box-shadow: 0 16px 48px rgba(0,0,0,0.3); } +.dialog-msg { font-size: 15px; margin-bottom: 16px; line-height: 1.5; } +.dialog-input { width: 100%; padding: 10px 14px; border: 1.5px solid var(--border); border-radius: 10px; font-size: 14px; outline: none; margin-bottom: 16px; } +.dialog-input:focus { border-color: var(--primary); } +.dialog-btns { display: flex; gap: 8px; justify-content: flex-end; } +.dialog-cancel { padding: 8px 18px; border: none; border-radius: 8px; background: #f0f0f0; color: #666; cursor: pointer; font-size: 14px; } +.dialog-ok { padding: 8px 18px; border: none; border-radius: 8px; background: var(--primary); color: white; cursor: pointer; font-size: 14px; } +.dialog-danger { padding: 8px 18px; border: none; border-radius: 8px; background: var(--danger); color: white; cursor: pointer; font-size: 14px; } + +/* ===== Toast ===== */ +.toast { + position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 3000; + background: #333; color: white; padding: 10px 24px; border-radius: 10px; font-size: 14px; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); animation: toastIn 0.3s; +} +@keyframes toastIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } } + +/* ===== Sleep Buddy ===== */ +.buddy-layout { } +.buddy-login { + display: flex; flex-direction: column; align-items: center; justify-content: center; + min-height: 60vh; text-align: center; +} +.buddy-login-logo { font-size: 56px; margin-bottom: 16px; } +.buddy-login h1 { font-size: 26px; font-weight: 700; color: #333; } +.buddy-login p { font-size: 13px; color: #aaa; margin-bottom: 24px; } +.buddy-login-card { background: white; border-radius: 20px; padding: 28px 24px; width: 300px; box-shadow: var(--shadow); display: flex; flex-direction: column; gap: 12px; } +.buddy-login-card input { padding: 12px 16px; border: 1.5px solid var(--border); border-radius: 12px; font-size: 15px; outline: none; } +.buddy-login-card input:focus { border-color: var(--primary); } +.buddy-main-btn { width: 100%; padding: 14px; border: none; border-radius: 12px; background: linear-gradient(135deg, var(--primary), var(--accent)); color: white; font-size: 16px; font-weight: 600; cursor: pointer; } +.buddy-toggle-btn { background: none; border: none; color: var(--text-muted); font-size: 13px; cursor: pointer; } +.buddy-error { color: var(--danger); font-size: 13px; min-height: 20px; } + +.buddy-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; } +.buddy-header h2 { font-size: 18px; } +.user-chip { + padding: 6px 12px; border-radius: 10px; background: #f5f5f5; cursor: pointer; + font-size: 13px; color: var(--text-light); position: relative; +} +.user-menu { + position: absolute; top: 36px; right: 0; background: white; border-radius: 10px; + box-shadow: 0 4px 16px rgba(0,0,0,0.15); overflow: hidden; z-index: 100; min-width: 100px; +} +.user-menu button { + display: block; width: 100%; padding: 10px 16px; border: none; background: none; + color: var(--text-light); font-size: 13px; cursor: pointer; text-align: left; +} +.user-menu button:hover { background: #f5f5f5; } + +.sleep-btn { + display: block; width: 100%; padding: 20px; border: none; border-radius: 18px; + background: linear-gradient(135deg, var(--primary), var(--accent)); color: white; + font-size: 20px; font-weight: 700; cursor: pointer; margin-bottom: 14px; + box-shadow: 0 6px 24px rgba(102,126,234,0.4); +} +.notif-bar { + background: linear-gradient(135deg, var(--primary), var(--accent)); border-radius: 14px; + padding: 14px; margin-bottom: 14px; text-align: center; color: white; font-size: 14px; +} +.target-card { + display: flex; align-items: center; justify-content: space-between; background: white; + border-radius: 12px; padding: 12px 16px; margin-bottom: 14px; box-shadow: var(--shadow); font-size: 13px; +} +.target-time { font-size: 16px; color: var(--primary); font-weight: 600; } +.target-card button { padding: 4px 10px; border: 1px solid var(--border); border-radius: 6px; background: white; color: var(--text-light); font-size: 12px; cursor: pointer; } +.buddy-hint { font-size: 12px; color: var(--text-muted); margin-top: 6px; min-height: 16px; } +.buddy-record { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; } +.buddy-record span:first-child { flex: 1; } + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + main { padding: 16px; } + .quadrant-grid { grid-template-columns: 1fr; } + .schedule-flex { flex-direction: column; } + .module-pool { width: 100%; } + .doc-grid { grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); } +} + +/* ===== Checklist ===== */ +.checklist-card { background: white; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; box-shadow: var(--shadow); } +.checklist-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; } +.checklist-title-input { border: none; font-size: 15px; font-weight: 600; outline: none; width: 100%; } +.checklist-item { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 13px; } +.checklist-item .done { text-decoration: line-through; color: #ccc; } + +/* ===== Goal ===== */ +.goal-card { background: white; border-radius: 12px; padding: 14px 16px; margin-bottom: 10px; box-shadow: var(--shadow); } +.goal-header { display: flex; align-items: center; gap: 10px; } +.goal-header strong { flex: 1; } +.goal-month { font-size: 12px; color: var(--text-light); } + +.sleep-hint { font-size: 12px; color: var(--primary); margin-bottom: 12px; } diff --git a/frontend/src/components/CustomDialog.vue b/frontend/src/components/CustomDialog.vue new file mode 100644 index 0000000..5e07980 --- /dev/null +++ b/frontend/src/components/CustomDialog.vue @@ -0,0 +1,41 @@ + + + diff --git a/frontend/src/composables/useApi.js b/frontend/src/composables/useApi.js new file mode 100644 index 0000000..8a84775 --- /dev/null +++ b/frontend/src/composables/useApi.js @@ -0,0 +1,33 @@ +const API_BASE = '' + +async function request(path, opts = {}) { + const headers = { 'Content-Type': 'application/json', ...opts.headers } + const res = await fetch(API_BASE + path, { ...opts, headers }) + return res +} + +async function requestJSON(path, opts = {}) { + const res = await request(path, opts) + if (!res.ok) { + let msg = `${res.status}` + try { + const body = await res.json() + msg = body.detail || body.message || msg + } catch {} + const err = new Error(msg) + err.status = res.status + throw err + } + return res.json() +} + +function apiFn(path, opts = {}) { + return request(path, opts) +} + +apiFn.get = (path) => requestJSON(path) +apiFn.post = (path, body) => requestJSON(path, { method: 'POST', body: JSON.stringify(body) }) +apiFn.put = (path, body) => requestJSON(path, { method: 'PUT', body: JSON.stringify(body) }) +apiFn.del = (path) => requestJSON(path, { method: 'DELETE' }) + +export const api = apiFn diff --git a/frontend/src/composables/useDialog.js b/frontend/src/composables/useDialog.js new file mode 100644 index 0000000..bde6658 --- /dev/null +++ b/frontend/src/composables/useDialog.js @@ -0,0 +1,45 @@ +import { ref } from 'vue' + +const visible = ref(false) +const message = ref('') +const type = ref('confirm') // 'confirm' | 'prompt' | 'date' | 'time' +const inputValue = ref('') +const inputType = ref('text') +let resolvePromise = null + +export function useDialog() { + function showDialog(msg, dialogType = 'confirm', defaultVal = '') { + return new Promise(resolve => { + resolvePromise = resolve + message.value = msg + type.value = dialogType + if (dialogType === 'prompt') { + inputType.value = msg.includes('密码') ? 'password' : 'text' + } else if (dialogType === 'date') { + inputType.value = 'date' + } else if (dialogType === 'time') { + inputType.value = 'time' + } + inputValue.value = defaultVal + visible.value = true + }) + } + + function closeDialog(value) { + visible.value = false + if (resolvePromise) { + resolvePromise(value) + resolvePromise = null + } + } + + return { + visible, + message, + type, + inputValue, + inputType, + showDialog, + closeDialog, + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..7a0895c --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './assets/styles.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..729c9a1 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,51 @@ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/', + name: 'Notes', + component: () => import('../views/NotesView.vue'), + }, + { + path: '/tasks', + name: 'Tasks', + component: () => import('../views/TasksView.vue'), + }, + { + path: '/reminders', + name: 'Reminders', + component: () => import('../views/RemindersView.vue'), + }, + { + path: '/body', + name: 'Body', + component: () => import('../views/BodyView.vue'), + }, + { + path: '/music', + name: 'Music', + component: () => import('../views/MusicView.vue'), + }, + { + path: '/docs', + name: 'Docs', + component: () => import('../views/DocsView.vue'), + }, + { + path: '/planning', + name: 'Planning', + component: () => import('../views/PlanningView.vue'), + }, + { + path: '/sleep-buddy', + name: 'SleepBuddy', + component: () => import('../views/SleepBuddyView.vue'), + }, +] + +const router = createRouter({ + history: createWebHistory(), + routes, +}) + +export default router diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js new file mode 100644 index 0000000..e9ff0d1 --- /dev/null +++ b/frontend/src/stores/auth.js @@ -0,0 +1,42 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { api } from '../composables/useApi' + +export const useAuthStore = defineStore('auth', () => { + const loggedIn = ref(false) + + function checkLogin() { + const exp = localStorage.getItem('sp_login_expires') + loggedIn.value = exp && Date.now() < parseInt(exp) + return loggedIn.value + } + + async function login(password) { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(password)) + const hash = Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('') + await api.post('/api/login', { hash }) + localStorage.setItem('sp_login_expires', String(Date.now() + 7 * 86400000)) + loggedIn.value = true + } + + function logout() { + localStorage.removeItem('sp_login_expires') + loggedIn.value = false + } + + async function changePassword(oldPass, newPass) { + const hash = async (s) => { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s)) + return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('') + } + await api.post('/api/change-password', { + oldHash: await hash(oldPass), + newHash: await hash(newPass), + }) + } + + // Auto-check on init + checkLogin() + + return { loggedIn, checkLogin, login, logout, changePassword } +}) diff --git a/frontend/src/stores/planner.js b/frontend/src/stores/planner.js new file mode 100644 index 0000000..cefbfac --- /dev/null +++ b/frontend/src/stores/planner.js @@ -0,0 +1,360 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import { api } from '../composables/useApi' + +export const usePlannerStore = defineStore('planner', () => { + const notes = ref([]) + const todos = ref([]) + const inbox = ref([]) + const reminders = ref([]) + const goals = ref([]) + const checklists = ref([]) + const sleepRecords = ref([]) + const gymRecords = ref([]) + const periodRecords = ref([]) + const docs = ref([]) + const bugs = ref([]) + const reviews = ref([]) + const healthItems = ref([]) + const healthPlans = ref([]) + const healthChecks = ref([]) + const musicItems = ref([]) + const musicPlans = ref([]) + const musicChecks = ref([]) + const scheduleModules = ref([]) + const scheduleSlots = ref([]) + const weeklyTemplate = ref([]) + const loading = ref(false) + + async function loadAll() { + loading.value = true + try { + const [ + notesData, todosData, inboxData, remindersData, + goalsData, checklistsData, sleepData, gymData, + periodData, docsData, bugsData, reviewsData, + hItems, hPlans, hChecks, + mItems, mPlans, mChecks, + sMods, sSlots, wTemplate, + ] = await Promise.all([ + api.get('/api/notes'), + api.get('/api/todos'), + api.get('/api/inbox'), + api.get('/api/reminders'), + api.get('/api/goals'), + api.get('/api/checklists'), + api.get('/api/sleep'), + api.get('/api/gym'), + api.get('/api/period'), + api.get('/api/docs'), + api.get('/api/bugs'), + api.get('/api/reviews'), + api.get('/api/health-items?type=health'), + api.get('/api/health-plans?type=health'), + api.get('/api/health-checks?type=health'), + api.get('/api/health-items?type=music'), + api.get('/api/health-plans?type=music'), + api.get('/api/health-checks?type=music'), + api.get('/api/schedule-modules'), + api.get('/api/schedule-slots'), + api.get('/api/weekly-template'), + ]) + notes.value = notesData + todos.value = todosData + inbox.value = inboxData + reminders.value = remindersData + goals.value = goalsData + checklists.value = checklistsData + sleepRecords.value = sleepData + gymRecords.value = gymData + periodRecords.value = periodData + docs.value = docsData + bugs.value = bugsData + reviews.value = reviewsData + healthItems.value = hItems + healthPlans.value = hPlans + healthChecks.value = hChecks + musicItems.value = mItems + musicPlans.value = mPlans + musicChecks.value = mChecks + scheduleModules.value = sMods + scheduleSlots.value = sSlots + weeklyTemplate.value = wTemplate + } finally { + loading.value = false + } + } + + // ---- Notes ---- + async function addNote(note) { + await api.post('/api/notes', note) + notes.value.unshift(note) + } + async function deleteNote(id) { + await api.del(`/api/notes/${id}`) + notes.value = notes.value.filter(n => n.id !== id) + } + async function updateNote(note) { + await api.post('/api/notes', note) + const idx = notes.value.findIndex(n => n.id === note.id) + if (idx >= 0) notes.value[idx] = { ...notes.value[idx], ...note } + } + + // ---- Todos ---- + async function addTodo(todo) { + await api.post('/api/todos', todo) + todos.value.unshift(todo) + } + async function updateTodo(todo) { + await api.post('/api/todos', todo) + const idx = todos.value.findIndex(t => t.id === todo.id) + if (idx >= 0) todos.value[idx] = { ...todos.value[idx], ...todo } + } + async function deleteTodo(id) { + await api.del(`/api/todos/${id}`) + todos.value = todos.value.filter(t => t.id !== id) + } + + // ---- Inbox ---- + async function addInbox(item) { + await api.post('/api/inbox', item) + inbox.value.unshift(item) + } + async function deleteInbox(id) { + await api.del(`/api/inbox/${id}`) + inbox.value = inbox.value.filter(i => i.id !== id) + } + async function clearInbox() { + await api.del('/api/inbox') + inbox.value = [] + } + + // ---- Reminders ---- + async function addReminder(r) { + await api.post('/api/reminders', r) + reminders.value.push(r) + } + async function updateReminder(r) { + await api.post('/api/reminders', r) + const idx = reminders.value.findIndex(x => x.id === r.id) + if (idx >= 0) reminders.value[idx] = { ...reminders.value[idx], ...r } + } + async function deleteReminder(id) { + await api.del(`/api/reminders/${id}`) + reminders.value = reminders.value.filter(r => r.id !== id) + } + + // ---- Goals ---- + async function addGoal(g) { + await api.post('/api/goals', g) + goals.value.unshift(g) + } + async function updateGoal(g) { + await api.post('/api/goals', g) + const idx = goals.value.findIndex(x => x.id === g.id) + if (idx >= 0) goals.value[idx] = { ...goals.value[idx], ...g } + } + async function deleteGoal(id) { + await api.del(`/api/goals/${id}`) + goals.value = goals.value.filter(g => g.id !== id) + } + + // ---- Checklists ---- + async function addChecklist(cl) { + await api.post('/api/checklists', cl) + checklists.value.unshift(cl) + } + async function updateChecklist(cl) { + await api.post('/api/checklists', cl) + const idx = checklists.value.findIndex(x => x.id === cl.id) + if (idx >= 0) checklists.value[idx] = { ...checklists.value[idx], ...cl } + } + async function deleteChecklist(id) { + await api.del(`/api/checklists/${id}`) + checklists.value = checklists.value.filter(c => c.id !== id) + } + + // ---- Sleep ---- + async function addSleep(record) { + await api.post('/api/sleep', record) + const idx = sleepRecords.value.findIndex(r => r.date === record.date) + if (idx >= 0) sleepRecords.value[idx] = record + else sleepRecords.value.unshift(record) + } + async function deleteSleep(date) { + await api.del(`/api/sleep/${date}`) + sleepRecords.value = sleepRecords.value.filter(r => r.date !== date) + } + + // ---- Gym ---- + async function addGym(record) { + await api.post('/api/gym', record) + gymRecords.value.unshift(record) + } + async function deleteGym(id) { + await api.del(`/api/gym/${id}`) + gymRecords.value = gymRecords.value.filter(r => r.id !== id) + } + + // ---- Period ---- + async function addPeriod(record) { + await api.post('/api/period', record) + periodRecords.value.unshift(record) + } + async function updatePeriod(record) { + await api.post('/api/period', record) + const idx = periodRecords.value.findIndex(r => r.id === record.id) + if (idx >= 0) periodRecords.value[idx] = { ...periodRecords.value[idx], ...record } + } + async function deletePeriod(id) { + await api.del(`/api/period/${id}`) + periodRecords.value = periodRecords.value.filter(r => r.id !== id) + } + + // ---- Docs ---- + async function addDoc(doc) { + await api.post('/api/docs', doc) + docs.value.push({ ...doc, entries: [] }) + } + async function updateDoc(doc) { + await api.post('/api/docs', doc) + const idx = docs.value.findIndex(d => d.id === doc.id) + if (idx >= 0) docs.value[idx] = { ...docs.value[idx], ...doc } + } + async function deleteDoc(id) { + await api.del(`/api/docs/${id}`) + docs.value = docs.value.filter(d => d.id !== id) + } + async function addDocEntry(entry) { + await api.post('/api/doc-entries', entry) + const doc = docs.value.find(d => d.id === entry.doc_id) + if (doc) { + if (!doc.entries) doc.entries = [] + doc.entries.unshift(entry) + } + } + async function deleteDocEntry(entryId, docId) { + await api.del(`/api/doc-entries/${entryId}`) + const doc = docs.value.find(d => d.id === docId) + if (doc) doc.entries = doc.entries.filter(e => e.id !== entryId) + } + + // ---- Bugs ---- + async function addBug(bug) { + await api.post('/api/bugs', bug) + bugs.value.unshift(bug) + } + async function updateBug(bug) { + await api.post('/api/bugs', bug) + const idx = bugs.value.findIndex(b => b.id === bug.id) + if (idx >= 0) bugs.value[idx] = { ...bugs.value[idx], ...bug } + } + async function deleteBug(id) { + await api.del(`/api/bugs/${id}`) + bugs.value = bugs.value.filter(b => b.id !== id) + } + + // ---- Reviews ---- + async function saveReview(review) { + await api.post('/api/reviews', review) + const idx = reviews.value.findIndex(r => r.week === review.week) + if (idx >= 0) reviews.value[idx] = review + else reviews.value.unshift(review) + } + + // ---- Health check-in ---- + async function addHealthItem(item) { + await api.post('/api/health-items', item) + if (item.type === 'music') musicItems.value.push(item) + else healthItems.value.push(item) + } + async function deleteHealthItem(id) { + await api.del(`/api/health-items/${id}`) + healthItems.value = healthItems.value.filter(i => i.id !== id) + musicItems.value = musicItems.value.filter(i => i.id !== id) + } + async function saveHealthPlan(plan) { + await api.post('/api/health-plans', plan) + const list = plan.type === 'music' ? musicPlans : healthPlans + const idx = list.value.findIndex(p => p.month === plan.month && p.type === plan.type) + if (idx >= 0) list.value[idx] = plan + else list.value.push(plan) + } + async function toggleHealthCheck(check) { + await api.post('/api/health-checks', check) + const list = check.type === 'music' ? musicChecks : healthChecks + if (check.checked) { + list.value.push(check) + } else { + list.value = list.value.filter( + c => !(c.date === check.date && c.type === check.type && c.item_id === check.item_id) + ) + } + } + + // ---- Schedule ---- + async function addScheduleModule(m) { + await api.post('/api/schedule-modules', m) + scheduleModules.value.push(m) + } + async function updateScheduleModule(m) { + await api.post('/api/schedule-modules', m) + const idx = scheduleModules.value.findIndex(x => x.id === m.id) + if (idx >= 0) scheduleModules.value[idx] = { ...scheduleModules.value[idx], ...m } + } + async function deleteScheduleModule(id) { + await api.del(`/api/schedule-modules/${id}`) + scheduleModules.value = scheduleModules.value.filter(m => m.id !== id) + scheduleSlots.value = scheduleSlots.value.filter(s => s.module_id !== id) + } + async function addScheduleSlot(slot) { + await api.post('/api/schedule-slots', slot) + scheduleSlots.value.push(slot) + } + async function removeScheduleSlot(date, timeSlot, moduleId) { + await api.del(`/api/schedule-slots?date=${date}&time_slot=${timeSlot}&module_id=${moduleId}`) + scheduleSlots.value = scheduleSlots.value.filter( + s => !(s.date === date && s.time_slot === timeSlot && s.module_id === moduleId) + ) + } + async function clearScheduleDay(date) { + await api.del(`/api/schedule-slots?date=${date}`) + scheduleSlots.value = scheduleSlots.value.filter(s => s.date !== date) + } + + // ---- Weekly Template ---- + async function saveWeeklyTemplate(day, data) { + await api.post('/api/weekly-template', { day, data: JSON.stringify(data) }) + const idx = weeklyTemplate.value.findIndex(t => t.day === day) + if (idx >= 0) weeklyTemplate.value[idx].data = JSON.stringify(data) + else weeklyTemplate.value.push({ day, data: JSON.stringify(data) }) + } + + return { + // State + notes, todos, inbox, reminders, goals, checklists, + sleepRecords, gymRecords, periodRecords, docs, bugs, reviews, + healthItems, healthPlans, healthChecks, + musicItems, musicPlans, musicChecks, + scheduleModules, scheduleSlots, weeklyTemplate, + loading, + // Actions + loadAll, + addNote, deleteNote, updateNote, + addTodo, updateTodo, deleteTodo, + addInbox, deleteInbox, clearInbox, + addReminder, updateReminder, deleteReminder, + addGoal, updateGoal, deleteGoal, + addChecklist, updateChecklist, deleteChecklist, + addSleep, deleteSleep, + addGym, deleteGym, + addPeriod, updatePeriod, deletePeriod, + addDoc, updateDoc, deleteDoc, addDocEntry, deleteDocEntry, + addBug, updateBug, deleteBug, + saveReview, + addHealthItem, deleteHealthItem, saveHealthPlan, toggleHealthCheck, + addScheduleModule, updateScheduleModule, deleteScheduleModule, + addScheduleSlot, removeScheduleSlot, clearScheduleDay, + saveWeeklyTemplate, + } +}) diff --git a/frontend/src/stores/ui.js b/frontend/src/stores/ui.js new file mode 100644 index 0000000..b3be8b4 --- /dev/null +++ b/frontend/src/stores/ui.js @@ -0,0 +1,30 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export const useUiStore = defineStore('ui', () => { + const currentTab = ref(localStorage.getItem('sp_current_tab') || 'notes') + const showLoginModal = ref(false) + const toasts = ref([]) + + function setTab(tab) { + currentTab.value = tab + localStorage.setItem('sp_current_tab', tab) + } + + function openLogin() { + showLoginModal.value = true + } + + function closeLogin() { + showLoginModal.value = false + } + + function toast(msg, duration = 2000) { + toasts.value.push(msg) + setTimeout(() => { + toasts.value.shift() + }, duration) + } + + return { currentTab, showLoginModal, toasts, setTab, openLogin, closeLogin, toast } +}) diff --git a/frontend/src/views/BodyView.vue b/frontend/src/views/BodyView.vue new file mode 100644 index 0000000..8c69cfa --- /dev/null +++ b/frontend/src/views/BodyView.vue @@ -0,0 +1,260 @@ + + + diff --git a/frontend/src/views/DocsView.vue b/frontend/src/views/DocsView.vue new file mode 100644 index 0000000..a5e3850 --- /dev/null +++ b/frontend/src/views/DocsView.vue @@ -0,0 +1,144 @@ + + + diff --git a/frontend/src/views/MusicView.vue b/frontend/src/views/MusicView.vue new file mode 100644 index 0000000..a836571 --- /dev/null +++ b/frontend/src/views/MusicView.vue @@ -0,0 +1,94 @@ + + + diff --git a/frontend/src/views/NotesView.vue b/frontend/src/views/NotesView.vue new file mode 100644 index 0000000..1b8f0e6 --- /dev/null +++ b/frontend/src/views/NotesView.vue @@ -0,0 +1,148 @@ + + + diff --git a/frontend/src/views/PlanningView.vue b/frontend/src/views/PlanningView.vue new file mode 100644 index 0000000..9421fdb --- /dev/null +++ b/frontend/src/views/PlanningView.vue @@ -0,0 +1,224 @@ + + + diff --git a/frontend/src/views/RemindersView.vue b/frontend/src/views/RemindersView.vue new file mode 100644 index 0000000..4107ce2 --- /dev/null +++ b/frontend/src/views/RemindersView.vue @@ -0,0 +1,77 @@ + + + diff --git a/frontend/src/views/SleepBuddyView.vue b/frontend/src/views/SleepBuddyView.vue new file mode 100644 index 0000000..29ae6bc --- /dev/null +++ b/frontend/src/views/SleepBuddyView.vue @@ -0,0 +1,183 @@ + + + diff --git a/frontend/src/views/TasksView.vue b/frontend/src/views/TasksView.vue new file mode 100644 index 0000000..906a5c8 --- /dev/null +++ b/frontend/src/views/TasksView.vue @@ -0,0 +1,209 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..d9b539a --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': 'http://localhost:8000' + } + }, + build: { + outDir: 'dist' + } +}) diff --git a/scripts/deploy-preview.py b/scripts/deploy-preview.py new file mode 100644 index 0000000..ff5b725 --- /dev/null +++ b/scripts/deploy-preview.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Deploy or teardown a PR preview environment on local k3s. + +Runs directly on the oci server (where k3s and docker are local). + +Usage: + python3 scripts/deploy-preview.py deploy + python3 scripts/deploy-preview.py teardown + python3 scripts/deploy-preview.py deploy-prod +""" + +import subprocess +import sys +import json +import tempfile +import textwrap +from pathlib import Path + +REGISTRY = "registry.oci.euphon.net" +BASE_DOMAIN = "planner.oci.euphon.net" +PROD_NS = "planner" +APP_NAME = "planner" + + +def run(cmd: list[str] | str, *, check=True, capture=False) -> subprocess.CompletedProcess: + if isinstance(cmd, str): + cmd = ["sh", "-c", cmd] + display = " ".join(cmd) if isinstance(cmd, list) else cmd + print(f" $ {display}") + r = subprocess.run(cmd, text=True, capture_output=capture) + if capture and r.stdout.strip(): + for line in r.stdout.strip().splitlines()[:5]: + print(f" {line}") + if check and r.returncode != 0: + print(f" FAILED (exit {r.returncode})") + if capture and r.stderr.strip(): + print(f" {r.stderr.strip()[:200]}") + sys.exit(1) + return r + + +def kubectl(*args, capture=False, check=True) -> subprocess.CompletedProcess: + return run(["sudo", "k3s", "kubectl", *args], capture=capture, check=check) + + +def docker(*args, check=True) -> subprocess.CompletedProcess: + return run(["docker", *args], check=check) + + +def write_temp(content: str, suffix=".yaml") -> Path: + f = tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) + f.write(content) + f.close() + return Path(f.name) + + +# ─── Deploy Preview ───────────────────────────────────── + +def deploy(pr_id: str): + ns = f"planner-pr-{pr_id}" + host = f"pr-{pr_id}.{BASE_DOMAIN}" + image = f"{REGISTRY}/{APP_NAME}:pr-{pr_id}" + + print(f"\n{'='*60}") + print(f" Deploying: https://{host}") + print(f" Namespace: {ns}") + print(f"{'='*60}\n") + + # 1. Copy production DB into build context + print("[1/5] Copying production database...") + Path("data").mkdir(exist_ok=True) + prod_pod = kubectl( + "get", "pods", "-n", PROD_NS, + "-l", f"app={APP_NAME}", + "--field-selector=status.phase=Running", + "-o", "jsonpath={.items[0].metadata.name}", + capture=True, check=False + ).stdout.strip() + + if prod_pod: + kubectl("cp", f"{PROD_NS}/{prod_pod}:/data/planner.db", "data/planner.db") + else: + print(" WARNING: No running prod pod, using empty DB") + Path("data/planner.db").touch() + + # 2. Build and push image + print("[2/5] Building Docker image...") + dockerfile = textwrap.dedent("""\ + FROM node:20-slim AS frontend-build + WORKDIR /build + COPY frontend/package.json frontend/package-lock.json ./ + RUN npm ci + COPY frontend/ ./ + RUN npm run build + + FROM python:3.12-slim + WORKDIR /app + COPY backend/requirements.txt . + RUN pip install --no-cache-dir -r requirements.txt + COPY backend/ ./backend/ + COPY --from=frontend-build /build/dist ./frontend/ + COPY data/planner.db /data/planner.db + ENV DB_PATH=/data/planner.db + ENV FRONTEND_DIR=/app/frontend + ENV DATA_DIR=/data + EXPOSE 8000 + CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"] + """) + df = write_temp(dockerfile, suffix=".Dockerfile") + docker("build", "-f", str(df), "-t", image, ".") + df.unlink() + docker("push", image) + + # 3. Create namespace + regcred + print("[3/5] Creating namespace...") + run(f"sudo k3s kubectl create namespace {ns} --dry-run=client -o yaml | sudo k3s kubectl apply -f -") + + # Copy regcred from prod namespace + r = kubectl("get", "secret", "regcred", "-n", PROD_NS, "-o", "json", capture=True, check=False) + if r.returncode == 0 and r.stdout.strip(): + secret = json.loads(r.stdout) + secret["metadata"] = {"name": "regcred", "namespace": ns} + p = write_temp(json.dumps(secret), suffix=".json") + kubectl("apply", "-f", str(p)) + p.unlink() + + # 4. Apply manifests + print("[4/5] Applying K8s resources...") + manifests = textwrap.dedent(f"""\ + apiVersion: apps/v1 + kind: Deployment + metadata: + name: {APP_NAME} + namespace: {ns} + spec: + replicas: 1 + selector: + matchLabels: + app: {APP_NAME} + template: + metadata: + labels: + app: {APP_NAME} + spec: + imagePullSecrets: + - name: regcred + containers: + - name: app + image: {image} + imagePullPolicy: Always + ports: + - containerPort: 8000 + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi + --- + apiVersion: v1 + kind: Service + metadata: + name: {APP_NAME} + namespace: {ns} + spec: + selector: + app: {APP_NAME} + ports: + - port: 80 + targetPort: 8000 + --- + apiVersion: networking.k8s.io/v1 + kind: Ingress + metadata: + name: {APP_NAME} + namespace: {ns} + annotations: + traefik.ingress.kubernetes.io/router.tls.certresolver: le + spec: + ingressClassName: traefik + tls: + - hosts: + - {host} + rules: + - host: {host} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {APP_NAME} + port: + number: 80 + """) + p = write_temp(manifests) + kubectl("apply", "-f", str(p)) + p.unlink() + + # 5. Restart and wait + print("[5/5] Restarting deployment...") + kubectl("rollout", "restart", f"deploy/{APP_NAME}", "-n", ns) + kubectl("rollout", "status", f"deploy/{APP_NAME}", "-n", ns, "--timeout=120s") + + # Cleanup + run("rm -rf data/planner.db", check=False) + + print(f"\n{'='*60}") + print(f" Preview live: https://{host}") + print(f"{'='*60}\n") + + +# ─── Teardown ──────────────────────────────────────────── + +def teardown(pr_id: str): + ns = f"planner-pr-{pr_id}" + image = f"{REGISTRY}/{APP_NAME}:pr-{pr_id}" + + print(f"\n Tearing down: {ns}") + kubectl("delete", "namespace", ns, "--ignore-not-found") + docker("rmi", image, check=False) + print(" Done.\n") + + +# ─── Deploy Production ─────────────────────────────────── + +def deploy_prod(): + image = f"{REGISTRY}/{APP_NAME}:latest" + + print(f"\n{'='*60}") + print(f" Deploying production: https://{BASE_DOMAIN}") + print(f"{'='*60}\n") + + docker("build", "-t", image, ".") + docker("push", image) + kubectl("rollout", "restart", f"deploy/{APP_NAME}", "-n", PROD_NS) + kubectl("rollout", "status", f"deploy/{APP_NAME}", "-n", PROD_NS, "--timeout=120s") + + print(f"\n Production deployed: https://{BASE_DOMAIN}\n") + + +# ─── Main ──────────────────────────────────────────────── + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + + action = sys.argv[1] + if action == "deploy" and len(sys.argv) >= 3: + deploy(sys.argv[2]) + elif action == "teardown" and len(sys.argv) >= 3: + teardown(sys.argv[2]) + elif action == "deploy-prod": + deploy_prod() + else: + print(__doc__) + sys.exit(1)