Skip to content
Draft
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
39 changes: 39 additions & 0 deletions .github/workflows/capacity-board.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Capacity Board checks

on:
pull_request:
paths:
- 'applications/capacity-board/**'
- '.github/workflows/capacity-board.yml'
push:
branches: [main]
paths:
- 'applications/capacity-board/**'
- '.github/workflows/capacity-board.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-24.04
defaults:
run:
working-directory: applications/capacity-board
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4
with:
dotnet-version: '10.0.401'
- name: Locked native checks
run: |
dotnet restore --locked-mode
dotnet restore Tests/Tests.csproj --locked-mode
dotnet format --verify-no-changes --no-restore
dotnet format Tests/Tests.csproj --verify-no-changes --no-restore
dotnet build -c Release --no-restore
dotnet run --project Tests/Tests.csproj -c Release --no-restore
dotnet publish -c Release --no-restore -o /tmp/capacity-published
python3 test/preflight.py /tmp/capacity-published/CapacityBoard
# Real Cloud/browser acceptance is explicit and uses no Cloud secrets in CI.
10 changes: 10 additions & 0 deletions applications/capacity-board/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
PGHOST=your-cloud-host
PGPORT=5432
PGDATABASE=postgres
PGUSER=capacity_app
PGPASSWORD=replace-with-runtime-password
PGSSLROOTCERT=/private/path/ca.pem
ADMIN_USER=cloud-created-user
ADMIN_PASSWORD=cloud-created-password
MIGRATION_PASSWORD=replace-with-separate-password
APP_ORIGIN=http://127.0.0.1:5000
6 changes: 6 additions & 0 deletions applications/capacity-board/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/bin/
**/obj/
.env
*.pem
node_modules/
__pycache__/
16 changes: 16 additions & 0 deletions applications/capacity-board/CapacityBoard.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<DefaultItemExcludes>$(DefaultItemExcludes);Tests/**</DefaultItemExcludes>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="[2.1.89]" />
<PackageReference Include="Npgsql" Version="[10.0.3]" />
<Compile Remove="Tests/**/*.cs" />
<EmbeddedResource Include="sql/migrate.sql" />
</ItemGroup>
</Project>
17 changes: 17 additions & 0 deletions applications/capacity-board/Components/App.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<base href="/" />
<link rel="stylesheet" href="app.css" />
<HeadOutlet />
</head>
<body>
<Routes @rendermode="new InteractiveServerRenderMode(prerender: false)" />
<div id="components-reconnect-modal" class="reconnect">
Connection interrupted. <a href="/">Reload committed state</a> if reconnection fails.
</div>
<script src="_framework/blazor.web.js"></script>
</body>
</html>
152 changes: 152 additions & 0 deletions applications/capacity-board/Components/Pages/Board.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
@page "/"
@inject BoardRepository Repository
@implements IDisposable
<PageTitle>Capacity Board</PageTitle>

<main>
<header>
<div class="eyebrow">SYNTHETIC WORKSPACE</div>
<h1>Capacity Board</h1>
<p>Plan one sprint within 30 points. Saves replace the complete allocation.</p>
<p class="scope">A trusted local workspace with no sign-in or tenant isolation.</p>
</header>
@if (committed is null)
{
<p role="status">@message</p>
<button @onclick="Reload" disabled="@pending">Load board</button>
}
else
{
<section class="summary" aria-label="Committed allocation">
<div><span>Committed points</span><strong id="committed-total">@committed.Total</strong></div>
<div><span>Remaining capacity</span><strong id="committed-remaining">@committed.Remaining</strong></div>
<div><span>Loaded revision</span><strong id="loaded-revision">@committed.Revision</strong></div>
</section>
<section class="editor">
<div class="editor-heading"><h2>@committed.Title</h2><span>Draft: <b id="draft-total">@DraftTotal</b> / @committed.Capacity points</span></div>
<p class="hint">Changes below are unsaved. Reload explicitly to replace them with committed state.</p>
<EditForm Model="draft" OnSubmit="Save" FormName="allocation">
<fieldset disabled="@(pending || retry is not null)">
@for (var index = 0; index < draft.Items.Count; index++)
{
var item = draft.Items[index];
var number = index + 1;
<div class="item" @key="item.Id">
<label>Item @number<input aria-label="Item @number name" maxlength="80" @bind="item.Name" @bind:event="oninput" /></label>
<label>Points<input aria-label="Item @number points" type="text" inputmode="numeric" maxlength="2" @bind="item.Points" @bind:event="oninput" /></label>
<button type="button" class="secondary" @onclick="() => Remove(item)">Remove item @number</button>
</div>
}
<button type="button" class="secondary" @onclick="Add" disabled="@(draft.Items.Count >= 20)">Add work item</button>
</fieldset>
<div class="actions">
<button type="submit" disabled="@(pending || retry is not null)">@(pending ? "Saving…" : "Save allocation")</button>
@if (retry is not null)
{
<button type="button" @onclick="Retry" disabled="@pending">Retry original save</button>
}
<button type="button" class="secondary" @onclick="Reload" disabled="@pending">Reload committed state</button>
</div>
</EditForm>
<p class="message" role="status">@message</p>
</section>
<p class="footnote">At most 20 items and 25 retained saves. Exact retries still work at the limit. Reloading discards this circuit’s unsaved edits.</p>
}
</main>

