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
.csprojfor 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') }} Kč</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 |