Files
ReadMeABook/documentation/archive/BOOKDATE_IMPLEMENTATION_STATUS.md
T
2026-01-28 11:41:24 -05:00

21 KiB

BookDate Implementation Status

Completed Phases (1-5)

Phase 1: Database Schema

Files:

  • prisma/schema.prisma - Added 3 models:
    • BookDateConfig - Per-user AI configuration (encrypted API keys)
    • BookDateRecommendation - Cached recommendations
    • BookDateSwipe - Swipe history for learning
    • Added relationships to User model

To apply schema:

docker-compose restart app
# Or manually: npx prisma db push && npx prisma generate

Phase 2: Backend API - Configuration

Files created:

  • src/app/api/bookdate/test-connection/route.ts - Test AI provider & fetch models
  • src/app/api/bookdate/config/route.ts - GET/POST/DELETE user config
  • src/app/api/admin/bookdate/toggle/route.ts - Admin global toggle
  • src/app/api/bookdate/swipes/route.ts - Clear swipe history

Phase 3: Backend API - Recommendations

Files created:

  • src/lib/bookdate/helpers.ts - Complete helper functions:
    • getUserLibraryBooks() - Get Plex library books
    • getUserRecentSwipes() - Get swipe history
    • buildAIPrompt() - Generate AI prompt
    • callAI() - Call OpenAI/Claude APIs
    • matchToAudnexus() - Match recommendations to Audible
    • isInLibrary(), isAlreadyRequested(), isAlreadySwiped() - Filtering helpers
  • src/app/api/bookdate/recommendations/route.ts - Get recommendations (cached or generate)
  • src/app/api/bookdate/swipe/route.ts - Record swipe & create request
  • src/app/api/bookdate/undo/route.ts - Undo last swipe
  • src/app/api/bookdate/generate/route.ts - Force generate new batch

Phase 4: Setup Wizard Integration

Files modified:

  • src/app/setup/page.tsx - Added BookDate as step 7 (now 9 total steps)
  • src/app/setup/steps/BookDateStep.tsx - New setup step component
  • src/app/api/setup/complete/route.ts - Save BookDate config during setup

Phase 5: Settings Page

Files created:

  • src/app/settings/page.tsx - User settings page with:
    • AI provider selection (OpenAI/Claude)
    • API key management (encrypted)
    • Model selection
    • Library scope (full/listened/rated)
    • Custom prompt
    • Clear swipe history

Remaining Work (Phases 6-8)

Phase 6: BookDate UI - Main Page & Components 🚧

6.1 Install Dependencies

npm install react-swipeable framer-motion

6.2 Files to Create

Main BookDate Page:

  • src/app/bookdate/page.tsx - Main swipe interface page

Components:

  • src/components/bookdate/RecommendationCard.tsx - Swipeable card component
  • src/components/bookdate/LoadingScreen.tsx - Animated loading screen
  • src/components/bookdate/EmptyState.tsx - Empty state when no recommendations

Key Features:

  • Mobile: Touch swipe gestures (left/right/up)
  • Desktop: Button controls
  • Visual feedback during drag
  • Confirmation toast for right swipes
  • Undo button for left/up swipes
  • Auto-request creation on right swipe + confirm

6.3 Navigation Integration

Add BookDate tab to main navigation (conditional based on configuration):

  • Modify src/components/layout/Header.tsx (or wherever nav is)
  • Check /api/bookdate/config to show/hide tab
  • Only show if config.isVerified && config.isEnabled

Phase 7: Integration & Polish 🚧

7.1 Plex Library Integration

File: src/lib/bookdate/helpers.ts

Update getUserLibraryBooks():

  • Query Plex API directly (not just database cache)
  • For 'listened' scope: Calculate viewOffset / duration > 0.25
  • For 'rated' scope: Fetch user ratings from Plex
  • Extract genres from Plex metadata
  • Fallback to database if Plex API fails

7.2 Audnexus Matching Enhancement

File: src/lib/bookdate/helpers.ts

Update matchToAudnexus():

  • If not in AudibleCache, query Audnexus API directly
  • Implement fuzzy matching (Levenshtein distance < 3)
  • Handle multiple results (pick best by rating/popularity)
  • Cache new matches to AudibleCache

7.3 Request Integration

File: src/app/api/bookdate/swipe/route.ts

Already implemented:

  • Creates audiobook record if doesn't exist
  • Creates request on right swipe (if not marked as known)
  • Links to existing audiobook by ASIN

Phase 8: Testing & Verification 🚧

8.1 Database Testing

  • Build Docker image: docker-compose build
  • Start containers: docker-compose up -d
  • Check logs: docker-compose logs -f app
  • Verify Prisma migration: Check PostgreSQL tables
  • Test encrypted API key storage

