account-tracking/docs/superpowers/plans/2026-03-19-finance-tracker.md
Martin e0c451904c docs: add implementation plan for finance tracker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 23:30:58 +01:00

87 KiB

Finance Tracker — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a personal finance web app that imports ČSOB bank CSV exports and visualises spending through a two-column year/month dashboard.

Architecture: .NET 8 REST API backed by MySQL 8, consumed by a Vue 3 + Vuetify 3 SPA. NSwag generates a TypeScript API client from the .NET OpenAPI spec at build time. Three Docker containers (api, web, db) sit behind the user's existing Nginx reverse proxy.

Tech Stack: .NET 8, EF Core 8 + Pomelo MySQL provider, BCrypt.Net-Next, Polly, NSwag.MSBuild, NSwag.AspNetCore · Vue 3, Vuetify 3, Pinia, Vue Router 4, ApexCharts, vue3-apexcharts · MySQL 8 · Docker / docker-compose


File Map

account-tracking/
├── AccountTracking.sln
├── docker-compose.yml
├── .env.example
├── src/
│   ├── AccountTracking.Api/
│   │   ├── AccountTracking.Api.csproj
│   │   ├── Program.cs
│   │   ├── nswag.json                        ← generates openapi.json at dotnet build
│   │   ├── openapi.json                      ← NSwag output (committed)
│   │   ├── Controllers/
│   │   │   ├── AuthController.cs
│   │   │   ├── TransactionsController.cs
│   │   │   └── DashboardController.cs
│   │   ├── Services/
│   │   │   ├── CsvImportService.cs
│   │   │   └── DashboardService.cs
│   │   ├── Models/
│   │   │   ├── Transaction.cs
│   │   │   ├── ImportLog.cs
│   │   │   └── Dtos/
│   │   │       ├── LoginRequest.cs
│   │   │       ├── LoginResponse.cs
│   │   │       ├── ImportResult.cs
│   │   │       ├── TransactionDto.cs
│   │   │       ├── TransactionListResponse.cs
│   │   │       ├── SummaryDto.cs
│   │   │       ├── CategorySpendingDto.cs
│   │   │       ├── MonthlyBalanceDto.cs
│   │   │       └── CumulativeSpendingDto.cs
│   │   ├── Data/
│   │   │   └── AppDbContext.cs
│   │   └── Dockerfile
│   ├── AccountTracking.Api.Tests/
│   │   ├── AccountTracking.Api.Tests.csproj
│   │   ├── CsvImportServiceTests.cs
│   │   └── DashboardServiceTests.cs
│   └── AccountTracking.Web/
│       ├── package.json
│       ├── vite.config.ts
│       ├── tsconfig.json
│       ├── nswag.json                        ← reads openapi.json, generates src/api/
│       ├── index.html
│       ├── Dockerfile
│       └── src/
│           ├── main.ts
│           ├── App.vue
│           ├── api/
│           │   ├── apiClient.ts              ← NSwag-generated (do not edit)
│           │   └── index.ts                  ← manually written: authenticated clients
│           ├── router/
│           │   └── index.ts
│           ├── stores/
│           │   ├── auth.ts
│           │   ├── dashPeriod.ts
│           │   └── txPeriod.ts
│           ├── views/
│           │   ├── LoginView.vue
│           │   ├── DashboardView.vue
│           │   └── TransactionsView.vue
│           └── components/
│               ├── YearSummary.vue
│               ├── MonthSummary.vue
│               ├── DonutChart.vue
│               ├── MonthlyBalancesChart.vue
│               ├── CumulativeSpendingChart.vue
│               └── UploadCsvButton.vue

Task 1: Solution & Project Scaffold

Files:

  • Create: AccountTracking.sln

  • Create: src/AccountTracking.Api/AccountTracking.Api.csproj

  • Create: src/AccountTracking.Api.Tests/AccountTracking.Api.Tests.csproj

  • Step 1: Create the .NET solution

cd /mnt/c/Repos/Personal/account-tracking
dotnet new sln -n AccountTracking
dotnet new webapi -n AccountTracking.Api -o src/AccountTracking.Api --no-openapi
dotnet new xunit -n AccountTracking.Api.Tests -o src/AccountTracking.Api.Tests
dotnet sln add src/AccountTracking.Api/AccountTracking.Api.csproj
dotnet sln add src/AccountTracking.Api.Tests/AccountTracking.Api.Tests.csproj
  • Step 2: Add NuGet packages to API project
cd src/AccountTracking.Api
dotnet add package Pomelo.EntityFrameworkCore.MySql --version 8.0.2
dotnet add package Microsoft.EntityFrameworkCore.Design --version 8.0.10
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 8.0.10
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer --version 8.0.10
dotnet add package NSwag.AspNetCore --version 14.1.0
dotnet add package NSwag.MSBuild --version 14.1.0
dotnet add package BCrypt.Net-Next --version 4.0.3
dotnet add package Polly --version 8.4.1
  • Step 3: Add NuGet packages to test project
cd ../AccountTracking.Api.Tests
dotnet add reference ../AccountTracking.Api/AccountTracking.Api.csproj
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 8.0.10
  • Step 4: Configure the API .csproj for NSwag MSBuild generation

Replace the contents of src/AccountTracking.Api/AccountTracking.Api.csproj:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <GenerateDocumentationFile>true</GenerateDocumentationFile>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.10">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.10" />
    <PackageReference Include="NSwag.AspNetCore" Version="14.1.0" />
    <PackageReference Include="NSwag.MSBuild" Version="14.1.0">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Polly" Version="8.4.1" />
    <PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="8.0.2" />
  </ItemGroup>

  <Target Name="NSwag" AfterTargets="Build" Condition="'$(GenerateOpenApiSpec)' == 'true'">
    <Exec Command="$(NSwagExe_Net80) run nswag.json /variables:Configuration=$(Configuration)"
          WorkingDirectory="$(ProjectDir)" />
  </Target>

</Project>

Note: The NSwag target is gated by GenerateOpenApiSpec=true so it only runs when explicitly requested (not on every build, which would slow the inner loop).

  • Step 5: Delete the template boilerplate files
cd /mnt/c/Repos/Personal/account-tracking/src/AccountTracking.Api
rm -f WeatherForecast.cs Controllers/WeatherForecastController.cs
  • Step 6: Verify solution builds
cd /mnt/c/Repos/Personal/account-tracking
dotnet build

Expected: Build succeeded.

  • Step 7: Commit
git add AccountTracking.sln src/AccountTracking.Api/ src/AccountTracking.Api.Tests/
git commit -m "feat: scaffold .NET solution with API and test projects"

Task 2: Data Models, EF Core Context & Migrations

Files:

  • Create: src/AccountTracking.Api/Models/Transaction.cs

  • Create: src/AccountTracking.Api/Models/ImportLog.cs

  • Create: src/AccountTracking.Api/Models/Dtos/ (all DTO files)

  • Create: src/AccountTracking.Api/Data/AppDbContext.cs

  • Step 1: Create the Transaction entity

src/AccountTracking.Api/Models/Transaction.cs:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace AccountTracking.Api.Models;

[Table("transactions")]
public class Transaction
{
    [Key]
    [Column("id")]
    public int Id { get; set; }

    [Column("account_number")]
    [MaxLength(30)]
    public string AccountNumber { get; set; } = "";

    [Column("booking_date")]
    public DateOnly BookingDate { get; set; }

    [Column("amount")]
    [Precision(15, 2)]
    public decimal Amount { get; set; }

    [Column("currency")]
    [MaxLength(3)]
    public string Currency { get; set; } = "CZK";

    [Column("balance")]
    [Precision(15, 2)]
    public decimal Balance { get; set; }

    [Column("counter_party_name")]
    [MaxLength(255)]
    public string? CounterPartyName { get; set; }

    [Column("operation_description")]
    [MaxLength(255)]
    public string? OperationDescription { get; set; }

    [Column("message")]
    public string? Message { get; set; }

    [Column("category")]
    [MaxLength(100)]
    public string? Category { get; set; }

    [Column("variable_symbol")]
    [MaxLength(30)]
    public string? VariableSymbol { get; set; }

    [Column("bank_note")]
    [MaxLength(255)]
    public string? BankNote { get; set; }

    [Column("transaction_id")]
    [MaxLength(512)]
    public string TransactionId { get; set; } = "";

    // Extra CSV columns stored but not used in UI
    [Column("counter_bank_code")] [MaxLength(255)] public string? CounterBankCode { get; set; }
    [Column("constant_symbol")] [MaxLength(255)] public string? ConstantSymbol { get; set; }
    [Column("specific_symbol")] [MaxLength(255)] public string? SpecificSymbol { get; set; }
    [Column("order_name")] [MaxLength(255)] public string? OrderName { get; set; }
    [Column("exchange_rate")] [MaxLength(255)] public string? ExchangeRate { get; set; }
    [Column("e2e_id")] [MaxLength(255)] public string? E2EId { get; set; }
    [Column("payer_reference")] [MaxLength(255)] public string? PayerReference { get; set; }
    [Column("original_payer")] [MaxLength(255)] public string? OriginalPayer { get; set; }
    [Column("final_recipient")] [MaxLength(255)] public string? FinalRecipient { get; set; }
    [Column("original_transaction")] [MaxLength(255)] public string? OriginalTransaction { get; set; }
}
  • Step 2: Create the ImportLog entity

src/AccountTracking.Api/Models/ImportLog.cs:

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace AccountTracking.Api.Models;

[Table("import_logs")]
public class ImportLog
{
    [Key]
    [Column("id")]
    public int Id { get; set; }

    [Column("imported_at")]
    public DateTime ImportedAt { get; set; } = DateTime.UtcNow;

    [Column("filename")]
    [MaxLength(255)]
    public string Filename { get; set; } = "";

    [Column("records_imported")]
    public int RecordsImported { get; set; }

    [Column("records_skipped")]
    public int RecordsSkipped { get; set; }
}
  • Step 3: Create all DTOs

src/AccountTracking.Api/Models/Dtos/LoginRequest.cs:

namespace AccountTracking.Api.Models.Dtos;
public record LoginRequest(string Username, string Password);

src/AccountTracking.Api/Models/Dtos/LoginResponse.cs:

namespace AccountTracking.Api.Models.Dtos;
public record LoginResponse(string Token, string ExpiresAt);

src/AccountTracking.Api/Models/Dtos/ImportResult.cs:

namespace AccountTracking.Api.Models.Dtos;
public record ImportResult(int RecordsImported, int RecordsSkipped);
public record ErrorResult(string Error);

src/AccountTracking.Api/Models/Dtos/TransactionDto.cs:

namespace AccountTracking.Api.Models.Dtos;
public record TransactionDto(
    int Id,
    DateOnly BookingDate,
    string? CounterPartyName,
    string? Category,
    decimal Amount,
    decimal Balance,
    string? Message
);

src/AccountTracking.Api/Models/Dtos/TransactionListResponse.cs:

namespace AccountTracking.Api.Models.Dtos;
public record TransactionListResponse(
    IEnumerable<TransactionDto> Items,
    int TotalCount,
    int Page,
    int PageSize
);

src/AccountTracking.Api/Models/Dtos/SummaryDto.cs:

namespace AccountTracking.Api.Models.Dtos;
public record SummaryDto(decimal TotalSpent, decimal TotalIncome);

src/AccountTracking.Api/Models/Dtos/CategorySpendingDto.cs:

namespace AccountTracking.Api.Models.Dtos;
public record CategorySpendingDto(string Category, decimal Total);

src/AccountTracking.Api/Models/Dtos/MonthlyBalanceDto.cs:

namespace AccountTracking.Api.Models.Dtos;
public record MonthlyBalanceDto(int Month, decimal ClosingBalance);

src/AccountTracking.Api/Models/Dtos/CumulativeSpendingDto.cs:

namespace AccountTracking.Api.Models.Dtos;
public record CumulativeSpendingDto(int Day, decimal CumulativeSpent);
  • Step 4: Create AppDbContext

src/AccountTracking.Api/Data/AppDbContext.cs:

using AccountTracking.Api.Models;
using Microsoft.EntityFrameworkCore;

namespace AccountTracking.Api.Data;

public class AppDbContext : DbContext
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<Transaction> Transactions => Set<Transaction>();
    public DbSet<ImportLog> ImportLogs => Set<ImportLog>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // transaction_id: case-sensitive unique index using utf8mb4_bin
        modelBuilder.Entity<Transaction>()
            .HasIndex(t => t.TransactionId)
            .IsUnique();

        modelBuilder.Entity<Transaction>()
            .Property(t => t.TransactionId)
            .UseCollation("utf8mb4_bin");

        // ImportedAt stored as UTC
        modelBuilder.Entity<ImportLog>()
            .Property(l => l.ImportedAt)
            .HasConversion(
                v => DateTime.SpecifyKind(v, DateTimeKind.Utc),
                v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
    }
}
  • Step 5: Verify it compiles
cd /mnt/c/Repos/Personal/account-tracking
dotnet build

Expected: Build succeeded.

  • Step 6: Commit
git add src/AccountTracking.Api/Models/ src/AccountTracking.Api/Data/
git commit -m "feat: add EF Core models, DTOs and AppDbContext"

Task 3: Application Startup (Program.cs)

Files:

  • Create/replace: src/AccountTracking.Api/Program.cs

This task wires up all middleware: CORS, JWT auth, NSwag/Swagger, database registration with InMemory fallback for NSwag generation, and Polly migration retry.

  • Step 1: Write Program.cs

src/AccountTracking.Api/Program.cs:

using System.Text;
using AccountTracking.Api.Data;
using AccountTracking.Api.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Polly;

var builder = WebApplication.CreateBuilder(args);

// ── Configuration ──────────────────────────────────────────────────────────
var connectionString = builder.Configuration["DB_CONNECTION"];
var jwtSecret = builder.Configuration["JWT_SECRET"]
    ?? throw new InvalidOperationException("JWT_SECRET is required");
var appUsername = builder.Configuration["APP_USERNAME"]
    ?? throw new InvalidOperationException("APP_USERNAME is required");
var appPassword = builder.Configuration["APP_PASSWORD"]
    ?? throw new InvalidOperationException("APP_PASSWORD is required");
var allowedOrigin = builder.Configuration["ALLOWED_ORIGIN"] ?? "http://localhost:3000";

// ── Database ────────────────────────────────────────────────────────────────
if (!string.IsNullOrEmpty(connectionString))
{
    builder.Services.AddDbContext<AppDbContext>(opts =>
        opts.UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)));
}
else
{
    // NSwag build-time spec generation: no real DB available
    builder.Services.AddDbContext<AppDbContext>(opts =>
        opts.UseInMemoryDatabase("nswag_gen"));
}

// ── Services ────────────────────────────────────────────────────────────────
builder.Services.AddScoped<CsvImportService>();
builder.Services.AddScoped<DashboardService>();

// ── Auth ────────────────────────────────────────────────────────────────────
var keyBytes = Convert.FromBase64String(jwtSecret);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(opts =>
    {
        opts.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
            ValidateIssuer = false,
            ValidateAudience = false,
            ClockSkew = TimeSpan.Zero
        };
    });
builder.Services.AddAuthorization();

// Store credentials for use by AuthController
builder.Services.AddSingleton(new AppCredentials(appUsername, appPassword, keyBytes));

// ── CORS ────────────────────────────────────────────────────────────────────
builder.Services.AddCors(opts =>
{
    opts.AddDefaultPolicy(policy =>
        policy.WithOrigins(allowedOrigin, "http://localhost:3000")
              .AllowAnyHeader()
              .AllowAnyMethod());
});

// ── Controllers & OpenAPI ───────────────────────────────────────────────────
builder.Services.AddControllers();
builder.Services.AddOpenApiDocument(config =>
{
    config.Title = "AccountTracking API";
    config.Version = "v1";
    config.AddSecurity("Bearer", new NSwag.OpenApiSecurityScheme
    {
        Type = NSwag.OpenApiSecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT"
    });
});

// ── Kestrel: limit upload to 10 MB ─────────────────────────────────────────
builder.WebHost.ConfigureKestrel(opts =>
    opts.Limits.MaxRequestBodySize = 10 * 1024 * 1024);

var app = builder.Build();

// ── Migrate DB (skip when running without a real DB) ───────────────────────
if (!string.IsNullOrEmpty(connectionString))
{
    var retryPolicy = Policy
        .Handle<Exception>()
        .WaitAndRetry(
            5,
            attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt - 1)),
            (ex, delay, attempt, _) =>
                Console.WriteLine($"DB migration attempt {attempt} failed: {ex.Message}. Retrying in {delay.TotalSeconds}s..."));

    using var scope = app.Services.CreateScope();
    var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    retryPolicy.Execute(() => db.Database.Migrate());
}

// ── Middleware pipeline ─────────────────────────────────────────────────────
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.UseOpenApi();
app.UseSwaggerUi();
app.MapControllers();

app.Run();

// Accessible by tests
public partial class Program { }

// Credential container registered in DI
public record AppCredentials(string Username, string PasswordHash, byte[] JwtKeyBytes);
  • Step 2: Verify it builds
dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj

Expected: Build succeeded.

  • Step 3: Commit
git add src/AccountTracking.Api/Program.cs
git commit -m "feat: configure startup — CORS, JWT, NSwag, Polly migration retry"

Task 4: Auth Controller

Files:

  • Create: src/AccountTracking.Api/Controllers/AuthController.cs

  • Step 1: Create AuthController

src/AccountTracking.Api/Controllers/AuthController.cs:

using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using AccountTracking.Api.Models.Dtos;
using Microsoft.AspNetCore.Mvc;
using Microsoft.IdentityModel.Tokens;

namespace AccountTracking.Api.Controllers;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly AppCredentials _credentials;

    public AuthController(AppCredentials credentials)
        => _credentials = credentials;

    [HttpPost("login")]
    public IActionResult Login([FromBody] LoginRequest request)
    {
        if (request.Username != _credentials.Username
            || !BCrypt.Net.BCrypt.Verify(request.Password, _credentials.PasswordHash))
            return Unauthorized();

        var expiry = DateTime.UtcNow.AddDays(30);
        var token = GenerateToken(expiry);

        return Ok(new LoginResponse(token, expiry.ToString("O")));
    }

    private string GenerateToken(DateTime expiry)
    {
        var key = new SymmetricSecurityKey(_credentials.JwtKeyBytes);
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
        var jwt = new JwtSecurityToken(
            claims: [new Claim(ClaimTypes.Name, _credentials.Username)],
            expires: expiry,
            signingCredentials: creds);
        return new JwtSecurityTokenHandler().WriteToken(jwt);
    }
}
  • Step 2: Build and verify
dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj

Expected: Build succeeded.

  • Step 3: Commit
git add src/AccountTracking.Api/Controllers/AuthController.cs
git commit -m "feat: add JWT auth endpoint"

Task 5: CSV Import Service (TDD)

Files:

  • Create: src/AccountTracking.Api/Services/CsvImportService.cs
  • Create: src/AccountTracking.Api.Tests/CsvImportServiceTests.cs

The service parses ČSOB semicolon-delimited UTF-8 CSV files. Header row is found by scanning for a line starting with "číslo účtu". Czech-locale amounts ("1 740,80") are normalised before parsing.

  • Step 1: Write the failing tests

src/AccountTracking.Api.Tests/CsvImportServiceTests.cs:

using AccountTracking.Api.Data;
using AccountTracking.Api.Services;
using Microsoft.EntityFrameworkCore;

namespace AccountTracking.Api.Tests;

public class CsvImportServiceTests
{
    private static AppDbContext CreateDb()
    {
        var opts = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;
        return new AppDbContext(opts);
    }

    private static CsvImportService CreateService(AppDbContext db)
        => new(db);

    // Minimal valid CSV with two data rows
    private const string ValidCsv = """
        Pohyby na účtu 216868554/0300 dne 19.03.2026

        číslo účtu;datum zaúčtování;částka;měna;zůstatek;číslo protiúčtu;kód banky protiúčtu;jméno protistrany;adresa protistrany;konstantní symbol;variabilní symbol;specifický symbol;označení operace;název trálého příkazu;vlastní poznámka;zpráva;kategorie;původní transakce;kurz;E2E identifikace;reference plátce;původní plátce;konečný příjemce;ID transakce
        216868554/0300;15.03.2026;-1 740,80;CZK;93 346,57;;;;;;205000001;;Transakce platební kartou;;;Zpráva 1;Potraviny;;;;;tx-001
        216868554/0300;14.03.2026;-800,00;CZK;95 087,37;;;;;;205000002;;Transakce platební kartou;;;Zpráva 2;Restaurace;;;;;tx-002
        """;

