Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 31 additions & 7 deletions WSIST/WSIST.Engine/PriorityCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

public class PriorityCalculator
{
public int CalculateUrgencyScore(DateOnly dueDate)
public int CalculateUrgencyScore(DateOnly dueDate, DateOnly? asOfDate = null)
{
var daysUntil = dueDate.DayNumber - DateOnly.FromDateTime(DateTime.Today).DayNumber;
var reference = asOfDate ?? DateOnly.FromDateTime(DateTime.Today);
var daysUntil = dueDate.DayNumber - reference.DayNumber;

return daysUntil switch
{
Expand Down Expand Up @@ -62,18 +63,19 @@ public int CalculateGradeScore(int subjectId, List<Test> allTests)
};
}

public int CalculateTotalScore(Test test, List<Test> allTests)
public int CalculateTotalScore(Test test, List<Test> allTests, DateOnly? asOfDate = null)
{
var sum = 0;
sum += CalculateUrgencyScore(test.DueDate);
sum += CalculateUrgencyScore(test.DueDate, asOfDate);
sum += CalculateVolumeScore(test.Volume);
sum += CalculateUnderstandingScore(test.Understanding);
sum += CalculateGradeScore(test.Subject, allTests);
return sum;
}

public List<Test> GetStudyRecommendations(List<Test> allTests, double hoursAvailable)
public List<Test> GetStudyRecommendations(List<Test> allTests, double hoursAvailable, DateOnly? asOfDate = null)
{
var reference = asOfDate ?? DateOnly.FromDateTime(DateTime.Today);
var topCount = hoursAvailable switch
{
< 1 => 1,
Expand All @@ -84,10 +86,32 @@ public List<Test> GetStudyRecommendations(List<Test> allTests, double hoursAvail
};

return allTests
.Where(t => t.DueDate >= DateOnly.FromDateTime(DateTime.Today)) // exclude past tests
.OrderByDescending(t => CalculateTotalScore(t, allTests))
.Where(t => t.DueDate >= reference) // exclude tests already due by the reference date
.OrderByDescending(t => CalculateTotalScore(t, allTests, reference))
.ThenBy(t => t.DueDate)
.Take(topCount)
.ToList();
}

// Plan the coming 7 days. A test repeating on several days is intentional —
// it has consistently high priority throughout the week and deserves repeated attention.
public Dictionary<DateOnly, List<Test>> GetWeeklyPlan(
List<Test> allTests,
Dictionary<DayOfWeek, double> hoursPerDay)
{
var plan = new Dictionary<DateOnly, List<Test>>();
var today = DateOnly.FromDateTime(DateTime.Today);

for (int i = 0; i < 7; i++)
{
var date = today.AddDays(i);
var hours = hoursPerDay.GetValueOrDefault(date.DayOfWeek, 0);

plan[date] = hours > 0
? GetStudyRecommendations(allTests, hours, date)
: [];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return plan;
}
}
84 changes: 84 additions & 0 deletions WSIST/WSIST.Web/Components/Pages/Home.razor
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,22 @@
</div>
</div>

@if (MissingGrades.Any())
{
<div class="missing-grades-strip">
<span class="missing-grades-label">⏳ Enter missing grades</span>
<div class="missing-grades-list">
@foreach (var test in MissingGrades)
{
<button class="missing-grade-chip" @onclick="() => OpenEditTestModal(test)">
@test.Title
<span class="missing-grade-chip-date">@test.DueDate.ToShortDateString()</span>
</button>
}
</div>
</div>
}

@if (topRecommendation is not null)
{
var score = calculator.CalculateTotalScore(topRecommendation, allTests);
Expand Down Expand Up @@ -115,6 +131,74 @@
</div>
}

@{
var gradeHistory = GetGradeHistory();
}

@if (gradeHistory.Any())
{
<div class="grade-trends">
<h2 class="grade-overview-title">Grade trends</h2>
<div class="grade-trend-grid">
@foreach (var (subjectId, history) in gradeHistory)
{
var subjectName = subjects.FirstOrDefault(s => s.Id == subjectId)?.Name ?? subjectId.ToString();
var latest = history.Last().Grade;
var first = history.First().Grade;
var trend = latest - first;

// SVG dimensions
const int w = 160;
const int h = 56;
const int pad = 8;
int pts = history.Count;

// Map grades (1–6) to Y coords — higher grade = lower Y value (top of SVG)
string ToY(double g) => ((h - pad) - ((g - 1) / 5.0 * (h - pad * 2))).ToString("F1");
string ToX(int i) => (pad + i * (double)(w - pad * 2) / (pts - 1)).ToString("F1");

var points = string.Join(" ", history.Select((item, i) => $"{ToX(i)},{ToY(item.Grade)}"));
var dotPoints = history.Select((item, i) => (X: ToX(i), Y: ToY(item.Grade), Grade: item.Grade)).ToList();

<div class="grade-trend-card">
<div class="grade-trend-header">
<span class="grade-trend-subject">@subjectName</span>
<span class="grade-trend-latest @GetGradeClass(latest)">@latest.ToString("F1")</span>
</div>
<svg viewBox="0 0 @w @h" class="grade-trend-svg" preserveAspectRatio="none">
<!-- Guide lines at grades 4 and 5 -->
<line x1="@pad" y1="@ToY(4)" x2="@(w - pad)" y2="@ToY(4)"
stroke="var(--border)" stroke-width="1" stroke-dasharray="3,3"/>
<line x1="@pad" y1="@ToY(5)" x2="@(w - pad)" y2="@ToY(5)"
stroke="var(--border)" stroke-width="1" stroke-dasharray="3,3"/>
<!-- Grade line -->
<polyline points="@points"
fill="none"
stroke="var(--accent)"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"/>
<!-- Grade dots -->
@foreach (var dot in dotPoints)
{
<circle cx="@dot.X" cy="@dot.Y" r="3"
fill="var(--accent)" stroke="var(--bg-elevated)" stroke-width="1.5">
<title>@dot.Grade.ToString("F1")</title>
</circle>
}
</svg>
<div class="grade-trend-footer">
<span class="grade-trend-count">@history.Count test@(history.Count == 1 ? "" : "s")</span>
<span class="grade-trend-delta @(trend >= 0 ? "delta-up" : "delta-down")">
@(trend >= 0 ? "↑" : "↓") @Math.Abs(trend).ToString("F1")
</span>
</div>
</div>
}
</div>
</div>
}

@if (showModal && temporaryTest is not null)
{
<div class="wsist-modal">
Expand Down
22 changes: 22 additions & 0 deletions WSIST/WSIST.Web/Components/Pages/Home.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
AuthenticationStateProvider authStateProvider,
NavigationManager navigation,
PriorityCalculator calculator)
: AuthenticatedComponentBase(management, authStateProvider, navigation)

Check warning on line 12 in WSIST/WSIST.Web/Components/Pages/Home.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Parameter 'TestManagement management' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 12 in WSIST/WSIST.Web/Components/Pages/Home.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Parameter 'TestManagement management' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.
{
private List<Test> allTests = [];
private List<Test> tests = [];
Expand All @@ -21,6 +21,13 @@

private static DateOnly Today => DateOnly.FromDateTime(DateTime.Today);

// Past tests that still have no grade entered, oldest first.
private List<Test> MissingGrades =>
allTests
.Where(t => t.DueDate < DateOnly.FromDateTime(DateTime.Today) && t.Grade == null)
.OrderBy(t => t.DueDate)
.ToList();

protected override Task OnAuthenticatedAsync()
{
Refresh();
Expand All @@ -46,6 +53,21 @@
);
}

// Chronological grade history per subject, only for subjects with 2+ graded tests.
private Dictionary<int, List<(DateOnly Date, double Grade)>> GetGradeHistory()
{
return allTests
.Where(t => t.Grade.HasValue)
.GroupBy(t => t.Subject)
.Where(g => g.Count() >= 2)
.ToDictionary(
g => g.Key,
g => g.OrderBy(t => t.DueDate)
.Select(t => (t.DueDate, t.Grade!.Value))
.ToList()
);
}

private string GetGradeClass(double avg) => avg switch
{
>= 5 => "grade-good",
Expand Down
96 changes: 96 additions & 0 deletions WSIST/WSIST.Web/Components/Pages/Study.razor
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@page "/study"
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@using WSIST.Engine
@inherits AuthenticatedComponentBase
<PageTitle>WSIST — Study</PageTitle>

Expand All @@ -19,6 +20,17 @@
<p>Tell us how much time you have — we'll figure out the rest.</p>
</div>

<div class="study-mode-toggle">
<button class="study-mode-btn @(!weeklyMode ? "active" : "")" @onclick="() => weeklyMode = false">
Today
</button>
<button class="study-mode-btn @(weeklyMode ? "active" : "")" @onclick="() => weeklyMode = true">
This week
</button>
</div>

@if (!weeklyMode)
{
<div class="study-input-card">
<label class="study-input-label">Hours available today</label>
<div class="study-input-row">
Expand Down Expand Up @@ -59,6 +71,90 @@
<div class="study-card-bar-bg">
<div class="study-card-bar" style="width: @(score / 40.0 * 100)%"></div>
</div>

@if (studiedTestId == test.Id)
{
<div class="studied-prompt">
<span class="studied-prompt-label">How's your understanding now?</span>
<div class="studied-prompt-row">
<select class="studied-select" @bind="updatedUnderstanding">
@foreach (var u in Enum.GetValues<Test.PersonalUnderstanding>())
{
<option value="@u">@Test.UnderstandingHelper(u)</option>
}
</select>
<button class="button-primary button-accent" @onclick="() => SaveStudiedUnderstanding(test)">Save</button>
<button class="button-primary" @onclick="CancelStudiedPrompt">Cancel</button>
</div>
</div>
}
else
{
<div class="study-card-footer">
<button class="button-primary studied-btn" @onclick="() => OpenStudiedPrompt(test)">
✓ Studied it
</button>
</div>
}
</div>
}
</div>
}
}
}
else
{
<div class="study-input-card">
<label class="study-input-label">Hours available each day</label>
<div class="weekly-hours-grid">
@foreach (var day in weeklyHours.Keys)
{
<div class="weekly-hours-row">
<span class="weekly-day-label">@day.ToString()[..3]</span>
<input type="number" min="0" max="12" step="0.5"
value="@weeklyHours[day]"
@onchange="e => weeklyHours[day] = double.TryParse(e.Value?.ToString(), out var v) ? v : 0"/>
</div>
}
</div>
<button class="button-primary button-accent" style="align-self: flex-start; margin-top: 8px;" @onclick="CalculateWeekly">
Plan my week
</button>
</div>

@if (weeklyCalculated)
{
<div class="weekly-plan">
@foreach (var (date, tests) in weeklyPlan.OrderBy(x => x.Key))
{
<div class="weekly-day-block">
<div class="weekly-day-header">
<span class="weekly-day-name">@DayLabel(date)</span>
@if (!tests.Any())
{
<span class="weekly-day-off">Day off</span>
}
</div>
@if (tests.Any())
{
<div class="weekly-day-tests">
@foreach (var test in tests)
{
var score = calculator.CalculateTotalScore(test, allTests, date);
var days = test.DueDate.DayNumber - date.DayNumber;
<div class="weekly-test-row">
<div class="weekly-test-info">
<span class="weekly-test-title">@test.Title</span>
<span class="weekly-test-meta">
@(subjects.FirstOrDefault(s => s.Id == test.Subject)?.Name ?? "Unknown")
· @days day@(days == 1 ? "" : "s") away
</span>
</div>
<span class="weekly-test-score @GetGradeClass(score / 40.0 * 6)">@score/40</span>
</div>
}
</div>
}
</div>
}
</div>
Expand Down
65 changes: 65 additions & 0 deletions WSIST/WSIST.Web/Components/Pages/Study.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,28 @@
namespace WSIST.Web.Components.Pages;

public partial class Study(TestManagement management, AuthenticationStateProvider authStateProvider, NavigationManager navigation, PriorityCalculator calculator)
: AuthenticatedComponentBase(management, authStateProvider, navigation)

Check warning on line 9 in WSIST/WSIST.Web/Components/Pages/Study.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Parameter 'TestManagement management' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.

Check warning on line 9 in WSIST/WSIST.Web/Components/Pages/Study.razor.cs

View workflow job for this annotation

GitHub Actions / build-and-test

Parameter 'TestManagement management' is captured into the state of the enclosing type and its value is also passed to the base constructor. The value might be captured by the base class as well.
{
private List<Test> allTests = [];
private List<Test> recommendations = [];
private List<Subject> subjects = [];
private double hoursAvailable = 1;
private bool calculated = false;
private Guid? studiedTestId;
private Test.PersonalUnderstanding updatedUnderstanding;
private bool weeklyMode = false;
private Dictionary<DayOfWeek, double> weeklyHours = new()
{
{ DayOfWeek.Monday, 1 },
{ DayOfWeek.Tuesday, 1 },
{ DayOfWeek.Wednesday, 1 },
{ DayOfWeek.Thursday, 1 },
{ DayOfWeek.Friday, 1 },
{ DayOfWeek.Saturday, 0 },
{ DayOfWeek.Sunday, 0 },
};
private Dictionary<DateOnly, List<Test>> weeklyPlan = [];
private bool weeklyCalculated = false;

protected override Task OnAuthenticatedAsync()
{
Expand All @@ -27,6 +42,56 @@
calculated = true;
}

private void CalculateWeekly()
{
weeklyPlan = calculator.GetWeeklyPlan(allTests, weeklyHours);
weeklyCalculated = true;
}

private static string DayLabel(DateOnly date)
{
var today = DateOnly.FromDateTime(DateTime.Today);
return date == today ? "Today" : date.ToString("ddd d MMM");
}

private string GetGradeClass(double avg) => avg switch
{
>= 5 => "grade-good",
>= 4 => "grade-ok",
_ => "grade-poor"
};

private void OpenStudiedPrompt(Test test)
{
studiedTestId = test.Id;
updatedUnderstanding = test.Understanding;
}

private void CancelStudiedPrompt()
{
studiedTestId = null;
}

private void SaveStudiedUnderstanding(Test test)
{
management.TestEditor(
test.Id,
test.Title,
test.Subject,
test.DueDate,
test.Volume,
updatedUnderstanding,
test.Grade
);

allTests = management.LoadAllTests(CurrentUserId);
recommendations = calculator.GetStudyRecommendations(allTests, hoursAvailable);
if (weeklyCalculated)
weeklyPlan = calculator.GetWeeklyPlan(allTests, weeklyHours);
studiedTestId = null;
StateHasChanged();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

private string GetScoreBreakdown(Test test)
{
var urgency = calculator.CalculateUrgencyScore(test.DueDate);
Expand Down
Loading
Loading