Add weekly consistency goals

This commit is contained in:
hz
2026-08-01 04:14:39 -05:00
parent ecd5da9162
commit 300b230c03
16 changed files with 589 additions and 2 deletions
@@ -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));
}
}
@@ -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();
}
+1
View File
@@ -111,6 +111,7 @@ public static class CreditReasonCodes
public const string ExamSimulationComplete = "exam_simulation_complete"; public const string ExamSimulationComplete = "exam_simulation_complete";
public const string SessionGoalComplete = "session_goal_complete"; public const string SessionGoalComplete = "session_goal_complete";
public const string DailyMissionComplete = "daily_mission_complete"; public const string DailyMissionComplete = "daily_mission_complete";
public const string WeeklyGoalComplete = "weekly_goal_complete";
public const string Purchase = "purchase"; 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; } = [];
}
+1
View File
@@ -113,6 +113,7 @@ builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>(); builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<StudySessionStore>(); builder.Services.AddSingleton<StudySessionStore>();
builder.Services.AddSingleton<DailyMissionStore>(); builder.Services.AddSingleton<DailyMissionStore>();
builder.Services.AddSingleton<WeeklyGoalStore>();
builder.Services.AddSingleton<UserProfileStore>(); builder.Services.AddSingleton<UserProfileStore>();
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>(); builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
builder.Services.AddSingleton<FlashcardDeckStore>(); builder.Services.AddSingleton<FlashcardDeckStore>();
+10
View File
@@ -178,6 +178,16 @@ public sealed class CreditStore
return ToStatus(account); 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) public async Task<int> GetBankCycleAsync(string userId)
{ {
await EnsureAndCleanAsync(userId, _clock.GetUtcNow().UtcDateTime); 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> _dailyMissionCompleted = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionExpired = new(); private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionExpired = new();
private long _dailyMissionReplaced; 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 AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates); 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 DailyMissionCompleted(string type) => _dailyMissionCompleted.AddOrUpdate(type, 1, (_, value) => value + 1);
public void DailyMissionExpired(string type) => _dailyMissionExpired.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 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() public string RenderPrometheus()
{ {
@@ -103,6 +111,15 @@ public sealed class ServiceMetrics
AppendMissionSample(output, "step3_daily_missions_expired_incomplete_total", type, _dailyMissionExpired.GetValueOrDefault(type)); AppendMissionSample(output, "step3_daily_missions_expired_incomplete_total", type, _dailyMissionExpired.GetValueOrDefault(type));
} }
AppendCounter(output, "step3_daily_missions_replaced_total", Interlocked.Read(ref _dailyMissionReplaced)); 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.AppendLine("# TYPE step3_http_request_duration_seconds summary");
output.Append("step3_http_request_duration_seconds_count ") output.Append("step3_http_request_duration_seconds_count ")
.Append(Interlocked.Read(ref _httpRequests)) .Append(Interlocked.Read(ref _httpRequests))
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
"ExternalIdentities", "ExternalAuthTransactions", "ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets" "UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets", "WeeklyGoals"
]; ];
public UserProfileStore(MongoDatabaseProvider mongo) 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; }
}
}
+1 -1
View File
@@ -162,7 +162,7 @@ social notifications.
### Phase 2: progression ### Phase 2: progression
1. Add weekly consistency goals. 1. [x] Add weekly consistency goals.
2. Add breadth-based ranks and personal records. 2. Add breadth-based ranks and personal records.
3. Expand the RZ Coin store with cosmetic and utility items. 3. Expand the RZ Coin store with cosmetic and utility items.
4. Add limited streak protection and recovery days. 4. Add limited streak protection and recovery days.
+1
View File
@@ -42,6 +42,7 @@ DashboardService="*res://scripts/autoload/dashboard_service.gd"
CreditsService="*res://scripts/autoload/credits_service.gd" CreditsService="*res://scripts/autoload/credits_service.gd"
StudySessionService="*res://scripts/autoload/study_session_service.gd" StudySessionService="*res://scripts/autoload/study_session_service.gd"
DailyMissionService="*res://scripts/autoload/daily_mission_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" FlashcardService="*res://scripts/autoload/flashcard_service.gd"
AccountProfileService="*res://scripts/autoload/account_profile_service.gd" AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
DonationService="*res://scripts/autoload/donation_service.gd" DonationService="*res://scripts/autoload/donation_service.gd"
+67
View File
@@ -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()
@@ -0,0 +1 @@
uid://cosnvqogps38d
+74
View File
@@ -24,6 +24,7 @@ var review_list: VBoxContainer
var recent_list: VBoxContainer var recent_list: VBoxContainer
var weekly_summary: VBoxContainer var weekly_summary: VBoxContainer
var mission_content: VBoxContainer var mission_content: VBoxContainer
var weekly_goal_content: VBoxContainer
var metric_values: Dictionary = {} var metric_values: Dictionary = {}
var metric_details: Dictionary = {} var metric_details: Dictionary = {}
var metric_trends: Dictionary = {} var metric_trends: Dictionary = {}
@@ -45,6 +46,8 @@ func _ready() -> void:
DashboardService.dashboard_failed.connect(_show_error) DashboardService.dashboard_failed.connect(_show_error)
DailyMissionService.missions_changed.connect(_render_missions) DailyMissionService.missions_changed.connect(_render_missions)
DailyMissionService.missions_failed.connect(_show_mission_error) 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_loaded.connect(_on_review_question_loaded)
review_queue_service.question_failed.connect(_on_review_question_failed) review_queue_service.question_failed.connect(_on_review_question_failed)
review_queue_service.pass_completed.connect(_on_review_pass_completed) review_queue_service.pass_completed.connect(_on_review_pass_completed)
@@ -60,6 +63,7 @@ func on_show(_payload: Variant = null) -> void:
_render() _render()
DashboardService.refresh() DashboardService.refresh()
DailyMissionService.refresh() DailyMissionService.refresh()
WeeklyGoalService.refresh()
func _build() -> void: func _build() -> void:
@@ -187,6 +191,10 @@ func _build() -> void:
weekly_summary.custom_minimum_size.x = 180 weekly_summary.custom_minimum_size.x = 180
weekly_summary.add_theme_constant_override("separation", 10) weekly_summary.add_theme_constant_override("separation", 10)
weekly_row.add_child(weekly_summary) 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)) lower_grid.add_child(_card(weekly, 16))
recent_list = VBoxContainer.new() recent_list = VBoxContainer.new()
recent_list.add_theme_constant_override("separation", 6) recent_list.add_theme_constant_override("separation", 6)
@@ -221,6 +229,7 @@ func _render() -> void:
_render_weekly(data, weekly) _render_weekly(data, weekly)
_render_recent(data.get("recentActivity", [])) _render_recent(data.get("recentActivity", []))
_render_missions() _render_missions()
_render_weekly_goal()
func _render_missions() -> void: func _render_missions() -> void:
@@ -304,6 +313,71 @@ func _show_mission_error(message: String) -> void:
mission_content.add_child(_label(message, 13, Brand.MAGENTA)) 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: func _trend_series(trend: Array, field: String) -> Array:
var series: Array = [] var series: Array = []
for point in trend: for point in trend:
+1
View File
@@ -47,6 +47,7 @@ func _ready() -> void:
func _on_study_session_completed(summary: Dictionary) -> void: func _on_study_session_completed(summary: Dictionary) -> void:
WeeklyGoalService.refresh()
call_deferred("show_screen", "session_summary", summary) call_deferred("show_screen", "session_summary", summary)
+9
View File
@@ -10,14 +10,21 @@ func _initialize() -> void:
func _run() -> void: func _run() -> void:
var dashboard_service = root.get_node("DashboardService") var dashboard_service = root.get_node("DashboardService")
var mission_service = root.get_node("DailyMissionService") var mission_service = root.get_node("DailyMissionService")
var weekly_goal_service = root.get_node("WeeklyGoalService")
var prior_data: Dictionary = dashboard_service.data var prior_data: Dictionary = dashboard_service.data
var prior_missions: Dictionary = mission_service.state var prior_missions: Dictionary = mission_service.state
var prior_weekly_goal: Dictionary = weekly_goal_service.state
dashboard_service.data = _dashboard_data() dashboard_service.data = _dashboard_data()
mission_service.state = {"replacementAvailable": true, "missions": [ mission_service.state = {"replacementAvailable": true, "missions": [
{"id": "m1", "type": "due_reviews", "title": "Complete 3 scheduled reviews", "target": 3, "progress": 1, "completed": false}, {"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": "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} {"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() var dashboard = load("res://scripts/ui/dashboard_screen.gd").new()
dashboard.size = Vector2(1280, 900) dashboard.size = Vector2(1280, 900)
root.add_child(dashboard) root.add_child(dashboard)
@@ -30,6 +37,7 @@ func _run() -> void:
var first_mastery_row: BoxContainer = dashboard.mastery_rows[0] var first_mastery_row: BoxContainer = dashboard.mastery_rows[0]
_assert(not first_mastery_row.vertical, "desktop mastery rows should remain horizontal") _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.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] var first_mission_row: BoxContainer = dashboard.mission_rows[0]
_assert(not first_mission_row.vertical, "desktop mission rows should remain horizontal") _assert(not first_mission_row.vertical, "desktop mission rows should remain horizontal")
dashboard.size = Vector2(640, 900) dashboard.size = Vector2(640, 900)
@@ -45,6 +53,7 @@ func _run() -> void:
dashboard.queue_free() dashboard.queue_free()
dashboard_service.data = prior_data dashboard_service.data = prior_data
mission_service.state = prior_missions mission_service.state = prior_missions
weekly_goal_service.state = prior_weekly_goal
await process_frame await process_frame
quit(1 if failures > 0 else 0) quit(1 if failures > 0 else 0)