Appearance
Are you an LLM? You can read better optimized documentation at /features/visited_lessons.md for this page in Markdown format
Visited lessons tracking
This document provides a detailed technical overview of the lesson progress tracking feature within TheExampleApp. This feature gives users a persistent visual indication of which course lessons they have previously visited.
Lesson progress tracking
The primary goal of this feature is to enhance the user experience by tracking lesson completion. When a user visits a lesson page, the system marks that lesson as "visited." This status is then used to apply a distinct visual style to the lesson's link in the course's table of contents, allowing users to easily see their progress.
The implementation is composed of three main components:
VisitedLessonsManager: A service responsible for the core logic of reading, updating, and persisting the list of visited lessons.- Razor Page Model (
Lessons.cshtml.cs): The server-side logic for a lesson page, which orchestrates the interaction with theVisitedLessonsManager. - Razor View (
TableOfContents.cshtml): The UI component that consumes the tracking data to render the appropriate visual state.
This feature is designed to be lightweight and stateless from the server's perspective, relying on client-side persistence.
VisitedLessonsManager
The VisitedLessonsManager is the central service for managing the state of visited lessons. It encapsulates all logic for reading from and writing to the persistence layer.
Service Registration
The manager is registered with the application's dependency injection container using a transient lifetime. This ensures a new instance is created for each HTTP request, which is appropriate as its state is derived from the incoming request's cookie.
See the Dependency Injection documentation for more details on service lifetimes.
csharp
// File: TheExampleApp/Startup.cs
public void ConfigureServices(IServiceCollection services)
{
// ... other services
services.AddTransient<IVisitedLessonsManager, VisitedLessonsManager>();
// ...
}1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
Core Logic and State Management
The manager's state is not stored on the server. Instead, it is initialized by reading a cookie from the client's browser on each request.
- Persistence: State is stored in a client-side cookie named
ContentfulVisitedLessons. - Data Format: The cookie's value is a single string containing the Contentful entry IDs of visited lessons and courses, delimited by semicolons (
;). - Initialization: The
VisitedLessonsManagerconstructor accesses theIHttpContextAccessorto read the cookie. It then parses the semicolon-delimited string into aList<string>which is stored in the publicVisitedLessonsproperty. It gracefully handles cases where the cookie is empty or does not exist. - Updating State: The
AddVisitedLesson(string lessonId)method adds a new ID to the in-memory list and then immediately writes the updated list back to the response cookie. It uses LINQ'sDistinct()method to ensure no duplicate IDs are stored.
csharp
// File: TheExampleApp/Configuration/VisitedLessons.cs
/// <summary>
/// Class responsible for keeping track of which lessons and courses have been visited and not.
/// </summary>
public class VisitedLessonsManager: IVisitedLessonsManager
{
private readonly string _key = "ContentfulVisitedLessons";
private readonly IHttpContextAccessor _accessor;
/// <summary>
/// The lessons and courses that have been visited.
/// </summary>
public List<string> VisitedLessons { get; private set; }
/// <summary>
/// Initializes a new <see cref="VisitedLessonsManager"/>.
/// </summary>
/// <param name="accessor">The http context accessor used to retrieve and read cookies.</param>
public VisitedLessonsManager(IHttpContextAccessor accessor)
{
VisitedLessons = new List<string>();
_accessor = accessor;
var cookie = _accessor.HttpContext.Request.Cookies[_key];
if (!string.IsNullOrEmpty(cookie))
{
// The split option handles trailing semicolons and prevents empty entries.
VisitedLessons = new List<string>(cookie.Split(';', StringSplitOptions.RemoveEmptyEntries));
}
}
/// <summary>
/// Adds a lesson or course as visited.
/// </summary>
/// <param name="lessonId">The id of the course or lesson.</param>
public void AddVisitedLesson(string lessonId)
{
VisitedLessons.Add(lessonId);
var options = new CookieOptions();
options.HttpOnly = true;
options.Expires = DateTime.Now.AddDays(7);
_accessor.HttpContext.Response.Cookies.Append(_key, string.Join(';', VisitedLessons.Distinct()), options);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
UI integration
The data managed by VisitedLessonsManager is integrated into the UI through the ASP.NET Core Razor Pages model. For more information on this pattern, see the Razor Pages documentation.
Page Model Interaction
The LessonsModel, which serves the individual lesson pages, is responsible for updating the visited status and passing the data to the view.
- Dependency Injection: The
IVisitedLessonsManageris injected into theLessonsModel's constructor. - Marking as Visited: Inside the
OnGethandler, after successfully fetching the current lesson's content, the model calls_visitedLessonsManager.AddVisitedLesson(SelectedLesson.Sys.Id). This action adds the current lesson's ID to the list and updates the client's cookie for future requests. - Passing Data to View: The complete, updated list of visited lesson IDs is retrieved from
_visitedLessonsManager.VisitedLessonsand placed into theViewDatadictionary. This makes the data accessible from any corresponding Razor view or partial view.
csharp
// File: TheExampleApp/Pages/Courses/Lessons.cshtml.cs
public class LessonsModel : BasePageModel
{
private readonly IVisitedLessonsManager _visitedLessonsManager;
// ...
public LessonsModel(IContentfulClient client, IVisitedLessonsManager visitedLessonsManager, /*...*/) : base(client)
{
_visitedLessonsManager = visitedLessonsManager;
// ...
}
public async Task<IActionResult> OnGet(string slug, string lessonSlug)
{
// ... logic to fetch course and lesson from Contentful ...
// Add the current lesson as visited.
_visitedLessonsManager.AddVisitedLesson(SelectedLesson.Sys.Id);
// Add the visited lessons and courses to viewdata for the UI to consume.
ViewData["VisitedLessons"] = _visitedLessonsManager.VisitedLessons;
// ...
return Page();
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
View Rendering
The TableOfContents.cshtml partial view is responsible for rendering the list of lessons for a course. It uses the data from ViewData to conditionally apply a CSS class.
- Data Retrieval: The view casts
ViewData["VisitedLessons"]to aList<string>. - Conditional Styling: A local Razor function,
isVisited, is defined to check if a given lesson ID exists in the list. - CSS Class Application: This function is called within the
classattribute of each lesson's anchor tag (<a>). If the lesson has been visited, thevisitedclass is added, allowing CSS to apply a specific style (e.g., changing the color or adding an icon).
csharp
// File: TheExampleApp/Views/Shared/TableOfContents.cshtml
@model Course
@{
// Helper function to check if a lesson ID is in the visited list.
Func<string, string> isVisited = (s) => { return (ViewData["VisitedLessons"] as List<string>).Contains(s) ? "visited" : ""; };
}
// ...
<ul class="table-of-contents__list">
<li class="table-of-contents__item">
<a class="table-of-contents__link @(ViewData["slug"] == null ? "active" : "") @isVisited(Model.Sys.Id)" href="/courses/@Model.Slug">@Localizer["courseOverviewLabel"]</a>
</li>
@foreach (var lesson in Model.Lessons)
{
<li class="table-of-contents__item">
<a class="table-of-contents__link @(ViewData["slug"]?.ToString() == lesson.Slug ? "active" : "") @isVisited(lesson.Sys.Id)" href="/courses/@Model.Slug/lessons/@lesson.Slug">@lesson.Title</a>
</li>
}
</ul>
// ...1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Implementation details
Cookie-based Persistence
The choice of a cookie for persistence is intentional for this feature.
- Rationale: It provides persistence across browser sessions without consuming server-side resources. This is ideal for non-critical user preference data. The state is entirely managed by the client, aligning with a stateless server architecture.
- Configuration: The cookie is configured with specific security and lifetime properties:
HttpOnly = true: This is a critical security measure that prevents the cookie from being accessed by client-side scripts, mitigating the risk of cross-site scripting (XSS) attacks.Expires = DateTime.Now.AddDays(7): User progress is remembered for one week. After this period, the cookie expires and the tracking is reset.
Data Serialization
The serialization mechanism is a simple string concatenation.
- Format:
string.Join(';', VisitedLessons.Distinct()) - Deserialization:
cookie.Split(';', StringSplitOptions.RemoveEmptyEntries) - Robustness: The use of
StringSplitOptions.RemoveEmptyEntriesmakes the parsing resilient to malformed cookie values, such as those with trailing semicolons. This is confirmed by unit tests inVisitedLessonsManagerTests.cs. - Scalability Consideration: This approach is highly efficient for a moderate number of lessons. However, developers should be aware of the standard 4KB size limit for cookies. If a course were to contain thousands of lessons, this limit could theoretically be reached. For the scope of this application, this is not a concern.
Session vs. Cookie Clarification
It is important to note that while the application configures and uses ASP.NET Core session state (see Startup.cs and our Session Management documentation), the VisitedLessonsManager bypasses it and interacts directly with the Request.Cookies and Response.Cookies collections. This was a deliberate design choice to keep this feature's state independent of the server-side session lifetime and storage mechanism.