2:I[7012,["4765","static/chunks/4765-f5afdf8061f456f3.js","9856","static/chunks/9856-3b185291364d9bef.js","6687","static/chunks/app/docs/%5B...slug%5D/page-e07536548216bee4.js"],"MarkdownRenderer"] 4:I[9856,["4765","static/chunks/4765-f5afdf8061f456f3.js","9856","static/chunks/9856-3b185291364d9bef.js","6687","static/chunks/app/docs/%5B...slug%5D/page-e07536548216bee4.js"],""] 5:I[4126,[],""] 7:I[9630,[],""] 8:I[4278,["9856","static/chunks/9856-3b185291364d9bef.js","8172","static/chunks/8172-b3a2d6fe4ae10d40.js","3185","static/chunks/app/layout-2814fa5d15b84fe4.js"],"HeadingProvider"] 9:I[1476,["9856","static/chunks/9856-3b185291364d9bef.js","8172","static/chunks/8172-b3a2d6fe4ae10d40.js","3185","static/chunks/app/layout-2814fa5d15b84fe4.js"],"Header"] a:I[3167,["9856","static/chunks/9856-3b185291364d9bef.js","8172","static/chunks/8172-b3a2d6fe4ae10d40.js","3185","static/chunks/app/layout-2814fa5d15b84fe4.js"],"Sidebar"] b:I[7409,["9856","static/chunks/9856-3b185291364d9bef.js","8172","static/chunks/8172-b3a2d6fe4ae10d40.js","3185","static/chunks/app/layout-2814fa5d15b84fe4.js"],"PageFrame"] 3:Ta85b, # VoiceAssist Web App - Comprehensive Feature Specifications **Version:** 1.0 **Date:** 2025-11-21 **Application:** Main User-Facing Medical AI Assistant **URL:** https://voiceassist.asimo.io --- ## 📋 Table of Contents 1. [Overview](#overview) 2. [Authentication & User Management](#authentication--user-management) (5 features) 3. [Chat Interface](#chat-interface) (12 features) 4. [Voice Mode](#voice-mode) (8 features) 5. [Clinical Context](#clinical-context) (6 features) 6. [File Management](#file-management) (4 features) 7. [Citations & Sources](#citations--sources) (5 features) 8. [Conversation Management](#conversation-management) (5 features) 9. [Advanced Features](#advanced-features) (10 features) 10. [Technical Implementation](#technical-implementation) 11. [Testing Strategy](#testing-strategy) **Total Features:** 55 (expanded from original 45) --- ## 🎯 Overview The VoiceAssist Web App is a React-based single-page application that provides medical professionals with an AI-powered assistant for clinical decision support, medical literature queries, and patient care workflows. ### Core Technology Stack - **Framework:** React 18.2+ with TypeScript 5.0+ - **Build Tool:** Vite 5.0+ - **Routing:** React Router 6.20+ - **State Management:** Zustand 4.4+ - **UI Components:** shadcn/ui + Radix UI - **Styling:** Tailwind CSS 3.4+ - **Forms:** React Hook Form 7.48+ with Zod validation - **Real-time:** Native WebSocket + Socket.io client - **Audio:** Web Audio API + MediaRecorder API - **Markdown:** React Markdown + remark-gfm + rehype-katex - **Charts:** Recharts 2.10+ - **Testing:** Vitest + React Testing Library + Playwright ### Design Principles 1. **Speed First** - Every interaction feels instant 2. **Medical Professionalism** - Trust-building design 3. **Accessibility** - WCAG 2.1 AA compliant 4. **Mobile-Ready** - Responsive from 320px to 4K 5. **Offline Capable** - Service worker with smart caching --- ## 1. Authentication & User Management ### 1.1 Email/Password Login **Priority:** P0 (Critical) **Effort:** 5 days **Dependencies:** Backend auth API #### Specification A clean, professional login interface with email and password fields, validation, and error handling. **User Flow:** 1. User navigates to `/login` 2. Enters email and password 3. Clicks "Sign In" or presses Enter 4. System validates credentials 5. On success: Redirects to chat interface 6. On failure: Shows inline error message **Features:** - Real-time validation - Password visibility toggle - "Remember me" checkbox - Forgot password link - Social login options (Google, Microsoft) - Rate limiting (5 attempts per 15 minutes) - Loading states during authentication #### Component Structure ```tsx // File: apps/web-app/src/pages/auth/LoginPage.tsx import { useState } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button } from "@voiceassist/ui"; import { Input } from "@voiceassist/ui"; import { Label } from "@voiceassist/ui"; import { useAuth } from "@/hooks/useAuth"; import { GoogleIcon, MicrosoftIcon } from "@/components/icons"; // Validation schema const loginSchema = z.object({ email: z.string().email("Invalid email address").min(1, "Email is required"), password: z.string().min(8, "Password must be at least 8 characters").max(100, "Password is too long"), rememberMe: z.boolean().optional(), }); type LoginFormData = z.infer; export function LoginPage() { const navigate = useNavigate(); const { login, loginWithOAuth } = useAuth(); const [showPassword, setShowPassword] = useState(false); const [apiError, setApiError] = useState(null); const { register, handleSubmit, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(loginSchema), defaultValues: { rememberMe: false, }, }); const onSubmit = async (data: LoginFormData) => { try { setApiError(null); await login(data.email, data.password, data.rememberMe); navigate("/chat"); } catch (error: any) { if (error.response?.status === 401) { setApiError("Invalid email or password"); } else if (error.response?.status === 429) { setApiError("Too many login attempts. Please try again later."); } else { setApiError("An error occurred. Please try again."); } } }; const handleOAuthLogin = async (provider: "google" | "microsoft") => { try { await loginWithOAuth(provider); } catch (error) { setApiError(`Failed to sign in with ${provider}`); } }; return (
{/* Logo */}
VoiceAssist