8.2 API Testing (Manual)

Use Postman/Thunder Client or curl:

# Test connection
curl -X POST http://localhost:3030/api/bookdate/test-connection \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"provider":"openai","apiKey":"sk-..."}'

# Save config
curl -X POST http://localhost:3030/api/bookdate/config \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"provider":"openai","apiKey":"sk-...","model":"gpt-4o","libraryScope":"full"}'

# Get recommendations
curl http://localhost:3030/api/bookdate/recommendations \
  -H "Authorization: Bearer YOUR_TOKEN"

8.3 UI Testing

  • Setup wizard: Complete step 7 (BookDate)
  • Settings page: Save/update config
  • BookDate tab: Visibility based on config
  • Swipe gestures: Test on mobile and desktop
  • Loading states: Check animations
  • Error handling: Test invalid API keys, network errors
  • Dark mode: Verify all components

8.4 Integration Testing

  • Right swipe → Confirm → Creates request
  • Check request appears in /requests page
  • Verify request status updates
  • Test undo functionality
  • Clear swipe history from settings

📋 Quick Implementation Guide for Remaining Work

Step 1: Create BookDate Main Page

Create src/app/bookdate/page.tsx:

'use client';

import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Header } from '@/components/layout/Header';
import { RecommendationCard } from '@/components/bookdate/RecommendationCard';
import { LoadingScreen } from '@/components/bookdate/LoadingScreen';

export default function BookDatePage() {
  const [recommendations, setRecommendations] = useState<any[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [currentIndex, setCurrentIndex] = useState(0);
  const router = useRouter();

  useEffect(() => {
    loadRecommendations();
  }, []);

  const loadRecommendations = async () => {
    setLoading(true);
    try {
      const accessToken = localStorage.getItem('accessToken');
      const response = await fetch('/api/bookdate/recommendations', {
        headers: { 'Authorization': `Bearer ${accessToken}` }
      });

      const data = await response.json();

      if (!response.ok) {
        setError(data.error);
        return;
      }

      setRecommendations(data.recommendations);
    } catch (error: any) {
      setError(error.message);
    } finally {
      setLoading(false);
    }
  };

  const handleSwipe = async (action: 'left' | 'right' | 'up', markedAsKnown = false) => {
    const recommendation = recommendations[currentIndex];

    try {
      const accessToken = localStorage.getItem('accessToken');
      await fetch('/api/bookdate/swipe', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'Authorization': `Bearer ${accessToken}`
        },
        body: JSON.stringify({
          recommendationId: recommendation.id,
          action,
          markedAsKnown
        })
      });

      setCurrentIndex(currentIndex + 1);

      // Check if we need to load more
      if (currentIndex + 1 >= recommendations.length) {
        // Show empty state or load more
      }
    } catch (error) {
      console.error('Swipe error:', error);
    }
  };

  if (loading) {
    return <LoadingScreen />;
  }

  if (error) {
    return (
      <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
        <Header />
        <div className="flex flex-col items-center justify-center min-h-[80vh]">
          <h2 className="text-2xl font-bold mb-4">Could not load recommendations</h2>
          <p className="text-gray-600 mb-4">{error}</p>
          <div className="flex gap-4">
            <button
              onClick={loadRecommendations}
              className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
            >
              Try Again
            </button>
            <button
              onClick={() => router.push('/settings')}
              className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
            >
              Go to Settings
            </button>
          </div>
        </div>
      </div>
    );
  }

  if (currentIndex >= recommendations.length) {
    return (
      <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
        <Header />
        <div className="flex flex-col items-center justify-center min-h-[80vh]">
          <h2 className="text-2xl font-bold mb-4">You've seen all recommendations!</h2>
          <p className="text-gray-600 mb-4">Want more suggestions?</p>
          <div className="flex gap-4">
            <button
              onClick={loadRecommendations}
              className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
            >
              Get More
            </button>
            <button
              onClick={() => router.push('/')}
              className="px-6 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
            >
              Go Home
            </button>
          </div>
        </div>
      </div>
    );
  }

  const currentRec = recommendations[currentIndex];

  return (
    <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
      <Header />
      <div className="flex flex-col items-center justify-center min-h-[80vh] p-4">
        <RecommendationCard
          recommendation={currentRec}
          onSwipe={handleSwipe}
        />
      </div>
    </div>
  );
}

Step 2: Create Recommendation Card Component

Install dependencies first:

npm install react-swipeable

Create src/components/bookdate/RecommendationCard.tsx:

'use client';

import { useState } from 'react';
import Image from 'next/image';
import { useSwipeable } from 'react-swipeable';

interface RecommendationCardProps {
  recommendation: any;
  onSwipe: (action: 'left' | 'right' | 'up', markedAsKnown?: boolean) => void;
}