    private const string CsvMissingHeader = """
        Pohyby na účtu
        Nějaky obsah bez správné hlavičky
        datum;částka
        """;

    private const string CsvMissingRequiredColumn = """
        číslo účtu;datum zaúčtování;měna;zůstatek;ID transakce
        216868554/0300;15.03.2026;CZK;93346,57;tx-001
        """;

    [Fact]
    public async Task Import_ParsesRowsCorrectly()
    {
        using var db = CreateDb();
        var svc = CreateService(db);

        var result = await svc.ImportAsync(ValidCsv, "test.csv");

        Assert.True(result.IsSuccess);
        Assert.Equal(2, result.Value!.RecordsImported);
        Assert.Equal(0, result.Value.RecordsSkipped);

        var transactions = await db.Transactions.ToListAsync();
        Assert.Equal(2, transactions.Count);

        var tx = transactions.First(t => t.TransactionId == "tx-001");
        Assert.Equal(-1740.80m, tx.Amount);
        Assert.Equal(93346.57m, tx.Balance);
        Assert.Equal("Potraviny", tx.Category);
        Assert.Equal(new DateOnly(2026, 3, 15), tx.BookingDate);
    }

    [Fact]
    public async Task Import_SkipsDuplicateTransactionIds()
    {
        using var db = CreateDb();
        var svc = CreateService(db);

        await svc.ImportAsync(ValidCsv, "first.csv");
        var result = await svc.ImportAsync(ValidCsv, "second.csv");

        Assert.True(result.IsSuccess);
        Assert.Equal(0, result.Value!.RecordsImported);
        Assert.Equal(2, result.Value.RecordsSkipped);
        Assert.Equal(2, await db.Transactions.CountAsync());
    }

    [Fact]
    public async Task Import_FindsHeaderByScanning_NotByLineNumber()
    {
        // Header is on line 3 in ValidCsv — but we scan, not hardcode
        using var db = CreateDb();
        var svc = CreateService(db);

        var result = await svc.ImportAsync(ValidCsv, "test.csv");

        Assert.True(result.IsSuccess);
    }

    [Fact]
    public async Task Import_ReturnsError_WhenHeaderNotFound()
    {
        using var db = CreateDb();
        var svc = CreateService(db);

        var result = await svc.ImportAsync(CsvMissingHeader, "bad.csv");

        Assert.False(result.IsSuccess);
        Assert.Contains("hlavičku", result.Error, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task Import_ReturnsError_WhenRequiredColumnMissing()
    {
        using var db = CreateDb();
        var svc = CreateService(db);

        var result = await svc.ImportAsync(CsvMissingRequiredColumn, "bad.csv");

        Assert.False(result.IsSuccess);
        Assert.Contains("částka", result.Error, StringComparison.OrdinalIgnoreCase);
    }

    [Theory]
    [InlineData("1 740,80", 1740.80)]
    [InlineData("-4 263,15", -4263.15)]
    [InlineData("-800,00", -800.00)]
    [InlineData("93 346,57", 93346.57)]
    public void ParseAmount_HandlesChechLocaleFormat(string input, decimal expected)
    {
        var result = CsvImportService.ParseCzechDecimal(input);
        Assert.Equal(expected, result);
    }
}
  • Step 2: Run tests — verify they all fail
cd /mnt/c/Repos/Personal/account-tracking
dotnet test src/AccountTracking.Api.Tests/ --no-build 2>&1 | head -30

Expected: build errors because CsvImportService does not exist yet.

  • Step 3: Implement CsvImportService

src/AccountTracking.Api/Services/CsvImportService.cs:

using AccountTracking.Api.Data;
using AccountTracking.Api.Models;
using AccountTracking.Api.Models.Dtos;

namespace AccountTracking.Api.Services;

public class CsvImportService(AppDbContext db)
{
    private static readonly string[] RequiredColumns = ["částka", "ID transakce", "datum zaúčtování"];

    public async Task<ImportServiceResult> ImportAsync(string csvContent, string filename)
    {
        var lines = csvContent
            .Split('\n', StringSplitOptions.None)
            .Select(l => l.TrimEnd('\r'))
            .ToArray();

        // Find header row by scanning for line starting with "číslo účtu"
        int headerIndex = -1;
        for (int i = 0; i < lines.Length; i++)
        {
            if (lines[i].StartsWith("číslo účtu", StringComparison.OrdinalIgnoreCase))
            {
                headerIndex = i;
                break;
            }
        }

        if (headerIndex < 0)
            return ImportServiceResult.Failure("Soubor neobsahuje správnou hlavičku (řádek začínající 'číslo účtu').");

        var headers = lines[headerIndex].Split(';');
        var columnIndex = BuildColumnIndex(headers);

        // Validate required columns
        foreach (var col in RequiredColumns)
        {
            if (!columnIndex.ContainsKey(col))
                return ImportServiceResult.Failure($"Souboru chybí povinný sloupec: '{col}'.");
        }

        // Load existing transaction IDs to detect duplicates
        var existingIds = db.Transactions
            .Select(t => t.TransactionId)
            .ToHashSet();

        int imported = 0, skipped = 0;
        var toInsert = new List<Transaction>();

        for (int i = headerIndex + 1; i < lines.Length; i++)
        {
            var line = lines[i];
            if (string.IsNullOrWhiteSpace(line)) continue;

            var fields = line.Split(';');
            if (fields.Length < headers.Length) continue;

            var txId = GetField(fields, columnIndex, "ID transakce");
            if (string.IsNullOrEmpty(txId)) continue;

            if (existingIds.Contains(txId))
            {
                skipped++;
                continue;
            }

            existingIds.Add(txId); // prevent duplicates within the same file

            toInsert.Add(new Transaction
            {
                AccountNumber = GetField(fields, columnIndex, "číslo účtu"),
                BookingDate = DateOnly.ParseExact(GetField(fields, columnIndex, "datum zaúčtování"), "d.M.yyyy"),
                Amount = ParseCzechDecimal(GetField(fields, columnIndex, "částka")),
                Currency = GetField(fields, columnIndex, "měna"),
                Balance = ParseCzechDecimal(GetField(fields, columnIndex, "zůstatek")),
                CounterPartyName = GetField(fields, columnIndex, "jméno protistrany"),
                OperationDescription = GetField(fields, columnIndex, "označení operace"),
                Message = GetField(fields, columnIndex, "zpráva"),
                Category = GetField(fields, columnIndex, "kategorie"),
                VariableSymbol = GetField(fields, columnIndex, "variabilní symbol"),
                BankNote = GetField(fields, columnIndex, "vlastní poznámka"),
                TransactionId = txId,
                CounterBankCode = GetField(fields, columnIndex, "kód banky protiúčtu"),
                ConstantSymbol = GetField(fields, columnIndex, "konstantní symbol"),
                SpecificSymbol = GetField(fields, columnIndex, "specifický symbol"),
                OrderName = GetField(fields, columnIndex, "název trvalého příkazu"),
                ExchangeRate = GetField(fields, columnIndex, "kurz"),
                E2EId = GetField(fields, columnIndex, "E2E identifikace"),
                PayerReference = GetField(fields, columnIndex, "reference plátce"),
                OriginalPayer = GetField(fields, columnIndex, "původní plátce"),
                FinalRecipient = GetField(fields, columnIndex, "konečný příjemce"),
                OriginalTransaction = GetField(fields, columnIndex, "původní transakce"),
            });
            imported++;
        }

        if (toInsert.Count > 0)
        {
            db.Transactions.AddRange(toInsert);
            db.ImportLogs.Add(new ImportLog
            {
                Filename = filename,
                RecordsImported = imported,
                RecordsSkipped = skipped,
                ImportedAt = DateTime.UtcNow
            });
            await db.SaveChangesAsync();
        }

        return ImportServiceResult.Success(new ImportResult(imported, skipped));
    }

    public static decimal ParseCzechDecimal(string value)
    {
        // Czech format: "1 740,80" → remove spaces, replace comma with dot
        var normalised = value.Replace(" ", "").Replace(",", ".");
        return decimal.Parse(normalised, System.Globalization.CultureInfo.InvariantCulture);
    }

    private static Dictionary<string, int> BuildColumnIndex(string[] headers)
        => headers
            .Select((h, i) => (Header: h.Trim(), Index: i))
            .Where(x => !string.IsNullOrEmpty(x.Header))
            .ToDictionary(x => x.Header, x => x.Index, StringComparer.OrdinalIgnoreCase);

    private static string GetField(string[] fields, Dictionary<string, int> index, string column)
    {
        if (!index.TryGetValue(column, out int i)) return "";
        return i < fields.Length ? fields[i].Trim() : "";
    }
}

public class ImportServiceResult
{
    public bool IsSuccess { get; private init; }
    public ImportResult? Value { get; private init; }
    public string? Error { get; private init; }

    public static ImportServiceResult Success(ImportResult value)
        => new() { IsSuccess = true, Value = value };

    public static ImportServiceResult Failure(string error)
        => new() { IsSuccess = false, Error = error };
}
  • Step 4: Run tests — verify they pass
dotnet test src/AccountTracking.Api.Tests/ -v normal

Expected: all 8 tests pass.

  • Step 5: Commit
git add src/AccountTracking.Api/Services/CsvImportService.cs \
        src/AccountTracking.Api.Tests/CsvImportServiceTests.cs
git commit -m "feat: implement CSV import service with tests (TDD)"

Task 6: Transactions API Controller

Files:

  • Create: src/AccountTracking.Api/Controllers/TransactionsController.cs

  • Step 1: Create TransactionsController

src/AccountTracking.Api/Controllers/TransactionsController.cs:

using AccountTracking.Api.Data;
using AccountTracking.Api.Models.Dtos;
using AccountTracking.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace AccountTracking.Api.Controllers;

[ApiController]
[Route("api/transactions")]
[Authorize]
public class TransactionsController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly CsvImportService _importService;

    public TransactionsController(AppDbContext db, CsvImportService importService)
    {
        _db = db;
        _importService = importService;
    }

    [HttpPost("import")]
    public async Task<IActionResult> Import(IFormFile file)
    {
        if (file == null || file.Length == 0)
            return BadRequest(new ErrorResult("Nebyl vybrán žádný soubor."));

        string content;
        using (var reader = new StreamReader(file.OpenReadStream(), System.Text.Encoding.UTF8))
            content = await reader.ReadToEndAsync();

        var result = await _importService.ImportAsync(content, file.FileName);

        if (!result.IsSuccess)
            return BadRequest(new ErrorResult(result.Error!));

        return Ok(result.Value);
    }

    [HttpGet]
    public async Task<IActionResult> List(
        [FromQuery] int year,
        [FromQuery] int? month,
        [FromQuery] string? category,
        [FromQuery] string? search,
        [FromQuery] int page = 1,
        [FromQuery] int pageSize = 50)
    {
        pageSize = Math.Clamp(pageSize, 1, 200);
        page = Math.Max(1, page);

        var query = _db.Transactions.AsQueryable();

        query = query.Where(t => t.BookingDate.Year == year);
        if (month.HasValue)
            query = query.Where(t => t.BookingDate.Month == month.Value);
        if (!string.IsNullOrWhiteSpace(category))
            query = query.Where(t => t.Category == category);
        if (!string.IsNullOrWhiteSpace(search))
            query = query.Where(t =>
                (t.CounterPartyName != null && EF.Functions.Like(t.CounterPartyName, $"%{search}%")) ||
                (t.Message != null && EF.Functions.Like(t.Message, $"%{search}%")));

        var total = await query.CountAsync();
        var items = await query
            .OrderByDescending(t => t.BookingDate)
            .ThenByDescending(t => t.Id)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Select(t => new TransactionDto(
                t.Id, t.BookingDate, t.CounterPartyName,
                t.Category, t.Amount, t.Balance, t.Message))
            .ToListAsync();

        return Ok(new TransactionListResponse(items, total, page, pageSize));
    }

    [HttpGet("categories")]
    public async Task<IActionResult> Categories()
    {
        var cats = await _db.Transactions
            .Where(t => t.Category != null)
            .Select(t => t.Category!)
            .Distinct()
            .OrderBy(c => c)
            .ToListAsync();

        return Ok(cats);
    }
}
  • Step 2: Build and verify
dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj

Expected: Build succeeded.