@code {
private readonly CancellationTokenSource lifetime = new();
private BoardSnapshot? committed;
private Draft draft = new();
private SaveRequest? retry;
private bool pending;
private string message = "Loading committed allocation…";
private string DraftTotal
{
get
{
try { return draft.Items.Sum(item => Allocation.Points(item.Points)).ToString(); }
catch (BoardError) { return "Check points"; }
}
}

protected override Task OnInitializedAsync() => Reload();

private async Task Reload()
{
if (pending) return;
pending = true;
try
{
var snapshot = await Repository.ReadAsync(lifetime.Token);
committed = snapshot;
draft = new Draft { Items = snapshot.Items.Select(item => new EditItem
{ Id = item.Id, Name = item.Name, Points = item.Points.ToString(System.Globalization.CultureInfo.InvariantCulture) }).ToList() };
retry = null;
message = "Committed allocation loaded. Changes stay in this editor until saved.";
}
catch (Exception) { message = "The board couldn't be loaded. Try loading again."; }
finally { pending = false; }
}

private void Add() { if (!pending && draft.Items.Count < 20) draft.Items.Add(new EditItem()); }
private void Remove(EditItem item) { if (!pending) draft.Items.Remove(item); }

private Task Save(EditContext context)
{
if (pending || retry is not null || committed is null) return Task.CompletedTask;
try
{
var request = new SaveRequest(Guid.NewGuid(), committed.Revision,
draft.Items.Select(item => new WorkItem(item.Id, item.Name, Allocation.Points(item.Points))).ToArray());
return SaveRequest(request);
}
catch (BoardError)
{
message = "Each effort must be a whole number from 0 to 30. Your draft is preserved.";
return Task.CompletedTask;
}
}
private Task Retry() => pending || retry is null ? Task.CompletedTask : SaveRequest(retry);

private async Task SaveRequest(SaveRequest request)
{
pending = true;
try
{
var result = await Repository.SaveAsync(request, lifetime.Token);
committed = result;
draft = new Draft { Items = result.Items.Select(item => new EditItem
{ Id = item.Id, Name = item.Name, Points = item.Points.ToString(System.Globalization.CultureInfo.InvariantCulture) }).ToList() };
retry = null;
message = $"Saved revision {result.Revision}. {result.Total} points committed.";
}
catch (BoardError error)
{
retry = null;
message = error.Code switch
{
"stale_revision" => "Newer committed state exists. Your draft is preserved. Reload explicitly before editing the newer revision.",
"over_capacity" => "The draft exceeds capacity. Reduce its points; your values are preserved.",
"revision_limit" => "This teaching board has reached its retained-save limit.",
"operation_conflict" => "That operation ID belongs to different content. Reload committed state.",
"busy" => "All database operations are busy. Your draft is preserved; try saving again.",
_ => "Use at most 20 items, names of 1–80 characters without controls, and 0–30 integer points."
};
}
catch (Exception)
{
retry = request;
message = "The save couldn't be confirmed. Retry the original save, or reload committed state. Editing is paused to keep that retry exact.";
}
finally { pending = false; }
}
public void Dispose() { lifetime.Cancel(); lifetime.Dispose(); }
private sealed class Draft { public List<EditItem> Items { get; set; } = []; }
private sealed class EditItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = "";
public string Points { get; set; } = "0";
}
}
7 changes: 7 additions & 0 deletions applications/capacity-board/Components/Routes.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="routeData" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
<NotFound><p>Page not found. <a href="/">Open the board</a></p></NotFound>
</Router>
8 changes: 8 additions & 0 deletions applications/capacity-board/Components/_Imports.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Routing
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using CapacityBoard.Components
@using CapacityBoard.Domain
@using CapacityBoard.Data
128 changes: 128 additions & 0 deletions applications/capacity-board/Data/BoardRepository.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
using System.Text.Json;
using CapacityBoard.Domain;
using Dapper;
using Npgsql;

namespace CapacityBoard.Data;

// This service keeps only a data source and admission gate, never an open connection.
public sealed class BoardRepository(NpgsqlDataSource source) : IDisposable
{
private readonly SemaphoreSlim admission = new(4);

public async Task<BoardSnapshot> ReadAsync(CancellationToken cancellation = default)
{
if (!await admission.WaitAsync(0, cancellation)) throw new BoardError("busy");
try
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
deadline.CancelAfter(TimeSpan.FromSeconds(10));
await using var conn = await source.OpenConnectionAsync(deadline.Token);
return await ReadOnAsync(conn, null, deadline.Token);
}
finally { admission.Release(); }
}