export function RecommendationCard({ recommendation, onSwipe }: RecommendationCardProps) {
  const [showToast, setShowToast] = useState(false);
  const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });

  const handleSwipeRight = () => {
    setShowToast(true);
  };

  const handleToastAction = (action: 'request' | 'known' | 'cancel') => {
    setShowToast(false);
    if (action === 'request') {
      onSwipe('right', false);
    } else if (action === 'known') {
      onSwipe('right', true);
    }
  };

  const swipeHandlers = useSwipeable({
    onSwipedLeft: () => onSwipe('left'),
    onSwipedRight: handleSwipeRight,
    onSwipedUp: () => onSwipe('up'),
    onSwiping: (eventData) => {
      setDragOffset({ x: eventData.deltaX, y: eventData.deltaY });
    },
    trackMouse: true
  });

  return (
    <>
      <div
        {...swipeHandlers}
        className="relative w-full max-w-md bg-white dark:bg-gray-800 rounded-2xl shadow-2xl overflow-hidden transition-transform"
        style={{
          transform: `translate(${dragOffset.x}px, ${dragOffset.y}px)`,
          transition: dragOffset.x === 0 ? 'transform 0.3s' : 'none'
        }}
      >
        {/* Drag overlay indicators */}
        {dragOffset.x > 50 && (
          <div className="absolute inset-0 bg-green-500 bg-opacity-30 flex items-center justify-center">
            <span className="text-6xl"></span>
          </div>
        )}
        {dragOffset.x < -50 && (
          <div className="absolute inset-0 bg-red-500 bg-opacity-30 flex items-center justify-center">
            <span className="text-6xl"></span>
          </div>
        )}
        {dragOffset.y < -50 && (
          <div className="absolute inset-0 bg-blue-500 bg-opacity-30 flex items-center justify-center">
            <span className="text-6xl">⬆️</span>
          </div>
        )}

        {/* Cover image */}
        <div className="w-full h-96 relative bg-gray-200 dark:bg-gray-700">
          {recommendation.coverUrl ? (
            <Image
              src={recommendation.coverUrl}
              alt={recommendation.title}
              fill
              className="object-cover"
            />
          ) : (
            <div className="w-full h-full flex items-center justify-center">
              <span className="text-6xl">📚</span>
            </div>
          )}
        </div>

        {/* Book info */}
        <div className="p-6">
          <h3 className="text-2xl font-bold mb-2 text-gray-900 dark:text-white">
            {recommendation.title}
          </h3>
          <p className="text-gray-600 dark:text-gray-400 mb-1">{recommendation.author}</p>
          {recommendation.narrator && (
            <p className="text-sm text-gray-500 mb-3">
              Narrated by {recommendation.narrator}
            </p>
          )}
          {recommendation.rating && (
            <div className="flex items-center mb-3">
              <span className="text-yellow-500"></span>
              <span className="ml-1 text-gray-700 dark:text-gray-300">
                {recommendation.rating}
              </span>
            </div>
          )}
          {recommendation.description && (
            <p className="text-sm text-gray-700 dark:text-gray-300 line-clamp-4">
              {recommendation.description}
            </p>
          )}
          {recommendation.aiReason && (
            <p className="text-xs text-blue-600 dark:text-blue-400 mt-3 italic">
              {recommendation.aiReason}
            </p>
          )}
        </div>

        {/* Desktop buttons */}
        <div className="hidden md:flex justify-center gap-4 p-6 border-t border-gray-200 dark:border-gray-700">
          <button
            onClick={() => onSwipe('left')}
            className="px-6 py-3 bg-red-500 hover:bg-red-600 text-white rounded-full transition-colors"
          >
             Not Interested
          </button>
          <button
            onClick={() => onSwipe('up')}
            className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-full transition-colors"
          >
            ⬆️ Dismiss
          </button>
          <button
            onClick={handleSwipeRight}
            className="px-6 py-3 bg-green-500 hover:bg-green-600 text-white rounded-full transition-colors"
          >
             Request
          </button>
        </div>
      </div>

      {/* Confirmation Toast */}
      {showToast && (
        <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
          <div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md mx-4">
            <h3 className="text-xl font-bold mb-4">Request "{recommendation.title}"?</h3>
            <p className="text-gray-600 dark:text-gray-400 mb-6">
              Do you want to request this audiobook, or have you already read/listened to it elsewhere?
            </p>
            <div className="flex gap-3">
              <button
                onClick={() => handleToastAction('known')}
                className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
              >
                Mark as Known
              </button>
              <button
                onClick={() => handleToastAction('request')}
                className="flex-1 px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg"
              >
                Request
              </button>
              <button
                onClick={() => handleToastAction('cancel')}
                className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700"
              >
                Cancel
              </button>
            </div>
          </div>
        </div>
      )}
    </>
  );
}

