Personal Best (PB) System Documentation
Overview
The Personal Best (PB) system allows users to define custom PB criteria and automatically track when they beat their records. The system integrates with catch verification and can automatically create social posts when a PB is achieved.
Core Entities
PersonalBestCriteria
Defines what counts as a Personal Best for a user. Users can create multiple criteria (e.g., "Largest Chinook by weight", "Longest Walleye", "Heaviest Bass").
Key Features:
- Species Filter: Optional - can be species-specific or all species
- Variant Handling: Control whether to include subspecies, hybrids, life stages, and related species
IncludeVariants: Include subspecies/variants (e.g., Redear Sunfish counts for Bluegill PB)IncludeHybrids: Include hybrids (e.g., Hybrid Bluegill counts for Bluegill PB)IncludeLifeStages: Include life stages (e.g., Steelhead counts for Rainbow Trout PB)IncludeRelatedSpecies: Include closely related species (same genus/family)
- Measurement Type: Weight, Length, Girth, Fork Length, etc.
- Comparison Type: Largest, Longest, Heaviest, etc.
- Additional Filters: Location, method, year (for annual PBs)
- Auto-Detection: Automatically detect when PB is beaten
- Verification: Optional requirement for catch verification
- Auto-Post: Automatically create post when PB is achieved
PersonalBest
Represents an actual PB catch record.
Key Features:
- Links to
FishingLogEntryandCatchDetail - Stores PB measurements (weight, length, girth)
- Tracks improvement over previous PB
- Links to catch verification (if required)
- Can create automatic social post
- Supports state/world records
- Ultimate PB:
IsUltimatePbflag marks the user's most important PB (featured on profile)
Measurement Types
PersonalBestMeasurementType Enum
Weight: Weight-based PB (lbs/kg)Length: Length-based PB (inches/cm)Girth: Girth/circumference PBTotalLength: Total length PBForkLength: Fork length PBStandardLength: Standard length PBCombined: Combined measurementCustom: User-defined measurement
Comparison Types
PersonalBestComparisonType Enum
Largest: Largest (general)Longest: Longest (for length)Heaviest: Heaviest (for weight)Smallest,Shortest,Lightest: For competitionsMost,Least: For count-based PBsBest: User-defined best
Usage Examples
Creating PB Criteria
// Largest Chinook Salmon by weight (includes all variants)
var chinookPbCriteria = new PersonalBestCriteria
{
UserId = userId,
Name = "Largest Chinook Salmon",
Description = "Heaviest Chinook Salmon I've caught",
FishSpeciesId = chinookSpeciesId,
MeasurementType = PersonalBestMeasurementType.Weight,
ComparisonType = PersonalBestComparisonType.Heaviest,
IncludeVariants = true, // Include subspecies/variants
IncludeHybrids = true, // Include hybrids
IncludeLifeStages = true, // Include life stages
IncludeRelatedSpecies = false, // Don't include related species
AutoDetect = true,
RequireVerification = false,
AutoCreatePost = true,
PostTemplate = "Just caught my PB Chinook Salmon! {Weight} lbs! 🎣",
IsLifetime = true,
IsActive = true
};
// Longest Rainbow Trout (includes Steelhead as life stage)
var rainbowTroutPbCriteria = new PersonalBestCriteria
{
UserId = userId,
Name = "Longest Rainbow Trout",
Description = "Longest Rainbow Trout (including Steelhead)",
FishSpeciesId = rainbowTroutSpeciesId,
MeasurementType = PersonalBestMeasurementType.Length,
ComparisonType = PersonalBestComparisonType.Longest,
IncludeVariants = true,
IncludeHybrids = true,
IncludeLifeStages = true, // Steelhead counts as Rainbow Trout PB
IncludeRelatedSpecies = false,
AutoDetect = true,
IsLifetime = true
};
// Largest Bluegill (species-specific only, no variants)
var bluegillPbCriteria = new PersonalBestCriteria
{
UserId = userId,
Name = "Largest Bluegill",
Description = "Largest Bluegill only (not including Redear Sunfish)",
FishSpeciesId = bluegillSpeciesId,
MeasurementType = PersonalBestMeasurementType.Weight,
ComparisonType = PersonalBestComparisonType.Heaviest,
IncludeVariants = false, // Don't include Redear Sunfish
IncludeHybrids = false, // Don't include Hybrid Bluegill
IncludeLifeStages = true,
IncludeRelatedSpecies = false,
AutoDetect = true,
IsLifetime = true
};
// Longest Walleye (all time)
var walleyePbCriteria = new PersonalBestCriteria
{
UserId = userId,
Name = "Longest Walleye",
Description = "Longest Walleye I've caught",
FishSpeciesId = walleyeSpeciesId,
MeasurementType = PersonalBestMeasurementType.Length,
ComparisonType = PersonalBestComparisonType.Longest,
AutoDetect = true,
RequireVerification = true, // Require verification for this PB
AutoCreatePost = true,
IsLifetime = true
};
// Annual PB (resets each year)
var annualBassPbCriteria = new PersonalBestCriteria
{
UserId = userId,
Name = "2024 Largest Bass",
Description = "Largest bass caught in 2024",
FishSpeciesId = bassSpeciesId,
MeasurementType = PersonalBestMeasurementType.Weight,
ComparisonType = PersonalBestComparisonType.Heaviest,
YearFilter = 2024,
IsLifetime = false, // Annual PB
AutoDetect = true
};
context.PersonalBestCriteria.AddRange(chinookPbCriteria, walleyePbCriteria, annualBassPbCriteria);
context.SaveChanges();
Automatic PB Detection
// When a catch is logged, check if it beats any PB criteria
public async Task CheckPersonalBests(Guid userId, Guid fishingLogEntryId, Guid catchDetailId)
{
var catchDetail = await context.CatchDetails
.Include(cd => cd.FishSpecies)
.Include(cd => cd.FishingLogEntry)
.FirstOrDefaultAsync(cd => cd.Id == catchDetailId);
if (catchDetail == null) return;
// Get all active PB criteria for this user
var criteria = await context.PersonalBestCriteria
.Where(pbc => pbc.UserId == userId && pbc.IsActive && pbc.AutoDetect)
.ToListAsync();
foreach (var criterion in criteria)
{
// Check if this catch matches the criteria
if (!await MatchesCriteriaAsync(catchDetail, criterion, context))
continue;
// Check if verification is required
if (criterion.RequireVerification)
{
var verification = await context.CatchVerifications
.FirstOrDefaultAsync(cv => cv.FishingLogEntryId == catchDetail.FishingLogEntryId);
if (verification == null || verification.Status != VerificationStatus.Verified)
continue; // Skip if not verified
}
// Get current PB for this criteria
var currentPb = criterion.CurrentPersonalBest;
// Check if this catch beats the current PB
if (BeatsPersonalBest(catchDetail, criterion, currentPb))
{
await CreatePersonalBest(userId, catchDetail, criterion, currentPb);
}
}
}
private async Task<bool> MatchesCriteriaAsync(CatchDetail catchDetail, PersonalBestCriteria criterion, AppDbContext context)
{
// Check species filter
if (criterion.FishSpeciesId.HasValue)
{
var catchSpeciesId = catchDetail.FishSpeciesId;
// Exact match
if (catchSpeciesId == criterion.FishSpeciesId)
{
// Matches exactly - continue to other filters
}
// Check variants if enabled
else if (criterion.IncludeVariants || criterion.IncludeHybrids || criterion.IncludeLifeStages || criterion.IncludeRelatedSpecies)
{
var matches = false;
// Check if catch species is a variant/subspecies of the criteria species
if (criterion.IncludeVariants)
{
var isVariant = await context.FishSpeciesRelationships
.AnyAsync(r => r.SourceSpeciesId == catchSpeciesId
&& r.TargetSpeciesId == criterion.FishSpeciesId
&& (r.RelationshipType == SpeciesRelationshipType.Subspecies
|| r.RelationshipType == SpeciesRelationshipType.Variant
|| r.RelationshipType == SpeciesRelationshipType.ParentSpecies));
if (isVariant) matches = true;
}
// Check if catch species is a hybrid of the criteria species
if (!matches && criterion.IncludeHybrids)
{
var isHybrid = await context.FishSpeciesRelationships
.AnyAsync(r => r.SourceSpeciesId == catchSpeciesId
&& r.TargetSpeciesId == criterion.FishSpeciesId
&& (r.RelationshipType == SpeciesRelationshipType.Hybrid
|| r.RelationshipType == SpeciesRelationshipType.HybridParent));
if (isHybrid) matches = true;
}
// Check if catch species is a life stage of the criteria species
if (!matches && criterion.IncludeLifeStages)
{
var isLifeStage = await context.FishSpeciesRelationships
.AnyAsync(r => r.SourceSpeciesId == catchSpeciesId
&& r.TargetSpeciesId == criterion.FishSpeciesId
&& (r.RelationshipType == SpeciesRelationshipType.LifeStage
|| r.RelationshipType == SpeciesRelationshipType.JuvenileForm
|| r.RelationshipType == SpeciesRelationshipType.AdultForm));
if (isLifeStage) matches = true;
}
// Check if catch species is related to the criteria species
if (!matches && criterion.IncludeRelatedSpecies)
{
var isRelated = await context.FishSpeciesRelationships
.AnyAsync(r => r.SourceSpeciesId == catchSpeciesId
&& r.TargetSpeciesId == criterion.FishSpeciesId
&& (r.RelationshipType == SpeciesRelationshipType.RelatedSpecies
|| r.RelationshipType == SpeciesRelationshipType.SimilarSpecies));
if (isRelated) matches = true;
}
if (!matches) return false;
}
else
{
return false; // Doesn't match and variants not included
}
}
// Check location filter
if (!string.IsNullOrEmpty(criterion.LocationFilter))
{
var locationName = catchDetail.FishingLogEntry.LocationName;
if (locationName == null || !locationName.Contains(criterion.LocationFilter))
return false;
}
// Check method filter
if (!string.IsNullOrEmpty(criterion.MethodFilter))
{
var method = catchDetail.FishingLogEntry.FishingMethod.ToString();
if (method != criterion.MethodFilter)
return false;
}
// Check year filter
if (criterion.YearFilter.HasValue)
{
var catchYear = catchDetail.FishingLogEntry.Date.Year;
if (catchYear != criterion.YearFilter.Value)
return false;
}
return true;
}
private bool BeatsPersonalBest(CatchDetail catchDetail, PersonalBestCriteria criterion, PersonalBest? currentPb)
{
if (currentPb == null)
return true; // No current PB, so this is automatically a PB
float currentValue = currentPb.PbValue;
float newValue = GetMeasurementValue(catchDetail, criterion.MeasurementType);
// Compare based on comparison type
switch (criterion.ComparisonType)
{
case PersonalBestComparisonType.Largest:
case PersonalBestComparisonType.Longest:
case PersonalBestComparisonType.Heaviest:
case PersonalBestComparisonType.Biggest:
return newValue > currentValue;
case PersonalBestComparisonType.Smallest:
case PersonalBestComparisonType.Shortest:
case PersonalBestComparisonType.Lightest:
return newValue < currentValue;
default:
return newValue > currentValue; // Default to larger is better
}
}
private float GetMeasurementValue(CatchDetail catchDetail, PersonalBestMeasurementType measurementType)
{
return measurementType switch
{
PersonalBestMeasurementType.Weight => catchDetail.WeightKg * 2.20462f, // Convert to lbs
PersonalBestMeasurementType.Length => catchDetail.LengthCm / 2.54f, // Convert to inches
PersonalBestMeasurementType.Girth => catchDetail.GirthCm / 2.54f, // Convert to inches
_ => 0f
};
}
Creating a Personal Best
private async Task CreatePersonalBest(Guid userId, CatchDetail catchDetail, PersonalBestCriteria criterion, PersonalBest? previousPb)
{
var pbValue = GetMeasurementValue(catchDetail, criterion.MeasurementType);
var pbUnit = GetMeasurementUnit(criterion.MeasurementType);
var personalBest = new PersonalBest
{
UserId = userId,
PersonalBestCriteriaId = criterion.Id,
FishingLogEntryId = catchDetail.FishingLogEntryId,
CatchDetailId = catchDetail.Id,
WeightLbs = catchDetail.WeightKg * 2.20462f,
WeightKg = catchDetail.WeightKg,
LengthInches = catchDetail.LengthCm / 2.54f,
LengthCm = catchDetail.LengthCm,
GirthInches = catchDetail.GirthCm / 2.54f,
GirthCm = catchDetail.GirthCm,
PbValue = pbValue,
PbUnit = pbUnit,
PreviousPersonalBestId = previousPb?.Id,
CaughtAt = catchDetail.FishingLogEntry.CaughtAt,
IsPublic = true
};
// Calculate improvement
if (previousPb != null)
{
personalBest.ImprovementAmount = pbValue - previousPb.PbValue;
personalBest.ImprovementUnit = pbUnit;
personalBest.ImprovementPercentage = (personalBest.ImprovementAmount / previousPb.PbValue) * 100f;
}
// Link verification if exists
var verification = await context.CatchVerifications
.FirstOrDefaultAsync(cv => cv.FishingLogEntryId == catchDetail.FishingLogEntryId);
if (verification != null)
{
personalBest.CatchVerificationId = verification.Id;
personalBest.IsVerified = verification.Status == VerificationStatus.Verified;
}
context.PersonalBests.Add(personalBest);
// Update criteria's current PB
criterion.CurrentPersonalBestId = personalBest.Id;
await context.SaveChangesAsync();
// Create automatic post if enabled
if (criterion.AutoCreatePost)
{
await CreatePbPost(personalBest, criterion);
}
}
Creating Automatic PB Post
private async Task CreatePbPost(PersonalBest personalBest, PersonalBestCriteria criterion)
{
var catchDetail = await context.CatchDetails
.Include(cd => cd.FishSpecies)
.Include(cd => cd.FishingLogEntry)
.FirstOrDefaultAsync(cd => cd.Id == personalBest.CatchDetailId);
// Use custom template or default
var postText = criterion.PostTemplate ??
$"Just caught my PB {catchDetail.FishSpecies.CommonName}! {personalBest.PbValue:F2} {personalBest.PbUnit}! 🎣";
// Add improvement if exists
if (personalBest.ImprovementAmount.HasValue && personalBest.ImprovementAmount > 0)
{
postText += $" Beat my previous PB by {personalBest.ImprovementAmount:F2} {personalBest.ImprovementUnit}!";
}
var post = new Post
{
UserId = personalBest.UserId,
Content = postText,
Visibility = PostVisibility.Public,
FishingLogEntryId = personalBest.FishingLogEntryId
};
context.Posts.Add(post);
await context.SaveChangesAsync();
// Link post to PB
personalBest.PostId = post.Id;
personalBest.HasPost = true;
await context.SaveChangesAsync();
}
Setting Ultimate PB
// Set a PB as the user's Ultimate PB (most important one)
public async Task SetUltimatePb(Guid userId, int personalBestId)
{
// First, unset any existing Ultimate PB
var existingUltimate = await context.PersonalBests
.Where(pb => pb.UserId == userId && pb.IsUltimatePb)
.ToListAsync();
foreach (var pb in existingUltimate)
{
pb.IsUltimatePb = false;
}
// Set the new Ultimate PB
var newUltimate = await context.PersonalBests
.FirstOrDefaultAsync(pb => pb.Id == personalBestId && pb.UserId == userId);
if (newUltimate != null)
{
newUltimate.IsUltimatePb = true;
// Update UserProfile
var userProfile = await context.UserProfiles
.FirstOrDefaultAsync(up => up.UserId == userId);
if (userProfile != null)
{
userProfile.UltimatePersonalBestId = personalBestId;
}
await context.SaveChangesAsync();
}
}
Querying Personal Bests
// Get all PBs for a user
var userPbs = await context.PersonalBests
.Where(pb => pb.UserId == userId)
.Include(pb => pb.PersonalBestCriteria)
.ThenInclude(pbc => pbc.FishSpecies)
.Include(pb => pb.CatchDetail)
.ThenInclude(cd => cd.FishSpecies)
.Include(pb => pb.FishingLogEntry)
.OrderByDescending(pb => pb.CaughtAt)
.ToListAsync();
// Get user's Ultimate PB
var ultimatePb = await context.PersonalBests
.Where(pb => pb.UserId == userId && pb.IsUltimatePb)
.Include(pb => pb.PersonalBestCriteria)
.ThenInclude(pbc => pbc.FishSpecies)
.Include(pb => pb.CatchDetail)
.ThenInclude(cd => cd.FishSpecies)
.Include(pb => pb.FishingLogEntry)
.FirstOrDefaultAsync();
// Get PBs per species (including variants)
var chinookPbs = await context.PersonalBests
.Where(pb => pb.UserId == userId)
.Include(pb => pb.CatchDetail)
.Where(pb => pb.CatchDetail.FishSpeciesId == chinookSpeciesId
|| context.FishSpeciesRelationships.Any(r =>
r.SourceSpeciesId == pb.CatchDetail.FishSpeciesId
&& r.TargetSpeciesId == chinookSpeciesId
&& (r.RelationshipType == SpeciesRelationshipType.LifeStage
|| r.RelationshipType == SpeciesRelationshipType.Subspecies)))
.OrderByDescending(pb => pb.PbValue)
.ToListAsync();
// Get current PBs (one per criteria)
var currentPbs = await context.PersonalBestCriteria
.Where(pbc => pbc.UserId == userId && pbc.IsActive)
.Include(pbc => pbc.CurrentPersonalBest)
.ThenInclude(pb => pb.CatchDetail)
.ThenInclude(cd => cd.FishSpecies)
.Where(pbc => pbc.CurrentPersonalBest != null)
.ToListAsync();
// Get PBs for a specific species
var chinookPbs = await context.PersonalBests
.Where(pb => pb.UserId == userId)
.Include(pb => pb.CatchDetail)
.Where(pb => pb.CatchDetail.FishSpeciesId == chinookSpeciesId)
.OrderByDescending(pb => pb.PbValue)
.ToListAsync();
Integration with AI Verification
The PB system integrates seamlessly with catch verification:
- Verification Requirement: Criteria can require verification before counting as PB
- Auto-Detection: After verification, system checks if catch beats PB
- Verified Badge: PBs can show verification status
- Record Claims: Verified PBs can be submitted for state/world records
Benefits
- Customizable: Users define what "PB" means to them
- Automatic: Auto-detection when catches beat PBs
- Flexible: Multiple criteria, species-specific, annual PBs
- Social: Automatic post creation for achievements
- Verification: Optional verification requirement
- Tracking: Track improvement over previous PBs
- Records: Support for state/world records
Future Enhancements
- PB Challenges: Challenge friends to beat your PB
- PB Leaderboards: Compare PBs with other anglers
- PB Badges: Achievement badges for PBs
- PB Analytics: Track PB trends over time
- PB Notifications: Notify when someone beats your PB
- PB Stories: Rich storytelling for PB catches