  • Step 3: Commit
git add src/AccountTracking.Api/Controllers/TransactionsController.cs
git commit -m "feat: add transactions import, list and categories endpoints"

Task 7: Dashboard Service & Controller (TDD)

Files:

  • Create: src/AccountTracking.Api/Services/DashboardService.cs

  • Create: src/AccountTracking.Api/Controllers/DashboardController.cs

  • Create: src/AccountTracking.Api.Tests/DashboardServiceTests.cs

  • Step 1: Write the failing tests

src/AccountTracking.Api.Tests/DashboardServiceTests.cs:

using AccountTracking.Api.Data;
using AccountTracking.Api.Models;
using AccountTracking.Api.Services;
using Microsoft.EntityFrameworkCore;

namespace AccountTracking.Api.Tests;

public class DashboardServiceTests
{
    private static AppDbContext CreateDb(params Transaction[] seed)
    {
        var opts = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(Guid.NewGuid().ToString())
            .Options;
        var db = new AppDbContext(opts);
        db.Transactions.AddRange(seed);
        db.SaveChanges();
        return db;
    }

    private static Transaction Tx(string date, decimal amount, decimal balance,
        string category = "Test", string txId = "") =>
        new()
        {
            BookingDate = DateOnly.Parse(date),
            Amount = amount,
            Balance = balance,
            Category = category,
            TransactionId = txId == "" ? Guid.NewGuid().ToString() : txId,
            Currency = "CZK",
            AccountNumber = "test"
        };

    // ── Summary ──────────────────────────────────────────────────────────────

    [Fact]
    public async Task Summary_YearOnly_ReturnsTotalsForWholeYear()
    {
        using var db = CreateDb(
            Tx("2025-01-10", -1000, 9000),
            Tx("2025-06-15", -2000, 7000),
            Tx("2025-03-01", 5000, 12000),
            Tx("2024-12-31", -500, 9500) // different year — excluded
        );
        var svc = new DashboardService(db);

        var result = await svc.GetSummaryAsync(2025, null);

        Assert.Equal(3000m, result.TotalSpent);   // 1000 + 2000
        Assert.Equal(5000m, result.TotalIncome);
    }

    [Fact]
    public async Task Summary_YearAndMonth_ReturnsTotalsForMonth()
    {
        using var db = CreateDb(
            Tx("2025-03-10", -1000, 9000),
            Tx("2025-03-20", -500, 8500),
            Tx("2025-04-01", -800, 7700) // different month — excluded
        );
        var svc = new DashboardService(db);

        var result = await svc.GetSummaryAsync(2025, 3);

        Assert.Equal(1500m, result.TotalSpent);
        Assert.Equal(0m, result.TotalIncome);
    }

    // ── SpendingByCategory ───────────────────────────────────────────────────

    [Fact]
    public async Task SpendingByCategory_ReturnsAbsoluteValuesSortedDescending()
    {
        using var db = CreateDb(
            Tx("2025-03-01", -100, 900, "A"),
            Tx("2025-03-02", -300, 600, "B"),
            Tx("2025-03-03", -200, 400, "A")
        );
        var svc = new DashboardService(db);

        var result = await svc.GetSpendingByCategoryAsync(2025, 3);

        Assert.Equal(2, result.Count);
        Assert.Equal("A", result[0].Category);
        Assert.Equal(300m, result[0].Total); // 100 + 200
        Assert.Equal("B", result[1].Category);
        Assert.Equal(300m, result[1].Total);
    }

    [Fact]
    public async Task SpendingByCategory_ExcludesPositiveAmounts()
    {
        using var db = CreateDb(
            Tx("2025-03-01", -500, 9500, "Potraviny"),
            Tx("2025-03-02", 200, 9700, "Potraviny")  // refund — excluded
        );
        var svc = new DashboardService(db);

        var result = await svc.GetSpendingByCategoryAsync(2025, 3);

        Assert.Single(result);
        Assert.Equal(500m, result[0].Total);
    }

    // ── MonthlyBalances ──────────────────────────────────────────────────────

    [Fact]
    public async Task MonthlyBalances_ReturnsLastBalancePerMonth()
    {
        using var db = CreateDb(
            Tx("2025-01-10", -100, 900),
            Tx("2025-01-20", -200, 700),  // last in Jan — use this balance
            Tx("2025-03-05", -150, 550)   // March — Feb omitted (no transactions)
        );
        var svc = new DashboardService(db);

        var result = await svc.GetMonthlyBalancesAsync(2025);

        Assert.Equal(2, result.Count);
        Assert.Equal(1, result[0].Month);
        Assert.Equal(700m, result[0].ClosingBalance);
        Assert.Equal(3, result[1].Month);
        Assert.Equal(550m, result[1].ClosingBalance);
    }

    // ── CumulativeSpending ───────────────────────────────────────────────────

    [Fact]
    public async Task CumulativeSpending_FillsGapsAndAccumulates()
    {
        using var db = CreateDb(
            Tx("2025-03-01", -100, 900),
            Tx("2025-03-03", -200, 700),  // gap on day 2
            Tx("2025-03-03", -50, 650)    // two transactions on same day
        );
        var svc = new DashboardService(db);

        var result = await svc.GetCumulativeSpendingAsync(2025, 3);

        // Day 1: 100, Day 2: filled = 100, Day 3: 100 + 250 = 350
        Assert.Equal(3, result.Count);
        Assert.Equal(1, result[0].Day); Assert.Equal(100m, result[0].CumulativeSpent);
        Assert.Equal(2, result[1].Day); Assert.Equal(100m, result[1].CumulativeSpent);
        Assert.Equal(3, result[2].Day); Assert.Equal(350m, result[2].CumulativeSpent);
    }

    [Fact]
    public async Task CumulativeSpending_ExcludesPositiveAmounts()
    {
        using var db = CreateDb(
            Tx("2025-03-01", -100, 900),
            Tx("2025-03-01", 500, 1400)   // income — not counted
        );
        var svc = new DashboardService(db);

        var result = await svc.GetCumulativeSpendingAsync(2025, 3);

        Assert.Single(result);
        Assert.Equal(100m, result[0].CumulativeSpent);
    }
}
  • Step 2: Run tests — verify they fail
dotnet test src/AccountTracking.Api.Tests/ 2>&1 | head -20

Expected: build error — DashboardService not found.

  • Step 3: Implement DashboardService

src/AccountTracking.Api/Services/DashboardService.cs:

using AccountTracking.Api.Data;
using AccountTracking.Api.Models.Dtos;
using Microsoft.EntityFrameworkCore;

namespace AccountTracking.Api.Services;

public class DashboardService(AppDbContext db)
{
    public async Task<SummaryDto> GetSummaryAsync(int year, int? month)
    {
        var query = db.Transactions.Where(t => t.BookingDate.Year == year);
        if (month.HasValue)
            query = query.Where(t => t.BookingDate.Month == month.Value);

        var spent = await query
            .Where(t => t.Amount < 0)
            .SumAsync(t => (decimal?)t.Amount) ?? 0;
        var income = await query
            .Where(t => t.Amount > 0)
            .SumAsync(t => (decimal?)t.Amount) ?? 0;

        return new SummaryDto(Math.Abs(spent), income);
    }

    public async Task<List<CategorySpendingDto>> GetSpendingByCategoryAsync(int year, int? month)
    {
        var query = db.Transactions
            .Where(t => t.BookingDate.Year == year && t.Amount < 0);

        if (month.HasValue)
            query = query.Where(t => t.BookingDate.Month == month.Value);

        return await query
            .GroupBy(t => t.Category ?? "Nezatříděno")
            .Select(g => new CategorySpendingDto(g.Key, Math.Abs(g.Sum(t => t.Amount))))
            .OrderByDescending(c => c.Total)
            .ToListAsync();
    }

    public async Task<List<MonthlyBalanceDto>> GetMonthlyBalancesAsync(int year)
    {
        // For each month: get the balance of the transaction with the latest booking_date
        // (highest id as tiebreaker for same-date rows)
        var rows = await db.Transactions
            .Where(t => t.BookingDate.Year == year)
            .GroupBy(t => t.BookingDate.Month)
            .Select(g => new MonthlyBalanceDto(
                g.Key,
                g.OrderByDescending(t => t.BookingDate)
                 .ThenByDescending(t => t.Id)
                 .First().Balance))
            .OrderBy(r => r.Month)
            .ToListAsync();

        return rows;
    }

