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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ A free, open-source, accessible, dark-mode-first kanban board — a calm alterna

**Slice 1 — local single-user board (in progress).**

Boards, lists, and cards work end to end — create, rename, delete, and reorder lists inside a board, add cards to a list, move a card within its list or to another list, mark a card done, label them, delete a card with a one-click undo, and open a card into a focused task view to edit its title, notes, due date, and labels — saved to SQLite, accessible and dark-mode-first.
Boards, lists, and cards work end to end — create, rename, delete, and reorder lists inside a board, add cards to a list, move a card within its list or to another list, label them, delete a card with a one-click undo, open a card into a focused task view with an Edit mode, keep a per-card checklist (add, rename, reorder, check off into a collapsible Done strip, delete with undo) with progress shown on the board's card chips, and tune it all in a small settings screen — saved to SQLite, accessible and dark-mode-first.

- **Done:** the board, list, card, and label backend (JSON APIs behind `IBoardRepository`, `IListRepository`, `ICardRepository`, and `ILabelRepository` seams, EF Core + SQLite, 124 NUnit tests, localhost-only) and the vanilla-JS MVC frontend (board-view navigation, accessible list reordering, card chips with a focused task view, accessible card moving with up/down buttons and a move-to-list dropdown, an inline label picker with soft-tint chips on cards and the board, a done checkbox that tucks completed cards into a collapsible per-board Done area, an undo-first card delete with a transient "Deleted · Undo" toast, screen-reader announcements, keyboard focus management).
- **Next:** a per-card checklist, then mobile + accessibility polish.
- **Done:** the board, list, card, label, and checklist backend (JSON APIs behind `IBoardRepository`, `IListRepository`, `ICardRepository`, `ILabelRepository`, and `IChecklistItemRepository` seams, EF Core + SQLite, 147 NUnit tests, localhost-only) and the vanilla-JS MVC frontend (board-view navigation, accessible list reordering, card chips with a focused task view, accessible card moving with up/down buttons and a move-to-list dropdown, an inline label picker with soft-tint chips, a per-card checklist with a Done strip and chip progress bars, an undo-first delete for cards and checklist items with a transient "Deleted · Undo" toast, a task-view Edit mode, a localStorage settings screen gating the card Done checkboxes and the Delete card button, screen-reader announcements, keyboard focus management).
- **Next:** mobile + accessibility polish (per-list Done strips and a single-list phone view).

Design specs: [`docs/2026-06-15-wend-slice1-design.md`](docs/2026-06-15-wend-slice1-design.md), [`docs/2026-06-19-wend-lists-design.md`](docs/2026-06-19-wend-lists-design.md), [`docs/2026-06-22-wend-cards-design.md`](docs/2026-06-22-wend-cards-design.md), [`docs/2026-06-23-wend-labels-design.md`](docs/2026-06-23-wend-labels-design.md), [`docs/2026-06-24-wend-card-moving-design.md`](docs/2026-06-24-wend-card-moving-design.md), [`docs/2026-06-25-wend-done-design.md`](docs/2026-06-25-wend-done-design.md), [`docs/2026-07-07-wend-delete-undo-design.md`](docs/2026-07-07-wend-delete-undo-design.md) · Build plans: [`docs/plans/2026-06-16-slice1-foundation-boards.md`](docs/plans/2026-06-16-slice1-foundation-boards.md), [`docs/plans/2026-06-19-slice1-lists.md`](docs/plans/2026-06-19-slice1-lists.md), [`docs/plans/2026-06-22-slice1-cards.md`](docs/plans/2026-06-22-slice1-cards.md), [`docs/plans/2026-06-23-slice1-labels.md`](docs/plans/2026-06-23-slice1-labels.md), [`docs/plans/2026-06-24-slice1-card-moving.md`](docs/plans/2026-06-24-slice1-card-moving.md), [`docs/plans/2026-06-25-slice1-done.md`](docs/plans/2026-06-25-slice1-done.md), [`docs/plans/2026-07-07-slice1-delete-undo.md`](docs/plans/2026-07-07-slice1-delete-undo.md)
Design specs: [`docs/2026-06-15-wend-slice1-design.md`](docs/2026-06-15-wend-slice1-design.md), [`docs/2026-06-19-wend-lists-design.md`](docs/2026-06-19-wend-lists-design.md), [`docs/2026-06-22-wend-cards-design.md`](docs/2026-06-22-wend-cards-design.md), [`docs/2026-06-23-wend-labels-design.md`](docs/2026-06-23-wend-labels-design.md), [`docs/2026-06-24-wend-card-moving-design.md`](docs/2026-06-24-wend-card-moving-design.md), [`docs/2026-06-25-wend-done-design.md`](docs/2026-06-25-wend-done-design.md), [`docs/2026-07-07-wend-delete-undo-design.md`](docs/2026-07-07-wend-delete-undo-design.md), [`docs/2026-07-07-wend-checklist-design.md`](docs/2026-07-07-wend-checklist-design.md) · Build plans: [`docs/plans/2026-06-16-slice1-foundation-boards.md`](docs/plans/2026-06-16-slice1-foundation-boards.md), [`docs/plans/2026-06-19-slice1-lists.md`](docs/plans/2026-06-19-slice1-lists.md), [`docs/plans/2026-06-22-slice1-cards.md`](docs/plans/2026-06-22-slice1-cards.md), [`docs/plans/2026-06-23-slice1-labels.md`](docs/plans/2026-06-23-slice1-labels.md), [`docs/plans/2026-06-24-slice1-card-moving.md`](docs/plans/2026-06-24-slice1-card-moving.md), [`docs/plans/2026-06-25-slice1-done.md`](docs/plans/2026-06-25-slice1-done.md), [`docs/plans/2026-07-07-slice1-delete-undo.md`](docs/plans/2026-07-07-slice1-delete-undo.md), [`docs/plans/2026-07-07-slice1-checklist.md`](docs/plans/2026-07-07-slice1-checklist.md)

