Skip to main content

Overview

EpiNeko includes a scoring system that allows users to rate anime in their library on a scale of 0-10, matching the MyAnimeList rating standard. Scores help you remember which series you enjoyed most and provide personal ratings for your collection.

0-10 Scale

Rate anime using the standard 0-10 point scale

Optional Rating

Scoring is optional - rate only what you want

Database Validated

Scores validated at database level for integrity

Library Integration

Scores stored with your library items

Score Field

The score is an optional integer field in library items:
export interface LibraryItem {
  id?: string;
  user_id?: string;
  anime_id_jikan: number;
  title: string;
  image_url?: string;
  status: LibraryStatus;
  score?: number;              // Rating from 0-10
  episodes_watched?: number;
}
Database Schema (supabase/migrations/20260218_initial_schema.sql:54):
create table public.user_library (
  -- ...
  score integer check (score >= 0 and score <= 10),
  -- ...
);
The database constraint ensures scores are always between 0 and 10 inclusive. Invalid scores are rejected at the database level.

Rating Scale

EpiNeko uses the standard 0-10 rating scale:
ScoreRatingDescription
10MasterpiecePerfect in every way
9GreatExceptional quality
8Very GoodHighly enjoyable
7GoodWorth watching
6FineDecent but flawed
5AverageNeither good nor bad
4BadBelow average
3Very BadSignificant problems
2HorribleAlmost unwatchable
1AppallingCompletely terrible
0UnratedNot scored yet
This scale matches MyAnimeList’s rating system, making it familiar to anime fans and allowing for easy comparison.

Adding Scores

Via updateLibraryItem

Add or update a score for any anime in your library:
import { updateLibraryItem } from '@/services/library';

// Add score to existing library item
await updateLibraryItem(52991, { 
  score: 9 
});

// Update score along with status
await updateLibraryItem(52991, { 
  status: 'completed',
  score: 9,
  episodes_watched: 28
});

When Adding to Library

Include score when first adding anime:
import { addToLibrary, LibraryItem } from '@/services/library';

const item: LibraryItem = {
  anime_id_jikan: 52991,
  title: "Sousou no Frieren",
  image_url: "https://cdn.myanimelist.net/images/anime/...",
  status: 'completed',
  episodes_watched: 28,
  score: 9  // Initial rating
};

await addToLibrary(item);

Score Without Completing

You can score anime even if not completed:
// Rate after dropping
await updateLibraryItem(animeId, {
  status: 'dropped',
  episodes_watched: 5,
  score: 4  // Rate what you watched
});

// Rate while watching
await updateLibraryItem(animeId, {
  status: 'watching',
  episodes_watched: 12,
  score: 8  // Rate based on episodes so far
});
While you can score incomplete anime, it’s recommended to score only completed series for the most accurate ratings.

Removing Scores

Set score to null or undefined to remove:
// Remove score
await updateLibraryItem(animeId, { 
  score: null 
});

// Or omit score field entirely
await updateLibraryItem(animeId, { 
  status: 'watching' 
  // score remains unchanged
});

Displaying Scores

In Library Cards

Show user’s personal score on library anime cards:
import AnimeCard from '@/components/anime/AnimeCard';

<AnimeCard 
  mal_id={item.anime_id_jikan}
  image={item.image_url}
  title={item.title}
  score={item.score?.toString()}  // Personal score
/>
AnimeCard Component (src/components/anime/AnimeCard.tsx:31-35):
{score && (
  <div className="absolute top-3 right-3 bg-black/60 backdrop-blur-md 
                  px-2 py-1 rounded-lg border border-white/10">
    <span className="text-yellow-400 text-xs font-bold">
{score}
    </span>
  </div>
)}

MAL Score vs Personal Score

Distinguish between MyAnimeList’s score and user’s personal score:
import { JikanAnime } from '@/services/jikan';

interface AnimeDisplay {
  malScore: number | null;      // From Jikan API
  personalScore?: number;       // From user's library
}