    public async Task<List<CumulativeSpendingDto>> GetCumulativeSpendingAsync(int year, int month)
    {
        // Get daily spending totals (absolute values, negative amounts only)
        var dailySpending = await db.Transactions
            .Where(t => t.BookingDate.Year == year
                     && t.BookingDate.Month == month
                     && t.Amount < 0)
            .GroupBy(t => t.BookingDate.Day)
            .Select(g => new { Day = g.Key, Spent = Math.Abs(g.Sum(t => t.Amount)) })
            .OrderBy(g => g.Day)
            .ToListAsync();

        if (dailySpending.Count == 0)
            return [];

        // Fill gaps: for each day from day 1 to last day with spending,
        // carry forward the previous cumulative value
        var result = new List<CumulativeSpendingDto>();
        decimal running = 0;
        int lastDay = dailySpending.Max(d => d.Day);
        var spendingByDay = dailySpending.ToDictionary(d => d.Day, d => d.Spent);

        for (int day = 1; day <= lastDay; day++)
        {
            if (spendingByDay.TryGetValue(day, out var spent))
                running += spent;

            // Only emit days up to and including last spending day
            // but fill gaps so chart is continuous
            if (day <= lastDay)
                result.Add(new CumulativeSpendingDto(day, running));
        }

        return result;
    }
}
  • Step 4: Run tests — verify they pass
dotnet test src/AccountTracking.Api.Tests/ -v normal

Expected: all tests pass (both CsvImportService and DashboardService suites).

  • Step 5: Create DashboardController

src/AccountTracking.Api/Controllers/DashboardController.cs:

using AccountTracking.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;

namespace AccountTracking.Api.Controllers;

[ApiController]
[Route("api/dashboard")]
[Authorize]
public class DashboardController : ControllerBase
{
    private readonly DashboardService _svc;
    public DashboardController(DashboardService svc) => _svc = svc;

    [HttpGet("summary")]
    public async Task<IActionResult> Summary([FromQuery] int year, [FromQuery] int? month)
        => Ok(await _svc.GetSummaryAsync(year, month));

    [HttpGet("spending-by-category")]
    public async Task<IActionResult> SpendingByCategory([FromQuery] int year, [FromQuery] int? month)
        => Ok(await _svc.GetSpendingByCategoryAsync(year, month));

    [HttpGet("monthly-balances")]
    public async Task<IActionResult> MonthlyBalances([FromQuery] int year)
        => Ok(await _svc.GetMonthlyBalancesAsync(year));

    [HttpGet("cumulative-spending")]
    public async Task<IActionResult> CumulativeSpending([FromQuery] int year, [FromQuery] int month)
        => Ok(await _svc.GetCumulativeSpendingAsync(year, month));
}
  • Step 6: Build and verify
dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj

Expected: Build succeeded.

  • Step 7: Commit
git add src/AccountTracking.Api/Services/DashboardService.cs \
        src/AccountTracking.Api/Controllers/DashboardController.cs \
        src/AccountTracking.Api.Tests/DashboardServiceTests.cs
git commit -m "feat: implement dashboard service and controller with tests (TDD)"

Task 8: EF Core Migrations

Files:

  • Create: src/AccountTracking.Api/Data/Migrations/ (generated)

Migrations require a real MySQL connection. Use a local dev DB for this step.

  • Step 1: Set up a local MySQL instance (if not running)
docker run -d --name finance-dev-db \
  -e MYSQL_ROOT_PASSWORD=devpass \
  -e MYSQL_DATABASE=accounttracking \
  -p 3306:3306 \
  mysql:8

Wait ~15 seconds for MySQL to start.

  • Step 2: Create the initial migration
cd /mnt/c/Repos/Personal/account-tracking
DB_CONNECTION="Server=localhost;Database=accounttracking;User=root;Password=devpass;" \
  dotnet ef migrations add InitialCreate \
  --project src/AccountTracking.Api \
  --output-dir Data/Migrations

Expected: Done. To undo this action, use 'ef migrations remove'

  • Step 3: Verify the migration applies
DB_CONNECTION="Server=localhost;Database=accounttracking;User=root;Password=devpass;" \
  dotnet ef database update \
  --project src/AccountTracking.Api

Expected: Done.

  • Step 4: Commit
git add src/AccountTracking.Api/Data/Migrations/
git commit -m "feat: add initial EF Core database migration"

Task 9: NSwag OpenAPI Spec Generation

Files:

  • Create: src/AccountTracking.Api/nswag.json

  • Create (generated): src/AccountTracking.Api/openapi.json

  • Step 1: Create nswag.json in the API project

src/AccountTracking.Api/nswag.json:

{
  "runtime": "Net80",
  "documentGenerator": {
    "aspNetCoreToOpenApi": {
      "project": "AccountTracking.Api.csproj",
      "msBuildProjectExtensionsPath": null,
      "configuration": "Debug",
      "noBuild": true,
      "verbose": false,
      "outputType": "OpenApi3",
      "output": "openapi.json"
    }
  },
  "codeGenerators": {}
}
  • Step 2: Build and generate the spec
cd /mnt/c/Repos/Personal/account-tracking
dotnet build src/AccountTracking.Api/AccountTracking.Api.csproj \
  /p:GenerateOpenApiSpec=true

If the NSwag target fails due to DB (no DB connection in build env), run manually:

cd src/AccountTracking.Api
dotnet build -c Debug
$(dotnet tool list --global | grep nswag | awk '{print $1}') run nswag.json

Or use the simpler approach — run the API and curl the swagger endpoint:

# Terminal 1: start the API with InMemory DB (no DB_CONNECTION set)
cd src/AccountTracking.Api
dotnet run &
API_PID=$!
sleep 3

# Fetch and save the OpenAPI spec
curl -s http://localhost:5000/swagger/v1/swagger.json -o openapi.json

kill $API_PID
  • Step 3: Verify openapi.json contains expected routes
cat src/AccountTracking.Api/openapi.json | grep -E '"\/api\/(auth|transactions|dashboard)'

Expected: lines containing /api/auth/login, /api/transactions, /api/dashboard/summary etc.

  • Step 4: Add openapi.json to .gitignore or commit it

The spec should be committed so the frontend Dockerfile can use it without running the API:

git add src/AccountTracking.Api/openapi.json src/AccountTracking.Api/nswag.json
git commit -m "feat: add NSwag config and commit generated openapi.json"

Task 10: Frontend Scaffold

Files:

  • Create: src/AccountTracking.Web/package.json

  • Create: src/AccountTracking.Web/vite.config.ts

  • Create: src/AccountTracking.Web/tsconfig.json

  • Create: src/AccountTracking.Web/index.html

  • Create: src/AccountTracking.Web/src/main.ts

  • Create: src/AccountTracking.Web/src/App.vue

  • Step 1: Scaffold the Vue project

cd /mnt/c/Repos/Personal/account-tracking/src
npm create vue@latest AccountTracking.Web -- \
  --typescript --router --pinia --no-jsx --no-vitest --no-playwright --no-eslint
cd AccountTracking.Web
npm install
  • Step 2: Add Vuetify, ApexCharts and NSwag dependencies
npm install vuetify@^3.6.0 @mdi/font apexcharts vue3-apexcharts
npm install --save-dev nswag vite-plugin-vuetify
  • Step 3: Configure Vite with Vuetify plugin and API proxy

Replace src/AccountTracking.Web/vite.config.ts:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vuetify from 'vite-plugin-vuetify'

export default defineConfig({
  plugins: [
    vue(),
    vuetify({ autoImport: true }),
  ],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:5000',
        changeOrigin: true,
      },
    },
  },
})
  • Step 4: Configure main.ts with Vuetify and Pinia

src/AccountTracking.Web/src/main.ts:

import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createVuetify } from 'vuetify'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
import VueApexCharts from 'vue3-apexcharts'
import router from './router'
import App from './App.vue'

import 'vuetify/styles'
import '@mdi/font/css/materialdesignicons.css'

const vuetify = createVuetify({
  components,
  directives,
  icons: { defaultSet: 'mdi', aliases, sets: { mdi } },
  theme: {
    defaultTheme: 'dark',
  },
})

createApp(App)
  .use(createPinia())
  .use(router)
  .use(vuetify)
  .use(VueApexCharts)
  .mount('#app')
  • Step 5: Create a minimal App.vue

src/AccountTracking.Web/src/App.vue:

<template>
  <v-app>
    <router-view />
  </v-app>
</template>
  • Step 6: Verify the dev server starts
cd src/AccountTracking.Web
npm run dev

Expected: VITE v5.x ready with a local URL. Open it — should show a blank dark page. Stop with Ctrl+C.

  • Step 7: Commit
cd /mnt/c/Repos/Personal/account-tracking
git add src/AccountTracking.Web/
git commit -m "feat: scaffold Vue 3 + Vuetify + Pinia frontend"

Task 11: NSwag TypeScript Client

Files:

  • Create: src/AccountTracking.Web/nswag.json

  • Create (generated): src/AccountTracking.Web/src/api/apiClient.ts

  • Create: src/AccountTracking.Web/src/api/index.ts

  • Step 1: Copy openapi.json to the web project

cp src/AccountTracking.Api/openapi.json src/AccountTracking.Web/openapi.json
  • Step 2: Create nswag.json for TypeScript client generation

src/AccountTracking.Web/nswag.json:

{
  "runtime": "Default",
  "documentGenerator": {
    "fromDocument": {
      "url": "openapi.json"
    }
  },
  "codeGenerators": {
    "openApiToTypeScriptClient": {
      "className": "{controller}Client",
      "template": "Fetch",
      "typeScriptVersion": 5.0,
      "dateTimeType": "String",
      "nullValue": "Undefined",
      "generateClientClasses": true,
      "generateDtoTypes": true,
      "operationGenerationMode": "MultipleClientsFromOperationId",
      "markOptionalProperties": true,
      "typeStyle": "Interface",
      "enumStyle": "Enum",
      "output": "src/api/apiClient.ts"
    }
  }
}
  • Step 3: Generate the TypeScript client
cd src/AccountTracking.Web
npx nswag run nswag.json

Expected: src/api/apiClient.ts created.

  • Step 4: Create the authenticated API wrapper

src/AccountTracking.Web/src/api/index.ts:

import { useRouter } from 'vue-router'
import { AuthClient, TransactionsClient, DashboardClient } from './apiClient'

// Custom fetch that injects the JWT and handles 401
class AuthFetch {
  fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
    const token = localStorage.getItem('token')
    const headers = new Headers(init?.headers)
    if (token) headers.set('Authorization', `Bearer ${token}`)

