Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2c6a6f4
feat(ci): remove push trigger from CI workflow for main branch
MatthewHsu1 Jul 15, 2026
7d5704f
feat: enhance CI workflow with formatting and linting checks; add Pre…
MatthewHsu1 Jul 15, 2026
3142f6b
feat: add date and number utilities with tests, implement phone forma…
MatthewHsu1 Jul 15, 2026
369a9cf
feat: implement grid components in web project
MatthewHsu1 Jul 17, 2026
30db729
fix: update comment for clarity in radixTheme configuration
MatthewHsu1 Jul 17, 2026
0c2790f
feat: enable strict type checking in TypeScript configuration for app…
MatthewHsu1 Jul 17, 2026
26f0350
docs: enhance comments for GridGrouping interface to clarify grouping…
MatthewHsu1 Jul 17, 2026
4514a34
refactor: move SoftBadge component to a new file and update imports
MatthewHsu1 Jul 17, 2026
f930122
feat: implement read-only cell behavior with em dash for empty values…
MatthewHsu1 Jul 20, 2026
105dacb
feat: implement bonds feature with routing, data source, and grid int…
MatthewHsu1 Jul 20, 2026
b3d124e
feat: migrate to @tanstack/react-router and restructure routing
MatthewHsu1 Jul 21, 2026
908b805
feat: implement app-wide listener middleware and integrate with grid …
MatthewHsu1 Jul 21, 2026
127ac7b
feat: enhance authentication and bond endpoints with typed results an…
MatthewHsu1 Jul 22, 2026
1eec277
feat: update core logic of canvas grid
MatthewHsu1 Aug 11, 2026
6820f57
Merge branch 'main' into feat/web-scaffold
MatthewHsu1 Aug 11, 2026
0d02447
feat: update .prettierignore to include mockServiceWorker.js
MatthewHsu1 Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

Expand Down Expand Up @@ -50,12 +48,28 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Check formatting
run: npm run format:check

- name: Lint
run: npm run lint

- name: Verify registry signatures
run: npm audit signatures

- name: Run tests
run: npm test

- name: Build
run: npm run build

# src/routeTree.gen.ts is generated but committed, and carries
# @ts-nocheck — so a deleted route file with a stale tree still type
# checks. The build above regenerates it; this fails if that produced
# a diff, which is the only signal that the committed tree is stale.
- name: Generated route tree is up to date
run: git diff --exit-code -- src/routeTree.gen.ts

# Builds the MAUI host (src/AppName.Maui) per target platform. Each platform's
# SDK is only available on certain OSes: iOS and MacCatalyst require Xcode on a
# macOS runner, Windows (WinUI) requires the Windows SDK on a windows runner, and
Expand Down
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ These rules apply **only to the C# project** (not the React/web code).
## C# unit testing

- When writing, reviewing, or refactoring **C# unit tests**, follow [docs/TestingConventionGuidance.md](docs/TestingConventionGuidance.md) — it is the authoritative guidance for this solution's xUnit + Moq conventions (the nested `Fixture` pattern, when to mock vs. use a real/hand-written double, test naming, and file placement). Read it before adding or changing tests.


## Typescript Styling
Format the code for human readability after implementing it. Group related statements into logical sections separated by a single blank line. Prefer multi-line if statements over one-line returns, and optimize for maintainability rather than minimal diff.
35 changes: 0 additions & 35 deletions justfile
Original file line number Diff line number Diff line change
@@ -1,10 +1,5 @@
# justfile — reusable dev commands for the MAUI + React solution.
# Install `just`: `cargo install just`, or grab a binary from https://github.com/casey/just/releases
# Run `just` (or `just --list`) to see all recipes.

set shell := ["bash", "-uc"]

# Host has memory pressure: dotnet must run single-proc with the MSBuild server/node-reuse off.
export DOTNET_CLI_USE_MSBUILD_SERVER := "0"
export MSBUILDDISABLENODEREUSE := "1"

