diff --git a/api/QuestionsApi.Tests/WeeklyGoalPolicyTests.cs b/api/QuestionsApi.Tests/WeeklyGoalPolicyTests.cs new file mode 100644 index 0000000..6cc91c7 --- /dev/null +++ b/api/QuestionsApi.Tests/WeeklyGoalPolicyTests.cs @@ -0,0 +1,51 @@ +using QuestionsApi.Models; +using QuestionsApi.Services; +using Xunit; + +namespace QuestionsApi.Tests; + +public sealed class WeeklyGoalPolicyTests +{ + [Theory] + [InlineData("2026-08-03", "2026-08-03")] + [InlineData("2026-08-09", "2026-08-03")] + [InlineData("2026-08-10", "2026-08-10")] + public void WeekStartsOnMondayUtc(string value, string expected) => + Assert.Equal(DateTime.Parse(expected).Date, WeeklyGoalStore.WeekStart(DateTime.Parse(value))); + + [Fact] + public void ActiveDaysAreDistinctUtcDates() + { + var values = new[] + { + new DateTime(2026, 8, 3, 1, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 8, 3, 22, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 8, 4, 1, 0, 0, DateTimeKind.Utc) + }; + Assert.Equal(2, WeeklyGoalStore.CountActiveDays(values)); + } + + [Theory] + [InlineData(0, WeeklyRemediationTypes.HardCorrect, 5)] + [InlineData(4, WeeklyRemediationTypes.HardCorrect, 5)] + [InlineData(5, WeeklyRemediationTypes.Reviews, 5)] + [InlineData(25, WeeklyRemediationTypes.Reviews, 15)] + public void RemediationIsFeasibleAndCapped(int available, string type, int target) => + Assert.Equal((type, target), WeeklyGoalStore.ChooseRemediation(available)); + + [Fact] + public void HardProgressRequiresCorrectEligibleDifficultyFourAttempt() + { + var system = Step3SystemCatalog.All[0]; + var start = new DateTime(2026, 8, 3, 0, 0, 0, DateTimeKind.Utc); + var goal = new WeeklyGoal { WeekStartUtc = start, ExpiresUtc = start.AddDays(7), EligibleSystems = [system] }; + var attempt = new QuestionAttempt { IsCorrect = true, CreatedUtc = start.AddDays(1) }; + var question = new Question { Step3System = system, Difficulty = 4 }; + Assert.True(WeeklyGoalStore.QualifiesHardAttempt(goal, attempt, question)); + attempt.IsCorrect = false; + Assert.False(WeeklyGoalStore.QualifiesHardAttempt(goal, attempt, question)); + attempt.IsCorrect = true; + question.Difficulty = 3; + Assert.False(WeeklyGoalStore.QualifiesHardAttempt(goal, attempt, question)); + } +} diff --git a/api/QuestionsApi/Controllers/WeeklyGoalsController.cs b/api/QuestionsApi/Controllers/WeeklyGoalsController.cs new file mode 100644 index 0000000..2aac171 --- /dev/null +++ b/api/QuestionsApi/Controllers/WeeklyGoalsController.cs @@ -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 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(); +} diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs index 87ca738..7620fb0 100644 --- a/api/QuestionsApi/Models/CreditModels.cs +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -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"; } diff --git a/api/QuestionsApi/Models/WeeklyGoalModels.cs b/api/QuestionsApi/Models/WeeklyGoalModels.cs new file mode 100644 index 0000000..fad15c5 --- /dev/null +++ b/api/QuestionsApi/Models/WeeklyGoalModels.cs @@ -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 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 EligibleSystems { get; set; } = []; + public string SuggestedSystem { get; set; } = ""; + public int RewardAmount { get; set; } + public int RewardMaximum { get; set; } = 10; + public List Components { get; set; } = []; +} + +public sealed class WeeklyGoalEnvelope +{ + public WeeklyGoalResponse State { get; set; } = new(); + public CreditStatusResponse? Credits { get; set; } + public List CreditAwards { get; set; } = []; +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index 4543eda..0b89c30 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -113,6 +113,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/Services/CreditStore.cs b/api/QuestionsApi/Services/CreditStore.cs index 71dcb64..4e689e9 100644 --- a/api/QuestionsApi/Services/CreditStore.cs +++ b/api/QuestionsApi/Services/CreditStore.cs @@ -178,6 +178,16 @@ public sealed class CreditStore return ToStatus(account); } + public async Task 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 GetBankCycleAsync(string userId) { await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); diff --git a/api/QuestionsApi/Services/ServiceMetrics.cs b/api/QuestionsApi/Services/ServiceMetrics.cs index c1a88b8..63edb40 100644 --- a/api/QuestionsApi/Services/ServiceMetrics.cs +++ b/api/QuestionsApi/Services/ServiceMetrics.cs @@ -25,6 +25,10 @@ public sealed class ServiceMetrics private readonly System.Collections.Concurrent.ConcurrentDictionary _dailyMissionCompleted = new(); private readonly System.Collections.Concurrent.ConcurrentDictionary _dailyMissionExpired = new(); private long _dailyMissionReplaced; + private readonly System.Collections.Concurrent.ConcurrentDictionary _weeklyGoalAssigned = new(); + private readonly System.Collections.Concurrent.ConcurrentDictionary _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)) diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 4a3fa7d..8a937da 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -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) diff --git a/api/QuestionsApi/Services/WeeklyGoalStore.cs b/api/QuestionsApi/Services/WeeklyGoalStore.cs new file mode 100644 index 0000000..061a24f --- /dev/null +++ b/api/QuestionsApi/Services/WeeklyGoalStore.cs @@ -0,0 +1,275 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class WeeklyGoalStore +{ + private readonly IMongoCollection _goals; + private readonly IMongoCollection _attempts; + private readonly IMongoCollection _reviews; + private readonly IMongoCollection _queue; + private readonly IMongoCollection _flashReviews; + private readonly IMongoCollection _flashSchedules; + private readonly IMongoCollection _flashNotes; + private readonly IMongoCollection _ccsAttempts; + private readonly IMongoCollection _studySessions; + private readonly IMongoCollection _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("WeeklyGoals"); + _attempts = db.GetCollection("QuestionAttempts"); + _reviews = db.GetCollection("QuestionReviewAttempts"); + _queue = db.GetCollection("ReviewQueueItems"); + _flashReviews = db.GetCollection("FlashcardReviewLogs"); + _flashSchedules = db.GetCollection("FlashcardSchedules"); + _flashNotes = db.GetCollection("FlashcardNotes"); + _ccsAttempts = db.GetCollection("CcsAttempts"); + _studySessions = db.GetCollection("StudySessions"); + _simulations = db.GetCollection("ExamSimulations"); + _questions = questions; + _credits = credits; + _clock = clock; + _metrics = metrics; + _goals.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.WeekStartUtc), + new CreateIndexOptions { Unique = true, Name = "uq_weekly_goal_user_start" })); + } + + public async Task GetAsync( + string userId, DashboardResponse dashboard, IReadOnlyCollection 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 SelectHardSystems( + DashboardResponse dashboard, IReadOnlyCollection 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(); + 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 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> FinalizeExpiredAsync(string userId, DateTime now) + { + var awards = new List(); + 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.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 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.Update.Set(value => value.CompletedUtc, completedUtc)); + goal.CompletedUtc = completedUtc; + if (changed.ModifiedCount == 1) _metrics.WeeklyGoalCompleted(goal.RemediationType); + } + var awards = new List(); + 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.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 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 + { + 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 Systems { get; set; } = []; + public int Remediation { get; set; } + } +} diff --git a/docs/QUESTION_BANK_GAMIFICATION.md b/docs/QUESTION_BANK_GAMIFICATION.md index 0cadf94..8be5768 100644 --- a/docs/QUESTION_BANK_GAMIFICATION.md +++ b/docs/QUESTION_BANK_GAMIFICATION.md @@ -162,7 +162,7 @@ social notifications. ### Phase 2: progression -1. Add weekly consistency goals. +1. [x] Add weekly consistency goals. 2. Add breadth-based ranks and personal records. 3. Expand the RZ Coin store with cosmetic and utility items. 4. Add limited streak protection and recovery days. diff --git a/project.godot b/project.godot index 2046ba8..b194791 100644 --- a/project.godot +++ b/project.godot @@ -42,6 +42,7 @@ DashboardService="*res://scripts/autoload/dashboard_service.gd" CreditsService="*res://scripts/autoload/credits_service.gd" StudySessionService="*res://scripts/autoload/study_session_service.gd" DailyMissionService="*res://scripts/autoload/daily_mission_service.gd" +WeeklyGoalService="*res://scripts/autoload/weekly_goal_service.gd" FlashcardService="*res://scripts/autoload/flashcard_service.gd" AccountProfileService="*res://scripts/autoload/account_profile_service.gd" DonationService="*res://scripts/autoload/donation_service.gd" diff --git a/scripts/autoload/weekly_goal_service.gd b/scripts/autoload/weekly_goal_service.gd new file mode 100644 index 0000000..e1d9f9a --- /dev/null +++ b/scripts/autoload/weekly_goal_service.gd @@ -0,0 +1,67 @@ +extends Node + +signal goal_changed +signal goal_failed(message: String) + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_URL := "https://us3q.ranaze.com/api/weekly-goals" + +var api_url := DEFAULT_URL +var request: HTTPRequest +var state: Dictionary = {} +var loading := false + + +func _ready() -> void: + api_url = EndpointConfig.resolve(DEFAULT_URL, "/api/weekly-goals") + request = HTTPRequest.new() + request.timeout = 25.0 + request.request_completed.connect(_on_completed) + add_child(request) + AuthState.auth_changed.connect(_on_auth_changed) + set_process(true) + + +func refresh() -> void: + if loading or not AuthState.can_send_authenticated_request(): + return + loading = true + var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token]) + var error := request.request(api_url, headers, HTTPClient.METHOD_GET) + if error != OK: + loading = false + goal_failed.emit("Weekly goal could not be loaded.") + + +func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + loading = false + if response_code == 401: + AuthState.handle_unauthorized(AuthState.access_token) + return + if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300: + goal_failed.emit("Weekly goal request failed.") + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY or typeof(parsed.get("state", null)) != TYPE_DICTIONARY: + goal_failed.emit("The weekly goal response was invalid.") + return + state = parsed.get("state", {}) + CreditsService.apply_award_response(parsed) + goal_changed.emit() + + +func _process(_delta: float) -> void: + if state.is_empty() or loading or not AuthState.can_send_authenticated_request(): + return + var expires := Time.get_unix_time_from_datetime_string(str(state.get("expiresUtc", "")).trim_suffix("Z")) + if expires > 0 and Time.get_unix_time_from_system() >= expires: + state = {} + refresh() + + +func _on_auth_changed() -> void: + if not AuthState.is_logged_in: + state = {} + loading = false + if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + request.cancel_request() diff --git a/scripts/autoload/weekly_goal_service.gd.uid b/scripts/autoload/weekly_goal_service.gd.uid new file mode 100644 index 0000000..488ad7b --- /dev/null +++ b/scripts/autoload/weekly_goal_service.gd.uid @@ -0,0 +1 @@ +uid://cosnvqogps38d diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index 95adde6..d0b2794 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -24,6 +24,7 @@ var review_list: VBoxContainer var recent_list: VBoxContainer var weekly_summary: VBoxContainer var mission_content: VBoxContainer +var weekly_goal_content: VBoxContainer var metric_values: Dictionary = {} var metric_details: Dictionary = {} var metric_trends: Dictionary = {} @@ -45,6 +46,8 @@ func _ready() -> void: DashboardService.dashboard_failed.connect(_show_error) DailyMissionService.missions_changed.connect(_render_missions) DailyMissionService.missions_failed.connect(_show_mission_error) + WeeklyGoalService.goal_changed.connect(_render_weekly_goal) + WeeklyGoalService.goal_failed.connect(_show_weekly_goal_error) review_queue_service.question_loaded.connect(_on_review_question_loaded) review_queue_service.question_failed.connect(_on_review_question_failed) review_queue_service.pass_completed.connect(_on_review_pass_completed) @@ -60,6 +63,7 @@ func on_show(_payload: Variant = null) -> void: _render() DashboardService.refresh() DailyMissionService.refresh() + WeeklyGoalService.refresh() func _build() -> void: @@ -187,6 +191,10 @@ func _build() -> void: weekly_summary.custom_minimum_size.x = 180 weekly_summary.add_theme_constant_override("separation", 10) weekly_row.add_child(weekly_summary) + weekly.add_child(HSeparator.new()) + weekly_goal_content = VBoxContainer.new() + weekly_goal_content.add_theme_constant_override("separation", 8) + weekly.add_child(weekly_goal_content) lower_grid.add_child(_card(weekly, 16)) recent_list = VBoxContainer.new() recent_list.add_theme_constant_override("separation", 6) @@ -221,6 +229,7 @@ func _render() -> void: _render_weekly(data, weekly) _render_recent(data.get("recentActivity", [])) _render_missions() + _render_weekly_goal() func _render_missions() -> void: @@ -304,6 +313,71 @@ func _show_mission_error(message: String) -> void: mission_content.add_child(_label(message, 13, Brand.MAGENTA)) +func _render_weekly_goal() -> void: + if weekly_goal_content == null: + return + _clear(weekly_goal_content) + var header := HBoxContainer.new() + header.add_child(_section_title("Balanced weekly goal")) + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + header.add_child(spacer) + header.add_child(_label("Monday-Sunday UTC", 11, Brand.muted_color(UIThemeService.is_dark_mode()))) + weekly_goal_content.add_child(header) + var components: Array = WeeklyGoalService.state.get("components", []) + if components.is_empty(): + weekly_goal_content.add_child(_label("Loading weekly progress...", 13, Brand.muted_color(UIThemeService.is_dark_mode()))) + return + for component_value in components: + var component: Dictionary = component_value + var row := HBoxContainer.new() + row.add_theme_constant_override("separation", 8) + var completed := bool(component.get("completed", false)) + var label := _label(str(component.get("label", "Weekly progress")), 12, + Brand.SUCCESS if completed else Brand.text_color(UIThemeService.is_dark_mode())) + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + row.add_child(label) + row.add_child(_label("%d / %d" % [int(component.get("progress", 0)), int(component.get("target", 1))], + 12, Brand.SUCCESS if completed else Brand.ROYAL_BLUE, true)) + if not completed: + var action := Button.new() + action.text = "Start" + action.flat = true + action.pressed.connect(_launch_weekly_component.bind(component)) + row.add_child(action) + weekly_goal_content.add_child(row) + if bool(WeeklyGoalService.state.get("completed", false)): + weekly_goal_content.add_child(_label("Weekly goal complete - %d RZ Coins earned" % int(WeeklyGoalService.state.get("rewardAmount", 0)), + 13, Brand.SUCCESS, true)) + else: + weekly_goal_content.add_child(_label("Complete all three for up to 10 RZ Coins.", 11, + Brand.muted_color(UIThemeService.is_dark_mode()))) + + +func _launch_weekly_component(component: Dictionary) -> void: + var type := str(component.get("type", "")) + if type == "reviews": + _start_review_queue() + return + var system := str(WeeklyGoalService.state.get("suggestedSystem", "")) + main.show_screen("setup", { + "mastery_target": not system.is_empty(), + "step3_system": system if not system.is_empty() else "All Systems", + "question_count": 10, + "difficulty_min": 4 if type == "hard_correct" else 0, + "difficulty_max": 5 if type == "hard_correct" else 0 + }) + + +func _show_weekly_goal_error(message: String) -> void: + if weekly_goal_content == null: + return + _clear(weekly_goal_content) + weekly_goal_content.add_child(_section_title("Balanced weekly goal")) + weekly_goal_content.add_child(_label(message, 12, Brand.MAGENTA)) + + func _trend_series(trend: Array, field: String) -> Array: var series: Array = [] for point in trend: diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 13d119a..308a691 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -47,6 +47,7 @@ func _ready() -> void: func _on_study_session_completed(summary: Dictionary) -> void: + WeeklyGoalService.refresh() call_deferred("show_screen", "session_summary", summary) diff --git a/tests/dashboard_metric_ui_test.gd b/tests/dashboard_metric_ui_test.gd index a7c7fe1..4cab4f0 100644 --- a/tests/dashboard_metric_ui_test.gd +++ b/tests/dashboard_metric_ui_test.gd @@ -10,14 +10,21 @@ func _initialize() -> void: func _run() -> void: var dashboard_service = root.get_node("DashboardService") var mission_service = root.get_node("DailyMissionService") + var weekly_goal_service = root.get_node("WeeklyGoalService") var prior_data: Dictionary = dashboard_service.data var prior_missions: Dictionary = mission_service.state + var prior_weekly_goal: Dictionary = weekly_goal_service.state dashboard_service.data = _dashboard_data() mission_service.state = {"replacementAvailable": true, "missions": [ {"id": "m1", "type": "due_reviews", "title": "Complete 3 scheduled reviews", "target": 3, "progress": 1, "completed": false}, {"id": "m2", "type": "weak_system_correct", "title": "Get 3 correct", "target": 3, "progress": 3, "completed": true}, {"id": "m3", "type": "difficulty_four_correct", "title": "Get 3 hard correct", "target": 3, "progress": 0, "completed": false} ]} + weekly_goal_service.state = {"completed": false, "suggestedSystem": "Cardiovascular System", "components": [ + {"type": "active_days", "label": "Study on 4 different days", "progress": 2, "target": 4, "completed": false}, + {"type": "system_breadth", "label": "Study 3 Step 3 systems", "progress": 1, "target": 3, "completed": false}, + {"type": "hard_correct", "label": "Get 5 hard answers correct", "progress": 3, "target": 5, "completed": false} + ]} var dashboard = load("res://scripts/ui/dashboard_screen.gd").new() dashboard.size = Vector2(1280, 900) root.add_child(dashboard) @@ -30,6 +37,7 @@ func _run() -> void: var first_mastery_row: BoxContainer = dashboard.mastery_rows[0] _assert(not first_mastery_row.vertical, "desktop mastery rows should remain horizontal") _assert(dashboard.mission_rows.size() == 3, "dashboard should render all three daily missions") + _assert(dashboard.weekly_goal_content.get_child_count() == 5, "dashboard should render the weekly header, three components, and reward note") var first_mission_row: BoxContainer = dashboard.mission_rows[0] _assert(not first_mission_row.vertical, "desktop mission rows should remain horizontal") dashboard.size = Vector2(640, 900) @@ -45,6 +53,7 @@ func _run() -> void: dashboard.queue_free() dashboard_service.data = prior_data mission_service.state = prior_missions + weekly_goal_service.state = prior_weekly_goal await process_frame quit(1 if failures > 0 else 0)