Add daily study missions

This commit is contained in:
hz
2026-08-01 04:02:24 -05:00
parent 2bd2628009
commit ecd5da9162
20 changed files with 619 additions and 6 deletions
@@ -0,0 +1,78 @@
using QuestionsApi.Models;
using QuestionsApi.Services;
using Xunit;
namespace QuestionsApi.Tests;
public sealed class DailyMissionPolicyTests
{
private static readonly DateTime Now = new(2026, 8, 1, 12, 0, 0, DateTimeKind.Utc);
[Fact]
public void DueReviewsArePrioritizedAndCappedAtTen()
{
var candidates = DailyMissionStore.BuildCandidates(Dashboard(17), Questions(), Now);
Assert.Equal(DailyMissionTypes.DueReviews, candidates[0].Type);
Assert.Equal(10, candidates[0].Target);
}
[Fact]
public void NoReviewsFallsBackToCoverageGaps()
{
var candidates = DailyMissionStore.BuildCandidates(Dashboard(0), Questions(), Now);
Assert.DoesNotContain(candidates, value => value.Type == DailyMissionTypes.DueReviews);
Assert.True(candidates.Count >= 3);
Assert.Contains(candidates.Take(3), value => value.Type == DailyMissionTypes.DifficultyFourCorrect);
Assert.All(candidates.Where(value => value.Type == DailyMissionTypes.WeakSystemCorrect),
value => Assert.Equal(3, value.Target));
}
[Fact]
public void HardMissionRequiresThreeEligiblePublishedQuestions()
{
var questions = Questions();
questions.RemoveAll(value => value.Step3System == Step3SystemCatalog.All[1] && value.Difficulty >= 4);
var candidates = DailyMissionStore.BuildCandidates(Dashboard(0), questions, Now);
Assert.DoesNotContain(candidates, value => value.Type == DailyMissionTypes.DifficultyFourCorrect
&& value.Step3System == Step3SystemCatalog.All[1]);
}
[Fact]
public void QuestionProgressRequiresCorrectEligibleActivityAfterMissionStart()
{
var system = Step3SystemCatalog.All[0];
var mission = new DailyMission
{
Type = DailyMissionTypes.DifficultyFourCorrect, Step3System = system, StartsUtc = Now
};
var question = new Question { Step3System = system, Difficulty = 4 };
Assert.True(DailyMissionStore.QualifiesQuestion(mission,
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now }, question));
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
new QuestionAttempt { IsCorrect = false, CreatedUtc = Now }, question));
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
new QuestionAttempt { IsCorrect = true, IsSkip = true, CreatedUtc = Now }, question));
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now.AddSeconds(-1) }, question));
question.Difficulty = 3;
Assert.False(DailyMissionStore.QualifiesQuestion(mission,
new QuestionAttempt { IsCorrect = true, CreatedUtc = Now }, question));
}
private static DashboardResponse Dashboard(int pending) => new()
{
MissedQuestionCount = pending,
Systems = Step3SystemCatalog.All.Take(4).Select((system, index) => new DashboardSystemPerformance
{
System = system, MasteryState = index == 0 ? MasteryStates.Unseen : MasteryStates.Learning,
MasteryScore = index * 10, DistinctQuestions = index, Difficulty4PlusSuccessful = index
}).ToList()
};
private static List<Question> Questions() => Step3SystemCatalog.All.Take(4)
.SelectMany(system => Enumerable.Range(0, 6).Select(index => new Question
{
ExternalId = $"{system}-{index}", Step3System = system, Difficulty = index < 3 ? 4 : 2,
IsPublished = true
})).ToList();
}
@@ -0,0 +1,36 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QuestionsApi.Services;
using System.Security.Claims;
namespace QuestionsApi.Controllers;
[ApiController]
[Route("api/daily-missions")]
[Authorize(Roles = "USER,SUPER")]
public sealed class DailyMissionsController(
DailyMissionStore missions, DashboardStore dashboard, QuestionStore questions) : ControllerBase
{
[HttpGet]
public async Task<IActionResult> Get()
{
var now = DateTime.UtcNow;
return Ok(await missions.GetOrCreateAsync(UserId(), await dashboard.GetAsync(UserId(), now),
await questions.GetPublishedAsync()));
}
[HttpPost("{missionId}/replace")]
public async Task<IActionResult> Replace(string missionId)
{
try
{
var result = await missions.ReplaceAsync(UserId(), missionId);
return result is null ? NotFound() : Ok(result);
}
catch (ArgumentException exception) { return NotFound(exception.Message); }
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
}
private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier)
?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException();
}
@@ -22,6 +22,7 @@ public class QuestionsController : ControllerBase
private readonly CreditStore _credits;
private readonly MasteryAwardService _masteryAwards;
private readonly StudySessionStore _studySessions;
private readonly DailyMissionStore _dailyMissions;
public QuestionsController(
QuestionStore questions,
@@ -34,7 +35,8 @@ public class QuestionsController : ControllerBase
ReviewQueueStore reviewQueue,
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions)
StudySessionStore studySessions,
DailyMissionStore dailyMissions)
{
_questions = questions;
_attempts = attempts;
@@ -47,6 +49,7 @@ public class QuestionsController : ControllerBase
_credits = credits;
_masteryAwards = masteryAwards;
_studySessions = studySessions;
_dailyMissions = dailyMissions;
}
[HttpGet("systems")]
@@ -232,6 +235,8 @@ public class QuestionsController : ControllerBase
creditAwards.AddRange(studySession.Summary.CoinAwards.Where(value =>
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
&& creditAwards.All(existing => existing.ReasonCode != value.ReasonCode)));
var dailyMissions = await _dailyMissions.RecordQuestionAsync(userId, record.Attempt, question);
if (dailyMissions is not null) creditAwards.AddRange(dailyMissions.CreditAwards);
return Ok(new FirstQuestionAttemptResponse
{
QuestionExternalId = question.ExternalId,
@@ -243,7 +248,8 @@ public class QuestionsController : ControllerBase
Credits = await _credits.GetStatusAsync(userId),
CreditAwards = creditAwards,
StudySessionProgress = studySession?.Progress,
SessionSummary = studySession?.Summary
SessionSummary = studySession?.Summary,
DailyMissions = dailyMissions?.State
});
}
@@ -17,7 +17,8 @@ public sealed class ReviewQueueController(
QuestionBookmarkStore bookmarks,
CreditStore credits,
MasteryAwardService masteryAwards,
StudySessionStore studySessions) : ControllerBase
StudySessionStore studySessions,
DailyMissionStore dailyMissions) : ControllerBase
{
[HttpPost("next")]
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
@@ -107,6 +108,8 @@ public sealed class ReviewQueueController(
awards.AddRange(studySession.Summary.CoinAwards.Where(value =>
value.ReasonCode == CreditReasonCodes.SessionGoalComplete
&& awards.All(existing => existing.ReasonCode != value.ReasonCode)));
var missionResult = await dailyMissions.RecordReviewAsync(userId, result.Attempt);
if (missionResult is not null) awards.AddRange(missionResult.CreditAwards);
return Ok(new ReviewQueueAttemptResponse
{
QuestionExternalId = question.ExternalId,
@@ -116,7 +119,8 @@ public sealed class ReviewQueueController(
Credits = await credits.GetStatusAsync(userId),
CreditAwards = awards,
StudySessionProgress = studySession?.Progress,
SessionSummary = studySession?.Summary
SessionSummary = studySession?.Summary,
DailyMissions = missionResult?.State
});
}
+1
View File
@@ -110,6 +110,7 @@ public static class CreditReasonCodes
public const string MasteryMastered = "mastery_mastered";
public const string ExamSimulationComplete = "exam_simulation_complete";
public const string SessionGoalComplete = "session_goal_complete";
public const string DailyMissionComplete = "daily_mission_complete";
public const string Purchase = "purchase";
}
@@ -0,0 +1,64 @@
using MongoDB.Bson.Serialization.Attributes;
namespace QuestionsApi.Models;
public static class DailyMissionTypes
{
public const string DueReviews = "due_reviews";
public const string WeakSystemCorrect = "weak_system_correct";
public const string DifficultyFourCorrect = "difficulty_four_correct";
}
public sealed class DailyMission
{
public string Id { get; set; } = Guid.NewGuid().ToString("N");
public string Type { get; set; } = "";
public string Title { get; set; } = "";
public string Step3System { get; set; } = "";
public int DifficultyMin { get; set; }
public int Target { get; set; }
public int Progress { get; set; }
public DateTime StartsUtc { get; set; }
public DateTime? CompletedUtc { get; set; }
public List<string> AppliedEventIds { get; set; } = [];
}
public sealed class DailyMissionSet
{
[BsonId]
public string Id { get; set; } = "";
public string UserId { get; set; } = "";
public string DateUtc { get; set; } = "";
public List<DailyMission> Missions { get; set; } = [];
public List<DailyMission> ReplacementCandidates { get; set; } = [];
public bool ReplacementUsed { get; set; }
public bool ExpirationRecorded { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime ExpiresUtc { get; set; }
}
public sealed class DailyMissionResponse
{
public string Id { get; set; } = "";
public string Type { get; set; } = "";
public string Title { get; set; } = "";
public string Step3System { get; set; } = "";
public int DifficultyMin { get; set; }
public int Target { get; set; }
public int Progress { get; set; }
public bool Completed { get; set; }
}
public sealed class DailyMissionSetResponse
{
public string DateUtc { get; set; } = "";
public DateTime ExpiresUtc { get; set; }
public bool ReplacementAvailable { get; set; }
public List<DailyMissionResponse> Missions { get; set; } = [];
}
public sealed class DailyMissionEnvelope
{
public DailyMissionSetResponse State { get; set; } = new();
public List<CreditAward> CreditAwards { get; set; } = [];
}
@@ -149,6 +149,7 @@ public class FirstQuestionAttemptResponse
public List<CreditAward> CreditAwards { get; set; } = [];
public StudySessionProgress? StudySessionProgress { get; set; }
public StudySessionSummary? SessionSummary { get; set; }
public DailyMissionSetResponse? DailyMissions { get; set; }
}
public class SkipQuestionResponse
@@ -86,6 +86,7 @@ public sealed class ReviewQueueAttemptResponse
public List<CreditAward> CreditAwards { get; set; } = [];
public StudySessionProgress? StudySessionProgress { get; set; }
public StudySessionSummary? SessionSummary { get; set; }
public DailyMissionSetResponse? DailyMissions { get; set; }
}
public sealed class ReviewQueueRecordResult
+1
View File
@@ -112,6 +112,7 @@ builder.Services.AddSingleton<QuestionBookmarkStore>();
builder.Services.AddSingleton<ReviewQueueStore>();
builder.Services.AddSingleton<DashboardStore>();
builder.Services.AddSingleton<StudySessionStore>();
builder.Services.AddSingleton<DailyMissionStore>();
builder.Services.AddSingleton<UserProfileStore>();
builder.Services.AddSingleton<IFlashcardScheduler, FsrsFlashcardScheduler>();
builder.Services.AddSingleton<FlashcardDeckStore>();
@@ -0,0 +1,203 @@
using MongoDB.Driver;
using QuestionsApi.Models;
namespace QuestionsApi.Services;
public sealed class DailyMissionStore
{
private readonly IMongoCollection<DailyMissionSet> _sets;
private readonly CreditStore _credits;
private readonly TimeProvider _clock;
private readonly ServiceMetrics _metrics;
public DailyMissionStore(MongoDatabaseProvider mongo, CreditStore credits, TimeProvider clock, ServiceMetrics metrics)
{
_sets = mongo.Database.GetCollection<DailyMissionSet>("DailyMissionSets");
_credits = credits;
_clock = clock;
_metrics = metrics;
_sets.Indexes.CreateOne(new CreateIndexModel<DailyMissionSet>(
Builders<DailyMissionSet>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.DateUtc),
new CreateIndexOptions { Unique = true, Name = "uq_daily_mission_user_date" }));
}
public async Task<DailyMissionEnvelope> GetOrCreateAsync(
string userId, DashboardResponse dashboard, IReadOnlyCollection<Question> published)
{
var now = _clock.GetUtcNow().UtcDateTime;
await RecordExpiredAsync(userId, now);
var key = DateKey(now);
var existing = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
if (existing is not null) return Envelope(existing);
var candidates = BuildCandidates(dashboard, published, now);
if (candidates.Count < 3) throw new InvalidOperationException("Not enough eligible question-bank coverage to create daily missions.");
var set = new DailyMissionSet
{
Id = $"{userId}|{key}", UserId = userId, DateUtc = key,
Missions = candidates.Take(3).ToList(), ReplacementCandidates = candidates.Skip(3).ToList(),
CreatedUtc = now, ExpiresUtc = now.Date.AddDays(1)
};
try
{
await _sets.InsertOneAsync(set);
foreach (var mission in set.Missions) _metrics.DailyMissionAssigned(mission.Type);
}
catch (MongoWriteException exception) when (exception.WriteError.Category == ServerErrorCategory.DuplicateKey)
{
set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstAsync();
}
return Envelope(set);
}
public async Task<DailyMissionEnvelope?> ReplaceAsync(string userId, string missionId)
{
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
if (set is null) return null;
if (set.ReplacementUsed) throw new InvalidOperationException("Today's mission replacement has already been used.");
var index = set.Missions.FindIndex(value => value.Id == missionId);
if (index < 0) throw new ArgumentException("Mission not found.");
if (set.Missions[index].CompletedUtc.HasValue) throw new InvalidOperationException("Completed missions cannot be replaced.");
if (set.ReplacementCandidates.Count == 0) throw new InvalidOperationException("No replacement mission is available.");
var replacement = set.ReplacementCandidates[0];
replacement.StartsUtc = _clock.GetUtcNow().UtcDateTime;
set.Missions[index] = replacement;
set.ReplacementCandidates.RemoveAt(0);
set.ReplacementUsed = true;
var changed = await _sets.ReplaceOneAsync(value => value.Id == set.Id && !value.ReplacementUsed, set);
if (changed.ModifiedCount != 1) throw new InvalidOperationException("Today's mission replacement has already been used.");
_metrics.DailyMissionReplaced();
return Envelope(set);
}
public Task<DailyMissionEnvelope?> RecordQuestionAsync(string userId, QuestionAttempt attempt, Question question) =>
!attempt.IsCorrect || attempt.IsSkip ? Task.FromResult<DailyMissionEnvelope?>(null) :
RecordAsync(userId, $"question:{attempt.Id}", attempt.CreatedUtc,
mission => QualifiesQuestion(mission, attempt, question));
public Task<DailyMissionEnvelope?> RecordReviewAsync(string userId, QuestionReviewAttempt attempt) =>
!attempt.IsCorrect ? Task.FromResult<DailyMissionEnvelope?>(null) :
RecordAsync(userId, $"review:{attempt.Id}", attempt.CreatedUtc,
mission => QualifiesReview(mission, attempt));
public static bool QualifiesQuestion(DailyMission mission, QuestionAttempt attempt, Question question)
{
if (!attempt.IsCorrect || attempt.IsSkip || attempt.CreatedUtc < mission.StartsUtc) return false;
var system = Step3SystemCatalog.Normalize(question);
return mission.Type == DailyMissionTypes.WeakSystemCorrect && mission.Step3System == system
|| mission.Type == DailyMissionTypes.DifficultyFourCorrect
&& mission.Step3System == system && question.Difficulty >= 4;
}
public static bool QualifiesReview(DailyMission mission, QuestionReviewAttempt attempt) =>
attempt.IsCorrect && attempt.CreatedUtc >= mission.StartsUtc && mission.Type == DailyMissionTypes.DueReviews;
private async Task<DailyMissionEnvelope?> RecordAsync(
string userId, string eventId, DateTime eventUtc, Func<DailyMission, bool> qualifies)
{
var key = DateKey(_clock.GetUtcNow().UtcDateTime);
var set = await _sets.Find(value => value.UserId == userId && value.DateUtc == key).FirstOrDefaultAsync();
if (set is null) return null;
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null
&& value.StartsUtc <= eventUtc && qualifies(value)))
{
var missionFilter = Builders<DailyMission>.Filter.Eq(value => value.Id, mission.Id)
& Builders<DailyMission>.Filter.Eq(value => value.CompletedUtc, null)
& Builders<DailyMission>.Filter.AnyNin(value => value.AppliedEventIds, [eventId]);
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions, missionFilter);
await _sets.UpdateOneAsync(filter, Builders<DailyMissionSet>.Update
.Inc("Missions.$.Progress", 1).AddToSet("Missions.$.AppliedEventIds", eventId));
}
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
var awards = new List<CreditAward>();
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null && value.Progress >= value.Target))
{
var now = _clock.GetUtcNow().UtcDateTime;
var filter = Builders<DailyMissionSet>.Filter.Eq(value => value.Id, set.Id)
& Builders<DailyMissionSet>.Filter.ElemMatch(value => value.Missions,
item => item.Id == mission.Id && item.CompletedUtc == null && item.Progress >= item.Target);
var completed = await _sets.UpdateOneAsync(filter,
Builders<DailyMissionSet>.Update.Set("Missions.$.CompletedUtc", now));
if (completed.ModifiedCount != 1) continue;
var award = await _credits.AwardAsync(userId, new CreditAwardRequest(
$"mission:{mission.Id}:complete", 3, CreditReasonCodes.DailyMissionComplete,
$"Completed daily mission: {mission.Title}", "daily_mission", mission.Id, true));
if (award.Award is not null) awards.Add(award.Award);
_metrics.DailyMissionCompleted(mission.Type);
}
set = await _sets.Find(value => value.Id == set.Id).FirstAsync();
return new DailyMissionEnvelope { State = Response(set), CreditAwards = awards };
}
public static List<DailyMission> BuildCandidates(
DashboardResponse dashboard, IReadOnlyCollection<Question> published, DateTime now)
{
var availability = published.GroupBy(Step3SystemCatalog.Normalize)
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
.ToDictionary(group => group.Key!, group => (All: group.Count(), Hard: group.Count(q => q.Difficulty >= 4)));
var systems = dashboard.Systems.Where(value => availability.ContainsKey(value.System))
.OrderBy(value => MasteryRank(value.MasteryState)).ThenBy(value => value.MasteryScore)
.ThenBy(value => value.DistinctQuestions).ThenBy(value => value.System).ToList();
var result = new List<DailyMission>();
if (dashboard.MissedQuestionCount > 0)
result.Add(New(DailyMissionTypes.DueReviews,
$"Complete {Math.Min(10, dashboard.MissedQuestionCount)} scheduled reviews", "", 0,
Math.Min(10, dashboard.MissedQuestionCount), now));
var weak = systems.Where(value => availability[value.System].All >= 3).Select(system =>
New(DailyMissionTypes.WeakSystemCorrect,
$"Get 3 first-attempt answers correct in {system.System}", system.System, 0, 3, now));
var hard = systems.Where(value => availability[value.System].Hard >= 3)
.OrderBy(value => value.Difficulty4PlusSuccessful).ThenBy(value => value.MasteryScore)
.Select(system => New(DailyMissionTypes.DifficultyFourCorrect,
$"Get 3 difficulty 4+ answers correct in {system.System}", system.System, 4, 3, now));
var weakList = weak.ToList();
var hardList = hard.ToList();
if (weakList.Count > 0) result.Add(weakList[0]);
var firstHard = hardList.FirstOrDefault(value => value.Step3System != weakList.FirstOrDefault()?.Step3System)
?? hardList.FirstOrDefault();
if (firstHard is not null) result.Add(firstHard);
result.AddRange(weakList.Skip(1));
result.AddRange(hardList.Where(value => value.Id != firstHard?.Id));
return result.GroupBy(value => $"{value.Type}|{value.Step3System}").Select(group => group.First()).ToList();
}
private static DailyMission New(string type, string title, string system, int difficulty, int target, DateTime now) =>
new() { Type = type, Title = title, Step3System = system, DifficultyMin = difficulty,
Target = target, StartsUtc = now };
private static int MasteryRank(string state) => state switch
{
MasteryStates.Unseen => 0, MasteryStates.Learning => 1, MasteryStates.Practicing => 2,
MasteryStates.Strong => 3, _ => 4
};
private static string DateKey(DateTime now) => now.ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture);
private async Task RecordExpiredAsync(string userId, DateTime now)
{
var expired = await _sets.Find(value => value.UserId == userId && value.ExpiresUtc <= now
&& !value.ExpirationRecorded).ToListAsync();
foreach (var set in expired)
{
var changed = await _sets.UpdateOneAsync(value => value.Id == set.Id && !value.ExpirationRecorded,
Builders<DailyMissionSet>.Update.Set(value => value.ExpirationRecorded, true));
if (changed.ModifiedCount != 1) continue;
foreach (var mission in set.Missions.Where(value => value.CompletedUtc is null))
_metrics.DailyMissionExpired(mission.Type);
}
}
private static DailyMissionEnvelope Envelope(DailyMissionSet set) => new() { State = Response(set) };
private static DailyMissionSetResponse Response(DailyMissionSet set) => new()
{
DateUtc = set.DateUtc, ExpiresUtc = set.ExpiresUtc,
ReplacementAvailable = !set.ReplacementUsed && set.ReplacementCandidates.Count > 0
&& set.Missions.Any(value => value.CompletedUtc is null),
Missions = set.Missions.Select(value => new DailyMissionResponse
{
Id = value.Id, Type = value.Type, Title = value.Title, Step3System = value.Step3System,
DifficultyMin = value.DifficultyMin, Target = value.Target,
Progress = Math.Min(value.Progress, value.Target), Completed = value.CompletedUtc.HasValue
}).ToList()
};
}
@@ -21,6 +21,10 @@ public sealed class ServiceMetrics
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCoins = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditDuplicates = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _creditCapped = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionAssigned = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionCompleted = new();
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _dailyMissionExpired = new();
private long _dailyMissionReplaced;
public void AttemptCreated() => Interlocked.Increment(ref _attemptsCreated);
public void AttemptDuplicate() => Interlocked.Increment(ref _attemptDuplicates);
@@ -54,6 +58,10 @@ public sealed class ServiceMetrics
}
public void CreditDuplicate(string reason) => _creditDuplicates.AddOrUpdate(reason, 1, (_, value) => value + 1);
public void CreditCapped(string reason) => _creditCapped.AddOrUpdate(reason, 1, (_, value) => value + 1);
public void DailyMissionAssigned(string type) => _dailyMissionAssigned.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 DailyMissionReplaced() => Interlocked.Increment(ref _dailyMissionReplaced);
public string RenderPrometheus()
{
@@ -85,6 +93,16 @@ public sealed class ServiceMetrics
AppendReasonSample(output, "step3_credit_award_duplicates_total", reason, _creditDuplicates.GetValueOrDefault(reason));
AppendReasonSample(output, "step3_credit_award_capped_total", reason, _creditCapped.GetValueOrDefault(reason));
}
output.AppendLine("# TYPE step3_daily_missions_assigned_total counter");
output.AppendLine("# TYPE step3_daily_missions_completed_total counter");
output.AppendLine("# TYPE step3_daily_missions_expired_incomplete_total counter");
foreach (var type in _dailyMissionAssigned.Keys.Concat(_dailyMissionCompleted.Keys).Concat(_dailyMissionExpired.Keys).Distinct().Order())
{
AppendMissionSample(output, "step3_daily_missions_assigned_total", type, _dailyMissionAssigned.GetValueOrDefault(type));
AppendMissionSample(output, "step3_daily_missions_completed_total", type, _dailyMissionCompleted.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));
output.AppendLine("# TYPE step3_http_request_duration_seconds summary");
output.Append("step3_http_request_duration_seconds_count ")
.Append(Interlocked.Read(ref _httpRequests))
@@ -116,4 +134,7 @@ public sealed class ServiceMetrics
private static void AppendReasonSample(StringBuilder output, string name, string reason, long value) =>
output.Append(name).Append("{reason=\"").Append(reason.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
private static void AppendMissionSample(StringBuilder output, string name, string type, long value) =>
output.Append(name).Append("{type=\"").Append(type.Replace("\"", "")).Append("\"} ").Append(value).AppendLine();
}
@@ -20,7 +20,7 @@ public sealed class UserProfileStore
"ExternalIdentities", "ExternalAuthTransactions",
"FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs",
"FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews",
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions"
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions", "DailyMissionSets"
];
public UserProfileStore(MongoDatabaseProvider mongo)
+1 -1
View File
@@ -158,7 +158,7 @@ social notifications.
- [x] Define the mastery model and expose topic-level progress.
- [x] Add server-authoritative RZ Coin awards for approved learning events.
- [x] Add user-selected session goals and an end-of-session summary.
- [ ] Add daily missions with one replacement per day.
- [x] Add daily missions with one replacement per day.
### Phase 2: progression
+1
View File
@@ -41,6 +41,7 @@ ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
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"
FlashcardService="*res://scripts/autoload/flashcard_service.gd"
AccountProfileService="*res://scripts/autoload/account_profile_service.gd"
DonationService="*res://scripts/autoload/donation_service.gd"
+88
View File
@@ -0,0 +1,88 @@
extends Node
signal missions_changed
signal missions_failed(message: String)
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
const DEFAULT_URL := "https://us3q.ranaze.com/api/daily-missions"
var api_url := DEFAULT_URL
var request: HTTPRequest
var state: Dictionary = {}
var loading := false
var pending_action := ""
func _ready() -> void:
api_url = EndpointConfig.resolve(DEFAULT_URL, "/api/daily-missions")
request = HTTPRequest.new()
request.timeout = 20.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
_send("refresh", HTTPClient.METHOD_GET, "")
func replace(mission_id: String) -> void:
if loading or mission_id.is_empty() or not AuthState.can_send_authenticated_request():
return
_send("replace", HTTPClient.METHOD_POST, "/%s/replace" % mission_id.uri_encode())
func apply_response(parsed: Dictionary) -> void:
var next_state: Variant = parsed.get("dailyMissions", null)
if typeof(next_state) == TYPE_DICTIONARY:
state = next_state
missions_changed.emit()
func _send(action: String, method: int, suffix: String) -> void:
loading = true
pending_action = action
var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token])
var error := request.request(api_url + suffix, headers, method)
if error != OK:
loading = false
pending_action = ""
missions_failed.emit("Daily missions could not be loaded.")
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
loading = false
pending_action = ""
if response_code == 401:
AuthState.handle_unauthorized(AuthState.access_token)
return
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
missions_failed.emit("Daily mission 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:
missions_failed.emit("The daily mission response was invalid.")
return
state = parsed.get("state", {})
CreditsService.apply_award_response(parsed)
missions_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://bwafdsrqdxleo
@@ -552,6 +552,7 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
_update_question_elo(question_id, question_elo)
_apply_rating_change(rating)
CreditsService.apply_award_response(parsed)
DailyMissionService.apply_response(parsed)
# Let Tutor users inspect the final answer before the summary replaces the player.
StudySessionService.apply_response(parsed, true)
choice_percentages_updated.emit(question_id, percentages)
+1
View File
@@ -198,6 +198,7 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack
var synced_is_correct := false
if typeof(parsed) == TYPE_DICTIONARY:
CreditsService.apply_award_response(parsed)
DailyMissionService.apply_response(parsed)
StudySessionService.apply_response(parsed, true)
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
synced_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
+93
View File
@@ -23,6 +23,7 @@ var focus_content: VBoxContainer
var review_list: VBoxContainer
var recent_list: VBoxContainer
var weekly_summary: VBoxContainer
var mission_content: VBoxContainer
var metric_values: Dictionary = {}
var metric_details: Dictionary = {}
var metric_trends: Dictionary = {}
@@ -34,6 +35,7 @@ var pending_review_launch := false
var review_queue_service: Variant
var trend_mode := "rating"
var mastery_rows: Array[BoxContainer] = []
var mission_rows: Array[BoxContainer] = []
func _ready() -> void:
@@ -41,6 +43,8 @@ func _ready() -> void:
review_queue_service = get_node("/root/ReviewQueueService")
DashboardService.dashboard_changed.connect(_render)
DashboardService.dashboard_failed.connect(_show_error)
DailyMissionService.missions_changed.connect(_render_missions)
DailyMissionService.missions_failed.connect(_show_mission_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)
@@ -55,6 +59,7 @@ func on_show(_payload: Variant = null) -> void:
else:
_render()
DashboardService.refresh()
DailyMissionService.refresh()
func _build() -> void:
@@ -65,6 +70,7 @@ func _build() -> void:
metric_details.clear()
metric_trends.clear()
mastery_rows.clear()
mission_rows.clear()
scroll = ScrollContainer.new()
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
@@ -82,6 +88,9 @@ func _build() -> void:
stat_card_grid.add_theme_constant_override("h_separation", 12)
stat_card_grid.add_theme_constant_override("v_separation", 12)
content.add_child(stat_card_grid)
mission_content = VBoxContainer.new()
mission_content.add_theme_constant_override("separation", 10)
content.add_child(_card(mission_content, 16))
qbank_sparkline = DashboardSparkline.new()
qbank_sparkline.custom_minimum_size = Vector2(0, 34)
@@ -211,6 +220,88 @@ func _render() -> void:
activity_chart.set_days(weekly, UIThemeService.is_dark_mode())
_render_weekly(data, weekly)
_render_recent(data.get("recentActivity", []))
_render_missions()
func _render_missions() -> void:
if mission_content == null:
return
_clear(mission_content)
mission_rows.clear()
var header := HBoxContainer.new()
header.add_child(_section_title("Daily missions"))
var spacer := Control.new()
spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
header.add_child(spacer)
header.add_child(_label("Resets at 00:00 UTC", 11, Brand.muted_color(UIThemeService.is_dark_mode())))
mission_content.add_child(header)
var missions: Array = DailyMissionService.state.get("missions", [])
if missions.is_empty():
mission_content.add_child(_label("Loading today's focused study missions...", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
return
var replacement_available := bool(DailyMissionService.state.get("replacementAvailable", false))
for mission_value in missions:
var mission: Dictionary = mission_value
var row := BoxContainer.new()
row.vertical = size.x < 700.0
mission_rows.append(row)
row.add_theme_constant_override("separation", 10)
var details := VBoxContainer.new()
details.size_flags_horizontal = Control.SIZE_EXPAND_FILL
var completed := bool(mission.get("completed", false))
var title := _label(str(mission.get("title", "Study mission")), 14,
Brand.SUCCESS if completed else Brand.text_color(UIThemeService.is_dark_mode()), true)
title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
details.add_child(title)
var progress_value := mini(int(mission.get("progress", 0)), int(mission.get("target", 1)))
var target := maxi(1, int(mission.get("target", 1)))
var progress := ProgressBar.new()
progress.show_percentage = false
progress.max_value = target
progress.value = progress_value
progress.custom_minimum_size.y = 7
_style_progress(progress, Brand.SUCCESS if completed else Brand.ROYAL_BLUE)
details.add_child(progress)
details.add_child(_label("Complete - 3 RZ Coins earned" if completed else "%d of %d" % [progress_value, target],
11, Brand.muted_color(UIThemeService.is_dark_mode())))
row.add_child(details)
var launch := _button("Review" if str(mission.get("type", "")) == "due_reviews" else "Start")
launch.disabled = completed
launch.pressed.connect(_launch_mission.bind(mission))
row.add_child(launch)
if replacement_available and not completed:
var replace := Button.new()
replace.text = "Replace"
replace.flat = true
replace.pressed.connect(_replace_mission.bind(str(mission.get("id", ""))))
row.add_child(replace)
mission_content.add_child(row)
func _launch_mission(mission: Dictionary) -> void:
if str(mission.get("type", "")) == "due_reviews":
_start_review_queue()
return
main.show_screen("setup", {
"mastery_target": true,
"step3_system": str(mission.get("step3System", "")),
"question_count": 10,
"difficulty_min": int(mission.get("difficultyMin", 0)),
"difficulty_max": 5 if int(mission.get("difficultyMin", 0)) > 0 else 0
})
func _replace_mission(mission_id: String) -> void:
DailyMissionService.replace(mission_id)
_render_missions()
func _show_mission_error(message: String) -> void:
if mission_content == null:
return
_clear(mission_content)
mission_content.add_child(_section_title("Daily missions"))
mission_content.add_child(_label(message, 13, Brand.MAGENTA))
func _trend_series(trend: Array, field: String) -> Array:
@@ -837,6 +928,8 @@ func _update_responsive() -> void:
for child in row.get_children():
if child is Button:
child.custom_minimum_size.x = 0.0 if narrow else 155.0
for row in mission_rows:
row.vertical = narrow
func _clear(container: Container) -> void:
+12
View File
@@ -9,8 +9,15 @@ func _initialize() -> void:
func _run() -> void:
var dashboard_service = root.get_node("DashboardService")
var mission_service = root.get_node("DailyMissionService")
var prior_data: Dictionary = dashboard_service.data
var prior_missions: Dictionary = mission_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}
]}
var dashboard = load("res://scripts/ui/dashboard_screen.gd").new()
dashboard.size = Vector2(1280, 900)
root.add_child(dashboard)
@@ -22,9 +29,13 @@ func _run() -> void:
_assert(dashboard.mastery_rows.size() == 14, "dashboard should render one mastery action row per system")
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")
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)
dashboard._update_responsive()
_assert(first_mastery_row.vertical, "narrow mastery rows should stack to avoid clipping")
_assert(first_mission_row.vertical, "narrow mission rows should stack to avoid clipping")
_assert(dashboard.rating_toggle.button_pressed, "rating should be the default trend mode")
_assert(dashboard.trend_chart.mode == "rating", "chart should initially render rating history")
dashboard._set_trend_mode("accuracy")
@@ -33,6 +44,7 @@ func _run() -> void:
dashboard.queue_free()
dashboard_service.data = prior_data
mission_service.state = prior_missions
await process_frame
quit(1 if failures > 0 else 0)