From b6ce21b1b8ed11e3bb7bef53656109b2875a4402 Mon Sep 17 00:00:00 2001 From: Andre Beging Date: Thu, 29 Jan 2026 10:17:16 +0100 Subject: [PATCH] feat: scaffold backend and shared library --- .github/copilot-instructions.md | 21 ++ .gitignore | 16 + .vscode/launch.json | 50 +++ ASTRAIN.slnx | 7 + docker-compose.yml | 12 + src/ASTRAIN.Api/ASTRAIN.Api.csproj | 21 ++ src/ASTRAIN.Api/Data/AppDbContext.cs | 63 ++++ src/ASTRAIN.Api/Dockerfile | 29 ++ src/ASTRAIN.Api/Program.cs | 331 ++++++++++++++++++ .../Properties/launchSettings.json | 23 ++ src/ASTRAIN.Api/Services/UserKeyGenerator.cs | 22 ++ src/ASTRAIN.Api/appsettings.Development.json | 8 + src/ASTRAIN.Api/appsettings.json | 12 + src/ASTRAIN.Shared/ASTRAIN.Shared.csproj | 9 + src/ASTRAIN.Shared/Class1.cs | 139 ++++++++ 15 files changed, 763 insertions(+) create mode 100644 .github/copilot-instructions.md create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 ASTRAIN.slnx create mode 100644 docker-compose.yml create mode 100644 src/ASTRAIN.Api/ASTRAIN.Api.csproj create mode 100644 src/ASTRAIN.Api/Data/AppDbContext.cs create mode 100644 src/ASTRAIN.Api/Dockerfile create mode 100644 src/ASTRAIN.Api/Program.cs create mode 100644 src/ASTRAIN.Api/Properties/launchSettings.json create mode 100644 src/ASTRAIN.Api/Services/UserKeyGenerator.cs create mode 100644 src/ASTRAIN.Api/appsettings.Development.json create mode 100644 src/ASTRAIN.Api/appsettings.json create mode 100644 src/ASTRAIN.Shared/ASTRAIN.Shared.csproj create mode 100644 src/ASTRAIN.Shared/Class1.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..8bcc417 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,21 @@ +- [x] Verify that the copilot-instructions.md file in the .github directory is created. + +- [x] Clarify Project Requirements + +- [x] Scaffold the Project + +- [x] Customize the Project + +- [x] Install Required Extensions + +- [x] Compile the Project + +- [x] Create and Run Task + +- [ ] Launch the Project + +- [x] Ensure Documentation is Complete + +- Work through each checklist item systematically. +- Keep communication concise and focused. +- Follow development best practices. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f62278 --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +**/bin/ +**/obj/ +**/out/ +**/.vs/ +**/.vscode/ +!.vscode/ +!.vscode/launch.json +*.user +*.suo +*.cache +*.log +.DS_Store +src/ASTRAIN.Api/Data/*.db +src/ASTRAIN.Api/Data/*.db-shm +src/ASTRAIN.Api/Data/*.db-wal +src/ASTRAIN.Api/ASTRAIN.Api.http diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..8a5fc6d --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,50 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "ASTRAIN.Api", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/ASTRAIN.Api/bin/Debug/net10.0/ASTRAIN.Api.dll", + "args": [], + "cwd": "${workspaceFolder}/src/ASTRAIN.Api", + "stopAtEntry": false, + "serverReadyAction": { + "action": "openExternally", + "pattern": "Now listening on: (https?://\\S+)", + "uriFormat": "%s" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + { + "name": "ASTRAIN.Client", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/src/ASTRAIN.Client/bin/Debug/net10.0/ASTRAIN.Client.dll", + "args": [], + "cwd": "${workspaceFolder}/src/ASTRAIN.Client", + "stopAtEntry": false, + "serverReadyAction": { + "action": "openExternally", + "pattern": "Now listening on: (https?://\\S+)", + "uriFormat": "%s" + }, + "env": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + ], + "compounds": [ + { + "name": "ASTRAIN.Full", + "configurations": [ + "ASTRAIN.Api", + "ASTRAIN.Client" + ] + } + ] +} diff --git a/ASTRAIN.slnx b/ASTRAIN.slnx new file mode 100644 index 0000000..88589b5 --- /dev/null +++ b/ASTRAIN.slnx @@ -0,0 +1,7 @@ + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32232c0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.9" +services: + astrain: + build: + context: ./src + dockerfile: ASTRAIN.Api/Dockerfile + ports: + - "8080:8080" + volumes: + - astrain-data:/app/Data +volumes: + astrain-data: diff --git a/src/ASTRAIN.Api/ASTRAIN.Api.csproj b/src/ASTRAIN.Api/ASTRAIN.Api.csproj new file mode 100644 index 0000000..0ea8a14 --- /dev/null +++ b/src/ASTRAIN.Api/ASTRAIN.Api.csproj @@ -0,0 +1,21 @@ + + + + net10.0 + enable + enable + + + + + + + all + + + + + + + + diff --git a/src/ASTRAIN.Api/Data/AppDbContext.cs b/src/ASTRAIN.Api/Data/AppDbContext.cs new file mode 100644 index 0000000..dc23af5 --- /dev/null +++ b/src/ASTRAIN.Api/Data/AppDbContext.cs @@ -0,0 +1,63 @@ +using ASTRAIN.Shared; +using Microsoft.EntityFrameworkCore; + +namespace ASTRAIN.Api.Data; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet Users => Set(); + public DbSet Exercises => Set(); + public DbSet Routines => Set(); + public DbSet RoutineExercises => Set(); + public DbSet RoutineRuns => Set(); + public DbSet RoutineRunEntries => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .HasMany(u => u.Exercises) + .WithOne(e => e.User) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasMany(u => u.Routines) + .WithOne(r => r.User) + .HasForeignKey(r => r.UserId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasIndex(re => new { re.RoutineId, re.Order }) + .IsUnique(); + + modelBuilder.Entity() + .HasOne(re => re.Routine) + .WithMany(r => r.Exercises) + .HasForeignKey(re => re.RoutineId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(re => re.Exercise) + .WithMany(e => e.RoutineExercises) + .HasForeignKey(re => re.ExerciseId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(rr => rr.Routine) + .WithMany(r => r.Runs) + .HasForeignKey(rr => rr.RoutineId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasOne(rre => rre.RoutineRun) + .WithMany(rr => rr.Entries) + .HasForeignKey(rre => rre.RoutineRunId) + .OnDelete(DeleteBehavior.Cascade); + + base.OnModelCreating(modelBuilder); + } +} diff --git a/src/ASTRAIN.Api/Dockerfile b/src/ASTRAIN.Api/Dockerfile new file mode 100644 index 0000000..33da399 --- /dev/null +++ b/src/ASTRAIN.Api/Dockerfile @@ -0,0 +1,29 @@ +# Build API +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY ASTRAIN.Api/ASTRAIN.Api.csproj ASTRAIN.Api/ +COPY ASTRAIN.Shared/ASTRAIN.Shared.csproj ASTRAIN.Shared/ +RUN dotnet restore ASTRAIN.Api/ASTRAIN.Api.csproj +COPY ASTRAIN.Api/ ASTRAIN.Api/ +COPY ASTRAIN.Shared/ ASTRAIN.Shared/ +RUN dotnet publish ASTRAIN.Api/ASTRAIN.Api.csproj -c Release -o /app/publish + +# Build Client +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build-client +WORKDIR /src +COPY ASTRAIN.Client/ASTRAIN.Client.csproj ASTRAIN.Client/ +COPY ASTRAIN.Shared/ASTRAIN.Shared.csproj ASTRAIN.Shared/ +RUN dotnet restore ASTRAIN.Client/ASTRAIN.Client.csproj +COPY ASTRAIN.Client/ ASTRAIN.Client/ +COPY ASTRAIN.Shared/ ASTRAIN.Shared/ +RUN dotnet publish ASTRAIN.Client/ASTRAIN.Client.csproj -c Release -o /app/client + +# Final runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +RUN mkdir -p /app/Data +COPY --from=build /app/publish/ ./ +COPY --from=build-client /app/client/wwwroot ./wwwroot +ENV ASPNETCORE_URLS=http://+:8080 +EXPOSE 8080 +ENTRYPOINT ["dotnet", "ASTRAIN.Api.dll"] diff --git a/src/ASTRAIN.Api/Program.cs b/src/ASTRAIN.Api/Program.cs new file mode 100644 index 0000000..9471c7f --- /dev/null +++ b/src/ASTRAIN.Api/Program.cs @@ -0,0 +1,331 @@ +using System.Text.RegularExpressions; +using ASTRAIN.Api.Data; +using ASTRAIN.Api.Services; +using ASTRAIN.Shared; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddOpenApi(); +builder.Services.AddCors(options => +{ + options.AddDefaultPolicy(policy => + { + policy.WithOrigins("http://localhost:5014", "https://localhost:7252") + .AllowAnyHeader() + .AllowAnyMethod(); + }); +}); +builder.Services.AddDbContext(options => +{ + var connectionString = builder.Configuration.GetConnectionString("Default") + ?? "Data Source=Data/astrain.db"; + options.UseSqlite(connectionString); +}); + +var app = builder.Build(); + +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); +} + +app.UseHttpsRedirection(); +app.UseCors(); +app.UseDefaultFiles(); +app.UseStaticFiles(); + +using (var scope = app.Services.CreateScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureCreated(); +} + +var api = app.MapGroup("/api"); + +api.MapGet("/health", () => Results.Ok(new { status = "ok" })); + +api.MapGet("/users/ensure", async (string? userId, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + return Results.Ok(new EnsureUserResponse(user.Id)); +}); + +api.MapGet("/users/{userId}", async (string userId, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + return Results.Ok(new EnsureUserResponse(user.Id)); +}); + +api.MapGet("/users/{userId}/exercises", async (string userId, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + var items = await db.Exercises + .Where(e => e.UserId == user.Id) + .OrderBy(e => e.Name) + .Select(e => new ExerciseDto(e.Id, e.Name)) + .ToListAsync(); + return Results.Ok(items); +}); + +api.MapPost("/users/{userId}/exercises", async (string userId, ExerciseUpsertRequest request, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest("Name is required."); + } + + var exercise = new Exercise + { + Name = request.Name.Trim(), + UserId = user.Id + }; + + db.Exercises.Add(exercise); + await db.SaveChangesAsync(); + + return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name)); +}); + +api.MapPut("/users/{userId}/exercises/{exerciseId:int}", async (string userId, int exerciseId, ExerciseUpsertRequest request, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + var exercise = await db.Exercises.FirstOrDefaultAsync(e => e.Id == exerciseId && e.UserId == user.Id); + if (exercise is null) + { + return Results.NotFound(); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest("Name is required."); + } + + exercise.Name = request.Name.Trim(); + await db.SaveChangesAsync(); + + return Results.Ok(new ExerciseDto(exercise.Id, exercise.Name)); +}); + +api.MapGet("/users/{userId}/routines", async (string userId, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + var routines = await db.Routines + .Include(r => r.Exercises) + .ThenInclude(re => re.Exercise) + .Where(r => r.UserId == user.Id) + .OrderBy(r => r.Name) + .ToListAsync(); + + var payload = routines.Select(r => new RoutineDto( + r.Id, + r.Name, + r.Exercises + .OrderBy(re => re.Order) + .Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order)) + .ToList() + )); + + return Results.Ok(payload); +}); + +api.MapGet("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + var routine = await db.Routines + .Include(r => r.Exercises) + .ThenInclude(re => re.Exercise) + .FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id); + + if (routine is null) + { + return Results.NotFound(); + } + + var payload = new RoutineDto( + routine.Id, + routine.Name, + routine.Exercises + .OrderBy(re => re.Order) + .Select(re => new RoutineExerciseDto(re.ExerciseId, re.Exercise?.Name ?? string.Empty, re.Order)) + .ToList()); + + return Results.Ok(payload); +}); + +api.MapPost("/users/{userId}/routines", async (string userId, RoutineUpsertRequest request, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest("Name is required."); + } + + var routine = new Routine + { + Name = request.Name.Trim(), + UserId = user.Id + }; + + var exercises = await db.Exercises + .Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id)) + .ToListAsync(); + + routine.Exercises = request.ExerciseIds + .Select((exerciseId, index) => new RoutineExercise + { + ExerciseId = exerciseId, + Order = index + }) + .ToList(); + + db.Routines.Add(routine); + await db.SaveChangesAsync(); + + var dto = new RoutineDto( + routine.Id, + routine.Name, + routine.Exercises + .Select((re, index) => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, index)) + .ToList()); + + return Results.Ok(dto); +}); + +api.MapPut("/users/{userId}/routines/{routineId:int}", async (string userId, int routineId, RoutineUpsertRequest request, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + var routine = await db.Routines + .Include(r => r.Exercises) + .FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id); + + if (routine is null) + { + return Results.NotFound(); + } + + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest("Name is required."); + } + + routine.Name = request.Name.Trim(); + routine.Exercises.Clear(); + foreach (var exerciseId in request.ExerciseIds) + { + routine.Exercises.Add(new RoutineExercise + { + ExerciseId = exerciseId, + Order = routine.Exercises.Count + }); + } + + await db.SaveChangesAsync(); + + var exercises = await db.Exercises + .Where(e => e.UserId == user.Id && request.ExerciseIds.Contains(e.Id)) + .ToListAsync(); + + var dto = new RoutineDto( + routine.Id, + routine.Name, + routine.Exercises + .OrderBy(re => re.Order) + .Select(re => new RoutineExerciseDto(re.ExerciseId, exercises.FirstOrDefault(e => e.Id == re.ExerciseId)?.Name ?? string.Empty, re.Order)) + .ToList()); + + return Results.Ok(dto); +}); + +api.MapGet("/users/{userId}/routines/{routineId:int}/last-run", async (string userId, int routineId, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + var lastRun = await db.RoutineRuns + .Include(rr => rr.Entries) + .Where(rr => rr.UserId == user.Id && rr.RoutineId == routineId) + .OrderByDescending(rr => rr.PerformedAt) + .FirstOrDefaultAsync(); + + if (lastRun is null) + { + return Results.Ok(new RoutineRunSummaryDto(DateTime.MinValue, new List())); + } + + var summary = new RoutineRunSummaryDto( + lastRun.PerformedAt, + lastRun.Entries + .Select(e => new RoutineRunEntryDto(e.ExerciseId, e.Weight, e.Completed)) + .ToList()); + + return Results.Ok(summary); +}); + +api.MapPost("/users/{userId}/routines/{routineId:int}/runs", async (string userId, int routineId, RoutineRunRequest request, AppDbContext db) => +{ + var user = await EnsureUserAsync(db, userId); + var routine = await db.Routines.FirstOrDefaultAsync(r => r.Id == routineId && r.UserId == user.Id); + if (routine is null) + { + return Results.NotFound(); + } + + var run = new RoutineRun + { + RoutineId = routine.Id, + UserId = user.Id, + PerformedAt = DateTime.UtcNow, + Entries = request.Entries.Select(entry => new RoutineRunEntry + { + ExerciseId = entry.ExerciseId, + Weight = entry.Weight, + Completed = entry.Completed + }).ToList() + }; + + db.RoutineRuns.Add(run); + await db.SaveChangesAsync(); + + return Results.Ok(new { run.Id, run.PerformedAt }); +}); + +app.MapFallbackToFile("index.html"); + +app.Run(); + +static async Task EnsureUserAsync(AppDbContext db, string? userId) +{ + if (!string.IsNullOrWhiteSpace(userId) && IsValidUserId(userId)) + { + var existing = await db.Users.FirstOrDefaultAsync(u => u.Id == userId); + if (existing is not null) + { + return existing; + } + + var created = new User { Id = userId }; + db.Users.Add(created); + await db.SaveChangesAsync(); + return created; + } + + while (true) + { + var newId = UserKeyGenerator.Generate(8); + var exists = await db.Users.AnyAsync(u => u.Id == newId); + if (exists) + { + continue; + } + + var user = new User { Id = newId }; + db.Users.Add(user); + await db.SaveChangesAsync(); + return user; + } +} + +static bool IsValidUserId(string userId) +{ + return Regex.IsMatch(userId, "^[A-Za-z0-9]{8}$"); +} diff --git a/src/ASTRAIN.Api/Properties/launchSettings.json b/src/ASTRAIN.Api/Properties/launchSettings.json new file mode 100644 index 0000000..9d1cf13 --- /dev/null +++ b/src/ASTRAIN.Api/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5055", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7049;http://localhost:5055", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/ASTRAIN.Api/Services/UserKeyGenerator.cs b/src/ASTRAIN.Api/Services/UserKeyGenerator.cs new file mode 100644 index 0000000..8483191 --- /dev/null +++ b/src/ASTRAIN.Api/Services/UserKeyGenerator.cs @@ -0,0 +1,22 @@ +using System.Security.Cryptography; +using System.Text; + +namespace ASTRAIN.Api.Services; + +public static class UserKeyGenerator +{ + private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + public static string Generate(int length = 8) + { + var buffer = new byte[length]; + RandomNumberGenerator.Fill(buffer); + var chars = new char[length]; + for (var i = 0; i < length; i++) + { + chars[i] = Alphabet[buffer[i] % Alphabet.Length]; + } + + return new string(chars); + } +} diff --git a/src/ASTRAIN.Api/appsettings.Development.json b/src/ASTRAIN.Api/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/ASTRAIN.Api/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/ASTRAIN.Api/appsettings.json b/src/ASTRAIN.Api/appsettings.json new file mode 100644 index 0000000..07729c7 --- /dev/null +++ b/src/ASTRAIN.Api/appsettings.json @@ -0,0 +1,12 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "ConnectionStrings": { + "Default": "Data Source=Data/astrain.db" + }, + "AllowedHosts": "*" +} diff --git a/src/ASTRAIN.Shared/ASTRAIN.Shared.csproj b/src/ASTRAIN.Shared/ASTRAIN.Shared.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/src/ASTRAIN.Shared/ASTRAIN.Shared.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/ASTRAIN.Shared/Class1.cs b/src/ASTRAIN.Shared/Class1.cs new file mode 100644 index 0000000..0e9d16c --- /dev/null +++ b/src/ASTRAIN.Shared/Class1.cs @@ -0,0 +1,139 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace ASTRAIN.Shared; + +public class User +{ + [Key] + [MaxLength(8)] + public string Id { get; set; } = string.Empty; + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public List Exercises { get; set; } = new(); + public List Routines { get; set; } = new(); +} + +public class Exercise +{ + public int Id { get; set; } + + [Required] + [MaxLength(120)] + public string Name { get; set; } = string.Empty; + + [Required] + [MaxLength(8)] + public string UserId { get; set; } = string.Empty; + + public User? User { get; set; } + + public List RoutineExercises { get; set; } = new(); +} + +public class Routine +{ + public int Id { get; set; } + + [Required] + [MaxLength(120)] + public string Name { get; set; } = string.Empty; + + [Required] + [MaxLength(8)] + public string UserId { get; set; } = string.Empty; + + public User? User { get; set; } + + public List Exercises { get; set; } = new(); + public List Runs { get; set; } = new(); +} + +public class RoutineExercise +{ + public int Id { get; set; } + + [Required] + public int RoutineId { get; set; } + + public Routine? Routine { get; set; } + + [Required] + public int ExerciseId { get; set; } + + public Exercise? Exercise { get; set; } + + public int Order { get; set; } +} + +public class RoutineRun +{ + public int Id { get; set; } + + [Required] + public int RoutineId { get; set; } + + public Routine? Routine { get; set; } + + [Required] + [MaxLength(8)] + public string UserId { get; set; } = string.Empty; + + public DateTime PerformedAt { get; set; } = DateTime.UtcNow; + + public List Entries { get; set; } = new(); +} + +public class RoutineRunEntry +{ + public int Id { get; set; } + + [Required] + public int RoutineRunId { get; set; } + + public RoutineRun? RoutineRun { get; set; } + + [Required] + public int ExerciseId { get; set; } + + public Exercise? Exercise { get; set; } + + public double Weight { get; set; } + + public bool Completed { get; set; } +} + +public record EnsureUserResponse(string UserId); + +public record ExerciseDto(int Id, string Name); + +public record ExerciseUpsertRequest(string Name); + +public record RoutineExerciseDto(int ExerciseId, string Name, int Order); + +public record RoutineDto(int Id, string Name, List Exercises); + +public record RoutineUpsertRequest(string Name, List ExerciseIds); + +public class RoutineRunEntryDto +{ + public int ExerciseId { get; set; } + public double Weight { get; set; } + public bool Completed { get; set; } + + public RoutineRunEntryDto() + { + } + + public RoutineRunEntryDto(int exerciseId, double weight, bool completed) + { + ExerciseId = exerciseId; + Weight = weight; + Completed = completed; + } +} + +public record RoutineRunRequest(List Entries); + +public record RoutineRunSummaryDto(DateTime PerformedAt, List Entries);