Step 3: Create Loading Screen Component

Create src/components/bookdate/LoadingScreen.tsx:

export function LoadingScreen() {
  return (
    <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-900">
      <div className="relative w-64 h-96 mb-8">
        {/* Animated book cards */}
        <div className="absolute inset-0 bg-gradient-to-br from-blue-500 to-purple-500 rounded-lg animate-pulse" />
        <div
          className="absolute inset-0 bg-gradient-to-br from-green-500 to-teal-500 rounded-lg animate-bounce"
          style={{ animationDelay: '0.2s' }}
        />
        <div
          className="absolute inset-0 bg-gradient-to-br from-orange-500 to-pink-500 rounded-lg animate-ping"
          style={{ animationDelay: '0.4s' }}
        />
      </div>
      <p className="text-xl text-gray-600 dark:text-gray-400">
        Finding your next great listen...
      </p>
    </div>
  );
}

Modify your main navigation component (likely src/components/layout/Header.tsx):

// Add to navigation links
const [showBookDate, setShowBookDate] = useState(false);

useEffect(() => {
  async function checkBookDate() {
    const accessToken = localStorage.getItem('accessToken');
    const response = await fetch('/api/bookdate/config', {
      headers: { 'Authorization': `Bearer ${accessToken}` }
    });
    const data = await response.json();
    setShowBookDate(data.config && data.config.isVerified && data.config.isEnabled);
  }
  checkBookDate();
}, []);

// In your navigation JSX:
{showBookDate && (
  <Link href="/bookdate" className="...">
    BookDate
  </Link>
)}

🧪 Testing Checklist

Initial Setup

  • Run npm install react-swipeable
  • Build Docker: docker-compose build
  • Start: docker-compose up -d
  • Check logs: docker-compose logs -f app

Feature Testing

  1. Setup Wizard

    • Complete wizard with BookDate config
    • Skip BookDate and continue
    • Verify config saved in database
  2. Settings Page

    • Navigate to /settings
    • Test OpenAI connection
    • Test Claude connection
    • Save configuration
    • Update existing configuration
    • Clear swipe history
  3. BookDate Tab

    • Verify tab visible after config
    • Verify tab hidden without config
    • Navigate to /bookdate
  4. Recommendations

    • View loading screen
    • See first recommendation
    • Swipe left (reject)
    • Swipe right (request - confirm)
    • Swipe up (dismiss)
    • Test undo button
    • Reach end of recommendations
    • Click "Get More"
  5. Integration

    • Right swipe creates request in /requests
    • Request status updates correctly
    • Recommendations exclude library books
    • Recommendations improve with swipes

Error Scenarios

  • Invalid API key
  • Network error during generation
  • No Audnexus matches
  • Empty Plex library
  • All recommendations filtered out

📝 Documentation to Update

After testing, update:

  1. TABLEOFCONTENTS.md

    ## BookDate (AI Recommendations)
    - **AI-powered recommendations, swipe interface** → features/bookdate.md
    - **Configuration, setup wizard integration** → features/bookdate.md
    
  2. Create documentation/features/bookdate.md (Token-efficient format summarizing the feature)


🚀 Deployment Notes

Environment Variables (already in docker-compose.yml)

CONFIG_ENCRYPTION_KEY: Z7vRDVuimy/oqPj9OB6pd/FLUzOTcTH9wlTrvETkVec=

Database Migration

Schema changes automatically applied on container start via prisma db push.

API Rate Limits

  • OpenAI: ~3500 RPM (requests per minute) for most models
  • Claude: ~4000 RPM
  • Consider adding rate limiting if needed

💡 Future Enhancements (Post-MVP)

  • Multi-AI voting (aggregate multiple AI recommendations)
  • Advanced filtering (exclude genres, narrator preferences)
  • Swipe analytics dashboard
  • Social features (see friends' swipes)
  • Recommendation explanations (show AI reasoning)
  • Listening goals ("Find books under 10 hours")
  • Better Plex integration (real-time listening status)
  • Direct Audnexus API integration (beyond cache)

MVP Definition

MVP is complete when:

  • Database schema deployed
  • All API endpoints working
  • Setup wizard includes BookDate
  • Settings page functional
  • 🚧 BookDate tab visible when configured
  • 🚧 Swipe interface works (mobile + desktop)
  • 🚧 Right swipe creates requests
  • 🚧 Recommendations cache correctly
  • 🚧 Dark mode supported
  • 🚧 Error states handled

Current Status: ~70% Complete

Completed: Backend, Database, Setup, Settings Remaining: Main UI, Testing, Documentation