Add study session goals and summaries
This commit is contained in:
@@ -12,7 +12,8 @@ namespace QuestionsApi.Controllers;
|
|||||||
public class ExamSimulationsController(
|
public class ExamSimulationsController(
|
||||||
ExamSimulationStore simulations,
|
ExamSimulationStore simulations,
|
||||||
CreditStore credits,
|
CreditStore credits,
|
||||||
MasteryAwardService masteryAwards) : ControllerBase
|
MasteryAwardService masteryAwards,
|
||||||
|
StudySessionStore studySessions) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("readiness")]
|
[HttpGet("readiness")]
|
||||||
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
public async Task<IActionResult> Readiness() => Ok(await simulations.GetReadinessAsync(UserId()));
|
||||||
@@ -116,7 +117,12 @@ public class ExamSimulationsController(
|
|||||||
{
|
{
|
||||||
var simulation = await simulations.GetOwnedAsync(id, UserId());
|
var simulation = await simulations.GetOwnedAsync(id, UserId());
|
||||||
if (simulation is null) return NotFound();
|
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); }
|
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +147,12 @@ public class ExamSimulationsController(
|
|||||||
await masteryAwards.PrepareAsync(userId);
|
await masteryAwards.PrepareAsync(userId);
|
||||||
simulation = await simulations.SubmitBlockAsync(simulation);
|
simulation = await simulations.SubmitBlockAsync(simulation);
|
||||||
var response = await simulations.ToResponseAsync(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)
|
if (simulation.State == ExamSimulationStates.Completed && !simulation.IsSample)
|
||||||
{
|
{
|
||||||
var result = await credits.AwardAsync(userId, new CreditAwardRequest(
|
var result = await credits.AwardAsync(userId, new CreditAwardRequest(
|
||||||
@@ -150,6 +162,8 @@ public class ExamSimulationsController(
|
|||||||
response.CreditAwards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
|
response.CreditAwards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
|
||||||
response.Credits = await credits.GetStatusAsync(userId);
|
response.Credits = await credits.GetStatusAsync(userId);
|
||||||
}
|
}
|
||||||
|
else if (session.Summary is not null)
|
||||||
|
response.Credits = await credits.GetStatusAsync(userId);
|
||||||
return Ok(response);
|
return Ok(response);
|
||||||
}
|
}
|
||||||
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
catch (InvalidOperationException exception) { return Conflict(exception.Message); }
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ public class QuestionsController : ControllerBase
|
|||||||
private readonly ReviewQueueStore _reviewQueue;
|
private readonly ReviewQueueStore _reviewQueue;
|
||||||
private readonly CreditStore _credits;
|
private readonly CreditStore _credits;
|
||||||
private readonly MasteryAwardService _masteryAwards;
|
private readonly MasteryAwardService _masteryAwards;
|
||||||
|
private readonly StudySessionStore _studySessions;
|
||||||
|
|
||||||
public QuestionsController(
|
public QuestionsController(
|
||||||
QuestionStore questions,
|
QuestionStore questions,
|
||||||
@@ -32,7 +33,8 @@ public class QuestionsController : ControllerBase
|
|||||||
QuestionBookmarkStore bookmarks,
|
QuestionBookmarkStore bookmarks,
|
||||||
ReviewQueueStore reviewQueue,
|
ReviewQueueStore reviewQueue,
|
||||||
CreditStore credits,
|
CreditStore credits,
|
||||||
MasteryAwardService masteryAwards)
|
MasteryAwardService masteryAwards,
|
||||||
|
StudySessionStore studySessions)
|
||||||
{
|
{
|
||||||
_questions = questions;
|
_questions = questions;
|
||||||
_attempts = attempts;
|
_attempts = attempts;
|
||||||
@@ -44,6 +46,7 @@ public class QuestionsController : ControllerBase
|
|||||||
_reviewQueue = reviewQueue;
|
_reviewQueue = reviewQueue;
|
||||||
_credits = credits;
|
_credits = credits;
|
||||||
_masteryAwards = masteryAwards;
|
_masteryAwards = masteryAwards;
|
||||||
|
_studySessions = studySessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("systems")]
|
[HttpGet("systems")]
|
||||||
@@ -221,6 +224,14 @@ public class QuestionsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
creditAwards.AddRange(await _masteryAwards.AwardPromotionsAsync(userId));
|
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
|
return Ok(new FirstQuestionAttemptResponse
|
||||||
{
|
{
|
||||||
QuestionExternalId = question.ExternalId,
|
QuestionExternalId = question.ExternalId,
|
||||||
@@ -230,7 +241,9 @@ public class QuestionsController : ControllerBase
|
|||||||
QuestionElo = rating.QuestionElo,
|
QuestionElo = rating.QuestionElo,
|
||||||
Rating = rating,
|
Rating = rating,
|
||||||
Credits = await _credits.GetStatusAsync(userId),
|
Credits = await _credits.GetStatusAsync(userId),
|
||||||
CreditAwards = creditAwards
|
CreditAwards = creditAwards,
|
||||||
|
StudySessionProgress = studySession?.Progress,
|
||||||
|
SessionSummary = studySession?.Summary
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ public sealed class ReviewQueueController(
|
|||||||
RatingStore ratings,
|
RatingStore ratings,
|
||||||
QuestionBookmarkStore bookmarks,
|
QuestionBookmarkStore bookmarks,
|
||||||
CreditStore credits,
|
CreditStore credits,
|
||||||
MasteryAwardService masteryAwards) : ControllerBase
|
MasteryAwardService masteryAwards,
|
||||||
|
StudySessionStore studySessions) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpPost("next")]
|
[HttpPost("next")]
|
||||||
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
|
public async Task<IActionResult> Next([FromBody] ReviewQueueNextRequest req)
|
||||||
@@ -85,9 +86,10 @@ public sealed class ReviewQueueController(
|
|||||||
req.DurationSeconds);
|
req.DurationSeconds);
|
||||||
if (result is null) return Conflict("review cycle is no longer active");
|
if (result is null) return Conflict("review cycle is no longer active");
|
||||||
var awards = new List<CreditAward>();
|
var awards = new List<CreditAward>();
|
||||||
|
var delayed = false;
|
||||||
if (result.Attempt.IsCorrect)
|
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);
|
&& result.Attempt.CreatedUtc - result.Item.CreatedUtc >= TimeSpan.FromHours(24);
|
||||||
var award = await credits.AwardAsync(userId, new CreditAwardRequest(
|
var award = await credits.AwardAsync(userId, new CreditAwardRequest(
|
||||||
$"review:{result.Attempt.Id}", CreditEconomy.ReviewAmount(delayed),
|
$"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);
|
if (award.Award is not null) awards.Add(award.Award);
|
||||||
}
|
}
|
||||||
awards.AddRange(await masteryAwards.AwardPromotionsAsync(userId));
|
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
|
return Ok(new ReviewQueueAttemptResponse
|
||||||
{
|
{
|
||||||
QuestionExternalId = question.ExternalId,
|
QuestionExternalId = question.ExternalId,
|
||||||
@@ -104,7 +114,9 @@ public sealed class ReviewQueueController(
|
|||||||
QueueState = result.Item?.State ?? ReviewQueueStates.Pending,
|
QueueState = result.Item?.State ?? ReviewQueueStates.Pending,
|
||||||
PendingCount = checked((int)await queue.CountPendingAsync(userId)),
|
PendingCount = checked((int)await queue.CountPendingAsync(userId)),
|
||||||
Credits = await credits.GetStatusAsync(userId),
|
Credits = await credits.GetStatusAsync(userId),
|
||||||
CreditAwards = awards
|
CreditAwards = awards,
|
||||||
|
StudySessionProgress = studySession?.Progress,
|
||||||
|
SessionSummary = studySession?.Summary
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<IActionResult> 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<IActionResult> Active()
|
||||||
|
{
|
||||||
|
var session = await sessions.GetActiveAsync(UserId());
|
||||||
|
return session is null ? NoContent() : Ok(sessions.Envelope(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("{id}/finish")]
|
||||||
|
public async Task<IActionResult> Finish(string id)
|
||||||
|
{
|
||||||
|
var result = await sessions.FinishEarlyAsync(id, UserId());
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/summary")]
|
||||||
|
public async Task<IActionResult> 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<IActionResult> 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();
|
||||||
|
}
|
||||||
@@ -109,6 +109,7 @@ public static class CreditReasonCodes
|
|||||||
public const string MasteryStrong = "mastery_strong";
|
public const string MasteryStrong = "mastery_strong";
|
||||||
public const string MasteryMastered = "mastery_mastered";
|
public const string MasteryMastered = "mastery_mastered";
|
||||||
public const string ExamSimulationComplete = "exam_simulation_complete";
|
public const string ExamSimulationComplete = "exam_simulation_complete";
|
||||||
|
public const string SessionGoalComplete = "session_goal_complete";
|
||||||
public const string Purchase = "purchase";
|
public const string Purchase = "purchase";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ public class ExamSimulationStateResponse
|
|||||||
public List<QuestionResponse> ReviewQuestions { get; set; } = [];
|
public List<QuestionResponse> ReviewQuestions { get; set; } = [];
|
||||||
public CreditStatusResponse? Credits { get; set; }
|
public CreditStatusResponse? Credits { get; set; }
|
||||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||||
|
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||||
|
public StudySessionSummary? SessionSummary { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class ExamSimulationReadinessResponse
|
public class ExamSimulationReadinessResponse
|
||||||
|
|||||||
@@ -51,4 +51,5 @@ public class FirstQuestionAttemptRequest
|
|||||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||||
public int DurationSeconds { get; set; }
|
public int DurationSeconds { get; set; }
|
||||||
public bool MetTimeLimit { get; set; } = true;
|
public bool MetTimeLimit { get; set; } = true;
|
||||||
|
public string? StudySessionId { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ public class FirstQuestionAttemptResponse
|
|||||||
public RatingChangeResponse? Rating { get; set; }
|
public RatingChangeResponse? Rating { get; set; }
|
||||||
public CreditStatusResponse? Credits { get; set; }
|
public CreditStatusResponse? Credits { get; set; }
|
||||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||||
|
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||||
|
public StudySessionSummary? SessionSummary { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SkipQuestionResponse
|
public class SkipQuestionResponse
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public sealed class ReviewQueueAttemptRequest
|
|||||||
public string ChoiceId { get; set; } = "";
|
public string ChoiceId { get; set; } = "";
|
||||||
public List<string> PresentedChoiceIds { get; set; } = [];
|
public List<string> PresentedChoiceIds { get; set; } = [];
|
||||||
public int DurationSeconds { get; set; }
|
public int DurationSeconds { get; set; }
|
||||||
|
public string? StudySessionId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ReviewQueueAttemptResponse
|
public sealed class ReviewQueueAttemptResponse
|
||||||
@@ -83,6 +84,8 @@ public sealed class ReviewQueueAttemptResponse
|
|||||||
public int PendingCount { get; set; }
|
public int PendingCount { get; set; }
|
||||||
public CreditStatusResponse? Credits { get; set; }
|
public CreditStatusResponse? Credits { get; set; }
|
||||||
public List<CreditAward> CreditAwards { get; set; } = [];
|
public List<CreditAward> CreditAwards { get; set; } = [];
|
||||||
|
public StudySessionProgress? StudySessionProgress { get; set; }
|
||||||
|
public StudySessionSummary? SessionSummary { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ReviewQueueRecordResult
|
public sealed class ReviewQueueRecordResult
|
||||||
|
|||||||
@@ -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<string> 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<CreditAward> CoinAwards { get; set; } = [];
|
||||||
|
public List<string> 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<CreditAward> 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; }
|
||||||
|
}
|
||||||
@@ -111,6 +111,7 @@ builder.Services.AddSingleton<UserNotepadStore>();
|
|||||||
builder.Services.AddSingleton<QuestionBookmarkStore>();
|
builder.Services.AddSingleton<QuestionBookmarkStore>();
|
||||||
builder.Services.AddSingleton<ReviewQueueStore>();
|
builder.Services.AddSingleton<ReviewQueueStore>();
|
||||||
builder.Services.AddSingleton<DashboardStore>();
|
builder.Services.AddSingleton<DashboardStore>();
|
||||||
|
builder.Services.AddSingleton<StudySessionStore>();
|
||||||
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>();
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using QuestionsApi.Models;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Services;
|
||||||
|
|
||||||
|
public sealed class StudySessionStore
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<StudySession> _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<StudySession>("StudySessions");
|
||||||
|
_dashboard = dashboard;
|
||||||
|
_credits = credits;
|
||||||
|
_reviews = reviews;
|
||||||
|
_clock = clock;
|
||||||
|
_sessions.Indexes.CreateOne(new CreateIndexModel<StudySession>(
|
||||||
|
Builders<StudySession>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.ClientSessionId),
|
||||||
|
new CreateIndexOptions { Unique = true, Name = "uq_study_session_client" }));
|
||||||
|
_sessions.Indexes.CreateOne(new CreateIndexModel<StudySession>(
|
||||||
|
Builders<StudySession>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State),
|
||||||
|
new CreateIndexOptions { Name = "study_session_user_state" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<StudySession> 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<StudySession>.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<StudySession?> 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<StudySession?> GetOwnedAsync(string id, string userId) =>
|
||||||
|
await _sessions.Find(value => value.Id == id && value.UserId == userId).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public async Task<StudySessionEnvelope?> RecordQuestionAsync(
|
||||||
|
string id, string userId, QuestionAttempt attempt, Question question, IReadOnlyCollection<CreditAward> 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<StudySessionEnvelope?> RecordReviewAsync(
|
||||||
|
string id, string userId, QuestionReviewAttempt attempt, bool recovered, IReadOnlyCollection<CreditAward> 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<StudySessionEnvelope?> 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<StudySession>.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<StudySessionEnvelope?> CheckAsync(string id, string userId) => EvaluateAsync(id, userId);
|
||||||
|
|
||||||
|
public async Task<StudySession> 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<StudySession>.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<StudySessionEnvelope> 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<StudySession>.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<CreditAward> awards)
|
||||||
|
{
|
||||||
|
var now = _clock.GetUtcNow().UtcDateTime;
|
||||||
|
var filter = Builders<StudySession>.Filter.Eq(value => value.Id, session.Id)
|
||||||
|
& Builders<StudySession>.Filter.Eq(value => value.State, StudySessionStates.Active)
|
||||||
|
& Builders<StudySession>.Filter.AnyNin(value => value.AppliedEventIds, [eventId]);
|
||||||
|
var update = Builders<StudySession>.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<StudySessionEnvelope?> 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<StudySession>.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<StudySession>.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<StudySession>.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<StudySession>.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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
"UserCreditAccounts", "CreditLedgerEntries", "StudySessions"
|
||||||
];
|
];
|
||||||
|
|
||||||
public UserProfileStore(MongoDatabaseProvider mongo)
|
public UserProfileStore(MongoDatabaseProvider mongo)
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ social notifications.
|
|||||||
|
|
||||||
- [x] Define the mastery model and expose topic-level progress.
|
- [x] Define the mastery model and expose topic-level progress.
|
||||||
- [x] Add server-authoritative RZ Coin awards for approved learning events.
|
- [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.
|
- [ ] Add daily missions with one replacement per day.
|
||||||
|
|
||||||
### Phase 2: progression
|
### Phase 2: progression
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
|||||||
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
ReviewQueueService="*res://scripts/autoload/review_queue_service.gd"
|
||||||
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
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"
|
||||||
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"
|
||||||
|
|||||||
@@ -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.")
|
request_failed.emit("The simulation API returned an invalid response.")
|
||||||
return
|
return
|
||||||
CreditsService.apply_award_response(parsed)
|
CreditsService.apply_award_response(parsed)
|
||||||
|
StudySessionService.apply_response(parsed)
|
||||||
if action == "readiness":
|
if action == "readiness":
|
||||||
readiness = parsed
|
readiness = parsed
|
||||||
readiness_changed.emit()
|
readiness_changed.emit()
|
||||||
|
|||||||
@@ -437,6 +437,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
|
|||||||
"presented_choice_ids": presented_choice_ids.duplicate(),
|
"presented_choice_ids": presented_choice_ids.duplicate(),
|
||||||
"duration_seconds": maxi(0, duration_seconds),
|
"duration_seconds": maxi(0, duration_seconds),
|
||||||
"met_time_limit": met_time_limit,
|
"met_time_limit": met_time_limit,
|
||||||
|
"study_session_id": StudySessionService.active_session_id,
|
||||||
"retry_count": 0
|
"retry_count": 0
|
||||||
})
|
})
|
||||||
_save_attempt_queue()
|
_save_attempt_queue()
|
||||||
@@ -472,6 +473,11 @@ func _process_attempt_queue() -> void:
|
|||||||
"durationSeconds": int(queued.get("duration_seconds", 0)),
|
"durationSeconds": int(queued.get("duration_seconds", 0)),
|
||||||
"metTimeLimit": bool(queued.get("met_time_limit", true))
|
"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":
|
if str(queued.get("kind", "answer")) == "skip":
|
||||||
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()]
|
||||||
body = ""
|
body = ""
|
||||||
@@ -546,6 +552,8 @@ func _apply_first_attempt_response(parsed: Dictionary) -> void:
|
|||||||
_update_question_elo(question_id, question_elo)
|
_update_question_elo(question_id, question_elo)
|
||||||
_apply_rating_change(rating)
|
_apply_rating_change(rating)
|
||||||
CreditsService.apply_award_response(parsed)
|
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)
|
choice_percentages_updated.emit(question_id, percentages)
|
||||||
question_rating_updated.emit(question_id, question_elo, rating)
|
question_rating_updated.emit(question_id, question_elo, rating)
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ var retained_count := 0
|
|||||||
var outbox: Array = []
|
var outbox: Array = []
|
||||||
var active_account_key := ""
|
var active_account_key := ""
|
||||||
var step3_system := ""
|
var step3_system := ""
|
||||||
|
var target_count := 20
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -49,8 +50,9 @@ func _ready() -> void:
|
|||||||
_on_auth_changed()
|
_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
|
step3_system = requested_step3_system
|
||||||
|
target_count = clampi(requested_count, 1, 20)
|
||||||
excluded_question_ids.clear()
|
excluded_question_ids.clear()
|
||||||
reviewed_count = 0
|
reviewed_count = 0
|
||||||
cleared_count = 0
|
cleared_count = 0
|
||||||
@@ -59,6 +61,10 @@ func start_pass(requested_step3_system: String = "") -> void:
|
|||||||
|
|
||||||
|
|
||||||
func load_next() -> void:
|
func load_next() -> void:
|
||||||
|
if reviewed_count >= target_count:
|
||||||
|
StudySessionService.emit_deferred_summary()
|
||||||
|
pass_completed.emit(get_summary())
|
||||||
|
return
|
||||||
if loading:
|
if loading:
|
||||||
return
|
return
|
||||||
if not AuthState.can_send_authenticated_request():
|
if not AuthState.can_send_authenticated_request():
|
||||||
@@ -99,7 +105,8 @@ func record_first_submission(question: Dictionary, choice_id: String, presented_
|
|||||||
"cycle": current_cycle,
|
"cycle": current_cycle,
|
||||||
"choiceId": choice_id,
|
"choiceId": choice_id,
|
||||||
"presentedChoiceIds": presented_choice_ids.duplicate(),
|
"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})
|
outbox.append({"account": _account_key(), "payload": payload, "retry_count": 0})
|
||||||
_save_outbox()
|
_save_outbox()
|
||||||
@@ -191,6 +198,7 @@ func _on_attempt_completed(result: int, response_code: int, _headers_value: Pack
|
|||||||
var synced_is_correct := false
|
var synced_is_correct := false
|
||||||
if typeof(parsed) == TYPE_DICTIONARY:
|
if typeof(parsed) == TYPE_DICTIONARY:
|
||||||
CreditsService.apply_award_response(parsed)
|
CreditsService.apply_award_response(parsed)
|
||||||
|
StudySessionService.apply_response(parsed, true)
|
||||||
pending_count = int(parsed.get("pendingCount", parsed.get("pending_count", pending_count)))
|
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_question_id = str(parsed.get("questionExternalId", parsed.get("question_external_id", "")))
|
||||||
synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false)))
|
synced_is_correct = bool(parsed.get("isCorrect", parsed.get("is_correct", false)))
|
||||||
|
|||||||
@@ -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)]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://da63hc4qgjlso
|
||||||
@@ -11,12 +11,17 @@ var status_label: Label
|
|||||||
var start_button: Button
|
var start_button: Button
|
||||||
var requested_count := 10
|
var requested_count := 10
|
||||||
var targeted_setup := false
|
var targeted_setup := false
|
||||||
|
var goal_option: OptionButton
|
||||||
|
var target_label: Label
|
||||||
|
var pending_launch := false
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
set_anchors_preset(Control.PRESET_FULL_RECT)
|
set_anchors_preset(Control.PRESET_FULL_RECT)
|
||||||
add_theme_constant_override("separation", 16)
|
add_theme_constant_override("separation", 16)
|
||||||
if not QuestionBankService.questions_changed.is_connected(_refresh_setup):
|
if not QuestionBankService.questions_changed.is_connected(_refresh_setup):
|
||||||
QuestionBankService.questions_changed.connect(_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()
|
var title := Label.new()
|
||||||
title.text = "Block Setup"
|
title.text = "Block Setup"
|
||||||
@@ -27,9 +32,23 @@ func _ready() -> void:
|
|||||||
Style.set_label(system_label, 22, Style.CYAN)
|
Style.set_label(system_label, 22, Style.CYAN)
|
||||||
add_child(system_label)
|
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()
|
var size_row := HBoxContainer.new()
|
||||||
size_row.add_child(Label.new())
|
target_label = Label.new()
|
||||||
size_row.get_child(0).text = "Question count"
|
target_label.text = "Question count"
|
||||||
|
size_row.add_child(target_label)
|
||||||
size_spin = SpinBox.new()
|
size_spin = SpinBox.new()
|
||||||
size_spin.min_value = 1
|
size_spin.min_value = 1
|
||||||
size_spin.max_value = 40
|
size_spin.max_value = 40
|
||||||
@@ -70,13 +89,18 @@ func _ready() -> void:
|
|||||||
|
|
||||||
func on_show(payload: Variant = null) -> void:
|
func on_show(payload: Variant = null) -> void:
|
||||||
if typeof(payload) == TYPE_DICTIONARY:
|
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"))
|
selected_system = str(payload.get("step3_system", "All Systems"))
|
||||||
requested_count = int(payload.get("question_count", 10))
|
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
|
size_spin.value = requested_count
|
||||||
QuestionBankService.load_targeted_questions(selected_system, requested_count,
|
QuestionBankService.load_targeted_questions(selected_system, requested_count,
|
||||||
int(payload.get("difficulty_min", 0)), int(payload.get("difficulty_max", 0)))
|
int(payload.get("difficulty_min", 0)), int(payload.get("difficulty_max", 0)))
|
||||||
else:
|
else:
|
||||||
|
goal_option.set_meta("mastery_target", false)
|
||||||
targeted_setup = false
|
targeted_setup = false
|
||||||
selected_system = str(payload) if payload != null else "All Systems"
|
selected_system = str(payload) if payload != null else "All Systems"
|
||||||
_refresh_setup()
|
_refresh_setup()
|
||||||
@@ -87,8 +111,9 @@ func _refresh_setup() -> void:
|
|||||||
return
|
return
|
||||||
system_label.text = "Zone: %s" % selected_system
|
system_label.text = "Zone: %s" % selected_system
|
||||||
var available := QuestionBankService.get_questions_for_system(selected_system).size()
|
var available := QuestionBankService.get_questions_for_system(selected_system).size()
|
||||||
size_spin.max_value = max(1, available)
|
if str(goal_option.get_item_metadata(goal_option.selected)) != "duration":
|
||||||
if not QuestionBankService.is_loading and available > 0:
|
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)
|
size_spin.value = min(requested_count, available) if targeted_setup else min(size_spin.value, available)
|
||||||
targeted_setup = false
|
targeted_setup = false
|
||||||
start_button.disabled = available == 0 or QuestionBankService.is_loading
|
start_button.disabled = available == 0 or QuestionBankService.is_loading
|
||||||
@@ -105,5 +130,45 @@ func _start_block() -> void:
|
|||||||
if not QuestionBankService.has_questions():
|
if not QuestionBankService.has_questions():
|
||||||
status_label.text = "Questions have not loaded from the API yet."
|
status_label.text = "Questions have not loaded from the API yet."
|
||||||
return
|
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")
|
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
|
||||||
|
|||||||
@@ -487,6 +487,7 @@ func _launch_mastery_action(item: Dictionary) -> void:
|
|||||||
_start_review_queue(system_name)
|
_start_review_queue(system_name)
|
||||||
return
|
return
|
||||||
main.show_screen("setup", {
|
main.show_screen("setup", {
|
||||||
|
"mastery_target": true,
|
||||||
"step3_system": system_name,
|
"step3_system": system_name,
|
||||||
"question_count": int(action.get("questionCount", 10)),
|
"question_count": int(action.get("questionCount", 10)),
|
||||||
"difficulty_min": int(action.get("difficultyMin", 0)) if action.get("difficultyMin") != null else 0,
|
"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
|
return
|
||||||
pending_review_launch = true
|
pending_review_launch = true
|
||||||
_show_loading_message("Preparing your review queue...")
|
_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)
|
_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:
|
func _on_review_question_loaded(question: Dictionary) -> void:
|
||||||
if not pending_review_launch or not visible:
|
if not pending_review_launch or not visible:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -39,12 +39,17 @@ func _ready() -> void:
|
|||||||
Style.apply_app_theme(self)
|
Style.apply_app_theme(self)
|
||||||
_build_ui()
|
_build_ui()
|
||||||
CreditsService.award_received.connect(_show_coin_award)
|
CreditsService.award_received.connect(_show_coin_award)
|
||||||
|
StudySessionService.session_completed.connect(_on_study_session_completed)
|
||||||
theme_service.theme_changed.connect(_on_theme_changed)
|
theme_service.theme_changed.connect(_on_theme_changed)
|
||||||
resized.connect(_update_responsive_layout)
|
resized.connect(_update_responsive_layout)
|
||||||
_update_responsive_layout()
|
_update_responsive_layout()
|
||||||
show_screen(("legal_consent" if AuthState.requires_legal_acceptance else "dashboard") if AuthState.is_logged_in else "login")
|
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:
|
func _show_coin_award(amount: int, reason: String) -> void:
|
||||||
var toast := PanelContainer.new()
|
var toast := PanelContainer.new()
|
||||||
toast.z_index = 200
|
toast.z_index = 200
|
||||||
@@ -287,6 +292,12 @@ func _build_ui() -> void:
|
|||||||
screens_container.add_child(review)
|
screens_container.add_child(review)
|
||||||
screens["review"] = 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()
|
var review_queue_results := preload("res://scripts/ui/review_queue_results.gd").new()
|
||||||
review_queue_results.name = "ReviewQueueResults"
|
review_queue_results.name = "ReviewQueueResults"
|
||||||
review_queue_results.main = self
|
review_queue_results.main = self
|
||||||
@@ -499,6 +510,7 @@ func _update_header_title(screen_name: String) -> void:
|
|||||||
"dynamic_questions": "Dynamic Questions",
|
"dynamic_questions": "Dynamic Questions",
|
||||||
"simulation_history": "Exam Simulations",
|
"simulation_history": "Exam Simulations",
|
||||||
"simulation_results": "Exam Simulations",
|
"simulation_results": "Exam Simulations",
|
||||||
|
"session_summary": "Session Summary",
|
||||||
"ccs_hub": "CCS Cases",
|
"ccs_hub": "CCS Cases",
|
||||||
"flashcards_hub": "Flashcards",
|
"flashcards_hub": "Flashcards",
|
||||||
"flashcards_study": "Flashcards",
|
"flashcards_study": "Flashcards",
|
||||||
|
|||||||
@@ -1224,13 +1224,16 @@ func _process(delta: float) -> void:
|
|||||||
return
|
return
|
||||||
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
|
if visible and BlockSessionService.block_started and BlockSessionService.session_kind == "core":
|
||||||
_update_timer_bar()
|
_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)
|
BlockSessionService.seconds_remaining = max(0.0, BlockSessionService.seconds_remaining - delta)
|
||||||
var shown_seconds := int(ceil(BlockSessionService.seconds_remaining))
|
var shown_seconds := int(ceil(BlockSessionService.seconds_remaining))
|
||||||
_set_timer_display(shown_seconds)
|
_set_timer_display(shown_seconds)
|
||||||
_update_timer_bar()
|
_update_timer_bar()
|
||||||
if BlockSessionService.seconds_remaining <= 0:
|
if BlockSessionService.seconds_remaining <= 0 and not duration_goal:
|
||||||
_finish()
|
_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):
|
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
|
# 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
|
# ("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:
|
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():
|
if BlockSessionService.move_next():
|
||||||
_load_question()
|
_load_question()
|
||||||
else:
|
else:
|
||||||
@@ -2105,6 +2114,8 @@ func _finish() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _exit_run() -> void:
|
func _exit_run() -> void:
|
||||||
|
if StudySessionService.is_active() and BlockSessionService.session_kind != "simulation":
|
||||||
|
StudySessionService.finish_early()
|
||||||
if BlockSessionService.session_kind == "bookmark":
|
if BlockSessionService.session_kind == "bookmark":
|
||||||
BlockSessionService.finish_block()
|
BlockSessionService.finish_block()
|
||||||
main.show_screen("bookmarks")
|
main.show_screen("bookmarks")
|
||||||
@@ -2157,6 +2168,8 @@ func _set_timer_display(total_seconds: int) -> void:
|
|||||||
func _is_session_timer_visible() -> bool:
|
func _is_session_timer_visible() -> bool:
|
||||||
if BlockSessionService.session_kind == "core" or BlockSessionService.session_kind == "simulation":
|
if BlockSessionService.session_kind == "core" or BlockSessionService.session_kind == "simulation":
|
||||||
return true
|
return true
|
||||||
|
if str(StudySessionService.progress.get("goalType", "")) == "duration":
|
||||||
|
return true
|
||||||
return BlockSessionService.mode == "Timed"
|
return BlockSessionService.mode == "Timed"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,21 @@ func _start_another() -> void:
|
|||||||
another_button.disabled = true
|
another_button.disabled = true
|
||||||
status_label.text = "Preparing another review pass..."
|
status_label.text = "Preparing another review pass..."
|
||||||
status_label.visible = true
|
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:
|
func _on_question_loaded(question: Dictionary) -> void:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://gj2gvcawmgng
|
||||||
Reference in New Issue
Block a user