diff --git a/api/QuestionsApi/Controllers/ExamSimulationsController.cs b/api/QuestionsApi/Controllers/ExamSimulationsController.cs index ba8eeb7..5acc085 100644 --- a/api/QuestionsApi/Controllers/ExamSimulationsController.cs +++ b/api/QuestionsApi/Controllers/ExamSimulationsController.cs @@ -12,7 +12,8 @@ namespace QuestionsApi.Controllers; public class ExamSimulationsController( ExamSimulationStore simulations, CreditStore credits, - MasteryAwardService masteryAwards) : ControllerBase + MasteryAwardService masteryAwards, + StudySessionStore studySessions) : ControllerBase { [HttpGet("readiness")] public async Task Readiness() => Ok(await simulations.GetReadinessAsync(UserId())); @@ -116,7 +117,12 @@ public class ExamSimulationsController( { var simulation = await simulations.GetOwnedAsync(id, UserId()); if (simulation is null) return NotFound(); - try { return Ok(await simulations.ToResponseAsync(await simulations.StartNextBlockAsync(simulation))); } + try + { + simulation = await simulations.StartNextBlockAsync(simulation); + await studySessions.EnsureSimulationBlockAsync(UserId(), simulation.Id ?? id, simulation.CurrentBlock); + return Ok(await simulations.ToResponseAsync(simulation)); + } catch (InvalidOperationException exception) { return Conflict(exception.Message); } } @@ -141,6 +147,12 @@ public class ExamSimulationsController( await masteryAwards.PrepareAsync(userId); simulation = await simulations.SubmitBlockAsync(simulation); var response = await simulations.ToResponseAsync(simulation); + var session = await studySessions.CompleteSimulationBlockAsync(userId, simulation.Id ?? id, simulation.CurrentBlock); + response.StudySessionProgress = session.Progress; + response.SessionSummary = session.Summary; + if (session.Summary is not null) + response.CreditAwards.AddRange(session.Summary.CoinAwards.Where(value => + value.ReasonCode == CreditReasonCodes.SessionGoalComplete)); if (simulation.State == ExamSimulationStates.Completed && !simulation.IsSample) { var result = await credits.AwardAsync(userId, new CreditAwardRequest( @@ -150,6 +162,8 @@ public class ExamSimulationsController( response.CreditAwards.AddRange(await masteryAwards.AwardPromotionsAsync(userId)); response.Credits = await credits.GetStatusAsync(userId); } + else if (session.Summary is not null) + response.Credits = await credits.GetStatusAsync(userId); return Ok(response); } catch (InvalidOperationException exception) { return Conflict(exception.Message); } diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 354e3cf..283aea1 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -21,6 +21,7 @@ public class QuestionsController : ControllerBase private readonly ReviewQueueStore _reviewQueue; private readonly CreditStore _credits; private readonly MasteryAwardService _masteryAwards; + private readonly StudySessionStore _studySessions; public QuestionsController( QuestionStore questions, @@ -32,7 +33,8 @@ public class QuestionsController : ControllerBase QuestionBookmarkStore bookmarks, ReviewQueueStore reviewQueue, CreditStore credits, - MasteryAwardService masteryAwards) + MasteryAwardService masteryAwards, + StudySessionStore studySessions) { _questions = questions; _attempts = attempts; @@ -44,6 +46,7 @@ public class QuestionsController : ControllerBase _reviewQueue = reviewQueue; _credits = credits; _masteryAwards = masteryAwards; + _studySessions = studySessions; } [HttpGet("systems")] @@ -221,6 +224,14 @@ public class QuestionsController : ControllerBase } creditAwards.AddRange(await _masteryAwards.AwardPromotionsAsync(userId)); + StudySessionEnvelope? studySession = null; + if (!string.IsNullOrWhiteSpace(req.StudySessionId)) + studySession = await _studySessions.RecordQuestionAsync( + req.StudySessionId, userId, record.Attempt, question, creditAwards); + if (studySession?.Summary is not null) + creditAwards.AddRange(studySession.Summary.CoinAwards.Where(value => + value.ReasonCode == CreditReasonCodes.SessionGoalComplete + && creditAwards.All(existing => existing.ReasonCode != value.ReasonCode))); return Ok(new FirstQuestionAttemptResponse { QuestionExternalId = question.ExternalId, @@ -230,7 +241,9 @@ public class QuestionsController : ControllerBase QuestionElo = rating.QuestionElo, Rating = rating, Credits = await _credits.GetStatusAsync(userId), - CreditAwards = creditAwards + CreditAwards = creditAwards, + StudySessionProgress = studySession?.Progress, + SessionSummary = studySession?.Summary }); } diff --git a/api/QuestionsApi/Controllers/ReviewQueueController.cs b/api/QuestionsApi/Controllers/ReviewQueueController.cs index 32b3768..a215498 100644 --- a/api/QuestionsApi/Controllers/ReviewQueueController.cs +++ b/api/QuestionsApi/Controllers/ReviewQueueController.cs @@ -16,7 +16,8 @@ public sealed class ReviewQueueController( RatingStore ratings, QuestionBookmarkStore bookmarks, CreditStore credits, - MasteryAwardService masteryAwards) : ControllerBase + MasteryAwardService masteryAwards, + StudySessionStore studySessions) : ControllerBase { [HttpPost("next")] public async Task Next([FromBody] ReviewQueueNextRequest req) @@ -85,9 +86,10 @@ public sealed class ReviewQueueController( req.DurationSeconds); if (result is null) return Conflict("review cycle is no longer active"); var awards = new List(); + var delayed = false; if (result.Attempt.IsCorrect) { - var delayed = result.Item is not null + delayed = result.Item is not null && result.Attempt.CreatedUtc - result.Item.CreatedUtc >= TimeSpan.FromHours(24); var award = await credits.AwardAsync(userId, new CreditAwardRequest( $"review:{result.Attempt.Id}", CreditEconomy.ReviewAmount(delayed), @@ -97,6 +99,14 @@ public sealed class ReviewQueueController( if (award.Award is not null) awards.Add(award.Award); } awards.AddRange(await masteryAwards.AwardPromotionsAsync(userId)); + StudySessionEnvelope? studySession = null; + if (!string.IsNullOrWhiteSpace(req.StudySessionId)) + studySession = await studySessions.RecordReviewAsync( + req.StudySessionId, userId, result.Attempt, delayed, awards); + if (studySession?.Summary is not null) + awards.AddRange(studySession.Summary.CoinAwards.Where(value => + value.ReasonCode == CreditReasonCodes.SessionGoalComplete + && awards.All(existing => existing.ReasonCode != value.ReasonCode))); return Ok(new ReviewQueueAttemptResponse { QuestionExternalId = question.ExternalId, @@ -104,7 +114,9 @@ public sealed class ReviewQueueController( QueueState = result.Item?.State ?? ReviewQueueStates.Pending, PendingCount = checked((int)await queue.CountPendingAsync(userId)), Credits = await credits.GetStatusAsync(userId), - CreditAwards = awards + CreditAwards = awards, + StudySessionProgress = studySession?.Progress, + SessionSummary = studySession?.Summary }); } diff --git a/api/QuestionsApi/Controllers/StudySessionsController.cs b/api/QuestionsApi/Controllers/StudySessionsController.cs new file mode 100644 index 0000000..ed92bde --- /dev/null +++ b/api/QuestionsApi/Controllers/StudySessionsController.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using QuestionsApi.Models; +using QuestionsApi.Services; +using System.Security.Claims; + +namespace QuestionsApi.Controllers; + +[ApiController] +[Route("api/study-sessions")] +[Authorize(Roles = "USER,SUPER")] +public sealed class StudySessionsController(StudySessionStore sessions) : ControllerBase +{ + [HttpPost] + public async Task Create(CreateStudySessionRequest request) + { + try { return Ok(sessions.Envelope(await sessions.CreateAsync(UserId(), request))); } + catch (ArgumentException exception) { return BadRequest(exception.Message); } + catch (InvalidOperationException exception) { return Conflict(exception.Message); } + } + + [HttpGet("active")] + public async Task Active() + { + var session = await sessions.GetActiveAsync(UserId()); + return session is null ? NoContent() : Ok(sessions.Envelope(session)); + } + + [HttpPost("{id}/finish")] + public async Task Finish(string id) + { + var result = await sessions.FinishEarlyAsync(id, UserId()); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("{id}/summary")] + public async Task Summary(string id) + { + var session = await sessions.GetOwnedAsync(id, UserId()); + return session is null ? NotFound() : Ok(sessions.Envelope(session)); + } + + [HttpPost("{id}/check")] + public async Task Check(string id) + { + var result = await sessions.CheckAsync(id, UserId()); + return result is null ? NotFound() : Ok(result); + } + + private string UserId() => User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirstValue(ClaimTypes.Name) ?? User.Identity?.Name ?? throw new UnauthorizedAccessException(); +} diff --git a/api/QuestionsApi/Models/CreditModels.cs b/api/QuestionsApi/Models/CreditModels.cs index 3e3431a..8f438ff 100644 --- a/api/QuestionsApi/Models/CreditModels.cs +++ b/api/QuestionsApi/Models/CreditModels.cs @@ -109,6 +109,7 @@ public static class CreditReasonCodes public const string MasteryStrong = "mastery_strong"; public const string MasteryMastered = "mastery_mastered"; public const string ExamSimulationComplete = "exam_simulation_complete"; + public const string SessionGoalComplete = "session_goal_complete"; public const string Purchase = "purchase"; } diff --git a/api/QuestionsApi/Models/ExamSimulation.cs b/api/QuestionsApi/Models/ExamSimulation.cs index a80271a..1ae35a6 100644 --- a/api/QuestionsApi/Models/ExamSimulation.cs +++ b/api/QuestionsApi/Models/ExamSimulation.cs @@ -119,6 +119,8 @@ public class ExamSimulationStateResponse public List ReviewQuestions { get; set; } = []; public CreditStatusResponse? Credits { get; set; } public List CreditAwards { get; set; } = []; + public StudySessionProgress? StudySessionProgress { get; set; } + public StudySessionSummary? SessionSummary { get; set; } } public class ExamSimulationReadinessResponse diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 801952e..c0a7184 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -51,4 +51,5 @@ public class FirstQuestionAttemptRequest public List PresentedChoiceIds { get; set; } = []; public int DurationSeconds { get; set; } public bool MetTimeLimit { get; set; } = true; + public string? StudySessionId { get; set; } } diff --git a/api/QuestionsApi/Models/QuestionResponse.cs b/api/QuestionsApi/Models/QuestionResponse.cs index 56f0345..7468ae2 100644 --- a/api/QuestionsApi/Models/QuestionResponse.cs +++ b/api/QuestionsApi/Models/QuestionResponse.cs @@ -147,6 +147,8 @@ public class FirstQuestionAttemptResponse public RatingChangeResponse? Rating { get; set; } public CreditStatusResponse? Credits { get; set; } public List CreditAwards { get; set; } = []; + public StudySessionProgress? StudySessionProgress { get; set; } + public StudySessionSummary? SessionSummary { get; set; } } public class SkipQuestionResponse diff --git a/api/QuestionsApi/Models/ReviewQueueModels.cs b/api/QuestionsApi/Models/ReviewQueueModels.cs index ec4948c..e98be67 100644 --- a/api/QuestionsApi/Models/ReviewQueueModels.cs +++ b/api/QuestionsApi/Models/ReviewQueueModels.cs @@ -73,6 +73,7 @@ public sealed class ReviewQueueAttemptRequest public string ChoiceId { get; set; } = ""; public List PresentedChoiceIds { get; set; } = []; public int DurationSeconds { get; set; } + public string? StudySessionId { get; set; } } public sealed class ReviewQueueAttemptResponse @@ -83,6 +84,8 @@ public sealed class ReviewQueueAttemptResponse public int PendingCount { get; set; } public CreditStatusResponse? Credits { get; set; } public List CreditAwards { get; set; } = []; + public StudySessionProgress? StudySessionProgress { get; set; } + public StudySessionSummary? SessionSummary { get; set; } } public sealed class ReviewQueueRecordResult diff --git a/api/QuestionsApi/Models/StudySessionModels.cs b/api/QuestionsApi/Models/StudySessionModels.cs new file mode 100644 index 0000000..1a52d06 --- /dev/null +++ b/api/QuestionsApi/Models/StudySessionModels.cs @@ -0,0 +1,106 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace QuestionsApi.Models; + +public static class StudyGoalTypes +{ + public const string QuestionCount = "question_count"; + public const string Duration = "duration"; + public const string WeakSystem = "weak_system"; + public const string DueReviews = "due_reviews"; + public const string SimulationBlock = "simulation_block"; + public static readonly HashSet All = [QuestionCount, Duration, WeakSystem, DueReviews, SimulationBlock]; +} + +public static class StudySessionStates +{ + public const string Active = "active"; + public const string Completed = "completed"; + public const string EndedEarly = "ended_early"; + public const string Interrupted = "interrupted"; +} + +public sealed class StudySession +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string? Id { get; set; } + public string UserId { get; set; } = ""; + public string ClientSessionId { get; set; } = ""; + public string GoalType { get; set; } = StudyGoalTypes.QuestionCount; + public string Mode { get; set; } = "Tutor"; + public string Step3System { get; set; } = ""; + public int TargetValue { get; set; } = 10; + public string State { get; set; } = StudySessionStates.Active; + public int QuestionsCompleted { get; set; } + public int Correct { get; set; } + public int ReviewsCompleted { get; set; } + public int Recovered { get; set; } + public int CoinsEarned { get; set; } + public List CoinAwards { get; set; } = []; + public List AppliedEventIds { get; set; } = []; + public int? InitialMasteryScore { get; set; } + public string InitialMasteryState { get; set; } = ""; + public int? FinalMasteryScore { get; set; } + public string FinalMasteryState { get; set; } = ""; + public int? InitialReadiness { get; set; } + public int? FinalReadiness { get; set; } + public string SimulationId { get; set; } = ""; + public int? SimulationBlock { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } + public DateTime? TargetEndUtc { get; set; } + public DateTime? CompletedUtc { get; set; } +} + +public sealed class CreateStudySessionRequest +{ + public string ClientSessionId { get; set; } = ""; + public string GoalType { get; set; } = StudyGoalTypes.QuestionCount; + public int TargetValue { get; set; } = 10; + public string Mode { get; set; } = "Tutor"; + public string Step3System { get; set; } = ""; +} + +public sealed class StudySessionProgress +{ + public string Id { get; set; } = ""; + public string State { get; set; } = ""; + public string GoalType { get; set; } = ""; + public int TargetValue { get; set; } + public int CompletedValue { get; set; } + public int QuestionsCompleted { get; set; } + public int SecondsRemaining { get; set; } + public bool GoalReached { get; set; } +} + +public sealed class StudySessionSummary +{ + public string Id { get; set; } = ""; + public string State { get; set; } = ""; + public string GoalType { get; set; } = ""; + public int TargetValue { get; set; } + public int CompletedValue { get; set; } + public int QuestionsCompleted { get; set; } + public int Correct { get; set; } + public int Accuracy { get; set; } + public int ReviewsCompleted { get; set; } + public int Recovered { get; set; } + public int ElapsedSeconds { get; set; } + public int CoinsEarned { get; set; } + public List CoinAwards { get; set; } = []; + public string Step3System { get; set; } = ""; + public int? MasteryScoreChange { get; set; } + public string InitialMasteryState { get; set; } = ""; + public string FinalMasteryState { get; set; } = ""; + public int? ReadinessChange { get; set; } + public string ReadinessExplanation { get; set; } = "A completed exam simulation is required to update readiness."; + public bool GoalBonusAwarded { get; set; } +} + +public sealed class StudySessionEnvelope +{ + public StudySessionProgress Progress { get; set; } = new(); + public StudySessionSummary? Summary { get; set; } +} diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index d581faf..3eff679 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -111,6 +111,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/api/QuestionsApi/Services/StudySessionStore.cs b/api/QuestionsApi/Services/StudySessionStore.cs new file mode 100644 index 0000000..13a5d40 --- /dev/null +++ b/api/QuestionsApi/Services/StudySessionStore.cs @@ -0,0 +1,307 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class StudySessionStore +{ + private readonly IMongoCollection _sessions; + private readonly DashboardStore _dashboard; + private readonly CreditStore _credits; + private readonly ReviewQueueStore _reviews; + private readonly TimeProvider _clock; + + public StudySessionStore( + MongoDatabaseProvider mongo, + DashboardStore dashboard, + CreditStore credits, + ReviewQueueStore reviews, + TimeProvider clock) + { + _sessions = mongo.Database.GetCollection("StudySessions"); + _dashboard = dashboard; + _credits = credits; + _reviews = reviews; + _clock = clock; + _sessions.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ClientSessionId), + new CreateIndexOptions { Unique = true, Name = "uq_study_session_client" })); + _sessions.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State), + new CreateIndexOptions { Name = "study_session_user_state" })); + } + + public async Task CreateAsync(string userId, CreateStudySessionRequest request) + { + Validate(request); + var existing = await _sessions.Find(value => value.UserId == userId + && value.ClientSessionId == request.ClientSessionId).FirstOrDefaultAsync(); + if (existing is not null) return existing; + var now = _clock.GetUtcNow().UtcDateTime; + await CloseStaleAsync(userId, now); + var activeSimulation = await _sessions.Find(value => value.UserId == userId + && value.State == StudySessionStates.Active && value.GoalType == StudyGoalTypes.SimulationBlock) + .AnyAsync(); + if (activeSimulation) throw new InvalidOperationException("Finish the active simulation block before starting another study session."); + await _sessions.UpdateManyAsync( + value => value.UserId == userId && value.State == StudySessionStates.Active, + Builders.Update.Set(value => value.State, StudySessionStates.Interrupted) + .Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now)); + var dashboard = await _dashboard.GetAsync(userId, now); + var normalizedSystem = string.IsNullOrWhiteSpace(request.Step3System) + ? "" : Step3SystemCatalog.Normalize(request.Step3System) ?? throw new ArgumentException("Invalid Step 3 system."); + var target = request.GoalType switch + { + StudyGoalTypes.QuestionCount => Math.Clamp(request.TargetValue, 5, 40), + StudyGoalTypes.Duration => Math.Clamp(request.TargetValue, 10, 60), + StudyGoalTypes.WeakSystem => 10, + StudyGoalTypes.DueReviews => Math.Clamp(request.TargetValue, 1, 20), + _ => request.TargetValue + }; + if (request.GoalType == StudyGoalTypes.DueReviews) + { + var pending = checked((int)await _reviews.CountPendingAsync(userId, normalizedSystem)); + if (pending == 0) throw new ArgumentException("No reviews are currently due."); + target = Math.Min(target, pending); + } + var mastery = dashboard.Systems.FirstOrDefault(value => value.System == normalizedSystem); + var session = new StudySession + { + UserId = userId, ClientSessionId = request.ClientSessionId, + GoalType = request.GoalType, TargetValue = target, Mode = request.Mode, + Step3System = normalizedSystem, State = StudySessionStates.Active, + CreatedUtc = now, UpdatedUtc = now, + TargetEndUtc = request.GoalType == StudyGoalTypes.Duration ? now.AddMinutes(target) : null, + InitialMasteryScore = mastery?.MasteryScore, InitialMasteryState = mastery?.MasteryState ?? "", + InitialReadiness = dashboard.ExamReadinessProjectedPassPercent + }; + await _sessions.InsertOneAsync(session); + return session; + } + + public async Task GetActiveAsync(string userId) + { + await CloseStaleAsync(userId, _clock.GetUtcNow().UtcDateTime); + return await _sessions.Find(value => value.UserId == userId && value.State == StudySessionStates.Active) + .SortByDescending(value => value.CreatedUtc).FirstOrDefaultAsync(); + } + + public async Task GetOwnedAsync(string id, string userId) => + await _sessions.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync(); + + public async Task RecordQuestionAsync( + string id, string userId, QuestionAttempt attempt, Question question, IReadOnlyCollection awards) + { + var session = await GetOwnedAsync(id, userId); + if (session is null || session.State != StudySessionStates.Active) return null; + if (session.GoalType == StudyGoalTypes.DueReviews || session.GoalType == StudyGoalTypes.SimulationBlock) return null; + if (session.GoalType == StudyGoalTypes.WeakSystem + && Step3SystemCatalog.Normalize(question) != session.Step3System) return null; + if (!attempt.IsSkip) + await ApplyEventAsync(session, $"question:{attempt.Id}", 1, attempt.IsCorrect ? 1 : 0, 0, 0, awards); + return await EvaluateAsync(id, userId); + } + + public async Task RecordReviewAsync( + string id, string userId, QuestionReviewAttempt attempt, bool recovered, IReadOnlyCollection awards) + { + var session = await GetOwnedAsync(id, userId); + if (session is null || session.State != StudySessionStates.Active || session.GoalType != StudyGoalTypes.DueReviews) + return null; + await ApplyEventAsync(session, $"review:{attempt.Id}", 0, attempt.IsCorrect ? 1 : 0, 1, recovered ? 1 : 0, awards); + return await EvaluateAsync(id, userId); + } + + public async Task FinishEarlyAsync(string id, string userId) + { + var now = _clock.GetUtcNow().UtcDateTime; + await _sessions.UpdateOneAsync(value => value.Id == id && value.UserId == userId + && value.State == StudySessionStates.Active, + Builders.Update.Set(value => value.State, StudySessionStates.EndedEarly) + .Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now)); + var session = await GetOwnedAsync(id, userId); + if (session is null) return null; + await SnapshotFinalAsync(session); + return Envelope(session); + } + + public Task CheckAsync(string id, string userId) => EvaluateAsync(id, userId); + + public async Task EnsureSimulationBlockAsync(string userId, string simulationId, int block) + { + var clientId = $"simulation:{simulationId}:{block}"; + var existing = await _sessions.Find(value => value.UserId == userId && value.ClientSessionId == clientId).FirstOrDefaultAsync(); + if (existing is not null) return existing; + var now = _clock.GetUtcNow().UtcDateTime; + await _sessions.UpdateManyAsync(value => value.UserId == userId && value.State == StudySessionStates.Active, + Builders.Update.Set(value => value.State, StudySessionStates.Interrupted) + .Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now)); + var dashboard = await _dashboard.GetAsync(userId, now); + var session = new StudySession + { + UserId = userId, ClientSessionId = clientId, GoalType = StudyGoalTypes.SimulationBlock, + TargetValue = 1, Mode = "Simulation", State = StudySessionStates.Active, + SimulationId = simulationId, SimulationBlock = block, CreatedUtc = now, UpdatedUtc = now, + InitialReadiness = dashboard.ExamReadinessProjectedPassPercent + }; + try { await _sessions.InsertOneAsync(session); } + catch (MongoWriteException) { return await _sessions.Find(value => value.UserId == userId && value.ClientSessionId == clientId).FirstAsync(); } + return session; + } + + public async Task CompleteSimulationBlockAsync(string userId, string simulationId, int block) + { + var session = await EnsureSimulationBlockAsync(userId, simulationId, block); + if (session.State == StudySessionStates.Active) + { + var now = _clock.GetUtcNow().UtcDateTime; + await _sessions.UpdateOneAsync(value => value.Id == session.Id && value.State == StudySessionStates.Active, + Builders.Update.Set(value => value.QuestionsCompleted, 1) + .Set(value => value.State, StudySessionStates.Completed).Set(value => value.CompletedUtc, now) + .Set(value => value.UpdatedUtc, now)); + session = (await GetOwnedAsync(session.Id!, userId))!; + await ApplyCompletionBonusAsync(session); + await SnapshotFinalAsync(session); + } + return Envelope((await GetOwnedAsync(session.Id!, userId))!); + } + + public StudySessionEnvelope Envelope(StudySession session) => new() + { + Progress = Progress(session), + Summary = session.State == StudySessionStates.Active ? null : Summary(session) + }; + + private async Task ApplyEventAsync( + StudySession session, string eventId, int questions, int correct, int reviews, int recovered, + IReadOnlyCollection awards) + { + var now = _clock.GetUtcNow().UtcDateTime; + var filter = Builders.Filter.Eq(value => value.Id, session.Id) + & Builders.Filter.Eq(value => value.State, StudySessionStates.Active) + & Builders.Filter.AnyNin(value => value.AppliedEventIds, [eventId]); + var update = Builders.Update.AddToSet(value => value.AppliedEventIds, eventId) + .Inc(value => value.QuestionsCompleted, questions).Inc(value => value.Correct, correct) + .Inc(value => value.ReviewsCompleted, reviews).Inc(value => value.Recovered, recovered) + .Inc(value => value.CoinsEarned, awards.Sum(value => value.Amount)).Set(value => value.UpdatedUtc, now); + foreach (var award in awards) update = update.Push(value => value.CoinAwards, award); + await _sessions.UpdateOneAsync(filter, update); + } + + private async Task EvaluateAsync(string id, string userId) + { + var session = await GetOwnedAsync(id, userId); + if (session is null) return null; + if (session.State == StudySessionStates.Active && Reached(session, _clock.GetUtcNow().UtcDateTime)) + { + var now = _clock.GetUtcNow().UtcDateTime; + var changed = await _sessions.UpdateOneAsync(value => value.Id == id && value.UserId == userId + && value.State == StudySessionStates.Active, + Builders.Update.Set(value => value.State, StudySessionStates.Completed) + .Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now)); + session = (await GetOwnedAsync(id, userId))!; + if (changed.ModifiedCount == 1) + { + await ApplyCompletionBonusAsync(session); + await SnapshotFinalAsync(session); + session = (await GetOwnedAsync(id, userId))!; + } + } + return Envelope(session); + } + + private async Task ApplyCompletionBonusAsync(StudySession session) + { + if (!BonusEligible(session)) return; + var result = await _credits.AwardAsync(session.UserId, new CreditAwardRequest( + $"session:{session.Id}:complete", 3, CreditReasonCodes.SessionGoalComplete, "Completed a study session goal", + "study_session", session.Id!, true)); + if (result.Award is null) return; + await _sessions.UpdateOneAsync(value => value.Id == session.Id, + Builders.Update.Inc(value => value.CoinsEarned, result.Award.Amount) + .Push(value => value.CoinAwards, result.Award)); + } + + private async Task SnapshotFinalAsync(StudySession session) + { + var dashboard = await _dashboard.GetAsync(session.UserId, _clock.GetUtcNow().UtcDateTime); + var mastery = dashboard.Systems.FirstOrDefault(value => value.System == session.Step3System); + await _sessions.UpdateOneAsync(value => value.Id == session.Id, + Builders.Update.Set(value => value.FinalMasteryScore, mastery == null ? null : mastery.MasteryScore) + .Set(value => value.FinalMasteryState, mastery?.MasteryState ?? "") + .Set(value => value.FinalReadiness, dashboard.ExamReadinessProjectedPassPercent)); + } + + private async Task CloseStaleAsync(string userId, DateTime now) => + await _sessions.UpdateManyAsync(value => value.UserId == userId && value.State == StudySessionStates.Active + && value.UpdatedUtc < now.AddHours(-12), + Builders.Update.Set(value => value.State, StudySessionStates.Interrupted) + .Set(value => value.CompletedUtc, now).Set(value => value.UpdatedUtc, now)); + + private static bool Reached(StudySession session, DateTime now) => session.GoalType switch + { + StudyGoalTypes.Duration => session.TargetEndUtc <= now && session.QuestionsCompleted > 0, + StudyGoalTypes.DueReviews => session.ReviewsCompleted >= session.TargetValue, + _ => session.QuestionsCompleted >= session.TargetValue + }; + + private static bool BonusEligible(StudySession session) => session.GoalType switch + { + StudyGoalTypes.Duration => session.TargetValue >= 10 && session.QuestionsCompleted >= 5, + StudyGoalTypes.DueReviews => session.ReviewsCompleted >= 5, + StudyGoalTypes.SimulationBlock => true, + _ => session.QuestionsCompleted >= 5 + }; + + private StudySessionProgress Progress(StudySession session) + { + var completed = session.GoalType == StudyGoalTypes.DueReviews ? session.ReviewsCompleted + : session.GoalType == StudyGoalTypes.Duration + ? Math.Min(session.TargetValue * 60, (int)((session.CompletedUtc ?? _clock.GetUtcNow().UtcDateTime) - session.CreatedUtc).TotalSeconds) + : session.QuestionsCompleted; + return new StudySessionProgress + { + Id = session.Id ?? "", State = session.State, GoalType = session.GoalType, + TargetValue = session.TargetValue, CompletedValue = completed, + QuestionsCompleted = session.QuestionsCompleted, + SecondsRemaining = session.TargetEndUtc.HasValue + ? Math.Max(0, (int)(session.TargetEndUtc.Value - _clock.GetUtcNow().UtcDateTime).TotalSeconds) : 0, + GoalReached = session.State == StudySessionStates.Completed + }; + } + + private StudySessionSummary Summary(StudySession session) + { + var progress = Progress(session); + return new StudySessionSummary + { + Id = session.Id ?? "", State = session.State, GoalType = session.GoalType, + TargetValue = session.TargetValue, CompletedValue = progress.CompletedValue, + QuestionsCompleted = session.QuestionsCompleted, Correct = session.Correct, + Accuracy = session.QuestionsCompleted == 0 ? 0 : (int)Math.Round(100.0 * session.Correct / session.QuestionsCompleted), + ReviewsCompleted = session.ReviewsCompleted, Recovered = session.Recovered, + ElapsedSeconds = Math.Max(0, (int)((session.CompletedUtc ?? _clock.GetUtcNow().UtcDateTime) - session.CreatedUtc).TotalSeconds), + CoinsEarned = session.CoinsEarned, CoinAwards = session.CoinAwards, + Step3System = session.Step3System, + MasteryScoreChange = session.InitialMasteryScore.HasValue && session.FinalMasteryScore.HasValue + ? session.FinalMasteryScore - session.InitialMasteryScore : null, + InitialMasteryState = session.InitialMasteryState, FinalMasteryState = session.FinalMasteryState, + ReadinessChange = session.InitialReadiness.HasValue && session.FinalReadiness.HasValue + ? session.FinalReadiness - session.InitialReadiness : null, + ReadinessExplanation = session.FinalReadiness != session.InitialReadiness + ? "Readiness changed after a completed exam simulation." + : "A completed exam simulation is required to update readiness.", + GoalBonusAwarded = session.CoinAwards.Any(value => value.ReasonCode == CreditReasonCodes.SessionGoalComplete) + }; + } + + private static void Validate(CreateStudySessionRequest request) + { + if (!Guid.TryParse(request.ClientSessionId, out _)) throw new ArgumentException("clientSessionId must be a UUID."); + if (!StudyGoalTypes.All.Contains(request.GoalType) || request.GoalType == StudyGoalTypes.SimulationBlock) + throw new ArgumentException("Unsupported study goal type."); + if (request.GoalType == StudyGoalTypes.WeakSystem && string.IsNullOrWhiteSpace(request.Step3System)) + throw new ArgumentException("A Step 3 system is required for a weak-system goal."); + if (request.Mode is not ("Tutor" or "Timed")) throw new ArgumentException("mode must be Tutor or Timed."); + } +} diff --git a/api/QuestionsApi/Services/UserProfileStore.cs b/api/QuestionsApi/Services/UserProfileStore.cs index 030652c..1f1d91b 100644 --- a/api/QuestionsApi/Services/UserProfileStore.cs +++ b/api/QuestionsApi/Services/UserProfileStore.cs @@ -20,7 +20,7 @@ public sealed class UserProfileStore "ExternalIdentities", "ExternalAuthTransactions", "FlashcardDecks", "FlashcardNotes", "FlashcardSchedules", "FlashcardReviewLogs", "FlashcardMediaAssets", "FlashcardSettings", "FlashcardImportPreviews", - "UserCreditAccounts", "CreditLedgerEntries" + "UserCreditAccounts", "CreditLedgerEntries", "StudySessions" ]; public UserProfileStore(MongoDatabaseProvider mongo) diff --git a/docs/QUESTION_BANK_GAMIFICATION.md b/docs/QUESTION_BANK_GAMIFICATION.md index f96f0c5..c140a7b 100644 --- a/docs/QUESTION_BANK_GAMIFICATION.md +++ b/docs/QUESTION_BANK_GAMIFICATION.md @@ -157,7 +157,7 @@ social notifications. - [x] Define the mastery model and expose topic-level progress. - [x] Add server-authoritative RZ Coin awards for approved learning events. -- [ ] Add user-selected session goals and an end-of-session summary. +- [x] Add user-selected session goals and an end-of-session summary. - [ ] Add daily missions with one replacement per day. ### Phase 2: progression diff --git a/project.godot b/project.godot index 3fad8fc..d718e84 100644 --- a/project.godot +++ b/project.godot @@ -40,6 +40,7 @@ BookmarkService="*res://scripts/autoload/bookmark_service.gd" 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" FlashcardService="*res://scripts/autoload/flashcard_service.gd" AccountProfileService="*res://scripts/autoload/account_profile_service.gd" DonationService="*res://scripts/autoload/donation_service.gd" diff --git a/scripts/autoload/exam_simulation_service.gd b/scripts/autoload/exam_simulation_service.gd index 5ec0e21..e3186ee 100644 --- a/scripts/autoload/exam_simulation_service.gd +++ b/scripts/autoload/exam_simulation_service.gd @@ -197,6 +197,7 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri request_failed.emit("The simulation API returned an invalid response.") return CreditsService.apply_award_response(parsed) + StudySessionService.apply_response(parsed) if action == "readiness": readiness = parsed readiness_changed.emit() diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index a55900a..ec328fc 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -437,6 +437,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres "presented_choice_ids": presented_choice_ids.duplicate(), "duration_seconds": maxi(0, duration_seconds), "met_time_limit": met_time_limit, + "study_session_id": StudySessionService.active_session_id, "retry_count": 0 }) _save_attempt_queue() @@ -472,6 +473,11 @@ func _process_attempt_queue() -> void: "durationSeconds": int(queued.get("duration_seconds", 0)), "metTimeLimit": bool(queued.get("met_time_limit", true)) }) + var study_session_id := str(queued.get("study_session_id", "")) + if not study_session_id.is_empty(): + var decoded: Dictionary = JSON.parse_string(body) + decoded["studySessionId"] = study_session_id + body = JSON.stringify(decoded) if str(queued.get("kind", "answer")) == "skip": url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()] body = "" @@ -546,6 +552,8 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void: _update_question_elo(question_id, question_elo) _apply_rating_change(rating) CreditsService.apply_award_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) question_rating_updated.emit(question_id, question_elo, rating) diff --git a/scripts/autoload/review_queue_service.gd b/scripts/autoload/review_queue_service.gd index 7bc79f6..919180f 100644 --- a/scripts/autoload/review_queue_service.gd +++ b/scripts/autoload/review_queue_service.gd @@ -25,6 +25,7 @@ var retained_count := 0 var outbox: Array = [] var active_account_key := "" var step3_system := "" +var target_count := 20 func _ready() -> void: @@ -49,8 +50,9 @@ func _ready() -> void: _on_auth_changed() -func start_pass(requested_step3_system: String = "") -> void: +func start_pass(requested_step3_system: String = "", requested_count: int = 20) -> void: step3_system = requested_step3_system + target_count = clampi(requested_count, 1, 20) excluded_question_ids.clear() reviewed_count = 0 cleared_count = 0 @@ -59,6 +61,10 @@ func start_pass(requested_step3_system: String = "") -> void: func load_next() -> void: + if reviewed_count >= target_count: + StudySessionService.emit_deferred_summary() + pass_completed.emit(get_summary()) + return if loading: return if not AuthState.can_send_authenticated_request(): @@ -99,7 +105,8 @@ func record_first_submission(question: Dictionary, choice_id: String, presented_ "cycle": current_cycle, "choiceId": choice_id, "presentedChoiceIds": presented_choice_ids.duplicate(), - "durationSeconds": clampi(duration_seconds, 0, 86400) + "durationSeconds": clampi(duration_seconds, 0, 86400), + "studySessionId": StudySessionService.active_session_id } outbox.append({"account": _account_key(), "payload": payload, "retry_count": 0}) _save_outbox() @@ -191,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) + 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", ""))) synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false))) diff --git a/scripts/autoload/study_session_service.gd b/scripts/autoload/study_session_service.gd new file mode 100644 index 0000000..b563b48 --- /dev/null +++ b/scripts/autoload/study_session_service.gd @@ -0,0 +1,133 @@ +extends Node + +signal session_started +signal progress_changed +signal session_completed(summary: Dictionary) +signal session_failed(message: String) + +const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd") +const DEFAULT_URL := "https://us3q.ranaze.com/api/study-sessions" + +var api_url := DEFAULT_URL +var request: HTTPRequest +var active_session_id := "" +var progress: Dictionary = {} +var summary: Dictionary = {} +var pending_action := "" +var pending_launch: Dictionary = {} + + +func _ready() -> void: + api_url = EndpointConfig.resolve(DEFAULT_URL, "/api/study-sessions") + request = HTTPRequest.new() + request.timeout = 25.0 + request.request_completed.connect(_on_completed) + add_child(request) + AuthState.auth_changed.connect(_on_auth_changed) + + +func start(goal_type: String, target_value: int, mode: String, step3_system: String = "", launch: Dictionary = {}) -> void: + if not AuthState.can_send_authenticated_request(): + session_failed.emit("Login is required to start a study session.") + return + pending_launch = launch.duplicate(true) + summary = {} + _send("create", HTTPClient.METHOD_POST, "", { + "clientSessionId": _new_uuid(), "goalType": goal_type, "targetValue": target_value, + "mode": mode, "step3System": step3_system + }) + + +func finish_early() -> void: + if active_session_id.is_empty(): + return + _send("finish", HTTPClient.METHOD_POST, "/%s/finish" % active_session_id.uri_encode()) + + +func check_completion() -> void: + if active_session_id.is_empty(): + return + _send("check", HTTPClient.METHOD_POST, "/%s/check" % active_session_id.uri_encode()) + + +func apply_response(parsed: Dictionary, defer_completion: bool = false) -> void: + var next_progress: Variant = parsed.get("studySessionProgress", null) + if typeof(next_progress) == TYPE_DICTIONARY: + progress = next_progress + active_session_id = str(progress.get("id", active_session_id)) + progress_changed.emit() + var next_summary: Variant = parsed.get("sessionSummary", null) + if typeof(next_summary) == TYPE_DICTIONARY and not next_summary.is_empty(): + summary = next_summary + active_session_id = "" + if not defer_completion: + session_completed.emit(summary) + + +func emit_deferred_summary() -> void: + if not summary.is_empty(): + session_completed.emit(summary) + + +func is_active() -> bool: + return not active_session_id.is_empty() + + +func seconds_remaining() -> int: + return int(progress.get("secondsRemaining", 0)) + + +func _send(action: String, method: int, suffix: String, payload: Dictionary = {}) -> void: + if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED: + return + pending_action = action + var headers := PackedStringArray(["Authorization: Bearer %s" % AuthState.access_token]) + var body := "" + if not payload.is_empty(): + headers.append("Content-Type: application/json") + body = JSON.stringify(payload) + var error := request.request(api_url + suffix, headers, method, body) + if error != OK: + pending_action = "" + session_failed.emit("The study session could not be started.") + + +func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void: + var action := pending_action + 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: + session_failed.emit("The study session request failed.") + return + var parsed: Variant = JSON.parse_string(body.get_string_from_utf8()) + if typeof(parsed) != TYPE_DICTIONARY: + session_failed.emit("The study session response was invalid.") + return + var next_progress: Dictionary = parsed.get("progress", {}) + progress = next_progress + active_session_id = str(progress.get("id", "")) if str(progress.get("state", "")) == "active" else "" + var next_summary: Variant = parsed.get("summary", null) + if typeof(next_summary) == TYPE_DICTIONARY: + summary = next_summary + progress_changed.emit() + if action == "create": + session_started.emit() + elif not summary.is_empty(): + session_completed.emit(summary) + + +func _on_auth_changed() -> void: + if not AuthState.is_logged_in: + active_session_id = "" + progress = {} + summary = {} + + +func _new_uuid() -> String: + var bytes := Crypto.new().generate_random_bytes(16) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + var value := bytes.hex_encode() + return "%s-%s-%s-%s-%s" % [value.substr(0, 8), value.substr(8, 4), value.substr(12, 4), value.substr(16, 4), value.substr(20, 12)] diff --git a/scripts/autoload/study_session_service.gd.uid b/scripts/autoload/study_session_service.gd.uid new file mode 100644 index 0000000..f9c2a1f --- /dev/null +++ b/scripts/autoload/study_session_service.gd.uid @@ -0,0 +1 @@ +uid://da63hc4qgjlso diff --git a/scripts/ui/block_setup.gd b/scripts/ui/block_setup.gd index ffbf8b5..cb50181 100644 --- a/scripts/ui/block_setup.gd +++ b/scripts/ui/block_setup.gd @@ -11,12 +11,17 @@ var status_label: Label var start_button: Button var requested_count := 10 var targeted_setup := false +var goal_option: OptionButton +var target_label: Label +var pending_launch := false func _ready() -> void: set_anchors_preset(Control.PRESET_FULL_RECT) add_theme_constant_override("separation", 16) if not QuestionBankService.questions_changed.is_connected(_refresh_setup): QuestionBankService.questions_changed.connect(_refresh_setup) + StudySessionService.session_started.connect(_on_session_started) + StudySessionService.session_failed.connect(_on_session_failed) var title := Label.new() title.text = "Block Setup" @@ -27,9 +32,23 @@ func _ready() -> void: Style.set_label(system_label, 22, Style.CYAN) add_child(system_label) + var goal_row := HBoxContainer.new() + var goal_label := Label.new() + goal_label.text = "Finish line" + goal_row.add_child(goal_label) + goal_option = OptionButton.new() + goal_option.add_item("Questions") + goal_option.set_item_metadata(0, "question_count") + goal_option.add_item("Study duration") + goal_option.set_item_metadata(1, "duration") + goal_option.item_selected.connect(_on_goal_changed) + goal_row.add_child(goal_option) + add_child(goal_row) + var size_row := HBoxContainer.new() - size_row.add_child(Label.new()) - size_row.get_child(0).text = "Question count" + target_label = Label.new() + target_label.text = "Question count" + size_row.add_child(target_label) size_spin = SpinBox.new() size_spin.min_value = 1 size_spin.max_value = 40 @@ -70,13 +89,18 @@ func _ready() -> void: func on_show(payload: Variant = null) -> void: if typeof(payload) == TYPE_DICTIONARY: - targeted_setup = true + targeted_setup = bool(payload.get("mastery_target", false)) selected_system = str(payload.get("step3_system", "All Systems")) requested_count = int(payload.get("question_count", 10)) + goal_option.set_meta("mastery_target", targeted_setup) + var requested_goal := str(payload.get("goal_type", "question_count")) + goal_option.select(1 if requested_goal == "duration" else 0) + _on_goal_changed(goal_option.selected) size_spin.value = requested_count QuestionBankService.load_targeted_questions(selected_system, requested_count, int(payload.get("difficulty_min", 0)), int(payload.get("difficulty_max", 0))) else: + goal_option.set_meta("mastery_target", false) targeted_setup = false selected_system = str(payload) if payload != null else "All Systems" _refresh_setup() @@ -87,8 +111,9 @@ func _refresh_setup() -> void: return system_label.text = "Zone: %s" % selected_system var available := QuestionBankService.get_questions_for_system(selected_system).size() - size_spin.max_value = max(1, available) - if not QuestionBankService.is_loading and available > 0: + if str(goal_option.get_item_metadata(goal_option.selected)) != "duration": + size_spin.max_value = max(5, available) + if not QuestionBankService.is_loading and available > 0 and str(goal_option.get_item_metadata(goal_option.selected)) != "duration": size_spin.value = min(requested_count, available) if targeted_setup else min(size_spin.value, available) targeted_setup = false start_button.disabled = available == 0 or QuestionBankService.is_loading @@ -105,5 +130,45 @@ func _start_block() -> void: if not QuestionBankService.has_questions(): status_label.text = "Questions have not loaded from the API yet." return - BlockSessionService.start_block(selected_system, int(size_spin.value), mode_option.get_item_text(mode_option.selected)) + pending_launch = true + start_button.disabled = true + status_label.text = "Preparing your study goal..." + var mastery_target := bool(goal_option.get_meta("mastery_target", false)) + var goal_type := "weak_system" if mastery_target else str(goal_option.get_item_metadata(goal_option.selected)) + var target := 10 if mastery_target else int(size_spin.value) + StudySessionService.start(goal_type, target, mode_option.get_item_text(mode_option.selected), + selected_system if selected_system != "All Systems" else "") + + +func _on_goal_changed(_index: int) -> void: + if str(goal_option.get_item_metadata(goal_option.selected)) == "duration": + target_label.text = "Minutes" + size_spin.min_value = 10 + size_spin.max_value = 60 + size_spin.step = 5 + size_spin.value = 20 + else: + target_label.text = "Question count" + size_spin.min_value = 5 + size_spin.step = 1 + _refresh_setup() + + +func _on_session_started() -> void: + if not pending_launch or not visible: + return + pending_launch = false + var goal_type := str(StudySessionService.progress.get("goalType", "question_count")) + var block_size := 40 if goal_type == "duration" else int(StudySessionService.progress.get("targetValue", size_spin.value)) + BlockSessionService.start_block(selected_system, block_size, mode_option.get_item_text(mode_option.selected)) + if goal_type == "duration": + BlockSessionService.seconds_remaining = float(int(StudySessionService.progress.get("secondsRemaining", 0))) main.show_screen("player") + + +func _on_session_failed(message: String) -> void: + if not pending_launch or not visible: + return + pending_launch = false + start_button.disabled = false + status_label.text = message diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd index 509ee5f..c7f4b3c 100644 --- a/scripts/ui/dashboard_screen.gd +++ b/scripts/ui/dashboard_screen.gd @@ -487,6 +487,7 @@ func _launch_mastery_action(item: Dictionary) -> void: _start_review_queue(system_name) return main.show_screen("setup", { + "mastery_target": true, "step3_system": system_name, "question_count": int(action.get("questionCount", 10)), "difficulty_min": int(action.get("difficultyMin", 0)) if action.get("difficultyMin") != null else 0, @@ -639,10 +640,28 @@ func _start_review_queue(step3_system: String = "") -> void: return pending_review_launch = true _show_loading_message("Preparing your review queue...") - review_queue_service.start_pass(step3_system) + set_meta("pending_review_system", step3_system) + if not StudySessionService.session_started.is_connected(_on_review_session_started): + StudySessionService.session_started.connect(_on_review_session_started) + if not StudySessionService.session_failed.is_connected(_on_review_session_failed): + StudySessionService.session_failed.connect(_on_review_session_failed) + StudySessionService.start("due_reviews", 20, "Tutor", step3_system) _render_review(DashboardService.data) +func _on_review_session_started() -> void: + if not pending_review_launch or not visible: + return + review_queue_service.start_pass(str(get_meta("pending_review_system", "")), + int(StudySessionService.progress.get("targetValue", 20))) + + +func _on_review_session_failed(message: String) -> void: + if pending_review_launch and visible: + pending_review_launch = false + _show_error(message) + + func _on_review_question_loaded(question: Dictionary) -> void: if not pending_review_launch or not visible: return diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 2a12b2d..13d119a 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -39,12 +39,17 @@ func _ready() -> void: Style.apply_app_theme(self) _build_ui() CreditsService.award_received.connect(_show_coin_award) + StudySessionService.session_completed.connect(_on_study_session_completed) theme_service.theme_changed.connect(_on_theme_changed) resized.connect(_update_responsive_layout) _update_responsive_layout() show_screen(("legal_consent" if AuthState.requires_legal_acceptance else "dashboard") if AuthState.is_logged_in else "login") +func _on_study_session_completed(summary: Dictionary) -> void: + call_deferred("show_screen", "session_summary", summary) + + func _show_coin_award(amount: int, reason: String) -> void: var toast := PanelContainer.new() toast.z_index = 200 @@ -287,6 +292,12 @@ func _build_ui() -> void: screens_container.add_child(review) screens["review"] = review + var session_summary := preload("res://scripts/ui/session_summary.gd").new() + session_summary.name = "SessionSummary" + session_summary.main = self + screens_container.add_child(session_summary) + screens["session_summary"] = session_summary + var review_queue_results := preload("res://scripts/ui/review_queue_results.gd").new() review_queue_results.name = "ReviewQueueResults" review_queue_results.main = self @@ -499,6 +510,7 @@ func _update_header_title(screen_name: String) -> void: "dynamic_questions": "Dynamic Questions", "simulation_history": "Exam Simulations", "simulation_results": "Exam Simulations", + "session_summary": "Session Summary", "ccs_hub": "CCS Cases", "flashcards_hub": "Flashcards", "flashcards_study": "Flashcards", diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index d1eebb9..ed4b2a0 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -1224,13 +1224,16 @@ func _process(delta: float) -> void: return if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core": _update_timer_bar() - if visible and BlockSessionService.block_started and BlockSessionService.mode == "Timed" and (report_overlay == null or not report_overlay.visible): + var duration_goal := str(StudySessionService.progress.get("goalType", "")) == "duration" + if visible and BlockSessionService.block_started and (BlockSessionService.mode == "Timed" or duration_goal) and (report_overlay == null or not report_overlay.visible): BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta) var shown_seconds := int(ceil(BlockSessionService.seconds_remaining)) _set_timer_display(shown_seconds) _update_timer_bar() - if BlockSessionService.seconds_remaining <= 0: + if BlockSessionService.seconds_remaining <= 0 and not duration_goal: _finish() + elif BlockSessionService.seconds_remaining <= 0 and duration_goal: + timer_label.text = "Finish current question" if visible and not _is_session_timer_visible() and (report_overlay == null or not report_overlay.visible): # No real time limit applies here - show a simple local count-up stopwatch # ("Time elapsed") so the bottom-bar time slot is never blank, without @@ -2081,6 +2084,12 @@ func _open_reference_url(url: String) -> void: func _advance_or_finish() -> void: + if not StudySessionService.summary.is_empty(): + StudySessionService.emit_deferred_summary() + return + if str(StudySessionService.progress.get("goalType", "")) == "duration" and BlockSessionService.seconds_remaining <= 0: + StudySessionService.check_completion() + return if BlockSessionService.move_next(): _load_question() else: @@ -2105,6 +2114,8 @@ func _finish() -> void: func _exit_run() -> void: + if StudySessionService.is_active() and BlockSessionService.session_kind != "simulation": + StudySessionService.finish_early() if BlockSessionService.session_kind == "bookmark": BlockSessionService.finish_block() main.show_screen("bookmarks") @@ -2157,6 +2168,8 @@ func _set_timer_display(total_seconds: int) -> void: func _is_session_timer_visible() -> bool: if BlockSessionService.session_kind == "core" or BlockSessionService.session_kind == "simulation": return true + if str(StudySessionService.progress.get("goalType", "")) == "duration": + return true return BlockSessionService.mode == "Timed" diff --git a/scripts/ui/review_queue_results.gd b/scripts/ui/review_queue_results.gd index b68934a..5533651 100644 --- a/scripts/ui/review_queue_results.gd +++ b/scripts/ui/review_queue_results.gd @@ -84,7 +84,21 @@ func _start_another() -> void: another_button.disabled = true status_label.text = "Preparing another review pass..." status_label.visible = true - ReviewQueueService.start_pass() + if not StudySessionService.session_started.is_connected(_on_review_session_started): + StudySessionService.session_started.connect(_on_review_session_started) + if not StudySessionService.session_failed.is_connected(_on_review_session_failed): + StudySessionService.session_failed.connect(_on_review_session_failed) + StudySessionService.start("due_reviews", 20, "Tutor") + + +func _on_review_session_started() -> void: + if launch_pending and visible: + ReviewQueueService.start_pass("", int(StudySessionService.progress.get("targetValue", 20))) + + +func _on_review_session_failed(message: String) -> void: + if launch_pending and visible: + _on_question_failed(message) func _on_question_loaded(question: Dictionary) -> void: diff --git a/scripts/ui/session_summary.gd b/scripts/ui/session_summary.gd new file mode 100644 index 0000000..be20b48 --- /dev/null +++ b/scripts/ui/session_summary.gd @@ -0,0 +1,115 @@ +extends VBoxContainer + +const Brand := preload("res://scripts/ui/medmind_style.gd") + +var main: Control +var current_summary: Dictionary = {} +var content: VBoxContainer +var continue_pending := false + + +func _ready() -> void: + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + alignment = BoxContainer.ALIGNMENT_CENTER + content = VBoxContainer.new() + content.custom_minimum_size = Vector2(420, 0) + content.size_flags_horizontal = Control.SIZE_SHRINK_CENTER + content.add_theme_constant_override("separation", 14) + add_child(content) + StudySessionService.session_started.connect(_on_continuation_started) + UIThemeService.theme_changed.connect(func(_dark: bool): _render()) + + +func on_show(payload: Variant = null) -> void: + current_summary = payload if typeof(payload) == TYPE_DICTIONARY else StudySessionService.summary + continue_pending = false + _render() + + +func _render() -> void: + for child in content.get_children(): + child.queue_free() + var dark := UIThemeService.is_dark_mode() + var completed := str(current_summary.get("state", "")) == "completed" + content.add_child(_label("Session complete" if completed else "Session ended", 30, Brand.SUCCESS if completed else Brand.AMBER)) + var questions := int(current_summary.get("questionsCompleted", 0)) + var accuracy := int(current_summary.get("accuracy", 0)) + var elapsed := int(current_summary.get("elapsedSeconds", 0)) + var detail := "Questions completed: %d\nFirst-attempt accuracy: %d%%\nStudy time: %d min %02d sec" % [ + questions, accuracy, elapsed / 60, elapsed % 60] + if int(current_summary.get("reviewsCompleted", 0)) > 0: + detail += "\nScheduled reviews: %d · Recovered: %d" % [current_summary.get("reviewsCompleted", 0), current_summary.get("recovered", 0)] + var mastery_change: Variant = current_summary.get("masteryScoreChange", null) + if mastery_change != null: + detail += "\n%s mastery: %+d points (%s → %s)" % [ + str(current_summary.get("step3System", "System")), int(mastery_change), + str(current_summary.get("initialMasteryState", "")), str(current_summary.get("finalMasteryState", ""))] + detail += "\nRZ Coins earned: %d" % int(current_summary.get("coinsEarned", 0)) + var award_lines: Array[String] = [] + for award in current_summary.get("coinAwards", []): + if typeof(award) == TYPE_DICTIONARY: + award_lines.append("+%d %s" % [int(award.get("amount", 0)), str(award.get("description", "RZ Coin award"))]) + if not award_lines.is_empty(): + detail += "\nAwards: %s" % "; ".join(award_lines) + var body := _label(detail, 17, Brand.text_color(dark)) + body.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(body) + var readiness := _label(str(current_summary.get("readinessExplanation", "A completed exam simulation is required to update readiness.")), 13, Brand.muted_color(dark)) + readiness.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(readiness) + var recommendation := _label(_recommendation(), 15, Brand.SKY_BLUE) + recommendation.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + content.add_child(recommendation) + var actions := HBoxContainer.new() + actions.add_theme_constant_override("separation", 12) + var finish := Button.new() + finish.text = "Finish for today" + finish.custom_minimum_size = Vector2(220, 52) + finish.size_flags_horizontal = Control.SIZE_EXPAND_FILL + finish.pressed.connect(func(): DashboardService.refresh(); main.show_screen("dashboard")) + actions.add_child(finish) + var continue_button := Button.new() + continue_button.text = "Continue with another block" + continue_button.custom_minimum_size = Vector2(220, 52) + continue_button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + continue_button.pressed.connect(_continue) + actions.add_child(continue_button) + content.add_child(actions) + + +func _continue() -> void: + var goal := str(current_summary.get("goalType", "question_count")) + if goal == "simulation_block": + main.show_screen("simulation_break") + return + if goal == "due_reviews": + continue_pending = true + StudySessionService.start("due_reviews", 20, "Tutor", str(current_summary.get("step3System", ""))) + return + main.show_screen("setup", { + "step3_system": str(current_summary.get("step3System", "All Systems")), + "question_count": int(current_summary.get("targetValue", 10)), + "goal_type": goal + }) + + +func _on_continuation_started() -> void: + if continue_pending and visible: + continue_pending = false + ReviewQueueService.start_pass(str(current_summary.get("step3System", "")), + int(StudySessionService.progress.get("targetValue", 20))) + + +func _recommendation() -> String: + if int(current_summary.get("recovered", 0)) > 0: + return "Recommended next session: reinforce another weak system or complete the next scheduled reviews." + return "Recommended next session: continue the same goal, or finish for today and return refreshed." + + +func _label(text: String, size: int, color: Color) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_override("font", Brand.font) + label.add_theme_font_size_override("font_size", size) + label.add_theme_color_override("font_color", color) + return label diff --git a/scripts/ui/session_summary.gd.uid b/scripts/ui/session_summary.gd.uid new file mode 100644 index 0000000..eec69c6 --- /dev/null +++ b/scripts/ui/session_summary.gd.uid @@ -0,0 +1 @@ +uid://gj2gvcawmgng