diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/.manus/db/db-query-1773487370636.json b/.manus/db/db-query-1773487370636.json new file mode 100644 index 0000000..68f5b65 --- /dev/null +++ b/.manus/db/db-query-1773487370636.json @@ -0,0 +1,9 @@ +{ + "query": "CREATE TABLE `pose_analyses` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`videoId` int NOT NULL,\n\t`userId` int NOT NULL,\n\t`overallScore` float,\n\t`poseMetrics` json,\n\t`detectedIssues` json,\n\t`corrections` json,\n\t`exerciseType` varchar(64),\n\t`framesAnalyzed` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `pose_analyses_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_plans` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`skillLevel` enum('beginner','intermediate','advanced') NOT NULL,\n\t`durationDays` int NOT NULL DEFAULT 7,\n\t`exercises` json NOT NULL,\n\t`isActive` int NOT NULL DEFAULT 1,\n\t`adjustmentNotes` text,\n\t`version` int NOT NULL DEFAULT 1,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_plans_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_records` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`planId` int,\n\t`exerciseName` varchar(128) NOT NULL,\n\t`durationMinutes` int,\n\t`completed` int NOT NULL DEFAULT 0,\n\t`notes` text,\n\t`poseScore` float,\n\t`trainingDate` timestamp NOT NULL DEFAULT (now()),\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `training_records_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_videos` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`fileKey` varchar(512) NOT NULL,\n\t`url` text NOT NULL,\n\t`format` varchar(16) NOT NULL,\n\t`fileSize` int,\n\t`duration` float,\n\t`exerciseType` varchar(64),\n\t`analysisStatus` enum('pending','analyzing','completed','failed') DEFAULT 'pending',\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_videos_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `username_accounts` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`username` varchar(64) NOT NULL,\n\t`userId` int NOT NULL,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `username_accounts_id` PRIMARY KEY(`id`),\n\tCONSTRAINT `username_accounts_username_unique` UNIQUE(`username`)\n);\n\nALTER TABLE `users` ADD `skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner';\nALTER TABLE `users` ADD `trainingGoals` text;", + "command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway04.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2DECURBBieadmmU.root --database auVVpV3E7dpuxwRrSUT9kL --execute CREATE TABLE `pose_analyses` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`videoId` int NOT NULL,\n\t`userId` int NOT NULL,\n\t`overallScore` float,\n\t`poseMetrics` json,\n\t`detectedIssues` json,\n\t`corrections` json,\n\t`exerciseType` varchar(64),\n\t`framesAnalyzed` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `pose_analyses_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_plans` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`skillLevel` enum('beginner','intermediate','advanced') NOT NULL,\n\t`durationDays` int NOT NULL DEFAULT 7,\n\t`exercises` json NOT NULL,\n\t`isActive` int NOT NULL DEFAULT 1,\n\t`adjustmentNotes` text,\n\t`version` int NOT NULL DEFAULT 1,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_plans_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_records` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`planId` int,\n\t`exerciseName` varchar(128) NOT NULL,\n\t`durationMinutes` int,\n\t`completed` int NOT NULL DEFAULT 0,\n\t`notes` text,\n\t`poseScore` float,\n\t`trainingDate` timestamp NOT NULL DEFAULT (now()),\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `training_records_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `training_videos` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`title` varchar(256) NOT NULL,\n\t`fileKey` varchar(512) NOT NULL,\n\t`url` text NOT NULL,\n\t`format` varchar(16) NOT NULL,\n\t`fileSize` int,\n\t`duration` float,\n\t`exerciseType` varchar(64),\n\t`analysisStatus` enum('pending','analyzing','completed','failed') DEFAULT 'pending',\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\t`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,\n\tCONSTRAINT `training_videos_id` PRIMARY KEY(`id`)\n);\n\nCREATE TABLE `username_accounts` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`username` varchar(64) NOT NULL,\n\t`userId` int NOT NULL,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `username_accounts_id` PRIMARY KEY(`id`),\n\tCONSTRAINT `username_accounts_username_unique` UNIQUE(`username`)\n);\n\nALTER TABLE `users` ADD `skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner';\nALTER TABLE `users` ADD `trainingGoals` text;", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 5704 +} \ No newline at end of file diff --git a/.manus/db/db-query-1773487661413.json b/.manus/db/db-query-1773487661413.json new file mode 100644 index 0000000..08893aa --- /dev/null +++ b/.manus/db/db-query-1773487661413.json @@ -0,0 +1,9 @@ +{ + "query": "CREATE TABLE `rating_history` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`rating` float NOT NULL,\n\t`reason` varchar(256),\n\t`dimensionScores` json,\n\t`analysisId` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `rating_history_id` PRIMARY KEY(`id`)\n);\n\nALTER TABLE `pose_analyses` ADD `shotCount` int DEFAULT 0;\nALTER TABLE `pose_analyses` ADD `avgSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `maxSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `totalMovementDistance` float;\nALTER TABLE `pose_analyses` ADD `strokeConsistency` float;\nALTER TABLE `pose_analyses` ADD `footworkScore` float;\nALTER TABLE `pose_analyses` ADD `fluidityScore` float;\nALTER TABLE `pose_analyses` ADD `keyMoments` json;\nALTER TABLE `pose_analyses` ADD `movementTrajectory` json;\nALTER TABLE `users` ADD `ntrpRating` float DEFAULT 1.5;\nALTER TABLE `users` ADD `totalSessions` int DEFAULT 0;\nALTER TABLE `users` ADD `totalMinutes` int DEFAULT 0;", + "command": "mysql --batch --raw --column-names --default-character-set=utf8mb4 --host gateway04.us-east-1.prod.aws.tidbcloud.com --port 4000 --user 2DECURBBieadmmU.root --database auVVpV3E7dpuxwRrSUT9kL --execute CREATE TABLE `rating_history` (\n\t`id` int AUTO_INCREMENT NOT NULL,\n\t`userId` int NOT NULL,\n\t`rating` float NOT NULL,\n\t`reason` varchar(256),\n\t`dimensionScores` json,\n\t`analysisId` int,\n\t`createdAt` timestamp NOT NULL DEFAULT (now()),\n\tCONSTRAINT `rating_history_id` PRIMARY KEY(`id`)\n);\n\nALTER TABLE `pose_analyses` ADD `shotCount` int DEFAULT 0;\nALTER TABLE `pose_analyses` ADD `avgSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `maxSwingSpeed` float;\nALTER TABLE `pose_analyses` ADD `totalMovementDistance` float;\nALTER TABLE `pose_analyses` ADD `strokeConsistency` float;\nALTER TABLE `pose_analyses` ADD `footworkScore` float;\nALTER TABLE `pose_analyses` ADD `fluidityScore` float;\nALTER TABLE `pose_analyses` ADD `keyMoments` json;\nALTER TABLE `pose_analyses` ADD `movementTrajectory` json;\nALTER TABLE `users` ADD `ntrpRating` float DEFAULT 1.5;\nALTER TABLE `users` ADD `totalSessions` int DEFAULT 0;\nALTER TABLE `users` ADD `totalMinutes` int DEFAULT 0;", + "rows": [], + "messages": [], + "stdout": "", + "stderr": "", + "execution_time_ms": 7485 +} \ No newline at end of file diff --git a/client/index.html b/client/index.html index 0aafa3b..6310b19 100644 --- a/client/index.html +++ b/client/index.html @@ -1,17 +1,15 @@ - + - Tennis Training Hub - AI网球训练助手 - + diff --git a/client/src/App.tsx b/client/src/App.tsx index 5c7a610..f244875 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,32 +4,57 @@ import NotFound from "@/pages/NotFound"; import { Route, Switch } from "wouter"; import ErrorBoundary from "./components/ErrorBoundary"; import { ThemeProvider } from "./contexts/ThemeContext"; +import DashboardLayout from "./components/DashboardLayout"; import Home from "./pages/Home"; +import Login from "./pages/Login"; +import Dashboard from "./pages/Dashboard"; +import Training from "./pages/Training"; +import Analysis from "./pages/Analysis"; +import Videos from "./pages/Videos"; +import Progress from "./pages/Progress"; +import Rating from "./pages/Rating"; + +function DashboardRoute({ component: Component }: { component: React.ComponentType }) { + return ( + + + + ); +} function Router() { - // make sure to consider if you need authentication for certain routes return ( - - - {/* Final fallback route */} + + + + + + + + + + + + + + + + + + + + + ); } -// NOTE: About Theme -// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css -// to keep consistent foreground/background color across components -// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook - function App() { return ( - + diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 0bf7437..3386b74 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -19,23 +19,28 @@ import { SidebarTrigger, useSidebar, } from "@/components/ui/sidebar"; -import { getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react"; +import { + LayoutDashboard, LogOut, PanelLeft, Target, Video, + Award, Activity, FileVideo +} from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; -import { useLocation } from "wouter"; +import { useLocation, Redirect } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; -import { Button } from "./ui/button"; const menuItems = [ - { icon: LayoutDashboard, label: "Page 1", path: "/" }, - { icon: Users, label: "Page 2", path: "/some-path" }, + { icon: LayoutDashboard, label: "仪表盘", path: "/dashboard" }, + { icon: Target, label: "训练计划", path: "/training" }, + { icon: Video, label: "视频分析", path: "/analysis" }, + { icon: FileVideo, label: "视频库", path: "/videos" }, + { icon: Activity, label: "训练进度", path: "/progress" }, + { icon: Award, label: "NTRP评分", path: "/rating" }, ]; const SIDEBAR_WIDTH_KEY = "sidebar-width"; -const DEFAULT_WIDTH = 280; +const DEFAULT_WIDTH = 260; const MIN_WIDTH = 200; -const MAX_WIDTH = 480; +const MAX_WIDTH = 400; export default function DashboardLayout({ children, @@ -57,29 +62,7 @@ export default function DashboardLayout({ } if (!user) { - return ( -
-
-
-

- Sign in to continue -

-

- Access to this dashboard requires authentication. Continue to launch the login flow. -

-
- -
-
- ); + return ; } return ( @@ -124,7 +107,6 @@ function DashboardLayoutContent({ useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!isResizing) return; - const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0; const newWidth = e.clientX - sidebarLeft; if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) { @@ -170,8 +152,9 @@ function DashboardLayoutContent({ {!isCollapsed ? (
- - Navigation + + + Tennis Hub
) : null} @@ -206,16 +189,16 @@ function DashboardLayoutContent({ @@ -226,7 +209,7 @@ function DashboardLayoutContent({ className="cursor-pointer text-destructive focus:text-destructive" > - Sign out + 退出登录 @@ -249,15 +232,15 @@ function DashboardLayoutContent({
- - {activeMenuItem?.label ?? "Menu"} + + {activeMenuItem?.label ?? "Tennis Hub"}
)} -
{children}
+
{children}
); diff --git a/client/src/index.css b/client/src/index.css index 72b423d..8b81e49 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -40,77 +40,79 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --font-sans: 'Inter', 'Noto Sans SC', system-ui, sans-serif; } :root { - --primary: var(--color-blue-700); - --primary-foreground: var(--color-blue-50); - --sidebar-primary: var(--color-blue-600); - --sidebar-primary-foreground: var(--color-blue-50); - --chart-1: var(--color-blue-300); - --chart-2: var(--color-blue-500); - --chart-3: var(--color-blue-600); - --chart-4: var(--color-blue-700); - --chart-5: var(--color-blue-800); - --radius: 0.65rem; - --background: oklch(1 0 0); - --foreground: oklch(0.235 0.015 65); + /* Tennis green theme */ + --primary: oklch(0.55 0.16 145); + --primary-foreground: oklch(0.98 0 0); + --sidebar-primary: oklch(0.50 0.15 145); + --sidebar-primary-foreground: oklch(0.98 0 0); + --chart-1: oklch(0.65 0.18 145); + --chart-2: oklch(0.55 0.16 145); + --chart-3: oklch(0.72 0.12 80); + --chart-4: oklch(0.60 0.14 200); + --chart-5: oklch(0.50 0.10 280); + --radius: 0.625rem; + --background: oklch(0.985 0.002 100); + --foreground: oklch(0.18 0.02 260); --card: oklch(1 0 0); - --card-foreground: oklch(0.235 0.015 65); + --card-foreground: oklch(0.18 0.02 260); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.235 0.015 65); - --secondary: oklch(0.98 0.001 286.375); - --secondary-foreground: oklch(0.4 0.015 65); - --muted: oklch(0.967 0.001 286.375); - --muted-foreground: oklch(0.552 0.016 285.938); - --accent: oklch(0.967 0.001 286.375); - --accent-foreground: oklch(0.141 0.005 285.823); + --popover-foreground: oklch(0.18 0.02 260); + --secondary: oklch(0.96 0.01 145); + --secondary-foreground: oklch(0.35 0.08 145); + --muted: oklch(0.96 0.005 260); + --muted-foreground: oklch(0.50 0.02 260); + --accent: oklch(0.95 0.02 145); + --accent-foreground: oklch(0.25 0.06 145); --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); - --border: oklch(0.92 0.004 286.32); - --input: oklch(0.92 0.004 286.32); - --ring: oklch(0.623 0.214 259.815); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.235 0.015 65); - --sidebar-accent: oklch(0.967 0.001 286.375); - --sidebar-accent-foreground: oklch(0.141 0.005 285.823); - --sidebar-border: oklch(0.92 0.004 286.32); - --sidebar-ring: oklch(0.623 0.214 259.815); + --border: oklch(0.91 0.005 145); + --input: oklch(0.91 0.005 145); + --ring: oklch(0.55 0.16 145); + --sidebar: oklch(0.98 0.005 145); + --sidebar-foreground: oklch(0.18 0.02 260); + --sidebar-accent: oklch(0.94 0.02 145); + --sidebar-accent-foreground: oklch(0.25 0.06 145); + --sidebar-border: oklch(0.91 0.005 145); + --sidebar-ring: oklch(0.55 0.16 145); } .dark { - --primary: var(--color-blue-700); - --primary-foreground: var(--color-blue-50); - --sidebar-primary: var(--color-blue-500); - --sidebar-primary-foreground: var(--color-blue-50); - --background: oklch(0.141 0.005 285.823); - --foreground: oklch(0.85 0.005 65); - --card: oklch(0.21 0.006 285.885); - --card-foreground: oklch(0.85 0.005 65); - --popover: oklch(0.21 0.006 285.885); - --popover-foreground: oklch(0.85 0.005 65); - --secondary: oklch(0.24 0.006 286.033); - --secondary-foreground: oklch(0.7 0.005 65); - --muted: oklch(0.274 0.006 286.033); - --muted-foreground: oklch(0.705 0.015 286.067); - --accent: oklch(0.274 0.006 286.033); - --accent-foreground: oklch(0.92 0.005 65); + --primary: oklch(0.65 0.18 145); + --primary-foreground: oklch(0.12 0.02 145); + --sidebar-primary: oklch(0.60 0.16 145); + --sidebar-primary-foreground: oklch(0.12 0.02 145); + --background: oklch(0.14 0.01 260); + --foreground: oklch(0.90 0.005 100); + --card: oklch(0.19 0.01 260); + --card-foreground: oklch(0.90 0.005 100); + --popover: oklch(0.19 0.01 260); + --popover-foreground: oklch(0.90 0.005 100); + --secondary: oklch(0.22 0.015 145); + --secondary-foreground: oklch(0.75 0.08 145); + --muted: oklch(0.25 0.01 260); + --muted-foreground: oklch(0.65 0.015 260); + --accent: oklch(0.25 0.02 145); + --accent-foreground: oklch(0.85 0.06 145); --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); - --ring: oklch(0.488 0.243 264.376); - --chart-1: var(--color-blue-300); - --chart-2: var(--color-blue-500); - --chart-3: var(--color-blue-600); - --chart-4: var(--color-blue-700); - --chart-5: var(--color-blue-800); - --sidebar: oklch(0.21 0.006 285.885); - --sidebar-foreground: oklch(0.85 0.005 65); - --sidebar-accent: oklch(0.274 0.006 286.033); - --sidebar-accent-foreground: oklch(0.985 0 0); + --ring: oklch(0.65 0.18 145); + --chart-1: oklch(0.65 0.18 145); + --chart-2: oklch(0.55 0.16 145); + --chart-3: oklch(0.72 0.12 80); + --chart-4: oklch(0.60 0.14 200); + --chart-5: oklch(0.50 0.10 280); + --sidebar: oklch(0.19 0.01 260); + --sidebar-foreground: oklch(0.90 0.005 100); + --sidebar-accent: oklch(0.25 0.02 145); + --sidebar-accent-foreground: oklch(0.85 0.06 145); --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.488 0.243 264.376); + --sidebar-ring: oklch(0.65 0.18 145); } @layer base { @@ -134,24 +136,11 @@ } @layer components { - /** - * Custom container utility that centers content and adds responsive padding. - * - * This overrides Tailwind's default container behavior to: - * - Auto-center content (mx-auto) - * - Add responsive horizontal padding - * - Set max-width for large screens - * - * Usage:
...
- * - * For custom widths, use max-w-* utilities directly: - *
...
- */ .container { width: 100%; margin-left: auto; margin-right: auto; - padding-left: 1rem; /* 16px - mobile padding */ + padding-left: 1rem; padding-right: 1rem; } @@ -162,16 +151,16 @@ @media (min-width: 640px) { .container { - padding-left: 1.5rem; /* 24px - tablet padding */ + padding-left: 1.5rem; padding-right: 1.5rem; } } @media (min-width: 1024px) { .container { - padding-left: 2rem; /* 32px - desktop padding */ + padding-left: 2rem; padding-right: 2rem; - max-width: 1280px; /* Standard content width */ + max-width: 1280px; } } -} \ No newline at end of file +} diff --git a/client/src/pages/Analysis.tsx b/client/src/pages/Analysis.tsx new file mode 100644 index 0000000..9fbc498 --- /dev/null +++ b/client/src/pages/Analysis.tsx @@ -0,0 +1,669 @@ +import { useState, useRef, useCallback, useEffect } from "react"; +import { useAuth } from "@/_core/hooks/useAuth"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Progress } from "@/components/ui/progress"; +import { Badge } from "@/components/ui/badge"; +import { toast } from "sonner"; +import { + Upload, Video, Loader2, Play, Pause, RotateCcw, + Zap, Target, Activity, TrendingUp, Eye +} from "lucide-react"; +import { Streamdown } from "streamdown"; + +type AnalysisResult = { + overallScore: number; + shotCount: number; + avgSwingSpeed: number; + maxSwingSpeed: number; + totalMovementDistance: number; + strokeConsistency: number; + footworkScore: number; + fluidityScore: number; + poseMetrics: any; + detectedIssues: string[]; + keyMoments: any[]; + movementTrajectory: any[]; + framesAnalyzed: number; +}; + +export default function Analysis() { + const { user } = useAuth(); + const [videoFile, setVideoFile] = useState(null); + const [videoUrl, setVideoUrl] = useState(""); + const [exerciseType, setExerciseType] = useState("forehand"); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analysisProgress, setAnalysisProgress] = useState(0); + const [analysisResult, setAnalysisResult] = useState(null); + const [corrections, setCorrections] = useState(""); + const [showSkeleton, setShowSkeleton] = useState(false); + const videoRef = useRef(null); + const canvasRef = useRef(null); + const fileInputRef = useRef(null); + + const utils = trpc.useUtils(); + + const uploadMutation = trpc.video.upload.useMutation(); + const saveMutation = trpc.analysis.save.useMutation({ + onSuccess: () => { + utils.profile.stats.invalidate(); + utils.analysis.list.invalidate(); + utils.rating.current.invalidate(); + utils.rating.history.invalidate(); + }, + }); + const correctionMutation = trpc.analysis.getCorrections.useMutation(); + + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + const ext = file.name.split(".").pop()?.toLowerCase(); + if (!["mp4", "webm"].includes(ext || "")) { + toast.error("仅支持 MP4 和 WebM 格式"); + return; + } + if (file.size > 100 * 1024 * 1024) { + toast.error("文件大小不能超过100MB"); + return; + } + setVideoFile(file); + setVideoUrl(URL.createObjectURL(file)); + setAnalysisResult(null); + setCorrections(""); + }; + + const analyzeVideo = useCallback(async () => { + if (!videoRef.current || !canvasRef.current || !videoFile) return; + + setIsAnalyzing(true); + setAnalysisProgress(0); + setShowSkeleton(true); + + try { + // Load MediaPipe Pose + const { Pose } = await import("@mediapipe/pose"); + const { drawConnectors, drawLandmarks } = await import("@mediapipe/drawing_utils"); + const { POSE_CONNECTIONS } = await import("@mediapipe/pose"); + + const pose = new Pose({ + locateFile: (file: string) => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`, + }); + + pose.setOptions({ + modelComplexity: 1, + smoothLandmarks: true, + enableSegmentation: false, + minDetectionConfidence: 0.5, + minTrackingConfidence: 0.5, + }); + + const video = videoRef.current; + const canvas = canvasRef.current; + const ctx = canvas.getContext("2d")!; + + // Analysis accumulators + let framesAnalyzed = 0; + let allPoseData: any[] = []; + let swingSpeedHistory: number[] = []; + let prevWristPos: { x: number; y: number } | null = null; + let shotCount = 0; + let prevWristSpeed = 0; + let isInSwing = false; + let movementTrajectory: { x: number; y: number; frame: number }[] = []; + let prevHipCenter: { x: number; y: number } | null = null; + let totalMovement = 0; + let keyMoments: { frame: number; type: string; description: string }[] = []; + let jointAnglesHistory: any[] = []; + + // Pose callback + pose.onResults((results: any) => { + if (!results.poseLandmarks) return; + + const landmarks = results.poseLandmarks; + framesAnalyzed++; + + // Draw skeleton on canvas + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + ctx.drawImage(video, 0, 0); + + if (showSkeleton) { + drawConnectors(ctx, landmarks, POSE_CONNECTIONS, { color: "#00FF00", lineWidth: 2 }); + drawLandmarks(ctx, landmarks, { color: "#FF0000", lineWidth: 1, radius: 3 }); + } + + // Calculate joint angles + const angles = calculateJointAngles(landmarks); + jointAnglesHistory.push(angles); + + // Wrist tracking for swing speed (right wrist = index 16) + const rightWrist = landmarks[16]; + const leftWrist = landmarks[15]; + const dominantWrist = exerciseType.includes("backhand") ? leftWrist : rightWrist; + + if (dominantWrist && prevWristPos) { + const dx = (dominantWrist.x - prevWristPos.x) * canvas.width; + const dy = (dominantWrist.y - prevWristPos.y) * canvas.height; + const speed = Math.sqrt(dx * dx + dy * dy); + swingSpeedHistory.push(speed); + + // Shot detection: speed spike above threshold + if (speed > 15 && prevWristSpeed < 15 && !isInSwing) { + isInSwing = true; + shotCount++; + keyMoments.push({ + frame: framesAnalyzed, + type: "shot", + description: `第${shotCount}次击球 - 挥拍速度: ${speed.toFixed(1)}px/frame`, + }); + } + if (speed < 5 && isInSwing) { + isInSwing = false; + } + prevWristSpeed = speed; + } + if (dominantWrist) { + prevWristPos = { x: dominantWrist.x, y: dominantWrist.y }; + } + + // Body center tracking (hip midpoint) + const leftHip = landmarks[23]; + const rightHip = landmarks[24]; + if (leftHip && rightHip) { + const hipCenter = { + x: (leftHip.x + rightHip.x) / 2, + y: (leftHip.y + rightHip.y) / 2, + }; + movementTrajectory.push({ ...hipCenter, frame: framesAnalyzed }); + + if (prevHipCenter) { + const dx = (hipCenter.x - prevHipCenter.x) * canvas.width; + const dy = (hipCenter.y - prevHipCenter.y) * canvas.height; + totalMovement += Math.sqrt(dx * dx + dy * dy); + } + prevHipCenter = hipCenter; + } + + allPoseData.push(landmarks); + }); + + // Process video frames + const fps = 15; // Sample at 15fps for performance + const duration = video.duration; + const totalFrames = Math.floor(duration * fps); + let currentFrame = 0; + + video.currentTime = 0; + + await new Promise((resolve) => { + const processFrame = async () => { + if (currentFrame >= totalFrames || video.currentTime >= duration) { + resolve(); + return; + } + + await pose.send({ image: video }); + currentFrame++; + setAnalysisProgress(Math.round((currentFrame / totalFrames) * 100)); + + video.currentTime = currentFrame / fps; + video.onseeked = () => { + requestAnimationFrame(processFrame); + }; + }; + + video.onseeked = () => processFrame(); + video.currentTime = 0; + }); + + // Calculate final metrics + const avgSwingSpeed = swingSpeedHistory.length > 0 + ? swingSpeedHistory.reduce((a, b) => a + b, 0) / swingSpeedHistory.length : 0; + const maxSwingSpeed = swingSpeedHistory.length > 0 + ? Math.max(...swingSpeedHistory) : 0; + + // Stroke consistency: std deviation of swing speeds during shots + const shotSpeeds = swingSpeedHistory.filter(s => s > 10); + const strokeConsistency = calculateConsistency(shotSpeeds); + + // Footwork score based on movement patterns + const footworkScore = calculateFootworkScore(movementTrajectory, canvas.width, canvas.height); + + // Fluidity score based on angle smoothness + const fluidityScore = calculateFluidityScore(jointAnglesHistory); + + // Overall score + const overallScore = Math.round( + strokeConsistency * 0.25 + + footworkScore * 0.25 + + fluidityScore * 0.25 + + Math.min(100, avgSwingSpeed * 3) * 0.15 + + Math.min(100, shotCount * 10) * 0.10 + ); + + // Detect issues + const detectedIssues = detectIssues(jointAnglesHistory, exerciseType, avgSwingSpeed, footworkScore); + + const result: AnalysisResult = { + overallScore, + shotCount, + avgSwingSpeed: Math.round(avgSwingSpeed * 10) / 10, + maxSwingSpeed: Math.round(maxSwingSpeed * 10) / 10, + totalMovementDistance: Math.round(totalMovement), + strokeConsistency: Math.round(strokeConsistency), + footworkScore: Math.round(footworkScore), + fluidityScore: Math.round(fluidityScore), + poseMetrics: { + avgAngles: averageAngles(jointAnglesHistory), + frameCount: framesAnalyzed, + }, + detectedIssues, + keyMoments, + movementTrajectory, + framesAnalyzed, + }; + + setAnalysisResult(result); + + // Upload video and save analysis + const reader = new FileReader(); + reader.onload = async () => { + const base64 = (reader.result as string).split(",")[1]; + try { + const { videoId } = await uploadMutation.mutateAsync({ + title: `${exerciseType}_${new Date().toISOString().slice(0, 10)}`, + format: videoFile.name.split(".").pop() || "mp4", + fileSize: videoFile.size, + exerciseType, + fileBase64: base64, + }); + + await saveMutation.mutateAsync({ + videoId, + ...result, + }); + + toast.success("分析完成并已保存!NTRP评分已自动更新。"); + } catch (err: any) { + toast.error("保存失败: " + err.message); + } + }; + reader.readAsDataURL(videoFile); + + // Get AI corrections + correctionMutation.mutate({ + poseMetrics: result.poseMetrics, + exerciseType, + detectedIssues: result.detectedIssues, + }, { + onSuccess: (data) => setCorrections(data.corrections as string), + }); + + pose.close(); + } catch (err: any) { + toast.error("分析失败: " + err.message); + console.error(err); + } finally { + setIsAnalyzing(false); + } + }, [videoFile, exerciseType, showSkeleton]); + + return ( +
+
+

视频姿势分析

+

上传训练视频,AI自动识别姿势并给出矫正建议

+
+ + {/* Upload section */} + + + + + 上传训练视频 + + + +
+
+ + +
+
+ + +
+
+ + {videoUrl && ( +
+
+
+ +
+
+
+ + +
+ +
+
+ +
+ + {isAnalyzing && ( + + )} +
+
+ )} +
+
+ + {/* Analysis results */} + {analysisResult && ( + <> + {/* Score overview */} +
+ + +

综合评分

+

{analysisResult.overallScore}

+

/100

+
+
+ + +

击球次数

+

{analysisResult.shotCount}

+

+
+
+ + +

平均挥拍速度

+

{analysisResult.avgSwingSpeed}

+

px/帧

+
+
+ + +

移动距离

+

{analysisResult.totalMovementDistance}

+

px

+
+
+
+ + {/* Dimension scores */} + + + 多维度评分 + + +
+ {[ + { label: "击球一致性", value: analysisResult.strokeConsistency, color: "bg-blue-500" }, + { label: "脚步移动", value: analysisResult.footworkScore, color: "bg-green-500" }, + { label: "动作流畅性", value: analysisResult.fluidityScore, color: "bg-purple-500" }, + { label: "最大挥拍速度", value: Math.min(100, analysisResult.maxSwingSpeed * 3), color: "bg-orange-500" }, + ].map(item => ( +
+
+ {item.label} + {Math.round(item.value)}/100 +
+
+
+
+
+ ))} +
+ + + + {/* Detected issues */} + {analysisResult.detectedIssues.length > 0 && ( + + + + + 检测到的问题 + + + +
+ {analysisResult.detectedIssues.map((issue, i) => ( +
+ ⚠️ +

{issue}

+
+ ))} +
+
+
+ )} + + {/* Key moments */} + {analysisResult.keyMoments.length > 0 && ( + + + + + 关键时刻 + + + +
+ {analysisResult.keyMoments.map((moment: any, i: number) => ( +
+ 帧 {moment.frame} + {moment.description} +
+ ))} +
+
+
+ )} + + {/* AI Corrections */} + + + + + AI矫正建议 + + + + {correctionMutation.isPending ? ( +
+ + AI正在生成矫正建议... +
+ ) : corrections ? ( +
+ {corrections} +
+ ) : ( +

暂无矫正建议

+ )} +
+
+ + )} +
+ ); +} + +// ===== Helper functions ===== + +function calculateJointAngles(landmarks: any[]) { + const getAngle = (a: any, b: any, c: any) => { + const radians = Math.atan2(c.y - b.y, c.x - b.x) - Math.atan2(a.y - b.y, a.x - b.x); + let angle = Math.abs(radians * 180 / Math.PI); + if (angle > 180) angle = 360 - angle; + return angle; + }; + + return { + rightElbow: getAngle(landmarks[12], landmarks[14], landmarks[16]), + leftElbow: getAngle(landmarks[11], landmarks[13], landmarks[15]), + rightShoulder: getAngle(landmarks[14], landmarks[12], landmarks[24]), + leftShoulder: getAngle(landmarks[13], landmarks[11], landmarks[23]), + rightKnee: getAngle(landmarks[24], landmarks[26], landmarks[28]), + leftKnee: getAngle(landmarks[23], landmarks[25], landmarks[27]), + rightHip: getAngle(landmarks[12], landmarks[24], landmarks[26]), + leftHip: getAngle(landmarks[11], landmarks[23], landmarks[25]), + torsoLean: getAngle(landmarks[11], landmarks[23], { x: landmarks[23].x, y: 0 }), + }; +} + +function calculateConsistency(speeds: number[]): number { + if (speeds.length < 2) return 50; + const mean = speeds.reduce((a, b) => a + b, 0) / speeds.length; + const variance = speeds.reduce((sum, s) => sum + Math.pow(s - mean, 2), 0) / speeds.length; + const cv = Math.sqrt(variance) / (mean || 1); + return Math.max(0, Math.min(100, 100 - cv * 100)); +} + +function calculateFootworkScore(trajectory: any[], width: number, height: number): number { + if (trajectory.length < 10) return 50; + // Score based on movement variety and smoothness + let directionChanges = 0; + let totalDist = 0; + for (let i = 2; i < trajectory.length; i++) { + const dx1 = trajectory[i - 1].x - trajectory[i - 2].x; + const dy1 = trajectory[i - 1].y - trajectory[i - 2].y; + const dx2 = trajectory[i].x - trajectory[i - 1].x; + const dy2 = trajectory[i].y - trajectory[i - 1].y; + if ((dx1 * dx2 + dy1 * dy2) < 0) directionChanges++; + totalDist += Math.sqrt(dx2 * dx2 + dy2 * dy2); + } + const changeRate = directionChanges / trajectory.length; + const movementRange = totalDist * 1000; + return Math.min(100, Math.max(20, changeRate * 200 + movementRange * 0.5)); +} + +function calculateFluidityScore(anglesHistory: any[]): number { + if (anglesHistory.length < 3) return 50; + let totalJerkiness = 0; + const keys = Object.keys(anglesHistory[0] || {}); + for (let i = 2; i < anglesHistory.length; i++) { + for (const key of keys) { + const a0 = anglesHistory[i - 2][key] || 0; + const a1 = anglesHistory[i - 1][key] || 0; + const a2 = anglesHistory[i][key] || 0; + const jerk = Math.abs((a2 - a1) - (a1 - a0)); + totalJerkiness += jerk; + } + } + const avgJerk = totalJerkiness / ((anglesHistory.length - 2) * keys.length); + return Math.max(0, Math.min(100, 100 - avgJerk * 2)); +} + +function detectIssues(anglesHistory: any[], exerciseType: string, avgSpeed: number, footworkScore: number): string[] { + const issues: string[] = []; + if (anglesHistory.length === 0) return issues; + + const avgAngles = averageAngles(anglesHistory); + + // Check elbow angle for strokes + if (exerciseType === "forehand" || exerciseType === "shadow") { + if (avgAngles.rightElbow < 90) issues.push("正手击球时肘部弯曲过大,建议保持手臂更加伸展"); + if (avgAngles.rightElbow > 170) issues.push("正手击球时手臂过于僵直,建议略微弯曲肘部"); + } + if (exerciseType === "backhand") { + if (avgAngles.leftElbow < 80) issues.push("反手击球时肘部弯曲过大"); + } + if (exerciseType === "serve") { + if (avgAngles.rightShoulder < 140) issues.push("发球时肩部旋转不够充分,需要更大的肩部打开角度"); + } + + // Check knee bend + if (avgAngles.rightKnee > 170 && avgAngles.leftKnee > 170) { + issues.push("膝盖弯曲不足,建议保持适当的屈膝姿势以提高稳定性"); + } + + // Check torso + if (avgAngles.torsoLean < 70) { + issues.push("身体前倾过多,注意保持上身直立"); + } + + // Speed check + if (avgSpeed < 5) { + issues.push("挥拍速度偏慢,建议加快挥拍节奏"); + } + + // Footwork check + if (footworkScore < 40) { + issues.push("脚步移动不够活跃,建议增加脚步训练"); + } + + return issues; +} + +function averageAngles(anglesHistory: any[]) { + if (anglesHistory.length === 0) return {}; + const keys = Object.keys(anglesHistory[0] || {}); + const avg: Record = {}; + for (const key of keys) { + avg[key] = Math.round( + anglesHistory.reduce((sum, a) => sum + (a[key] || 0), 0) / anglesHistory.length + ); + } + return avg; +} diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx new file mode 100644 index 0000000..75ed290 --- /dev/null +++ b/client/src/pages/Dashboard.tsx @@ -0,0 +1,279 @@ +import { useAuth } from "@/_core/hooks/useAuth"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Target, Video, Activity, TrendingUp, Award, Clock, + Zap, BarChart3, ChevronRight +} from "lucide-react"; +import { useLocation } from "wouter"; +import { ResponsiveContainer, LineChart, Line, XAxis, YAxis, Tooltip, CartesianGrid, AreaChart, Area } from "recharts"; + +function NTRPBadge({ rating }: { rating: number }) { + let level = "初学者"; + let color = "bg-gray-100 text-gray-700"; + if (rating >= 4.0) { level = "高级竞技"; color = "bg-purple-100 text-purple-700"; } + else if (rating >= 3.0) { level = "中高级"; color = "bg-blue-100 text-blue-700"; } + else if (rating >= 2.5) { level = "中级"; color = "bg-green-100 text-green-700"; } + else if (rating >= 2.0) { level = "初中级"; color = "bg-yellow-100 text-yellow-700"; } + else if (rating >= 1.5) { level = "初级"; color = "bg-orange-100 text-orange-700"; } + return ( + + NTRP {rating.toFixed(1)} · {level} + + ); +} + +export default function Dashboard() { + const { user } = useAuth(); + const { data: stats, isLoading } = trpc.profile.stats.useQuery(); + const [, setLocation] = useLocation(); + + if (isLoading) { + return ( +
+ +
+ {[1, 2, 3, 4].map(i => )} +
+
+ ); + } + + const ratingData = stats?.ratingHistory?.map((r: any) => ({ + date: new Date(r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }), + rating: r.rating, + ...((r.dimensionScores as any) || {}), + })) || []; + + return ( +
+ {/* Welcome header */} +
+
+

+ 欢迎回来,{user?.name || "球友"} +

+
+ + + 已完成 {stats?.totalSessions || 0} 次训练 + +
+
+
+ + +
+
+ + {/* Stats cards */} +
+ + +
+
+

NTRP评分

+

+ {(stats?.ntrpRating || 1.5).toFixed(1)} +

+
+
+ +
+
+
+
+ + + +
+
+

训练次数

+

{stats?.totalSessions || 0}

+
+
+ +
+
+
+
+ + + +
+
+

训练时长

+

{stats?.totalMinutes || 0}分钟

+
+
+ +
+
+
+
+ + + +
+
+

总击球数

+

{stats?.totalShots || 0}

+
+
+ +
+
+
+
+
+ + {/* Rating trend chart */} +
+ + +
+ + + NTRP评分趋势 + + +
+
+ + {ratingData.length > 0 ? ( + + + + + + + + + + + + + + + + ) : ( +
+
+ +

完成视频分析后将显示评分趋势

+
+
+ )} +
+
+ + {/* Recent analyses */} + + +
+ + + +
+
+ + {(stats?.recentAnalyses?.length || 0) > 0 ? ( +
+ {stats!.recentAnalyses.slice(0, 4).map((a: any) => ( +
+
+
+ {Math.round(a.overallScore || 0)} +
+
+

{a.exerciseType || "综合分析"}

+

+ {new Date(a.createdAt).toLocaleDateString("zh-CN")} + {a.shotCount ? ` · ${a.shotCount}次击球` : ""} +

+
+
+
+ + {Math.round(a.overallScore || 0)}分 +
+
+ ))} +
+ ) : ( +
+
+
+
+ )} +
+
+
+ + {/* Quick actions */} + + + 快速开始 + + +
+ + + +
+
+
+
+ ); +} diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 8b4b79b..77e0fd5 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -1,31 +1,150 @@ import { useAuth } from "@/_core/hooks/useAuth"; import { Button } from "@/components/ui/button"; -import { Loader2 } from "lucide-react"; -import { getLoginUrl } from "@/const"; -import { Streamdown } from 'streamdown'; +import { useLocation, Redirect } from "wouter"; +import { + Target, Video, Award, TrendingUp, Zap, Activity, + ChevronRight, Footprints, BarChart3 +} from "lucide-react"; -/** - * All content in this page are only for example, replace with your own feature implementation - * When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls - */ export default function Home() { - // The userAuth hooks provides authentication state - // To implement login/logout functionality, simply call logout() or redirect to getLoginUrl() - let { user, loading, error, isAuthenticated, logout } = useAuth(); + const { user, loading, isAuthenticated } = useAuth(); + const [, setLocation] = useLocation(); - // If theme is switchable in App.tsx, we can implement theme toggling like this: - // const { theme, toggleTheme } = useTheme(); + if (loading) return null; + if (isAuthenticated) return ; return ( -
-
- {/* Example: lucide-react for icons */} - - Example Page - {/* Example: Streamdown for markdown rendering */} - Any **markdown** content - -
+
+ {/* Hero */} +
+
+ + Tennis Training Hub +
+ +
+ +
+
+
+ + AI驱动的网球训练助手 +
+

+ 在家也能提升 + 网球技术水平 +

+

+ 只需一支球拍,通过AI姿势识别和智能训练计划,在家高效训练。 + 实时分析挥拍动作,自动评分,持续进步。 +

+
+ +
+
+
+ + {/* Features */} +
+

核心功能

+
+ {[ + { + icon: Video, + title: "AI姿势识别", + desc: "基于MediaPipe的浏览器端实时姿势分析,识别33个身体关键点,精准评估挥拍动作", + color: "bg-blue-50 text-blue-600", + }, + { + icon: Target, + title: "智能训练计划", + desc: "根据您的水平和分析结果,AI自动生成和调整个性化训练方案,只需球拍即可在家训练", + color: "bg-green-50 text-green-600", + }, + { + icon: Award, + title: "NTRP自动评分", + desc: "基于美国网球协会标准,从5个维度综合评估您的技术水平,自动更新评分", + color: "bg-purple-50 text-purple-600", + }, + { + icon: Zap, + title: "击球统计分析", + desc: "自动检测击球次数、挥拍速度、击球一致性,量化每次训练效果", + color: "bg-orange-50 text-orange-600", + }, + { + icon: Footprints, + title: "运动轨迹追踪", + desc: "记录身体重心移动轨迹,分析脚步移动模式,提升步法灵活性", + color: "bg-teal-50 text-teal-600", + }, + { + icon: TrendingUp, + title: "进度可视化", + desc: "直观展示训练历史、能力提升趋势和评分变化,激励持续进步", + color: "bg-indigo-50 text-indigo-600", + }, + ].map((feature) => ( +
+
+ +
+

{feature.title}

+

{feature.desc}

+
+ ))} +
+
+ + {/* How it works */} +
+

使用流程

+
+ {[ + { step: "1", title: "输入用户名", desc: "无需注册,输入用户名即可开始" }, + { step: "2", title: "生成训练计划", desc: "选择水平,AI生成个性化方案" }, + { step: "3", title: "上传训练视频", desc: "录制挥拍视频并上传分析" }, + { step: "4", title: "获取评分建议", desc: "查看分析结果和矫正建议" }, + ].map((item) => ( +
+
+ {item.step} +
+

{item.title}

+

{item.desc}

+
+ ))} +
+
+ + {/* CTA */} +
+
+

准备好提升网球技术了吗?

+

完全免费,无需注册,输入用户名即可开始

+ +
+
+ + {/* Footer */} +
+
+
+ + Tennis Training Hub +
+ AI驱动的在家网球训练助手 +
+
); } diff --git a/client/src/pages/Login.tsx b/client/src/pages/Login.tsx new file mode 100644 index 0000000..c2e0507 --- /dev/null +++ b/client/src/pages/Login.tsx @@ -0,0 +1,102 @@ +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { trpc } from "@/lib/trpc"; +import { useLocation } from "wouter"; +import { toast } from "sonner"; +import { Target, Loader2 } from "lucide-react"; + +export default function Login() { + const [username, setUsername] = useState(""); + const [, setLocation] = useLocation(); + const loginMutation = trpc.auth.loginWithUsername.useMutation({ + onSuccess: (data) => { + toast.success(data.isNew ? `欢迎加入,${data.user.name}!` : `欢迎回来,${data.user.name}!`); + setLocation("/dashboard"); + }, + onError: (err) => { + toast.error("登录失败: " + err.message); + }, + }); + + const handleLogin = (e: React.FormEvent) => { + e.preventDefault(); + if (!username.trim()) { + toast.error("请输入用户名"); + return; + } + loginMutation.mutate({ username: username.trim() }); + }; + + return ( +
+
+
+
+ +
+

Tennis Training Hub

+

AI驱动的在家网球训练助手

+
+ + + + 开始训练 + 输入用户名即可开始使用 + + +
+
+ setUsername(e.target.value)} + className="h-12 text-base" + autoFocus + maxLength={64} + /> +
+ +
+ +
+
+
+
AI
+ 姿势识别 +
+
+
📊
+ 训练计划 +
+
+
🎯
+ NTRP评分 +
+
+
+
+
+ +

+ 无需注册,输入用户名即可使用全部功能 +

+
+
+ ); +} diff --git a/client/src/pages/Progress.tsx b/client/src/pages/Progress.tsx new file mode 100644 index 0000000..b0c8d5a --- /dev/null +++ b/client/src/pages/Progress.tsx @@ -0,0 +1,215 @@ +import { useAuth } from "@/_core/hooks/useAuth"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { Activity, Calendar, CheckCircle2, Clock, TrendingUp, Target } from "lucide-react"; +import { + ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, + LineChart, Line, Legend +} from "recharts"; +import { useLocation } from "wouter"; + +export default function Progress() { + const { user } = useAuth(); + const { data: records, isLoading } = trpc.record.list.useQuery({ limit: 100 }); + const { data: analyses } = trpc.analysis.list.useQuery(); + const { data: stats } = trpc.profile.stats.useQuery(); + const [, setLocation] = useLocation(); + + if (isLoading) { + return ( +
+ {[1, 2, 3].map(i => )} +
+ ); + } + + // Aggregate data by date for charts + const dateMap = new Map(); + (records || []).forEach((r: any) => { + const date = new Date(r.trainingDate || r.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }); + const existing = dateMap.get(date) || { date, sessions: 0, minutes: 0, avgScore: 0, scores: [] }; + existing.sessions++; + existing.minutes += r.durationMinutes || 0; + if (r.poseScore) existing.scores.push(r.poseScore); + dateMap.set(date, existing); + }); + + const chartData = Array.from(dateMap.values()).map(d => ({ + ...d, + avgScore: d.scores.length > 0 ? Math.round(d.scores.reduce((a, b) => a + b, 0) / d.scores.length) : 0, + })); + + // Analysis score trend + const scoreTrend = (analyses || []).map((a: any) => ({ + date: new Date(a.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }), + overall: Math.round(a.overallScore || 0), + consistency: Math.round(a.strokeConsistency || 0), + footwork: Math.round(a.footworkScore || 0), + fluidity: Math.round(a.fluidityScore || 0), + })); + + const completedRecords = (records || []).filter((r: any) => r.completed === 1); + const totalMinutes = (records || []).reduce((sum: number, r: any) => sum + (r.durationMinutes || 0), 0); + + return ( +
+
+

训练进度

+

追踪您的训练历史和能力提升趋势

+
+ + {/* Summary stats */} +
+ + +
+ 总训练次数 +
+

{stats?.totalSessions || 0}

+
+
+ + +
+ 总训练时长 +
+

{totalMinutes}分钟

+
+
+ + +
+ 已完成 +
+

{completedRecords.length}

+
+
+ + +
+ 视频分析 +
+

{analyses?.length || 0}

+
+
+
+ +
+ {/* Training frequency chart */} + + + + + 训练频率 + + + + {chartData.length > 0 ? ( + + + + + + + + + + ) : ( +
+
+ +

开始训练后将显示频率统计

+
+
+ )} +
+
+ + {/* Score improvement trend */} + + + + + 能力提升趋势 + + + + {scoreTrend.length > 0 ? ( + + + + + + + + + + + + + ) : ( +
+
+ +

完成视频分析后将显示能力趋势

+
+
+ )} +
+
+
+ + {/* Recent records */} + + + 最近训练记录 + + + {(records?.length || 0) > 0 ? ( +
+ {(records || []).slice(0, 20).map((record: any) => ( +
+
+
+ {record.completed ? : } +
+
+

{record.exerciseName}

+

+ {new Date(record.trainingDate || record.createdAt).toLocaleDateString("zh-CN")} + {record.durationMinutes ? ` · ${record.durationMinutes}分钟` : ""} +

+
+
+
+ {record.poseScore && ( + {Math.round(record.poseScore)}分 + )} + {record.completed ? ( + 已完成 + ) : ( + 进行中 + )} +
+
+ ))} +
+ ) : ( +
+ +

还没有训练记录

+ +
+ )} +
+
+
+ ); +} diff --git a/client/src/pages/Rating.tsx b/client/src/pages/Rating.tsx new file mode 100644 index 0000000..b9722f9 --- /dev/null +++ b/client/src/pages/Rating.tsx @@ -0,0 +1,230 @@ +import { useAuth } from "@/_core/hooks/useAuth"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Award, TrendingUp, Target, Zap, Footprints, Activity, Wind } from "lucide-react"; +import { + ResponsiveContainer, RadarChart, PolarGrid, PolarAngleAxis, + PolarRadiusAxis, Radar, AreaChart, Area, XAxis, YAxis, + CartesianGrid, Tooltip, Legend +} from "recharts"; + +const NTRP_LEVELS = [ + { min: 1.0, max: 1.5, label: "初学者", desc: "刚开始学习网球,正在学习基本击球动作", color: "bg-gray-100 text-gray-700" }, + { min: 1.5, max: 2.0, label: "初级", desc: "能够进行简单的来回击球,但缺乏一致性", color: "bg-orange-100 text-orange-700" }, + { min: 2.0, max: 2.5, label: "初中级", desc: "击球更加稳定,开始理解基本策略", color: "bg-yellow-100 text-yellow-700" }, + { min: 2.5, max: 3.0, label: "中级", desc: "能够稳定地进行中速击球,具备基本的网前技术", color: "bg-green-100 text-green-700" }, + { min: 3.0, max: 3.5, label: "中高级", desc: "击球力量和控制力增强,开始使用旋转", color: "bg-blue-100 text-blue-700" }, + { min: 3.5, max: 4.0, label: "高级", desc: "具备全面的技术,能够在比赛中运用战术", color: "bg-indigo-100 text-indigo-700" }, + { min: 4.0, max: 4.5, label: "高级竞技", desc: "技术精湛,具备强大的进攻和防守能力", color: "bg-purple-100 text-purple-700" }, + { min: 4.5, max: 5.0, label: "专业水平", desc: "接近职业水平,全面的技术和战术能力", color: "bg-red-100 text-red-700" }, +]; + +function getNTRPLevel(rating: number) { + return NTRP_LEVELS.find(l => rating >= l.min && rating < l.max) || NTRP_LEVELS[0]; +} + +export default function Rating() { + const { user } = useAuth(); + const { data: ratingData } = trpc.rating.current.useQuery(); + const { data: history, isLoading } = trpc.rating.history.useQuery(); + const { data: stats } = trpc.profile.stats.useQuery(); + + const currentRating = ratingData?.rating || 1.5; + const level = getNTRPLevel(currentRating); + + // Get latest dimension scores + const latestWithDimensions = history?.find((h: any) => h.dimensionScores); + const dimensions = (latestWithDimensions as any)?.dimensionScores || {}; + + const radarData = [ + { dimension: "姿势准确", value: dimensions.poseAccuracy || 0, fullMark: 100 }, + { dimension: "击球一致", value: dimensions.strokeConsistency || 0, fullMark: 100 }, + { dimension: "脚步移动", value: dimensions.footwork || 0, fullMark: 100 }, + { dimension: "动作流畅", value: dimensions.fluidity || 0, fullMark: 100 }, + { dimension: "力量", value: dimensions.power || 0, fullMark: 100 }, + ]; + + const trendData = (history || []).map((h: any) => ({ + date: new Date(h.createdAt).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }), + rating: h.rating, + })); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+
+

NTRP评分系统

+

基于所有历史训练记录自动计算的综合评分

+
+ + {/* Current rating card */} + +
+
+
+
+ {currentRating.toFixed(1)} +
+
+

{level.label}

+

{level.desc}

+
+ + NTRP {currentRating.toFixed(1)} +
+
+
+
+
+
+ +
+ {/* Radar chart */} + + + + + 能力雷达图 + + 五维度综合能力评估 + + + {Object.keys(dimensions).length > 0 ? ( + + + + + + + + + ) : ( +
+
+ +

完成视频分析后将显示能力雷达图

+
+
+ )} +
+
+ + {/* Rating trend */} + + + + + 评分变化趋势 + + NTRP评分随时间的变化 + + + {trendData.length > 0 ? ( + + + + + + + + + + + + + + + + ) : ( +
+
+ +

完成视频分析后将显示评分趋势

+
+
+ )} +
+
+
+ + {/* Dimension details */} + + + 评分维度说明 + NTRP评分由以下五个维度加权计算 + + +
+ {[ + { icon: Target, label: "姿势准确性", weight: "30%", desc: "关节角度与标准动作的匹配度", value: dimensions.poseAccuracy }, + { icon: Zap, label: "击球一致性", weight: "25%", desc: "多次击球动作的稳定性", value: dimensions.strokeConsistency }, + { icon: Footprints, label: "脚步移动", weight: "20%", desc: "步法灵活性和重心转移", value: dimensions.footwork }, + { icon: Wind, label: "动作流畅性", weight: "15%", desc: "动作连贯性和平滑度", value: dimensions.fluidity }, + { icon: Activity, label: "力量", weight: "10%", desc: "挥拍速度和爆发力", value: dimensions.power }, + ].map(item => ( +
+
+ + {item.label} +
+

{item.value ? Math.round(item.value) : "--"}

+

权重 {item.weight}

+

{item.desc}

+
+ ))} +
+
+
+ + {/* NTRP level reference */} + + + NTRP等级参考 + 美国网球协会(USTA)标准评级体系 + + +
+ {NTRP_LEVELS.map(l => ( +
= l.min && currentRating < l.max + ? "bg-primary/5 border border-primary/20" + : "hover:bg-muted/50" + }`} + > + + {l.min.toFixed(1)}-{l.max.toFixed(1)} + +
+ {l.label} +

{l.desc}

+
+ {currentRating >= l.min && currentRating < l.max && ( + 当前等级 + )} +
+ ))} +
+
+
+
+ ); +} diff --git a/client/src/pages/Training.tsx b/client/src/pages/Training.tsx new file mode 100644 index 0000000..1c3249a --- /dev/null +++ b/client/src/pages/Training.tsx @@ -0,0 +1,287 @@ +import { useState, useMemo } from "react"; +import { useAuth } from "@/_core/hooks/useAuth"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { toast } from "sonner"; +import { + Target, Loader2, CheckCircle2, Circle, Clock, Dumbbell, + RefreshCw, Footprints, Hand, ArrowRight, Sparkles +} from "lucide-react"; + +const categoryIcons: Record = { + "影子挥拍": , + "脚步移动": , + "体能训练": , + "墙壁练习": , +}; + +const categoryColors: Record = { + "影子挥拍": "bg-blue-50 text-blue-700 border-blue-200", + "脚步移动": "bg-green-50 text-green-700 border-green-200", + "体能训练": "bg-orange-50 text-orange-700 border-orange-200", + "墙壁练习": "bg-purple-50 text-purple-700 border-purple-200", +}; + +type Exercise = { + day: number; + name: string; + category: string; + duration: number; + description: string; + tips: string; + sets: number; + reps: number; +}; + +export default function Training() { + const { user } = useAuth(); + const [skillLevel, setSkillLevel] = useState<"beginner" | "intermediate" | "advanced">("beginner"); + const [durationDays, setDurationDays] = useState(7); + const [selectedDay, setSelectedDay] = useState(1); + + const utils = trpc.useUtils(); + const { data: activePlan, isLoading: planLoading } = trpc.plan.active.useQuery(); + + const generateMutation = trpc.plan.generate.useMutation({ + onSuccess: () => { + toast.success("训练计划已生成!"); + utils.plan.active.invalidate(); + utils.plan.list.invalidate(); + }, + onError: (err) => toast.error("生成失败: " + err.message), + }); + + const adjustMutation = trpc.plan.adjust.useMutation({ + onSuccess: (data) => { + toast.success("训练计划已调整!"); + utils.plan.active.invalidate(); + if (data.adjustmentNotes) toast.info("调整说明: " + data.adjustmentNotes); + }, + onError: (err) => toast.error("调整失败: " + err.message), + }); + + const recordMutation = trpc.record.create.useMutation({ + onSuccess: () => toast.success("训练记录已创建"), + }); + + const completeMutation = trpc.record.complete.useMutation({ + onSuccess: () => { + toast.success("训练已完成!"); + utils.profile.stats.invalidate(); + }, + }); + + const exercises = useMemo(() => { + if (!activePlan?.exercises) return []; + return (activePlan.exercises as Exercise[]).filter(e => e.day === selectedDay); + }, [activePlan, selectedDay]); + + const totalDays = activePlan?.durationDays || 7; + + if (planLoading) { + return ( +
+ + +
+ ); + } + + return ( +
+
+
+

训练计划

+

AI为您定制的在家网球训练方案

+
+
+ + {!activePlan ? ( + /* Generate new plan */ + + + + + 生成训练计划 + + + 根据您的水平和目标,AI将生成个性化的在家训练方案(只需球拍) + + + +
+
+ + +
+
+ + +
+
+ +
+
+ ) : ( + /* Active plan display */ + <> + + +
+
+ {activePlan.title} + + + {activePlan.skillLevel === "beginner" ? "初级" : activePlan.skillLevel === "intermediate" ? "中级" : "高级"} + + {activePlan.durationDays}天计划 + {activePlan.version > 1 && ( + v{activePlan.version} 已调整 + )} + +
+ +
+ {activePlan.adjustmentNotes && ( +
+ 调整说明:{activePlan.adjustmentNotes} +
+ )} +
+ + {/* Day selector */} +
+ {Array.from({ length: totalDays }, (_, i) => i + 1).map(day => ( + + ))} +
+ +

第 {selectedDay} 天训练

+ + {exercises.length > 0 ? ( +
+ {exercises.map((ex, idx) => ( +
+
+
+
+ {categoryIcons[ex.category] || } +
+
+

{ex.name}

+

{ex.description}

+
+ + {ex.duration}分钟 + + {ex.sets}组 × {ex.reps}次 +
+ {ex.tips && ( +

+ 💡 {ex.tips} +

+ )} +
+
+ +
+
+ ))} +
+ ) : ( +
+

该天暂无训练安排

+
+ )} +
+
+ +
+ +
+ + )} +
+ ); +} diff --git a/client/src/pages/Videos.tsx b/client/src/pages/Videos.tsx new file mode 100644 index 0000000..3c8111f --- /dev/null +++ b/client/src/pages/Videos.tsx @@ -0,0 +1,150 @@ +import { useAuth } from "@/_core/hooks/useAuth"; +import { trpc } from "@/lib/trpc"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Video, Play, BarChart3, Clock, Zap, ChevronRight, FileVideo } from "lucide-react"; +import { useLocation } from "wouter"; + +const statusMap: Record = { + pending: { label: "待分析", color: "bg-yellow-100 text-yellow-700" }, + analyzing: { label: "分析中", color: "bg-blue-100 text-blue-700" }, + completed: { label: "已完成", color: "bg-green-100 text-green-700" }, + failed: { label: "失败", color: "bg-red-100 text-red-700" }, +}; + +const exerciseTypeMap: Record = { + forehand: "正手挥拍", + backhand: "反手挥拍", + serve: "发球", + volley: "截击", + footwork: "脚步移动", + shadow: "影子挥拍", + wall: "墙壁练习", +}; + +export default function Videos() { + const { user } = useAuth(); + const { data: videos, isLoading } = trpc.video.list.useQuery(); + const { data: analyses } = trpc.analysis.list.useQuery(); + const [, setLocation] = useLocation(); + + const getAnalysis = (videoId: number) => { + return analyses?.find((a: any) => a.videoId === videoId); + }; + + if (isLoading) { + return ( +
+ + {[1, 2, 3].map(i => )} +
+ ); + } + + return ( +
+
+
+

训练视频库

+

+ 管理您的所有训练视频及分析结果 · 共 {videos?.length || 0} 个视频 +

+
+ +
+ + {(!videos || videos.length === 0) ? ( + + + +

还没有训练视频

+

上传您的训练视频,AI将自动分析姿势并给出建议

+ +
+
+ ) : ( +
+ {videos.map((video: any) => { + const analysis = getAnalysis(video.id); + const status = statusMap[video.analysisStatus] || statusMap.pending; + + return ( + + +
+ {/* Thumbnail / icon */} +
+ {video.url ? ( +
+ + {/* Info */} +
+
+
+

{video.title}

+
+ {status.label} + {video.exerciseType && ( + + {exerciseTypeMap[video.exerciseType] || video.exerciseType} + + )} + + + {new Date(video.createdAt).toLocaleDateString("zh-CN")} + + + {(video.fileSize / 1024 / 1024).toFixed(1)}MB + +
+
+
+ + {/* Analysis summary */} + {analysis && ( +
+
+ + {Math.round(analysis.overallScore || 0)}分 +
+ {(analysis.shotCount ?? 0) > 0 && ( +
+ + {analysis.shotCount}次击球 +
+ )} + {(analysis.avgSwingSpeed ?? 0) > 0 && ( +
+ 速度 {(analysis.avgSwingSpeed ?? 0).toFixed(1)} +
+ )} + {(analysis.strokeConsistency ?? 0) > 0 && ( +
+ 一致性 {Math.round(analysis.strokeConsistency ?? 0)}% +
+ )} +
+ )} +
+
+
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/drizzle/0000_absurd_ink.sql b/drizzle/0000_absurd_ink.sql new file mode 100644 index 0000000..d0cd6eb --- /dev/null +++ b/drizzle/0000_absurd_ink.sql @@ -0,0 +1,13 @@ +CREATE TABLE `users` ( + `id` int AUTO_INCREMENT NOT NULL, + `openId` varchar(64) NOT NULL, + `name` text, + `email` varchar(320), + `loginMethod` varchar(64), + `role` enum('user','admin') NOT NULL DEFAULT 'user', + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + `lastSignedIn` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `users_id` PRIMARY KEY(`id`), + CONSTRAINT `users_openId_unique` UNIQUE(`openId`) +); diff --git a/drizzle/0001_public_prowler.sql b/drizzle/0001_public_prowler.sql new file mode 100644 index 0000000..a62ae7e --- /dev/null +++ b/drizzle/0001_public_prowler.sql @@ -0,0 +1,70 @@ +CREATE TABLE `pose_analyses` ( + `id` int AUTO_INCREMENT NOT NULL, + `videoId` int NOT NULL, + `userId` int NOT NULL, + `overallScore` float, + `poseMetrics` json, + `detectedIssues` json, + `corrections` json, + `exerciseType` varchar(64), + `framesAnalyzed` int, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `pose_analyses_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `training_plans` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `title` varchar(256) NOT NULL, + `skillLevel` enum('beginner','intermediate','advanced') NOT NULL, + `durationDays` int NOT NULL DEFAULT 7, + `exercises` json NOT NULL, + `isActive` int NOT NULL DEFAULT 1, + `adjustmentNotes` text, + `version` int NOT NULL DEFAULT 1, + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `training_plans_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `training_records` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `planId` int, + `exerciseName` varchar(128) NOT NULL, + `durationMinutes` int, + `completed` int NOT NULL DEFAULT 0, + `notes` text, + `poseScore` float, + `trainingDate` timestamp NOT NULL DEFAULT (now()), + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `training_records_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `training_videos` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `title` varchar(256) NOT NULL, + `fileKey` varchar(512) NOT NULL, + `url` text NOT NULL, + `format` varchar(16) NOT NULL, + `fileSize` int, + `duration` float, + `exerciseType` varchar(64), + `analysisStatus` enum('pending','analyzing','completed','failed') DEFAULT 'pending', + `createdAt` timestamp NOT NULL DEFAULT (now()), + `updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT `training_videos_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +CREATE TABLE `username_accounts` ( + `id` int AUTO_INCREMENT NOT NULL, + `username` varchar(64) NOT NULL, + `userId` int NOT NULL, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `username_accounts_id` PRIMARY KEY(`id`), + CONSTRAINT `username_accounts_username_unique` UNIQUE(`username`) +); +--> statement-breakpoint +ALTER TABLE `users` ADD `skillLevel` enum('beginner','intermediate','advanced') DEFAULT 'beginner';--> statement-breakpoint +ALTER TABLE `users` ADD `trainingGoals` text; \ No newline at end of file diff --git a/drizzle/0002_overrated_shriek.sql b/drizzle/0002_overrated_shriek.sql new file mode 100644 index 0000000..507de3a --- /dev/null +++ b/drizzle/0002_overrated_shriek.sql @@ -0,0 +1,23 @@ +CREATE TABLE `rating_history` ( + `id` int AUTO_INCREMENT NOT NULL, + `userId` int NOT NULL, + `rating` float NOT NULL, + `reason` varchar(256), + `dimensionScores` json, + `analysisId` int, + `createdAt` timestamp NOT NULL DEFAULT (now()), + CONSTRAINT `rating_history_id` PRIMARY KEY(`id`) +); +--> statement-breakpoint +ALTER TABLE `pose_analyses` ADD `shotCount` int DEFAULT 0;--> statement-breakpoint +ALTER TABLE `pose_analyses` ADD `avgSwingSpeed` float;--> statement-breakpoint +ALTER TABLE `pose_analyses` ADD `maxSwingSpeed` float;--> statement-breakpoint +ALTER TABLE `pose_analyses` ADD `totalMovementDistance` float;--> statement-breakpoint +ALTER TABLE `pose_analyses` ADD `strokeConsistency` float;--> statement-breakpoint +ALTER TABLE `pose_analyses` ADD `footworkScore` float;--> statement-breakpoint +ALTER TABLE `pose_analyses` ADD `fluidityScore` float;--> statement-breakpoint +ALTER TABLE `pose_analyses` ADD `keyMoments` json;--> statement-breakpoint +ALTER TABLE `pose_analyses` ADD `movementTrajectory` json;--> statement-breakpoint +ALTER TABLE `users` ADD `ntrpRating` float DEFAULT 1.5;--> statement-breakpoint +ALTER TABLE `users` ADD `totalSessions` int DEFAULT 0;--> statement-breakpoint +ALTER TABLE `users` ADD `totalMinutes` int DEFAULT 0; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..8185bc3 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,110 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "2acf7bc5-0126-41ee-83bf-6a2725124288", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..17231c0 --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,561 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "1bbd761e-f39b-4623-87fc-18e38f82bc98", + "prevId": "2acf7bc5-0126-41ee-83bf-6a2725124288", + "tables": { + "pose_analyses": { + "name": "pose_analyses", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "videoId": { + "name": "videoId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overallScore": { + "name": "overallScore", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poseMetrics": { + "name": "poseMetrics", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detectedIssues": { + "name": "detectedIssues", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "corrections": { + "name": "corrections", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exerciseType": { + "name": "exerciseType", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "framesAnalyzed": { + "name": "framesAnalyzed", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "pose_analyses_id": { + "name": "pose_analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "training_plans": { + "name": "training_plans", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skillLevel": { + "name": "skillLevel", + "type": "enum('beginner','intermediate','advanced')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "durationDays": { + "name": "durationDays", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 7 + }, + "exercises": { + "name": "exercises", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isActive": { + "name": "isActive", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "adjustmentNotes": { + "name": "adjustmentNotes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "training_plans_id": { + "name": "training_plans_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "training_records": { + "name": "training_records", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planId": { + "name": "planId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exerciseName": { + "name": "exerciseName", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "durationMinutes": { + "name": "durationMinutes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed": { + "name": "completed", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poseScore": { + "name": "poseScore", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trainingDate": { + "name": "trainingDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "training_records_id": { + "name": "training_records_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "training_videos": { + "name": "training_videos", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileKey": { + "name": "fileKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileSize": { + "name": "fileSize", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exerciseType": { + "name": "exerciseType", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysisStatus": { + "name": "analysisStatus", + "type": "enum('pending','analyzing','completed','failed')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "training_videos_id": { + "name": "training_videos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "username_accounts": { + "name": "username_accounts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "username_accounts_id": { + "name": "username_accounts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "username_accounts_username_unique": { + "name": "username_accounts_username_unique", + "columns": [ + "username" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "skillLevel": { + "name": "skillLevel", + "type": "enum('beginner','intermediate','advanced')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'beginner'" + }, + "trainingGoals": { + "name": "trainingGoals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..f14efb5 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,716 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "a9a3ce4f-a15b-4af1-b99f-d12a1644a83b", + "prevId": "1bbd761e-f39b-4623-87fc-18e38f82bc98", + "tables": { + "pose_analyses": { + "name": "pose_analyses", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "videoId": { + "name": "videoId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "overallScore": { + "name": "overallScore", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poseMetrics": { + "name": "poseMetrics", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "detectedIssues": { + "name": "detectedIssues", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "corrections": { + "name": "corrections", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exerciseType": { + "name": "exerciseType", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "framesAnalyzed": { + "name": "framesAnalyzed", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shotCount": { + "name": "shotCount", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "avgSwingSpeed": { + "name": "avgSwingSpeed", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maxSwingSpeed": { + "name": "maxSwingSpeed", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totalMovementDistance": { + "name": "totalMovementDistance", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "strokeConsistency": { + "name": "strokeConsistency", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "footworkScore": { + "name": "footworkScore", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fluidityScore": { + "name": "fluidityScore", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keyMoments": { + "name": "keyMoments", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "movementTrajectory": { + "name": "movementTrajectory", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "pose_analyses_id": { + "name": "pose_analyses_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "rating_history": { + "name": "rating_history", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating": { + "name": "rating", + "type": "float", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dimensionScores": { + "name": "dimensionScores", + "type": "json", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysisId": { + "name": "analysisId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "rating_history_id": { + "name": "rating_history_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "training_plans": { + "name": "training_plans", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "skillLevel": { + "name": "skillLevel", + "type": "enum('beginner','intermediate','advanced')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "durationDays": { + "name": "durationDays", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 7 + }, + "exercises": { + "name": "exercises", + "type": "json", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "isActive": { + "name": "isActive", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "adjustmentNotes": { + "name": "adjustmentNotes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "training_plans_id": { + "name": "training_plans_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "training_records": { + "name": "training_records", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "planId": { + "name": "planId", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exerciseName": { + "name": "exerciseName", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "durationMinutes": { + "name": "durationMinutes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed": { + "name": "completed", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "poseScore": { + "name": "poseScore", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trainingDate": { + "name": "trainingDate", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "training_records_id": { + "name": "training_records_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "training_videos": { + "name": "training_videos", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileKey": { + "name": "fileKey", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fileSize": { + "name": "fileSize", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exerciseType": { + "name": "exerciseType", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "analysisStatus": { + "name": "analysisStatus", + "type": "enum('pending','analyzing','completed','failed')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'pending'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "training_videos_id": { + "name": "training_videos_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "username_accounts": { + "name": "username_accounts", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "username_accounts_id": { + "name": "username_accounts_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "username_accounts_username_unique": { + "name": "username_accounts_username_unique", + "columns": [ + "username" + ] + } + }, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "skillLevel": { + "name": "skillLevel", + "type": "enum('beginner','intermediate','advanced')", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'beginner'" + }, + "trainingGoals": { + "name": "trainingGoals", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ntrpRating": { + "name": "ntrpRating", + "type": "float", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1.5 + }, + "totalSessions": { + "name": "totalSessions", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "totalMinutes": { + "name": "totalMinutes", + "type": "int", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 22fb7e8..9d75b46 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -1,5 +1,27 @@ { "version": "7", "dialect": "mysql", - "entries": [] -} + "entries": [ + { + "idx": 0, + "version": "5", + "when": 1773487141067, + "tag": "0000_absurd_ink", + "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1773487352919, + "tag": "0001_public_prowler", + "breakpoints": true + }, + { + "idx": 2, + "version": "5", + "when": 1773487643444, + "tag": "0002_overrated_shriek", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 96f47f2..989af4c 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,22 +1,25 @@ -import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core"; +import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, json, float } from "drizzle-orm/mysql-core"; /** - * Core user table backing auth flow. - * Extend this file with additional tables as your product grows. - * Columns use camelCase to match both database fields and generated types. + * Core user table - supports both OAuth and simple username login */ export const users = mysqlTable("users", { - /** - * Surrogate primary key. Auto-incremented numeric value managed by the database. - * Use this for relations between tables. - */ id: int("id").autoincrement().primaryKey(), - /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */ openId: varchar("openId", { length: 64 }).notNull().unique(), name: text("name"), email: varchar("email", { length: 320 }), loginMethod: varchar("loginMethod", { length: 64 }), role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), + /** Tennis skill level */ + skillLevel: mysqlEnum("skillLevel", ["beginner", "intermediate", "advanced"]).default("beginner"), + /** User's training goals */ + trainingGoals: text("trainingGoals"), + /** NTRP rating (1.0 - 5.0) */ + ntrpRating: float("ntrpRating").default(1.5), + /** Total training sessions completed */ + totalSessions: int("totalSessions").default(0), + /** Total training minutes */ + totalMinutes: int("totalMinutes").default(0), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(), @@ -25,4 +28,156 @@ export const users = mysqlTable("users", { export type User = typeof users.$inferSelect; export type InsertUser = typeof users.$inferInsert; -// TODO: Add your tables here \ No newline at end of file +/** + * Simple username-based login accounts + */ +export const usernameAccounts = mysqlTable("username_accounts", { + id: int("id").autoincrement().primaryKey(), + username: varchar("username", { length: 64 }).notNull().unique(), + userId: int("userId").notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type UsernameAccount = typeof usernameAccounts.$inferSelect; + +/** + * Training plans generated for users + */ +export const trainingPlans = mysqlTable("training_plans", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), + title: varchar("title", { length: 256 }).notNull(), + skillLevel: mysqlEnum("skillLevel", ["beginner", "intermediate", "advanced"]).notNull(), + /** Plan duration in days */ + durationDays: int("durationDays").notNull().default(7), + /** JSON array of training exercises */ + exercises: json("exercises").notNull(), + /** Whether this plan is currently active */ + isActive: int("isActive").notNull().default(1), + /** Auto-adjustment notes from AI analysis */ + adjustmentNotes: text("adjustmentNotes"), + /** Plan generation version for tracking adjustments */ + version: int("version").notNull().default(1), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type TrainingPlan = typeof trainingPlans.$inferSelect; +export type InsertTrainingPlan = typeof trainingPlans.$inferInsert; + +/** + * Training videos uploaded by users + */ +export const trainingVideos = mysqlTable("training_videos", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), + title: varchar("title", { length: 256 }).notNull(), + /** S3 file key */ + fileKey: varchar("fileKey", { length: 512 }).notNull(), + /** CDN URL for the video */ + url: text("url").notNull(), + /** Video format: webm or mp4 */ + format: varchar("format", { length: 16 }).notNull(), + /** File size in bytes */ + fileSize: int("fileSize"), + /** Duration in seconds */ + duration: float("duration"), + /** Type of exercise in the video */ + exerciseType: varchar("exerciseType", { length: 64 }), + /** Analysis status */ + analysisStatus: mysqlEnum("analysisStatus", ["pending", "analyzing", "completed", "failed"]).default("pending"), + createdAt: timestamp("createdAt").defaultNow().notNull(), + updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), +}); + +export type TrainingVideo = typeof trainingVideos.$inferSelect; +export type InsertTrainingVideo = typeof trainingVideos.$inferInsert; + +/** + * Pose analysis results from MediaPipe - enhanced with tennis_analysis features + */ +export const poseAnalyses = mysqlTable("pose_analyses", { + id: int("id").autoincrement().primaryKey(), + videoId: int("videoId").notNull(), + userId: int("userId").notNull(), + /** Overall pose score (0-100) */ + overallScore: float("overallScore"), + /** JSON object with detailed joint angles and metrics */ + poseMetrics: json("poseMetrics"), + /** JSON array of detected issues */ + detectedIssues: json("detectedIssues"), + /** JSON array of correction suggestions */ + corrections: json("corrections"), + /** Exercise type analyzed */ + exerciseType: varchar("exerciseType", { length: 64 }), + /** Number of frames analyzed */ + framesAnalyzed: int("framesAnalyzed"), + /** --- tennis_analysis inspired fields --- */ + /** Number of swings/shots detected */ + shotCount: int("shotCount").default(0), + /** Average swing speed (estimated from keypoint displacement, px/frame) */ + avgSwingSpeed: float("avgSwingSpeed"), + /** Max swing speed detected */ + maxSwingSpeed: float("maxSwingSpeed"), + /** Total body movement distance in pixels */ + totalMovementDistance: float("totalMovementDistance"), + /** Stroke consistency score (0-100) */ + strokeConsistency: float("strokeConsistency"), + /** Footwork score (0-100) */ + footworkScore: float("footworkScore"), + /** Fluidity/smoothness score (0-100) */ + fluidityScore: float("fluidityScore"), + /** JSON array of key moments [{frame, type, description}] */ + keyMoments: json("keyMoments"), + /** JSON array of movement trajectory points [{x, y, frame}] */ + movementTrajectory: json("movementTrajectory"), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type PoseAnalysis = typeof poseAnalyses.$inferSelect; +export type InsertPoseAnalysis = typeof poseAnalyses.$inferInsert; + +/** + * Training session records for progress tracking + */ +export const trainingRecords = mysqlTable("training_records", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), + planId: int("planId"), + /** Exercise name/type */ + exerciseName: varchar("exerciseName", { length: 128 }).notNull(), + /** Duration in minutes */ + durationMinutes: int("durationMinutes"), + /** Completion status */ + completed: int("completed").notNull().default(0), + /** Optional notes */ + notes: text("notes"), + /** Pose score if video was analyzed */ + poseScore: float("poseScore"), + /** Date of training */ + trainingDate: timestamp("trainingDate").defaultNow().notNull(), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type TrainingRecord = typeof trainingRecords.$inferSelect; +export type InsertTrainingRecord = typeof trainingRecords.$inferInsert; + +/** + * NTRP Rating history - tracks rating changes over time + */ +export const ratingHistory = mysqlTable("rating_history", { + id: int("id").autoincrement().primaryKey(), + userId: int("userId").notNull(), + /** NTRP rating at this point */ + rating: float("rating").notNull(), + /** What triggered this rating update */ + reason: varchar("reason", { length: 256 }), + /** JSON breakdown of dimension scores */ + dimensionScores: json("dimensionScores"), + /** Reference analysis ID if applicable */ + analysisId: int("analysisId"), + createdAt: timestamp("createdAt").defaultNow().notNull(), +}); + +export type RatingHistory = typeof ratingHistory.$inferSelect; +export type InsertRatingHistory = typeof ratingHistory.$inferInsert; diff --git a/package.json b/package.json index acde75b..9a0f743 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "@aws-sdk/client-s3": "^3.693.0", "@aws-sdk/s3-request-presigner": "^3.693.0", "@hookform/resolvers": "^5.2.2", + "@mediapipe/drawing_utils": "^0.3.1675466124", + "@mediapipe/pose": "^0.5.1675469404", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-aspect-ratio": "^1.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25a9528..77b2cf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,12 @@ importers: '@hookform/resolvers': specifier: ^5.2.2 version: 5.2.2(react-hook-form@7.64.0(react@19.2.1)) + '@mediapipe/drawing_utils': + specifier: ^0.3.1675466124 + version: 0.3.1675466124 + '@mediapipe/pose': + specifier: ^0.5.1675469404 + version: 0.5.1675469404 '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.1(@types/react@19.2.1))(@types/react@19.2.1)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -1049,6 +1055,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mediapipe/drawing_utils@0.3.1675466124': + resolution: {integrity: sha512-/IWIB/iYRMtiUKe3k7yGqvwseWHCOqzVpRDfMgZ6gv9z7EEimg6iZbRluoPbcNKHbYSxN5yOvYTzUYb8KVf22Q==} + + '@mediapipe/pose@0.5.1675469404': + resolution: {integrity: sha512-DFZsNWTsSphRIZppnUCuunzBiHP2FdJXR9ehc7mMi4KG+oPaOH0Em3d6kr7Py+TSyTXC1doH88KcF28k2sBxsQ==} + '@medv/finder@4.0.2': resolution: {integrity: sha512-RraNY9SCcx4KZV0Dh6BEW6XEW2swkqYca74pkFFRw6hHItSHiy+O/xMnpbofjYbzXj0tSpBGthUF1hHTsr3vIQ==} @@ -5247,6 +5259,10 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mediapipe/drawing_utils@0.3.1675466124': {} + + '@mediapipe/pose@0.5.1675469404': {} + '@medv/finder@4.0.2': {} '@mermaid-js/parser@0.6.3': diff --git a/server/db.ts b/server/db.ts index 795c205..64dbf46 100644 --- a/server/db.ts +++ b/server/db.ts @@ -1,11 +1,18 @@ -import { eq } from "drizzle-orm"; +import { eq, desc, and, sql } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; -import { InsertUser, users } from "../drizzle/schema"; +import { + InsertUser, users, + usernameAccounts, + trainingPlans, InsertTrainingPlan, + trainingVideos, InsertTrainingVideo, + poseAnalyses, InsertPoseAnalysis, + trainingRecords, InsertTrainingRecord, + ratingHistory, InsertRatingHistory, +} from "../drizzle/schema"; import { ENV } from './_core/env'; let _db: ReturnType | null = null; -// Lazily create the drizzle instance so local tooling can run without a DB. export async function getDb() { if (!_db && process.env.DATABASE_URL) { try { @@ -18,26 +25,19 @@ export async function getDb() { return _db; } -export async function upsertUser(user: InsertUser): Promise { - if (!user.openId) { - throw new Error("User openId is required for upsert"); - } +// ===== USER OPERATIONS ===== +export async function upsertUser(user: InsertUser): Promise { + if (!user.openId) throw new Error("User openId is required for upsert"); const db = await getDb(); - if (!db) { - console.warn("[Database] Cannot upsert user: database not available"); - return; - } + if (!db) { console.warn("[Database] Cannot upsert user: database not available"); return; } try { - const values: InsertUser = { - openId: user.openId, - }; + const values: InsertUser = { openId: user.openId }; const updateSet: Record = {}; const textFields = ["name", "email", "loginMethod"] as const; type TextField = (typeof textFields)[number]; - const assignNullable = (field: TextField) => { const value = user[field]; if (value === undefined) return; @@ -45,48 +45,222 @@ export async function upsertUser(user: InsertUser): Promise { values[field] = normalized; updateSet[field] = normalized; }; - textFields.forEach(assignNullable); - if (user.lastSignedIn !== undefined) { - values.lastSignedIn = user.lastSignedIn; - updateSet.lastSignedIn = user.lastSignedIn; - } - if (user.role !== undefined) { - values.role = user.role; - updateSet.role = user.role; - } else if (user.openId === ENV.ownerOpenId) { - values.role = 'admin'; - updateSet.role = 'admin'; - } + if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; } + if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; } + else if (user.openId === ENV.ownerOpenId) { values.role = 'admin'; updateSet.role = 'admin'; } + if (!values.lastSignedIn) values.lastSignedIn = new Date(); + if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date(); - if (!values.lastSignedIn) { - values.lastSignedIn = new Date(); - } - - if (Object.keys(updateSet).length === 0) { - updateSet.lastSignedIn = new Date(); - } - - await db.insert(users).values(values).onDuplicateKeyUpdate({ - set: updateSet, - }); - } catch (error) { - console.error("[Database] Failed to upsert user:", error); - throw error; - } + await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet }); + } catch (error) { console.error("[Database] Failed to upsert user:", error); throw error; } } export async function getUserByOpenId(openId: string) { const db = await getDb(); - if (!db) { - console.warn("[Database] Cannot get user: database not available"); - return undefined; - } - + if (!db) return undefined; const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1); - return result.length > 0 ? result[0] : undefined; } -// TODO: add feature queries here as your schema grows. +export async function getUserByUsername(username: string) { + const db = await getDb(); + if (!db) return undefined; + const result = await db.select().from(usernameAccounts).where(eq(usernameAccounts.username, username)).limit(1); + if (result.length === 0) return undefined; + const userResult = await db.select().from(users).where(eq(users.id, result[0].userId)).limit(1); + return userResult.length > 0 ? userResult[0] : undefined; +} + +export async function createUsernameAccount(username: string): Promise<{ user: typeof users.$inferSelect; isNew: boolean }> { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + // Check if username already exists + const existing = await db.select().from(usernameAccounts).where(eq(usernameAccounts.username, username)).limit(1); + if (existing.length > 0) { + const user = await db.select().from(users).where(eq(users.id, existing[0].userId)).limit(1); + if (user.length > 0) { + await db.update(users).set({ lastSignedIn: new Date() }).where(eq(users.id, user[0].id)); + return { user: user[0], isNew: false }; + } + } + + // Create new user with username as openId + const openId = `username_${username}_${Date.now()}`; + await db.insert(users).values({ + openId, + name: username, + loginMethod: "username", + lastSignedIn: new Date(), + ntrpRating: 1.5, + totalSessions: 0, + totalMinutes: 0, + }); + + const newUser = await db.select().from(users).where(eq(users.openId, openId)).limit(1); + if (newUser.length === 0) throw new Error("Failed to create user"); + + await db.insert(usernameAccounts).values({ username, userId: newUser[0].id }); + return { user: newUser[0], isNew: true }; +} + +export async function updateUserProfile(userId: number, data: { + skillLevel?: "beginner" | "intermediate" | "advanced"; + trainingGoals?: string; + ntrpRating?: number; + totalSessions?: number; + totalMinutes?: number; +}) { + const db = await getDb(); + if (!db) return; + await db.update(users).set(data).where(eq(users.id, userId)); +} + +// ===== TRAINING PLAN OPERATIONS ===== + +export async function createTrainingPlan(plan: InsertTrainingPlan) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + // Deactivate existing active plans + await db.update(trainingPlans).set({ isActive: 0 }).where(and(eq(trainingPlans.userId, plan.userId), eq(trainingPlans.isActive, 1))); + const result = await db.insert(trainingPlans).values(plan); + return result[0].insertId; +} + +export async function getUserTrainingPlans(userId: number) { + const db = await getDb(); + if (!db) return []; + return db.select().from(trainingPlans).where(eq(trainingPlans.userId, userId)).orderBy(desc(trainingPlans.createdAt)); +} + +export async function getActivePlan(userId: number) { + const db = await getDb(); + if (!db) return undefined; + const result = await db.select().from(trainingPlans).where(and(eq(trainingPlans.userId, userId), eq(trainingPlans.isActive, 1))).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function updateTrainingPlan(planId: number, data: Partial) { + const db = await getDb(); + if (!db) return; + await db.update(trainingPlans).set(data).where(eq(trainingPlans.id, planId)); +} + +// ===== VIDEO OPERATIONS ===== + +export async function createVideo(video: InsertTrainingVideo) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(trainingVideos).values(video); + return result[0].insertId; +} + +export async function getUserVideos(userId: number) { + const db = await getDb(); + if (!db) return []; + return db.select().from(trainingVideos).where(eq(trainingVideos.userId, userId)).orderBy(desc(trainingVideos.createdAt)); +} + +export async function getVideoById(videoId: number) { + const db = await getDb(); + if (!db) return undefined; + const result = await db.select().from(trainingVideos).where(eq(trainingVideos.id, videoId)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function updateVideoStatus(videoId: number, status: "pending" | "analyzing" | "completed" | "failed") { + const db = await getDb(); + if (!db) return; + await db.update(trainingVideos).set({ analysisStatus: status }).where(eq(trainingVideos.id, videoId)); +} + +// ===== POSE ANALYSIS OPERATIONS ===== + +export async function createPoseAnalysis(analysis: InsertPoseAnalysis) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(poseAnalyses).values(analysis); + return result[0].insertId; +} + +export async function getAnalysisByVideoId(videoId: number) { + const db = await getDb(); + if (!db) return undefined; + const result = await db.select().from(poseAnalyses).where(eq(poseAnalyses.videoId, videoId)).orderBy(desc(poseAnalyses.createdAt)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function getUserAnalyses(userId: number) { + const db = await getDb(); + if (!db) return []; + return db.select().from(poseAnalyses).where(eq(poseAnalyses.userId, userId)).orderBy(desc(poseAnalyses.createdAt)); +} + +// ===== TRAINING RECORD OPERATIONS ===== + +export async function createTrainingRecord(record: InsertTrainingRecord) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(trainingRecords).values(record); + return result[0].insertId; +} + +export async function getUserTrainingRecords(userId: number, limit = 50) { + const db = await getDb(); + if (!db) return []; + return db.select().from(trainingRecords).where(eq(trainingRecords.userId, userId)).orderBy(desc(trainingRecords.trainingDate)).limit(limit); +} + +export async function markRecordCompleted(recordId: number, poseScore?: number) { + const db = await getDb(); + if (!db) return; + await db.update(trainingRecords).set({ completed: 1, poseScore: poseScore ?? null }).where(eq(trainingRecords.id, recordId)); +} + +// ===== RATING HISTORY OPERATIONS ===== + +export async function createRatingEntry(entry: InsertRatingHistory) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + const result = await db.insert(ratingHistory).values(entry); + return result[0].insertId; +} + +export async function getUserRatingHistory(userId: number, limit = 30) { + const db = await getDb(); + if (!db) return []; + return db.select().from(ratingHistory).where(eq(ratingHistory.userId, userId)).orderBy(desc(ratingHistory.createdAt)).limit(limit); +} + +// ===== STATS HELPERS ===== + +export async function getUserStats(userId: number) { + const db = await getDb(); + if (!db) return null; + + const [userRow] = await db.select().from(users).where(eq(users.id, userId)).limit(1); + if (!userRow) return null; + + const analyses = await db.select().from(poseAnalyses).where(eq(poseAnalyses.userId, userId)); + const records = await db.select().from(trainingRecords).where(eq(trainingRecords.userId, userId)); + const videos = await db.select().from(trainingVideos).where(eq(trainingVideos.userId, userId)); + const ratings = await db.select().from(ratingHistory).where(eq(ratingHistory.userId, userId)).orderBy(desc(ratingHistory.createdAt)).limit(30); + + const completedRecords = records.filter(r => r.completed === 1); + const totalShots = analyses.reduce((sum, a) => sum + (a.shotCount || 0), 0); + const avgScore = analyses.length > 0 ? analyses.reduce((sum, a) => sum + (a.overallScore || 0), 0) / analyses.length : 0; + + return { + ntrpRating: userRow.ntrpRating || 1.5, + totalSessions: completedRecords.length, + totalMinutes: records.reduce((sum, r) => sum + (r.durationMinutes || 0), 0), + totalVideos: videos.length, + analyzedVideos: videos.filter(v => v.analysisStatus === "completed").length, + totalShots, + averageScore: Math.round(avgScore * 10) / 10, + ratingHistory: ratings.reverse(), + recentAnalyses: analyses.slice(0, 10), + }; +} diff --git a/server/features.test.ts b/server/features.test.ts new file mode 100644 index 0000000..3614c09 --- /dev/null +++ b/server/features.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { appRouter } from "./routers"; +import { COOKIE_NAME } from "../shared/const"; +import type { TrpcContext } from "./_core/context"; + +type AuthenticatedUser = NonNullable; + +function createTestUser(overrides?: Partial): AuthenticatedUser { + return { + id: 1, + openId: "test-user-001", + email: "test@example.com", + name: "TestPlayer", + loginMethod: "username", + role: "user", + skillLevel: "beginner", + trainingGoals: null, + ntrpRating: 1.5, + totalSessions: 0, + totalMinutes: 0, + createdAt: new Date(), + updatedAt: new Date(), + lastSignedIn: new Date(), + ...overrides, + }; +} + +function createMockContext(user: AuthenticatedUser | null = null): { + ctx: TrpcContext; + clearedCookies: { name: string; options: Record }[]; + setCookies: { name: string; value: string; options: Record }[]; +} { + const clearedCookies: { name: string; options: Record }[] = []; + const setCookies: { name: string; value: string; options: Record }[] = []; + + return { + ctx: { + user, + req: { + protocol: "https", + headers: {}, + } as TrpcContext["req"], + res: { + clearCookie: (name: string, options: Record) => { + clearedCookies.push({ name, options }); + }, + cookie: (name: string, value: string, options: Record) => { + setCookies.push({ name, value, options }); + }, + } as TrpcContext["res"], + }, + clearedCookies, + setCookies, + }; +} + +describe("auth.me", () => { + it("returns null for unauthenticated users", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + const result = await caller.auth.me(); + expect(result).toBeNull(); + }); + + it("returns user data for authenticated users", async () => { + const user = createTestUser(); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + const result = await caller.auth.me(); + expect(result).toBeDefined(); + expect(result?.name).toBe("TestPlayer"); + expect(result?.openId).toBe("test-user-001"); + }); +}); + +describe("auth.logout", () => { + it("clears the session cookie and reports success", async () => { + const user = createTestUser(); + const { ctx, clearedCookies } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + const result = await caller.auth.logout(); + + expect(result).toEqual({ success: true }); + expect(clearedCookies).toHaveLength(1); + expect(clearedCookies[0]?.name).toBe(COOKIE_NAME); + expect(clearedCookies[0]?.options).toMatchObject({ + maxAge: -1, + secure: true, + sameSite: "none", + httpOnly: true, + path: "/", + }); + }); +}); + +describe("profile.stats", () => { + it("requires authentication", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + await expect(caller.profile.stats()).rejects.toThrow(); + }); +}); + +describe("plan.generate input validation", () => { + it("rejects invalid skill level", async () => { + const user = createTestUser(); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.plan.generate({ + skillLevel: "expert" as any, + durationDays: 7, + }) + ).rejects.toThrow(); + }); + + it("rejects invalid duration", async () => { + const user = createTestUser(); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.plan.generate({ + skillLevel: "beginner", + durationDays: 0, + }) + ).rejects.toThrow(); + }); + + it("rejects duration over 30", async () => { + const user = createTestUser(); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.plan.generate({ + skillLevel: "beginner", + durationDays: 31, + }) + ).rejects.toThrow(); + }); +}); + +describe("video.upload input validation", () => { + it("requires authentication", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.video.upload({ + title: "test", + format: "mp4", + fileSize: 1000, + fileBase64: "dGVzdA==", + }) + ).rejects.toThrow(); + }); +}); + +describe("analysis.save input validation", () => { + it("requires authentication", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.analysis.save({ + videoId: 1, + overallScore: 75, + }) + ).rejects.toThrow(); + }); +}); + +describe("analysis.getCorrections input validation", () => { + it("requires authentication", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.analysis.getCorrections({ + poseMetrics: {}, + exerciseType: "forehand", + detectedIssues: [], + }) + ).rejects.toThrow(); + }); +}); + +describe("record.create input validation", () => { + it("requires authentication", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + + await expect( + caller.record.create({ + exerciseName: "正手挥拍", + durationMinutes: 30, + }) + ).rejects.toThrow(); + }); + + it("accepts valid exercise name", async () => { + const user = createTestUser(); + const { ctx } = createMockContext(user); + const caller = appRouter.createCaller(ctx); + + // This should not throw on input validation (may throw on DB) + // We just verify the input schema accepts a valid name + try { + await caller.record.create({ + exerciseName: "正手挥拍", + durationMinutes: 30, + }); + } catch (e: any) { + // DB errors are expected in test env, but input validation should pass + expect(e.message).not.toContain("invalid_type"); + } + }); +}); + +describe("rating.history", () => { + it("requires authentication", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + await expect(caller.rating.history()).rejects.toThrow(); + }); +}); + +describe("rating.current", () => { + it("requires authentication", async () => { + const { ctx } = createMockContext(null); + const caller = appRouter.createCaller(ctx); + await expect(caller.rating.current()).rejects.toThrow(); + }); +}); diff --git a/server/routers.ts b/server/routers.ts index 21836ad..16e9521 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -1,28 +1,479 @@ -import { COOKIE_NAME } from "@shared/const"; +import { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; import { getSessionCookieOptions } from "./_core/cookies"; import { systemRouter } from "./_core/systemRouter"; -import { publicProcedure, router } from "./_core/trpc"; +import { publicProcedure, protectedProcedure, router } from "./_core/trpc"; +import { z } from "zod"; +import { sdk } from "./_core/sdk"; +import { invokeLLM } from "./_core/llm"; +import { storagePut } from "./storage"; +import * as db from "./db"; +import { nanoid } from "nanoid"; export const appRouter = router({ - // if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly system: systemRouter, + auth: router({ me: publicProcedure.query(opts => opts.ctx.user), logout: publicProcedure.mutation(({ ctx }) => { const cookieOptions = getSessionCookieOptions(ctx.req); ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 }); - return { - success: true, - } as const; + return { success: true } as const; + }), + + // Username-based login + loginWithUsername: publicProcedure + .input(z.object({ username: z.string().min(1).max(64) })) + .mutation(async ({ ctx, input }) => { + const { user, isNew } = await db.createUsernameAccount(input.username); + const sessionToken = await sdk.createSessionToken(user.openId, { + name: user.name || input.username, + expiresInMs: ONE_YEAR_MS, + }); + const cookieOptions = getSessionCookieOptions(ctx.req); + ctx.res.cookie(COOKIE_NAME, sessionToken, { ...cookieOptions, maxAge: ONE_YEAR_MS }); + return { user, isNew }; + }), + }), + + // User profile management + profile: router({ + update: protectedProcedure + .input(z.object({ + skillLevel: z.enum(["beginner", "intermediate", "advanced"]).optional(), + trainingGoals: z.string().optional(), + })) + .mutation(async ({ ctx, input }) => { + await db.updateUserProfile(ctx.user.id, input); + return { success: true }; + }), + stats: protectedProcedure.query(async ({ ctx }) => { + return db.getUserStats(ctx.user.id); }), }), - // TODO: add feature routers here, e.g. - // todo: router({ - // list: protectedProcedure.query(({ ctx }) => - // db.getUserTodos(ctx.user.id) - // ), - // }), + // Training plan management + plan: router({ + generate: protectedProcedure + .input(z.object({ + skillLevel: z.enum(["beginner", "intermediate", "advanced"]), + durationDays: z.number().min(1).max(30).default(7), + focusAreas: z.array(z.string()).optional(), + })) + .mutation(async ({ ctx, input }) => { + const user = ctx.user; + // Get user's recent analyses for personalization + const analyses = await db.getUserAnalyses(user.id); + const recentScores = analyses.slice(0, 5).map(a => ({ + score: a.overallScore, + issues: a.detectedIssues, + exerciseType: a.exerciseType, + shotCount: a.shotCount, + strokeConsistency: a.strokeConsistency, + footworkScore: a.footworkScore, + })); + + const prompt = `你是一位专业网球教练。请为一位${ + input.skillLevel === "beginner" ? "初级" : input.skillLevel === "intermediate" ? "中级" : "高级" + }水平的网球学员生成一个${input.durationDays}天的在家训练计划。 + +要求: +- 只需要球拍,不需要球场和球网 +- 包含影子挥拍、墙壁练习、脚步移动、体能训练等 +- 每天训练30-60分钟 +${input.focusAreas?.length ? `- 重点关注: ${input.focusAreas.join(", ")}` : ""} +${recentScores.length > 0 ? `- 用户最近的分析数据: ${JSON.stringify(recentScores)}` : ""} + +请返回JSON格式,包含每天的训练内容。`; + + const response = await invokeLLM({ + messages: [ + { role: "system", content: "你是专业网球教练AI助手。返回严格的JSON格式。" }, + { role: "user", content: prompt }, + ], + response_format: { + type: "json_schema", + json_schema: { + name: "training_plan", + strict: true, + schema: { + type: "object", + properties: { + title: { type: "string", description: "训练计划标题" }, + exercises: { + type: "array", + items: { + type: "object", + properties: { + day: { type: "number" }, + name: { type: "string" }, + category: { type: "string" }, + duration: { type: "number", description: "分钟" }, + description: { type: "string" }, + tips: { type: "string" }, + sets: { type: "number" }, + reps: { type: "number" }, + }, + required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"], + additionalProperties: false, + }, + }, + }, + required: ["title", "exercises"], + additionalProperties: false, + }, + }, + }, + }); + + const content = response.choices[0]?.message?.content; + const parsed = typeof content === "string" ? JSON.parse(content) : null; + if (!parsed) throw new Error("Failed to generate training plan"); + + const planId = await db.createTrainingPlan({ + userId: user.id, + title: parsed.title, + skillLevel: input.skillLevel, + durationDays: input.durationDays, + exercises: parsed.exercises, + isActive: 1, + version: 1, + }); + + return { planId, plan: parsed }; + }), + + list: protectedProcedure.query(async ({ ctx }) => { + return db.getUserTrainingPlans(ctx.user.id); + }), + + active: protectedProcedure.query(async ({ ctx }) => { + return db.getActivePlan(ctx.user.id); + }), + + adjust: protectedProcedure + .input(z.object({ planId: z.number() })) + .mutation(async ({ ctx, input }) => { + const analyses = await db.getUserAnalyses(ctx.user.id); + const recentAnalyses = analyses.slice(0, 5); + const currentPlan = (await db.getUserTrainingPlans(ctx.user.id)).find(p => p.id === input.planId); + if (!currentPlan) throw new Error("Plan not found"); + + const prompt = `基于以下用户的姿势分析结果,调整训练计划: + +当前计划: ${JSON.stringify(currentPlan.exercises)} +最近分析结果: ${JSON.stringify(recentAnalyses.map(a => ({ + score: a.overallScore, + issues: a.detectedIssues, + corrections: a.corrections, + shotCount: a.shotCount, + strokeConsistency: a.strokeConsistency, + footworkScore: a.footworkScore, + fluidityScore: a.fluidityScore, + })))} + +请根据分析结果调整训练计划,增加针对薄弱环节的训练,返回与原计划相同格式的JSON。`; + + const response = await invokeLLM({ + messages: [ + { role: "system", content: "你是专业网球教练AI助手。返回严格的JSON格式。" }, + { role: "user", content: prompt }, + ], + response_format: { + type: "json_schema", + json_schema: { + name: "adjusted_plan", + strict: true, + schema: { + type: "object", + properties: { + title: { type: "string" }, + adjustmentNotes: { type: "string", description: "调整说明" }, + exercises: { + type: "array", + items: { + type: "object", + properties: { + day: { type: "number" }, + name: { type: "string" }, + category: { type: "string" }, + duration: { type: "number" }, + description: { type: "string" }, + tips: { type: "string" }, + sets: { type: "number" }, + reps: { type: "number" }, + }, + required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"], + additionalProperties: false, + }, + }, + }, + required: ["title", "adjustmentNotes", "exercises"], + additionalProperties: false, + }, + }, + }, + }); + + const content = response.choices[0]?.message?.content; + const parsed = typeof content === "string" ? JSON.parse(content) : null; + if (!parsed) throw new Error("Failed to adjust plan"); + + await db.updateTrainingPlan(input.planId, { + exercises: parsed.exercises, + adjustmentNotes: parsed.adjustmentNotes, + version: (currentPlan.version || 1) + 1, + }); + + return { success: true, adjustmentNotes: parsed.adjustmentNotes }; + }), + }), + + // Video management + video: router({ + upload: protectedProcedure + .input(z.object({ + title: z.string(), + format: z.string(), + fileSize: z.number(), + exerciseType: z.string().optional(), + fileBase64: z.string(), + })) + .mutation(async ({ ctx, input }) => { + const fileBuffer = Buffer.from(input.fileBase64, "base64"); + const fileKey = `videos/${ctx.user.id}/${nanoid()}.${input.format}`; + const contentType = input.format === "webm" ? "video/webm" : "video/mp4"; + const { url } = await storagePut(fileKey, fileBuffer, contentType); + + const videoId = await db.createVideo({ + userId: ctx.user.id, + title: input.title, + fileKey, + url, + format: input.format, + fileSize: input.fileSize, + exerciseType: input.exerciseType || null, + analysisStatus: "pending", + }); + + return { videoId, url }; + }), + + list: protectedProcedure.query(async ({ ctx }) => { + return db.getUserVideos(ctx.user.id); + }), + + get: protectedProcedure + .input(z.object({ videoId: z.number() })) + .query(async ({ input }) => { + return db.getVideoById(input.videoId); + }), + + updateStatus: protectedProcedure + .input(z.object({ + videoId: z.number(), + status: z.enum(["pending", "analyzing", "completed", "failed"]), + })) + .mutation(async ({ input }) => { + await db.updateVideoStatus(input.videoId, input.status); + return { success: true }; + }), + }), + + // Pose analysis + analysis: router({ + save: protectedProcedure + .input(z.object({ + videoId: z.number(), + overallScore: z.number().optional(), + poseMetrics: z.any().optional(), + detectedIssues: z.any().optional(), + corrections: z.any().optional(), + exerciseType: z.string().optional(), + framesAnalyzed: z.number().optional(), + shotCount: z.number().optional(), + avgSwingSpeed: z.number().optional(), + maxSwingSpeed: z.number().optional(), + totalMovementDistance: z.number().optional(), + strokeConsistency: z.number().optional(), + footworkScore: z.number().optional(), + fluidityScore: z.number().optional(), + keyMoments: z.any().optional(), + movementTrajectory: z.any().optional(), + })) + .mutation(async ({ ctx, input }) => { + const analysisId = await db.createPoseAnalysis({ + ...input, + userId: ctx.user.id, + }); + await db.updateVideoStatus(input.videoId, "completed"); + + // Auto-update NTRP rating after analysis + await recalculateNTRPRating(ctx.user.id, analysisId); + + return { analysisId }; + }), + + getByVideo: protectedProcedure + .input(z.object({ videoId: z.number() })) + .query(async ({ input }) => { + return db.getAnalysisByVideoId(input.videoId); + }), + + list: protectedProcedure.query(async ({ ctx }) => { + return db.getUserAnalyses(ctx.user.id); + }), + + // Generate AI correction suggestions + getCorrections: protectedProcedure + .input(z.object({ + poseMetrics: z.any(), + exerciseType: z.string(), + detectedIssues: z.any(), + })) + .mutation(async ({ input }) => { + const response = await invokeLLM({ + messages: [ + { + role: "system", + content: "你是一位专业网球教练。根据MediaPipe姿势分析数据,给出具体的姿势矫正建议。用中文回答。", + }, + { + role: "user", + content: `分析以下网球动作数据并给出矫正建议: +动作类型: ${input.exerciseType} +姿势指标: ${JSON.stringify(input.poseMetrics)} +检测到的问题: ${JSON.stringify(input.detectedIssues)} + +请给出: +1. 每个问题的具体矫正方法 +2. 推荐的练习动作 +3. 需要注意的关键点`, + }, + ], + }); + + return { + corrections: response.choices[0]?.message?.content || "暂无建议", + }; + }), + }), + + // Training records + record: router({ + create: protectedProcedure + .input(z.object({ + planId: z.number().optional(), + exerciseName: z.string(), + durationMinutes: z.number().optional(), + notes: z.string().optional(), + poseScore: z.number().optional(), + })) + .mutation(async ({ ctx, input }) => { + const recordId = await db.createTrainingRecord({ + userId: ctx.user.id, + ...input, + completed: 0, + }); + return { recordId }; + }), + + complete: protectedProcedure + .input(z.object({ + recordId: z.number(), + poseScore: z.number().optional(), + })) + .mutation(async ({ ctx, input }) => { + await db.markRecordCompleted(input.recordId, input.poseScore); + // Update user stats + const records = await db.getUserTrainingRecords(ctx.user.id, 1000); + const completed = records.filter(r => r.completed === 1); + const totalMinutes = records.reduce((sum, r) => sum + (r.durationMinutes || 0), 0); + await db.updateUserProfile(ctx.user.id, { + totalSessions: completed.length, + totalMinutes, + }); + return { success: true }; + }), + + list: protectedProcedure + .input(z.object({ limit: z.number().default(50) }).optional()) + .query(async ({ ctx, input }) => { + return db.getUserTrainingRecords(ctx.user.id, input?.limit || 50); + }), + }), + + // Rating system + rating: router({ + history: protectedProcedure.query(async ({ ctx }) => { + return db.getUserRatingHistory(ctx.user.id); + }), + current: protectedProcedure.query(async ({ ctx }) => { + const user = await db.getUserByOpenId(ctx.user.openId); + return { rating: user?.ntrpRating || 1.5 }; + }), + }), }); +// NTRP Rating calculation function +async function recalculateNTRPRating(userId: number, latestAnalysisId: number) { + const analyses = await db.getUserAnalyses(userId); + if (analyses.length === 0) return; + + // Weight recent analyses more heavily + const weightedScores = analyses.slice(0, 20).map((a, i) => { + const weight = Math.max(0.3, 1 - i * 0.05); // Recent = higher weight + return { + overallScore: (a.overallScore || 0) * weight, + strokeConsistency: (a.strokeConsistency || 0) * weight, + footworkScore: (a.footworkScore || 0) * weight, + fluidityScore: (a.fluidityScore || 0) * weight, + shotCount: (a.shotCount || 0) * weight, + avgSwingSpeed: (a.avgSwingSpeed || 0) * weight, + weight, + }; + }); + + const totalWeight = weightedScores.reduce((sum, s) => sum + s.weight, 0); + + const dimensions = { + poseAccuracy: weightedScores.reduce((sum, s) => sum + s.overallScore, 0) / totalWeight, + strokeConsistency: weightedScores.reduce((sum, s) => sum + s.strokeConsistency, 0) / totalWeight, + footwork: weightedScores.reduce((sum, s) => sum + s.footworkScore, 0) / totalWeight, + fluidity: weightedScores.reduce((sum, s) => sum + s.fluidityScore, 0) / totalWeight, + power: Math.min(100, weightedScores.reduce((sum, s) => sum + s.avgSwingSpeed, 0) / totalWeight * 5), + }; + + // Convert 0-100 scores to NTRP 1.0-5.0 + // NTRP mapping: 0-20 → 1.0-1.5, 20-40 → 1.5-2.5, 40-60 → 2.5-3.5, 60-80 → 3.5-4.5, 80-100 → 4.5-5.0 + const avgDimension = ( + dimensions.poseAccuracy * 0.30 + + dimensions.strokeConsistency * 0.25 + + dimensions.footwork * 0.20 + + dimensions.fluidity * 0.15 + + dimensions.power * 0.10 + ); + + let ntrpRating: number; + if (avgDimension <= 20) ntrpRating = 1.0 + (avgDimension / 20) * 0.5; + else if (avgDimension <= 40) ntrpRating = 1.5 + ((avgDimension - 20) / 20) * 1.0; + else if (avgDimension <= 60) ntrpRating = 2.5 + ((avgDimension - 40) / 20) * 1.0; + else if (avgDimension <= 80) ntrpRating = 3.5 + ((avgDimension - 60) / 20) * 1.0; + else ntrpRating = 4.5 + ((avgDimension - 80) / 20) * 0.5; + + ntrpRating = Math.round(ntrpRating * 10) / 10; + ntrpRating = Math.max(1.0, Math.min(5.0, ntrpRating)); + + // Save rating history + await db.createRatingEntry({ + userId, + rating: ntrpRating, + reason: `基于${analyses.length}次视频分析自动评分`, + dimensionScores: dimensions, + analysisId: latestAnalysisId, + }); + + // Update user's current rating + await db.updateUserProfile(userId, { ntrpRating }); +} + export type AppRouter = typeof appRouter; diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..4e50295 --- /dev/null +++ b/todo.md @@ -0,0 +1,28 @@ +# Project TODO + +- [x] 用户名简单登录系统(只需输入用户名即可登录) +- [x] 数据库schema设计(用户表、训练计划表、视频表、分析结果表、训练记录表) +- [x] 训练计划生成系统(根据初级/中级/高级水平生成计划) +- [x] 训练计划内容(影子挥拍、墙壁练习、脚步移动等只需球拍的训练) +- [x] 视频上传功能(支持webm/mp4格式) +- [x] 视频存储到S3 +- [x] MediaPipe Pose浏览器端姿势识别集成 +- [x] 视频播放与关键点可视化叠加显示 +- [x] 姿势矫正建议系统(基于AI识别结果生成矫正建议) +- [x] 训练计划自动调整(根据姿势识别结果和进度调整计划) +- [x] 训练进度追踪(可视化展示训练历史和姿势改进趋势) +- [x] 训练视频库管理(保存和管理所有训练视频及分析结果) +- [x] 全局UI设计和主题配置 +- [x] 响应式布局适配 +- [x] 编写vitest测试 +- [x] 击球次数统计(参考tennis_analysis) +- [x] 挥拍速度估算(手腕/手臂关键点帧间位移) +- [x] 运动轨迹可视化(身体中心点移动轨迹) +- [ ] 迷你球场可视化叠加 +- [x] 球员统计面板(综合展示分析数据) +- [x] 帧级别关键时刻标注 +- [x] NTRP自动评分系统(1.0-5.0) +- [x] 基于所有历史记录自动更新用户评分 +- [x] 多维度评分(姿势正确性、动作流畅性、击球一致性、脚步移动、挥拍速度) +- [x] 评分趋势图表展示 +- [ ] 推送代码到Gitea仓库