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 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; } = [];
}
+1
View File
@@ -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>();
+10
View File
@@ -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; }
}
}
+1 -1
View File
@@ -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.
+1
View File
@@ -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"
+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 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:
+1
View File
@@ -47,6 +47,7 @@ func _ready() -> void:
func _on_study_session_completed(summary: Dictionary) -> void:
WeeklyGoalService.refresh()
call_deferred("show_screen", "session_summary", summary)
+9
View File
@@ -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)