refactor: code quality cleanup — remove any types, add logger, rename Kine to Tabata
- Phase 0: Rename all Kine references to Tabata (types, files, imports, i18n, analytics events) - Phase 1: Add test coverage for tabataProgramStore, workoutProgramStore, and color utils (47 tests) - Phase 2: Remove all `any` types from production code with proper typed replacements - Phase 3: Replace ~60 raw console.* calls with __DEV__-gated logger utility - Phase 4: Verify .DS_Store housekeeping (already clean) 0 TypeScript errors, 583/583 tests passing.
This commit is contained in:
15
admin-web/lib/CLAUDE.md
Normal file
15
admin-web/lib/CLAUDE.md
Normal file
@@ -0,0 +1,15 @@
|
||||
<claude-mem-context>
|
||||
# Recent Activity
|
||||
|
||||
<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->
|
||||
|
||||
### Apr 16, 2026
|
||||
|
||||
| ID | Time | T | Title | Read |
|
||||
|----|------|---|-------|------|
|
||||
| #6329 | 9:39 PM | 🔄 | Refactored admin AI library to remove legacy program_workouts table references | ~356 |
|
||||
| #6328 | " | 🔄 | Removed ProgramWorkoutRow type definition from admin AI integration | ~364 |
|
||||
| #6327 | 9:38 PM | 🔵 | Identified legacy database schema references in admin AI library | ~316 |
|
||||
| #6326 | " | 🔵 | Discovered AI integration file with legacy database schema references | ~336 |
|
||||
| #6324 | " | 🔄 | Updated Supabase database type definitions for workout program schema | ~457 |
|
||||
</claude-mem-context>
|
||||
171
admin-web/lib/ai.ts
Normal file
171
admin-web/lib/ai.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { GoogleGenAI } from '@google/genai'
|
||||
import { supabase } from './supabase'
|
||||
import type { Database } from './supabase'
|
||||
|
||||
type WorkoutRow = Database['public']['Tables']['workouts']['Row']
|
||||
|
||||
function getAI(): GoogleGenAI {
|
||||
const apiKey = process.env['GEMINI_API_KEY']
|
||||
if (!apiKey) {
|
||||
throw new Error('GEMINI_API_KEY environment variable is not set')
|
||||
}
|
||||
return new GoogleGenAI({ apiKey })
|
||||
}
|
||||
|
||||
export interface GeneratedImage {
|
||||
buffer: Buffer
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export interface VideoGenerationResult {
|
||||
workoutId: string
|
||||
trainerId: string
|
||||
videoUrl: string
|
||||
videoPath: string
|
||||
}
|
||||
|
||||
export async function generateTrainerAvatar(
|
||||
prompt: string,
|
||||
_trainerName: string
|
||||
): Promise<GeneratedImage[]> {
|
||||
const config = {
|
||||
imageConfig: {
|
||||
aspectRatio: '1:1',
|
||||
imageSize: '1K',
|
||||
personGeneration: 'ALLOW_ADULT',
|
||||
},
|
||||
responseModalities: ['IMAGE', 'TEXT'],
|
||||
}
|
||||
|
||||
const response = await getAI().models.generateContentStream({
|
||||
model: 'gemini-3.1-flash-image-preview',
|
||||
config,
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
})
|
||||
|
||||
const images: GeneratedImage[] = []
|
||||
|
||||
for await (const chunk of response) {
|
||||
const parts = chunk.candidates?.[0]?.content?.parts
|
||||
if (!parts) continue
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.inlineData) {
|
||||
images.push({
|
||||
buffer: Buffer.from(part.inlineData.data || '', 'base64'),
|
||||
mimeType: part.inlineData.mimeType || 'image/png',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return images
|
||||
}
|
||||
|
||||
export async function saveAvatarToStorage(
|
||||
trainerId: string,
|
||||
imageBuffer: Buffer,
|
||||
filename: string
|
||||
): Promise<string> {
|
||||
const path = `${trainerId}/${filename}`
|
||||
|
||||
const { error } = await supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.upload(path, imageBuffer, {
|
||||
contentType: 'image/png',
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) throw new Error(`Failed to upload avatar: ${error.message}`)
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('trainer-avatars')
|
||||
.getPublicUrl(path)
|
||||
|
||||
return publicUrl
|
||||
}
|
||||
|
||||
export async function generateWorkoutVideo(
|
||||
workoutId: string,
|
||||
trainerId: string,
|
||||
_trainerAvatarUrl: string,
|
||||
workoutTitle: string,
|
||||
exercises: { name: string; duration: number }[]
|
||||
): Promise<VideoGenerationResult> {
|
||||
const exerciseList = exercises.map(e => e.name).join(', ')
|
||||
|
||||
const prompt = `${workoutTitle}. Exercises: ${exerciseList}. The video must loop seamlessly - start and end in the exact same standing position with arms at sides. Person should appear energetic and properly demonstrate each exercise. 16:9 aspect ratio.`
|
||||
|
||||
const operation = await getAI().models.generateVideos({
|
||||
model: 'veo-3.1-fast-generate-preview',
|
||||
source: { prompt },
|
||||
config: {
|
||||
numberOfVideos: 1,
|
||||
aspectRatio: '16:9',
|
||||
resolution: '1080p',
|
||||
personGeneration: 'dont_allow',
|
||||
durationSeconds: 8,
|
||||
},
|
||||
})
|
||||
|
||||
let currentOperation = operation
|
||||
while (!currentOperation.done) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10000))
|
||||
currentOperation = await getAI().operations.getVideosOperation({ operation: currentOperation })
|
||||
}
|
||||
|
||||
const videoUri = currentOperation.response?.generatedVideos?.[0]?.video?.uri
|
||||
if (!videoUri) throw new Error('No video generated')
|
||||
|
||||
const response = await fetch(`${videoUri}&key=${process.env['GEMINI_API_KEY']}`)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const videoBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
const videoPath = `${trainerId}/${workoutId}.mp4`
|
||||
const { error } = await supabase.storage
|
||||
.from('trainers-videos')
|
||||
.upload(videoPath, videoBuffer, {
|
||||
contentType: 'video/mp4',
|
||||
upsert: true,
|
||||
})
|
||||
|
||||
if (error) throw new Error(`Failed to upload video: ${error.message}`)
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('trainers-videos')
|
||||
.getPublicUrl(videoPath)
|
||||
|
||||
return { workoutId, trainerId, videoUrl: publicUrl, videoPath }
|
||||
}
|
||||
|
||||
export async function isWorkoutInProgram(_workoutId: string): Promise<boolean> {
|
||||
// Legacy program_workouts table removed — workout programs now use program_tabatas
|
||||
return false
|
||||
}
|
||||
|
||||
export async function isVideoGeneratedForWorkout(_workoutId: string): Promise<boolean> {
|
||||
// Legacy program_workouts table removed — video status tracked in workouts table
|
||||
return false
|
||||
}
|
||||
|
||||
export async function getWorkoutVideoStatus(workoutId: string): Promise<{
|
||||
hasVideo: boolean
|
||||
videoUrl: string | null
|
||||
inProgram: boolean
|
||||
videoGenerated: boolean
|
||||
}> {
|
||||
const workoutResult = await supabase
|
||||
.from('workouts')
|
||||
.select('video_url')
|
||||
.eq('id', workoutId)
|
||||
.single()
|
||||
|
||||
const workoutVideoUrl = (workoutResult.data as WorkoutRow | null)?.video_url ?? null
|
||||
|
||||
return {
|
||||
hasVideo: !!workoutVideoUrl,
|
||||
videoUrl: workoutVideoUrl,
|
||||
inProgram: false,
|
||||
videoGenerated: false,
|
||||
}
|
||||
}
|
||||
@@ -150,6 +150,138 @@ export interface Database {
|
||||
sort_order: number
|
||||
}
|
||||
}
|
||||
workout_programs: {
|
||||
Row: {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
body_zone: 'upper-body' | 'lower-body' | 'full-body'
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
is_free: boolean
|
||||
estimated_duration: number
|
||||
estimated_calories: number
|
||||
icon: string | null
|
||||
accent_color: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
title: string
|
||||
description?: string
|
||||
body_zone: 'upper-body' | 'lower-body' | 'full-body'
|
||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
||||
is_free?: boolean
|
||||
estimated_duration?: number
|
||||
estimated_calories?: number
|
||||
icon?: string | null
|
||||
accent_color?: string | null
|
||||
sort_order?: number
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['workout_programs']['Insert'], 'id'>>
|
||||
}
|
||||
program_tabatas: {
|
||||
Row: {
|
||||
id: string
|
||||
program_id: string
|
||||
position: number
|
||||
exercise_1_name: string
|
||||
exercise_1_name_en: string | null
|
||||
exercise_1_tip: string | null
|
||||
exercise_1_tip_en: string | null
|
||||
exercise_1_modification: string | null
|
||||
exercise_1_modification_en: string | null
|
||||
exercise_1_progression: string | null
|
||||
exercise_1_progression_en: string | null
|
||||
exercise_2_name: string
|
||||
exercise_2_name_en: string | null
|
||||
exercise_2_tip: string | null
|
||||
exercise_2_tip_en: string | null
|
||||
exercise_2_modification: string | null
|
||||
exercise_2_modification_en: string | null
|
||||
exercise_2_progression: string | null
|
||||
exercise_2_progression_en: string | null
|
||||
rounds: number
|
||||
work_time: number
|
||||
rest_time: number
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
program_id: string
|
||||
position: number
|
||||
exercise_1_name: string
|
||||
exercise_1_name_en?: string | null
|
||||
exercise_1_tip?: string | null
|
||||
exercise_1_tip_en?: string | null
|
||||
exercise_1_modification?: string | null
|
||||
exercise_1_modification_en?: string | null
|
||||
exercise_1_progression?: string | null
|
||||
exercise_1_progression_en?: string | null
|
||||
exercise_2_name: string
|
||||
exercise_2_name_en?: string | null
|
||||
exercise_2_tip?: string | null
|
||||
exercise_2_tip_en?: string | null
|
||||
exercise_2_modification?: string | null
|
||||
exercise_2_modification_en?: string | null
|
||||
exercise_2_progression?: string | null
|
||||
exercise_2_progression_en?: string | null
|
||||
rounds?: number
|
||||
work_time?: number
|
||||
rest_time?: number
|
||||
}
|
||||
Update: Partial<Omit<Database['public']['Tables']['program_tabatas']['Insert'], 'id' | 'program_id'>>
|
||||
}
|
||||
workout_videos: {
|
||||
Row: {
|
||||
id: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
}
|
||||
Update: {
|
||||
video_url?: string
|
||||
video_path?: string
|
||||
}
|
||||
}
|
||||
exercise_videos: {
|
||||
Row: {
|
||||
id: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
exercise_name: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
video_type: 'exercise' | 'rest'
|
||||
duration_seconds: number | null
|
||||
created_at: string
|
||||
}
|
||||
Insert: {
|
||||
id?: string
|
||||
workout_id: string
|
||||
trainer_id: string
|
||||
exercise_name: string
|
||||
video_url: string
|
||||
video_path: string
|
||||
video_type?: 'exercise' | 'rest'
|
||||
duration_seconds?: number | null
|
||||
}
|
||||
Update: {
|
||||
video_url?: string
|
||||
video_path?: string
|
||||
video_type?: 'exercise' | 'rest'
|
||||
duration_seconds?: number | null
|
||||
}
|
||||
}
|
||||
admin_users: {
|
||||
Row: {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user