// Display both
console.log(`MAL Score: ${anime.score ?? 'N/A'}`);
console.log(`Your Score: ${libraryItem.score ?? 'Not rated'}`);
Example Display:
<div className="scores">
  <div className="mal-score">
    <span className="label">MAL Average</span>
    <span className="value">{anime.score || 'N/A'}</span>
  </div>
  
  {libraryItem?.score && (
    <div className="personal-score">
      <span className="label">Your Rating</span>
      <span className="value">{libraryItem.score}</span>
    </div>
  )}
</div>

Score Input Components

Simple Dropdown

Basic score selector:
<select 
  value={score || 0}
  onChange={(e) => handleScoreChange(Number(e.target.value))}
>
  <option value={0}>Not Rated</option>
  {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => (
    <option key={n} value={n}>{n} - {getRatingLabel(n)}</option>
  ))}
</select>

Star Rating Input

Interactive star-based rating:
const StarRating = ({ value, onChange }: { value: number, onChange: (n: number) => void }) => {
  return (
    <div className="flex gap-1">
      {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(n => (
        <button
          key={n}
          onClick={() => onChange(n)}
          className={n <= value ? 'text-yellow-400' : 'text-zinc-600'}
        >

        </button>
      ))}
    </div>
  );
};

Slider Input

Slider for granular control:
<input
  type="range"
  min="0"
  max="10"
  step="1"
  value={score || 0}
  onChange={(e) => handleScoreChange(Number(e.target.value))}
  className="range range-primary"
/>
<div className="text-center text-2xl font-bold">
  {score ? `★ ${score}` : 'Not Rated'}
</div>

Score Statistics

Calculate statistics from your library scores:
import { getLibrary } from '@/services/library';

const library = await getLibrary();

// Filter scored items
const scoredItems = library.filter(item => item.score !== null && item.score !== undefined);

// Calculate average score
const averageScore = scoredItems.reduce((sum, item) => sum + (item.score || 0), 0) / scoredItems.length;

console.log(`Average Score: ${averageScore.toFixed(2)}`);
console.log(`Rated: ${scoredItems.length} / ${library.length}`);

Score Distribution

Analyze your rating patterns:
const scoreDistribution = {
  masterpiece: library.filter(i => i.score === 10).length,
  great: library.filter(i => i.score === 9).length,
  veryGood: library.filter(i => i.score === 8).length,
  good: library.filter(i => i.score === 7).length,
  fine: library.filter(i => i.score === 6).length,
  average: library.filter(i => i.score === 5).length,
  belowAverage: library.filter(i => i.score && i.score < 5).length,
};

// Find your highest rated anime
const topRated = library
  .filter(i => i.score)
  .sort((a, b) => (b.score || 0) - (a.score || 0))
  .slice(0, 10);

Visualization

Display score distribution chart:
const ScoreDistribution = ({ library }: { library: LibraryItem[] }) => {
  const scores = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
  
  return (
    <div className="space-y-2">
      {scores.map(score => {
        const count = library.filter(i => i.score === score).length;
        const percentage = (count / library.length) * 100;
        
        return (
          <div key={score} className="flex items-center gap-4">
            <span className="w-8 text-right font-bold">{score}</span>
            <div className="flex-1 h-6 bg-zinc-800 rounded-full overflow-hidden">
              <div 
                className="h-full bg-yellow-400"
                style={{ width: `${percentage}%` }}
              />
            </div>
            <span className="w-12 text-zinc-400">{count}</span>
          </div>
        );
      })}
    </div>
  );
};

Scoring Recommendations

Best Practices

Score After Completion

Rate anime after finishing for most accurate scores

Use Full Scale

Don’t be afraid to use the entire 0-10 range

Be Consistent

Develop personal criteria for each score level

Update Scores

Revisit and adjust scores as your tastes evolve

Personal Rating Guidelines

Develop your own rating criteria:
  • 10: Perfect narrative
  • 8-9: Excellent storytelling
  • 6-7: Good but flawed plot
  • 4-5: Weak story
  • 1-3: Poor narrative

Database Validation

Scores are validated at multiple levels:

Database Constraint

PostgreSQL check constraint:
score integer check (score >= 0 and score <= 10)
Behavior:
  • Rejects scores < 0
  • Rejects scores > 10
  • Accepts null (no score)
  • Accepts 0-10 inclusive

Application Validation

Add client-side validation:
const validateScore = (score: number | null | undefined): boolean => {
  if (score === null || score === undefined) return true; // Null is valid
  return score >= 0 && score <= 10 && Number.isInteger(score);
};

// Before saving
if (!validateScore(score)) {
  throw new Error('Score must be an integer between 0 and 10');
}

await updateLibraryItem(animeId, { score });

Type Safety

Use TypeScript for type safety:
type Score = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | null | undefined;

interface LibraryItem {
  // ...
  score?: Score;
}

Score-Based Features

Filtering by Score

Filter library by rating:
const library = await getLibrary();

// Highly rated anime (8+)
const highlyRated = library.filter(item => item.score && item.score >= 8);

// Low rated (< 5)
const lowRated = library.filter(item => item.score && item.score < 5);

// Unrated
const unrated = library.filter(item => !item.score);

Sorting by Score

Sort library by personal ratings:
// Highest rated first
const sortedByScore = library.sort((a, b) => 
  (b.score || 0) - (a.score || 0)
);

// Lowest rated first
const lowestFirst = library.sort((a, b) => 
  (a.score || 0) - (b.score || 0)
);

// Unrated first, then by score
const unratedFirst = library.sort((a, b) => {
  if (!a.score && !b.score) return 0;
  if (!a.score) return -1;
  if (!b.score) return 1;
  return b.score - a.score;
});

Recommendations

Find similar highly-rated anime:
const getRecommendations = async (animeId: number) => {
  const library = await getLibrary();
  const targetItem = library.find(i => i.anime_id_jikan === animeId);
  
  if (!targetItem?.score) return [];
  
  // Find anime with similar scores
  return library.filter(item => 
    item.anime_id_jikan !== animeId &&
    item.score &&
    Math.abs(item.score - targetItem.score) <= 1
  );
};

Comparing Scores

MAL Score Comparison

Compare your rating to MAL average:
interface ScoreComparison {
  anime: JikanAnime;
  malScore: number | null;
  personalScore: number | null;
  difference: number | null;
}

const compareScores = (anime: JikanAnime, libraryItem?: LibraryItem): ScoreComparison => {
  const malScore = anime.score;
  const personalScore = libraryItem?.score || null;
  
  const difference = malScore && personalScore 
    ? personalScore - malScore 
    : null;
  
  return {
    anime,
    malScore,
    personalScore,
    difference
  };
};

// Usage
const comparison = compareScores(anime, libraryItem);
console.log(`Your score is ${comparison.difference} points ${
  comparison.difference > 0 ? 'higher' : 'lower'
} than MAL average`);

Score Agreement Analysis

Analyze how often you agree with MAL:
const analyzeAgreement = (library: LibraryItem[], animeData: JikanAnime[]) => {
  let agreements = 0;
  let disagreements = 0;
  
  library.forEach(item => {
    const anime = animeData.find(a => a.mal_id === item.anime_id_jikan);
    if (!anime?.score || !item.score) return;
    
    const diff = Math.abs(item.score - anime.score);
    if (diff <= 1) {
      agreements++;
    } else {
      disagreements++;
    }
  });
  
  return {
    agreementRate: (agreements / (agreements + disagreements)) * 100,
    agreements,
    disagreements
  };
};

Export Scores

Export your ratings for backup or analysis:
const exportScores = async () => {
  const library = await getLibrary();
  
  const scores = library
    .filter(item => item.score)
    .map(item => ({
      animeId: item.anime_id_jikan,
      title: item.title,
      score: item.score,
      status: item.status
    }));
  
  // Convert to CSV
  const csv = [
    'Anime ID,Title,Score,Status',
    ...scores.map(s => `${s.animeId},"${s.title}",${s.score},${s.status}`)
  ].join('\n');
  
  return csv;
};