private static async Task<BoardSnapshot> ReadOnAsync(NpgsqlConnection conn, NpgsqlTransaction? tx,
CancellationToken cancellation)
{
// One joined statement observes parent and children from the same read snapshot.
var rows = (await conn.QueryAsync<SnapshotRow>(new CommandDefinition("""
SELECT b.id AS BoardId, b.title, b.capacity, b.revision,
i.id AS ItemId, i.name, i.points
FROM capacity_board.boards b
LEFT JOIN capacity_board.work_items i ON i.board_id = b.id
WHERE b.id = @Id ORDER BY i.position
""", new { Id = Allocation.BoardId }, tx, 4, cancellationToken: cancellation))).ToArray();
if (rows.Length == 0) throw new BoardError("missing_board");
var first = rows[0];
return new BoardSnapshot(first.BoardId, first.Title, first.Capacity, first.Revision,
rows.Where(row => row.ItemId.HasValue)
.Select(row => new WorkItem(row.ItemId!.Value, row.Name!, row.Points!.Value)).ToArray());
}

public async Task<BoardSnapshot> SaveAsync(SaveRequest request, CancellationToken cancellation = default)
{
var save = Allocation.Validate(request);
if (!await admission.WaitAsync(0, cancellation)) throw new BoardError("busy");
try
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellation);
deadline.CancelAfter(TimeSpan.FromSeconds(10));
var ct = deadline.Token;
await using var conn = await source.OpenConnectionAsync(ct);
await using var tx = await conn.BeginTransactionAsync(ct);
CommandDefinition Command(string sql, object? parameters = null) =>
new(sql, parameters, tx, 4, cancellationToken: ct);
var board = await conn.QuerySingleAsync<ParentRow>(Command("""
SELECT capacity, revision FROM capacity_board.boards WHERE id = @Id FOR UPDATE
""", new { Id = Allocation.BoardId }));
var previous = await conn.QuerySingleOrDefaultAsync<RetainedRow>(Command("""
SELECT fingerprint, response::text AS Response
FROM capacity_board.saves WHERE operation_id = @OperationId AND board_id = @BoardId
""", new { save.OperationId, BoardId = Allocation.BoardId }));
if (previous is not null)
{
if (previous.Fingerprint != save.Fingerprint) throw new BoardError("operation_conflict");
var response = JsonSerializer.Deserialize<BoardSnapshot>(previous.Response)
?? throw new BoardError("unavailable");
await tx.CommitAsync(ct);
return response;
}
if (board.Revision != save.ExpectedRevision) throw new BoardError("stale_revision");
if (board.Revision > Allocation.MaxSaves) throw new BoardError("revision_limit");
if (save.Items.Sum(item => item.Points) > board.Capacity) throw new BoardError("over_capacity");
await conn.ExecuteAsync(Command("DELETE FROM capacity_board.work_items WHERE board_id = @Id",
new { Id = Allocation.BoardId }));
for (var position = 0; position < save.Items.Length; position++)
{
var item = save.Items[position];
await conn.ExecuteAsync(Command("""
INSERT INTO capacity_board.work_items(board_id,id,position,name,points)
VALUES (@BoardId,@Id,@Position,@Name,@Points)
""", new { BoardId = Allocation.BoardId, item.Id, Position = position, item.Name, item.Points }));
}
await conn.ExecuteAsync(Command("""
UPDATE capacity_board.boards SET revision = revision + 1 WHERE id = @Id
""", new { Id = Allocation.BoardId }));
var result = await ReadOnAsync(conn, tx, ct);
await conn.ExecuteAsync(Command("""
INSERT INTO capacity_board.saves(operation_id,board_id,fingerprint,revision,response)
VALUES (@OperationId,@BoardId,@Fingerprint,@Revision,CAST(@Response AS jsonb))
""", new
{
save.OperationId,
BoardId = Allocation.BoardId,
save.Fingerprint,
result.Revision,
Response = JsonSerializer.Serialize(result)
}));
await tx.CommitAsync(ct);
return result;
}
finally { admission.Release(); }
}

public void Dispose() => admission.Dispose();

private sealed class SnapshotRow
{
public Guid BoardId { get; set; }
public string Title { get; set; } = "";
public int Capacity { get; set; }
public int Revision { get; set; }
public Guid? ItemId { get; set; }
public string? Name { get; set; }
public int? Points { get; set; }
}
private sealed class ParentRow
{
public int Capacity { get; set; }
public int Revision { get; set; }
}
private sealed class RetainedRow
{
public string Fingerprint { get; set; } = "";
public string Response { get; set; } = "";
}
}
Loading
Loading