Compare commits

...

6 Commits

Author SHA1 Message Date
65a13539e0 Add VS Code workspace settings
- Configure auto-approve for git commit in chat tools
- Set default solution file
2026-02-04 21:29:04 +01:00
fd8395cc48 Update configuration for deployment
- Change API base address to external IP for development
- Update launch settings to bind to any host interface
- Remove commented CORS policy code
2026-02-04 21:29:00 +01:00
8f2284e1fc Remove unused Blazor pages
- Delete Counter.razor and Weather.razor as they are not needed for the application
2026-02-04 21:28:54 +01:00
5db6fee866 Refactor Blazor components to use code-behind files
- Move @code blocks from Exercises.razor, Home.razor, and Routines.razor to separate .cs files
- Add XML documentation comments to all methods in the code-behind files
2026-02-04 21:28:44 +01:00
01581b7a91 Add Docker build/push tooling and Traefik compose 2026-02-01 10:29:14 +01:00
56aacb0134 Add sample data toggle to user provisioning 2026-02-01 10:29:00 +01:00
23 changed files with 618 additions and 416 deletions

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"chat.tools.terminal.autoApprove": {
"git commit": true
},
"dotnet.defaultSolution": "ASTRAIN.slnx"
}

4
.vscode/tasks.json vendored
View File

@@ -24,11 +24,11 @@
"group": "build"
},
{
"label": "Build Docker Image",
"label": "Build & Push Docker",
"type": "shell",
"command": "${workspaceFolder}\\.venv\\Scripts\\python.exe",
"args": [
"${workspaceFolder}/docker/build_image.py"
"${workspaceFolder}/docker/build_and_push_image.py"
],
"problemMatcher": []
}

View File

@@ -0,0 +1,15 @@
services:
astrain:
image: git.beging.de/troogs/astrain:latest
restart: unless-stopped
container_name: astrain
networks:
- proxy
labels:
- traefik.enable=true
- traefik.http.routers.fs-onboarding-si.rule=Host(`astrain.melvin.beging.de`)
- traefik.http.services.fs-onboarding-si.loadbalancer.server.port=8080
networks:
proxy:
external: true

View File

@@ -0,0 +1,26 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
def main() -> int:
script_dir = Path(__file__).resolve().parent
scripts = ["build_image.py", "push_image.py"]
for script in scripts:
script_path = script_dir / script
print(f"Executing {script}...")
try:
subprocess.run([sys.executable, str(script_path)], check=True)
print(f"{script} executed successfully.\n")
except subprocess.CalledProcessError as error:
print(f"Error: {script} failed with exit code {error.returncode}.")
return error.returncode
return 0
if __name__ == "__main__":
raise SystemExit(main())

