Add user progress dashboard
This commit is contained in:
@@ -0,0 +1,30 @@
|
|||||||
|
using QuestionsApi.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Tests;
|
||||||
|
|
||||||
|
public sealed class DashboardTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void PercentageRoundsToNearestWholeNumber()
|
||||||
|
{
|
||||||
|
Assert.Equal(67, DashboardStore.Percentage(2, 3));
|
||||||
|
Assert.Equal(0, DashboardStore.Percentage(0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StreakIncludesYesterdayWhenUserHasNotStudiedToday()
|
||||||
|
{
|
||||||
|
var today = new DateTime(2026, 7, 18, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var dates = new[] { today.AddDays(-1), today.AddDays(-2), today.AddDays(-3), today.AddDays(-5) };
|
||||||
|
Assert.Equal(3, DashboardStore.CalculateCurrentStreak(dates, today));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void StreakStopsAtFirstMissingDay()
|
||||||
|
{
|
||||||
|
var today = new DateTime(2026, 7, 18, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
var dates = new[] { today, today.AddDays(-1), today.AddDays(-3) };
|
||||||
|
Assert.Equal(2, DashboardStore.CalculateCurrentStreak(dates, today));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using QuestionsApi.Services;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/dashboard")]
|
||||||
|
[Authorize(Roles = "USER,SUPER")]
|
||||||
|
public sealed class DashboardController(DashboardStore dashboard) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Get()
|
||||||
|
{
|
||||||
|
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
|
?? User.FindFirstValue(ClaimTypes.Name)
|
||||||
|
?? User.Identity?.Name
|
||||||
|
?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
||||||
|
return Ok(await dashboard.GetAsync(userId, DateTime.UtcNow));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,7 +150,8 @@ public class QuestionsController : ControllerBase
|
|||||||
req.ChoiceId.Trim(),
|
req.ChoiceId.Trim(),
|
||||||
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
req.ChoiceId.Trim() == question.CorrectChoiceId,
|
||||||
false,
|
false,
|
||||||
presentedChoiceIds);
|
presentedChoiceIds,
|
||||||
|
req.DurationSeconds);
|
||||||
if (record.Inserted)
|
if (record.Inserted)
|
||||||
{
|
{
|
||||||
_metrics.AttemptCreated();
|
_metrics.AttemptCreated();
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
namespace QuestionsApi.Models;
|
||||||
|
|
||||||
|
public sealed class DashboardResponse
|
||||||
|
{
|
||||||
|
public int QuestionBankRating { get; set; } = 1000;
|
||||||
|
public int RatedQuestionCount { get; set; }
|
||||||
|
public int CcsRating { get; set; } = 1000;
|
||||||
|
public int RatedCcsCaseCount { get; set; }
|
||||||
|
public int FirstAttemptAccuracy { get; set; }
|
||||||
|
public int AccuracySampleSize { get; set; }
|
||||||
|
public int QuestionsThisWeek { get; set; }
|
||||||
|
public int StudyTimeSecondsThisWeek { get; set; }
|
||||||
|
public int CurrentStreak { get; set; }
|
||||||
|
public int MissedQuestionCount { get; set; }
|
||||||
|
public int BookmarkCount { get; set; }
|
||||||
|
public int NoteCount { get; set; }
|
||||||
|
public List<DashboardTrendPoint> PerformanceTrend { get; set; } = [];
|
||||||
|
public List<DashboardSystemPerformance> Systems { get; set; } = [];
|
||||||
|
public List<DashboardActivityDay> WeeklyActivity { get; set; } = [];
|
||||||
|
public List<DashboardRecentActivity> RecentActivity { get; set; } = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardTrendPoint
|
||||||
|
{
|
||||||
|
public DateTime DateUtc { get; set; }
|
||||||
|
public int Correct { get; set; }
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int Accuracy { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardSystemPerformance
|
||||||
|
{
|
||||||
|
public string System { get; set; } = "General";
|
||||||
|
public int Correct { get; set; }
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int Accuracy { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardActivityDay
|
||||||
|
{
|
||||||
|
public DateTime DateUtc { get; set; }
|
||||||
|
public int Questions { get; set; }
|
||||||
|
public int CcsCases { get; set; }
|
||||||
|
public int StudyTimeSeconds { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DashboardRecentActivity
|
||||||
|
{
|
||||||
|
public string Type { get; set; } = "question";
|
||||||
|
public string Title { get; set; } = "";
|
||||||
|
public string Detail { get; set; } = "";
|
||||||
|
public DateTime OccurredUtc { get; set; }
|
||||||
|
public string Route { get; set; } = "";
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ public class QuestionAttempt
|
|||||||
public bool TracksPresentation { get; set; }
|
public bool TracksPresentation { get; set; }
|
||||||
public bool IsCorrect { get; set; }
|
public bool IsCorrect { get; set; }
|
||||||
public bool IsSkip { get; set; }
|
public bool IsSkip { get; set; }
|
||||||
|
public int DurationSeconds { get; set; }
|
||||||
public int CounterBucket { get; set; }
|
public int CounterBucket { get; set; }
|
||||||
public bool? RatingApplied { get; set; }
|
public bool? RatingApplied { get; set; }
|
||||||
public string RatingLeaseOwner { get; set; } = "";
|
public string RatingLeaseOwner { get; set; } = "";
|
||||||
|
|||||||
@@ -43,4 +43,5 @@ public class FirstQuestionAttemptRequest
|
|||||||
public string QuestionExternalId { get; set; } = "";
|
public string QuestionExternalId { get; set; } = "";
|
||||||
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ builder.Services.AddSingleton<CcsCaseReportStore>();
|
|||||||
builder.Services.AddSingleton<AppFeedbackStore>();
|
builder.Services.AddSingleton<AppFeedbackStore>();
|
||||||
builder.Services.AddSingleton<UserNotepadStore>();
|
builder.Services.AddSingleton<UserNotepadStore>();
|
||||||
builder.Services.AddSingleton<QuestionBookmarkStore>();
|
builder.Services.AddSingleton<QuestionBookmarkStore>();
|
||||||
|
builder.Services.AddSingleton<DashboardStore>();
|
||||||
builder.Services.AddSingleton<ServiceMetrics>();
|
builder.Services.AddSingleton<ServiceMetrics>();
|
||||||
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
builder.Services.AddHostedService<AnswerStatisticsMaintenanceService>();
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ public sealed class CcsStore
|
|||||||
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State).Descending(value => value.UpdatedUtc)));
|
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State).Descending(value => value.UpdatedUtc)));
|
||||||
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
||||||
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId).Ascending(value => value.Mode).Ascending(value => value.CreatedUtc)));
|
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId).Ascending(value => value.Mode).Ascending(value => value.CreatedUtc)));
|
||||||
|
_attempts.Indexes.CreateOne(new CreateIndexModel<CcsAttempt>(
|
||||||
|
Builders<CcsAttempt>.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<CcsImportResponse> ImportAsync(CcsImportRequest request)
|
public async Task<CcsImportResponse> ImportAsync(CcsImportRequest request)
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using MongoDB.Driver;
|
||||||
|
using QuestionsApi.Models;
|
||||||
|
|
||||||
|
namespace QuestionsApi.Services;
|
||||||
|
|
||||||
|
public sealed class DashboardStore
|
||||||
|
{
|
||||||
|
private readonly IMongoCollection<QuestionAttempt> _attempts;
|
||||||
|
private readonly IMongoCollection<UserRating> _ratings;
|
||||||
|
private readonly IMongoCollection<CcsUserRating> _ccsRatings;
|
||||||
|
private readonly IMongoCollection<CcsAttempt> _ccsAttempts;
|
||||||
|
private readonly IMongoCollection<CcsCaseRevision> _ccsCases;
|
||||||
|
private readonly IMongoCollection<ExamSimulation> _simulations;
|
||||||
|
private readonly IMongoCollection<QuestionBookmark> _bookmarks;
|
||||||
|
private readonly IMongoCollection<UserNotepad> _notes;
|
||||||
|
private readonly QuestionStore _questions;
|
||||||
|
|
||||||
|
public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions)
|
||||||
|
{
|
||||||
|
_attempts = mongo.Database.GetCollection<QuestionAttempt>("QuestionAttempts");
|
||||||
|
_ratings = mongo.Database.GetCollection<UserRating>("UserRatings");
|
||||||
|
_ccsRatings = mongo.Database.GetCollection<CcsUserRating>("CcsUserRatings");
|
||||||
|
_ccsAttempts = mongo.Database.GetCollection<CcsAttempt>("CcsAttempts");
|
||||||
|
_ccsCases = mongo.Database.GetCollection<CcsCaseRevision>("CcsCaseRevisions");
|
||||||
|
_simulations = mongo.Database.GetCollection<ExamSimulation>("ExamSimulations");
|
||||||
|
_bookmarks = mongo.Database.GetCollection<QuestionBookmark>("QuestionBookmarks");
|
||||||
|
_notes = mongo.Database.GetCollection<UserNotepad>("UserNotepads");
|
||||||
|
_questions = questions;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<DashboardResponse> GetAsync(string userId, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var today = nowUtc.Date;
|
||||||
|
var trendStart = today.AddDays(-29);
|
||||||
|
var weekStart = today.AddDays(-6);
|
||||||
|
var attemptsTask = _attempts.Find(value => value.UserId == userId).ToListAsync();
|
||||||
|
var ratingTask = _ratings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||||
|
var ccsRatingTask = _ccsRatings.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||||
|
var ccsAttemptsTask = _ccsAttempts.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||||
|
var simulationsTask = _simulations.Find(value => value.UserId == userId && value.CreatedUtc >= trendStart).ToListAsync();
|
||||||
|
var bookmarkCountTask = _bookmarks.CountDocumentsAsync(value => value.UserId == userId);
|
||||||
|
var notepadTask = _notes.Find(value => value.UserId == userId).FirstOrDefaultAsync();
|
||||||
|
await Task.WhenAll(attemptsTask, ratingTask, ccsRatingTask, ccsAttemptsTask,
|
||||||
|
simulationsTask, bookmarkCountTask, notepadTask);
|
||||||
|
|
||||||
|
var attempts = attemptsTask.Result;
|
||||||
|
var answeredAttempts = attempts.Where(value => !value.IsSkip).ToList();
|
||||||
|
var recentAnswered = answeredAttempts.OrderByDescending(value => value.CreatedUtc).Take(100).ToList();
|
||||||
|
var questionMap = await _questions.GetByExternalIdsAsync(attempts.Select(value => value.QuestionExternalId));
|
||||||
|
var ccsAttempts = ccsAttemptsTask.Result;
|
||||||
|
var simulations = simulationsTask.Result;
|
||||||
|
var completedCcs = ccsAttempts.Where(value => value.State == CcsAttemptStates.Completed && value.CompletedUtc.HasValue).ToList();
|
||||||
|
|
||||||
|
var response = new DashboardResponse
|
||||||
|
{
|
||||||
|
QuestionBankRating = ratingTask.Result?.OverallElo ?? 1000,
|
||||||
|
RatedQuestionCount = ratingTask.Result?.RatedQuestionCount ?? 0,
|
||||||
|
CcsRating = ccsRatingTask.Result?.Elo ?? 1000,
|
||||||
|
RatedCcsCaseCount = ccsRatingTask.Result?.RatedCaseCount ?? 0,
|
||||||
|
AccuracySampleSize = recentAnswered.Count,
|
||||||
|
FirstAttemptAccuracy = Percentage(recentAnswered.Count(value => value.IsCorrect), recentAnswered.Count),
|
||||||
|
QuestionsThisWeek = answeredAttempts.Count(value => value.CreatedUtc >= weekStart),
|
||||||
|
BookmarkCount = checked((int)bookmarkCountTask.Result),
|
||||||
|
NoteCount = CountNotes(notepadTask.Result?.Content),
|
||||||
|
MissedQuestionCount = attempts.Where(value => !value.IsCorrect).Select(value => value.QuestionExternalId).Distinct().Count()
|
||||||
|
};
|
||||||
|
|
||||||
|
response.PerformanceTrend = Enumerable.Range(0, 30).Select(offset =>
|
||||||
|
{
|
||||||
|
var date = trendStart.AddDays(offset);
|
||||||
|
var daily = answeredAttempts.Where(value => value.CreatedUtc.Date == date).ToList();
|
||||||
|
return new DashboardTrendPoint
|
||||||
|
{
|
||||||
|
DateUtc = date,
|
||||||
|
Correct = daily.Count(value => value.IsCorrect),
|
||||||
|
Total = daily.Count,
|
||||||
|
Accuracy = Percentage(daily.Count(value => value.IsCorrect), daily.Count)
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
response.Systems = answeredAttempts
|
||||||
|
.Where(value => questionMap.ContainsKey(value.QuestionExternalId))
|
||||||
|
.GroupBy(value => NormalizeSystem(questionMap[value.QuestionExternalId].System))
|
||||||
|
.Select(group => new DashboardSystemPerformance
|
||||||
|
{
|
||||||
|
System = group.Key,
|
||||||
|
Correct = group.Count(value => value.IsCorrect),
|
||||||
|
Total = group.Count(),
|
||||||
|
Accuracy = Percentage(group.Count(value => value.IsCorrect), group.Count())
|
||||||
|
})
|
||||||
|
.OrderByDescending(value => value.Total).ThenByDescending(value => value.Accuracy)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
response.WeeklyActivity = Enumerable.Range(0, 7).Select(offset =>
|
||||||
|
{
|
||||||
|
var date = weekStart.AddDays(offset);
|
||||||
|
var dailyQuestions = answeredAttempts.Where(value => value.CreatedUtc.Date == date).ToList();
|
||||||
|
var dailyCcs = completedCcs.Where(value => value.CompletedUtc!.Value.Date == date).ToList();
|
||||||
|
var dailySimulations = simulations.Where(value => value.CompletedUtc?.Date == date).ToList();
|
||||||
|
return new DashboardActivityDay
|
||||||
|
{
|
||||||
|
DateUtc = date,
|
||||||
|
Questions = dailyQuestions.Count,
|
||||||
|
CcsCases = dailyCcs.Count,
|
||||||
|
StudyTimeSeconds = dailyQuestions.Sum(value => value.DurationSeconds)
|
||||||
|
+ dailyCcs.Sum(CcsDurationSeconds)
|
||||||
|
+ dailySimulations.Sum(value => value.Responses.Sum(item => Math.Max(0, item.ElapsedSeconds)))
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
response.StudyTimeSecondsThisWeek = response.WeeklyActivity.Sum(value => value.StudyTimeSeconds);
|
||||||
|
var activeDates = attempts.Select(value => value.CreatedUtc.Date)
|
||||||
|
.Concat(completedCcs.Select(value => value.CompletedUtc!.Value.Date))
|
||||||
|
.Concat(simulations.Where(value => value.CompletedUtc.HasValue).Select(value => value.CompletedUtc!.Value.Date));
|
||||||
|
response.CurrentStreak = CalculateCurrentStreak(activeDates, today);
|
||||||
|
response.RecentActivity = await BuildRecentActivityAsync(answeredAttempts, questionMap, completedCcs, simulations);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int Percentage(int correct, int total) =>
|
||||||
|
total <= 0 ? 0 : (int)Math.Round(correct * 100.0 / total, MidpointRounding.AwayFromZero);
|
||||||
|
|
||||||
|
public static int CalculateCurrentStreak(IEnumerable<DateTime> activeDates, DateTime todayUtc)
|
||||||
|
{
|
||||||
|
var dates = activeDates.Select(value => value.Date).ToHashSet();
|
||||||
|
var cursor = todayUtc.Date;
|
||||||
|
if (!dates.Contains(cursor)) cursor = cursor.AddDays(-1);
|
||||||
|
var streak = 0;
|
||||||
|
while (dates.Contains(cursor))
|
||||||
|
{
|
||||||
|
streak++;
|
||||||
|
cursor = cursor.AddDays(-1);
|
||||||
|
}
|
||||||
|
return streak;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<List<DashboardRecentActivity>> BuildRecentActivityAsync(
|
||||||
|
List<QuestionAttempt> attempts,
|
||||||
|
Dictionary<string, Question> questionMap,
|
||||||
|
List<CcsAttempt> ccsAttempts,
|
||||||
|
List<ExamSimulation> simulations)
|
||||||
|
{
|
||||||
|
var caseIds = ccsAttempts.Select(value => value.CaseExternalId).Distinct().ToList();
|
||||||
|
var caseTitles = caseIds.Count == 0
|
||||||
|
? []
|
||||||
|
: await _ccsCases.Find(value => caseIds.Contains(value.ExternalId) && value.IsCurrent).ToListAsync();
|
||||||
|
var titleMap = caseTitles.GroupBy(value => value.ExternalId).ToDictionary(group => group.Key, group => group.First().Title);
|
||||||
|
var activity = attempts.OrderByDescending(value => value.CreatedUtc).Take(6).Select(value =>
|
||||||
|
{
|
||||||
|
questionMap.TryGetValue(value.QuestionExternalId, out var question);
|
||||||
|
return new DashboardRecentActivity
|
||||||
|
{
|
||||||
|
Type = "question",
|
||||||
|
Title = $"Answered {NormalizeSystem(question?.System)} question",
|
||||||
|
Detail = value.IsCorrect ? "Correct" : "Incorrect",
|
||||||
|
OccurredUtc = value.CreatedUtc,
|
||||||
|
Route = "question_bank"
|
||||||
|
};
|
||||||
|
}).ToList();
|
||||||
|
activity.AddRange(ccsAttempts.OrderByDescending(value => value.CompletedUtc).Take(4).Select(value =>
|
||||||
|
new DashboardRecentActivity
|
||||||
|
{
|
||||||
|
Type = "ccs",
|
||||||
|
Title = $"Completed {titleMap.GetValueOrDefault(value.CaseExternalId, "CCS case")}",
|
||||||
|
Detail = $"{Percentage(value.Score?.EarnedPoints ?? 0, value.Score?.PossiblePoints ?? 0)}%",
|
||||||
|
OccurredUtc = value.CompletedUtc ?? value.UpdatedUtc,
|
||||||
|
Route = "ccs"
|
||||||
|
}));
|
||||||
|
activity.AddRange(simulations.Where(value => value.State == ExamSimulationStates.Completed)
|
||||||
|
.OrderByDescending(value => value.CompletedUtc).Take(3).Select(value =>
|
||||||
|
new DashboardRecentActivity
|
||||||
|
{
|
||||||
|
Type = "simulation",
|
||||||
|
Title = "Completed Step 3 simulation",
|
||||||
|
Detail = value.Score is null ? "Completed" : $"Predicted score {value.Score.PredictedScore}",
|
||||||
|
OccurredUtc = value.CompletedUtc ?? value.UpdatedUtc,
|
||||||
|
Route = "exams"
|
||||||
|
}));
|
||||||
|
return activity.OrderByDescending(value => value.OccurredUtc).Take(6).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CcsDurationSeconds(CcsAttempt attempt) => attempt.CompletedUtc.HasValue
|
||||||
|
? Math.Max(0, (int)(attempt.CompletedUtc.Value - attempt.CreatedUtc).TotalSeconds)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
private static int CountNotes(string? content) => string.IsNullOrWhiteSpace(content)
|
||||||
|
? 0
|
||||||
|
: content.Split('\n').Count(line => !string.IsNullOrWhiteSpace(line));
|
||||||
|
|
||||||
|
private static string NormalizeSystem(string? value) => string.IsNullOrWhiteSpace(value) ? "General" : value.Trim();
|
||||||
|
}
|
||||||
@@ -27,6 +27,8 @@ public class QuestionAttemptStore
|
|||||||
|
|
||||||
var createdIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.CreatedUtc);
|
var createdIndex = Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.CreatedUtc);
|
||||||
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(createdIndex));
|
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(createdIndex));
|
||||||
|
_col.Indexes.CreateOne(new CreateIndexModel<QuestionAttempt>(
|
||||||
|
Builders<QuestionAttempt>.IndexKeys.Ascending(a => a.UserId).Descending(a => a.CreatedUtc)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<QuestionAttemptRecordResult> RecordFirstAttemptAsync(
|
public async Task<QuestionAttemptRecordResult> RecordFirstAttemptAsync(
|
||||||
@@ -35,7 +37,8 @@ public class QuestionAttemptStore
|
|||||||
string choiceId,
|
string choiceId,
|
||||||
bool isCorrect,
|
bool isCorrect,
|
||||||
bool isSkip = false,
|
bool isSkip = false,
|
||||||
IReadOnlyCollection<string>? presentedChoiceIds = null)
|
IReadOnlyCollection<string>? presentedChoiceIds = null,
|
||||||
|
int durationSeconds = 0)
|
||||||
{
|
{
|
||||||
var attempt = new QuestionAttempt
|
var attempt = new QuestionAttempt
|
||||||
{
|
{
|
||||||
@@ -46,6 +49,7 @@ public class QuestionAttemptStore
|
|||||||
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
TracksPresentation = presentedChoiceIds is { Count: > 0 },
|
||||||
IsCorrect = isCorrect,
|
IsCorrect = isCorrect,
|
||||||
IsSkip = isSkip,
|
IsSkip = isSkip,
|
||||||
|
DurationSeconds = Math.Clamp(durationSeconds, 0, 86_400),
|
||||||
CounterBucket = GetCounterBucket(userId),
|
CounterBucket = GetCounterBucket(userId),
|
||||||
RatingApplied = false,
|
RatingApplied = false,
|
||||||
CreatedUtc = DateTime.UtcNow
|
CreatedUtc = DateTime.UtcNow
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ CCSService="*res://scripts/autoload/ccs_service.gd"
|
|||||||
FeedbackService="*res://scripts/autoload/feedback_service.gd"
|
FeedbackService="*res://scripts/autoload/feedback_service.gd"
|
||||||
NotesService="*res://scripts/autoload/notes_service.gd"
|
NotesService="*res://scripts/autoload/notes_service.gd"
|
||||||
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
BookmarkService="*res://scripts/autoload/bookmark_service.gd"
|
||||||
|
DashboardService="*res://scripts/autoload/dashboard_service.gd"
|
||||||
|
|
||||||
[display]
|
[display]
|
||||||
|
|
||||||
@@ -55,4 +56,5 @@ questions_api_url="https://s3q.ranaze.com/api/questions/search"
|
|||||||
question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
|
question_reports_api_url="https://s3q.ranaze.com/api/question-reports"
|
||||||
app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback"
|
app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback"
|
||||||
notes_api_url="https://s3q.ranaze.com/api/notes"
|
notes_api_url="https://s3q.ranaze.com/api/notes"
|
||||||
|
dashboard_api_url="https://s3q.ranaze.com/api/dashboard"
|
||||||
build_revision="development"
|
build_revision="development"
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
extends Node
|
||||||
|
|
||||||
|
signal dashboard_changed
|
||||||
|
signal dashboard_failed(message: String)
|
||||||
|
|
||||||
|
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||||
|
const DEFAULT_URL := "https://s3q.ranaze.com/api/dashboard"
|
||||||
|
|
||||||
|
var data: Dictionary = {}
|
||||||
|
var loading := false
|
||||||
|
var request: HTTPRequest
|
||||||
|
var request_token := ""
|
||||||
|
var api_url := DEFAULT_URL
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
api_url = EndpointConfig.resolve(
|
||||||
|
str(ProjectSettings.get_setting("step3/dashboard_api_url", DEFAULT_URL)),
|
||||||
|
"/api/dashboard")
|
||||||
|
request = HTTPRequest.new()
|
||||||
|
request.timeout = 20.0
|
||||||
|
request.request_completed.connect(_on_completed)
|
||||||
|
add_child(request)
|
||||||
|
AuthState.auth_changed.connect(_on_auth_changed)
|
||||||
|
|
||||||
|
|
||||||
|
func refresh() -> void:
|
||||||
|
if loading or not AuthState.can_send_authenticated_request():
|
||||||
|
return
|
||||||
|
loading = true
|
||||||
|
request_token = AuthState.access_token
|
||||||
|
var headers := PackedStringArray(["Authorization: Bearer %s" % request_token])
|
||||||
|
var error := request.request(api_url, headers, HTTPClient.METHOD_GET)
|
||||||
|
if error != OK:
|
||||||
|
loading = false
|
||||||
|
dashboard_failed.emit("Dashboard data could not be loaded. Check your connection and try again.")
|
||||||
|
|
||||||
|
|
||||||
|
func _on_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||||
|
loading = false
|
||||||
|
if response_code == 401:
|
||||||
|
AuthState.handle_unauthorized(request_token)
|
||||||
|
return
|
||||||
|
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||||
|
dashboard_failed.emit("Dashboard data could not be loaded. Check your connection and try again.")
|
||||||
|
return
|
||||||
|
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||||
|
if typeof(parsed) != TYPE_DICTIONARY:
|
||||||
|
dashboard_failed.emit("The dashboard service returned an invalid response.")
|
||||||
|
return
|
||||||
|
data = parsed
|
||||||
|
dashboard_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
|
func _on_auth_changed() -> void:
|
||||||
|
if not AuthState.is_logged_in:
|
||||||
|
data = {}
|
||||||
|
loading = false
|
||||||
|
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||||
|
request.cancel_request()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c8nmhxwb875uy
|
||||||
@@ -257,10 +257,10 @@ func clear_questions() -> void:
|
|||||||
ratings_changed.emit()
|
ratings_changed.emit()
|
||||||
|
|
||||||
|
|
||||||
func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = []) -> void:
|
func record_first_attempt(question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void:
|
||||||
if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "":
|
if not AuthState.can_send_authenticated_request() or question_id == "" or choice_id == "":
|
||||||
return
|
return
|
||||||
_enqueue_attempt("answer", question_id, choice_id, presented_choice_ids)
|
_enqueue_attempt("answer", question_id, choice_id, presented_choice_ids, duration_seconds)
|
||||||
|
|
||||||
|
|
||||||
func skip_question(question_id: String) -> void:
|
func skip_question(question_id: String) -> void:
|
||||||
@@ -386,7 +386,7 @@ func _on_retry_timer_timeout() -> void:
|
|||||||
load_questions_from_api(false)
|
load_questions_from_api(false)
|
||||||
|
|
||||||
|
|
||||||
func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = []) -> void:
|
func _enqueue_attempt(kind: String, question_id: String, choice_id: String, presented_choice_ids: Array = [], duration_seconds: int = 0) -> void:
|
||||||
var account := AuthState.username.strip_edges()
|
var account := AuthState.username.strip_edges()
|
||||||
if account == "":
|
if account == "":
|
||||||
return
|
return
|
||||||
@@ -399,6 +399,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres
|
|||||||
"question_id": question_id,
|
"question_id": question_id,
|
||||||
"choice_id": choice_id,
|
"choice_id": choice_id,
|
||||||
"presented_choice_ids": presented_choice_ids.duplicate(),
|
"presented_choice_ids": presented_choice_ids.duplicate(),
|
||||||
|
"duration_seconds": maxi(0, duration_seconds),
|
||||||
"retry_count": 0
|
"retry_count": 0
|
||||||
})
|
})
|
||||||
_save_attempt_queue()
|
_save_attempt_queue()
|
||||||
@@ -430,7 +431,8 @@ func _process_attempt_queue() -> void:
|
|||||||
var body := JSON.stringify({
|
var body := JSON.stringify({
|
||||||
"questionExternalId": question_id,
|
"questionExternalId": question_id,
|
||||||
"choiceId": str(queued.get("choice_id", "")),
|
"choiceId": str(queued.get("choice_id", "")),
|
||||||
"presentedChoiceIds": queued.get("presented_choice_ids", [])
|
"presentedChoiceIds": queued.get("presented_choice_ids", []),
|
||||||
|
"durationSeconds": int(queued.get("duration_seconds", 0))
|
||||||
})
|
})
|
||||||
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()]
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
|
|
||||||
|
var days: Array = []
|
||||||
|
var dark := true
|
||||||
|
|
||||||
|
|
||||||
|
func set_days(next_days: Array, dark_mode: bool) -> void:
|
||||||
|
days = next_days.duplicate(true)
|
||||||
|
dark = dark_mode
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
if days.is_empty():
|
||||||
|
return
|
||||||
|
var bottom := size.y - 24.0
|
||||||
|
var top := 20.0
|
||||||
|
var slot: float = size.x / float(days.size())
|
||||||
|
var maximum := 1
|
||||||
|
for day in days:
|
||||||
|
maximum = maxi(maximum, int(day.get("studyTimeSeconds", 0)))
|
||||||
|
for index in range(days.size()):
|
||||||
|
var day: Dictionary = days[index]
|
||||||
|
var seconds := int(day.get("studyTimeSeconds", 0))
|
||||||
|
var bar_height: float = (bottom - top) * float(seconds) / float(maximum)
|
||||||
|
var width: float = minf(30.0, slot * 0.48)
|
||||||
|
var rect := Rect2(index * slot + (slot - width) * 0.5, bottom - bar_height, width, maxf(2.0, bar_height))
|
||||||
|
draw_rect(rect, Brand.ROYAL_BLUE, true)
|
||||||
|
var label := _weekday(str(day.get("dateUtc", "")))
|
||||||
|
draw_string(Brand.font, Vector2(index * slot, size.y - 5), label, HORIZONTAL_ALIGNMENT_CENTER, slot, 11, Brand.muted_color(dark))
|
||||||
|
|
||||||
|
|
||||||
|
func _weekday(iso_date: String) -> String:
|
||||||
|
if iso_date.length() < 10:
|
||||||
|
return ""
|
||||||
|
var unix := Time.get_unix_time_from_datetime_string(iso_date.left(10) + "T00:00:00")
|
||||||
|
var date := Time.get_date_dict_from_unix_time(unix)
|
||||||
|
return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(date.get("weekday", 0))]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://4yvpcg55sqe
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
|
|
||||||
|
var values: PackedFloat32Array = []
|
||||||
|
var dark := true
|
||||||
|
|
||||||
|
|
||||||
|
func set_values(next_values: Array, dark_mode: bool) -> void:
|
||||||
|
values = PackedFloat32Array(next_values)
|
||||||
|
dark = dark_mode
|
||||||
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
|
func _draw() -> void:
|
||||||
|
var left := 42.0
|
||||||
|
var top := 12.0
|
||||||
|
var right := size.x - 12.0
|
||||||
|
var bottom := size.y - 28.0
|
||||||
|
var grid := Color("24314b") if dark else Color("dbe3ef")
|
||||||
|
var muted := Brand.muted_color(dark)
|
||||||
|
for percentage in [0, 25, 50, 75, 100]:
|
||||||
|
var y: float = bottom - (bottom - top) * float(percentage) / 100.0
|
||||||
|
draw_line(Vector2(left, y), Vector2(right, y), grid, 1.0)
|
||||||
|
draw_string(Brand.font, Vector2(4, y + 4), str(percentage), HORIZONTAL_ALIGNMENT_LEFT, 34, 11, muted)
|
||||||
|
var target_y: float = bottom - (bottom - top) * 0.70
|
||||||
|
draw_dashed_line(Vector2(left, target_y), Vector2(right, target_y), Brand.ROYAL_BLUE, 5.0, 2.0)
|
||||||
|
if values.is_empty():
|
||||||
|
return
|
||||||
|
var points := PackedVector2Array()
|
||||||
|
var count: int = values.size()
|
||||||
|
for index in range(count):
|
||||||
|
var x: float = left if count == 1 else lerpf(left, right, float(index) / float(count - 1))
|
||||||
|
var y: float = bottom - (bottom - top) * clampf(values[index], 0.0, 100.0) / 100.0
|
||||||
|
points.append(Vector2(x, y))
|
||||||
|
if points.size() > 1:
|
||||||
|
draw_polyline(points, Brand.ROYAL_BLUE, 2.5, true)
|
||||||
|
for point in points:
|
||||||
|
draw_circle(point, 3.5, Brand.PURPLE)
|
||||||
|
draw_circle(point, 2.0, Color.WHITE)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c11sgvq5oewuj
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
extends Control
|
||||||
|
|
||||||
|
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||||
|
const TrendChart := preload("res://scripts/ui/dashboard_chart.gd")
|
||||||
|
const ActivityChart := preload("res://scripts/ui/dashboard_activity_chart.gd")
|
||||||
|
|
||||||
|
var main: Control
|
||||||
|
var scroll: ScrollContainer
|
||||||
|
var content: VBoxContainer
|
||||||
|
var status_label: Label
|
||||||
|
var metric_grid: GridContainer
|
||||||
|
var analytics_grid: GridContainer
|
||||||
|
var lower_grid: GridContainer
|
||||||
|
var trend_chart: Control
|
||||||
|
var activity_chart: Control
|
||||||
|
var system_list: VBoxContainer
|
||||||
|
var focus_content: VBoxContainer
|
||||||
|
var review_list: VBoxContainer
|
||||||
|
var recent_list: VBoxContainer
|
||||||
|
var weekly_summary: VBoxContainer
|
||||||
|
var metric_values: Dictionary = {}
|
||||||
|
var metric_details: Dictionary = {}
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
DashboardService.dashboard_changed.connect(_render)
|
||||||
|
DashboardService.dashboard_failed.connect(_show_error)
|
||||||
|
UIThemeService.theme_changed.connect(_rebuild)
|
||||||
|
resized.connect(_update_responsive)
|
||||||
|
_build()
|
||||||
|
|
||||||
|
|
||||||
|
func on_show(_payload: Variant = null) -> void:
|
||||||
|
if DashboardService.data.is_empty():
|
||||||
|
_show_loading()
|
||||||
|
else:
|
||||||
|
_render()
|
||||||
|
DashboardService.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
func _build() -> void:
|
||||||
|
for child in get_children():
|
||||||
|
remove_child(child)
|
||||||
|
child.free()
|
||||||
|
metric_values.clear()
|
||||||
|
metric_details.clear()
|
||||||
|
scroll = ScrollContainer.new()
|
||||||
|
scroll.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||||
|
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||||
|
add_child(scroll)
|
||||||
|
content = VBoxContainer.new()
|
||||||
|
content.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
content.add_theme_constant_override("separation", 14)
|
||||||
|
scroll.add_child(content)
|
||||||
|
|
||||||
|
var eyebrow := _label("YOUR PROGRESS", 13, Brand.ROYAL_BLUE)
|
||||||
|
content.add_child(eyebrow)
|
||||||
|
var hero_row := HBoxContainer.new()
|
||||||
|
hero_row.add_theme_constant_override("separation", 16)
|
||||||
|
content.add_child(hero_row)
|
||||||
|
var hero_copy := VBoxContainer.new()
|
||||||
|
hero_copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
hero_row.add_child(hero_copy)
|
||||||
|
var display_name := AuthState.username.strip_edges()
|
||||||
|
if display_name.is_empty():
|
||||||
|
display_name = "there"
|
||||||
|
else:
|
||||||
|
display_name = display_name.split("@")[0].capitalize()
|
||||||
|
hero_copy.add_child(_label("Welcome back, %s" % display_name, 28, Brand.text_color(UIThemeService.is_dark_mode()), true))
|
||||||
|
hero_copy.add_child(_label("Here is how your Step 3 preparation is progressing.", 15, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||||
|
var actions := HBoxContainer.new()
|
||||||
|
actions.add_theme_constant_override("separation", 10)
|
||||||
|
hero_copy.add_child(actions)
|
||||||
|
var continue_button := _button("Continue studying", "chevron-right")
|
||||||
|
continue_button.pressed.connect(func(): main.show_screen("selection", "question_bank"))
|
||||||
|
actions.add_child(continue_button)
|
||||||
|
var missed_button := _button("Review missed questions", "clipboard-list", false)
|
||||||
|
missed_button.pressed.connect(func(): main.show_screen("profile"))
|
||||||
|
actions.add_child(missed_button)
|
||||||
|
|
||||||
|
status_label = _label("", 14, Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||||
|
status_label.visible = false
|
||||||
|
content.add_child(status_label)
|
||||||
|
metric_grid = GridContainer.new()
|
||||||
|
metric_grid.columns = 4
|
||||||
|
metric_grid.add_theme_constant_override("h_separation", 0)
|
||||||
|
metric_grid.add_theme_constant_override("v_separation", 8)
|
||||||
|
content.add_child(_card(metric_grid, 12))
|
||||||
|
_add_metric("qbank", "Question Bank Rating", "chart-no-axes-column-increasing", Brand.ROYAL_BLUE)
|
||||||
|
_add_metric("ccs", "CCS Rating", "stethoscope", Brand.PURPLE)
|
||||||
|
_add_metric("accuracy", "First-Attempt Accuracy", "activity", Brand.ROYAL_BLUE)
|
||||||
|
_add_metric("time", "Study Time", "clock-3", Brand.PURPLE)
|
||||||
|
|
||||||
|
analytics_grid = GridContainer.new()
|
||||||
|
analytics_grid.columns = 2
|
||||||
|
analytics_grid.add_theme_constant_override("h_separation", 12)
|
||||||
|
analytics_grid.add_theme_constant_override("v_separation", 12)
|
||||||
|
content.add_child(analytics_grid)
|
||||||
|
var performance := VBoxContainer.new()
|
||||||
|
performance.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
performance.add_theme_constant_override("separation", 10)
|
||||||
|
performance.add_child(_section_title("Performance trend"))
|
||||||
|
trend_chart = TrendChart.new()
|
||||||
|
trend_chart.custom_minimum_size = Vector2(520, 190)
|
||||||
|
trend_chart.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
performance.add_child(trend_chart)
|
||||||
|
performance.add_child(HSeparator.new())
|
||||||
|
var system_header := HBoxContainer.new()
|
||||||
|
system_header.add_child(_section_title("Performance by system"))
|
||||||
|
var header_space := Control.new()
|
||||||
|
header_space.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
system_header.add_child(header_space)
|
||||||
|
var breakdown := Button.new()
|
||||||
|
breakdown.text = "View full breakdown"
|
||||||
|
breakdown.flat = true
|
||||||
|
breakdown.pressed.connect(func(): main.show_screen("profile"))
|
||||||
|
system_header.add_child(breakdown)
|
||||||
|
performance.add_child(system_header)
|
||||||
|
system_list = VBoxContainer.new()
|
||||||
|
system_list.add_theme_constant_override("separation", 8)
|
||||||
|
performance.add_child(system_list)
|
||||||
|
analytics_grid.add_child(_card(performance, 18))
|
||||||
|
|
||||||
|
var right_column := VBoxContainer.new()
|
||||||
|
right_column.add_theme_constant_override("separation", 12)
|
||||||
|
right_column.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
analytics_grid.add_child(right_column)
|
||||||
|
focus_content = VBoxContainer.new()
|
||||||
|
focus_content.add_theme_constant_override("separation", 10)
|
||||||
|
right_column.add_child(_card(focus_content, 16))
|
||||||
|
review_list = VBoxContainer.new()
|
||||||
|
review_list.add_theme_constant_override("separation", 7)
|
||||||
|
right_column.add_child(_card(review_list, 16))
|
||||||
|
|
||||||
|
lower_grid = GridContainer.new()
|
||||||
|
lower_grid.columns = 2
|
||||||
|
lower_grid.add_theme_constant_override("h_separation", 12)
|
||||||
|
lower_grid.add_theme_constant_override("v_separation", 12)
|
||||||
|
content.add_child(lower_grid)
|
||||||
|
var weekly := VBoxContainer.new()
|
||||||
|
weekly.add_theme_constant_override("separation", 8)
|
||||||
|
weekly.add_child(_section_title("Weekly activity"))
|
||||||
|
var weekly_row := HBoxContainer.new()
|
||||||
|
weekly_row.add_theme_constant_override("separation", 18)
|
||||||
|
weekly.add_child(weekly_row)
|
||||||
|
activity_chart = ActivityChart.new()
|
||||||
|
activity_chart.custom_minimum_size = Vector2(360, 150)
|
||||||
|
activity_chart.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
weekly_row.add_child(activity_chart)
|
||||||
|
weekly_summary = VBoxContainer.new()
|
||||||
|
weekly_summary.custom_minimum_size.x = 180
|
||||||
|
weekly_summary.add_theme_constant_override("separation", 10)
|
||||||
|
weekly_row.add_child(weekly_summary)
|
||||||
|
lower_grid.add_child(_card(weekly, 16))
|
||||||
|
recent_list = VBoxContainer.new()
|
||||||
|
recent_list.add_theme_constant_override("separation", 6)
|
||||||
|
lower_grid.add_child(_card(recent_list, 16))
|
||||||
|
_update_responsive()
|
||||||
|
if not DashboardService.data.is_empty():
|
||||||
|
_render()
|
||||||
|
|
||||||
|
|
||||||
|
func _rebuild(_is_dark: bool = UIThemeService.is_dark_mode()) -> void:
|
||||||
|
_build()
|
||||||
|
|
||||||
|
|
||||||
|
func _render() -> void:
|
||||||
|
var data: Dictionary = DashboardService.data
|
||||||
|
status_label.visible = false
|
||||||
|
_set_metric("qbank", str(int(data.get("questionBankRating", 1000))), "%d rated questions" % int(data.get("ratedQuestionCount", 0)))
|
||||||
|
_set_metric("ccs", str(int(data.get("ccsRating", 1000))), "%d rated cases" % int(data.get("ratedCcsCaseCount", 0)))
|
||||||
|
_set_metric("accuracy", "%d%%" % int(data.get("firstAttemptAccuracy", 0)), "Last %d questions" % int(data.get("accuracySampleSize", 0)))
|
||||||
|
_set_metric("time", _format_duration(int(data.get("studyTimeSecondsThisWeek", 0))), "This week")
|
||||||
|
var trend_values: Array = []
|
||||||
|
for point in data.get("performanceTrend", []):
|
||||||
|
if int(point.get("total", 0)) > 0:
|
||||||
|
trend_values.append(float(point.get("accuracy", 0)))
|
||||||
|
trend_chart.set_values(trend_values, UIThemeService.is_dark_mode())
|
||||||
|
_render_systems(data.get("systems", []))
|
||||||
|
_render_focus(data.get("systems", []))
|
||||||
|
_render_review(data)
|
||||||
|
var weekly: Array = data.get("weeklyActivity", [])
|
||||||
|
activity_chart.set_days(weekly, UIThemeService.is_dark_mode())
|
||||||
|
_render_weekly(data, weekly)
|
||||||
|
_render_recent(data.get("recentActivity", []))
|
||||||
|
|
||||||
|
|
||||||
|
func _render_systems(systems_value: Array) -> void:
|
||||||
|
_clear(system_list)
|
||||||
|
var systems := systems_value.duplicate(true)
|
||||||
|
systems.sort_custom(func(a: Dictionary, b: Dictionary): return int(a.get("total", 0)) > int(b.get("total", 0)))
|
||||||
|
if systems.is_empty():
|
||||||
|
system_list.add_child(_label("Answer questions to build your system breakdown.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||||
|
return
|
||||||
|
var displayed: Array = systems.slice(0, mini(5, systems.size()))
|
||||||
|
var best_accuracy := -1
|
||||||
|
var worst_accuracy := 101
|
||||||
|
for item in displayed:
|
||||||
|
best_accuracy = maxi(best_accuracy, int(item.get("accuracy", 0)))
|
||||||
|
worst_accuracy = mini(worst_accuracy, int(item.get("accuracy", 0)))
|
||||||
|
for item in displayed:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 10)
|
||||||
|
var name := _label(str(item.get("system", "General")), 13, Brand.text_color(UIThemeService.is_dark_mode()))
|
||||||
|
name.custom_minimum_size.x = 190
|
||||||
|
name.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||||
|
row.add_child(name)
|
||||||
|
var bar := ProgressBar.new()
|
||||||
|
bar.show_percentage = false
|
||||||
|
bar.value = int(item.get("accuracy", 0))
|
||||||
|
bar.custom_minimum_size = Vector2(150, 7)
|
||||||
|
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
bar.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
|
_style_progress(bar, Brand.PURPLE if int(item.get("accuracy", 0)) == worst_accuracy else Brand.ROYAL_BLUE)
|
||||||
|
row.add_child(bar)
|
||||||
|
var percentage := _label("%d%%" % int(item.get("accuracy", 0)), 14, Brand.ROYAL_BLUE, true)
|
||||||
|
percentage.custom_minimum_size.x = 48
|
||||||
|
row.add_child(percentage)
|
||||||
|
var count := _label("%d / %d" % [int(item.get("correct", 0)), int(item.get("total", 0))], 12, Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||||
|
count.custom_minimum_size.x = 58
|
||||||
|
row.add_child(count)
|
||||||
|
var marker := "Strongest" if int(item.get("accuracy", 0)) == best_accuracy else ("Needs work" if int(item.get("accuracy", 0)) == worst_accuracy else "")
|
||||||
|
var marker_label := _label(marker, 11, Brand.SUCCESS if marker == "Strongest" else Brand.MAGENTA)
|
||||||
|
marker_label.custom_minimum_size.x = 72
|
||||||
|
row.add_child(marker_label)
|
||||||
|
system_list.add_child(row)
|
||||||
|
|
||||||
|
|
||||||
|
func _render_focus(systems: Array) -> void:
|
||||||
|
_clear(focus_content)
|
||||||
|
focus_content.add_child(_section_title("Study focus"))
|
||||||
|
if systems.is_empty():
|
||||||
|
focus_content.add_child(_label("Complete a few questions to receive a focused recommendation.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||||
|
return
|
||||||
|
var weakest: Dictionary = systems[0]
|
||||||
|
for item in systems:
|
||||||
|
if int(item.get("accuracy", 0)) < int(weakest.get("accuracy", 0)):
|
||||||
|
weakest = item
|
||||||
|
focus_content.add_child(_label("Recommended focus", 12, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||||
|
focus_content.add_child(_label(str(weakest.get("system", "General")), 23, Brand.PURPLE, true))
|
||||||
|
focus_content.add_child(_label("%d%% accuracy across %d questions" % [int(weakest.get("accuracy", 0)), int(weakest.get("total", 0))], 13, Brand.text_color(UIThemeService.is_dark_mode())))
|
||||||
|
var progress := ProgressBar.new()
|
||||||
|
progress.show_percentage = false
|
||||||
|
progress.value = int(weakest.get("accuracy", 0))
|
||||||
|
progress.custom_minimum_size.y = 8
|
||||||
|
_style_progress(progress, Brand.PURPLE)
|
||||||
|
focus_content.add_child(progress)
|
||||||
|
var practice := _button("Practice this system", "chevron-right")
|
||||||
|
practice.pressed.connect(func(): main.show_screen("selection", "question_bank"))
|
||||||
|
focus_content.add_child(practice)
|
||||||
|
|
||||||
|
|
||||||
|
func _render_review(data: Dictionary) -> void:
|
||||||
|
_clear(review_list)
|
||||||
|
review_list.add_child(_section_title("Review queue"))
|
||||||
|
review_list.add_child(_review_action("%d missed questions" % int(data.get("missedQuestionCount", 0)), "x", Brand.MAGENTA, "profile"))
|
||||||
|
review_list.add_child(_review_action("%d bookmarks" % int(data.get("bookmarkCount", 0)), "bookmark", Brand.ROYAL_BLUE, "bookmarks"))
|
||||||
|
review_list.add_child(_review_action("%d notes" % int(data.get("noteCount", 0)), "notebook-pen", Brand.AMBER, "notes"))
|
||||||
|
|
||||||
|
|
||||||
|
func _render_weekly(data: Dictionary, days: Array) -> void:
|
||||||
|
_clear(weekly_summary)
|
||||||
|
var questions := 0
|
||||||
|
var cases := 0
|
||||||
|
for day in days:
|
||||||
|
questions += int(day.get("questions", 0))
|
||||||
|
cases += int(day.get("ccsCases", 0))
|
||||||
|
weekly_summary.add_child(_summary_stat(str(questions), "Questions answered", "notebook-pen", Brand.ROYAL_BLUE))
|
||||||
|
weekly_summary.add_child(_summary_stat(str(cases), "CCS cases completed", "stethoscope", Brand.PURPLE))
|
||||||
|
weekly_summary.add_child(_summary_stat("%d-day" % int(data.get("currentStreak", 0)), "Current streak", "sun", Brand.AMBER))
|
||||||
|
|
||||||
|
|
||||||
|
func _render_recent(items: Array) -> void:
|
||||||
|
_clear(recent_list)
|
||||||
|
recent_list.add_child(_section_title("Recent activity"))
|
||||||
|
if items.is_empty():
|
||||||
|
recent_list.add_child(_label("Your completed study activity will appear here.", 14, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||||
|
return
|
||||||
|
for item in items:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.custom_minimum_size.y = 34
|
||||||
|
row.add_theme_constant_override("separation", 9)
|
||||||
|
var type := str(item.get("type", "question"))
|
||||||
|
var icon_name := "stethoscope" if type == "ccs" else ("clipboard-list" if type == "simulation" else "activity")
|
||||||
|
var icon := TextureRect.new()
|
||||||
|
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
||||||
|
icon.custom_minimum_size = Vector2(20, 20)
|
||||||
|
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||||
|
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
row.add_child(icon)
|
||||||
|
var title := _label(str(item.get("title", "Study activity")), 13, Brand.text_color(UIThemeService.is_dark_mode()))
|
||||||
|
title.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
title.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||||
|
row.add_child(title)
|
||||||
|
row.add_child(_label(str(item.get("detail", "")), 13, Brand.ROYAL_BLUE, true))
|
||||||
|
row.add_child(_label(_relative_time(str(item.get("occurredUtc", ""))), 11, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||||
|
recent_list.add_child(row)
|
||||||
|
|
||||||
|
|
||||||
|
func _add_metric(key: String, title: String, icon_name: String, accent: Color) -> void:
|
||||||
|
var cell := HBoxContainer.new()
|
||||||
|
cell.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
cell.custom_minimum_size = Vector2(230, 82)
|
||||||
|
cell.add_theme_constant_override("separation", 12)
|
||||||
|
var icon_panel := PanelContainer.new()
|
||||||
|
icon_panel.custom_minimum_size = Vector2(48, 48)
|
||||||
|
icon_panel.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
|
icon_panel.add_theme_stylebox_override("panel", Brand.icon_circle_box(UIThemeService.is_dark_mode(), accent, 24))
|
||||||
|
var icon := TextureRect.new()
|
||||||
|
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
||||||
|
icon.custom_minimum_size = Vector2(22, 22)
|
||||||
|
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||||
|
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
icon_panel.add_child(icon)
|
||||||
|
cell.add_child(icon_panel)
|
||||||
|
var copy := VBoxContainer.new()
|
||||||
|
copy.size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||||
|
copy.add_child(_label(title, 12, Brand.text_color(UIThemeService.is_dark_mode())))
|
||||||
|
var value := _label("--", 25, Brand.text_color(UIThemeService.is_dark_mode()), true)
|
||||||
|
copy.add_child(value)
|
||||||
|
var detail := _label("Loading", 11, Brand.muted_color(UIThemeService.is_dark_mode()))
|
||||||
|
copy.add_child(detail)
|
||||||
|
metric_values[key] = value
|
||||||
|
metric_details[key] = detail
|
||||||
|
cell.add_child(copy)
|
||||||
|
metric_grid.add_child(cell)
|
||||||
|
|
||||||
|
|
||||||
|
func _set_metric(key: String, value: String, detail: String) -> void:
|
||||||
|
(metric_values.get(key) as Label).text = value
|
||||||
|
(metric_details.get(key) as Label).text = detail
|
||||||
|
|
||||||
|
|
||||||
|
func _review_action(text: String, icon_name: String, accent: Color, route: String) -> Button:
|
||||||
|
var button := _button(text, icon_name, false)
|
||||||
|
button.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||||
|
button.add_theme_color_override("icon_normal_color", accent)
|
||||||
|
button.pressed.connect(func(): main._on_navigation_pressed(route))
|
||||||
|
return button
|
||||||
|
|
||||||
|
|
||||||
|
func _summary_stat(value: String, caption: String, icon_name: String, accent: Color) -> HBoxContainer:
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
row.add_theme_constant_override("separation", 10)
|
||||||
|
var icon := TextureRect.new()
|
||||||
|
icon.texture = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
||||||
|
icon.custom_minimum_size = Vector2(24, 24)
|
||||||
|
icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
|
||||||
|
icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
|
||||||
|
row.add_child(icon)
|
||||||
|
var copy := VBoxContainer.new()
|
||||||
|
copy.add_child(_label(value, 20, accent, true))
|
||||||
|
copy.add_child(_label(caption, 11, Brand.muted_color(UIThemeService.is_dark_mode())))
|
||||||
|
row.add_child(copy)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
func _card(child: Control, margin: int) -> PanelContainer:
|
||||||
|
var panel := PanelContainer.new()
|
||||||
|
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
panel.add_theme_stylebox_override("panel", _card_box(margin))
|
||||||
|
panel.add_child(child)
|
||||||
|
return panel
|
||||||
|
|
||||||
|
|
||||||
|
func _card_box(margin: int) -> StyleBoxFlat:
|
||||||
|
var box := StyleBoxFlat.new()
|
||||||
|
var dark := UIThemeService.is_dark_mode()
|
||||||
|
box.bg_color = Color("081426") if dark else Color.WHITE
|
||||||
|
box.border_color = Color("26344f") if dark else Brand.LIGHT_BORDER
|
||||||
|
box.set_border_width_all(1)
|
||||||
|
box.set_corner_radius_all(8)
|
||||||
|
box.content_margin_left = margin
|
||||||
|
box.content_margin_right = margin
|
||||||
|
box.content_margin_top = margin
|
||||||
|
box.content_margin_bottom = margin
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
func _button(text: String, icon_name: String = "", primary: bool = true) -> Button:
|
||||||
|
var button := Button.new()
|
||||||
|
button.text = text
|
||||||
|
button.custom_minimum_size = Vector2(0, 42)
|
||||||
|
button.add_theme_font_override("font", Brand.font)
|
||||||
|
button.add_theme_font_size_override("font_size", 14)
|
||||||
|
if not icon_name.is_empty():
|
||||||
|
button.icon = load("res://assets/icons/lucide/%s.svg" % icon_name)
|
||||||
|
button.add_theme_constant_override("icon_max_width", 18)
|
||||||
|
if primary:
|
||||||
|
var normal := StyleBoxFlat.new()
|
||||||
|
normal.bg_color = Brand.ROYAL_BLUE
|
||||||
|
normal.set_corner_radius_all(6)
|
||||||
|
var hover := normal.duplicate() as StyleBoxFlat
|
||||||
|
hover.bg_color = Color("3474ee")
|
||||||
|
button.add_theme_stylebox_override("normal", normal)
|
||||||
|
button.add_theme_stylebox_override("hover", hover)
|
||||||
|
button.add_theme_stylebox_override("pressed", normal)
|
||||||
|
button.add_theme_color_override("font_color", Color.WHITE)
|
||||||
|
button.add_theme_color_override("icon_normal_color", Color.WHITE)
|
||||||
|
else:
|
||||||
|
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(UIThemeService.is_dark_mode()))
|
||||||
|
return button
|
||||||
|
|
||||||
|
|
||||||
|
func _label(text: String, font_size: int, color: Color, bold: bool = false) -> Label:
|
||||||
|
var label := Label.new()
|
||||||
|
label.text = text
|
||||||
|
label.add_theme_font_override("font", Brand.font)
|
||||||
|
label.add_theme_font_size_override("font_size", font_size)
|
||||||
|
label.add_theme_color_override("font_color", color)
|
||||||
|
if bold:
|
||||||
|
label.add_theme_constant_override("outline_size", 0)
|
||||||
|
return label
|
||||||
|
|
||||||
|
|
||||||
|
func _section_title(text: String) -> Label:
|
||||||
|
return _label(text, 16, Brand.text_color(UIThemeService.is_dark_mode()), true)
|
||||||
|
|
||||||
|
|
||||||
|
func _style_progress(bar: ProgressBar, accent: Color) -> void:
|
||||||
|
var background := StyleBoxFlat.new()
|
||||||
|
background.bg_color = Color("26324a") if UIThemeService.is_dark_mode() else Color("e2e8f0")
|
||||||
|
background.set_corner_radius_all(4)
|
||||||
|
var fill := StyleBoxFlat.new()
|
||||||
|
fill.bg_color = accent
|
||||||
|
fill.set_corner_radius_all(4)
|
||||||
|
bar.add_theme_stylebox_override("background", background)
|
||||||
|
bar.add_theme_stylebox_override("fill", fill)
|
||||||
|
|
||||||
|
|
||||||
|
func _show_loading() -> void:
|
||||||
|
if status_label != null:
|
||||||
|
status_label.text = "Loading your progress..."
|
||||||
|
status_label.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
func _show_error(message: String) -> void:
|
||||||
|
status_label.text = message
|
||||||
|
status_label.add_theme_color_override("font_color", Brand.MAGENTA)
|
||||||
|
status_label.visible = true
|
||||||
|
|
||||||
|
|
||||||
|
func _update_responsive() -> void:
|
||||||
|
if metric_grid == null:
|
||||||
|
return
|
||||||
|
var compact := size.x < 1050.0
|
||||||
|
metric_grid.columns = 2 if compact else 4
|
||||||
|
analytics_grid.columns = 1 if compact else 2
|
||||||
|
lower_grid.columns = 1 if compact else 2
|
||||||
|
|
||||||
|
|
||||||
|
func _clear(container: Container) -> void:
|
||||||
|
for child in container.get_children():
|
||||||
|
container.remove_child(child)
|
||||||
|
child.free()
|
||||||
|
|
||||||
|
|
||||||
|
func _format_duration(total_seconds: int) -> String:
|
||||||
|
var hours := int(floor(float(total_seconds) / 3600.0))
|
||||||
|
var minutes := int(floor(float(total_seconds % 3600) / 60.0))
|
||||||
|
return "%dh %02dm" % [hours, minutes]
|
||||||
|
|
||||||
|
|
||||||
|
func _relative_time(iso_value: String) -> String:
|
||||||
|
var cleaned := iso_value.trim_suffix("Z")
|
||||||
|
var occurred := Time.get_unix_time_from_datetime_string(cleaned)
|
||||||
|
if occurred <= 0:
|
||||||
|
return ""
|
||||||
|
var elapsed := maxi(0, int(Time.get_unix_time_from_system() - occurred))
|
||||||
|
if elapsed < 3600:
|
||||||
|
return "%dm ago" % maxi(1, int(floor(float(elapsed) / 60.0)))
|
||||||
|
if elapsed < 86400:
|
||||||
|
return "%dh ago" % int(floor(float(elapsed) / 3600.0))
|
||||||
|
return "%dd ago" % int(floor(float(elapsed) / 86400.0))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://fe058164alja
|
||||||
@@ -662,7 +662,7 @@ func _accept_session(body: PackedByteArray, fallback_username: String) -> void:
|
|||||||
password_input.text = ""
|
password_input.text = ""
|
||||||
_set_busy(false)
|
_set_busy(false)
|
||||||
UISoundService.play_sound("correct")
|
UISoundService.play_sound("correct")
|
||||||
main.show_screen("selection", "exam")
|
main.show_screen("dashboard")
|
||||||
|
|
||||||
|
|
||||||
func _create_request(key: String, callback: Callable) -> void:
|
func _create_request(key: String, callback: Callable) -> void:
|
||||||
|
|||||||
+37
-7
@@ -6,6 +6,7 @@ const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd")
|
|||||||
|
|
||||||
var screens := {}
|
var screens := {}
|
||||||
var header_panel: PanelContainer
|
var header_panel: PanelContainer
|
||||||
|
var header_title: Label
|
||||||
var app_menu: PanelContainer
|
var app_menu: PanelContainer
|
||||||
var menu_exit_button: Button
|
var menu_exit_button: Button
|
||||||
var sound_toggle: CheckButton
|
var sound_toggle: CheckButton
|
||||||
@@ -39,7 +40,7 @@ func _ready() -> void:
|
|||||||
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("selection" if AuthState.is_logged_in else "login", "exam")
|
show_screen("dashboard" if AuthState.is_logged_in else "login")
|
||||||
|
|
||||||
|
|
||||||
func _update_responsive_layout() -> void:
|
func _update_responsive_layout() -> void:
|
||||||
@@ -76,6 +77,7 @@ func show_screen(screen_name: String, payload: Variant = null) -> void:
|
|||||||
return
|
return
|
||||||
|
|
||||||
current_screen_name = screen_name
|
current_screen_name = screen_name
|
||||||
|
_update_header_title(screen_name)
|
||||||
_apply_screen_margins(screen_name)
|
_apply_screen_margins(screen_name)
|
||||||
screen.visible = true
|
screen.visible = true
|
||||||
if header_panel != null:
|
if header_panel != null:
|
||||||
@@ -140,6 +142,12 @@ func _build_ui() -> void:
|
|||||||
menu_button.pressed.connect(_open_drawer)
|
menu_button.pressed.connect(_open_drawer)
|
||||||
header.add_child(menu_button)
|
header.add_child(menu_button)
|
||||||
|
|
||||||
|
header_title = Label.new()
|
||||||
|
header_title.add_theme_font_override("font", Brand.font)
|
||||||
|
header_title.add_theme_font_size_override("font_size", 22)
|
||||||
|
header_title.add_theme_color_override("font_color", Brand.text_color(UIThemeService.is_dark_mode()))
|
||||||
|
header.add_child(header_title)
|
||||||
|
|
||||||
var header_spacer := Control.new()
|
var header_spacer := Control.new()
|
||||||
header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
header.add_child(header_spacer)
|
header.add_child(header_spacer)
|
||||||
@@ -208,6 +216,12 @@ func _build_ui() -> void:
|
|||||||
screens_container.add_child(login)
|
screens_container.add_child(login)
|
||||||
screens["login"] = login
|
screens["login"] = login
|
||||||
|
|
||||||
|
var dashboard := preload("res://scripts/ui/dashboard_screen.gd").new()
|
||||||
|
dashboard.name = "DashboardScreen"
|
||||||
|
dashboard.main = self
|
||||||
|
screens_container.add_child(dashboard)
|
||||||
|
screens["dashboard"] = dashboard
|
||||||
|
|
||||||
var selector := preload("res://scripts/ui/study_selector.gd").new()
|
var selector := preload("res://scripts/ui/study_selector.gd").new()
|
||||||
selector.name = "StudySelector"
|
selector.name = "StudySelector"
|
||||||
selector.main = self
|
selector.main = self
|
||||||
@@ -355,15 +369,13 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
|||||||
wordmark.add_child(mind)
|
wordmark.add_child(mind)
|
||||||
container.add_child(HSeparator.new())
|
container.add_child(HSeparator.new())
|
||||||
|
|
||||||
_add_nav_button(container, "home", "Home", "house", true)
|
_add_nav_button(container, "home", "Dashboard", "chart-no-axes-column-increasing", true)
|
||||||
_add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true)
|
_add_nav_button(container, "question_bank", "Question Bank", "clipboard-list", true)
|
||||||
_add_nav_button(container, "exams", "Exams", "clipboard-list", true)
|
|
||||||
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true)
|
_add_nav_button(container, "ccs", "CCS Cases", "stethoscope", true)
|
||||||
_add_nav_button(container, "performance", "Performance", "chart-no-axes-column-increasing", true)
|
_add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true)
|
||||||
_add_nav_button(container, "flashcards", "Flashcards", "copy", false)
|
|
||||||
_add_nav_button(container, "study_plan", "Study Plan", "calendar-days", true)
|
|
||||||
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
|
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
|
||||||
_add_nav_button(container, "notes", "Notes", "notebook-pen", true)
|
_add_nav_button(container, "notes", "Notes", "notebook-pen", true)
|
||||||
|
_add_nav_button(container, "performance", "Profile", "user-round", true)
|
||||||
|
|
||||||
var spacer := Control.new()
|
var spacer := Control.new()
|
||||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||||
@@ -399,7 +411,7 @@ func _add_nav_button(container: VBoxContainer, key: String, label_text: String,
|
|||||||
|
|
||||||
func _on_navigation_pressed(key: String) -> void:
|
func _on_navigation_pressed(key: String) -> void:
|
||||||
match key:
|
match key:
|
||||||
"home": show_screen("selection", "exam")
|
"home": show_screen("dashboard")
|
||||||
"question_bank": show_screen("selection", "question_bank")
|
"question_bank": show_screen("selection", "question_bank")
|
||||||
"exams": show_screen("simulation_history")
|
"exams": show_screen("simulation_history")
|
||||||
"ccs": show_screen("ccs_hub")
|
"ccs": show_screen("ccs_hub")
|
||||||
@@ -411,6 +423,22 @@ func _on_navigation_pressed(key: String) -> void:
|
|||||||
"logout": _logout()
|
"logout": _logout()
|
||||||
|
|
||||||
|
|
||||||
|
func _update_header_title(screen_name: String) -> void:
|
||||||
|
if header_title == null:
|
||||||
|
return
|
||||||
|
var titles := {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"selection": "Question Bank",
|
||||||
|
"simulation_history": "Exam Simulations",
|
||||||
|
"ccs_hub": "CCS Cases",
|
||||||
|
"profile": "Profile",
|
||||||
|
"settings": "Settings",
|
||||||
|
"notes": "Notes",
|
||||||
|
"bookmarks": "Bookmarks"
|
||||||
|
}
|
||||||
|
header_title.text = str(titles.get(screen_name, ""))
|
||||||
|
|
||||||
|
|
||||||
func _build_account_menu() -> void:
|
func _build_account_menu() -> void:
|
||||||
account_menu = PanelContainer.new()
|
account_menu = PanelContainer.new()
|
||||||
account_menu.visible = false
|
account_menu.visible = false
|
||||||
@@ -691,6 +719,8 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
|
|||||||
sidebar.add_theme_stylebox_override("panel", _sidebar_box())
|
sidebar.add_theme_stylebox_override("panel", _sidebar_box())
|
||||||
if header_panel != null:
|
if header_panel != null:
|
||||||
header_panel.add_theme_stylebox_override("panel", _top_bar_box())
|
header_panel.add_theme_stylebox_override("panel", _top_bar_box())
|
||||||
|
if header_title != null:
|
||||||
|
header_title.add_theme_color_override("font_color", Brand.text_color(is_dark_mode))
|
||||||
if theme_button != null:
|
if theme_button != null:
|
||||||
_style_shell_theme_button(theme_button)
|
_style_shell_theme_button(theme_button)
|
||||||
for nav_key in nav_buttons:
|
for nav_key in nav_buttons:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ extends RefCounted
|
|||||||
|
|
||||||
static func key_for_screen(screen_name: String, payload: Variant = null) -> String:
|
static func key_for_screen(screen_name: String, payload: Variant = null) -> String:
|
||||||
var key := "home"
|
var key := "home"
|
||||||
if screen_name == "selection":
|
if screen_name == "dashboard":
|
||||||
|
key = "home"
|
||||||
|
elif screen_name == "selection":
|
||||||
key = "question_bank" if str(payload) == "question_bank" else "home"
|
key = "question_bank" if str(payload) == "question_bank" else "home"
|
||||||
elif screen_name in ["map", "setup", "review"]:
|
elif screen_name in ["map", "setup", "review"]:
|
||||||
key = "study_plan" if screen_name == "map" else "question_bank"
|
key = "study_plan" if screen_name == "map" else "question_bank"
|
||||||
|
|||||||
@@ -1298,7 +1298,8 @@ func _submit_or_next() -> void:
|
|||||||
QuestionBankService.record_first_attempt(
|
QuestionBankService.record_first_attempt(
|
||||||
question_id_for_attempt,
|
question_id_for_attempt,
|
||||||
attempted_choice,
|
attempted_choice,
|
||||||
_presented_choice_ids()
|
_presented_choice_ids(),
|
||||||
|
int(floor(BlockSessionService.get_core_question_total_elapsed_seconds()))
|
||||||
)
|
)
|
||||||
var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged) if BlockSessionService.session_kind == "core" else BlockSessionService.submit_bookmark_attempt(selected_choice, eliminated, flagged)
|
var result := BlockSessionService.submit_core_attempt(selected_choice, eliminated, flagged) if BlockSessionService.session_kind == "core" else BlockSessionService.submit_bookmark_attempt(selected_choice, eliminated, flagged)
|
||||||
if result.is_empty():
|
if result.is_empty():
|
||||||
@@ -1322,7 +1323,8 @@ func _submit_or_next() -> void:
|
|||||||
QuestionBankService.record_first_attempt(
|
QuestionBankService.record_first_attempt(
|
||||||
question_id_for_attempt,
|
question_id_for_attempt,
|
||||||
selected_choice,
|
selected_choice,
|
||||||
_presented_choice_ids()
|
_presented_choice_ids(),
|
||||||
|
int(floor(BlockSessionService.get_current_elapsed_seconds()))
|
||||||
)
|
)
|
||||||
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
|
current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged)
|
||||||
_show_answer_review()
|
_show_answer_review()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ var failures := 0
|
|||||||
|
|
||||||
|
|
||||||
func _initialize() -> void:
|
func _initialize() -> void:
|
||||||
|
_assert(NavigationRoutes.key_for_screen("dashboard") == "home", "dashboard should activate Dashboard navigation")
|
||||||
_assert(NavigationRoutes.key_for_screen("selection", "exam") == "home", "exam selection should activate Home")
|
_assert(NavigationRoutes.key_for_screen("selection", "exam") == "home", "exam selection should activate Home")
|
||||||
_assert(NavigationRoutes.key_for_screen("selection", "step3") == "home", "Step 3 mode selection should remain part of Home")
|
_assert(NavigationRoutes.key_for_screen("selection", "step3") == "home", "Step 3 mode selection should remain part of Home")
|
||||||
_assert(NavigationRoutes.key_for_screen("selection", "question_bank") == "question_bank", "direct question bank launch should activate Question Bank")
|
_assert(NavigationRoutes.key_for_screen("selection", "question_bank") == "question_bank", "direct question bank launch should activate Question Bank")
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func _run() -> void:
|
|||||||
host.add_child(main_screen)
|
host.add_child(main_screen)
|
||||||
await process_frame
|
await process_frame
|
||||||
main_screen._update_responsive_layout()
|
main_screen._update_responsive_layout()
|
||||||
|
_assert(main_screen.screens.has("dashboard"), "the authenticated shell should include the dashboard screen")
|
||||||
_assert(main_screen.theme_button.custom_minimum_size == Vector2(44, 44), "the theme toggle should keep a stable square footprint")
|
_assert(main_screen.theme_button.custom_minimum_size == Vector2(44, 44), "the theme toggle should keep a stable square footprint")
|
||||||
_assert(main_screen.theme_button.size.x >= 44.0 and main_screen.theme_button.size.y >= 44.0, "the theme toggle should not collapse into an oval")
|
_assert(main_screen.theme_button.size.x >= 44.0 and main_screen.theme_button.size.y >= 44.0, "the theme toggle should not collapse into an oval")
|
||||||
_assert(main_screen.theme_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "the theme toggle icon should remain centered")
|
_assert(main_screen.theme_button.icon_alignment == HORIZONTAL_ALIGNMENT_CENTER, "the theme toggle icon should remain centered")
|
||||||
|
|||||||
Reference in New Issue
Block a user