Skip to content

Commit b0cd81e

Browse files
Merge feature/labels-#30: Labels and Filtering (v2.4)
2 parents 3a06b16 + 1472b52 commit b0cd81e

34 files changed

Lines changed: 2445 additions & 66 deletions

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"Bash(gh repo view:*)",
2727
"Bash(gh release list:*)",
2828
"Bash(gh issue list:*)",
29-
"Bash(npx jest:*)"
29+
"Bash(npx jest:*)",
30+
"Bash(cd:*)"
3031
],
3132
"additionalDirectories": [
3233
"/tmp"

CHANGELOG.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,34 @@
22

33
---
44

5+
## v2.4 - Labels and Filtering
6+
*March 2026*
7+
8+
### What changed
9+
10+
**Backend**
11+
- New `Labels` table with many-to-many relationship to Tasks (implicit join table via EF Core)
12+
- New endpoints: `GET /api/labels`, `POST /api/labels`, `PATCH /api/labels/{id}`, `DELETE /api/labels/{id}`
13+
- New endpoint: `PATCH /api/tasks/{id}/labels` - replaces the full label set on a task
14+
- Label name uniqueness enforced (case-insensitive, returns 409 Conflict)
15+
- Label ID validation on assignment (returns 400 with invalid IDs)
16+
- Task queries now eager-load labels via `.Include(t => t.Labels)`
17+
18+
**Frontend**
19+
- Labels dashboard page (`/labels`) accessible from the sidebar - full CRUD with inline rename, color picker, and delete with confirmation
20+
- 10-color VIBGYOR palette for label colors, auto-cycling on creation
21+
- Labels field in AddTaskForm with autocomplete, multi-select, and auto-create on Enter
22+
- Labels row on task detail page with add/remove via AssignLabelsButton (optimistic updates)
23+
- Label chips (`#labelname`) shown on mobile task cards
24+
- Filter panel popover (three-dot menu in task list header) with label, project, and date filters
25+
- Filter state persists across navigation via sessionStorage
26+
- New components: `LabelsClient`, `FilterPanel`, `ColorPicker`, `LabelChip`, `AssignLabelsButton`
27+
28+
**Tests**
29+
- 15 new tests for `LabelsController` (CRUD, validation, uniqueness, color range)
30+
31+
---
32+
533
## v2.3 - Mobile Task Cards
634
*March 2026*
735

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
using Microsoft.AspNetCore.Mvc;
2+
using Microsoft.EntityFrameworkCore;
3+
using Tasklog.Api.Data;
4+
using Tasklog.Api.Models;
5+
6+
namespace Tasklog.Api.Controllers
7+
{
8+
[ApiController]
9+
[Route("api/labels")]
10+
public class LabelsController : ControllerBase
11+
{
12+
private readonly TasklogDbContext _context;
13+
14+
public LabelsController(TasklogDbContext context)
15+
{
16+
_context = context;
17+
}
18+
19+
// GET /api/labels
20+
// Returns all labels ordered alphabetically by name.
21+
[HttpGet]
22+
public async Task<IActionResult> GetAll()
23+
{
24+
var labels = await _context.Labels
25+
.OrderBy(l => l.Name)
26+
.ToListAsync();
27+
28+
return Ok(labels);
29+
}
30+
31+
// POST /api/labels
32+
// Creates a new label. Expects { name: string, colorIndex: int }.
33+
[HttpPost]
34+
public async Task<IActionResult> Create([FromBody] CreateLabelRequest request)
35+
{
36+
if (string.IsNullOrWhiteSpace(request.Name))
37+
return BadRequest(new { message = "Label name is required." });
38+
39+
if (request.ColorIndex < 0 || request.ColorIndex > 9)
40+
return BadRequest(new { message = "ColorIndex must be between 0 and 9." });
41+
42+
// Reject the request if a label with the same name already exists (case-insensitive).
43+
var trimmedName = request.Name.Trim();
44+
var nameExists = await _context.Labels
45+
.AnyAsync(l => l.Name.ToLower() == trimmedName.ToLower());
46+
47+
if (nameExists)
48+
return Conflict(new { message = $"A label named \"{trimmedName}\" already exists." });
49+
50+
var label = new Label
51+
{
52+
Name = trimmedName,
53+
ColorIndex = request.ColorIndex,
54+
CreatedAt = DateTime.UtcNow
55+
};
56+
57+
_context.Labels.Add(label);
58+
await _context.SaveChangesAsync();
59+
60+
return CreatedAtAction(nameof(GetAll), new { id = label.Id }, label);
61+
}
62+
63+
// PATCH /api/labels/{id}
64+
// Updates a label's name and/or color. Returns the updated label.
65+
[HttpPatch("{id:int}")]
66+
public async Task<IActionResult> Update(int id, [FromBody] UpdateLabelRequest request)
67+
{
68+
if (string.IsNullOrWhiteSpace(request.Name))
69+
return BadRequest(new { message = "Label name is required." });
70+
71+
if (request.ColorIndex < 0 || request.ColorIndex > 9)
72+
return BadRequest(new { message = "ColorIndex must be between 0 and 9." });
73+
74+
var label = await _context.Labels.FindAsync(id);
75+
76+
if (label is null)
77+
return NotFound(new { message = $"Label {id} not found." });
78+
79+
// Reject the rename if another label already has the same name (case-insensitive).
80+
// The current label is excluded from the check so saving with no name change still works.
81+
var trimmedName = request.Name.Trim();
82+
var nameConflict = await _context.Labels
83+
.AnyAsync(l => l.Id != id && l.Name.ToLower() == trimmedName.ToLower());
84+
85+
if (nameConflict)
86+
return Conflict(new { message = $"A label named \"{trimmedName}\" already exists." });
87+
88+
label.Name = trimmedName;
89+
label.ColorIndex = request.ColorIndex;
90+
await _context.SaveChangesAsync();
91+
92+
return Ok(label);
93+
}
94+
95+
// DELETE /api/labels/{id}
96+
// Deletes a label. The join table entries are cascade-deleted by the database,
97+
// so this safely unlinks the label from all tasks without deleting those tasks.
98+
// Returns 204 No Content on success, 404 if not found.
99+
[HttpDelete("{id:int}")]
100+
public async Task<IActionResult> Delete(int id)
101+
{
102+
var label = await _context.Labels.FindAsync(id);
103+
104+
if (label is null)
105+
return NotFound(new { message = $"Label {id} not found." });
106+
107+
_context.Labels.Remove(label);
108+
await _context.SaveChangesAsync();
109+
110+
return NoContent();
111+
}
112+
}
113+
114+
// Request body for label creation.
115+
public record CreateLabelRequest(string Name, int ColorIndex);
116+
117+
// Request body for label update (name and/or color).
118+
public record UpdateLabelRequest(string Name, int ColorIndex);
119+
}

backend/Tasklog.Api/Controllers/TasksController.cs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@ public TasksController(TasklogDbContext context)
1818

1919
// GET /api/tasks
2020
// Returns all tasks ordered by creation date, newest first.
21+
// Labels are eagerly loaded so callers don't need a second request.
2122
[HttpGet]
2223
public async Task<IActionResult> GetAll()
2324
{
2425
var tasks = await _context.Tasks
26+
.Include(t => t.Labels)
2527
.OrderByDescending(t => t.CreatedAt)
2628
.ToListAsync();
2729

@@ -30,10 +32,14 @@ public async Task<IActionResult> GetAll()
3032

3133
// GET /api/tasks/{id}
3234
// Returns a single task by ID, or 404 if not found.
35+
// Labels are eagerly loaded alongside the task.
3336
[HttpGet("{id:int}")]
3437
public async Task<IActionResult> GetById(int id)
3538
{
36-
var task = await _context.Tasks.FindAsync(id);
39+
// FindAsync does not support Include, so we use FirstOrDefaultAsync here.
40+
var task = await _context.Tasks
41+
.Include(t => t.Labels)
42+
.FirstOrDefaultAsync(t => t.Id == id);
3743

3844
if (task is null)
3945
return NotFound(new { message = $"Task {id} not found." });
@@ -114,6 +120,46 @@ public async Task<IActionResult> AssignProject(int id, [FromBody] AssignProjectR
114120

115121
return Ok(task);
116122
}
123+
124+
// PATCH /api/tasks/{id}/labels
125+
// Replaces the full set of labels on a task. Accepts an array of label IDs.
126+
// Sends back the updated task with labels included.
127+
// Send an empty array to remove all labels from the task.
128+
[HttpPatch("{id:int}/labels")]
129+
public async Task<IActionResult> SetLabels(int id, [FromBody] SetTaskLabelsRequest request)
130+
{
131+
// Load the task with its current labels so EF can track the relationship changes.
132+
var task = await _context.Tasks
133+
.Include(t => t.Labels)
134+
.FirstOrDefaultAsync(t => t.Id == id);
135+
136+
if (task is null)
137+
return NotFound(new { message = $"Task {id} not found." });
138+
139+
// Load the requested labels and reject the request if any IDs don't exist.
140+
// An empty array is valid - it clears all labels from the task.
141+
var newLabels = await _context.Labels
142+
.Where(l => request.LabelIds.Contains(l.Id))
143+
.ToListAsync();
144+
145+
if (request.LabelIds.Length > 0)
146+
{
147+
var foundIds = newLabels.Select(l => l.Id).ToHashSet();
148+
var invalidIds = request.LabelIds.Where(id => !foundIds.Contains(id)).ToList();
149+
150+
if (invalidIds.Any())
151+
return BadRequest(new { message = $"Label IDs not found: {string.Join(", ", invalidIds)}." });
152+
}
153+
154+
// Replace the current label collection. EF Core handles join table updates.
155+
task.Labels.Clear();
156+
foreach (var label in newLabels)
157+
task.Labels.Add(label);
158+
159+
await _context.SaveChangesAsync();
160+
161+
return Ok(task);
162+
}
117163
}
118164

119165
// Request body shape for task creation.
@@ -124,4 +170,7 @@ public record CompleteTaskRequest(bool IsCompleted);
124170

125171
// Request body shape for assigning or unassigning a project on a task.
126172
public record AssignProjectRequest(int? ProjectId);
173+
174+
// Request body shape for replacing a task's full label set.
175+
public record SetTaskLabelsRequest(int[] LabelIds);
127176
}

backend/Tasklog.Api/Data/TasklogDbContext.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,15 @@ public TasklogDbContext(DbContextOptions<TasklogDbContext> options) : base(optio
99

1010
public DbSet<TaskModel> Tasks => Set<TaskModel>();
1111
public DbSet<Project> Projects => Set<Project>();
12+
public DbSet<Label> Labels => Set<Label>();
13+
14+
protected override void OnModelCreating(ModelBuilder modelBuilder)
15+
{
16+
// Configure the implicit many-to-many join table between tasks and labels.
17+
// EF Core creates a "LabelTaskModel" join table automatically from these nav properties.
18+
modelBuilder.Entity<TaskModel>()
19+
.HasMany(t => t.Labels)
20+
.WithMany(l => l.Tasks);
21+
}
1222
}
1323
}

0 commit comments

Comments
 (0)