57
docker/push_image.py Normal file
View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import datetime as dt
import subprocess
from pathlib import Path
def get_timestamp_tag(today: dt.date) -> str:
year_suffix = today.year % 100
day_of_year = today.timetuple().tm_yday
return f"{year_suffix}.{day_of_year}"
def run_command(command: list[str], repo_root: Path) -> int:
print(" ".join(command))
result = subprocess.run(command, cwd=str(repo_root))
return result.returncode
def main() -> int:
repo_root = Path(__file__).resolve().parents[1]
today = dt.date.today()
timestamp_tag = get_timestamp_tag(today)
local_image = "troogs/astrain"
registry_image = "git.beging.de/troogs/astrain"
tags = ["latest", timestamp_tag]
for tag in tags:
tag_cmd = [
"docker",
"tag",
f"{local_image}:{tag}",
f"{registry_image}:{tag}",
]
print(f"Tagging {local_image}:{tag} as {registry_image}:{tag}")
exit_code = run_command(tag_cmd, repo_root)
if exit_code != 0:
return exit_code
for tag in tags:
push_cmd = [
"docker",
"push",
f"{registry_image}:{tag}",
]
print(f"Pushing {registry_image}:{tag}")
exit_code = run_command(push_cmd, repo_root)
if exit_code != 0:
return exit_code
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace ASTRAIN.Api.Endpoints;
@@ -22,9 +23,10 @@ internal static class ExerciseEndpoints
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapExerciseEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/{userId}/exercises", async (string userId, AppDbContext db) =>
group.MapGet("/users/{userId}/exercises", async (string userId, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var items = await db.Exercises
.Where(e => e.UserId == user.Id)
.OrderBy(e => e.Name)
@@ -35,9 +37,10 @@ internal static class ExerciseEndpoints
.WithSummary("List exercises")
.WithDescription("Returns the exercises for the specified user.");
group.MapPost("/users/{userId}/exercises", async (string userId, ExerciseUpsertRequest request, AppDbContext db) =>
group.MapPost("/users/{userId}/exercises", async (string userId, ExerciseUpsertRequest request, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
@@ -57,9 +60,10 @@ internal static class ExerciseEndpoints
.WithSummary("Create exercise")
.WithDescription("Creates a new exercise for the specified user.");
group.MapPut("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, ExerciseUpsertRequest request, AppDbContext db) =>
group.MapPut("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, ExerciseUpsertRequest request, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id);
if (exercise is null)
{
@@ -79,9 +83,10 @@ internal static class ExerciseEndpoints
.WithSummary("Update exercise")
.WithDescription("Updates the name of an exercise for the specified user.");
group.MapDelete("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, AppDbContext db) =>
group.MapDelete("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id);
if (exercise is null)
{

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace ASTRAIN.Api.Endpoints;
@@ -22,9 +23,10 @@ internal static class RoutineEndpoints
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapRoutineEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/{userId}/routines", async (string userId, AppDbContext db) =>
group.MapGet("/users/{userId}/routines", async (string userId, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var routines = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
@@ -46,9 +48,10 @@ internal static class RoutineEndpoints
.WithSummary("List routines")
.WithDescription("Returns all routines for the specified user.");
group.MapGet("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db) =>
group.MapGet("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var routine = await db.Routines
.Include(r => r.Exercises)
.ThenInclude(re => re.Exercise)
@@ -72,9 +75,10 @@ internal static class RoutineEndpoints
.WithSummary("Get routine")
.WithDescription("Returns a specific routine and its exercises.");
group.MapPost("/users/{userId}/routines", async (string userId, RoutineUpsertRequest request, AppDbContext db) =>
group.MapPost("/users/{userId}/routines", async (string userId, RoutineUpsertRequest request, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
if (string.IsNullOrWhiteSpace(request.Name))
{
return Results.BadRequest("Name is required.");
@@ -113,9 +117,10 @@ internal static class RoutineEndpoints
.WithSummary("Create routine")
.WithDescription("Creates a routine and associates exercises with it.");
group.MapPut("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, RoutineUpsertRequest request, AppDbContext db) =>
group.MapPut("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, RoutineUpsertRequest request, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var routine = await db.Routines
.Include(r => r.Exercises)
.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
@@ -160,9 +165,10 @@ internal static class RoutineEndpoints
.WithSummary("Update routine")
.WithDescription("Updates routine metadata and exercise ordering.");
group.MapDelete("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db) =>
group.MapDelete("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{

View File

@@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace ASTRAIN.Api.Endpoints;
@@ -22,9 +23,10 @@ internal static class RoutineRunEndpoints
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapRoutineRunEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/{userId}/routines/{routineId:int}/last-run", async (string userId, int routineId, AppDbContext db) =>
group.MapGet("/users/{userId}/routines/{routineId:int}/last-run", async (string userId, int routineId, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var lastRun = await db.RoutineRuns
.Include(rr => rr.Entries)
.Where(rr => rr.UserId == user.Id && rr.RoutineId == routineId)
@@ -47,9 +49,10 @@ internal static class RoutineRunEndpoints
.WithSummary("Get last routine run")
.WithDescription("Returns the most recent run summary for a routine.");
group.MapPost("/users/{userId}/routines/{routineId:int}/runs", async (string userId, int routineId, RoutineRunRequest request, AppDbContext db) =>
group.MapPost("/users/{userId}/routines/{routineId:int}/runs", async (string userId, int routineId, RoutineRunRequest request, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id);
if (routine is null)
{

View File

@@ -4,6 +4,7 @@ using ASTRAIN.Shared.Responses;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Configuration;
namespace ASTRAIN.Api.Endpoints;
@@ -19,17 +20,19 @@ internal static class UserEndpoints
/// <returns>The same route group for chaining.</returns>
public static RouteGroupBuilder MapUserEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/users/ensure", async (string? userId, AppDbContext db) =>
group.MapGet("/users/ensure", async (string? userId, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
return Results.Ok(new EnsureUserResponse(user.Id));
})
.WithSummary("Ensure user")
.WithDescription("Ensures a user exists and returns the user id.");
group.MapGet("/users/{userId}", async (string userId, AppDbContext db) =>
group.MapGet("/users/{userId}", async (string userId, AppDbContext db, IConfiguration config) =>
{
var user = await UserProvisioning.EnsureUserAsync(db, userId);
var populateSampleData = config.GetValue("SampleData:Enabled", false);
var user = await UserProvisioning.EnsureUserAsync(db, userId, populateSampleData);
return Results.Ok(new EnsureUserResponse(user.Id));
})
.WithSummary("Get user")

View File

@@ -15,10 +15,6 @@ builder.Services.AddCors(options =>
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod();
// policy.WithOrigins("http://localhost:5016", "http://:5016", "https://localhost:7252")
// .AllowAnyHeader()
// .AllowAnyMethod();
});
});
builder.Services.AddDbContext<AppDbContext>(options =>

View File

@@ -14,7 +14,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7049;http://localhost:5055",
"applicationUrl": "https://+:7049;http://+:5055",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View File

@@ -15,8 +15,9 @@ internal static class UserProvisioning
/// </summary>
/// <param name="db">The application database context.</param>
/// <param name="userId">Optional user id to validate or create.</param>
/// <param name="populateSampleData">Whether to populate sample data for new users.</param>
/// <returns>The existing or newly created user.</returns>
public static async Task<User> EnsureUserAsync(AppDbContext db, string? userId)
public static async Task<User> EnsureUserAsync(AppDbContext db, string? userId, bool populateSampleData)
{
if (!string.IsNullOrWhiteSpace(userId) && IsValidUserId(userId))
{
@@ -29,7 +30,10 @@ internal static class UserProvisioning
var created = new User { Id = userId };
db.Users.Add(created);
await db.SaveChangesAsync();
await PopulateSampleDataAsync(db, created);
if (populateSampleData)
{
await PopulateSampleDataAsync(db, created);
}
return created;
}
@@ -45,7 +49,10 @@ internal static class UserProvisioning
var user = new User { Id = newId };
db.Users.Add(user);
await db.SaveChangesAsync();
await PopulateSampleDataAsync(db, user);
if (populateSampleData)
{
await PopulateSampleDataAsync(db, user);
}
return user;
}
}

View File

@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"SampleData": {
"Enabled": false
}
}

View File

@@ -8,5 +8,8 @@
"ConnectionStrings": {
"Default": "Data Source=Data/astrain.db"
},
"SampleData": {
"Enabled": false
},
"AllowedHosts": "*"
}

View File

@@ -1,18 +0,0 @@
@page "/counter"
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}

View File

@@ -65,102 +65,3 @@
</section>
</div>
@code {
[Parameter]
public string? UserId { get; set; }
private List<ExerciseDto> ExerciseList { get; set; } = new();
private bool IsLoading { get; set; } = true;
private bool ShowCreateExercise { get; set; }
private string NewExerciseName { get; set; } = string.Empty;
private int? EditingId { get; set; }
private string EditingName { get; set; } = string.Empty;
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
if (UserId != ensured)
{
Navigation.NavigateTo($"/{ensured}/exercises", true);
return;
}
await LoadExercisesAsync();
}
private async Task LoadExercisesAsync()
{
IsLoading = true;
ExerciseList = await Api.GetExercisesAsync(UserContext.UserId);
IsLoading = false;
}
private async Task CreateExerciseAsync()
{
if (string.IsNullOrWhiteSpace(NewExerciseName))
{
return;
}
var result = await Api.CreateExerciseAsync(UserContext.UserId, new ExerciseUpsertRequest(NewExerciseName));
if (result is not null)
{
ExerciseList.Add(result);
ExerciseList = ExerciseList.OrderBy(e => e.Name).ToList();
NewExerciseName = string.Empty;
}
}
private void ToggleCreate()
{
ShowCreateExercise = !ShowCreateExercise;
}
private void StartEdit(ExerciseDto exercise)
{
EditingId = exercise.Id;
EditingName = exercise.Name;
}
private void CancelEdit()
{
EditingId = null;
EditingName = string.Empty;
}
private async Task SaveEditAsync(int exerciseId)
{
if (string.IsNullOrWhiteSpace(EditingName))
{
return;
}
var result = await Api.UpdateExerciseAsync(UserContext.UserId, exerciseId, new ExerciseUpsertRequest(EditingName));
if (result is not null)
{
var index = ExerciseList.FindIndex(e => e.Id == exerciseId);
if (index >= 0)
{
ExerciseList[index] = result;
ExerciseList = ExerciseList.OrderBy(e => e.Name).ToList();
}
}
CancelEdit();
}
private async Task DeleteExerciseAsync(int exerciseId)
{
var confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to delete this exercise?");
if (!confirmed) return;
await Api.DeleteExerciseAsync(UserContext.UserId, exerciseId);
ExerciseList.RemoveAll(e => e.Id == exerciseId);
}
}

View File

@@ -0,0 +1,159 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Requests;
namespace ASTRAIN.Client.Pages;
public partial class Exercises
{
/// <summary>
/// Optional user id from route.
/// </summary>
[Parameter]
public string? UserId { get; set; }
/// <summary>
/// The list of exercises for the current user.
/// </summary>
private List<ExerciseDto> ExerciseList { get; set; } = new();
/// <summary>
/// Whether the page is currently loading data.
/// </summary>
private bool IsLoading { get; set; } = true;
/// <summary>
/// Whether the create exercise UI is visible.
/// </summary>
private bool ShowCreateExercise { get; set; }
/// <summary>
/// Name for a new exercise being created.
/// </summary>
private string NewExerciseName { get; set; } = string.Empty;
/// <summary>
/// The currently edited exercise id, if any.
/// </summary>
private int? EditingId { get; set; }
/// <summary>
/// The current edited exercise name.
/// </summary>
private string EditingName { get; set; } = string.Empty;
/// <inheritdoc/>
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
if (UserId != ensured)
{
Navigation.NavigateTo($"/{ensured}/exercises", true);
return;
}
await LoadExercisesAsync();
}
/// <summary>
/// Loads exercises from the API.
/// </summary>
private async Task LoadExercisesAsync()
{
IsLoading = true;
ExerciseList = await Api.GetExercisesAsync(UserContext.UserId);
IsLoading = false;
}
/// <summary>
/// Creates a new exercise with the provided name.
/// </summary>
private async Task CreateExerciseAsync()
{
if (string.IsNullOrWhiteSpace(NewExerciseName))
{
return;
}
var result = await Api.CreateExerciseAsync(UserContext.UserId, new ExerciseUpsertRequest(NewExerciseName));
if (result is not null)
{
ExerciseList.Add(result);
ExerciseList = ExerciseList.OrderBy(e => e.Name).ToList();
NewExerciseName = string.Empty;
}
}
/// <summary>
/// Toggles the create form visibility.
/// </summary>
private void ToggleCreate()
{
ShowCreateExercise = !ShowCreateExercise;
}
/// <summary>
/// Begin editing the supplied exercise.
/// </summary>
private void StartEdit(ExerciseDto exercise)
{
EditingId = exercise.Id;
EditingName = exercise.Name;
}
/// <summary>
/// Cancel the current edit.
/// </summary>
private void CancelEdit()
{
EditingId = null;
EditingName = string.Empty;
}
/// <summary>
/// Save the edited exercise name.
/// </summary>
private async Task SaveEditAsync(int exerciseId)
{
if (string.IsNullOrWhiteSpace(EditingName))
{
return;
}
var result = await Api.UpdateExerciseAsync(UserContext.UserId, exerciseId, new ExerciseUpsertRequest(EditingName));
if (result is not null)
{
var index = ExerciseList.FindIndex(e => e.Id == exerciseId);
if (index >= 0)
{
ExerciseList[index] = result;
ExerciseList = ExerciseList.OrderBy(e => e.Name).ToList();
}
}
CancelEdit();
}
/// <summary>
/// Deletes the exercise with the given id after confirmation.
/// </summary>
private async Task DeleteExerciseAsync(int exerciseId)
{
var confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to delete this exercise?");
if (!confirmed) return;
await Api.DeleteExerciseAsync(UserContext.UserId, exerciseId);
ExerciseList.RemoveAll(e => e.Id == exerciseId);
}
}

View File

@@ -14,23 +14,3 @@
</div>
</div>
@code {
[Parameter]
public string? UserId { get; set; }
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
var target = $"/{ensured}/routines";
if (!Navigation.Uri.EndsWith(target, StringComparison.OrdinalIgnoreCase))
{
Navigation.NavigateTo(target, true);
}
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
namespace ASTRAIN.Client.Pages;
public partial class Home
{
/// <summary>
/// Optional user id from route.
/// </summary>
[Parameter]
public string? UserId { get; set; }
/// <inheritdoc/>
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
var target = $"/{ensured}/routines";
if (!Navigation.Uri.EndsWith(target, StringComparison.OrdinalIgnoreCase))
{
Navigation.NavigateTo(target, true);
}
}
}

View File

@@ -129,188 +129,3 @@
}
</div>
@code {
[Parameter]
public string? UserId { get; set; }
private List<ExerciseDto> ExerciseList { get; set; } = new();
private List<RoutineDto> RoutineList { get; set; } = new();
private bool IsLoading { get; set; } = true;
private bool ShowCreateRoutine { get; set; }
private string NewRoutineName { get; set; } = string.Empty;
private HashSet<int> SelectedExerciseIds { get; set; } = new();
private RoutineDto? EditingRoutine { get; set; }
private string EditingName { get; set; } = string.Empty;
private HashSet<int> EditingExerciseIds { get; set; } = new();
private RoutineDto? ActiveRun { get; set; }
private List<RoutineRunEntryDto> RunEntries { get; set; } = new();
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
if (UserId != ensured)
{
Navigation.NavigateTo($"/{ensured}/routines", true);
return;
}
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
IsLoading = true;
ExerciseList = await Api.GetExercisesAsync(UserContext.UserId);
RoutineList = await Api.GetRoutinesAsync(UserContext.UserId);
IsLoading = false;
}
private void ToggleExercise(int exerciseId)
{
if (!SelectedExerciseIds.Add(exerciseId))
{
SelectedExerciseIds.Remove(exerciseId);
}
}
private async Task CreateRoutineAsync()
{
if (string.IsNullOrWhiteSpace(NewRoutineName))
{
return;
}
var request = new RoutineUpsertRequest(NewRoutineName, SelectedExerciseIds.ToList());
var created = await Api.CreateRoutineAsync(UserContext.UserId, request);
if (created is not null)
{
RoutineList.Add(created);
RoutineList = RoutineList.OrderBy(r => r.Name).ToList();
NewRoutineName = string.Empty;
SelectedExerciseIds.Clear();
}
}
private void ToggleCreate()
{
ShowCreateRoutine = !ShowCreateRoutine;
}
private void GoToExercises()
{
Navigation.NavigateTo($"/{UserContext.UserId}/exercises");
}
private void StartEdit(RoutineDto routine)
{
EditingRoutine = routine;
EditingName = routine.Name;
EditingExerciseIds = routine.Exercises.Select(e => e.ExerciseId).ToHashSet();
}
private void CancelEdit()
{
EditingRoutine = null;
EditingName = string.Empty;
EditingExerciseIds.Clear();
}
private void ToggleEditExercise(int exerciseId)
{
if (!EditingExerciseIds.Add(exerciseId))
{
EditingExerciseIds.Remove(exerciseId);
}
}
private async Task SaveEditAsync()
{
if (EditingRoutine is null)
{
return;
}
var request = new RoutineUpsertRequest(EditingName, EditingExerciseIds.ToList());
var updated = await Api.UpdateRoutineAsync(UserContext.UserId, EditingRoutine.Id, request);
if (updated is not null)
{
var index = RoutineList.FindIndex(r => r.Id == EditingRoutine.Id);
if (index >= 0)
{
RoutineList[index] = updated;
RoutineList = RoutineList.OrderBy(r => r.Name).ToList();
}
}
CancelEdit();
}
private async Task DeleteRoutineAsync(int routineId)
{
var confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to delete this routine?");
if (!confirmed) return;
await Api.DeleteRoutineAsync(UserContext.UserId, routineId);
RoutineList.RemoveAll(r => r.Id == routineId);
}
private async Task StartRun(RoutineDto routine)
{
ActiveRun = routine;
var lastRun = await Api.GetLastRunAsync(UserContext.UserId, routine.Id);
RunEntries = routine.Exercises
.OrderBy(e => e.Order)
.Select(e =>
{
var last = lastRun.Entries.FirstOrDefault(x => x.ExerciseId == e.ExerciseId);
return new RoutineRunEntryDto(e.ExerciseId, last?.Weight ?? 0, false);
}).ToList();
}
private void ToggleRunCompleted(int exerciseId)
{
var entry = RunEntries.FirstOrDefault(e => e.ExerciseId == exerciseId);
if (entry is null)
{
return;
}
entry.Completed = !entry.Completed;
}
private string GetExerciseName(int exerciseId)
{
return ExerciseList.FirstOrDefault(e => e.Id == exerciseId)?.Name ?? "Exercise";
}
private async Task AbortRun()
{
var confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to abort this routine run?");
if (!confirmed) return;
ActiveRun = null;
RunEntries = new List<RoutineRunEntryDto>();
}
private async Task SaveRunAsync()
{
if (ActiveRun is null)
{
return;
}
var request = new RoutineRunRequest(RunEntries);
await Api.SaveRunAsync(UserContext.UserId, ActiveRun.Id, request);
ActiveRun = null;
RunEntries = new List<RoutineRunEntryDto>();
}
}

View File

@@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using ASTRAIN.Shared.Dtos;
using ASTRAIN.Shared.Requests;
namespace ASTRAIN.Client.Pages;
public partial class Routines
{
/// <summary>
/// Optional user id from route.
/// </summary>
[Parameter]
public string? UserId { get; set; }
private List<ExerciseDto> ExerciseList { get; set; } = new();
private List<RoutineDto> RoutineList { get; set; } = new();
private bool IsLoading { get; set; } = true;
private bool ShowCreateRoutine { get; set; }
private string NewRoutineName { get; set; } = string.Empty;
private HashSet<int> SelectedExerciseIds { get; set; } = new();
private RoutineDto? EditingRoutine { get; set; }
private string EditingName { get; set; } = string.Empty;
private HashSet<int> EditingExerciseIds { get; set; } = new();
private RoutineDto? ActiveRun { get; set; }
private List<RoutineRunEntryDto> RunEntries { get; set; } = new();
/// <inheritdoc/>
protected override async Task OnInitializedAsync()
{
var ensured = await Api.EnsureUserAsync(UserId);
if (string.IsNullOrWhiteSpace(ensured))
{
return;
}
UserContext.SetUserId(ensured);
if (UserId != ensured)
{
Navigation.NavigateTo($"/{ensured}/routines", true);
return;
}
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
IsLoading = true;
ExerciseList = await Api.GetExercisesAsync(UserContext.UserId);
RoutineList = await Api.GetRoutinesAsync(UserContext.UserId);
IsLoading = false;
}
/// <summary>
/// Loads exercises and routines from the API and updates the UI state.
/// </summary>
private void ToggleExercise(int exerciseId)
{
if (!SelectedExerciseIds.Add(exerciseId))
{
SelectedExerciseIds.Remove(exerciseId);
}
}
/// <summary>
/// Toggles whether an exercise is selected when creating a routine.
/// </summary>
private async Task CreateRoutineAsync()
{
if (string.IsNullOrWhiteSpace(NewRoutineName))
{
return;
}
var request = new RoutineUpsertRequest(NewRoutineName, SelectedExerciseIds.ToList());
var created = await Api.CreateRoutineAsync(UserContext.UserId, request);
if (created is not null)
{
RoutineList.Add(created);
RoutineList = RoutineList.OrderBy(r => r.Name).ToList();
NewRoutineName = string.Empty;
SelectedExerciseIds.Clear();
}
/// <summary>
/// Creates a new routine with the selected exercises.
/// </summary>
}
private void ToggleCreate()
{
ShowCreateRoutine = !ShowCreateRoutine;
}
/// <summary>
/// Toggle visibility of the create-routine UI.
/// </summary>
private void GoToExercises()
{
Navigation.NavigateTo($"/{UserContext.UserId}/exercises");
}
/// <summary>
/// Navigate to the exercises page for the current user.
/// </summary>
private void StartEdit(RoutineDto routine)
{
EditingRoutine = routine;
EditingName = routine.Name;
EditingExerciseIds = routine.Exercises.Select(e => e.ExerciseId).ToHashSet();
}
/// <summary>
/// Begin editing the supplied routine.
/// </summary>
private void CancelEdit()
{
EditingRoutine = null;
EditingName = string.Empty;
EditingExerciseIds.Clear();
}
/// <summary>
/// Cancel the current routine edit and reset state.
/// </summary>
private void ToggleEditExercise(int exerciseId)
{
if (!EditingExerciseIds.Add(exerciseId))
{
EditingExerciseIds.Remove(exerciseId);
}
}
/// <summary>
/// Toggle whether an exercise is selected in the routine edit UI.
/// </summary>
private async Task SaveEditAsync()
{
if (EditingRoutine is null)
{
return;
}
var request = new RoutineUpsertRequest(EditingName, EditingExerciseIds.ToList());
var updated = await Api.UpdateRoutineAsync(UserContext.UserId, EditingRoutine.Id, request);
if (updated is not null)
{
var index = RoutineList.FindIndex(r => r.Id == EditingRoutine.Id);
if (index >= 0)
{
RoutineList[index] = updated;
RoutineList = RoutineList.OrderBy(r => r.Name).ToList();
}
}
CancelEdit();
}
/// <summary>
/// Save changes made to the currently edited routine.
/// </summary>
private async Task DeleteRoutineAsync(int routineId)
{
var confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to delete this routine?");
if (!confirmed) return;
await Api.DeleteRoutineAsync(UserContext.UserId, routineId);
RoutineList.RemoveAll(r => r.Id == routineId);
}
/// <summary>
/// Delete the routine with the given id after confirmation.
/// </summary>
private async Task StartRun(RoutineDto routine)
{
ActiveRun = routine;
var lastRun = await Api.GetLastRunAsync(UserContext.UserId, routine.Id);
RunEntries = routine.Exercises
.OrderBy(e => e.Order)
.Select(e =>
{
var last = lastRun.Entries.FirstOrDefault(x => x.ExerciseId == e.ExerciseId);
return new RoutineRunEntryDto(e.ExerciseId, last?.Weight ?? 0, false);
}).ToList();
}
/// <summary>
/// Start a routine run and prepare run entries.
/// </summary>
private void ToggleRunCompleted(int exerciseId)
{
var entry = RunEntries.FirstOrDefault(e => e.ExerciseId == exerciseId);
if (entry is null)
{
return;
}
entry.Completed = !entry.Completed;
}
/// <summary>
/// Toggle the completion state of a run entry.
/// </summary>
private string GetExerciseName(int exerciseId)
{
return ExerciseList.FirstOrDefault(e => e.Id == exerciseId)?.Name ?? "Exercise";
}
/// <summary>
/// Get the display name for an exercise id.
/// </summary>
private async Task AbortRun()
{
var confirmed = await JS.InvokeAsync<bool>("confirm", "Are you sure you want to abort this routine run?");
if (!confirmed) return;
ActiveRun = null;
RunEntries = new List<RoutineRunEntryDto>();
}
/// <summary>
/// Abort the active routine run after confirmation.
/// </summary>
private async Task SaveRunAsync()
{
if (ActiveRun is null)
{
return;
}
var request = new RoutineRunRequest(RunEntries);
await Api.SaveRunAsync(UserContext.UserId, ActiveRun.Id, request);
ActiveRun = null;
RunEntries = new List<RoutineRunEntryDto>();
}
/// <summary>
/// Save the current routine run to the API.
/// </summary>
}

View File

@@ -1,57 +0,0 @@
@page "/weather"
@inject HttpClient Http
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates fetching data from the server.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
forecasts = await Http.GetFromJsonAsync<WeatherForecast[]>("sample-data/weather.json");
}
public class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}

View File

@@ -1,3 +1,3 @@
{
"ApiBaseAddress": "http://localhost:5055"
"ApiBaseAddress": "http://10.20.30.99:5055"
}