    return fetch(url, { ...init, headers }).then((res) => {
      if (res.status === 401) {
        localStorage.removeItem('token')
        localStorage.removeItem('expiresAt')
        // Navigate to login — import router lazily to avoid circular dep
        import('../router').then(({ default: router }) => router.push('/login'))
      }
      return res
    })
  }
}

const httpClient = new AuthFetch()

// Base URL is empty: NSwag generates full paths like /api/auth/login from the OpenAPI spec.
// Verify after generation — if routes come out as /auth/login, change '' to '/api'.
export const authApi = new AuthClient('', httpClient)
export const transactionsApi = new TransactionsClient('', httpClient)
export const dashboardApi = new DashboardClient('', httpClient)
  • Step 5: Add generated file to .gitignore or commit

The generated apiClient.ts should be committed (it's the contract between front and back). Add openapi.json to .gitignore in the web dir (it's a copy):

echo "openapi.json" >> src/AccountTracking.Web/.gitignore
  • Step 6: Verify TypeScript compiles
cd src/AccountTracking.Web
npm run build

Expected: build succeeds (or type errors from placeholder views — fix those in the next tasks).

  • Step 7: Commit
cd /mnt/c/Repos/Personal/account-tracking
git add src/AccountTracking.Web/nswag.json \
        src/AccountTracking.Web/src/api/ \
        src/AccountTracking.Web/.gitignore
git commit -m "feat: generate NSwag TypeScript client and authenticated API wrapper"

Task 12: Auth Store, Router Guard & Login Page

Files:

  • Create: src/AccountTracking.Web/src/stores/auth.ts

  • Modify: src/AccountTracking.Web/src/router/index.ts

  • Create: src/AccountTracking.Web/src/views/LoginView.vue

  • Step 1: Create auth Pinia store

src/AccountTracking.Web/src/stores/auth.ts:

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { authApi } from '../api'

export const useAuthStore = defineStore('auth', () => {
  const token = ref<string | null>(localStorage.getItem('token'))
  const expiresAt = ref<string | null>(localStorage.getItem('expiresAt'))

  const isAuthenticated = computed(() => {
    if (!token.value || !expiresAt.value) return false
    return Date.now() < Date.parse(expiresAt.value)
  })

  async function login(username: string, password: string): Promise<void> {
    const response = await authApi.login({ username, password })
    token.value = response.token ?? null
    expiresAt.value = response.expiresAt ?? null
    if (token.value) localStorage.setItem('token', token.value)
    if (expiresAt.value) localStorage.setItem('expiresAt', expiresAt.value)
  }

  function logout() {
    token.value = null
    expiresAt.value = null
    localStorage.removeItem('token')
    localStorage.removeItem('expiresAt')
  }

  return { token, isAuthenticated, login, logout }
})
  • Step 2: Update router with navigation guard

src/AccountTracking.Web/src/router/index.ts:

import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    { path: '/login', component: () => import('../views/LoginView.vue'), meta: { public: true } },
    { path: '/',      component: () => import('../views/DashboardView.vue') },
    { path: '/transactions', component: () => import('../views/TransactionsView.vue') },
  ],
})

router.beforeEach((to) => {
  if (to.meta.public) return true
  const auth = useAuthStore()
  if (!auth.isAuthenticated) return '/login'
  return true
})

export default router
  • Step 3: Create LoginView

src/AccountTracking.Web/src/views/LoginView.vue:

<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'

const router = useRouter()
const auth = useAuthStore()

const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)

async function submit() {
  error.value = ''
  loading.value = true
  try {
    await auth.login(username.value, password.value)
    router.push('/')
  } catch {
    error.value = 'Nesprávné přihlašovací údaje.'
  } finally {
    loading.value = false
  }
}
</script>

<template>
  <v-container class="fill-height" fluid>
    <v-row align="center" justify="center">
      <v-col cols="12" sm="8" md="4">
        <v-card>
          <v-card-title class="text-h5 pa-6">Finance Tracker</v-card-title>
          <v-card-text>
            <v-alert v-if="error" type="error" class="mb-4">{{ error }}</v-alert>
            <v-text-field
              v-model="username"
              label="Uživatelské jméno"
              prepend-inner-icon="mdi-account"
              @keyup.enter="submit"
            />
            <v-text-field
              v-model="password"
              label="Heslo"
              type="password"
              prepend-inner-icon="mdi-lock"
              @keyup.enter="submit"
            />
          </v-card-text>
          <v-card-actions class="pa-6 pt-0">
            <v-btn
              block
              color="primary"
              size="large"
              :loading="loading"
              @click="submit"
            >
              Přihlásit se
            </v-btn>
          </v-card-actions>
        </v-card>
      </v-col>
    </v-row>
  </v-container>
</template>
  • Step 4: Create placeholder views so the app compiles

src/AccountTracking.Web/src/views/DashboardView.vue:

<template><div>Dashboard</div></template>

src/AccountTracking.Web/src/views/TransactionsView.vue:

<template><div>Transactions</div></template>
  • Step 5: Build and verify
cd src/AccountTracking.Web && npm run build

Expected: build succeeds.

  • Step 6: Commit
cd /mnt/c/Repos/Personal/account-tracking
git add src/AccountTracking.Web/src/stores/auth.ts \
        src/AccountTracking.Web/src/router/index.ts \
        src/AccountTracking.Web/src/views/
git commit -m "feat: auth store, router guard and login page"

Task 13: Dashboard Page

Files:

  • Create: src/AccountTracking.Web/src/stores/dashPeriod.ts

  • Replace: src/AccountTracking.Web/src/views/DashboardView.vue

  • Create: src/AccountTracking.Web/src/components/YearSummary.vue

  • Create: src/AccountTracking.Web/src/components/MonthSummary.vue

  • Create: src/AccountTracking.Web/src/components/DonutChart.vue

  • Create: src/AccountTracking.Web/src/components/MonthlyBalancesChart.vue

  • Create: src/AccountTracking.Web/src/components/CumulativeSpendingChart.vue

  • Create: src/AccountTracking.Web/src/components/UploadCsvButton.vue

  • Step 1: Create dashPeriod store

src/AccountTracking.Web/src/stores/dashPeriod.ts:

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useDashPeriodStore = defineStore('dashPeriod', () => {
  const now = new Date()
  const year = ref(now.getFullYear())
  const month = ref(now.getMonth() + 1) // 1-based

  const monthLabel = computed(() => {
    const d = new Date(year.value, month.value - 1, 1)
    return d.toLocaleDateString('cs-CZ', { month: 'long', year: 'numeric' })
  })

  function prevMonth() {
    if (month.value === 1) { month.value = 12; year.value-- }
    else month.value--
  }

  function nextMonth() {
    if (month.value === 12) { month.value = 1; year.value++ }
    else month.value++
  }

  function prevYear() { year.value-- }
  function nextYear() { year.value++ }

  return { year, month, monthLabel, prevMonth, nextMonth, prevYear, nextYear }
})
  • Step 2: Create UploadCsvButton component

src/AccountTracking.Web/src/components/UploadCsvButton.vue:

<script setup lang="ts">
import { ref } from 'vue'
import { transactionsApi } from '../api'

const fileInput = ref<HTMLInputElement | null>(null)
const snackbar = ref(false)
const snackbarText = ref('')
const snackbarColor = ref('success')
const loading = ref(false)

function openPicker() {
  fileInput.value?.click()
}

async function onFileSelected(event: Event) {
  const file = (event.target as HTMLInputElement).files?.[0]
  if (!file) return

  loading.value = true
  try {
    const result = await transactionsApi.import({ fileName: file.name, data: file })
    snackbarText.value = `Importováno ${result.recordsImported}, přeskočeno ${result.recordsSkipped}.`
    snackbarColor.value = 'success'
  } catch (e: any) {
    const body = await e?.response?.json().catch(() => null)
    snackbarText.value = body?.error ?? 'Chyba při importu.'
    snackbarColor.value = 'error'
  } finally {
    loading.value = false
    snackbar.value = true
    if (fileInput.value) fileInput.value.value = ''
  }
}
</script>

<template>
  <input
    ref="fileInput"
    type="file"
    accept=".csv"
    style="display:none"
    @change="onFileSelected"
  />
  <v-btn
    prepend-icon="mdi-upload"
    color="primary"
    variant="outlined"
    :loading="loading"
    @click="openPicker"
  >
    Nahrát CSV
  </v-btn>
  <v-snackbar v-model="snackbar" :color="snackbarColor" timeout="4000">
    {{ snackbarText }}
  </v-snackbar>
</template>
  • Step 3: Create DonutChart component

src/AccountTracking.Web/src/components/DonutChart.vue:

<script setup lang="ts">
import { computed } from 'vue'
import type { CategorySpendingDto } from '../api/apiClient'

const props = defineProps<{
  data: CategorySpendingDto[]
  loading: boolean
}>()

const chartOptions = computed(() => ({
  chart: { type: 'donut', background: 'transparent' },
  labels: props.data.map(d => d.category ?? 'Nezatříděno'),
  theme: { mode: 'dark' },
  legend: { show: false },
  dataLabels: { enabled: false },
  tooltip: {
    y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
  }
}))

const series = computed(() => props.data.map(d => d.total ?? 0))

const legendItems = computed(() =>
  props.data.map((d, i) => ({
    label: d.category ?? 'Nezatříděno',
    total: d.total ?? 0,
    color: getColor(i),
  }))
)

function getColor(i: number) {
  const colors = ['#ef5350','#42a5f5','#66bb6a','#ffa726','#ab47bc',
                  '#26c6da','#d4e157','#ff7043','#8d6e63','#78909c']
  return colors[i % colors.length]
}
</script>

<template>
  <div v-if="loading">
    <v-skeleton-loader type="image" />
  </div>
  <div v-else-if="data.length === 0" class="text-center text-medium-emphasis py-4">
    Žádné výdaje
  </div>
  <div v-else>
    <apexchart type="donut" :options="chartOptions" :series="series" height="200" />
    <div class="mt-2">
      <div
        v-for="item in legendItems"
        :key="item.label"
        class="d-flex align-center gap-2 mb-1"
      >
        <div :style="{ width: '10px', height: '10px', borderRadius: '50%', background: item.color, flexShrink: 0 }" />
        <span class="text-body-2 flex-grow-1">{{ item.label }}</span>
        <span class="text-body-2 text-medium-emphasis">{{ item.total.toLocaleString('cs-CZ') }} </span>
      </div>
    </div>
  </div>
