diff --git a/api/QuestionsApi.Tests/DashboardTests.cs b/api/QuestionsApi.Tests/DashboardTests.cs new file mode 100644 index 0000000..6b50d2c --- /dev/null +++ b/api/QuestionsApi.Tests/DashboardTests.cs @@ -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)); + } +} diff --git a/api/QuestionsApi/Controllers/DashboardController.cs b/api/QuestionsApi/Controllers/DashboardController.cs new file mode 100644 index 0000000..9854828 --- /dev/null +++ b/api/QuestionsApi/Controllers/DashboardController.cs @@ -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 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)); + } +} diff --git a/api/QuestionsApi/Controllers/QuestionsController.cs b/api/QuestionsApi/Controllers/QuestionsController.cs index 4824317..e2850f3 100644 --- a/api/QuestionsApi/Controllers/QuestionsController.cs +++ b/api/QuestionsApi/Controllers/QuestionsController.cs @@ -150,7 +150,8 @@ public class QuestionsController : ControllerBase req.ChoiceId.Trim(), req.ChoiceId.Trim() == question.CorrectChoiceId, false, - presentedChoiceIds); + presentedChoiceIds, + req.DurationSeconds); if (record.Inserted) { _metrics.AttemptCreated(); diff --git a/api/QuestionsApi/Models/DashboardModels.cs b/api/QuestionsApi/Models/DashboardModels.cs new file mode 100644 index 0000000..1afea94 --- /dev/null +++ b/api/QuestionsApi/Models/DashboardModels.cs @@ -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 PerformanceTrend { get; set; } = []; + public List Systems { get; set; } = []; + public List WeeklyActivity { get; set; } = []; + public List 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; } = ""; +} diff --git a/api/QuestionsApi/Models/QuestionAttempt.cs b/api/QuestionsApi/Models/QuestionAttempt.cs index 4657406..c0f4440 100644 --- a/api/QuestionsApi/Models/QuestionAttempt.cs +++ b/api/QuestionsApi/Models/QuestionAttempt.cs @@ -16,6 +16,7 @@ public class QuestionAttempt public bool TracksPresentation { get; set; } public bool IsCorrect { get; set; } public bool IsSkip { get; set; } + public int DurationSeconds { get; set; } public int CounterBucket { get; set; } public bool? RatingApplied { get; set; } public string RatingLeaseOwner { get; set; } = ""; diff --git a/api/QuestionsApi/Models/QuestionRequests.cs b/api/QuestionsApi/Models/QuestionRequests.cs index 0f86b55..9ddbb26 100644 --- a/api/QuestionsApi/Models/QuestionRequests.cs +++ b/api/QuestionsApi/Models/QuestionRequests.cs @@ -43,4 +43,5 @@ public class FirstQuestionAttemptRequest public string QuestionExternalId { get; set; } = ""; public string ChoiceId { get; set; } = ""; public List PresentedChoiceIds { get; set; } = []; + public int DurationSeconds { get; set; } } diff --git a/api/QuestionsApi/Program.cs b/api/QuestionsApi/Program.cs index ff56610..6f8de5e 100644 --- a/api/QuestionsApi/Program.cs +++ b/api/QuestionsApi/Program.cs @@ -23,6 +23,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddHostedService(); diff --git a/api/QuestionsApi/Services/CcsStore.cs b/api/QuestionsApi/Services/CcsStore.cs index a614517..0fad430 100644 --- a/api/QuestionsApi/Services/CcsStore.cs +++ b/api/QuestionsApi/Services/CcsStore.cs @@ -27,6 +27,8 @@ public sealed class CcsStore Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.State).Descending(value => value.UpdatedUtc))); _attempts.Indexes.CreateOne(new CreateIndexModel( Builders.IndexKeys.Ascending(value => value.UserId).Ascending(value => value.CaseExternalId).Ascending(value => value.Mode).Ascending(value => value.CreatedUtc))); + _attempts.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(value => value.UserId).Descending(value => value.CreatedUtc))); } public async Task ImportAsync(CcsImportRequest request) diff --git a/api/QuestionsApi/Services/DashboardStore.cs b/api/QuestionsApi/Services/DashboardStore.cs new file mode 100644 index 0000000..2696e0d --- /dev/null +++ b/api/QuestionsApi/Services/DashboardStore.cs @@ -0,0 +1,190 @@ +using MongoDB.Driver; +using QuestionsApi.Models; + +namespace QuestionsApi.Services; + +public sealed class DashboardStore +{ + private readonly IMongoCollection _attempts; + private readonly IMongoCollection _ratings; + private readonly IMongoCollection _ccsRatings; + private readonly IMongoCollection _ccsAttempts; + private readonly IMongoCollection _ccsCases; + private readonly IMongoCollection _simulations; + private readonly IMongoCollection _bookmarks; + private readonly IMongoCollection _notes; + private readonly QuestionStore _questions; + + public DashboardStore(MongoDatabaseProvider mongo, QuestionStore questions) + { + _attempts = mongo.Database.GetCollection("QuestionAttempts"); + _ratings = mongo.Database.GetCollection("UserRatings"); + _ccsRatings = mongo.Database.GetCollection("CcsUserRatings"); + _ccsAttempts = mongo.Database.GetCollection("CcsAttempts"); + _ccsCases = mongo.Database.GetCollection("CcsCaseRevisions"); + _simulations = mongo.Database.GetCollection("ExamSimulations"); + _bookmarks = mongo.Database.GetCollection("QuestionBookmarks"); + _notes = mongo.Database.GetCollection("UserNotepads"); + _questions = questions; + } + + public async Task 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 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> BuildRecentActivityAsync( + List attempts, + Dictionary questionMap, + List ccsAttempts, + List 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(); +} diff --git a/api/QuestionsApi/Services/QuestionAttemptStore.cs b/api/QuestionsApi/Services/QuestionAttemptStore.cs index b6e3d74..dd2b887 100644 --- a/api/QuestionsApi/Services/QuestionAttemptStore.cs +++ b/api/QuestionsApi/Services/QuestionAttemptStore.cs @@ -27,6 +27,8 @@ public class QuestionAttemptStore var createdIndex = Builders.IndexKeys.Ascending(a => a.CreatedUtc); _col.Indexes.CreateOne(new CreateIndexModel(createdIndex)); + _col.Indexes.CreateOne(new CreateIndexModel( + Builders.IndexKeys.Ascending(a => a.UserId).Descending(a => a.CreatedUtc))); } public async Task RecordFirstAttemptAsync( @@ -35,7 +37,8 @@ public class QuestionAttemptStore string choiceId, bool isCorrect, bool isSkip = false, - IReadOnlyCollection? presentedChoiceIds = null) + IReadOnlyCollection? presentedChoiceIds = null, + int durationSeconds = 0) { var attempt = new QuestionAttempt { @@ -46,6 +49,7 @@ public class QuestionAttemptStore TracksPresentation = presentedChoiceIds is { Count: > 0 }, IsCorrect = isCorrect, IsSkip = isSkip, + DurationSeconds = Math.Clamp(durationSeconds, 0, 86_400), CounterBucket = GetCounterBucket(userId), RatingApplied = false, CreatedUtc = DateTime.UtcNow diff --git a/project.godot b/project.godot index 80f8375..d77ee81 100644 --- a/project.godot +++ b/project.godot @@ -30,6 +30,7 @@ CCSService="*res://scripts/autoload/ccs_service.gd" FeedbackService="*res://scripts/autoload/feedback_service.gd" NotesService="*res://scripts/autoload/notes_service.gd" BookmarkService="*res://scripts/autoload/bookmark_service.gd" +DashboardService="*res://scripts/autoload/dashboard_service.gd" [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" app_feedback_api_url="https://s3q.ranaze.com/api/app-feedback" notes_api_url="https://s3q.ranaze.com/api/notes" +dashboard_api_url="https://s3q.ranaze.com/api/dashboard" build_revision="development" diff --git a/scripts/autoload/dashboard_service.gd b/scripts/autoload/dashboard_service.gd new file mode 100644 index 0000000..1d6152e --- /dev/null +++ b/scripts/autoload/dashboard_service.gd @@ -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() diff --git a/scripts/autoload/dashboard_service.gd.uid b/scripts/autoload/dashboard_service.gd.uid new file mode 100644 index 0000000..03360c1 --- /dev/null +++ b/scripts/autoload/dashboard_service.gd.uid @@ -0,0 +1 @@ +uid://c8nmhxwb875uy diff --git a/scripts/autoload/question_bank_service.gd b/scripts/autoload/question_bank_service.gd index 0161d65..0f7f1e4 100644 --- a/scripts/autoload/question_bank_service.gd +++ b/scripts/autoload/question_bank_service.gd @@ -257,10 +257,10 @@ func clear_questions() -> void: 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 == "": 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: @@ -386,7 +386,7 @@ func _on_retry_timer_timeout() -> void: 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() if account == "": return @@ -399,6 +399,7 @@ func _enqueue_attempt(kind: String, question_id: String, choice_id: String, pres "question_id": question_id, "choice_id": choice_id, "presented_choice_ids": presented_choice_ids.duplicate(), + "duration_seconds": maxi(0, duration_seconds), "retry_count": 0 }) _save_attempt_queue() @@ -430,7 +431,8 @@ func _process_attempt_queue() -> void: var body := JSON.stringify({ "questionExternalId": question_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": url = "%s/%s/skip" % [question_api_base_url.rstrip("/"), question_id.uri_encode()] diff --git a/scripts/ui/dashboard_activity_chart.gd b/scripts/ui/dashboard_activity_chart.gd new file mode 100644 index 0000000..b41a8b0 --- /dev/null +++ b/scripts/ui/dashboard_activity_chart.gd @@ -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))] diff --git a/scripts/ui/dashboard_activity_chart.gd.uid b/scripts/ui/dashboard_activity_chart.gd.uid new file mode 100644 index 0000000..5ad007b --- /dev/null +++ b/scripts/ui/dashboard_activity_chart.gd.uid @@ -0,0 +1 @@ +uid://4yvpcg55sqe diff --git a/scripts/ui/dashboard_chart.gd b/scripts/ui/dashboard_chart.gd new file mode 100644 index 0000000..bc3c002 --- /dev/null +++ b/scripts/ui/dashboard_chart.gd @@ -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) diff --git a/scripts/ui/dashboard_chart.gd.uid b/scripts/ui/dashboard_chart.gd.uid new file mode 100644 index 0000000..2a59a92 --- /dev/null +++ b/scripts/ui/dashboard_chart.gd.uid @@ -0,0 +1 @@ +uid://c11sgvq5oewuj diff --git a/scripts/ui/dashboard_screen.gd b/scripts/ui/dashboard_screen.gd new file mode 100644 index 0000000..778ae3e --- /dev/null +++ b/scripts/ui/dashboard_screen.gd @@ -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)) diff --git a/scripts/ui/dashboard_screen.gd.uid b/scripts/ui/dashboard_screen.gd.uid new file mode 100644 index 0000000..ae8a468 --- /dev/null +++ b/scripts/ui/dashboard_screen.gd.uid @@ -0,0 +1 @@ +uid://fe058164alja diff --git a/scripts/ui/login_screen.gd b/scripts/ui/login_screen.gd index 55d1a57..efac229 100644 --- a/scripts/ui/login_screen.gd +++ b/scripts/ui/login_screen.gd @@ -662,7 +662,7 @@ func _accept_session(body: PackedByteArray, fallback_username: String) -> void: password_input.text = "" _set_busy(false) UISoundService.play_sound("correct") - main.show_screen("selection", "exam") + main.show_screen("dashboard") func _create_request(key: String, callback: Callable) -> void: diff --git a/scripts/ui/main.gd b/scripts/ui/main.gd index 159e9d9..01eae39 100644 --- a/scripts/ui/main.gd +++ b/scripts/ui/main.gd @@ -6,6 +6,7 @@ const NavigationRoutes := preload("res://scripts/ui/navigation_routes.gd") var screens := {} var header_panel: PanelContainer +var header_title: Label var app_menu: PanelContainer var menu_exit_button: Button var sound_toggle: CheckButton @@ -39,7 +40,7 @@ func _ready() -> void: theme_service.theme_changed.connect(_on_theme_changed) resized.connect(_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: @@ -76,6 +77,7 @@ func show_screen(screen_name: String, payload: Variant = null) -> void: return current_screen_name = screen_name + _update_header_title(screen_name) _apply_screen_margins(screen_name) screen.visible = true if header_panel != null: @@ -140,6 +142,12 @@ func _build_ui() -> void: menu_button.pressed.connect(_open_drawer) 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() header_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL header.add_child(header_spacer) @@ -208,6 +216,12 @@ func _build_ui() -> void: screens_container.add_child(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() selector.name = "StudySelector" selector.main = self @@ -355,15 +369,13 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void wordmark.add_child(mind) 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, "exams", "Exams", "clipboard-list", 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, "flashcards", "Flashcards", "copy", false) - _add_nav_button(container, "study_plan", "Study Plan", "calendar-days", true) + _add_nav_button(container, "exams", "Exam Simulations", "clipboard-list", true) _add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true) _add_nav_button(container, "notes", "Notes", "notebook-pen", true) + _add_nav_button(container, "performance", "Profile", "user-round", true) var spacer := Control.new() 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: match key: - "home": show_screen("selection", "exam") + "home": show_screen("dashboard") "question_bank": show_screen("selection", "question_bank") "exams": show_screen("simulation_history") "ccs": show_screen("ccs_hub") @@ -411,6 +423,22 @@ func _on_navigation_pressed(key: String) -> void: "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: account_menu = PanelContainer.new() 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()) if header_panel != null: 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: _style_shell_theme_button(theme_button) for nav_key in nav_buttons: diff --git a/scripts/ui/navigation_routes.gd b/scripts/ui/navigation_routes.gd index 7de0569..262fc0b 100644 --- a/scripts/ui/navigation_routes.gd +++ b/scripts/ui/navigation_routes.gd @@ -3,7 +3,9 @@ extends RefCounted static func key_for_screen(screen_name: String, payload: Variant = null) -> String: 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" elif screen_name in ["map", "setup", "review"]: key = "study_plan" if screen_name == "map" else "question_bank" diff --git a/scripts/ui/question_player.gd b/scripts/ui/question_player.gd index 8f472e1..4bcc0d1 100644 --- a/scripts/ui/question_player.gd +++ b/scripts/ui/question_player.gd @@ -1298,7 +1298,8 @@ func _submit_or_next() -> void: QuestionBankService.record_first_attempt( question_id_for_attempt, 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) if result.is_empty(): @@ -1322,7 +1323,8 @@ func _submit_or_next() -> void: QuestionBankService.record_first_attempt( question_id_for_attempt, selected_choice, - _presented_choice_ids() + _presented_choice_ids(), + int(floor(BlockSessionService.get_current_elapsed_seconds())) ) current_answer = BlockSessionService.answer_current(selected_choice, eliminated, flagged) _show_answer_review() diff --git a/tests/shell_navigation_test.gd b/tests/shell_navigation_test.gd index b57bf81..ad9a4d2 100644 --- a/tests/shell_navigation_test.gd +++ b/tests/shell_navigation_test.gd @@ -6,6 +6,7 @@ var failures := 0 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", "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") diff --git a/tests/web_responsive_test.gd b/tests/web_responsive_test.gd index 409686c..310e834 100644 --- a/tests/web_responsive_test.gd +++ b/tests/web_responsive_test.gd @@ -42,6 +42,7 @@ func _run() -> void: host.add_child(main_screen) await process_frame 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.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")