Welcome Back

Sign in to your VoiceAssist account

{/* Login Form Card */}
{/* Email Field */}
{/* Password Field */}
Forgot password?
{/* Remember Me */}
{/* API Error */} {apiError && (

{apiError}

)} {/* Submit Button */}
{/* OAuth Divider */}
Or continue with
{/* OAuth Buttons */}
{/* Sign Up Link */}

Don't have an account?{" "} Sign up

{/* Footer */}

By signing in, you agree to our{" "} Terms of Service {" "} and{" "} Privacy Policy

); } ``` #### Authentication Hook ```tsx // File: apps/web-app/src/hooks/useAuth.ts import { create } from "zustand"; import { persist } from "zustand/middleware"; import { authApi } from "@voiceassist/api-client"; import type { User, AuthTokens } from "@voiceassist/types"; interface AuthState { user: User | null; tokens: AuthTokens | null; isAuthenticated: boolean; isLoading: boolean; login: (email: string, password: string, rememberMe?: boolean) => Promise; loginWithOAuth: (provider: "google" | "microsoft") => Promise; logout: () => Promise; refreshToken: () => Promise; updateProfile: (updates: Partial) => Promise; } export const useAuth = create()( persist( (set, get) => ({ user: null, tokens: null, isAuthenticated: false, isLoading: false, login: async (email: string, password: string, rememberMe = false) => { set({ isLoading: true }); try { const response = await authApi.login({ email, password, rememberMe }); set({ user: response.user, tokens: response.tokens, isAuthenticated: true, isLoading: false, }); // Set up token refresh scheduleTokenRefresh(response.tokens.expiresIn); } catch (error) { set({ isLoading: false }); throw error; } }, loginWithOAuth: async (provider) => { set({ isLoading: true }); try { // Redirect to OAuth provider const authUrl = await authApi.getOAuthUrl(provider); window.location.href = authUrl; } catch (error) { set({ isLoading: false }); throw error; } }, logout: async () => { const { tokens } = get(); if (tokens) { try { await authApi.logout(tokens.refreshToken); } catch (error) { console.error("Logout failed:", error); } } set({ user: null, tokens: null, isAuthenticated: false, }); }, refreshToken: async () => { const { tokens } = get(); if (!tokens?.refreshToken) { throw new Error("No refresh token available"); } try { const response = await authApi.refresh(tokens.refreshToken); set({ tokens: response.tokens, }); scheduleTokenRefresh(response.tokens.expiresIn); } catch (error) { // Refresh failed, logout user get().logout(); throw error; } }, updateProfile: async (updates) => { const { user } = get(); if (!user) throw new Error("No user logged in"); const updatedUser = await authApi.updateProfile(user.id, updates); set({ user: updatedUser }); }, }), { name: "voiceassist-auth", partialize: (state) => ({ user: state.user, tokens: state.tokens, isAuthenticated: state.isAuthenticated, }), }, ), ); // Helper function to schedule token refresh let refreshTimeout: NodeJS.Timeout | null = null; function scheduleTokenRefresh(expiresIn: number) { if (refreshTimeout) { clearTimeout(refreshTimeout); } // Refresh 5 minutes before expiry const refreshIn = (expiresIn - 300) * 1000; refreshTimeout = setTimeout(() => { useAuth.getState().refreshToken(); }, refreshIn); } ``` #### Protected Route Component ```tsx // File: apps/web-app/src/components/auth/ProtectedRoute.tsx import { Navigate, useLocation } from "react-router-dom"; import { useAuth } from "@/hooks/useAuth"; import { Spinner } from "@voiceassist/ui"; interface ProtectedRouteProps { children: React.ReactNode; requireAdmin?: boolean; } export function ProtectedRoute({ children, requireAdmin = false }: ProtectedRouteProps) { const { isAuthenticated, user, isLoading } = useAuth(); const location = useLocation(); if (isLoading) { return (
); } if (!isAuthenticated) { // Redirect to login with return URL return ; } if (requireAdmin && user?.role !== "admin") { return ; } return <>{children}; } ``` #### API Client (Auth Module) ```tsx // File: packages/api-client/src/auth.ts import { apiClient } from "./client"; import type { User, AuthTokens } from "@voiceassist/types"; interface LoginRequest { email: string; password: string; rememberMe?: boolean; } interface LoginResponse { user: User; tokens: AuthTokens; } interface RegisterRequest { email: string; password: string; firstName: string; lastName: string; specialty?: string; } export const authApi = { /** * Login with email and password */ login: async (data: LoginRequest): Promise => { const response = await apiClient.post("/api/auth/login", data); return response.data; }, /** * Register new user account */ register: async (data: RegisterRequest): Promise => { const response = await apiClient.post("/api/auth/register", data); return response.data; }, /** * Get OAuth authorization URL */ getOAuthUrl: async (provider: "google" | "microsoft"): Promise => { const response = await apiClient.get<{ url: string }>(`/api/auth/oauth/${provider}`); return response.data.url; }, /** * Exchange OAuth code for tokens */ oauthCallback: async (provider: string, code: string): Promise => { const response = await apiClient.post(`/api/auth/oauth/${provider}/callback`, { code, }); return response.data; }, /** * Refresh access token */ refresh: async (refreshToken: string): Promise<{ tokens: AuthTokens }> => { const response = await apiClient.post<{ tokens: AuthTokens }>("/api/auth/refresh", { refreshToken, }); return response.data; }, /** * Logout and revoke tokens */ logout: async (refreshToken: string): Promise => { await apiClient.post("/api/auth/logout", { refreshToken }); }, /** * Request password reset email */ requestPasswordReset: async (email: string): Promise => { await apiClient.post("/api/auth/forgot-password", { email }); }, /** * Reset password with token */ resetPassword: async (token: string, newPassword: string): Promise => { await apiClient.post("/api/auth/reset-password", { token, newPassword }); }, /** * Update user profile */ updateProfile: async (userId: string, updates: Partial): Promise => { const response = await apiClient.patch(`/api/users/${userId}`, updates); return response.data; }, /** * Get current user */ getCurrentUser: async (): Promise => { const response = await apiClient.get("/api/auth/me"); return response.data; }, }; ``` #### Testing ```tsx // File: apps/web-app/src/pages/auth/__tests__/LoginPage.test.tsx import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { BrowserRouter } from "react-router-dom"; import { LoginPage } from "../LoginPage"; import { useAuth } from "@/hooks/useAuth"; // Mock auth hook vi.mock("@/hooks/useAuth"); describe("LoginPage", () => { const mockLogin = vi.fn(); const mockLoginWithOAuth = vi.fn(); beforeEach(() => { vi.clearAllMocks(); (useAuth as any).mockReturnValue({ login: mockLogin, loginWithOAuth: mockLoginWithOAuth, }); }); it("renders login form", () => { render( , ); expect(screen.getByLabelText(/email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/password/i)).toBeInTheDocument(); expect(screen.getByRole("button", { name: /sign in/i })).toBeInTheDocument(); }); it("validates email format", async () => { const user = userEvent.setup(); render( , ); const emailInput = screen.getByLabelText(/email/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.type(emailInput, "invalid-email"); await user.click(submitButton); expect(await screen.findByText(/invalid email address/i)).toBeInTheDocument(); expect(mockLogin).not.toHaveBeenCalled(); }); it("validates password length", async () => { const user = userEvent.setup(); render( , ); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.type(emailInput, "test@example.com"); await user.type(passwordInput, "short"); await user.click(submitButton); expect(await screen.findByText(/password must be at least 8 characters/i)).toBeInTheDocument(); expect(mockLogin).not.toHaveBeenCalled(); }); it("submits valid credentials", async () => { const user = userEvent.setup(); mockLogin.mockResolvedValue(undefined); render( , ); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.type(emailInput, "doctor@hospital.com"); await user.type(passwordInput, "SecurePass123!"); await user.click(submitButton); await waitFor(() => { expect(mockLogin).toHaveBeenCalledWith("doctor@hospital.com", "SecurePass123!", false); }); }); it("handles login error", async () => { const user = userEvent.setup(); mockLogin.mockRejectedValue({ response: { status: 401 }, }); render( , ); const emailInput = screen.getByLabelText(/email/i); const passwordInput = screen.getByLabelText(/password/i); const submitButton = screen.getByRole("button", { name: /sign in/i }); await user.type(emailInput, "doctor@hospital.com"); await user.type(passwordInput, "WrongPassword"); await user.click(submitButton); expect(await screen.findByText(/invalid email or password/i)).toBeInTheDocument(); }); it("toggles password visibility", async () => { const user = userEvent.setup(); render( , ); const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement; const toggleButton = screen.getByRole("button", { name: /toggle password/i }); expect(passwordInput.type).toBe("password"); await user.click(toggleButton); expect(passwordInput.type).toBe("text"); await user.click(toggleButton); expect(passwordInput.type).toBe("password"); }); it("handles OAuth login", async () => { const user = userEvent.setup(); render( , ); const googleButton = screen.getByRole("button", { name: /google/i }); await user.click(googleButton); expect(mockLoginWithOAuth).toHaveBeenCalledWith("google"); }); }); ``` --- ### 1.2 User Registration **Priority:** P0 (Critical) **Effort:** 5 days **Dependencies:** Backend auth API, email service #### Specification Registration page for new users with comprehensive validation, email verification, and specialty selection. **User Flow:** 1. User navigates to `/register` 2. Fills in required information 3. Selects medical specialty 4. Agrees to terms and conditions 5. Submits form 6. Receives verification email 7. Clicks verification link 8. Account activated **Features:** - Real-time validation - Password strength meter - Email availability check - Specialty dropdown with search - Terms acceptance checkbox - Email verification workflow - Auto-login after verification #### Component Implementation ```tsx // File: apps/web-app/src/pages/auth/RegisterPage.tsx import { useState } from "react"; import { useNavigate, Link } from "react-router-dom"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, Input, Label, Select } from "@voiceassist/ui"; import { useAuth } from "@/hooks/useAuth"; import { PasswordStrengthMeter } from "@/components/auth/PasswordStrengthMeter"; import { medicalSpecialties } from "@/constants/specialties"; const registerSchema = z .object({ firstName: z.string().min(1, "First name is required").max(50), lastName: z.string().min(1, "Last name is required").max(50), email: z.string().email("Invalid email address"), password: z .string() .min(8, "Password must be at least 8 characters") .regex(/[A-Z]/, "Password must contain uppercase letter") .regex(/[a-z]/, "Password must contain lowercase letter") .regex(/[0-9]/, "Password must contain number") .regex(/[^A-Za-z0-9]/, "Password must contain special character"), confirmPassword: z.string(), specialty: z.string().optional(), licenseNumber: z.string().optional(), institution: z.string().optional(), agreeToTerms: z.boolean().refine((val) => val === true, { message: "You must agree to the terms and conditions", }), }) .refine((data) => data.password === data.confirmPassword, { message: "Passwords do not match", path: ["confirmPassword"], }); type RegisterFormData = z.infer; export function RegisterPage() { const navigate = useNavigate(); const { register: registerUser } = useAuth(); const [apiError, setApiError] = useState(null); const [emailAvailable, setEmailAvailable] = useState(null); const { register, handleSubmit, watch, formState: { errors, isSubmitting }, } = useForm({ resolver: zodResolver(registerSchema), }); const password = watch("password"); const email = watch("email"); // Check email availability with debounce const checkEmailAvailability = useDebouncedCallback(async (email: string) => { if (!email || !z.string().email().safeParse(email).success) { setEmailAvailable(null); return; } try { const available = await authApi.checkEmailAvailability(email); setEmailAvailable(available); } catch (error) { console.error("Failed to check email:", error); } }, 500); useEffect(() => { checkEmailAvailability(email); }, [email]); const onSubmit = async (data: RegisterFormData) => { try { setApiError(null); await registerUser({ email: data.email, password: data.password, firstName: data.firstName, lastName: data.lastName, specialty: data.specialty, licenseNumber: data.licenseNumber, institution: data.institution, }); // Show verification email sent message navigate("/verify-email", { state: { email: data.email }, }); } catch (error: any) { if (error.response?.data?.message) { setApiError(error.response.data.message); } else { setApiError("Registration failed. Please try again."); } } }; return (
{/* Header */}
VoiceAssist

Create Your Account

Join VoiceAssist to enhance your clinical practice

{/* Registration Form Card */}
{/* Name Fields */}
{/* Email Field */}
{emailAvailable === false && (
Already taken
)} {emailAvailable === true && (
)}
{/* Password Field */}
{password && }
{/* Confirm Password */}
{/* Professional Information */}

Professional Information

{/* Terms Agreement */}
{errors.agreeToTerms &&

{errors.agreeToTerms.message}

} {/* API Error */} {apiError && (

{apiError}

)} {/* Submit Button */}
{/* Login Link */}

Already have an account?{" "} Sign in

); } ``` #### Password Strength Component ```tsx // File: apps/web-app/src/components/auth/PasswordStrengthMeter.tsx interface PasswordStrengthMeterProps { password: string; } export function PasswordStrengthMeter({ password }: PasswordStrengthMeterProps) { const strength = calculatePasswordStrength(password); const getColor = () => { if (strength < 30) return "bg-red-500"; if (strength < 60) return "bg-yellow-500"; if (strength < 80) return "bg-blue-500"; return "bg-green-500"; }; const getLabel = () => { if (strength < 30) return "Weak"; if (strength < 60) return "Fair"; if (strength < 80) return "Good"; return "Strong"; }; return (
{getLabel()}
  • = 8 ? "text-green-600" : ""}>✓ At least 8 characters
  • ✓ Contains uppercase letter
  • ✓ Contains lowercase letter
  • ✓ Contains number
  • ✓ Contains special character
); } function calculatePasswordStrength(password: string): number { let strength = 0; // Length if (password.length >= 8) strength += 20; if (password.length >= 12) strength += 10; if (password.length >= 16) strength += 10; // Character variety if (/[a-z]/.test(password)) strength += 15; if (/[A-Z]/.test(password)) strength += 15; if (/[0-9]/.test(password)) strength += 15; if (/[^A-Za-z0-9]/.test(password)) strength += 15; return Math.min(strength, 100); } ``` --- ### 1.3 User Profile Management **Priority:** P1 (High) **Effort:** 3 days **Dependencies:** Auth system #### Specification Comprehensive user profile page where users can view and edit their personal and professional information. **Features:** - View profile information - Edit personal details - Update professional information - Change password - Upload profile picture - Manage notification preferences - View account activity - Delete account (with confirmation) #### Component Implementation ```tsx // File: apps/web-app/src/pages/settings/ProfilePage.tsx import { useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, Input, Label, Select, Avatar, Tabs, TabsList, TabsTrigger, TabsContent } from "@voiceassist/ui"; import { useAuth } from "@/hooks/useAuth"; import { medicalSpecialties } from "@/constants/specialties"; const profileSchema = z.object({ firstName: z.string().min(1, "Required"), lastName: z.string().min(1, "Required"), specialty: z.string().optional(), licenseNumber: z.string().optional(), institution: z.string().optional(), phone: z.string().optional(), bio: z.string().max(500).optional(), }); type ProfileFormData = z.infer; export function ProfilePage() { const { user, updateProfile } = useAuth(); const [isEditing, setIsEditing] = useState(false); const [isSaving, setIsSaving] = useState(false); const { register, handleSubmit, formState: { errors, isDirty }, reset, } = useForm({ resolver: zodResolver(profileSchema), defaultValues: { firstName: user?.firstName || "", lastName: user?.lastName || "", specialty: user?.specialty || "", licenseNumber: user?.licenseNumber || "", institution: user?.institution || "", phone: user?.phone || "", bio: user?.bio || "", }, }); const onSubmit = async (data: ProfileFormData) => { setIsSaving(true); try { await updateProfile(data); setIsEditing(false); toast.success("Profile updated successfully"); } catch (error) { toast.error("Failed to update profile"); } finally { setIsSaving(false); } }; const handleCancel = () => { reset(); setIsEditing(false); }; return (
Profile Security Notifications Activity {/* Profile Tab */}

{user?.firstName} {user?.lastName}

{user?.email}

{user?.specialty}

{!isEditing && }
{/* Personal Information */}

Personal Information

{/* Professional Information */}

Professional Information