</template>
  • Step 4: Create MonthlyBalancesChart component

src/AccountTracking.Web/src/components/MonthlyBalancesChart.vue:

<script setup lang="ts">
import { computed } from 'vue'
import type { MonthlyBalanceDto } from '../api/apiClient'

const props = defineProps<{
  data: MonthlyBalanceDto[]
  loading: boolean
}>()

const MONTH_NAMES = ['Led','Úno','Bře','Dub','Kvě','Čvn','Čvc','Srp','Zář','Říj','Lis','Pro']

const chartOptions = computed(() => ({
  chart: { type: 'bar', background: 'transparent', toolbar: { show: false } },
  xaxis: { categories: props.data.map(d => MONTH_NAMES[(d.month ?? 1) - 1]) },
  theme: { mode: 'dark' },
  dataLabels: { enabled: false },
  tooltip: {
    y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
  },
  colors: ['#42a5f5'],
}))

const series = computed(() => [{
  name: 'Zůstatek',
  data: props.data.map(d => d.closingBalance ?? 0)
}])
</script>

<template>
  <div v-if="loading"><v-skeleton-loader type="image" /></div>
  <apexchart v-else type="bar" :options="chartOptions" :series="series" height="220" />
</template>
  • Step 5: Create CumulativeSpendingChart component

src/AccountTracking.Web/src/components/CumulativeSpendingChart.vue:

<script setup lang="ts">
import { computed } from 'vue'
import type { CumulativeSpendingDto } from '../api/apiClient'

const props = defineProps<{
  data: CumulativeSpendingDto[]
  loading: boolean
}>()

const chartOptions = computed(() => ({
  chart: { type: 'line', background: 'transparent', toolbar: { show: false } },
  xaxis: { categories: props.data.map(d => `${d.day}.`) },
  theme: { mode: 'dark' },
  stroke: { curve: 'smooth', width: 2 },
  dataLabels: { enabled: false },
  tooltip: {
    y: { formatter: (val: number) => `${val.toLocaleString('cs-CZ')} Kč` }
  },
  colors: ['#ef5350'],
}))

const series = computed(() => [{
  name: 'Kumulativní výdaje',
  data: props.data.map(d => d.cumulativeSpent ?? 0)
}])
</script>

<template>
  <div v-if="loading"><v-skeleton-loader type="image" /></div>
  <div v-else-if="data.length === 0" class="text-center text-medium-emphasis py-4">Žádné výdaje</div>
  <apexchart v-else type="line" :options="chartOptions" :series="series" height="220" />
</template>
  • Step 6: Create YearSummary component

src/AccountTracking.Web/src/components/YearSummary.vue:

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useDashPeriodStore } from '../stores/dashPeriod'
import { dashboardApi } from '../api'
import type { CategorySpendingDto, MonthlyBalanceDto, SummaryDto } from '../api/apiClient'
import DonutChart from './DonutChart.vue'
import MonthlyBalancesChart from './MonthlyBalancesChart.vue'

const period = useDashPeriodStore()

const summary = ref<SummaryDto | null>(null)
const categories = ref<CategorySpendingDto[]>([])
const balances = ref<MonthlyBalanceDto[]>([])
const loadingSummary = ref(false)
const loadingCategories = ref(false)
const loadingBalances = ref(false)

async function load() {
  loadingSummary.value = true
  loadingCategories.value = true
  loadingBalances.value = true
  try {
    const [s, c, b] = await Promise.all([
      dashboardApi.summary(period.year, undefined),
      dashboardApi.spendingByCategory(period.year, undefined),
      dashboardApi.monthlyBalances(period.year),
    ])
    summary.value = s
    categories.value = c
    balances.value = b
  } finally {
    loadingSummary.value = false
    loadingCategories.value = false
    loadingBalances.value = false
  }
}

watch(() => period.year, load, { immediate: true })
</script>

<template>
  <v-card height="100%">
    <v-card-title class="d-flex align-center pa-3">
      <v-btn icon="mdi-chevron-left" variant="text" @click="period.prevYear" />
      <span class="flex-grow-1 text-center text-h6">{{ period.year }}</span>
      <v-btn icon="mdi-chevron-right" variant="text" @click="period.nextYear" />
    </v-card-title>

    <v-card-text>
      <v-skeleton-loader v-if="loadingSummary" type="text,text" />
      <v-row v-else class="mb-4">
        <v-col cols="6" class="text-center">
          <div class="text-caption text-medium-emphasis">VÝDAJE</div>
          <div class="text-h6 text-error">{{ summary?.totalSpent.toLocaleString('cs-CZ') }} Kč</div>
        </v-col>
        <v-col cols="6" class="text-center">
          <div class="text-caption text-medium-emphasis">PŘÍJMY</div>
          <div class="text-h6 text-success">{{ summary?.totalIncome.toLocaleString('cs-CZ') }} Kč</div>
        </v-col>
      </v-row>

      <v-row>
        <v-col cols="12" md="5">
          <div class="text-caption text-medium-emphasis mb-1">VÝDAJE DLE KATEGORIÍ</div>
          <DonutChart :data="categories" :loading="loadingCategories" />
        </v-col>
        <v-col cols="12" md="7">
          <div class="text-caption text-medium-emphasis mb-1">MĚSÍČNÍ ZŮSTATKY</div>
          <MonthlyBalancesChart :data="balances" :loading="loadingBalances" />
        </v-col>
      </v-row>
    </v-card-text>
  </v-card>
</template>
  • Step 7: Create MonthSummary component

src/AccountTracking.Web/src/components/MonthSummary.vue:

<script setup lang="ts">
import { ref, watch } from 'vue'
import { useDashPeriodStore } from '../stores/dashPeriod'
import { dashboardApi } from '../api'
import type { CategorySpendingDto, CumulativeSpendingDto, SummaryDto } from '../api/apiClient'
import DonutChart from './DonutChart.vue'
import CumulativeSpendingChart from './CumulativeSpendingChart.vue'

const period = useDashPeriodStore()

const summary = ref<SummaryDto | null>(null)
const categories = ref<CategorySpendingDto[]>([])
const cumulative = ref<CumulativeSpendingDto[]>([])
const loadingSummary = ref(false)
const loadingCategories = ref(false)
const loadingCumulative = ref(false)

async function load() {
  loadingSummary.value = true
  loadingCategories.value = true
  loadingCumulative.value = true
  try {
    const [s, c, cu] = await Promise.all([
      dashboardApi.summary(period.year, period.month),
      dashboardApi.spendingByCategory(period.year, period.month),
      dashboardApi.cumulativeSpending(period.year, period.month),
    ])
    summary.value = s
    categories.value = c
    cumulative.value = cu
  } finally {
    loadingSummary.value = false
    loadingCategories.value = false
    loadingCumulative.value = false
  }
}

watch([() => period.year, () => period.month], load, { immediate: true })
</script>

<template>
  <v-card height="100%">
    <v-card-title class="d-flex align-center pa-3">
      <v-btn icon="mdi-chevron-left" variant="text" @click="period.prevMonth" />
      <span class="flex-grow-1 text-center text-h6">{{ period.monthLabel }}</span>
      <v-btn icon="mdi-chevron-right" variant="text" @click="period.nextMonth" />
    </v-card-title>

    <v-card-text>
      <v-skeleton-loader v-if="loadingSummary" type="text,text" />
      <v-row v-else class="mb-4">
        <v-col cols="6" class="text-center">
          <div class="text-caption text-medium-emphasis">VÝDAJE</div>
          <div class="text-h6 text-error">{{ summary?.totalSpent.toLocaleString('cs-CZ') }} Kč</div>
        </v-col>
        <v-col cols="6" class="text-center">
          <div class="text-caption text-medium-emphasis">PŘÍJMY</div>
          <div class="text-h6 text-success">{{ summary?.totalIncome.toLocaleString('cs-CZ') }} Kč</div>
        </v-col>
      </v-row>

      <v-row>
        <v-col cols="12" md="5">
          <div class="text-caption text-medium-emphasis mb-1">VÝDAJE DLE KATEGORIÍ</div>
          <DonutChart :data="categories" :loading="loadingCategories" />
        </v-col>
        <v-col cols="12" md="7">
          <div class="text-caption text-medium-emphasis mb-1">KUMULATIVNÍ VÝDAJE</div>
          <CumulativeSpendingChart :data="cumulative" :loading="loadingCumulative" />
        </v-col>
      </v-row>
    </v-card-text>
  </v-card>
</template>
  • Step 8: Create DashboardView

src/AccountTracking.Web/src/views/DashboardView.vue:

<script setup lang="ts">
import { useAuthStore } from '../stores/auth'
import { useRouter } from 'vue-router'
import YearSummary from '../components/YearSummary.vue'
import MonthSummary from '../components/MonthSummary.vue'
import UploadCsvButton from '../components/UploadCsvButton.vue'

const auth = useAuthStore()
const router = useRouter()

function logout() {
  auth.logout()
  router.push('/login')
}
</script>

<template>
  <v-app-bar>
    <v-app-bar-title>Finance Tracker</v-app-bar-title>
    <template #append>
      <UploadCsvButton class="mr-2" />
      <v-btn icon="mdi-logout" variant="text" @click="logout" />
    </template>
  </v-app-bar>

  <v-main>
    <v-container fluid class="pa-4">
      <v-row>
        <v-col cols="12" md="6">
          <YearSummary />
        </v-col>
        <v-col cols="12" md="6">
          <MonthSummary />
        </v-col>
      </v-row>
    </v-container>
  </v-main>
</template>
  • Step 9: Build and verify
cd src/AccountTracking.Web && npm run build

Expected: build succeeds with no type errors.

  • Step 10: Commit
cd /mnt/c/Repos/Personal/account-tracking
git add src/AccountTracking.Web/src/
git commit -m "feat: dashboard page with year/month summary, charts and CSV upload"

Task 14: Transactions Page

Files:

  • Replace: src/AccountTracking.Web/src/views/TransactionsView.vue

  • Create: src/AccountTracking.Web/src/stores/txPeriod.ts

  • Step 1: Create txPeriod store

src/AccountTracking.Web/src/stores/txPeriod.ts:

import { defineStore } from 'pinia'
import { ref } from 'vue'

export const useTxPeriodStore = defineStore('txPeriod', () => {
  const now = new Date()
  const year = ref(now.getFullYear())
  const month = ref<number | null>(null)

  return { year, month }
})
  • Step 2: Create TransactionsView

