Add weekly consistency goals
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QuestionsApi.Services;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace QuestionsApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/weekly-goals")]
|
||||
[Authorize(Roles = "USER,SUPER")]
|
||||
public sealed class WeeklyGoalsController(
|
||||
WeeklyGoalStore goals, DashboardStore dashboard, QuestionStore questions) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var userId = UserId();
|
||||
var now = DateTime.UtcNow;
|
||||
return Ok(await goals.GetAsync(userId, await dashboard.GetAsync(userId, now),
|
||||
await questions.GetPublishedAsync()));
|
||||
}
|
||||
|
||||
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException();
|
||||
}
|
||||
@@ -111,6 +111,7 @@ public static class CreditReasonCodes
|
||||
public const string ExamSimulationComplete = "exam_simulation_complete";
|
||||
public const string SessionGoalComplete = "session_goal_complete";
|
||||
public const string DailyMissionComplete = "daily_mission_complete";
|
||||
public const string WeeklyGoalComplete = "weekly_goal_complete";
|
||||
public const string Purchase = "purchase";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace QuestionsApi.Models;
|
||||
|
||||
public static class WeeklyRemediationTypes
|
||||
{
|
||||
public const string Reviews = "reviews";
|
||||
public const string HardCorrect = "hard_correct";
|
||||
}
|
||||
|
||||
public sealed class WeeklyGoal
|
||||
{
|
||||
[BsonId] public string Id { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public DateTime WeekStartUtc { get; set; }
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
public string RemediationType { get; set; } = WeeklyRemediationTypes.Reviews;
|
||||
public int RemediationTarget { get; set; }
|
||||
public List<string> EligibleSystems { get; set; } = [];
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime? CompletedUtc { get; set; }
|
||||
public bool RewardIssued { get; set; }
|
||||
public int RewardAmount { get; set; }
|
||||
public bool ExpirationRecorded { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WeeklyGoalComponentResponse
|
||||
{
|
||||
public string Type { get; set; } = "";
|
||||
public string Label { get; set; } = "";
|
||||
public int Progress { get; set; }
|
||||
public int Target { get; set; }
|
||||
public bool Completed { get; set; }
|
||||
}
|
||||
|
||||
public sealed class WeeklyGoalResponse
|
||||
{
|
||||
public DateTime WeekStartUtc { get; set; }
|
||||
public DateTime ExpiresUtc { get; set; }
|
||||
public bool Completed { get; set; }
|
||||
public string RemediationType { get; set; } = "";
|
||||
public List<string> EligibleSystems { get; set; } = [];
|
||||
public string SuggestedSystem { get; set; } = "";
|
||||
public int RewardAmount { get; set; }
|
||||
public int RewardMaximum { get; set; } = 10;
|
||||
public List<WeeklyGoalComponentResponse> Components { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class WeeklyGoalEnvelope
|
||||
{
|
||||
public WeeklyGoalResponse State { get; set; } = new();
|
||||
public CreditStatusResponse? Credits { get; set; }
|
||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||
}
|
||||
@@ -113,6 +113,7 @@ builder.Services.AddSingleton<ReviewQueueStore>();
|
||||
builder.Services.AddSingleton<DashboardStore>();
|
||||
builder.Services.AddSingleton<StudySessionStore>();
|
||||
builder.Services.AddSingleton<DailyMissionStore>();
|
||||
builder.Services.AddSingleton<WeeklyGoalStore>();
|
||||
builder.Services.AddSingleton<UserProfileStore>();
|
||||
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
|
||||
builder.Services.AddSingleton<FlashcardDeckStore>();
|
||||
|
||||
@@ -178,6 +178,16 @@ public sealed class CreditStore
|
||||
return ToStatus(account);
|
||||
}
|
||||
|
||||
public async Task<CreditAward?> GetAppliedAwardAsync(string userId, string operationId)
|
||||
{
|
||||
var entry = await _ledger.Find(value => value.Id == LedgerId(userId, operationId)
|
||||
&& value.State == "applied").FirstOrDefaultAsync();
|
||||
return entry is null || entry.Amount <= 0 ? null : new CreditAward
|
||||
{
|
||||
Amount = entry.Amount, ReasonCode = entry.ReasonCode, Description = entry.Description
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<int> GetBankCycleAsync(string userId)
|
||||
{
|
||||
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime);
|
||||
|
||||
@@ -25,6 +25,10 @@ public sealed class ServiceMetrics
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionCompleted = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionExpired = new();
|
||||
private long _dailyMissionReplaced;
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _weeklyGoalAssigned = new();
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _weeklyGoalCompleted = new();
|
||||
private long _weeklyGoalExpiredIncomplete;
|
||||
private long _weeklyGoalLateFinalized;
|
||||
|
||||
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
|
||||
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
|
||||
@@ -62,6 +66,10 @@ public sealed class ServiceMetrics
|
||||
public void DailyMissionCompleted(string type) => _dailyMissionCompleted.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionExpired(string type) => _dailyMissionExpired.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void DailyMissionReplaced() => Interlocked.Increment(ref _dailyMissionReplaced);
|
||||
public void WeeklyGoalAssigned(string type) => _weeklyGoalAssigned.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void WeeklyGoalCompleted(string type) => _weeklyGoalCompleted.AddOrUpdate(type, 1, (_, value) => value + 1);
|
||||
public void WeeklyGoalExpiredIncomplete() => Interlocked.Increment(ref _weeklyGoalExpiredIncomplete);
|
||||
public void WeeklyGoalLateFinalized() => Interlocked.Increment(ref _weeklyGoalLateFinalized);
|
||||
|
||||
public string RenderPrometheus()
|
||||
{
|
||||
@@ -103,6 +111,15 @@ public sealed class ServiceMetrics
|
||||
AppendMissionSample(output, "step3_daily_missions_expired_incomplete_total", type, _dailyMissionExpired.GetValueOrDefault(type));
|
||||
}
|
||||
AppendCounter(output, "step3_daily_missions_replaced_total", Interlocked.Read(ref _dailyMissionReplaced));
|
||||
output.AppendLine("# TYPE step3_weekly_goals_assigned_total counter");
|
||||
output.AppendLine("# TYPE step3_weekly_goals_completed_total counter");
|
||||
foreach (var type in _weeklyGoalAssigned.Keys.Concat(_weeklyGoalCompleted.Keys).Distinct().Order())
|
||||
{
|
||||
AppendMissionSample(output, "step3_weekly_goals_assigned_total", type, _weeklyGoalAssigned.GetValueOrDefault(type));
|
||||
AppendMissionSample(output, "step3_weekly_goals_completed_total", type, _weeklyGoalCompleted.GetValueOrDefault(type));
|
||||
}
|
||||
AppendCounter(output, "step3_weekly_goals_expired_incomplete_total", Interlocked.Read(ref _weeklyGoalExpiredIncomplete));
|
||||
AppendCounter(output, "step3_weekly_goals_late_finalized_total", Interlocked.Read(ref _weeklyGoalLateFinalized));
|
||||
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
|
||||
output.Append("step3_http_request_duration_seconds_count ")
|
||||
.Append(Interlocked.Read(ref _httpRequests))
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
|
||||
"ExternalIdentities", "ExternalAuthTransactions",
|
||||
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
|
||||
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
|
||||
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets"
|
||||
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets", "WeeklyGoals"
|
||||
];
|
||||
|
||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
using MongoDB.Driver;
|
||||
using QuestionsApi.Models;
|
||||
|
||||
namespace QuestionsApi.Services;
|
||||
|
||||
public sealed class WeeklyGoalStore
|
||||
{
|
||||
private readonly IMongoCollection<WeeklyGoal> _goals;
|
||||
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
||||
private readonly IMongoCollection<QuestionReviewAttempt> _reviews;
|
||||
private readonly IMongoCollection<ReviewQueueItem> _queue;
|
||||
private readonly IMongoCollection<FlashcardReviewLog> _flashReviews;
|
||||
private readonly IMongoCollection<FlashcardSchedule> _flashSchedules;
|
||||
private readonly IMongoCollection<FlashcardNote> _flashNotes;
|
||||
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
|
||||
private readonly IMongoCollection<StudySession> _studySessions;
|
||||
private readonly IMongoCollection<ExamSimulation> _simulations;
|
||||
private readonly QuestionStore _questions;
|
||||
private readonly CreditStore _credits;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ServiceMetrics _metrics;
|
||||
|
||||
public WeeklyGoalStore(MongoDatabaseProvider mongo, QuestionStore questions, CreditStore credits,
|
||||
TimeProvider clock, ServiceMetrics metrics)
|
||||
{
|
||||
var db = mongo.Database;
|
||||
_goals = db.GetCollection<WeeklyGoal>("WeeklyGoals");
|
||||
_attempts = db.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||
_reviews = db.GetCollection<QuestionReviewAttempt>("QuestionReviewAttempts");
|
||||
_queue = db.GetCollection<ReviewQueueItem>("ReviewQueueItems");
|
||||
_flashReviews = db.GetCollection<FlashcardReviewLog>("FlashcardReviewLogs");
|
||||
_flashSchedules = db.GetCollection<FlashcardSchedule>("FlashcardSchedules");
|
||||
_flashNotes = db.GetCollection<FlashcardNote>("FlashcardNotes");
|
||||
_ccsAttempts = db.GetCollection<CcsAttempt>("CcsAttempts");
|
||||
_studySessions = db.GetCollection<StudySession>("StudySessions");
|
||||
_simulations = db.GetCollection<ExamSimulation>("ExamSimulations");
|
||||
_questions = questions;
|
||||
_credits = credits;
|
||||
_clock = clock;
|
||||
_metrics = metrics;
|
||||
_goals.Indexes.CreateOne(new CreateIndexModel<WeeklyGoal>(
|
||||
Builders<WeeklyGoal>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.WeekStartUtc),
|
||||
new CreateIndexOptions { Unique = true, Name = "uq_weekly_goal_user_start" }));
|
||||
}
|
||||
|
||||
public async Task<WeeklyGoalEnvelope> GetAsync(
|
||||
string userId, DashboardResponse dashboard, IReadOnlyCollection<Question> published)
|
||||
{
|
||||
var now = _clock.GetUtcNow().UtcDateTime;
|
||||
var awards = await FinalizeExpiredAsync(userId, now);
|
||||
var start = WeekStart(now);
|
||||
var goal = await _goals.Find(value => value.UserId == userId && value.WeekStartUtc == start)
|
||||
.FirstOrDefaultAsync();
|
||||
if (goal is null)
|
||||
{
|
||||
var reviewed = await _reviews.CountDocumentsAsync(value => value.UserId == userId && value.IsCorrect
|
||||
&& value.CreatedUtc >= start && value.CreatedUtc < start.AddDays(7));
|
||||
var pending = await _queue.CountDocumentsAsync(value => value.UserId == userId
|
||||
&& value.State == ReviewQueueStates.Pending);
|
||||
var availableReviews = checked((int)Math.Min(int.MaxValue, reviewed + pending));
|
||||
var remediation = ChooseRemediation(availableReviews);
|
||||
var eligible = SelectHardSystems(dashboard, published);
|
||||
goal = new WeeklyGoal
|
||||
{
|
||||
Id = $"{userId}|{start:yyyy-MM-dd}", UserId = userId, WeekStartUtc = start,
|
||||
ExpiresUtc = start.AddDays(7), CreatedUtc = now,
|
||||
RemediationType = remediation.Type, RemediationTarget = remediation.Target,
|
||||
EligibleSystems = eligible
|
||||
};
|
||||
try
|
||||
{
|
||||
await _goals.InsertOneAsync(goal);
|
||||
_metrics.WeeklyGoalAssigned(goal.RemediationType);
|
||||
}
|
||||
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
goal = await _goals.Find(value => value.UserId == userId && value.WeekStartUtc == start).FirstAsync();
|
||||
}
|
||||
}
|
||||
var evaluated = await EvaluateAsync(goal);
|
||||
awards.AddRange(evaluated.Awards);
|
||||
return new WeeklyGoalEnvelope
|
||||
{
|
||||
State = evaluated.Response, CreditAwards = awards,
|
||||
Credits = await _credits.GetStatusAsync(userId)
|
||||
};
|
||||
}
|
||||
|
||||
public static DateTime WeekStart(DateTime value)
|
||||
{
|
||||
var date = value.ToUniversalTime().Date;
|
||||
var offset = ((int)date.DayOfWeek + 6) % 7;
|
||||
return date.AddDays(-offset);
|
||||
}
|
||||
|
||||
public static List<string> SelectHardSystems(
|
||||
DashboardResponse dashboard, IReadOnlyCollection<Question> published)
|
||||
{
|
||||
var counts = published.Where(question => question.Difficulty >= 4)
|
||||
.GroupBy(Step3SystemCatalog.Normalize).Where(group => group.Key is not null)
|
||||
.ToDictionary(group => group.Key!, group => group.Count());
|
||||
var ordered = dashboard.Systems.Where(system => counts.GetValueOrDefault(system.System) > 0)
|
||||
.OrderBy(system => MasteryRank(system.MasteryState)).ThenBy(system => system.MasteryScore)
|
||||
.ThenBy(system => system.Difficulty4PlusSuccessful).Select(system => system.System).ToList();
|
||||
var result = new List<string>();
|
||||
var available = 0;
|
||||
foreach (var system in ordered)
|
||||
{
|
||||
result.Add(system);
|
||||
available += counts[system];
|
||||
if (available >= 5 || result.Count == 3) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public static (string Type, int Target) ChooseRemediation(int availableReviews) =>
|
||||
availableReviews >= 5
|
||||
? (WeeklyRemediationTypes.Reviews, Math.Min(15, availableReviews))
|
||||
: (WeeklyRemediationTypes.HardCorrect, 5);
|
||||
|
||||
public static int CountActiveDays(IEnumerable<DateTime> timestamps) =>
|
||||
timestamps.Select(value => value.ToUniversalTime().Date).Distinct().Count();
|
||||
|
||||
public static bool QualifiesHardAttempt(WeeklyGoal goal, QuestionAttempt attempt, Question question) =>
|
||||
!attempt.IsSkip && attempt.IsCorrect && attempt.CreatedUtc >= goal.WeekStartUtc
|
||||
&& attempt.CreatedUtc < goal.ExpiresUtc && question.Difficulty >= 4
|
||||
&& goal.EligibleSystems.Contains(Step3SystemCatalog.Normalize(question) ?? "");
|
||||
|
||||
private async Task<List<CreditAward>> FinalizeExpiredAsync(string userId, DateTime now)
|
||||
{
|
||||
var awards = new List<CreditAward>();
|
||||
var expired = await _goals.Find(value => value.UserId == userId && value.ExpiresUtc <= now
|
||||
&& !value.ExpirationRecorded).ToListAsync();
|
||||
foreach (var goal in expired)
|
||||
{
|
||||
var wasCompleted = goal.CompletedUtc.HasValue;
|
||||
var result = await EvaluateAsync(goal);
|
||||
awards.AddRange(result.Awards);
|
||||
var latest = await _goals.Find(value => value.Id == goal.Id).FirstAsync();
|
||||
var recorded = await _goals.UpdateOneAsync(value => value.Id == goal.Id && !value.ExpirationRecorded,
|
||||
Builders<WeeklyGoal>.Update.Set(value => value.ExpirationRecorded, true));
|
||||
if (recorded.ModifiedCount == 1 && !latest.CompletedUtc.HasValue) _metrics.WeeklyGoalExpiredIncomplete();
|
||||
if (recorded.ModifiedCount == 1 && !wasCompleted && latest.CompletedUtc.HasValue)
|
||||
_metrics.WeeklyGoalLateFinalized();
|
||||
}
|
||||
return awards;
|
||||
}
|
||||
|
||||
private async Task<(WeeklyGoalResponse Response, List<CreditAward> Awards)> EvaluateAsync(WeeklyGoal goal)
|
||||
{
|
||||
var evidence = await EvidenceAsync(goal);
|
||||
var complete = evidence.ActiveDays >= 4 && evidence.Systems.Count >= 3
|
||||
&& evidence.Remediation >= goal.RemediationTarget;
|
||||
if (complete && !goal.CompletedUtc.HasValue)
|
||||
{
|
||||
var completedUtc = _clock.GetUtcNow().UtcDateTime;
|
||||
var changed = await _goals.UpdateOneAsync(value => value.Id == goal.Id && value.CompletedUtc == null,
|
||||
Builders<WeeklyGoal>.Update.Set(value => value.CompletedUtc, completedUtc));
|
||||
goal.CompletedUtc = completedUtc;
|
||||
if (changed.ModifiedCount == 1) _metrics.WeeklyGoalCompleted(goal.RemediationType);
|
||||
}
|
||||
var awards = new List<CreditAward>();
|
||||
if (complete && !goal.RewardIssued)
|
||||
{
|
||||
var operationId = $"weekly-goal:{goal.Id}:complete";
|
||||
var result = await _credits.AwardAsync(goal.UserId, new CreditAwardRequest(
|
||||
operationId, 10, CreditReasonCodes.WeeklyGoalComplete,
|
||||
"Completed the balanced weekly study goal", "weekly_goal", goal.Id, true));
|
||||
if (result.Award is not null) awards.Add(result.Award);
|
||||
var applied = result.Award ?? await _credits.GetAppliedAwardAsync(goal.UserId, operationId);
|
||||
var amount = applied?.Amount ?? 0;
|
||||
await _goals.UpdateOneAsync(value => value.Id == goal.Id && !value.RewardIssued,
|
||||
Builders<WeeklyGoal>.Update.Set(value => value.RewardIssued, true)
|
||||
.Set(value => value.RewardAmount, amount));
|
||||
goal.RewardIssued = true;
|
||||
goal.RewardAmount = amount;
|
||||
}
|
||||
return (Response(goal, evidence), awards);
|
||||
}
|
||||
|
||||
private async Task<WeeklyEvidence> EvidenceAsync(WeeklyGoal goal)
|
||||
{
|
||||
var start = goal.WeekStartUtc;
|
||||
var end = goal.ExpiresUtc;
|
||||
var attemptsTask = _attempts.Find(value => value.UserId == goal.UserId && !value.IsSkip
|
||||
&& value.CreatedUtc >= start && value.CreatedUtc < end).ToListAsync();
|
||||
var reviewsTask = _reviews.Find(value => value.UserId == goal.UserId && value.IsCorrect
|
||||
&& value.CreatedUtc >= start && value.CreatedUtc < end).ToListAsync();
|
||||
var flashTask = _flashReviews.Find(value => value.UserId == goal.UserId && value.UndoneUtc == null
|
||||
&& value.ReviewedUtc >= start && value.ReviewedUtc < end).ToListAsync();
|
||||
var ccsTask = _ccsAttempts.Find(value => value.UserId == goal.UserId
|
||||
&& value.State == CcsAttemptStates.Completed && value.CompletedUtc >= start && value.CompletedUtc < end)
|
||||
.ToListAsync();
|
||||
var blocksTask = _studySessions.Find(value => value.UserId == goal.UserId
|
||||
&& value.GoalType == StudyGoalTypes.SimulationBlock && value.State == StudySessionStates.Completed
|
||||
&& value.CompletedUtc >= start && value.CompletedUtc < end).ToListAsync();
|
||||
var simulationsTask = _simulations.Find(value => value.UserId == goal.UserId
|
||||
&& value.Responses.Any(response => response.UpdatedUtc >= start && response.UpdatedUtc < end)).ToListAsync();
|
||||
await Task.WhenAll(attemptsTask, reviewsTask, flashTask, ccsTask, blocksTask, simulationsTask);
|
||||
|
||||
var attempts = attemptsTask.Result;
|
||||
var reviews = reviewsTask.Result;
|
||||
var flash = flashTask.Result;
|
||||
var dates = attempts.Select(value => value.CreatedUtc)
|
||||
.Concat(reviews.Select(value => value.CreatedUtc.Date))
|
||||
.Concat(flash.Select(value => value.ReviewedUtc))
|
||||
.Concat(ccsTask.Result.Select(value => value.CompletedUtc!.Value))
|
||||
.Concat(blocksTask.Result.Select(value => value.CompletedUtc!.Value)).ToList();
|
||||
|
||||
var questionIds = attempts.Select(value => value.QuestionExternalId)
|
||||
.Concat(reviews.Select(value => value.QuestionExternalId))
|
||||
.Concat(simulationsTask.Result.SelectMany(value => value.Responses)
|
||||
.Where(value => value.UpdatedUtc >= start && value.UpdatedUtc < end && !string.IsNullOrWhiteSpace(value.ChoiceId))
|
||||
.Select(value => value.QuestionExternalId)).ToHashSet(StringComparer.Ordinal);
|
||||
if (flash.Count > 0)
|
||||
{
|
||||
var cardIds = flash.Select(value => value.CardId).Distinct().ToList();
|
||||
var schedules = await _flashSchedules.Find(value => cardIds.Contains(value.Id!)).ToListAsync();
|
||||
var noteIds = schedules.Select(value => value.NoteId).Distinct().ToList();
|
||||
var notes = await _flashNotes.Find(value => noteIds.Contains(value.Id!)
|
||||
&& value.SourceQuestionExternalId != "").ToListAsync();
|
||||
foreach (var note in notes) questionIds.Add(note.SourceQuestionExternalId);
|
||||
}
|
||||
var questionMap = await _questions.GetByExternalIdsAsync(questionIds);
|
||||
var systems = questionMap.Values.Select(Step3SystemCatalog.Normalize)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value)).Select(value => value!).Distinct().ToHashSet();
|
||||
var hardCorrect = attempts.Count(attempt => questionMap.TryGetValue(attempt.QuestionExternalId, out var question)
|
||||
&& QualifiesHardAttempt(goal, attempt, question));
|
||||
return new WeeklyEvidence
|
||||
{
|
||||
ActiveDays = CountActiveDays(dates), Systems = systems,
|
||||
Remediation = goal.RemediationType == WeeklyRemediationTypes.Reviews ? reviews.Count : hardCorrect
|
||||
};
|
||||
}
|
||||
|
||||
private static WeeklyGoalResponse Response(WeeklyGoal goal, WeeklyEvidence evidence)
|
||||
{
|
||||
var components = new List<WeeklyGoalComponentResponse>
|
||||
{
|
||||
Component("active_days", "Study on 4 different days", evidence.ActiveDays, 4),
|
||||
Component("system_breadth", "Study 3 Step 3 systems", evidence.Systems.Count, 3),
|
||||
Component(goal.RemediationType,
|
||||
goal.RemediationType == WeeklyRemediationTypes.Reviews
|
||||
? $"Complete {goal.RemediationTarget} scheduled reviews"
|
||||
: "Get 5 difficulty 4+ answers correct in coverage gaps",
|
||||
evidence.Remediation, goal.RemediationTarget)
|
||||
};
|
||||
return new WeeklyGoalResponse
|
||||
{
|
||||
WeekStartUtc = goal.WeekStartUtc, ExpiresUtc = goal.ExpiresUtc,
|
||||
Completed = components.All(value => value.Completed), RemediationType = goal.RemediationType,
|
||||
EligibleSystems = goal.EligibleSystems,
|
||||
SuggestedSystem = goal.EligibleSystems.FirstOrDefault(value => !evidence.Systems.Contains(value))
|
||||
?? goal.EligibleSystems.FirstOrDefault() ?? "",
|
||||
RewardAmount = goal.RewardAmount, Components = components
|
||||
};
|
||||
}
|
||||
|
||||
private static WeeklyGoalComponentResponse Component(string type, string label, int progress, int target) =>
|
||||
new() { Type = type, Label = label, Progress = Math.Min(progress, target), Target = target,
|
||||
Completed = progress >= target };
|
||||
|
||||
private static int MasteryRank(string state) => state switch
|
||||
{
|
||||
MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
|
||||
MasteryStates.Strong => 3, _ => 4
|
||||
};
|
||||
|
||||
private sealed class WeeklyEvidence
|
||||
{
|
||||
public int ActiveDays { get; set; }
|
||||
public HashSet<string> Systems { get; set; } = [];
|
||||
public int Remediation { get; set; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user