Expand Down Expand Up @@ -74,33 +69,3 @@ migrate-update:
# List migrations and which are applied (`--no-connect` to skip the database round-trip).
migrate-list:
dotnet ef migrations list --project src/AppName.Infrastructure --startup-project src/AppName.Infrastructure

# ---- Docker (Android) ---------------------------------------------------------
# Builds the Android head inside a container that carries the Android SDK +
# `maui-android` workload, so the host never needs the Android SDK/Java
# installed. The repo is bind-mounted in so build outputs (and the APK) land
# in the normal working tree. See docker/README.md for details/caveats.

# Build the docker image (slow first time; several GB). Re-run after editing docker/android.Dockerfile.
docker-android-image:
docker build -t appname-maui-android -f docker/android.Dockerfile .

# Build the Android head inside the container; output lands in src/AppName.Maui/bin/Release/net10.0-android/.
docker-build-android:
docker run --rm -v "$PWD":/src -w /src appname-maui-android dotnet build src/AppName.Maui/AppName.Maui.csproj -f net10.0-android -c Release

# Open an interactive shell in the container (SDK + Android tooling + workload available).
docker-shell:
docker run --rm -it -v "$PWD":/src -w /src appname-maui-android bash

# ---- Housekeeping ------------------------------------------------------------

# Restore NuGet + npm dependencies.
restore:
dotnet restore -m:1
cd src/web && npm install

# Remove build outputs.
clean:
dotnet clean -m:1
rm -rf src/web/node_modules/.vite src/AppName.Maui/Resources/Raw/web
7 changes: 4 additions & 3 deletions src/AppName.Api/Endpoints/AuthEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Security.Claims;
using System.Text.Json.Serialization;
using AppName.Api.Authentication;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.IdentityModel.Tokens;

namespace AppName.Api.Endpoints;
Expand All @@ -20,7 +21,7 @@ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
// and MAUI clients can obtain a bearer token while developing locally.
// This must be replaced by a real identity provider (e.g. OIDC/Entra ID)
// before any non-local deployment — there is no credential check here.
group.MapPost("/auth/token", (IConfiguration config) =>
group.MapPost("/auth/token", Ok<TokenResponse> (IConfiguration config) =>
{
var jwt = JwtSettings.FromConfiguration(config);

Expand All @@ -45,7 +46,7 @@ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)

var accessToken = new JwtSecurityTokenHandler().WriteToken(token);

return Results.Ok(new TokenResponse(accessToken, (int)TimeSpan.FromHours(1).TotalSeconds));
return TypedResults.Ok(new TokenResponse(accessToken, (int)TimeSpan.FromHours(1).TotalSeconds));
})
.AllowAnonymous()
.WithName("MintDevToken");
Expand All @@ -59,7 +60,7 @@ public static RouteGroupBuilder MapAuthEndpoints(this RouteGroupBuilder group)
/// </summary>
/// <param name="AccessToken">The minted bearer token.</param>
/// <param name="ExpiresIn">Token lifetime in seconds.</param>
private sealed record TokenResponse(
internal sealed record TokenResponse(
[property: JsonPropertyName("access_token")] string AccessToken,
[property: JsonPropertyName("expires_in")] int ExpiresIn);
}
18 changes: 10 additions & 8 deletions src/AppName.Api/Endpoints/BondEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using AppName.Application.Dtos;
using AppName.Application.UseCases.Bonds;
using Microsoft.AspNetCore.Http.HttpResults;

namespace AppName.Api.Endpoints;

Expand All @@ -13,34 +15,34 @@ public static class BondEndpoints
/// </summary>
public static RouteGroupBuilder MapBondEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/valuations", async (GetValuationsUseCase useCase, CancellationToken ct) =>
group.MapGet("/valuations", async Task<Ok<IReadOnlyList<ConversionValuationDto>>> (GetValuationsUseCase useCase, CancellationToken ct) =>
{
var valuations = await useCase.ExecuteAsync(ct);
return Results.Ok(valuations);
return TypedResults.Ok(valuations);
})
.RequireAuthorization()
.WithName("GetValuations");