src/AccountTracking.Web/src/views/TransactionsView.vue:

<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useRouter } from 'vue-router'
import { transactionsApi } from '../api'
import { useAuthStore } from '../stores/auth'
import { useTxPeriodStore } from '../stores/txPeriod'
import type { TransactionDto } from '../api/apiClient'

const router = useRouter()
const auth = useAuthStore()
const period = useTxPeriodStore()

const items = ref<TransactionDto[]>([])
const totalCount = ref(0)
const page = ref(1)
const loading = ref(false)
const categories = ref<string[]>([])
const selectedCategory = ref<string | null>(null)
const search = ref('')
let searchTimeout: ReturnType<typeof setTimeout> | null = null

const MONTHS = [
  { title: 'Vše', value: null },
  ...Array.from({ length: 12 }, (_, i) => ({
    title: new Date(2000, i, 1).toLocaleDateString('cs-CZ', { month: 'long' }),
    value: i + 1
  }))
]

const YEARS = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)

const headers = [
  { title: 'Datum', key: 'bookingDate', sortable: false },
  { title: 'Protiúčet / Zpráva', key: 'counterPartyName', sortable: false },
  { title: 'Kategorie', key: 'category', sortable: false },
  { title: 'Částka', key: 'amount', sortable: false, align: 'end' as const },
  { title: 'Zůstatek', key: 'balance', sortable: false, align: 'end' as const },
]

async function loadCategories() {
  categories.value = await transactionsApi.categories()
}

async function loadTransactions() {
  loading.value = true
  try {
    const result = await transactionsApi.list(
      period.year,
      period.month ?? undefined,
      selectedCategory.value ?? undefined,
      search.value || undefined,
      page.value,
      50
    )
    items.value = result.items ?? []
    totalCount.value = result.totalCount ?? 0
  } finally {
    loading.value = false
  }
}

function onSearchInput() {
  if (searchTimeout) clearTimeout(searchTimeout)
  searchTimeout = setTimeout(() => { page.value = 1; loadTransactions() }, 300)
}

watch([() => period.year, () => period.month, selectedCategory, page], loadTransactions)

loadCategories()
loadTransactions()

function logout() {
  auth.logout()
  router.push('/login')
}

function formatAmount(val: number) {
  return `${val.toLocaleString('cs-CZ')} Kč`
}
</script>

<template>
  <v-app-bar>
    <v-app-bar-title>Finance Tracker  Transakce</v-app-bar-title>
    <template #append>
      <v-btn to="/" variant="text" class="mr-2">Dashboard</v-btn>
      <v-btn icon="mdi-logout" variant="text" @click="logout" />
    </template>
  </v-app-bar>

  <v-main>
    <v-container fluid class="pa-4">
      <v-row class="mb-2">
        <v-col cols="6" md="2">
          <v-select
            v-model="period.year"
            :items="YEARS"
            label="Rok"
            density="compact"
          />
        </v-col>
        <v-col cols="6" md="2">
          <v-select
            v-model="period.month"
            :items="MONTHS"
            item-title="title"
            item-value="value"
            label="Měsíc"
            density="compact"
          />
        </v-col>
        <v-col cols="12" md="3">
          <v-select
            v-model="selectedCategory"
            :items="[{ title: 'Vše', value: null }, ...categories.map(c => ({ title: c, value: c }))]"
            item-title="title"
            item-value="value"
            label="Kategorie"
            density="compact"
            clearable
          />
        </v-col>
        <v-col cols="12" md="5">
          <v-text-field
            v-model="search"
            label="Hledat"
            prepend-inner-icon="mdi-magnify"
            density="compact"
            clearable
            @input="onSearchInput"
          />
        </v-col>
      </v-row>

      <v-data-table-server
        :headers="headers"
        :items="items"
        :items-length="totalCount"
        :loading="loading"
        :items-per-page="50"
        v-model:page="page"
        @update:options="loadTransactions"
      >
        <template #item.bookingDate="{ item }">
          {{ item.bookingDate }}
        </template>
        <template #item.counterPartyName="{ item }">
          <div>{{ item.counterPartyName }}</div>
          <div class="text-caption text-medium-emphasis">{{ item.message }}</div>
        </template>
        <template #item.amount="{ item }">
          <span :class="item.amount < 0 ? 'text-error' : 'text-success'">
            {{ formatAmount(item.amount) }}
          </span>
        </template>
        <template #item.balance="{ item }">
          {{ formatAmount(item.balance) }}
        </template>
      </v-data-table-server>
    </v-container>
  </v-main>
</template>
  • Step 3: Add nav link from Dashboard to Transactions

In DashboardView.vue, add a navigation button next to logout:

<!-- in the <template #append> of v-app-bar -->
<v-btn to="/transactions" variant="text" class="mr-2">Transakce</v-btn>
  • Step 4: Build and verify
cd src/AccountTracking.Web && npm run build

Expected: build succeeds.

  • Step 5: Commit
cd /mnt/c/Repos/Personal/account-tracking
git add src/AccountTracking.Web/src/views/TransactionsView.vue \
        src/AccountTracking.Web/src/stores/txPeriod.ts \
        src/AccountTracking.Web/src/views/DashboardView.vue
git commit -m "feat: transactions page with server-side filtering and pagination"

Task 15: Docker & Deployment

Files:

  • Create: src/AccountTracking.Api/Dockerfile

  • Create: src/AccountTracking.Web/Dockerfile

  • Create: src/AccountTracking.Web/nginx.conf

  • Create: docker-compose.yml

  • Create: .env.example

  • Step 1: Create API Dockerfile

src/AccountTracking.Api/Dockerfile:

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY AccountTracking.Api.csproj .
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 5000
ENV ASPNETCORE_URLS=http://+:5000
ENTRYPOINT ["dotnet", "AccountTracking.Api.dll"]
  • Step 2: Create web Nginx config

src/AccountTracking.Web/nginx.conf:

server {
    listen 3000;
    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ /index.html;
    }
}
  • Step 3: Create web Dockerfile

src/AccountTracking.Web/Dockerfile:

# Stage 1: build .NET API to get openapi.json
# Build context is repo root (set in docker-compose.yml), so paths are relative to repo root
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS api-build
WORKDIR /src
COPY src/AccountTracking.Api/AccountTracking.Api.csproj ./AccountTracking.Api/
RUN dotnet restore AccountTracking.Api/AccountTracking.Api.csproj
COPY src/AccountTracking.Api/ ./AccountTracking.Api/
RUN dotnet build AccountTracking.Api/AccountTracking.Api.csproj -c Release

# Stage 2: build Vue frontend with NSwag client
FROM node:20-alpine AS web-build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY --from=api-build /src/AccountTracking.Api/openapi.json ./openapi.json
COPY . .
RUN npx nswag run nswag.json
RUN npm run build

# Stage 3: serve with Nginx
FROM nginx:alpine
COPY --from=web-build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

Note: the web Dockerfile is built from the repo root (so it can COPY from the sibling Api project). The docker-compose.yml sets context accordingly.

  • Step 4: Create docker-compose.yml

docker-compose.yml:

version: '3.8'

services:
  db:
    image: mysql:8
    environment:
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
      MYSQL_DATABASE: accounttracking
    volumes:
      - db_data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s
    restart: unless-stopped

  api:
    build:
      context: src/AccountTracking.Api
      dockerfile: Dockerfile
    environment:
      DB_CONNECTION: "Server=db;Database=accounttracking;User=root;Password=${DB_ROOT_PASSWORD};"
      JWT_SECRET: ${JWT_SECRET}
      APP_USERNAME: ${APP_USERNAME}
      APP_PASSWORD: ${APP_PASSWORD}
      ALLOWED_ORIGIN: ${ALLOWED_ORIGIN}
    ports:
      - "5000:5000"
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  web:
    build:
      context: .
      dockerfile: src/AccountTracking.Web/Dockerfile
    ports:
      - "3000:3000"
    restart: unless-stopped

volumes:
  db_data:
  • Step 5: Create .env.example

.env.example:

# MySQL root password (used by both db and api services)
DB_ROOT_PASSWORD=changeme

# JWT signing secret: generate with: openssl rand -base64 32
JWT_SECRET=CHANGE_ME_base64_encoded_32_bytes

# Single-user login credentials
APP_USERNAME=admin
# bcrypt hash of your password — generate with: htpasswd -bnBC 10 "" yourpassword | tr -d ':'
APP_PASSWORD=$2b$10$CHANGE_ME_bcrypt_hash

# Frontend origin for CORS (your home server URL)
ALLOWED_ORIGIN=http://finance.home
  • Step 6: Create actual .env from the example
cp .env.example .env
# Edit .env with your real values:
# 1. Generate JWT_SECRET:  openssl rand -base64 32
# 2. Generate APP_PASSWORD bcrypt hash (e.g. with a small Node script or Python):
#    node -e "const b=require('bcryptjs');console.log(b.hashSync('yourpassword',10))"
  • Step 7: Build and start all services
docker compose build
docker compose up -d
docker compose logs -f api

Expected: API logs show "Now listening on: http://[::]:5000" after migrations run.

  • Step 8: Verify the app works end-to-end
# Test login
curl -s -X POST http://localhost:5000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"yourpassword"}' | jq .

Expected: {"token":"eyJ...","expiresAt":"..."}.

Open http://localhost:3000 in a browser — should show the login page.

  • Step 9: Add .env to .gitignore (if not already)
grep -q '^\.env$' .gitignore || echo '.env' >> .gitignore
  • Step 10: Commit
git add src/AccountTracking.Api/Dockerfile \
        src/AccountTracking.Web/Dockerfile \
        src/AccountTracking.Web/nginx.conf \
        docker-compose.yml \
        .env.example \
        .gitignore
git commit -m "feat: Docker build and docker-compose deployment config"

Summary

Task Deliverable
1 .NET solution, projects, NuGet packages
2 EF Core entities and DTOs
3 Program.cs with auth, CORS, DB, Polly
4 JWT auth endpoint
5 CSV import service (TDD — 8 tests)
6 Transactions REST API
7 Dashboard service + controller (TDD — 9 tests)
8 EF Core migrations
9 NSwag OpenAPI spec generation
10 Vue 3 + Vuetify frontend scaffold
11 NSwag TypeScript client + authenticated wrapper
12 Auth store, router guard, login page
13 Dashboard page (period store, year/month columns, charts, CSV upload)
14 Transactions page
15 Dockerfiles, docker-compose, .env