Project: A Task Tracker
Time to put it all together. In this final lesson you'll read through a complete command-line task tracker: it adds tasks with priorities and due dates, lists and filters them, marks them done, shows statistics and saves everything to a JSON file. It's a single file you can run with dotnet run, and almost every part of the course appears somewhere in it.
What it does
The program reads commands, one per line:
| Command | Effect |
|---|---|
add Buy milk !high @2026-10-10 | Add a task. !low/!normal/!high sets the priority; @date sets a due date. Both are optional. |
list | Show open tasks, most urgent first. |
list all | Include completed tasks. |
list due | Open tasks due within the next 7 days. |
done 3 / remove 3 | Complete or delete task #3. |
stats | Counts by status and priority. |
quit | Save and exit. Tasks are also saved when input ends. |
Where each topic appears
| Feature | Used for | Lesson |
|---|---|---|
Records and with | Immutable TaskItem; completing a task makes a modified copy | 40 |
| Enums | Priority | 41 |
| Nullable types | Optional due date (DateOnly?) | 43 |
| List patterns | Parsing commands | 44 |
| Encapsulation | TaskStore owns the list and the ID counter | 35 |
| LINQ | Sorting, filtering, statistics | 49–50 |
| Exceptions with filters | Reporting bad commands without crashing | 52 |
| Files and JSON source generation | Saving and loading tasks.json | 54–55 |
| Dates | Due dates and overdue checks | 56 |
| String formatting | Aligned table output | 18 |
The complete program
Save it as tasks.cs and run dotnet run tasks.cs. To keep this page's output identical on every run, "today" is fixed at 8 October 2026; in real use you'd write DateOnly.FromDateTime(DateTime.Today).
using System.Text.Json; using System.Text.Json.Serialization; var today = new DateOnly(2026, 10, 8); // DateOnly.FromDateTime(DateTime.Today) in real use var store = TaskStore.Load("tasks.json"); Console.WriteLine($"Task tracker: {store.Count} tasks loaded. Commands: add, list, done, remove, stats, quit"); while (true) { Console.Write("> "); string? line = Console.ReadLine(); if (line is null) break; // end of input string[] words = line.Split(' ', StringSplitOptions.RemoveEmptyEntries); try { switch (words) { case []: Console.WriteLine(); break; case ["quit" or "exit"]: store.Save(); Console.WriteLine($"Saved {store.Count} tasks. Bye!"); return; case ["add", .. var rest]: TaskItem added = store.Add(ParseNewTask(rest)); Console.WriteLine($"Added #{added.Id}: {added.Title}"); break; case ["list"]: Print(store.All.Where(t => !t.Done)); break; case ["list", "all"]: Print(store.All); break; case ["list", "due"]: Print(store.All.Where(t => !t.Done && t.Due <= today.AddDays(7))); break; case ["done", var id]: Console.WriteLine($"Completed: {store.Complete(ParseId(id)).Title}"); break; case ["remove", var id]: Console.WriteLine($"Removed: {store.Remove(ParseId(id)).Title}"); break; case ["stats"]: PrintStats(); break; default: Console.WriteLine($"Unknown command '{line.Trim()}'"); break; } } catch (Exception ex) when (ex is ArgumentException or FormatException or KeyNotFoundException) { Console.WriteLine($"Error: {ex.Message}"); } } store.Save(); Console.WriteLine($"Saved {store.Count} tasks."); NewTask ParseNewTask(string[] words) { var priority = Priority.Normal; DateOnly? due = null; var title = new List<string>(); foreach (string w in words) { switch (w) { case ['!', .. var p]: priority = Enum.TryParse(p, ignoreCase: true, out Priority parsed) ? parsed : throw new FormatException($"Unknown priority '{p}'. Use low, normal or high."); break; case ['@', .. var d]: due = DateOnly.TryParseExact(d, "yyyy-MM-dd", out DateOnly date) ? date : throw new FormatException($"Bad date '{d}'. Use yyyy-MM-dd."); break; default: title.Add(w); break; } } if (title.Count == 0) throw new ArgumentException("A task needs a title."); return new NewTask(string.Join(' ', title), priority, due); } static int ParseId(string text) => int.TryParse(text.TrimStart('#'), out int id) ? id : throw new FormatException($"'{text}' is not a task number."); void Print(IEnumerable<TaskItem> tasks) { var sorted = tasks .OrderBy(t => t.Done) .ThenByDescending(t => t.Priority) .ThenBy(t => t.Due ?? DateOnly.MaxValue) .ToList(); if (sorted.Count == 0) { Console.WriteLine("(no tasks)"); return; } foreach (TaskItem t in sorted) { string box = t.Done ? "[x]" : "[ ]"; string due = t.Due switch { null => "", var d when !t.Done && d < today => $"OVERDUE {d:MMM d}", var d when d == today => "due today", var d => $"due {d:MMM d}", }; Console.WriteLine($"{box} #{t.Id,-3}{t.Title,-24}{t.Priority,-7}{due}"); } } void PrintStats() { var open = store.All.Where(t => !t.Done).ToList(); Console.WriteLine($"{store.Count} tasks: {open.Count} open, {store.Count - open.Count} done"); foreach (var (priority, count) in open.CountBy(t => t.Priority).OrderByDescending(p => p.Key)) Console.WriteLine($" {priority,-7}{new string('■', count)} {count}"); int overdue = open.Count(t => t.Due < today); if (overdue > 0) Console.WriteLine($" {overdue} overdue!"); } enum Priority { Low, Normal, High } record NewTask(string Title, Priority Priority, DateOnly? Due); record TaskItem(int Id, string Title, Priority Priority, DateOnly? Due, bool Done = false); class TaskStore { private readonly List<TaskItem> _tasks; private readonly string _path; private TaskStore(string path, List<TaskItem> tasks) { _path = path; _tasks = tasks; } public int Count => _tasks.Count; public IReadOnlyList<TaskItem> All => _tasks; public static TaskStore Load(string path) { if (!File.Exists(path)) return new TaskStore(path, []); string json = File.ReadAllText(path); return new TaskStore(path, JsonSerializer.Deserialize(json, TaskJson.Default.ListTaskItem) ?? []); } public void Save() => File.WriteAllText(_path, JsonSerializer.Serialize(_tasks, TaskJson.Default.ListTaskItem)); public TaskItem Add(NewTask t) { int id = _tasks.Count == 0 ? 1 : _tasks.Max(x => x.Id) + 1; var task = new TaskItem(id, t.Title, t.Priority, t.Due); _tasks.Add(task); return task; } public TaskItem Complete(int id) { int i = IndexOf(id); _tasks[i] = _tasks[i] with { Done = true }; return _tasks[i]; } public TaskItem Remove(int id) { int i = IndexOf(id); TaskItem removed = _tasks[i]; _tasks.RemoveAt(i); return removed; } private int IndexOf(int id) { int i = _tasks.FindIndex(t => t.Id == id); return i >= 0 ? i : throw new KeyNotFoundException($"There is no task #{id}."); } } [JsonSourceGenerationOptions(JsonSerializerDefaults.Web, WriteIndented = true, UseStringEnumConverter = true)] [JsonSerializable(typeof(List<TaskItem>))] partial class TaskJson : JsonSerializerContext;
add Renew passport !high @2026-10-06 add Buy birthday present @2026-10-12 add Read C# book !low add Fix bike !high add !urgent list done 2 remove 9 stats list all quit
Task tracker: 0 tasks loaded. Commands: add, list, done, remove, stats, quit > Added #1: Renew passport > Added #2: Buy birthday present > Added #3: Read C# book > Added #4: Fix bike > Error: Unknown priority 'urgent'. Use low, normal or high. > [ ] #1 Renew passport High OVERDUE Oct 6 [ ] #4 Fix bike High [ ] #2 Buy birthday present Normal due Oct 12 [ ] #3 Read C# book Low > Completed: Buy birthday present > Error: There is no task #9. > 4 tasks: 3 open, 1 done High ■■ 2 Low ■ 1 1 overdue! > [ ] #1 Renew passport High OVERDUE Oct 6 [ ] #4 Fix bike High [ ] #3 Read C# book Low [x] #2 Buy birthday present Normal due Oct 12 > Saved 4 tasks. Bye!
The sample session
The Input box above shows the commands typed in a sample run, and the Output box what the program printed. As in lesson 11, a real terminal would also echo what you type after each > prompt. Notice how the invalid priority and the unknown task number produce error messages instead of crashes, and how list sorts by priority and then by due date.
Run it again and the four tasks are loaded from tasks.json, which looks like this:
[
{
"id": 1,
"title": "Renew passport",
"priority": "High",
"due": "2026-10-06",
"done": false
},
...
]
How it works
Parsing commands with list patterns
Each line is split into words, and a switch on the word array picks the command. ["done", var id] matches exactly two words starting with "done"; ["add", .. var rest] matches "add" followed by anything, capturing the rest. The same technique parses options inside ParseNewTask: ['!', .. var p] matches a word starting with ! — strings support list patterns too.
Errors as messages, not crashes
Helper methods throw exceptions with clear messages when input is invalid. A single catch with a when filter turns the expected kinds of failure into an error message, while any unexpected exception (a real bug) would still stop the program loudly.
An immutable model with a mutable store
TaskItem is a record, so a task never changes after it's created. Completing one replaces it in the list with a copy made by with { Done = true }. All changes go through TaskStore, which exposes the tasks only as an IReadOnlyList — no other code can modify the list behind its back.
Saving with source-generated JSON
TaskJson is a JsonSerializerContext, so serialisation code is generated at compile time. That's why this single-file app works without the PublishAot=false directive from lesson 55. The options store enums as names ("High" rather than 2), which keeps the file readable and safe if the enum's numbers ever change.
Local functions share state
ParseNewTask, Print and PrintStats are local functions of the top-level program, so they can use today and store directly. ParseId is static because it needs neither.
Challenges
Extend the tracker to practise what you've learned. Each challenge points to the lesson that helps most.
- Add an
edit 3 New titlecommand that renames a task (lesson 40'swith). - Add tags with
#workor#home, and alist #workfilter (lessons 27 and 44). - Add a
search wordcommand that finds tasks by title, ignoring case (lesson 17). - Replace the fixed
todaywith aTimeProviderso you can test overdue logic (lesson 56). - Move
TaskStoreinto a class library and write xUnit tests forAdd,CompleteandRemove(lessons 59–60). - Make saving asynchronous with
File.WriteAllTextAsync(lesson 57). - Add a
Changedevent toTaskStoreand use it to save automatically after every change (lesson 48). - Publish it as a native executable with
dotnet publish tasks.csand run it without .NET installed (lesson 4). Native AOT compilation needs your platform's C/C++ build tools — Visual Studio's C++ workload on Windows, Xcode command-line tools on macOS, or clang on Linux.
Where to go next
You now know the C# language and the core of the .NET library. Natural next steps:
- Web development with ASP.NET Core — build web APIs and sites. Our ASP.NET course is a good start.
- Databases with Entity Framework Core, building on LINQ and our SQL course.
- Desktop and mobile apps with .NET MAUI, or games with Unity or Godot.
- Version control for your projects with Git.
Before moving on, test yourself with the quiz and exercises. Congratulations on finishing the course!