group.MapGet("/valuations/{symbol}", async (string symbol, GetConversionValuationUseCase useCase, CancellationToken ct) =>
group.MapGet("/valuations/{symbol}", async Task<Results<Ok<ConversionValuationDto>, NotFound>> (string symbol, GetConversionValuationUseCase useCase, CancellationToken ct) =>
{
var valuation = await useCase.ExecuteAsync(symbol, ct);
return valuation is null ? Results.NotFound() : Results.Ok(valuation);
return valuation is null ? TypedResults.NotFound() : TypedResults.Ok(valuation);
})
.RequireAuthorization()
.WithName("GetValuationBySymbol");

group.MapPost("/valuations/refresh", async (IRefreshAllBondsUseCase useCase, CancellationToken ct) =>
group.MapPost("/valuations/refresh", async Task<Accepted> (IRefreshAllBondsUseCase useCase, CancellationToken ct) =>
{
await useCase.ExecuteAsync(ct);
return Results.Accepted();
return TypedResults.Accepted((string?)null);
})
.RequireAuthorization()
.WithName("RefreshAllValuations");

group.MapPost("/bonds/{symbol}/refresh", async (string symbol, RefreshBondDataUseCase useCase, CancellationToken ct) =>
group.MapPost("/bonds/{symbol}/refresh", async Task<Accepted> (string symbol, RefreshBondDataUseCase useCase, CancellationToken ct) =>
{
await useCase.ExecuteAsync(symbol, ct);
return Results.Accepted();
return TypedResults.Accepted((string?)null);
})
.RequireAuthorization()
.WithName("RefreshBondData");
Expand Down
40 changes: 40 additions & 0 deletions src/AppName.Api/OpenApi/DecimalSchemaTransformer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;

namespace AppName.Api.OpenApi;

/// <summary>
/// Narrows the OpenAPI schema for <see cref="decimal"/> and <see cref="Nullable{T}"/> of
/// <see cref="decimal"/> from "number or string" down to "number" (or "number or null").
/// By default, .NET's OpenAPI document generator describes every <c>decimal</c> as a
/// union of <c>number</c> and <c>string</c> plus a validation <c>pattern</c>, because a
/// decimal-typed property can in principle be serialized either way depending on
/// <c>JsonSerializerOptions</c>. This API never sets
/// <see cref="System.Text.Json.Serialization.JsonNumberHandling.WriteAsString"/>, so
/// <c>System.Text.Json</c> always emits decimals as plain JSON numbers. Leaving the
/// string member in the schema is not defensive, it is inaccurate, and it forces
/// generated TypeScript clients to add a <c>number | string</c> coercion layer for values
/// that can never actually arrive as strings. Do not remove this transformer as dead code:
/// removing it silently reintroduces that phantom string arm into every generated client.
/// </summary>
public sealed class DecimalSchemaTransformer : IOpenApiSchemaTransformer
{
/// <inheritdoc/>
public Task TransformAsync(OpenApiSchema schema, OpenApiSchemaTransformerContext context, CancellationToken cancellationToken)
{
var type = context.JsonTypeInfo.Type;

if (type != typeof(decimal) && type != typeof(decimal?))
{
return Task.CompletedTask;
}

schema.Type = type == typeof(decimal?)
? JsonSchemaType.Number | JsonSchemaType.Null
: JsonSchemaType.Number;

schema.Pattern = null;

return Task.CompletedTask;
}
}
6 changes: 5 additions & 1 deletion src/AppName.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using AppName.Api.Authentication;
using AppName.Api.Cors;
using AppName.Api.Endpoints;
using AppName.Api.OpenApi;
using AppName.Application;
using AppName.Infrastructure;
using Scalar.AspNetCore;
Expand All @@ -14,7 +15,10 @@

// --- OpenAPI -----------------------------------------------------------

builder.Services.AddOpenApi();
builder.Services.AddOpenApi(options =>
{
options.AddSchemaTransformer<DecimalSchemaTransformer>();
});

// --- Auth (JWT bearer, dev stub) ----------------------------------------

Expand Down
1 change: 1 addition & 0 deletions src/web/.env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:5000
4 changes: 4 additions & 0 deletions src/web/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ dist
dist-ssr
*.local

# @tanstack/router-generator writes temp files here before renaming
# src/routeTree.gen.ts into place; a killed build leaves them behind.
.tanstack/

# Editor directories and files
.vscode/*
!.vscode/extensions.json
Expand Down
24 changes: 24 additions & 0 deletions src/web/.npmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Supply chain: only install versions published more than 14 days ago.
# Malicious npm releases are typically detected and unpublished within hours to
# a few days, so a cooldown means we never resolve one. This governs lockfile
# construction only — `npm ci` replays the lockfile and is unaffected.
# Override consciously for a genuine emergency: npm install --min-release-age=0 <pkg>
min-release-age=14

# No package in this tree needs an install script (fsevents is macOS-only and
# optional). Lifecycle hooks are the main supply-chain execution vector, so
# block them. Revisit if a dependency ever legitimately needs one.
ignore-scripts=true

# openapi-typescript declares peerDependencies.typescript: ^5.x, but this repo
# pins typescript to ~6.0.2. That mismatch makes a plain `npm install` fail
# with ERESOLVE. Do NOT reach for --legacy-peer-deps to get past it: that flag
# disables peer resolution for the whole tree, not just this one conflict, and
# it silently prunes other packages' peer deps that legitimately need
# resolving (that is how @testing-library/dom and friends went missing here).
# Instead, package.json carries a scoped "overrides" entry that pins
# openapi-typescript's own view of typescript to the repo's version:
# "overrides": { "openapi-typescript": { "typescript": "$typescript" } }
# That satisfies npm's resolver for just this one edge without touching peer
# resolution anywhere else. Remove the override if openapi-typescript ships a
# release with a typescript@6 peer range.
26 changes: 24 additions & 2 deletions src/web/.oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,30 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"ignorePatterns": ["src/routeTree.gen.ts", "src/api/schema.gen.ts"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
"react/only-export-components": ["warn", { "allowConstantExport": true }],
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "react-router",
"message": "Routing is TanStack Router — import from @tanstack/react-router."
},
{
"name": "react-router-dom",
"message": "Routing is TanStack Router — import from @tanstack/react-router."
}
]
}
]
},
"overrides": [
{
"files": ["src/routes/**"],
"rules": { "react/only-export-components": "off" }
}
]
}
18 changes: 18 additions & 0 deletions src/web/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
dist
node_modules
package-lock.json

# Agent tooling scratch, not source. These are created in whatever directory a
# sandboxed command runs in, and .mcp.json is a /dev/null bind-mount that
# prettier cannot read (EACCES). Never present in a clean CI checkout.
.claude
.mcp.json

# Generated by @tanstack/router-plugin from src/routes/.
src/routeTree.gen.ts

# Generated by openapi-typescript from the API's OpenAPI document.
src/api/schema.gen.ts

# Generated by `msw init public/`.
public/mockServiceWorker.js
5 changes: 5 additions & 0 deletions src/web/.prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"semi": true,
"singleQuote": false,
"printWidth": 100
}
7 changes: 7 additions & 0 deletions src/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,12 @@
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<!--
glide-data-grid renders every cell overlay editor into this element, and it
looks it up by id. Without it the grid logs "Cannot open Data Grid overlay
editor, because portal not found" and NO cell can be edited. It must stay
the last child of <body>, after #root, so an editor draws above the grid.
-->
<div id="portal" style="position: fixed; left: 0; top: 0; z-index: 9999"></div>
</body>
</html>
Loading
Loading