## Stack

Expand All @@ -40,7 +40,7 @@ Design specs: [`docs/2026-06-15-wend-slice1-design.md`](docs/2026-06-15-wend-sli
dotnet run --project Wend.Api
```

Then open http://127.0.0.1:5174 to create boards, open one to manage its lists (create, rename, delete, reorder), add cards and move them within or between lists — open a card for its task view to edit the title, notes, due date, and labels. The API lives under `/api/boards`, `/api/lists`, `/api/cards`, and `/api/labels`. On first run the SQLite database is created at `%LOCALAPPDATA%\Wend\data.db`.
Then open http://127.0.0.1:5174 to create boards, open one to manage its lists (create, rename, delete, reorder), add cards and move them within or between lists — open a card for its task view to edit the title, notes, due date, labels, and a per-card checklist. The API lives under `/api/boards`, `/api/lists`, `/api/cards`, `/api/labels`, and `/api/checklist-items`. On first run the SQLite database is created at `%LOCALAPPDATA%\Wend\data.db`.

## Tests

Expand Down
10 changes: 7 additions & 3 deletions Wend.Api/BoardEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,24 @@ public static RouteGroupBuilder MapBoardEndpoints(this RouteGroupBuilder group)
var board = await repo.CreateBoardAsync(title);
return Results.Created($"/api/boards/{board.Id}", board);
});
group.MapGet("/{id:int}", async (int id, IBoardRepository boards, IListRepository lists, ICardRepository cards, ILabelRepository labels) =>
group.MapGet("/{id:int}", async (int id, IBoardRepository boards, IListRepository lists,
ICardRepository cards, ILabelRepository labels, IChecklistItemRepository checklist) =>
{
if (await boards.GetBoardAsync(id) is not { } board) return Results.NotFound();

var palette = (await labels.GetBoardLabelsAsync(id))
.Select(l => new LabelDto(l.Id, l.Name, l.Colour)).ToList();
var labelIdsByCard = await labels.GetLabelIdsByCardAsync(id);
var counts = await checklist.GetCountsByCardAsync(id);

var summaries = new List<ListSummary>();
foreach (var l in await lists.GetListsForBoardAsync(id))
{
var cardSummaries = (await cards.GetCardsForListAsync(l.Id))
.Select(c => new CardSummary(c.Id, c.Title, c.DueDate, c.Position, c.CompletedAt,
labelIdsByCard.TryGetValue(c.Id, out var ids) ? ids : new List<int>()))
labelIdsByCard.TryGetValue(c.Id, out var ids) ? ids : new List<int>(),
counts.TryGetValue(c.Id, out var k) ? k.Done : 0,
counts.TryGetValue(c.Id, out var t) ? t.Total : 0))
.ToList();
summaries.Add(new ListSummary(l.Id, l.Title, l.Position, cardSummaries));
}
Expand All @@ -57,4 +61,4 @@ public record CreateBoardRequest(string Title);
public record RenameBoardRequest(string Title);
public record BoardDetail(int Id, string Title, IReadOnlyList<LabelDto> Labels, IReadOnlyList<ListSummary> Lists);
public record ListSummary(int Id, string Title, int Position, IReadOnlyList<CardSummary> Cards);
public record CardSummary(int Id, string Title, DateOnly? DueDate, int Position, DateTime? CompletedAt, IReadOnlyList<int> LabelIds);
public record CardSummary(int Id, string Title, DateOnly? DueDate, int Position, DateTime? CompletedAt, IReadOnlyList<int> LabelIds, int ChecklistDone, int ChecklistTotal);
9 changes: 6 additions & 3 deletions Wend.Api/CardEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@ public static IEndpointRouteBuilder MapCardEndpoints(this IEndpointRouteBuilder
return Results.Created($"/api/cards/{card.Id}", card);
});

app.MapGet("/api/cards/{id:int}", async (int id, ICardRepository cards, IListRepository lists, ILabelRepository labels) =>
app.MapGet("/api/cards/{id:int}", async (int id, ICardRepository cards, IListRepository lists,
ILabelRepository labels, IChecklistItemRepository checklist) =>
{
if (await cards.GetCardAsync(id) is not { } c) return Results.NotFound();
var list = await lists.GetListAsync(c.ListId);
var attached = (await labels.GetCardLabelsAsync(c.Id))
.Select(l => new LabelDto(l.Id, l.Name, l.Colour)).ToList();
var items = (await checklist.GetItemsForCardAsync(c.Id))
.Select(i => new ChecklistItemDto(i.Id, i.Text, i.CheckedAt, i.Position)).ToList();
return Results.Ok(new CardDetail(c.Id, c.ListId, list?.Title ?? "", list?.BoardId ?? 0,
c.Title, c.Description, c.DueDate, c.Position, c.CompletedAt, attached));
c.Title, c.Description, c.DueDate, c.Position, c.CompletedAt, attached, items));
});

app.MapPut("/api/cards/{id:int}", async (int id, EditCardRequest req, ICardRepository cards) =>
Expand Down Expand Up @@ -62,7 +65,7 @@ await cards.SetCardCompletedAsync(id, req.Completed)
}

public record CreateCardRequest(string Title);
public record CardDetail(int Id, int ListId, string ListTitle, int BoardId, string Title, string? Description, DateOnly? DueDate, int Position, DateTime? CompletedAt, IReadOnlyList<LabelDto> Labels);
public record CardDetail(int Id, int ListId, string ListTitle, int BoardId, string Title, string? Description, DateOnly? DueDate, int Position, DateTime? CompletedAt, IReadOnlyList<LabelDto> Labels, IReadOnlyList<ChecklistItemDto> Items);
public record EditCardRequest(string Title, string? Description, DateOnly? DueDate);
public record MoveCardRequest(int ListId, int Position);
public record CompleteCardRequest(bool Completed);
51 changes: 51 additions & 0 deletions Wend.Api/ChecklistItemEndpoints.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using Wend.Core;

namespace Wend.Api;

public static class ChecklistItemEndpoints
{
private const int MaxTextLength = 200;

public static IEndpointRouteBuilder MapChecklistItemEndpoints(this IEndpointRouteBuilder app)
{
app.MapPost("/api/cards/{cardId:int}/checklist-items",
async (int cardId, CreateChecklistItemRequest req, ICardRepository cards, IChecklistItemRepository items) =>
{
var text = req.Text?.Trim() ?? "";
if (text.Length is 0 or > MaxTextLength) return Results.BadRequest();
if (await cards.GetCardAsync(cardId) is null) return Results.NotFound();
var item = await items.AddItemAsync(cardId, text);
return Results.Created($"/api/checklist-items/{item.Id}", item);
});

app.MapPut("/api/checklist-items/{id:int}",
async (int id, RenameChecklistItemRequest req, IChecklistItemRepository items) =>
{
var text = req.Text?.Trim() ?? "";
if (text.Length is 0 or > MaxTextLength) return Results.BadRequest();
return await items.RenameItemAsync(id, text) ? Results.NoContent() : Results.NotFound();
});

app.MapPut("/api/checklist-items/{id:int}/check",
async (int id, CheckChecklistItemRequest req, IChecklistItemRepository items) =>
await items.SetCheckedAsync(id, req.Checked) ? Results.NoContent() : Results.NotFound());

app.MapPut("/api/checklist-items/{id:int}/move",
async (int id, MoveChecklistItemRequest req, IChecklistItemRepository items) =>
await items.MoveItemAsync(id, req.Position) ? Results.NoContent() : Results.NotFound());

app.MapDelete("/api/checklist-items/{id:int}", async (int id, IChecklistItemRepository items) =>
await items.DeleteItemAsync(id) ? Results.NoContent() : Results.NotFound());

app.MapPost("/api/checklist-items/{id:int}/restore", async (int id, IChecklistItemRepository items) =>
await items.RestoreItemAsync(id) ? Results.NoContent() : Results.NotFound());

return app;
}
}

public record CreateChecklistItemRequest(string Text);
public record RenameChecklistItemRequest(string Text);
public record CheckChecklistItemRequest(bool Checked);
public record MoveChecklistItemRequest(int Position);
public record ChecklistItemDto(int Id, string Text, DateTime? CheckedAt, int Position);
2 changes: 2 additions & 0 deletions Wend.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
builder.Services.AddScoped<IBoardRepository, EfBoardRepository>();builder.Services.AddScoped<IListRepository, EfListRepository>();
builder.Services.AddScoped<ICardRepository, EfCardRepository>();
builder.Services.AddScoped<ILabelRepository, EfLabelRepository>();
builder.Services.AddScoped<IChecklistItemRepository, EfChecklistItemRepository>();


// Keep request paths and bodies out of the framework logs; quiet the startup banner.
Expand Down Expand Up @@ -41,6 +42,7 @@
app.MapGroup("/api/boards").MapBoardEndpoints();app.MapListEndpoints();
app.MapCardEndpoints();
app.MapLabelEndpoints();
app.MapChecklistItemEndpoints();

// Any non-API path renders the SPA shell; the client handles routing from there.
app.MapFallbackToFile("index.html");
Expand Down
115 changes: 115 additions & 0 deletions Wend.Api/wwwroot/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@
padding: 1rem;
border-bottom: 1px solid;
border-color: color-mix(in srgb, currentColor 15%, transparent);
display: flex;
justify-content: space-between;
align-items: center;
}

/* Settings screen. */
.settings-view { display: flex; flex-direction: column; }
.settings-heading { margin: 0 0 1rem; font-size: 1.25rem; }
.setting-row {
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 44px; /* touch target */
}
.setting-hint {
opacity: 0.7;
font-size: 0.9rem;
margin: 0.25rem 0 1rem;
}

.app-header h1 {
Expand Down Expand Up @@ -310,3 +328,100 @@
.toast-action,
.toast-dismiss { min-height: 44px; }
.toast-dismiss { min-width: 44px; }

/* Checklist progress on a card chip: a quiet count pill + a thin decorative bar. */
.card-checklist {
font-size: 0.8rem;
opacity: 0.85;
}
.card-progress {
display: block;
height: 4px;
border-radius: 999px;
background: color-mix(in srgb, currentColor 12%, transparent);
overflow: hidden;
}
.card-progress-fill {
display: block;
height: 100%;
border-radius: 999px;
background: var(--wend-mint, #52e0b6);
}
/* Forced colors flatten backgrounds — keep the bar visible with real borders/colors. */
@media (forced-colors: active) {
.card-progress { border: 1px solid CanvasText; background: Canvas; }
.card-progress-fill { background: CanvasText; }
}

/* Checklist section in the task view. */
.checklist-section {
border: 1px solid;
border-color: color-mix(in srgb, currentColor 15%, transparent);
border-radius: 0.5rem;
padding: 0.75rem;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.checklist-header { display: flex; gap: 0.5rem; align-items: baseline; }
.checklist-title { font-weight: 600; }
.checklist-items, .done-items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.checklist-row, .done-item-row {
display: flex;
gap: 0.5rem;
align-items: center;
min-height: 44px; /* touch target */
}
.checklist-row .rename-trigger {
flex: 1;
text-align: left;
background: none;
border: none;
padding: 0.25rem 0;
font: inherit;
color: inherit;
}
.rename-form { flex: 1; display: flex; gap: 0.5rem; }
.rename-form input { flex: 1; }
.item-actions { display: flex; gap: 0.25rem; }
.item-form { display: flex; gap: 0.5rem; }
.item-form input { flex: 1; }
/* Done strip: dimmed, struck-through rows behind a disclosure. */
.done-strip {
border-top: 1px solid;
border-color: color-mix(in srgb, currentColor 15%, transparent);
padding-top: 0.5rem;
}
.done-item-row { opacity: 0.6; }
.done-item-text { text-decoration: line-through; }
.done-row-label { display: flex; gap: 0.5rem; align-items: center; flex: 1; }

/* Task-view header row + edit toggle. */
.card-view-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.card-view-top .back-link { margin-bottom: 0; }
.card-heading .rename-trigger {
background: none;
border: none;
padding: 0;
font: inherit;
color: inherit;
text-align: left;
}
.card-meta { margin: 0 0 0.5rem; }
.card-notes {
margin: 0 0 1rem;
white-space: pre-wrap; /* keep the user's line breaks in read-only notes */
}
5 changes: 4 additions & 1 deletion Wend.Api/wwwroot/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@
straight to the boards. Hidden until focused; the design-system styles .skip-link. -->
<a class="skip-link" href="#app">Skip to content</a>

<header class="app-header"><h1>Wend</h1></header>
<header class="app-header">
<h1>Wend</h1>
<button type="button" id="settings-link">Settings</button>
</header>

<!-- Transient "Deleted · Undo" toast. Fixed at the bottom via the design-system .toast-region,
but placed BEFORE #app in the DOM so its Undo button sits near the front of the tab order —
Expand Down
Loading