Add weekly consistency goals
This commit is contained in:
@@ -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();
|
||||||
|
}
|
||||||
@@ -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; } = [];
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user