Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c24e406f1 | |||
| 2f6227fb4f | |||
| 9604589785 | |||
| 77968da90c | |||
| 68ee6cacb0 | |||
| a24e6b3159 | |||
| 5edbe59692 | |||
| 421f902d21 |
@@ -0,0 +1,340 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Security.Claims;
|
||||
using System.Data.Common;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Hosting.Server;
|
||||
using Microsoft.AspNetCore.Hosting.Server.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class DashboardPreferencesTests
|
||||
{
|
||||
private const string Route = "/api/auth/preferences/dashboard";
|
||||
private const string Layout = "{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"cards\",\"visible\":true,\"size\":\"full\"}]}";
|
||||
|
||||
[Fact]
|
||||
public async Task Http_roundtrip_is_owner_scoped_and_preserves_other_preferences()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var owner = host.Client("owner");
|
||||
using var other = host.Client("other");
|
||||
using var anonymous = host.Client(null);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.GetAsync(Route)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await anonymous.PutAsync(Route, Json(Layout))).StatusCode);
|
||||
var initial = await owner.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(0, initial.GetProperty("revision").GetInt64());
|
||||
Assert.Empty(initial.GetProperty("widgets").EnumerateArray());
|
||||
var saved = await owner.PutAsync(Route + "?userId=other", Json(Layout));
|
||||
Assert.Equal(HttpStatusCode.OK, saved.StatusCode);
|
||||
Assert.Equal(1, (await saved.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("revision").GetInt64());
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await owner.PutAsync(Route, Json(Layout))).StatusCode);
|
||||
Assert.Equal(0, (await other.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
|
||||
await using var db = host.Db();
|
||||
var user = await db.Users.SingleAsync(x => x.Id == "owner");
|
||||
Assert.Equal("nb-NO", user.UiLanguage);
|
||||
Assert.False(user.EmailFollowUpRemindersEnabled);
|
||||
Assert.Equal("security", user.SecurityStamp);
|
||||
Assert.Equal("profile", user.DisplayName);
|
||||
Assert.Equal("concurrency", user.ConcurrencyStamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Simultaneous_first_writes_have_exactly_one_winner()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var first = host.Client("owner");
|
||||
using var second = host.Client("owner");
|
||||
var results = await Task.WhenAll(first.PutAsync(Route, Json(Layout)), second.PutAsync(Route, Json(Layout)));
|
||||
Assert.Equal(new[] { HttpStatusCode.OK, HttpStatusCode.Conflict }, results.Select(x => x.StatusCode).OrderBy(x => x));
|
||||
Assert.Equal(1, (await first.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Layout_write_succeeds_while_WAL_reader_remains_open()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
await using var db = host.Db();
|
||||
await db.Database.OpenConnectionAsync();
|
||||
await using var command = db.Database.GetDbConnection().CreateCommand();
|
||||
command.CommandText = "SELECT Id FROM AspNetUsers ORDER BY Id";
|
||||
await using var reader = await command.ExecuteReaderAsync();
|
||||
Assert.True(await reader.ReadAsync());
|
||||
using var client = host.Client("owner");
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PutAsync(Route, Json(Layout))).StatusCode);
|
||||
Assert.True(await reader.ReadAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unknown_authenticated_subject_cannot_access_or_write_preferences()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("missing-account");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync(Route)).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, (await client.PutAsync(Route, Json(Layout))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stale_identity_profile_update_cannot_overwrite_new_layout()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
await using var staleDb = host.Db();
|
||||
var staleUser = await staleDb.Users.SingleAsync(x => x.Id == "owner");
|
||||
using var client = host.Client("owner");
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PutAsync(Route, Json(Layout))).StatusCode);
|
||||
// Identity's store marks every user property modified, even an unchanged stale layout.
|
||||
staleUser.UiLanguage = "en";
|
||||
staleDb.Update(staleUser);
|
||||
await staleDb.SaveChangesAsync();
|
||||
Assert.Equal(1, (await client.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Read_normalises_corrupt_unknown_and_invalid_stored_widgets_without_resetting_revision()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
await using var db = host.Db();
|
||||
var stored = "{\"version\":1,\"widgets\":[{\"id\":\"future\",\"visible\":true,\"size\":\"full\"},{\"id\":\"cards\",\"visible\":false,\"size\":\"bad\"},{\"id\":\"funnel\",\"visible\":true,\"size\":\"bad\"}]}";
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={stored}, DashboardLayoutRevision=4 WHERE Id='owner'");
|
||||
var normalised = await client.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(4, normalised.GetProperty("revision").GetInt64());
|
||||
var widgets = normalised.GetProperty("widgets").EnumerateArray().ToArray();
|
||||
Assert.Equal(2, widgets.Length);
|
||||
Assert.Equal("full", widgets[0].GetProperty("size").GetString());
|
||||
Assert.False(widgets[0].GetProperty("visible").GetBoolean());
|
||||
Assert.Equal("standard", widgets[1].GetProperty("size").GetString());
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={"{"} WHERE Id='owner'");
|
||||
var corrupt = await client.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(4, corrupt.GetProperty("revision").GetInt64());
|
||||
Assert.Empty(corrupt.GetProperty("widgets").EnumerateArray());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Future_schema_is_exposed_and_cannot_be_overwritten_by_version_one()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
await using var db = host.Db();
|
||||
const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=4 WHERE Id='owner'");
|
||||
var response = await client.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(2, response.GetProperty("version").GetInt32());
|
||||
Assert.Equal(4, response.GetProperty("revision").GetInt64());
|
||||
Assert.Empty(response.GetProperty("widgets").EnumerateArray());
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":4,\"widgets\":[]}"))).StatusCode);
|
||||
var stored = await db.Users.AsNoTracking().Where(x => x.Id == "owner").Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleAsync();
|
||||
Assert.Equal(future, stored.DashboardLayoutJson);
|
||||
Assert.Equal(4, stored.DashboardLayoutRevision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Future_schema_installed_while_body_is_paused_cannot_be_overwritten_with_guessed_revision()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
var preRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var releaseBody = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
host.ReadObserver.OnLayoutRead = () => preRead.TrySetResult();
|
||||
using var body = new PausedJson("{\"version\":1,\"revision\":1,\"widgets\":[]}", releaseBody.Task);
|
||||
var pending = client.PutAsync(Route, body);
|
||||
try
|
||||
{
|
||||
await preRead.Task.WaitAsync(TimeSpan.FromSeconds(10));
|
||||
await using var db = host.Db();
|
||||
const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
|
||||
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=1 WHERE Id='owner'");
|
||||
releaseBody.TrySetResult();
|
||||
Assert.Equal(HttpStatusCode.Conflict, (await pending).StatusCode);
|
||||
var stored = await db.Users.AsNoTracking().Where(x => x.Id == "owner").Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleAsync();
|
||||
Assert.Equal(future, stored.DashboardLayoutJson);
|
||||
Assert.Equal(1, stored.DashboardLayoutRevision);
|
||||
}
|
||||
finally { releaseBody.TrySetResult(); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task All_known_widgets_and_empty_reset_save_roundtrip_in_order()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
var ids = new[] { "cards", "attention", "funnel", "activity", "stageAge", "sources", "salary", "companies", "skills" };
|
||||
var body = new { version = 1, revision = 0, widgets = ids.Select(id => new { id, visible = false, size = "full" }) };
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PutAsJsonAsync(Route, body)).StatusCode);
|
||||
var saved = await client.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(ids, saved.GetProperty("widgets").EnumerateArray().Select(x => x.GetProperty("id").GetString()));
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":1,\"widgets\":[]}"))).StatusCode);
|
||||
var reset = await client.GetFromJsonAsync<JsonElement>(Route);
|
||||
Assert.Equal(2, reset.GetProperty("revision").GetInt64());
|
||||
Assert.Empty(reset.GetProperty("widgets").EnumerateArray());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("{", 400)]
|
||||
[InlineData("{\"version\":\"1\",\"revision\":0,\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":\"0\",\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":9223372036854775807,\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":9007199254740991,\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":2,\"revision\":0,\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":-1,\"widgets\":[]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":null}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"unknown\",\"visible\":true,\"size\":\"full\"}]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"cards\",\"visible\":true,\"size\":\"standard\"}]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"funnel\",\"visible\":true,\"size\":\"huge\"}]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"funnel\",\"size\":\"full\"}]}", 400)]
|
||||
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"funnel\",\"visible\":\"true\",\"size\":\"full\"}]}", 400)]
|
||||
public async Task Invalid_payload_does_not_save(string payload, int expected)
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
Assert.Equal(expected, (int)(await client.PutAsync(Route, Json(payload))).StatusCode);
|
||||
Assert.Equal(0, (await client.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Duplicate_excessive_and_oversized_payloads_are_bounded()
|
||||
{
|
||||
await using var host = await Host.Start();
|
||||
using var client = host.Client("owner");
|
||||
const string widget = "{\"id\":\"cards\",\"visible\":true,\"size\":\"full\"}";
|
||||
foreach (var count in new[] { 2, 33 })
|
||||
Assert.Equal(HttpStatusCode.BadRequest, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":0,\"widgets\":[" + string.Join(',', Enumerable.Repeat(widget, count)) + "]}"))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await client.PutAsync(Route, Json(Layout + new string(' ', 16384)))).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.RequestEntityTooLarge, (await client.PutAsync(Route, new ChunkedJson(Layout + new string(' ', 16384)))).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Additive_migration_preserves_legacy_account_and_defaults_and_generates_MariaDb_SQL()
|
||||
{
|
||||
await using var host = await Host.Start(legacy: true);
|
||||
await using var db = host.Db();
|
||||
var user = await db.Users.SingleAsync(x => x.Id == "owner");
|
||||
Assert.Null(db.Entry(user).Property("DashboardLayoutJson").CurrentValue);
|
||||
Assert.Equal(0L, db.Entry(user).Property("DashboardLayoutRevision").CurrentValue);
|
||||
Assert.Equal("nb-NO", user.UiLanguage);
|
||||
Assert.Empty(await db.Database.GetPendingMigrationsAsync());
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseMySql("Server=127.0.0.1;Database=script_only;User=none;Password=none;", new MariaDbServerVersion(new Version(11, 0, 0))).Options;
|
||||
await using var maria = new JobTrackerContext(options, Mock.Of<ICurrentUserService>());
|
||||
var script = maria.GetService<IMigrator>().GenerateScript("20260830137000_AdoptSavedJobSearchSchema");
|
||||
Assert.Contains("`DashboardLayoutJson` longtext NULL", script);
|
||||
Assert.Contains("`DashboardLayoutRevision` bigint NOT NULL DEFAULT 0", script);
|
||||
}
|
||||
|
||||
private static StringContent Json(string value) => new(value, Encoding.UTF8, "application/json");
|
||||
|
||||
private sealed class ChunkedJson(string value) : HttpContent
|
||||
{
|
||||
protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => stream.WriteAsync(Encoding.UTF8.GetBytes(value)).AsTask();
|
||||
protected override bool TryComputeLength(out long length) { length = 0; return false; }
|
||||
}
|
||||
|
||||
private sealed class PausedJson(string value, Task release) : HttpContent
|
||||
{
|
||||
protected override async Task SerializeToStreamAsync(Stream stream, TransportContext? context)
|
||||
{
|
||||
await stream.WriteAsync(new byte[] { (byte)' ' });
|
||||
await stream.FlushAsync();
|
||||
await release;
|
||||
await stream.WriteAsync(Encoding.UTF8.GetBytes(value));
|
||||
}
|
||||
protected override bool TryComputeLength(out long length) { length = 0; return false; }
|
||||
}
|
||||
|
||||
private sealed class LayoutReadObserver : DbCommandInterceptor
|
||||
{
|
||||
public Action? OnLayoutRead { get; set; }
|
||||
public override InterceptionResult DataReaderDisposing(DbCommand command, DataReaderDisposingEventData eventData, InterceptionResult result)
|
||||
{
|
||||
if (command.CommandText.StartsWith("SELECT", StringComparison.Ordinal) && command.CommandText.Contains("DashboardLayoutJson", StringComparison.Ordinal)) OnLayoutRead?.Invoke();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class Host : IAsyncDisposable
|
||||
{
|
||||
private readonly WebApplication app;
|
||||
private readonly string directory;
|
||||
private readonly string connection;
|
||||
public LayoutReadObserver ReadObserver { get; }
|
||||
private Host(WebApplication app, string directory, string connection, LayoutReadObserver readObserver) => (this.app, this.directory, this.connection, ReadObserver) = (app, directory, connection, readObserver);
|
||||
public JobTrackerContext Db() => new(new DbContextOptionsBuilder<JobTrackerContext>().UseSqlite(connection).Options, Mock.Of<ICurrentUserService>());
|
||||
public HttpClient Client(string? user)
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = new Uri(app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single()) };
|
||||
if (user is not null) client.DefaultRequestHeaders.Add("X-Test-User", user);
|
||||
return client;
|
||||
}
|
||||
public static async Task<Host> Start(bool legacy = false)
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "dashboard-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(directory);
|
||||
var connection = $"Data Source={Path.Combine(directory, "test.db")};Pooling=False;Default Timeout=5";
|
||||
var builder = WebApplication.CreateBuilder();
|
||||
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = directory, ["Database:Provider"] = "sqlite", ["ConnectionStrings:JobTracker"] = connection, ["Auth:AdminEmail"] = "", ["Auth:AdminPassword"] = "" });
|
||||
builder.Logging.ClearProviders();
|
||||
builder.WebHost.UseUrls("http://127.0.0.1:0");
|
||||
builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly);
|
||||
builder.Services.AddAuthentication("local").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("local", _ => { });
|
||||
builder.Services.AddAuthorization();
|
||||
var readObserver = new LayoutReadObserver();
|
||||
builder.Services.AddDbContext<JobTrackerContext>(x => x.UseSqlite(connection).AddInterceptors(readObserver));
|
||||
builder.Services.AddSingleton(Mock.Of<ICurrentUserService>());
|
||||
builder.Services.AddSingleton<AppPaths>();
|
||||
builder.Services.AddSingleton<IStartupReadiness, StartupReadiness>();
|
||||
builder.Services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
|
||||
builder.Services.AddSingleton(Mock.Of<ITokenService>());
|
||||
builder.Services.AddSingleton(Mock.Of<IAppEmailSender>());
|
||||
builder.Services.AddSingleton(Mock.Of<IGoogleTokenValidator>());
|
||||
builder.Services.AddSingleton(Mock.Of<IMicrosoftTokenValidator>());
|
||||
builder.Services.AddSingleton(Mock.Of<ITwoFactorPendingTokenService>());
|
||||
var app = builder.Build();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
var host = new Host(app, directory, connection, readObserver);
|
||||
await using (var db = host.Db())
|
||||
{
|
||||
if (legacy)
|
||||
{
|
||||
await db.GetService<IMigrator>().MigrateAsync("20260830137000_AdoptSavedJobSearchSchema");
|
||||
await db.Database.ExecuteSqlRawAsync("INSERT INTO AspNetUsers (Id, UiLanguage, EmailConfirmed, PhoneNumberConfirmed, TwoFactorEnabled, LockoutEnabled, AccessFailedCount) VALUES ('owner', 'nb-NO', 0, 0, 0, 0, 0)");
|
||||
}
|
||||
await app.InitializeJobTrackerAsync();
|
||||
if (!legacy)
|
||||
{
|
||||
db.Users.AddRange(new ApplicationUser { Id = "owner", UiLanguage = "nb-NO", EmailFollowUpRemindersEnabled = false, SecurityStamp = "security", ConcurrencyStamp = "concurrency", DisplayName = "profile" }, new ApplicationUser { Id = "other" });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
await app.StartAsync();
|
||||
return host;
|
||||
}
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await app.DisposeAsync();
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class TestAuthentication(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync() => Task.FromResult(Request.Headers.TryGetValue("X-Test-User", out var id)
|
||||
? AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, id.ToString()) }, "local")), "local"))
|
||||
: AuthenticateResult.NoResult());
|
||||
}
|
||||
}
|
||||
@@ -544,6 +544,56 @@ public sealed class AuthController : ControllerBase
|
||||
|
||||
public sealed record UpdateLanguagePreferenceRequest(string? Language);
|
||||
|
||||
[HttpGet("preferences/dashboard")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> GetDashboardPreference(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = LocalAuthIdentity.GetRequiredUserId(User);
|
||||
if (userId is null) return Unauthorized();
|
||||
var user = await _db.Users.AsNoTracking().Where(x => x.Id == userId)
|
||||
.Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleOrDefaultAsync(cancellationToken);
|
||||
return user is null ? Unauthorized() : Ok(DashboardLayout.Read(user.DashboardLayoutJson, user.DashboardLayoutRevision));
|
||||
}
|
||||
|
||||
[HttpPut("preferences/dashboard")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UpdateDashboardPreference(CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = LocalAuthIdentity.GetRequiredUserId(User);
|
||||
if (userId is null) return Unauthorized();
|
||||
var existing = await _db.Users.AsNoTracking().Where(x => x.Id == userId)
|
||||
.Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleOrDefaultAsync(cancellationToken);
|
||||
if (existing is null) return Unauthorized();
|
||||
if (DashboardLayout.Read(existing.DashboardLayoutJson, 0).Version > 1)
|
||||
return Conflict(new { error = "dashboard_version_unsupported" });
|
||||
if (Request.ContentLength > DashboardLayout.MaxBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
// Bound actual bytes as well as Content-Length, including chunked bodies.
|
||||
var bytes = new byte[DashboardLayout.MaxBytes + 1];
|
||||
var length = 0;
|
||||
while (length < bytes.Length)
|
||||
{
|
||||
var count = await Request.Body.ReadAsync(bytes.AsMemory(length), cancellationToken);
|
||||
if (count == 0) break;
|
||||
length += count;
|
||||
}
|
||||
if (length > DashboardLayout.MaxBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge);
|
||||
DashboardLayout? layout;
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(bytes.AsMemory(0, length));
|
||||
layout = DashboardLayout.Parse(document.RootElement);
|
||||
}
|
||||
catch (JsonException) { return BadRequest("Invalid dashboard layout JSON."); }
|
||||
if (layout is null) return BadRequest("Invalid dashboard layout version, revision or widgets.");
|
||||
if (layout.Revision != existing.DashboardLayoutRevision)
|
||||
return Conflict(new { error = "dashboard_revision_conflict" });
|
||||
var json = JsonSerializer.Serialize(layout, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
var updated = await _db.Users.Where(x => x.Id == userId && x.DashboardLayoutRevision == layout.Revision)
|
||||
.ExecuteUpdateAsync(set => set.SetProperty(x => x.DashboardLayoutJson, json)
|
||||
.SetProperty(x => x.DashboardLayoutRevision, x => x.DashboardLayoutRevision + 1), cancellationToken);
|
||||
return updated == 0 ? Conflict(new { error = "dashboard_revision_conflict" }) : Ok(layout with { Revision = layout.Revision + 1 });
|
||||
}
|
||||
|
||||
[HttpPut("preferences/language")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public async Task<IActionResult> UpdateLanguagePreference([FromBody] UpdateLanguagePreferenceRequest request)
|
||||
|
||||
@@ -101,6 +101,15 @@ namespace JobTrackerApi.Data
|
||||
.Property(x => x.UiLanguage)
|
||||
.HasMaxLength(16);
|
||||
|
||||
modelBuilder.Entity<ApplicationUser>()
|
||||
.Property(x => x.DashboardLayoutRevision)
|
||||
.HasDefaultValue(0L);
|
||||
// Layout writes use the revision-guarded endpoint; Identity's whole-user update must not replay stale values.
|
||||
modelBuilder.Entity<ApplicationUser>().Property(x => x.DashboardLayoutJson)
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
modelBuilder.Entity<ApplicationUser>().Property(x => x.DashboardLayoutRevision)
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerUserId).HasMaxLength(255);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.OwnerKey).HasMaxLength(64);
|
||||
modelBuilder.Entity<AccountDeletionRequest>().Property(x => x.RequestedByUserId).HasMaxLength(255);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using JobTrackerApi.Data;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace JobTrackerApi.Migrations;
|
||||
|
||||
[DbContext(typeof(JobTrackerContext))]
|
||||
[Migration("20261021090000_AddDashboardLayoutPreference")]
|
||||
public partial class AddDashboardLayoutPreference : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
var mysql = ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase);
|
||||
migrationBuilder.AddColumn<string>(name: "DashboardLayoutJson", table: "AspNetUsers", type: mysql ? "longtext" : "TEXT", nullable: true);
|
||||
migrationBuilder.AddColumn<long>(name: "DashboardLayoutRevision", table: "AspNetUsers", type: mysql ? "bigint" : "INTEGER", nullable: false, defaultValue: 0L);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
if (ActiveProvider.Contains("Sqlite", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// These independent columns need no table rebuild (SQLite 3.35+ supports DROP COLUMN).
|
||||
migrationBuilder.Sql("ALTER TABLE \"AspNetUsers\" DROP COLUMN \"DashboardLayoutJson\";");
|
||||
migrationBuilder.Sql("ALTER TABLE \"AspNetUsers\" DROP COLUMN \"DashboardLayoutRevision\";");
|
||||
return;
|
||||
}
|
||||
migrationBuilder.DropColumn(name: "DashboardLayoutJson", table: "AspNetUsers");
|
||||
migrationBuilder.DropColumn(name: "DashboardLayoutRevision", table: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
@@ -485,6 +485,16 @@ namespace JobTrackerApi.Migrations
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("DashboardLayoutJson")
|
||||
.HasColumnType("TEXT")
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
|
||||
b.Property<long>("DashboardLayoutRevision")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0L)
|
||||
.Metadata.SetAfterSaveBehavior(Microsoft.EntityFrameworkCore.Metadata.PropertySaveBehavior.Ignore);
|
||||
|
||||
b.Property<DateTimeOffset?>("TotpEnabledAtUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ public sealed class ApplicationUser : IdentityUser
|
||||
public bool EmailFollowUpRemindersEnabled { get; set; } = true;
|
||||
// BCP 47 application UI preference. Null/legacy values safely fall back to English.
|
||||
public string? UiLanguage { get; set; }
|
||||
public string? DashboardLayoutJson { get; set; }
|
||||
public long DashboardLayoutRevision { get; set; }
|
||||
public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active;
|
||||
public DateTimeOffset? DeletionRequestedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed record DashboardWidget(string Id, bool Visible, string Size);
|
||||
public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList<DashboardWidget> Widgets)
|
||||
{
|
||||
public const int MaxBytes = 16 * 1024;
|
||||
// Responses cross a JavaScript client; the incremented revision must remain an exact integer there.
|
||||
private const long MaxRevision = 9_007_199_254_740_991L;
|
||||
private static readonly HashSet<string> KnownIds = new(StringComparer.Ordinal)
|
||||
{ "cards", "attention", "funnel", "activity", "stageAge", "sources", "salary", "companies", "skills" };
|
||||
|
||||
public static DashboardLayout? Parse(JsonElement root, bool reading = false, long revision = 0)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var v) || v != 1
|
||||
|| !root.TryGetProperty("widgets", out var widgets) || widgets.ValueKind != JsonValueKind.Array || widgets.GetArrayLength() > 32) return null;
|
||||
if (!reading && (!root.TryGetProperty("revision", out var rev) || rev.ValueKind != JsonValueKind.Number || !rev.TryGetInt64(out revision) || revision < 0 || revision >= MaxRevision)) return null;
|
||||
var result = new List<DashboardWidget>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var widget in widgets.EnumerateArray())
|
||||
{
|
||||
if (widget.ValueKind != JsonValueKind.Object || !widget.TryGetProperty("id", out var idValue) || idValue.ValueKind != JsonValueKind.String
|
||||
|| !KnownIds.Contains(idValue.GetString()!) || !seen.Add(idValue.GetString()!)
|
||||
|| !widget.TryGetProperty("visible", out var visible) || (visible.ValueKind != JsonValueKind.True && visible.ValueKind != JsonValueKind.False))
|
||||
{
|
||||
if (reading) continue;
|
||||
return null;
|
||||
}
|
||||
var id = idValue.GetString()!;
|
||||
var size = widget.TryGetProperty("size", out var sizeValue) && sizeValue.ValueKind == JsonValueKind.String ? sizeValue.GetString() : null;
|
||||
if (size is not ("standard" or "full") || (id == "cards" && size != "full"))
|
||||
{
|
||||
if (!reading) return null;
|
||||
size = id == "cards" ? "full" : "standard";
|
||||
}
|
||||
result.Add(new DashboardWidget(id, visible.GetBoolean(), size));
|
||||
}
|
||||
return new DashboardLayout(1, revision, result);
|
||||
}
|
||||
|
||||
public static DashboardLayout Read(string? json, long revision)
|
||||
{
|
||||
if (json is not null && Encoding.UTF8.GetByteCount(json) <= MaxBytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var document = JsonDocument.Parse(json);
|
||||
if (document.RootElement.ValueKind == JsonValueKind.Object
|
||||
&& document.RootElement.TryGetProperty("version", out var version) && version.ValueKind == JsonValueKind.Number
|
||||
&& version.TryGetInt32(out var futureVersion) && futureVersion > 1)
|
||||
return new(futureVersion, revision, []);
|
||||
return Parse(document.RootElement, reading: true, revision) ?? new(1, revision, []);
|
||||
}
|
||||
catch (JsonException) { }
|
||||
}
|
||||
return new(1, revision, []);
|
||||
}
|
||||
}
|
||||
@@ -320,6 +320,7 @@ public static class StartupInitializationExtensions
|
||||
EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
|
||||
// UiLanguage is migration-owned (AddUiLanguagePreference). Adding it here before
|
||||
// the per-migration loop makes a fresh database fail when that migration runs.
|
||||
// DashboardLayoutJson/Revision likewise belong to AddDashboardLayoutPreference.
|
||||
|
||||
static void ReconcileGmailConnectionColumns(DbConnection c)
|
||||
{
|
||||
@@ -539,6 +540,7 @@ public static class StartupInitializationExtensions
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
|
||||
// UiLanguage is migration-owned; the final reconciliation pass observes it
|
||||
// after AddUiLanguagePreference instead of racing the migration.
|
||||
// DashboardLayoutJson/Revision are also migration-owned for both providers.
|
||||
|
||||
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
|
||||
// used to create it too, which made a clean install fail with "Table 'RuleSettings'
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Phase 8 functionality preservation reconciliation
|
||||
|
||||
The exact 57 Phase 7 groups remain: 56 PARTIAL and PRES-C11 DEFERRED. These are retained finite-evidence/qualification statuses, not claims of 56 defects or whole-product acceptance. Original capabilities and remaining obligations are authoritative in [Phase 7](../phase-7/PRESERVATION.md). This table records Phase 8 impact without rewriting historical evidence. Final executed suite outcomes are in [acceptance](p8-05/FINAL_ACCEPTANCE.md).
|
||||
|
||||
| Group and protected capability | Retained status | Phase 8 treatment |
|
||||
|---|---|---|
|
||||
| **PRES-E01** Public product, Free/Pro comparison and honest unavailable billing | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-E02** Local login/register/remember, Turnstile, resend/reset/verify | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-E03** Google/Microsoft sign-in/link/relink, popup state/origin | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-E04** 2FA/recovery/setup/disable/trusted-device revocation | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-E05** Sessions/current-device/revoke/logout | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-E06** Avatar/account identity/email/password | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-E07** Expanded/rail/mobile navigation, active route, commands | PARTIAL | Existing shell/routes retained; draft route/unload warning and acknowledged-save navigation/focus added. |
|
||||
| **PRES-E08** Themes, EN/NB hydration, shared toast/confirm/prompt/errors | PARTIAL | MUI/theme/EN-NB/confirmation/toast preserved; focused keyboard/reflow/axe evidence with human qualifications retained. |
|
||||
| **PRES-E09** Account switch/stale read-write/BFCache/revalidation | PARTIAL | Identity generation/abort and owner-at-submit guards; late acknowledgements/account switch dispose layout state; server owner authorization. |
|
||||
| **PRES-J01** NAV search/filter/sort/source/freshness/details | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-J02** Named saved searches/run/delete/alerts/results/dismiss | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-J03** Reviewed manual/URL/paste creation/company/tags/dates/duplicates | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-J04** Extension/bookmarklet/share capture and optional post-create artifacts | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-J05** Search/filter/sort/pages/columns/saved views/row-card actions | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-J06** Bulk status/delete/restore, soft-delete/Trash, edit | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-J07** Board groups/custom statuses/pointer-keyboard move/cancel/menu | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-J08** Company/contact/source/pipeline-date/history editing | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-J09** Recorded offer compensation/deadline/notes/source | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-J10** CSV/JSON export/import/conversion/counts | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W01** Full job workspace/legacy links/optional overlay | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W02** Details/status/next action/dates/contact/timeline | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W03** Analysis/match/gaps/strategy/reviewed status suggestions | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W04** Checklist CRUD/completion/order/next action | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W05** Select/attach/create/tailor CV and source context | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W06** Manual/AI letter review/reject/apply/history/dirty guard | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W07** Drafts/frozen package versions/archived binaries | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W08** Upload/drop/category/name/consent/preview/download/delete | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W09** Interview categories/questions/answers/manual removal/AI review | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W10** Debrief/follow-up content/date/readiness | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W11** Reminders/rules/notifications/ICS/Google-Outlook events | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-W12** Async progress/failure/retry/cancel/notifications | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C01** All canonical structured/long-tail/custom career fields | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C02** Overview/completeness/import/raw copy/avatar/history restore | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C03** Upload/extract/reprocess/diff/low-confidence select/accept/discard/retry | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C04** Evidence sources/categories/tags/add/list/delete | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C05** Library CRUD/gallery/share/publication/PDF | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C06** Studio tabs/save states/debounce/pages/fit/mobile panes | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C07** Visibility/order/custom sections/item-document overrides/reset | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C08** Capability-aware typography/color/header/skills/sidebar/photo | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C09** A4/Letter/density/margins/dates/document locale/section layout | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C10** Undo/redo/save queue/dirty/history/conflict/preview lifecycle | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C11** Section/document AI improve/translate/instruction/grammar review | DEFERRED | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-C12** Private PDF/public slug preview-PDF/long searchable output | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-M01** Job manual correspondence log/search/delete/paste | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-M02** Gmail candidates/thread import/sync/unlink/relink/move | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-M03** Global inbox filters/page/provider/link/direction/detail/attachments | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-M04** Compose/reply/draft revisions/confirmed send/uncertain attempt | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-M05** Gmail reviewed job/link/reject/keep/notes/sync/create | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-M06** Gmail/Graph/IMAP connect/status/disconnect | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-S01** Personal counts/activity/funnel/company/skills/stage/salary/next action | PARTIAL | Existing exact Dashboard/Analytics report values, source destinations, independent errors and periods retained; all nine existing sections preserved. |
|
||||
| **PRES-S02** Five owner-local flags/onboarding/bell read-dismiss | PARTIAL | Five local flags migrate to the account layout on explicit Save; onboarding/bell retained; visibility/order/size/reset/cross-session/conflict tested. |
|
||||
| **PRES-S03** Rules/notification/table/theme/language/account preferences | PARTIAL | Narrow account layout fields only; stale Identity writes cannot overwrite them; unrelated language/notification/profile/stamp fields preserved. |
|
||||
| **PRES-S04** Privacy/AI usage/limits/server entitlement/billing availability | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-S05** Account ZIP/encrypted backup/lifecycle/deliberate delete | PARTIAL | No product implementation change in this capability; existing full regression and prior human/provider/output qualifications retained. |
|
||||
| **PRES-A01** Admin users/create/roles/reset/delete/last-admin/self guard | PARTIAL | Administration source unchanged; owner-only layout API cannot select another account and ordinary user cannot read admin statistics. |
|
||||
| **PRES-A02** Audit detail/supported undo/job restore | PARTIAL | Audit source unchanged; existing regression remains required. |
|
||||
| **PRES-A03** System/build/DB/AI/mail/SMTP/probe/benchmark/maintenance | PARTIAL | System/admin dashboard source unchanged; no administrative customisation or production operations. |
|
||||
|
||||
All route patterns in App.tsx match `ef94d57` exactly (including the approved Documents destination); no navigation pattern is removed. No renderer, provider, AI model, analytics aggregation, security permission, admin or production configuration is changed. Layout reset only submits layout metadata.
|
||||
|
||||
Performance boundary: report fetches and calculation owners remain the same; the focused ordering test asserts no report request on reorder. Rendering is bounded by nine catalogue entries. No production load, mobile hardware benchmark or claim of faster large-dataset aggregation is inferred. Existing O(N) Board scaling remains separately owned.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Phase 8 — Dashboard customisation
|
||||
|
||||
Starting baseline: `ef94d579cbfec62d1ac38346f556f80276cee95a`, verified clean on 21 October 2026. Direct remote `codex/ui-redesign-phase4` matches. Work branch: `feature/phase8-dashboard-customisation`. No main merge, production operation, Phase 9 or Phase 10 is authorised here.
|
||||
|
||||
## Design and scope
|
||||
|
||||
The owner's complete Phase 8 brief authorises autonomous implementation of visibility, ordering, supported sizing, account persistence and reset. Extend the existing DashboardView report owner; retain every metric, source link, period, loading/error boundary and independent Analytics consumer. Administration is unchanged. Use MUI, PageSection, confirmation/toast, existing native drag reorder and explicit move buttons. No dependencies or dashboard framework.
|
||||
|
||||
One canonical ordered widget list renders with CSS grid: one column below `md`, two above it; full-width spans both. Summary supports full-width only. All other widgets support standard and full-width. No fixed content heights, dense grid packing or CSS-only visual reordering. DOM order is reading order. Mobile retains the saved width but renders every widget in one column.
|
||||
|
||||
Editing is an explicit draft with immediate preview and a labelled management list. Save awaits server acknowledgement, Cancel discards with confirmation, reset replaces only the draft after confirmation. Loading preferences must not allow overwriting unknown server settings. Failed saves retain the draft; 409 offers explicit reload of the latest layout before reapplying changes. Disable mutation controls while saving. Warn on route navigation and unload with dirty drafts. Restore focus after edit exit and announce ordering changes.
|
||||
|
||||
## Widget catalogue
|
||||
|
||||
All widgets are personal/owner-scoped, default visible, available to any authenticated account. These are existing report sections, not new metrics. Empty account guidance and onboarding remain outside the configurable list. Minimum width is the responsive single-column content area (tested down to 320px viewport); standard desktop columns use minmax(0, 1fr), wrapped text and fluid charts/tables.
|
||||
|
||||
| Position | Stable ID | Display / purpose | Existing data owner | Default size | Content states |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | cards | Summary cards / tracked jobs, dated applications, current interviews/offers | stats | full | Summary loading/error shared; no fabricated counts |
|
||||
| 2 | attention | Needs your attention / existing workflow actions | reminders | standard | Summary boundary; explicit nothing due |
|
||||
| 3 | funnel | Current pipeline / canonical stages and raw status links | stats + analytics-overview | standard | Summary boundary; no pipeline data |
|
||||
| 4 | activity | Application activity / existing monthly applications and responses | analytics | full | Independent trends boundary; window-empty, exact values table |
|
||||
| 5 | stageAge | Current stage age / occupancy medians | analytics-overview | standard | Summary boundary; unavailable sample explained |
|
||||
| 6 | sources | Recorded response by source / sample denominators | analytics-overview | standard | Summary boundary; unavailable sample explained |
|
||||
| 7 | salary | Salary insights / separate currency and period samples | analytics-overview | full | Summary boundary; unavailable sample explained |
|
||||
| 8 | companies | Top companies by activity / exact filtered destinations | analytics-overview | standard | Summary boundary; companies-empty |
|
||||
| 9 | skills | Job tags / all-time counts and independent period trends | tags + tag-trends | standard | Trends boundary; tags/trend-empty, exact table |
|
||||
|
||||
The prior five IDs and hidden choices are preserved. Existing non-configurable sections receive stable IDs. Future unknown IDs are never rendered. Missing widgets in a saved version are appended hidden so a new release does not unexpectedly rearrange a user's visible dashboard. Legacy local flags are used only while the account has no saved layout, and become authoritative on the first explicit Save. Other devices without that old local data receive defaults until Save; no claim is made that prior local data already synced.
|
||||
|
||||
## Persistence contract
|
||||
|
||||
Extend the existing account preference owner (`ApplicationUser`) with nullable layout JSON and a default-zero revision, using an additive migration. No generic settings table or unrelated account overwrite. `GET/PUT /api/auth/preferences/dashboard` use local account authentication and derive the owner from the principal, never a supplied account ID. Response/request: `{version: 1, revision, widgets: [{id, visible, size}]}`. Empty widgets with revision zero denotes an unsaved account. Every successful save increments revision atomically with a conditional SQL update. A stale revision returns 409; simultaneous first saves cannot overwrite each other. JSON is bounded and validated, duplicate IDs and unsupported versions rejected, unsupported sizes normalised/rejected safely. No HTML, data query, job data or administrator statistics is accepted.
|
||||
|
||||
SQLite and MariaDB use provider-neutral EF mapping and conditional updates. Explicitly check the existing SQLite startup schema reconciliation/migration infrastructure and the complete migration chain. Legacy accounts gain no layout until first Save. Language, notifications, profile, security stamps and all job/document data remain unchanged.
|
||||
|
||||
### Adding or changing widgets later
|
||||
|
||||
Add a stable ID to both `src/dashboardLayout.ts` and the backend `DashboardLayout` allowlist, supply EN/NB titles and the existing report/data owner, then prove allowed widths, empty/error states and ownership. Do not recycle an ID for another meaning. Existing saved layouts append missing known IDs hidden; unsaved accounts receive catalogue defaults. Unknown stored IDs are ignored on read and rejected on write, so preserving metadata across an incompatible client requires a schema-version increment. Future versions are read-only in an older client and cannot be downgraded by its PUT. A genuine rename/removal needs an explicit versioned ID migration and regression proof; there is no speculative alias framework. Feature availability must remain enforced by the data endpoint, independently of layout metadata.
|
||||
|
||||
## Risk and dependency register
|
||||
|
||||
- Phase 7 is CONDITIONAL GO, not unconditional release acceptance. Read [final acceptance](../phase-7/p7-12/FINAL_ACCEPTANCE.md) and [handoff](../phase-7/p7-12/PHASE8_HANDOFF.md).
|
||||
- The historical save failure includes proven DELETE-mode reader/writer contention; P7 enables WAL before reconciliation. Reuse that baseline and add actual layout persistence/concurrency evidence. WAL does not eliminate all writer contention; no retry-count workaround or permanent reliability claim.
|
||||
- Human screen reader, physical touch/software keyboard, complete real zoom/reflow, other browser/native output and production font checks remain outstanding.
|
||||
- MariaDB runtime is unverified unless a disposable instance is actually exercised; SQL/migration inspection is not runtime proof.
|
||||
- Production remains NO-GO: retained filesystem, independently verified off-disk backup/restore, ingress/configuration/schema, rollback, deployed identity and AI model gates are not cleared by this work. P5-13 stays withheld.
|
||||
- All 57 preservation groups retain the Phase 7 statuses (56 PARTIAL, C11 DEFERRED) until fresh finite evidence is reconciled. No whole-group closure is inferred.
|
||||
|
||||
## Checkpoints and TODO
|
||||
|
||||
- [x] P8-00: baseline suites, catalogue, persistence and prerequisite review; commit/push plan.
|
||||
- [x] P8-01: editing, visibility, draft/cancel, empty recovery, localisation; focused component tests and shared P8-01/02 checkpoint commit/push.
|
||||
- [x] P8-02: existing drag helper, named move buttons, DOM ordering and discrete grid sizing; normalisation/focus tests and shared P8-01/02 checkpoint commit/push.
|
||||
- [x] P8-03: additive account storage, validated endpoint, revision conflict, retry/reset/session isolation; backend/frontend integration proof and checkpoint commit/push.
|
||||
- [x] P8-04: actual browser desktop/tablet/mobile, EN/NB, themes, keyboard, failures and axe; inspected captures and populated sizing regression; human qualifications retained; checkpoint commit/push.
|
||||
- [x] P8-05: full backend1084/frontend917/browser360, separate23 contracts/56sidecar/2sizing/1performance, types/lint/build/security;57-group reconciliation and [final decisions/handoff](p8-05/FINAL_ACCEPTANCE.md); acceptance checkpoint publication.
|
||||
|
||||
## Implementation and verification plan
|
||||
|
||||
Use test-first changes: reproduce missing draft/cancel behaviour before replacing the immediate local menu; pin order/size normalisation before adding it; prove rejected stale revisions/ownership before enabling server saves. Scope reaches DashboardView, a typed layout catalogue/editor, EN/NB translations, existing Dashboard tests and browser consumers, ApplicationUser/EF migration/startup compatibility, the owner-only preference endpoint and its tests. Analytics and administration consumers receive regression verification only.
|
||||
|
||||
Review focus: corrupt/oversized settings; simultaneous first saves; delayed acknowledgements after identity disposal; legacy hidden choices; route cancellation/focus after list movement. Evidence must distinguish real HTTP/storage from mocked UI responses, native browser emulation from physical devices, and source inspection from provider execution.
|
||||
|
||||
Baseline commands: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore`; `npm test -- --runInBand`. Runtime uses the pinned SDK at `C:\Users\Connor\.dotnet\dotnet.exe` when needed. Raw run output is under `.artifacts/phase8/`; portable checkpoint reports record actual totals, commands and limits.
|
||||
@@ -0,0 +1,22 @@
|
||||
# P8-00 — Baseline and architecture
|
||||
|
||||
Completed 21 October 2026. Entry `ef94d579cbfec62d1ac38346f556f80276cee95a`, clean working tree, local and direct remote `codex/ui-redesign-phase4` equal. Requested Phase 8 branch did not exist locally or remotely; created `feature/phase8-dashboard-customisation` from that entry. Planning checkpoint `421f902d21c08a814de12d7c32bc6ab8d4c01e15` pushed normally; `git ls-remote` directly confirms it. Main remains outside this work.
|
||||
|
||||
Reviewed Phase 7 final acceptance and Phase 8 handoff, Phase 6 handoff, original implementation plan, dashboard/Phase 3 architecture, design system, responsive/accessibility, guardrails, information architecture, flows and master spec. The current implementation confirms only five local visibility flags; attention, stage-age, source-response and salary sections were outside them. The new nine-entry [catalogue and design](../README.md) preserves these sections and all existing data owners.
|
||||
|
||||
## Fresh starting tests
|
||||
|
||||
| Command | Result | Evidence |
|
||||
|---|---|---|
|
||||
| Pinned .NET `test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore` | 1,060 passed; 0 failed, skipped; 16s test duration | `.artifacts/phase8/baseline/p8-baseline.trx`, `baseline-backend.log` |
|
||||
| UI `npm test -- --runInBand --json --outputFile=../.artifacts/phase8/baseline-frontend.json` | 899 passed / 101 suites; 0 failed, pending; 284.546s | `.artifacts/phase8/baseline-frontend.json`, `baseline-frontend.log` |
|
||||
|
||||
These executions precede product source changes. PowerShell's native stderr wrapping is retained in the raw log; Jest's result JSON records the actual outcomes. Historic Phase 7 browser351, separate23 and sidecar56 are inherited evidence, not fresh Phase 8 runs.
|
||||
|
||||
## Prerequisites and decisions
|
||||
|
||||
Account persistence is a necessary extension explicitly approved by the current brief, superseding Phase 3's proposed device-first option. Extend ApplicationUser with two additive fields and a narrow owner-only GET/PUT. Compare-and-update revision in one SQL statement; avoid whole-Identity updates for layout saves. Supported sizes are standard/full with a fixed full-width summary, one logical DOM order and single-column mobile. Reuse installed native drag helper and touch/keyboard move buttons; no dependency comparison/download needed.
|
||||
|
||||
The known failure investigation reviewed P7's deterministic held-reader reproduction against DELETE journaling, its WAL-before-bootstrap repair, and finite passing regression. Add actual layout reader/write and competing-save tests on that baseline. Do not infer permanent immunity to writer contention or identify the historical browser's exact competing connection.
|
||||
|
||||
No mandatory missing dependency blocks safe development. Retained conditions remain: real MariaDB runtime, human screen reader/physical devices/complete enlargement, other-browser/output and production release checks. Production NO-GO, P5-13 withheld. The [risk register](../README.md) carries these forward.
|
||||
@@ -0,0 +1,18 @@
|
||||
# P8-01 — Editing and visibility
|
||||
|
||||
Implemented an explicit inline editing mode using existing MUI/PageSection/confirmation/toast patterns. All nine existing report sections are configurable. Visibility updates only the draft preview; Save waits for the owner-only API acknowledgement; Cancel confirms a dirty discard and never writes. Every widget may be hidden, with an intentional empty message and the persistent Customise action. Existing onboarding/new-account guidance stays reachable. All new copy is English/Bokmål.
|
||||
|
||||
The typed catalogue and persistence hook are one shared prerequisite; visibility/order/size controls are one coherent editor. P8-01 and P8-02 share that frontend source commit rather than manufacture an intermediate editor that cannot represent its saved server contract. Backend storage was implemented first to make the first real Save safe. This is a checkpoint ordering/grouping deviation, not additional scope.
|
||||
|
||||
## Fresh evidence
|
||||
|
||||
- Original five new editor cases failed against the immediate-menu implementation (`editor-red.log`). Integrated focused run now passes **36 tests / 6 suites** (`dashboard-final.json`): Dashboard presentation, Analytics presentation, daily control loop, catalogue, layout hook and six editor cases. Zero failed/skipped in that run; the full frontend remains a separate final lane.
|
||||
- Existing exact monthly values, denominators, custom status/company destinations, independent summary/trends errors and period loading remain asserted. Original five-flag immediate local-write test now asserts all nine choices through explicit Save and remount with persisted server readback fixture.
|
||||
- Catalogue/hook tests exercise corrupted/unknown/duplicate IDs, unsupported sizes, missing new widgets appended hidden, legacy owner-only flags, failed save/retry, unsupported future version, 409/reload and late responses after identity change/unmount. Mocked component API responses are not cross-device HTTP evidence.
|
||||
- Corrected real browser run passed **10/10**, zero skipped/retried/flaky (`browser-corrected.json`): two retained Dashboard journeys plus eight new Phase 8 cases. Actual SQLite/API/cookies/CSRF verify saved readback, fresh browser login, logout/login, two-session conflict, isolation, reset/cancel, failure/retry and all-hidden recovery. The initial prebuilt bundle targeted port5202 instead of the isolated5302 API; its 10 failures are retained in `browser-focused.json`. Rebuild with `NEXT_PUBLIC_API_BASE_URL=http://localhost:5302/api` corrected the harness without relaxing assertions.
|
||||
|
||||
## Review repairs and conditions
|
||||
|
||||
Review reproduced pending-save navigation offering an untrue discard promise. A deferred-PUT test failed first, then passed after the guard began holding SPA navigation with a truthful wait status during saving. Aborting a browser request is never represented as a server rollback. A browser unload warning cannot guarantee save cancellation.
|
||||
|
||||
Screenshots exposed edit-entry focus scrolling the heading beneath the fixed header. An actual browser geometry regression failed against that bundle; local scroll-margin plus deliberate focus/scroll is now implemented, with the succeeding full browser run pending. New controls preserve the app's focus ring, native selects and 44px pointer controls. Final responsive, axe and human qualification is recorded in P8-04 rather than inferred from unit tests.
|
||||
@@ -0,0 +1,11 @@
|
||||
# P8-02 — Ordering and supported sizes
|
||||
|
||||
The existing `useDragReorder` provides desktop handle drag and an explicit drop-target border. The dedicated handle prevents ordinary widget actions from starting a drag. Named Earlier/Later buttons are the keyboard and touch alternative. Stable React keys, DOM order, a live position announcement and focus retention cover moving the focused control to the first/last position. No new drag library or dependency was added.
|
||||
|
||||
All nine IDs use one canonical order, including hidden widgets. Hiding preserves size and position. Summary cards support full width only; the other eight report sections support standard/full. The existing report nodes and accessible values tables render inside a two-column CSS grid at `md` and larger. Full width spans both columns. Below `md`, every widget uses one readable full-width column; saved widths remain unchanged. No dense packing, CSS visual-only order, fixed heights or arbitrary pixels.
|
||||
|
||||
Catalogue normalisation preserves known order, ignores unknown/duplicate IDs, falls back from unsupported sizes and appends absent entries hidden for saved revisions. The server separately rejects invalid writes, rather than trusting the frontend. The [catalogue](../README.md) names all defaults/data owners.
|
||||
|
||||
Integrated **36/36** focused frontend tests and corrected **10/10** real browser cases include order/size/visibility readback, native handle drag, keyboard focus/status, emulated touch button movement and zero page overflow at320/390/768/1440px in both themes/languages. Source evidence: `.artifacts/phase8/dashboard-final.json`, `browser-corrected.json`. The shared editor source is committed with P8-01 as documented there. Later fixed-header focus repair and full-regression results belong to final acceptance.
|
||||
|
||||
Physical touchscreen, software keyboard, human screen reader, complete true browser zoom, other-browser and production render qualification remain unperformed. Emulation does not close these requirements.
|
||||
@@ -0,0 +1,66 @@
|
||||
# P8-03 backend persistence evidence
|
||||
|
||||
Verified locally on 21 October 2026 against the Phase 7 WAL baseline. No production database or external service was used.
|
||||
|
||||
## Contract and ownership
|
||||
|
||||
`GET` and `PUT /api/auth/preferences/dashboard` require the existing `local` authentication scheme. The principal supplies the account ID; query/body account IDs have no authority. Missing/unknown subjects return 401. DTO: `{version:1, revision, widgets:[{id,visible,size}]}`. No saved layout returns revision 0 and an empty widget array. Successful PUT returns the canonical DTO with revision incremented by one; a stale revision returns 409 with `error: dashboard_revision_conflict`.
|
||||
|
||||
Writes accept at most 16,384 actual UTF-8 bytes, including chunked requests; larger bodies return 413. Invalid JSON, unsupported/missing version, non-integer/negative/exhausted revision, absent widgets, more than 32 entries, unknown/duplicate IDs, absent/non-boolean visibility, and invalid sizes return 400 without saving. Incoming revision must be below 9,007,199,254,740,991 so the incremented revision remains an exact JavaScript integer. Supported IDs are the nine in the Phase 8 catalogue. `cards` accepts only `full`; every other widget accepts `standard` or `full`. Unsupported sizes are rejected on writes. Only validated canonical fields are stored; extra request fields are discarded. An empty saved list is valid, but has a positive revision and therefore remains distinct from an unsaved account. The frontend owns missing-widget defaults and explicit reset behaviour.
|
||||
|
||||
Reads retain the database revision. Unknown, duplicate or malformed saved widgets are omitted; unsupported saved sizes become `full` for cards and `standard` otherwise. Corrupt or oversized stored JSON becomes an empty list without resetting the revision. This permits a deliberate revision-guarded repair save and prevents corrupt storage from rendering arbitrary content. A bounded JSON document declaring an integer schema version greater than 1 instead exposes that actual version and an empty widget list. PUT rejects such stored future schemas with 409 `dashboard_version_unsupported`, even when a version-1 request supplies the matching revision; newer settings remain intact and the frontend can disable editing.
|
||||
|
||||
`ApplicationUser` owns nullable `DashboardLayoutJson` and default-zero `DashboardLayoutRevision`. The endpoint uses one SQL `ExecuteUpdateAsync` guarded by owner ID and expected revision; it updates exactly these two columns. Simultaneous first writes cannot both win. No layout write invokes `UserManager.UpdateAsync`.
|
||||
|
||||
The future-schema guard and expected revision come from the same initial row snapshot. After the bounded body is read, its supplied revision must match that snapshot before the conditional SQL update runs. This closes a reproduced race where a paused chunked request could guess a newer revision installed with a future schema after the earlier schema check. The deterministic HTTP regression observes EF's completed initial row read, installs version 2/revision 1 on another connection while the body is held, then releases a version-1/revision-1 body. Before repair it returned 200 and overwrote the future settings; after repair it must return 409 and preserve the exact JSON/revision. The existing SQL revision predicate also rejects an intervening write after the snapshot comparison.
|
||||
|
||||
A reproduced additional race showed that an unrelated stale Identity whole-user update could otherwise replay the old layout/revision. Both layout properties ignore ordinary EF tracked updates after insertion. Their conditional endpoint update remains effective. The regression marks every property modified, matching Identity's store behaviour, then proves the newer layout survives. Profile, language, notification settings and Identity stamps remain unchanged by layout saves.
|
||||
|
||||
## Migration and startup
|
||||
|
||||
Additive migration `20261021090000_AddDashboardLayoutPreference` adds nullable TEXT/longtext JSON and non-null INTEGER/bigint revision default 0. No new table or backfill of layout choices. The snapshot includes both properties. SQLite and MariaDB startup reconciliation intentionally leave these columns to the migration, matching the existing language preference owner; adding them before the per-migration loop would collide on fresh startup. Actual fresh and legacy file startup plus existing complete migration-chain and repeated-startup tests pass.
|
||||
|
||||
The explicit SQLite downgrade drops only these independent columns using native SQLite `DROP COLUMN` (requires SQLite 3.35+, satisfied by the tested bundled runtime). MariaDB uses normal EF DropColumn operations. Migration-chain downgrade/upgrade tests pass; downgrade necessarily removes saved layouts. No downgrade was performed outside disposable tests.
|
||||
|
||||
## Test evidence
|
||||
|
||||
All commands used the pinned executable `C:\Users\Connor\.dotnet\dotnet.exe` through `rtk`, project `JobTrackerApi.Tests/JobTrackerApi.Tests.csproj`, flags `--configuration Release --no-restore`, and a TRX logger/results directory below `.artifacts/phase8/`.
|
||||
|
||||
| Run | Filter | Actual result | TRX |
|
||||
|---|---|---|---|
|
||||
| Meaningful pre-implementation RED | `FullyQualifiedName~DashboardPreferencesTests` | 0 pass / 13 fail / 0 skipped; absent routes/model | `backend-red/dashboard-red.trx` |
|
||||
| Initial implementation | same | 13 pass / 0 fail / 0 skipped | `backend-green/dashboard-green.trx` |
|
||||
| Stale Identity race RED | `FullyQualifiedName~Stale_identity_profile_update` | 0 pass / 1 fail; saved revision 1 overwritten by 0 | `backend-stale-red/dashboard-stale-red.trx` |
|
||||
| First expanded run | dashboard + migration chain + SQLite startup + schema ownership | 25 pass / 16 fail; 15 downgrade SQL generation failures and one malformed fixture SQL | `backend-focused/dashboard-focused.trx` |
|
||||
| Downgrade fix successor | same | 40 pass / 1 fail; remaining malformed fixture SQL | `backend-focused-successor/dashboard-focused.trx` |
|
||||
| Final expanded run | same, plus two further dashboard HTTP cases | **43 pass / 0 fail / 0 skipped** | `backend-focused-final/dashboard-focused.trx` |
|
||||
| Complete backend | no filter | **1081 pass / 0 fail / 0 skipped**, 29 seconds | `backend-full/dashboard-full.trx` |
|
||||
| Future-schema / JS-safe revision RED | `FullyQualifiedName~Future_schema\|FullyQualifiedName~Invalid_payload_does_not_save` | 12 pass / 2 fail; future version hidden and unsafe exhausted revision accepted | `backend-future-red/dashboard-future-red.trx` |
|
||||
| Complete backend after future-schema repair | no filter | **1083 pass / 0 fail / 0 skipped**, 32 seconds | `backend-full-final/dashboard-full.trx` |
|
||||
| Future-schema paused-body race RED | `FullyQualifiedName~Future_schema_installed` | 0 pass / 1 fail; returned 200 instead of 409 | `backend-toctou-red/dashboard-toctou-red.trx` |
|
||||
| Paused-body repair focused | `FullyQualifiedName~DashboardPreferencesTests` | **24 pass / 0 fail / 0 skipped**, 24 seconds | `backend-toctou-focused/dashboard-toctou-focused.trx` |
|
||||
| Complete backend after paused-body repair | no filter | **1084 pass / 0 fail / 0 skipped**, 33 seconds | `backend-toctou-full/dashboard-full.trx` |
|
||||
|
||||
The initial test-harness attempt omitted historical startup reconciliation and failed before reaching endpoints (missing historical AvatarImageDataUrl column). The meaningful RED above reran after wiring the actual startup owner. The subsequent malformed fixture SQL used a literal brace through EF's formatting API; parameterising it corrected the fixture without changing the product expectation.
|
||||
|
||||
Final focused command:
|
||||
|
||||
```powershell
|
||||
rtk C:\Users\Connor\.dotnet\dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --filter "FullyQualifiedName~DashboardPreferencesTests|FullyQualifiedName~MigrationChainTests|FullyQualifiedName~SqliteStartupConcurrencyTests|FullyQualifiedName~StartupSchemaOwnershipTests" --logger "trx;LogFileName=dashboard-focused.trx" --results-directory .artifacts/phase8/backend-focused-final
|
||||
```
|
||||
|
||||
Final complete command:
|
||||
|
||||
```powershell
|
||||
rtk C:\Users\Connor\.dotnet\dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore --logger "trx;LogFileName=dashboard-full.trx" --results-directory .artifacts/phase8/backend-toctou-full
|
||||
```
|
||||
|
||||
The 24 new dashboard cases use an actual local Kestrel HTTP listener, real EF SQLite files, the complete migrations and actual startup reconciliation. They verify 401, owner isolation despite an attempted query account override, GET/PUT acknowledgement, 409, concurrent first saves, unchanged unrelated account fields/stamps, stale Identity preservation, invalid/oversized/chunked payloads, all catalogue IDs/order, empty saved lists, corrupt reads, future-schema downgrade protection including a deterministic paused-body race, exact JavaScript revision bounds, legacy defaults and provider SQL generation. They also hold a SELECT reader open while saving the actual layout against the WAL file.
|
||||
|
||||
## Limits
|
||||
|
||||
The HTTP fixture substitutes a test-only local authentication handler; it proves endpoint scheme/owner enforcement, not production cookie issuance, CSRF/session expiry or proxy behaviour. It uses the existing minimal EF host conventions; TestHostFactory has no reusable real HTTP host, so the fixture adds only the services needed by AuthController/startup. Existing auth tests remain in the passing complete suite. Browser/session integration belongs to the UI acceptance lane.
|
||||
|
||||
MariaDB migration SQL is generated with the installed Pomelo provider and explicitly checks `longtext NULL` and `bigint NOT NULL DEFAULT 0`. This is SQL inspection, not MariaDB runtime evidence. `rtk docker ps --format "{{.Names}} {{.Image}} {{.Ports}}"` failed because the Docker Desktop Linux engine pipe was unavailable; no disposable MariaDB instance was exercised or created.
|
||||
|
||||
The held-reader and simultaneous-save tests establish finite regression evidence. WAL does not eliminate every form of writer contention; no retries, swallowed provider failures or permanent reliability claim were introduced. Human/device, deployed configuration, backup/restore and production gates remain outstanding; production remains NO-GO.
|
||||
@@ -0,0 +1,27 @@
|
||||
# P8-04 — Responsive and accessibility qualification
|
||||
|
||||
The final feature build uses the existing app theme, header measurements and MUI controls. No dependencies were added. The final acceptance report records the succeeding whole-browser run separately from the focused evidence below.
|
||||
|
||||
## Executed focused browser evidence
|
||||
|
||||
`browser-qualification.json` records **13 passed / 0 failed, skipped, flaky or retried** in93.929s: retained two Analytics journeys, retained two Dashboard journeys and nine Phase 8 cases. Actual local API/SQLite/accounts/session cookies/CSRF are used. Explicitly controlled GET/PUT503 and route-abort transport failures exercise recovery without implying a physical network outage. Fresh contexts and real logout/login prove account persistence across browser lifetimes; these are not physically separate devices.
|
||||
|
||||
Viewports320/390/768/1440px cover editor EN/Bokmål and light/dark; retained dashboard journeys also cover1024/1920px. Every measured root page has no horizontal overflow. Native selects keep long translated labels accessible; visual Size labels are short while complete widget-specific accessible names remain. Desktop pointer handle drag, keyboard Earlier/Later with live position/focus, emulated-touch button reordering and readback all pass. Summary stays full width; one logical DOM order becomes a single-column feed on small screens.
|
||||
|
||||
Forty-two initial Dashboard/editor PNGs were inspected through four contact sheets and representative full-size crops. Final desktop/light/English and320px/dark/Bokmål editor crops were separately inspected after the focus repair; final raw qualification contains60 distinct screenshots including Analytics. This does not claim an individual full-size inspection of every image. Exact metric/month/sample/source-link and persisted API assertions supply behavioral evidence beyond screenshots.
|
||||
|
||||
Two final-build captures from the uninterrupted whole-browser run are retained with this checkpoint: [English/light desktop editor](evidence/editor-en-light-1440.png) and [Bokmål/dark320px editor](evidence/editor-nb-dark-320.png). Both were inspected; they show the repaired edit-entry position and short visible size labels with the complete control names preserved in the accessibility tree.
|
||||
|
||||
## Reproduced issues and repairs
|
||||
|
||||
- A subsequent populated sizing review found salary amounts breaking into digit fragments at standard width despite passing overflow checks. The new diagnostic first failed its actual text-line assertion (one failed, one passed), then passed both complete standard/full cases after the standard personal widget's salary groups became a single column. Full-width and Analytics retain their existing three-column desktop layout. All eight resizable widgets were saved/read back at both sizes and measured at320/768/1024/1440px, with tables/charts contained and no root overflow. Eight captures were produced; the standard1024 capture was inspected and its [salary crop](evidence/salary-standard-1024.png) retained. The complete ordinary browser suite was then rerun against the rebuilt bundle; final results are recorded in acceptance.
|
||||
- Edit entry focused the panel beneath the fixed app header. A browser geometry check first failed (`focus-red.json`); deliberate preventScroll focus, the existing `--app-shell-header-height` scroll margin and explicit scroll now pass at320/768/1440px. The final reviewed crops show the heading and Save/Cancel controls below the header.
|
||||
- Pending-save route navigation offered a misleading discard promise. A deferred acknowledgement unit regression first failed, then passed; SPA navigation now waits with a localized status while saving. Browser unload warnings do not promise rollback of server writes.
|
||||
- Axe's incomplete triage identified a labelled preview `div` without a valid role. A role-based test failed first (`preview-semantics-red.log`); the preview is now a named region. The new editor browser case explicitly rejects `aria-prohibited-attr` incompletes. Final scan totals are in final acceptance; the earlier ten scans had0 automated violations and68 incomplete nodes (61contrast,6preview semantics,1drawer focus), so those earlier results are not presented as the repaired totals.
|
||||
- The Analytics all-hidden fixture still set only the original five local flags. Its failure remains in the interrupted full run. It now saves all nine hidden flags through the real owner endpoint, preserving the original Analytics exact-value/navigation/isolation assertions. The focused successor passes.
|
||||
- The initial prebuilt browser run pointed to5202 rather than isolated5302; ten startup/data failures are retained. Rebuilt with explicit test API URL; no product default, API assertion or auth guard was weakened.
|
||||
- Lint identified the Playwright fixture callback named `use` as a React hook; renamed the callback to `provide` without changing test behavior.
|
||||
|
||||
## Required manual qualification
|
||||
|
||||
Human screen readers, physical touch/software keyboard, complete actual browser zoom/text enlargement/reflow, other browsers, deployed Linux/fonts/photo/native print, manual layered-contrast review and retained drawer settled-state focus require real operators. Automated axe/emulation/screenshots do not satisfy them. No production or provider test is inferred. Existing vendor strings and Phase 7 accessibility/output conditions remain visible in [Phase 7 acceptance](../../phase-7/p7-12/FINAL_ACCEPTANCE.md).
|
||||
@@ -0,0 +1,68 @@
|
||||
import { test, expect, type APIRequestContext } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
async function login(request: APIRequestContext, email: string) {
|
||||
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await request.get(`${api}/auth/me`)).json();
|
||||
const csrf = (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
|
||||
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
|
||||
}
|
||||
|
||||
test('Finite 250-job dashboard load and edits preserve report request counts', async ({ page, playwright }, info) => {
|
||||
test.setTimeout(120000);
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `p8-performance-${Date.now()}@example.test`;
|
||||
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const headers = await login(page.request, email);
|
||||
const company = await page.request.post(`${api}/companies`, { headers, data: { name: 'Finite dashboard performance fixture' } });
|
||||
expect(company.status()).toBe(201);
|
||||
const companyId = (await company.json()).id;
|
||||
for (let index = 0; index < 250; index++) {
|
||||
expect((await page.request.post(`${api}/jobapplications`, { headers, data: { companyId, jobTitle: `Dashboard ${index}`, status: 'Applied', dateApplied: new Date().toISOString(), tags: 'TypeScript', salaryMin: 600000, salaryMax: 850000, salaryCurrency: 'NOK', salaryPeriod: 'year' } })).status()).toBe(201);
|
||||
}
|
||||
await page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
const reports = ['stats', 'analytics-overview', 'reminders', 'analytics', 'tags', 'tag-trends'];
|
||||
const counts: Record<string, number> = {};
|
||||
page.on('request', request => {
|
||||
const name = new URL(request.url()).pathname.replace('/api/jobapplications/', '');
|
||||
if (request.method() === 'GET' && reports.includes(name)) counts[name] = (counts[name] ?? 0) + 1;
|
||||
});
|
||||
const observations = [];
|
||||
for (const width of [1440, 320]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const beforeLoad = { ...counts };
|
||||
const started = Date.now();
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByRole('link', { name: 'Tracked jobs: 250', exact: true })).toBeVisible();
|
||||
await expect(page.locator('[data-dashboard-widget]')).toHaveCount(9);
|
||||
// The existing shell badge and dashboard independently read reminders on entry.
|
||||
await expect.poll(() => reports.every(name => counts[name] === (beforeLoad[name] ?? 0) + (name === 'reminders' ? 2 : 1))).toBe(true);
|
||||
const loadMs = Date.now() - started;
|
||||
const beforeEdits = { ...counts };
|
||||
await page.getByRole('button', { name: 'Customize dashboard', exact: true }).click();
|
||||
const editor = page.getByRole('region', { name: 'Editing dashboard', exact: true });
|
||||
const editStarted = Date.now();
|
||||
await editor.getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck();
|
||||
await editor.getByRole('checkbox', { name: 'Job tags', exact: true }).check();
|
||||
const row = editor.locator('[data-layout-row="funnel"]');
|
||||
await row.locator('select').selectOption(width === 1440 ? 'full' : 'standard');
|
||||
await row.getByRole('button', { name: /earlier/ }).click();
|
||||
const editMs = Date.now() - editStarted;
|
||||
const saveStarted = Date.now();
|
||||
const write = page.waitForResponse(response => response.url() === `${api}/auth/preferences/dashboard` && response.request().method() === 'PUT');
|
||||
await editor.getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||
expect((await write).status()).toBe(200);
|
||||
await expect(editor).toHaveCount(0);
|
||||
expect(counts).toEqual(beforeEdits);
|
||||
observations.push({ width, fixtureJobs: 250, loadMs, editSequenceMs: editMs, acknowledgedSaveMs: Date.now() - saveStarted,
|
||||
reportRequests: Object.fromEntries(reports.map(name => [name, counts[name] - (beforeLoad[name] ?? 0)])),
|
||||
renderer: await page.evaluate(() => ({ domElements: document.querySelectorAll('*').length,
|
||||
heapBytes: (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory?.usedJSHeapSize ?? null })) });
|
||||
}
|
||||
await info.attach('finite-dashboard-performance', { body: JSON.stringify({ observations,
|
||||
limitation: 'Two local unthrottled Chromium viewport observations; no Phase7 timing comparison, capacity/SLA, physical mobile resource or sustained drag-frame guarantee.' }, null, 2), contentType: 'application/json' });
|
||||
} finally { await admin.dispose(); }
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
|
||||
import base from '../../../../../job-tracker-ui/playwright.config';
|
||||
import path from 'node:path';
|
||||
|
||||
const cwd = path.resolve(__dirname, '../../../../../job-tracker-ui');
|
||||
const servers = Array.isArray(base.webServer) ? base.webServer : [base.webServer!];
|
||||
export default defineConfig({ ...base, testDir: __dirname, testMatch: ['sizes.spec.ts', 'performance.spec.ts'],
|
||||
webServer: servers.map(server => ({ ...server, cwd })) });
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect, type APIRequestContext } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
|
||||
import { WIDGETS } from '../../../../../job-tracker-ui/src/dashboardLayout';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
async function login(request: APIRequestContext, email: string) {
|
||||
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await request.get(`${api}/auth/me`)).json();
|
||||
const csrf = (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
|
||||
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
|
||||
}
|
||||
|
||||
for (const size of ['standard', 'full'] as const) test(`All populated widgets at ${size} across responsive widths`, async ({ page, playwright }, info) => {
|
||||
test.setTimeout(120000);
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `p8-sizes-${size}-${Date.now()}@example.test`;
|
||||
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const headers = await login(page.request, email);
|
||||
const created = await page.request.post(`${api}/companies`, { headers, data: { name: 'Very long Norwegian company name for readable widget layouts', source: 'Native size qualification' } });
|
||||
expect(created.status()).toBe(201);
|
||||
const companyId = (await created.json()).id;
|
||||
expect((await page.request.post(`${api}/jobapplications`, { headers, data: { companyId, jobTitle: 'Senior platform developer', status: 'Applied', dateApplied: new Date(Date.now() - 86400000).toISOString(), responseReceived: true, responseDate: new Date().toISOString(), tags: 'TypeScript, Norsk programvareutvikling', salaryMin: 600000, salaryMax: 850000, salaryCurrency: 'NOK', salaryPeriod: 'year' } })).status()).toBe(201);
|
||||
await page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
await page.goto('/dashboard');
|
||||
await page.getByRole('button', { name: 'Customize dashboard', exact: true }).click();
|
||||
const editor = page.getByRole('region', { name: 'Editing dashboard', exact: true });
|
||||
for (const widget of WIDGETS) if (widget.sizes.length > 1) {
|
||||
const select = editor.locator(`[data-layout-row="${widget.id}"] select`);
|
||||
await select.selectOption(size); await expect(select).toHaveValue(size);
|
||||
}
|
||||
await editor.getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
const readback = await (await page.request.get(`${api}/auth/preferences/dashboard`)).json();
|
||||
expect(readback.widgets.every((widget: { id: string; size: string }) => widget.size === (widget.id === 'cards' ? 'full' : size))).toBe(true);
|
||||
for (const summary of await page.locator('[data-dashboard-widget] details > summary').all()) await summary.click();
|
||||
for (const width of [320, 768, 1024, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect(page.locator('[data-dashboard-widget]')).toHaveCount(9);
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
const failures = await page.locator('[data-dashboard-widget]').evaluateAll(widgets => widgets.flatMap(widget => {
|
||||
const box = widget.getBoundingClientRect();
|
||||
return [...widget.querySelectorAll('table, svg, th, td')].filter(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && (rect.right > box.right + 1 || rect.left < box.left - 1);
|
||||
}).map(element => ({ widget: widget.getAttribute('data-dashboard-widget'), tag: element.tagName }));
|
||||
}));
|
||||
expect(failures).toEqual([]);
|
||||
const amount = page.locator('[data-dashboard-widget="salary"]').getByText(new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'NOK', maximumFractionDigits: 0 }).format(725000), { exact: true });
|
||||
await expect(amount).toBeVisible();
|
||||
expect(await amount.evaluate(element => {
|
||||
const range = document.createRange(); range.selectNodeContents(element);
|
||||
return range.getClientRects().length;
|
||||
})).toBe(1);
|
||||
const screenshot = info.outputPath(`${size}-${width}.png`);
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
await info.attach(`${size}-${width}`, { path: screenshot, contentType: 'image/png' });
|
||||
}
|
||||
} finally { await admin.dispose(); }
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,90 @@
|
||||
# Phase 8 — Dashboard customisation acceptance
|
||||
|
||||
Accepted with the qualifications below on22 October2026 (local time). This report qualifies the implementation against the accepted Phase7 baseline `ef94d579cbfec62d1ac38346f556f80276cee95a`. Work is confined to `feature/phase8-dashboard-customisation`; accepted product source is `2f6227fb4f9a6cc61838e8afde9e2dafd00bd428`. The final complete browser run exercised the same815 selected source/test, Release-assembly and static-export inputs recorded at its entry, with zero subsequent differences. Documentation and the separate performance diagnostic follow that source checkpoint.
|
||||
|
||||
## Independent decisions
|
||||
|
||||
| Decision | Status | Boundary |
|
||||
|---|---|---|
|
||||
| Phase 8 completion | **CONDITIONAL GO** | All requested capabilities are implemented; complete local regression and focused repairs pass. Human/device/provider qualifications below remain open. |
|
||||
| Phase 9 readiness | **CONDITIONAL GO** | Whole-application QA may proceed with the explicit handoff and retained obligations below. Phase9 has not started. |
|
||||
| Production release | **NO-GO** | No separate release process has cleared the retained mandatory gates. |
|
||||
|
||||
## Delivered scope and checkpoints
|
||||
|
||||
The personal dashboard now has one explicit, labelled editor and a live preview. All nine existing sections can be hidden/restored and reordered. The eight resizable reports support standard/full width; summary cards remain full width. A central typed catalogue supplies defaults, stable IDs, allowed sizes and normalization. Reading order follows the canonical DOM order at every width. Analytics retains its independent reporting view and does not load personal layout preferences. Administrator views and report data contracts were preserved.
|
||||
|
||||
Save waits for an acknowledged owner-scoped API write. Cancel restores the saved layout; Reset changes only the draft after confirmation and still requires Save. Failures retain the draft. Stale revisions offer explicit reload rather than silent overwriting. Empty layouts have an accessible recovery action. Loading/future-schema guards prevent unsafe editing; auth generations and cancellation protect account switches and stale responses. Legacy account-local hidden flags are used only before a server layout exists and sync on the first explicit Save.
|
||||
|
||||
| Checkpoint | Result and evidence |
|
||||
|---|---|
|
||||
| P8-00 | Complete, published: fresh baseline suites, catalogue/design, architecture and Phase 7 prerequisite review; [baseline](../p8-00/BASELINE.md). |
|
||||
| P8-01 | Complete, published: visibility, draft/save/cancel/reset, errors, empty recovery, EN/NB; [UI checkpoint](../p8-01/README.md). |
|
||||
| P8-02 | Complete, published: existing drag handle, named Earlier/Later controls, focus/live position, discrete sizes, responsive canonical grid; [layout checkpoint](../p8-02/README.md). |
|
||||
| P8-03 | Complete, published: additive persistence, strict HTTP contract, optimistic concurrency and isolation; [backend evidence](../p8-03/BACKEND.md). |
|
||||
| P8-04 | Complete, published: native browser matrices, keyboard, emulated touch, exact values, axe triage and populated sizing regression; [responsive/accessibility evidence](../p8-04/README.md). |
|
||||
| P8-05 | Complete local acceptance: backend/frontend/ordinary browser/contracts/sidecar, types/lint/build/security,57-group reconciliation, final decisions and handoff; published by the acceptance commit containing this report. |
|
||||
|
||||
P8-01/02 share their integrated UI checkpoint because the same editor/render owner implements visibility, order and sizing. Persistence was implemented first to support acknowledged end-to-end saves. These are sequencing/documentation deviations, not omitted capabilities. No dependencies, dashboard framework or generic settings service were added. No main merge, production deployment, production migration, AI model change or destructive live-data operation occurred.
|
||||
|
||||
## Contract, security and evolution
|
||||
|
||||
`GET/PUT /api/auth/preferences/dashboard` derives the account from local authentication. The version1 payload is `{version, revision, widgets:[{id,visible,size}]}`; revision0 with an empty list denotes an unsaved account. The additive migration adds nullable layout JSON and a default-zero revision to the existing account owner. Strict allowed IDs, boolean/size/version validation, duplicate rejection, maximum32 entries and an actual16KB byte cap apply even to chunked requests. Preferences contain presentation metadata only and cannot grant data access.
|
||||
|
||||
The conditional owner-and-revision update increments revision atomically; concurrent first saves and stale saves conflict with409. The revision read before the body must also match, preventing a future-schema update during a paused body from being downgraded. Unrelated Identity updates cannot replay stale layout columns. Corrupt reads normalize safely; unknown stored IDs are ignored, missing known widgets in saved revisions append hidden, and future schemas remain read-only to older clients. JavaScript-unsafe revisions are rejected. See the [full catalogue/evolution contract](../README.md).
|
||||
|
||||
Actual local HTTP and cookie/session browser tests cover anonymous401, ordinary-user admin403, A/B ownership, attempted account override, stale session/CSRF, independent preference fields and logout/login/fresh-context persistence. Backend tests use a test auth handler; browser tests exercise real local cookies/session context/CSRF. Neither establishes deployed identity or proxy acceptance. SQLite held-reader/write and simultaneous-save tests pass on WAL; this is finite contention evidence, not permanent immunity. SQLite migration/startup/chain tests pass. MariaDB provider SQL generation passes; MariaDB runtime remains untested because Docker's Linux engine is unavailable.
|
||||
|
||||
## Executed regression and build evidence
|
||||
|
||||
Raw output is retained under `.artifacts/phase8/`; portable counts, hashes and source identities are in [validation.json](evidence/validation.json) and [source-manifest.json](evidence/source-manifest.json). No retries, skips or flakes are counted as passes in accepted browser lanes. Targeted/standard-contract counts below are separate from the ordinary suite.
|
||||
|
||||
| Lane | Actual completed result | Evidence |
|
||||
|---|---|---|
|
||||
| Entry backend | 1,060 passed; 0 failed/skipped | `baseline/p8-baseline.trx` |
|
||||
| Complete backend after final API repair | **1,084 passed; 0 failed/skipped**, including24 new dashboard cases | `backend-toctou-full/dashboard-full.trx` |
|
||||
| Entry frontend | 899 tests /101 suites passed; 0 failed/pending | `baseline-frontend.json` |
|
||||
| Complete frontend before final salary CSS fix | 917 tests /104 suites passed; 0 failed/pending | `frontend-qualified.json` |
|
||||
| Complete frontend after salary fix | **917 tests /104 suites passed; 0 failed/pending**,332.201s | `frontend-salary.json` |
|
||||
| Previous uninterrupted ordinary browser | 360 passed; 0 failed/skipped/flaky/retried | `browser-accepted.json`; precedes the final salary sizing fix |
|
||||
| Final uninterrupted ordinary browser | **360 passed; 0 failed/skipped/flaky/retried**,2908.357s | `browser-salary-final.json` |
|
||||
| Separate standard browser contracts | **23 passed; 0 failed/skipped/flaky/retried** | AI2, auth recovery2, rate limits9, ingress6, PDF admission1, diagnostics3; `contract-*.json` |
|
||||
| Populated supported-size diagnostics | **2 passed; 0 failed/skipped/flaky/retried**,8 screenshots | `salary-green.json`; both complete size presets,320/768/1024/1440px |
|
||||
| Separate250-job performance diagnostic | **1 passed; 0 failed/skipped/flaky/retried** | `performance-successor.json`; desktop/mobile load, edits, acknowledged save, request counts and renderer observations |
|
||||
| Offline summarizer sidecar | **56 passed; 0 failed/errors/skipped** | `sidecar.xml`, model loading disabled |
|
||||
| Production frontend export | PASS after final salary fix, explicit local API5302 | `salary-build.log`; no production deployment |
|
||||
| Frontend types/lint; browser/diagnostic types | PASS after salary fix | `typecheck-salary.log`, `lint-salary.log`, `browser-typecheck-salary.log` |
|
||||
|
||||
Complete backend command: pinned `.dotnet/dotnet.exe test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-restore` with TRX logger. UI commands from `job-tracker-ui`: `node node_modules/jest/bin/jest.js --runInBand --json --outputFile=../.artifacts/phase8/frontend-salary.json`, `npm run typecheck`, `npm run lint`, `npm run build`. Build sets `NEXT_PUBLIC_API_BASE_URL=http://localhost:5302/api`. All shell commands use the repository RTK wrapper.
|
||||
|
||||
Ordinary native command: `node node_modules/@playwright/test/cli.js test --retries=0 --reporter=list,json`; `DOTNET_HOST_PATH` points to the pinned SDK, `PLAYWRIGHT_STATIC_EXPORT=true`, `PLAYWRIGHT_PREBUILT_EXPORT=true`, separate JSON/output paths. Separate contracts use existing configs for AI, auth recovery, rate limits, ingress, PDF and retained Phase5 diagnostics. Populated sizing uses `docs/redesign/phase-8/p8-04/diagnostics/playwright.config.ts`. Sidecar command from `tools/summarizer`: `.venv/Scripts/python.exe -m pytest -q tests --junitxml=../../.artifacts/phase8/sidecar.xml` with `SKIP_MODEL_LOAD=1`. The extra browser type project includes existing e2e/auth/AI configs and both retained/new diagnostic sources.
|
||||
|
||||
Offline security checks pass: tracked-secret scan; supply-chain helpers3; Ollama evaluation helpers5; browser-runtime helpers6; ingress helpers3; Node audit-policy/SMTP contracts15; extension URL contracts3; SBOM generation. Dependency manifests/lockfiles, project files and Docker/compose definitions are unchanged. Live npm/NuGet advisory refresh was **not run**; inherited NuGet Pkcs high/moderate and development advisory qualifications remain. Docker topology execution was attempted and **blocked** by the missing Linux engine pipe; static ingress tests are not runtime topology acceptance.
|
||||
|
||||
Failed experiments are retained, not erased: absent-feature RED; stale Identity replay RED; initial migration downgrade/fixture failures; future-schema and unsafe-revision RED; paused-body downgrade RED; initial browser API5202/5302 mismatch; interrupted full browser with obsolete five-flag Analytics fixture; editor header-focus RED; misleading pending-save navigation RED; preview-role RED; and salary digit-wrap RED. Their bounded repairs and successful successors are described in the checkpoint reports. No existing functional assertion was weakened to obtain a pass.
|
||||
|
||||
### Performance assessment
|
||||
|
||||
The [finite250-job observation](evidence/performance.json) measured load to all nine widgets at1105ms/995ms for1440px/320px, a hide/show/resize/reorder sequence at307ms/315ms, and acknowledged Save at170ms/130ms. There were711/716 DOM elements and Chromium reported27.6MB used JavaScript heap at both widths. These are two unthrottled local observations including automation overhead, not comparative Phase7 benchmarks, mobile hardware measurements, capacity/SLA acceptance or sustained drag-frame profiling.
|
||||
|
||||
Each load made one read per stats/overview/activity/tags/tag-trends endpoint. Reminders made two existing reads, one for the unchanged shell badge and one for Dashboard; baseline source confirms both predate Phase8. The diagnostic initially failed its incorrect one-reminder expectation, retained in `performance-final.json`; source/network inspection corrected that fixture expectation and the successor passed. Crucially, hide/show, size, keyboard order and acknowledged Save made **zero additional report reads** at both widths. The existing unit regression independently proves no report refetch during reorder; native pointer drag passes its functional browser case. This duplicate initial reminder read remains a shared-owner optimization opportunity, not silently described as eliminated.
|
||||
|
||||
Source assessment: layout changes are independent of the two existing resource loaders and their stable dependencies; report requests remain aggregate/bounded responses rather than loading250 job entities to render nine widgets. Small existing chart/tag/month computations still execute during view renders; no speculative memoization or query framework was added without a measured need. Full rerender counts, chart CPU/frame breakdown, sustained drag stress and physical mobile resource usage remain unmeasured and belong to broader Phase9 performance qualification. The measurements provide finite usability evidence, not proof against every noticeable slowdown.
|
||||
|
||||
## Responsive, accessibility and preservation
|
||||
|
||||
Native Chromium covers EN/Bokmål, light/dark, keyboard order/focus, desktop drag, emulated touch alternatives, errors and readback. Targeted editor widths include320/390/768/1440px; retained reports include1024/1920px. All populated widgets at both supported size presets were checked at320/768/1024/1440px for root overflow and contained tables/charts. The salary regression checks actual text line rectangles, because overflow alone missed fragmented numbers. No fixed widget height or CSS visual reordering was introduced. Two editor captures and the repaired salary crop were inspected and retained in [P8-04](../p8-04/README.md).
|
||||
|
||||
The final whole-run targeted Dashboard/Analytics/editor axe extraction recorded **10 scans,0 automated violations and64 incomplete node occurrences** (63contrast,1drawer focus), with invalid preview semantics removed; full details are in [axe.json](evidence/axe.json). Incompletes require manual assessment; zero violations is not WCAG certification. Human screen-reader operation, physical touch/software keyboard, complete actual browser zoom/text enlargement/reflow, other browsers, manual layered contrast/drawer focus, deployed Linux/font/photo/native output and linguistic review remain outstanding. Existing vendor strings and Phase7 qualifications remain.
|
||||
|
||||
All40 required scenarios are mapped individually in [SCENARIOS.md](SCENARIOS.md). The exact57 preservation groups remain **56 PARTIAL / PRES-C11 DEFERRED**, with explicit Phase8 impact in [PRESERVATION.md](../PRESERVATION.md). These statuses describe finite evidence and remaining qualifications; they are not upgraded to full acceptance. All33 concrete route patterns remain; the complete `App.tsx` route owner is byte-identical to the entry baseline. Existing Dashboard values, independent Analytics periods/loading/failures/links, administration and wider application tests are retained. No route or protected capability was intentionally removed.
|
||||
|
||||
## Phase 9 handoff and production withholding
|
||||
|
||||
Phase9 may use this branch's published implementation and exact final source evidence to begin whole-application QA under the conditional decision above. No Phase9/10 implementation is part of this work. Carry all40 scenario mappings and57 preservation statuses forward. Prioritize human accessibility/device/zoom/linguistic checks, independent provider/runtime/output checks, broader contention/resilience and performance profiling, and final integration against actual deployment configuration. If widget IDs/availability change, follow the explicit versioned contract; layout metadata must never become an authorization mechanism.
|
||||
|
||||
Production remains **NO-GO**. The previously reported production disk condition, independently verified off-disk backup/restore, ingress/configuration/schema agreement, rollback proof, deployed identity, native/manual output, Qwen/model and withheld P5-13 gates have not been cleared or rechecked by these local tests. They require their separate release-readiness process and owner authorization. A Phase8 acceptance decision cannot clear them.
|
||||
|
||||
## Source and publication
|
||||
|
||||
Published checkpoints: plan `421f902d21c08a814de12d7c32bc6ab8d4c01e15`; persistence `5edbe59692ae054dc868b352e69f367efa6bdc3b`; paused-body protection `a24e6b31593649f9351f48c85cb6ecd71c5b3192`; catalogue/state `68ee6ca`; integrated editor `77968da90caac73036dfd49fdbe0f47bd913405e`; responsive/semantics `960458978587a7503f8fc74063c5ee1e997e2254`; final salary repair/size diagnostics `2f6227fb4f9a6cc61838e8afde9e2dafd00bd428`. The latter was normally pushed and directly verified. The acceptance checkpoint is the commit containing this report and the separate performance diagnostic; it changes no accepted product bytes. Its exact hash, clean worktree and direct feature-ref equality are verified after commit/push and reported in the completion response, avoiding a self-referential commit hash here. Main remains directly observed at `5f835e54a9b74e6b71418422e28431b2af06453f`; no operation targets main.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Phase 8 acceptance scenario mapping
|
||||
|
||||
This matrix maps every requested scenario to its actual test owner. The [final report](FINAL_ACCEPTANCE.md) supplies executed whole-suite results and qualifications; a test reference alone is not an execution claim. Backend tests are `DashboardPreferencesTests.cs`; frontend tests are `dashboardLayout.test.ts`, `hooks/useDashboardLayout.test.tsx`, `dashboard-customisation.test.tsx` and retained Dashboard/Analytics tests. Native cases are `phase8-dashboard-customisation.spec.ts` plus retained Dashboard/Analytics presentation cases.
|
||||
|
||||
| # | Required scenario | Evidence and boundary |
|
||||
|---|---|---|
|
||||
| 1 | Useful defaults for a new user | Catalogue defaults, unsaved GET revision0, actual new-account Dashboard guidance; no fabricated metrics |
|
||||
| 2 | Existing users retain settings | Legacy owner-local five flags migrate only before first Save; real browser case and migration defaults |
|
||||
| 3 | Correct existing statistics | Retained exact monthly/count/denominator/custom-stage/company/source tests and native readback |
|
||||
| 4 | Enter/exit customisation | Explicit editor region, Cancel/Save, Escape and focus restoration; component/native cases |
|
||||
| 5 | Hide a widget | Draft checkbox updates preview; acknowledged-save HTTP readback |
|
||||
| 6 | Restore hidden widget | All-hidden recovery native case restores pipeline without changing job data |
|
||||
| 7 | Change order | Native drag and keyboard/touch buttons, live position, DOM order, real readback |
|
||||
| 8 | Supported size | Native select, mixed grid and persisted pipeline full-width; separate populated diagnostics save/read back all eight resizable widgets at both sizes across320/768/1024/1440px, including actual salary text-line regression; cards full only |
|
||||
| 9 | Save configuration | Real PUT acknowledgement and revision increment; disabled controls while pending |
|
||||
| 10 | Reload saved layout | Browser refresh plus actual API GET equals acknowledged widgets |
|
||||
| 11 | Logout/login | Real sign-out/sign-in journey retains server configuration |
|
||||
| 12 | Another session/device | Fresh browser context/login reads the same account layout; no physical second-device claim |
|
||||
| 13 | Cancel draft | Confirmation/no PUT, saved preview restored; reset/cancel server readback unchanged |
|
||||
| 14 | Reset defaults | Confirmed draft replacement, user can cancel before Save; no unrelated account write |
|
||||
| 15 | API error | Controlled503 keeps draft and localized error; retry persists exactly once after acknowledgement |
|
||||
| 16 | Network failure | Controlled route abort simulates transport loss; draft retained and retry works; no physical outage claim |
|
||||
| 17 | Obsolete widget | Actual backend stored-JSON GET ignores unknown IDs; frontend normalisation, no invalid renderer |
|
||||
| 18 | Invalid size | Backend HTTP rejects invalid write, stored read normalises; frontend falls back to catalogue size |
|
||||
| 19 | All hidden | Real nine-hidden saved layout, intentional empty state and accessible edit/restore |
|
||||
| 20 | Two sessions edit | Real session conflict409, explicit reload, reapply and acknowledgement; atomic simultaneous first-save regression |
|
||||
| 21 | Malformed/large payload | Actual Kestrel malformed/duplicate/unknown/oversized/chunked HTTP tests;16KB byte cap and32-entry maximum |
|
||||
| 22 | Statistics failure | Retained independent summary/trends loading/503/retry native cases; no false zeros |
|
||||
| 23 | New widgets/old layouts | Known missing entries appended hidden in saved revisions; future schema is read-only, downgrade denied |
|
||||
| 24 | Historic save failure | WAL held-reader actual layout write plus simultaneous saves and stale Identity replay regression; no permanent contention claim |
|
||||
| 25 | Cross-user access | Principal-derived GET/PUT, attempted query account override ignored, A/B persistence isolated; actual HTTP and cookie cases |
|
||||
| 26 | Anonymous denied | Actual GET/PUT401 in backend and real app auth browser tests |
|
||||
| 27 | Configuration cannot expose restricted data | Strict stable-ID/type/size/version allowlist; owner field never accepted; arbitrary query/HTML not supported |
|
||||
| 28 | Admin data protected | Ordinary cookie-authenticated account receives403; admin source stays unchanged |
|
||||
| 29 | Keyboard-only order | Native Enter on named move controls; live region and focus assertions |
|
||||
| 30 | Predictable focus | Editor entry geometry, row stable keys, first-position alternate-button focus, Cancel return, route keep-editing |
|
||||
| 31 | Responsive | Actual viewport matrices and no root horizontal overflow; preserved readable values tables and fluid chart |
|
||||
| 32 | Touch/scroll | Emulated-touch tap alternative passes; dedicated drag handle avoids ordinary-content drag. Physical device/software keyboard outstanding |
|
||||
| 33 | Bokmål | Localized editor/error/reset/control names; native EN/NB layouts. Human linguistic review remains outstanding |
|
||||
| 34 | Themes | Native light/dark screenshots and computed scheme assertions |
|
||||
| 35 | Reduced motion/focus | No custom motion; retained reduced-motion journeys and3px focus ring checks. Complete true zoom/manual contrast outstanding |
|
||||
| 36 | Dashboard data contracts | Same report endpoints/DTO consumers; exact existing source assertions retained |
|
||||
| 37 | Admin unchanged | No admin source edits; existing admin unit/native regression plus non-admin403 |
|
||||
| 38 | Navigation/other pages | All33 route patterns unchanged; final ordinary browser/backend/frontend suites |
|
||||
| 39 | Preference compatibility | Additive full migration chain/startup defaults, unrelated fields preserved, legacy flags and stale-session protections |
|
||||
| 40 | No protected capability removed | Exact57-group reconciliation with56PARTIAL/C11DEFERRED retained; source/test inventory and final suites |
|
||||
|
||||
Additional regressions cover future-schema overwrite races during a paused request body, exact JavaScript revision limits, delayed acknowledgement after account disposal, malformed stored JSON and Identity's unrelated whole-user save. No production, model, provider grant, migration against live data or destructive operation was performed.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"observations": [
|
||||
{
|
||||
"width": 1440,
|
||||
"fixtureJobs": 250,
|
||||
"loadMs": 1105,
|
||||
"editSequenceMs": 307,
|
||||
"acknowledgedSaveMs": 170,
|
||||
"reportRequests": {
|
||||
"stats": 1,
|
||||
"analytics-overview": 1,
|
||||
"reminders": 2,
|
||||
"analytics": 1,
|
||||
"tags": 1,
|
||||
"tag-trends": 1
|
||||
},
|
||||
"renderer": {
|
||||
"domElements": 711,
|
||||
"heapBytes": 27600000
|
||||
}
|
||||
},
|
||||
{
|
||||
"width": 320,
|
||||
"fixtureJobs": 250,
|
||||
"loadMs": 995,
|
||||
"editSequenceMs": 315,
|
||||
"acknowledgedSaveMs": 130,
|
||||
"reportRequests": {
|
||||
"stats": 1,
|
||||
"analytics-overview": 1,
|
||||
"reminders": 2,
|
||||
"analytics": 1,
|
||||
"tags": 1,
|
||||
"tag-trends": 1
|
||||
},
|
||||
"renderer": {
|
||||
"domElements": 716,
|
||||
"heapBytes": 27600000
|
||||
}
|
||||
}
|
||||
],
|
||||
"limitation": "Two local unthrottled Chromium viewport observations; no Phase7 timing comparison, capacity/SLA, physical mobile resource or sustained drag-frame guarantee."
|
||||
}
|
||||
@@ -0,0 +1,817 @@
|
||||
{
|
||||
"JobTrackerApi.Tests\\AccountDataExportTests.cs": "ef9dcd76a2db04a47892ce9144f46b30af558fa69f1649ccdd44183cf3fc6373",
|
||||
"JobTrackerApi.Tests\\AccountDeletionTests.cs": "024c734584dd1796a0681be81acb5c58a025f275f8033aa9dca38e36259d4b27",
|
||||
"JobTrackerApi.Tests\\AccountPlansTests.cs": "8475e012432d761cc9cc2b4d4d54844c253a9cf296784d8fdf4869a904154de8",
|
||||
"JobTrackerApi.Tests\\AiEvaluationFixtureTests.cs": "b11da83541077f3a69cc92f4093e0e3ef18820bca261de44cf34d9abcf4bdf5c",
|
||||
"JobTrackerApi.Tests\\AiOperationQueueTests.cs": "7c6700f9a26283ff5d699c77361eaaca76af1a22b38106e3c3e6f051842a7ed1",
|
||||
"JobTrackerApi.Tests\\AiPrivacyPolicyTests.cs": "8ee05a94eb3cf944bf9b5196762cc81b255ed3cc8130c85b191fceda60d8ea9e",
|
||||
"JobTrackerApi.Tests\\AiUsageMeterTests.cs": "44124c031ced9f8e77c1f6dcb11f2cd73ad451062cbee589d30689d04d82b4d0",
|
||||
"JobTrackerApi.Tests\\AiWorkspaceNotePersistenceTests.cs": "ccff51e4e889ede01350e69a70bd2cccd4d2f857f4696c08ce51772badf224aa",
|
||||
"JobTrackerApi.Tests\\AiWorkspaceTests.cs": "01808f99f1d68a04c2c4ab6c63ba156df9dc51b245c69c82e76e70db95833d3b",
|
||||
"JobTrackerApi.Tests\\AnalyticsSalaryTests.cs": "c26e08f1cc058320a4ba8fec36330dbad9835b376600d4f021c880861085c041",
|
||||
"JobTrackerApi.Tests\\ApiAuthorizationPostureTests.cs": "b0bf533d3696c6de93bf669d2f907063a213539d6814abc81dc3cb7b4a9aa3af",
|
||||
"JobTrackerApi.Tests\\ApplicationAssetsTests.cs": "1291602d291df13c445784e3a96e3396392ab7ac45996015527f51dbc1520731",
|
||||
"JobTrackerApi.Tests\\ApplicationCalendarControllerTests.cs": "fda00d91e9207ebf3d0b6c878ea38cd5e0129697277b15386cdc4e1c76ad8700",
|
||||
"JobTrackerApi.Tests\\ApplicationChecklistTests.cs": "4038c49ac8ff22474defa9d3461ea7f201e3bb51995adb2742243a44bc6f8152",
|
||||
"JobTrackerApi.Tests\\ApplicationIntelligenceTests.cs": "561a5490f3621dea43e8ca895e3b2260bdb7191ca85c8ae95bc96a63a9bc42d8",
|
||||
"JobTrackerApi.Tests\\ApplicationWorkspaceTests.cs": "4fa7a20ff64ad595903f02da4f401566d54ba47d7bfd0a357a08b21fe590c898",
|
||||
"JobTrackerApi.Tests\\AttachmentConsistencyTests.cs": "8759c572771611c198574fbf12261a88e2e3732dbb84caa75cd0ec9544bb2f9d",
|
||||
"JobTrackerApi.Tests\\AttachmentFlagsRecomputeTests.cs": "13c5bce6ae5661df5eac7d8bae913b493db58c7d0b4bc2fcfe385064a325040d",
|
||||
"JobTrackerApi.Tests\\AttachmentsControllerTests.cs": "1779ae51b9173aeb686e86834b33a1a0f6f0f6daf58ddf03eeb47833b13d93af",
|
||||
"JobTrackerApi.Tests\\AuthAndSystemControllerTests.cs": "95c6a680cb00db945da49f1d454f071688712e8220401327e170cb0442165a7f",
|
||||
"JobTrackerApi.Tests\\AuthSessionRevocationTests.cs": "2944a2e560c7b6b264239db9eeaff667a45db53d3e8ba51b49dc8263f600b92f",
|
||||
"JobTrackerApi.Tests\\AvatarStorageTests.cs": "d4af7a068acceaa4fac28eb208686015dc56227491a6a5d1716f553b59208ff1",
|
||||
"JobTrackerApi.Tests\\BackgroundWorkerTenantTests.cs": "9858a042b9d83bf2b2f8809f17f7364a6a623c3f2ef7517b20e758f4a63a6f64",
|
||||
"JobTrackerApi.Tests\\BackupControllerTests.cs": "5744dd4a5a7f6191ef2cccf0122ae76869a9111a74b8ad56297948d7b77bb25b",
|
||||
"JobTrackerApi.Tests\\BillingControllerTests.cs": "46477255d90c2b8d6e3dfe461457534f81b6fa535aeb46bd34b93e6f338e1036",
|
||||
"JobTrackerApi.Tests\\BuildMetadataTests.cs": "ad3b09389071067500a58cdf2c9b0e0806e6225d0f41b1b691f439f6af1cdada",
|
||||
"JobTrackerApi.Tests\\CareerEvidenceControllerTests.cs": "2621168ce6e0a287d2415da3c22a2348977d2989206e7737ba7de7e7780c7aae",
|
||||
"JobTrackerApi.Tests\\CareerProfileControllerTests.cs": "05aa1356072a33dac1ebf203e0487164b117b2a3020cc50b8f8f39bdf436e307",
|
||||
"JobTrackerApi.Tests\\CareerProfileServiceTests.cs": "806ec60b7465eefd46fc7ff548b7ff28f06a0c11dc5e34e815a77229d8bf6b2a",
|
||||
"JobTrackerApi.Tests\\ClientErrorsControllerTests.cs": "c589de308dc454001f9f284f80f07128a4dd16e1ae0b274c41a1fea2294a7733",
|
||||
"JobTrackerApi.Tests\\CompanyCreateCompatibilityTests.cs": "b15bbe1b256cae2a5712a954d2f84470d5a9acc1b734143d5879690486b5227c",
|
||||
"JobTrackerApi.Tests\\CompanyRelationshipTests.cs": "62563ff2414a43cd106c69457285c9739e2858c2ab40d14655532aa30f5a5c8e",
|
||||
"JobTrackerApi.Tests\\ConcurrentInitializationTests.cs": "5cec247df455423c2a03a00d35746c2e9d6c6a28186f14ea931ef2025aab4a82",
|
||||
"JobTrackerApi.Tests\\CorrespondenceControllerTests.cs": "02f90e5933dc24cc300745e16d3e0d54375d6764ef0de4aa3e8b0ec866850edb",
|
||||
"JobTrackerApi.Tests\\CsvExportTests.cs": "bf928629ed1e973d62125b78bc0ddf0a36257563ce8f5aef38a6495de738f80e",
|
||||
"JobTrackerApi.Tests\\CurrentUserIdLiveEvaluationTests.cs": "b967941bb70c19dca3acff35d20ef9e7986ce025c00b3f385787095ac3a9fce8",
|
||||
"JobTrackerApi.Tests\\CvAiEvaluationTests.cs": "530d6d9b4a224bd95684b36b53ebc65bda7918bcee0a0f9022f51d1c14ff38f5",
|
||||
"JobTrackerApi.Tests\\CvAiGroundingTests.cs": "3804fb2dc4369360da50f9875feee5580603a067d4a276158592b8344d80e774",
|
||||
"JobTrackerApi.Tests\\CvAiReadinessTests.cs": "ddfd745b81d75dbb01bd6f206c546d231fde22c77b02389c32bb7d780a397cab",
|
||||
"JobTrackerApi.Tests\\CvBuilderTests.cs": "fca823e4f23319153af2a3d2edaa983db8abadc6f566ae13140eee83ffa6171b",
|
||||
"JobTrackerApi.Tests\\CvCorpusHarnessTests.cs": "31ae65607f964d78d4ea2520f6b0817b871cf9e4b9755d589cd1fb30f6b1c1d7",
|
||||
"JobTrackerApi.Tests\\CvExportRetentionTests.cs": "0b91d38b290a43e371390cbbe97b2b77a8b27e0ade065c45747d84b177bad8b6",
|
||||
"JobTrackerApi.Tests\\CvExtractionCoverageTests.cs": "b8c70aec81acd80871cf4104cad30a2467fee8722137b6367d8d82fd282f4643",
|
||||
"JobTrackerApi.Tests\\CvProcessingOperationTests.cs": "4f40aad7614759d5e17e41fc34230d74b618360aa8ea2caa8fc362ba46b02484",
|
||||
"JobTrackerApi.Tests\\CvProfileDiffServiceTests.cs": "e26a50aab44b0175078a4f23cb199dbd8efdede1fb5fed0d8cda406466ecbe20",
|
||||
"JobTrackerApi.Tests\\CvRevisionReadinessTests.cs": "31cbe4219ebb035751c1ffb73c975ca579ecb88af0671e117a4c30a997fa60ce",
|
||||
"JobTrackerApi.Tests\\DashboardPreferencesTests.cs": "745c457a6308aef4ca307cd38ec9d427d0d9c08738468cbab902ad7df1404096",
|
||||
"JobTrackerApi.Tests\\DatabaseBackupRunnerTests.cs": "3107d265b272775eead9b26e0b56f8560db15f9102f0f421fe7fcfc63ec9bbc3",
|
||||
"JobTrackerApi.Tests\\DocumentsControllerTests.cs": "f16d3d78b072faaea87c376888803ecbf8daec6da62368d89ea78ecd8bc9bac2",
|
||||
"JobTrackerApi.Tests\\EmailControllerTests.cs": "662e460950162c2965cb27fc869107bfe5e347326ff8589f652b52b1f904e9e8",
|
||||
"JobTrackerApi.Tests\\EmailDraftPersistenceTests.cs": "b07366bc498231db84c40cde281ccd645a3836b2356358b6666d90d289aa1604",
|
||||
"JobTrackerApi.Tests\\EmailDraftsControllerTests.cs": "088c8b52ff90c44f91d1014966568fa4f5cf245cc6dff89b1cd6f19b56d4fdfd",
|
||||
"JobTrackerApi.Tests\\EmailOwnershipIntegrationTests.cs": "e5df2db6e7223de53d66e0b2c4e2bc08aaebeddd04075f794b7e8d6f82a3fe5b",
|
||||
"JobTrackerApi.Tests\\EmailProviderDeliveryTests.cs": "ad4997b2fcd821f163e9670328f866b84d5fb7a568b0454c2e4910f3e8969af4",
|
||||
"JobTrackerApi.Tests\\EmailSendAttemptStoreTests.cs": "97cabc2fc554a584723645a7c80eb1b7dd13967d90353facc701418140d5126a",
|
||||
"JobTrackerApi.Tests\\EmailSendControllerTests.cs": "f21032b2e1f8a663398ee834cea33cef4369f048039372997a041b5f2b56b179",
|
||||
"JobTrackerApi.Tests\\EmailStatusClassifierTests.cs": "cdf53f1a88d45ecdf79e3c6765bea30d60d219a1bd7139a3cfea24274c95cfca",
|
||||
"JobTrackerApi.Tests\\ExternalOriginTests.cs": "ff91757dd21da47708371ac7aa29bc6acc0ad4f9e1e0124a6ed5374c981be434",
|
||||
"JobTrackerApi.Tests\\Fixtures\\AiEvaluation\\cases.json": "3869e579f6704400762a08ceb6b721293f2cbd73a2d79b62f224215f76ee9707",
|
||||
"JobTrackerApi.Tests\\Fixtures\\AiEvaluation\\cv-rewrite-grounding.json": "424e09801f184e9e099212a8c9eef050bf197c8bba5a747dbb18d2d6823654d6",
|
||||
"JobTrackerApi.Tests\\Fixtures\\CvCorpus\\synthetic-developer-cv.txt": "056f1f0faa680c47e5b540b592678a6259ec4eb057f750d25af2fb65411a79b5",
|
||||
"JobTrackerApi.Tests\\ForwardedIngressTests.cs": "7be37035abdab064f17ae6adca041daa83d98d99a889093ff75409770f5d0f87",
|
||||
"JobTrackerApi.Tests\\GmailControllerTests.cs": "69ab904ad8f62cb57309d2038651ea1c5301070c5a6b76d2dda4150ca75e86e8",
|
||||
"JobTrackerApi.Tests\\GoogleTokenValidatorTests.cs": "7f37d568feea9e4f0c93199ce4fcae431cecc754eb437d86cfb4164a079d0c95",
|
||||
"JobTrackerApi.Tests\\HumanLanguageCatalogTests.cs": "25d3f8e629c6f91021bbe9fb2bf0647ba0ec77a94b9cfff71c01d40aae493634",
|
||||
"JobTrackerApi.Tests\\ImapControllerTests.cs": "ed9fc218df99b0f0b68802270fd8e5f69ef4d2bc1f328e1e667026b6bf584837",
|
||||
"JobTrackerApi.Tests\\ImapProviderTests.cs": "c1666157d28fbadadece5c99f0bd6f236ed0a47dec76701798224805c6838c16",
|
||||
"JobTrackerApi.Tests\\ImapServiceSsrfGuardTests.cs": "2357456278d59868d6a33e3c341fe55c3eb7cb952f39a4120bc8346a5265f8e4",
|
||||
"JobTrackerApi.Tests\\InterviewAiContextTests.cs": "3301a31c004119274753855c9714e91722ff39415f2ea49a6c933277613412e6",
|
||||
"JobTrackerApi.Tests\\InterviewPrepPersistenceTests.cs": "26ed6e1d4b04845e9bd61a6f281111d30807008a368835063e5e79df05889043",
|
||||
"JobTrackerApi.Tests\\InterviewPrepTests.cs": "77957a0fe01cc0960dbf3dfa18b782ff13fc6e63a72b4b3d04bce24bc931162c",
|
||||
"JobTrackerApi.Tests\\JobApplicationsApplicationPackageTests.cs": "5dff6fdf1bf2a3f5e965b211c871ea55044f42c83970f4cd29b0ba310488a946",
|
||||
"JobTrackerApi.Tests\\JobApplicationsAppliedDateHistoryTests.cs": "a64865b38cc81ebcac9422f83540a3ae172d7ec6a9af2a6771e934f1ef22cf3b",
|
||||
"JobTrackerApi.Tests\\JobApplicationsAuthorizationTests.cs": "a1513153818a5485b0406791e599c46a1247dcc6e9157d9ae85d0d19f7a81c17",
|
||||
"JobTrackerApi.Tests\\JobApplicationsControllerTests.cs": "63fa161f4c5c410ace99dc1b48621812ff44bda3be95b3002eaf6bbcadecda17",
|
||||
"JobTrackerApi.Tests\\JobApplicationsEndpointBehaviorTests.cs": "d5c0c29c7c483cecdf05370289ec0b51694aec3d6d0faed202a59de8335002f1",
|
||||
"JobTrackerApi.Tests\\JobApplicationsFollowUpDraftTests.cs": "72ebdca92d5e04c67f773171ee41988062c08da44088ba08dd2ec7ebcf7cb855",
|
||||
"JobTrackerApi.Tests\\JobApplicationsMariaDraftTests.cs": "2b4d0a427b56c229496237cc2492c9ec4fbe2e09a2835ded43db83044a25edaa",
|
||||
"JobTrackerApi.Tests\\JobApplicationsWorkflowSignalsTests.cs": "fcf5a226a8c927ed153e5248a3d37cfd9ca6281e8dd5ba8223eb742da0a9cab6",
|
||||
"JobTrackerApi.Tests\\JobCvMatchServiceTests.cs": "30e74ac4da597d7ec7871d400464643e19bbe081179fce49ecb4406e71228704",
|
||||
"JobTrackerApi.Tests\\JobDiscoveryControllerTests.cs": "a37087613dc75584d986bd5d300e625c800f092b3aedf1754cf12530b7e9f6b7",
|
||||
"JobTrackerApi.Tests\\JobImportHttpTransportTests.cs": "4df5f04867aebf9cefcc52f55a84904d1aac5ab7492d78fe5f0447a54a92d054",
|
||||
"JobTrackerApi.Tests\\JobImportServiceTests.cs": "548690eab644c1c62717181d59005375182b91b63f2e81c0ac1cb6d54c2d8748",
|
||||
"JobTrackerApi.Tests\\JobLifecycleEventTests.cs": "1d4c2096b551ef9090cd03e807b98d5bc8bb0182d5f0b456e7cd0cd064902514",
|
||||
"JobTrackerApi.Tests\\JobOpportunitySyncTests.cs": "3b036116160e3ee1d0f9c32dbea19698d354ba591b240d5f3a60b3ac3d787f71",
|
||||
"JobTrackerApi.Tests\\JobPipelineTests.cs": "04e491468fd63fea81144860ba4382d3c059424d9264d2a13eca8810e8ffd36b",
|
||||
"JobTrackerApi.Tests\\JobQueryParityTests.cs": "fd5ff8a7ed9cea356e1778514572380e203ac3806aff11ca334f7e1502a26fcd",
|
||||
"JobTrackerApi.Tests\\JobTrackerApi.Tests.csproj": "d1bf4553a7235372c4594e81b8ad81597817e97eabe7c47ecc7f574daf4805e8",
|
||||
"JobTrackerApi.Tests\\LocalAuthIdentityTests.cs": "ddff1dac6bf03dd8f92185eafe5345bbf418b44861ede755f5ae1c4e80b8d3ed",
|
||||
"JobTrackerApi.Tests\\MaintenanceJobDispatcherTests.cs": "61b93390b123a307917143b56915546e60bbcacd59c9771a30e45bacac3753cf",
|
||||
"JobTrackerApi.Tests\\MeteredSummarizerServiceTests.cs": "2d4d067470eba6f9063fcbd3660d2636ccf6bdd8b9c3546b1a6e26503eb16964",
|
||||
"JobTrackerApi.Tests\\MicrosoftGraphControllerTests.cs": "0847a7b630cc5cf52a9eb1196d8c5e7aeb7d7b37b7bf327131ef2e6f32ecde57",
|
||||
"JobTrackerApi.Tests\\MicrosoftGraphProviderTests.cs": "dd6cb7915be541927a61c4089cdc6c57fa187154c4631b7b61597901e9a683e9",
|
||||
"JobTrackerApi.Tests\\MicrosoftTokenValidatorTests.cs": "406fd947e208ffde5794dd94f696b5a594c205620cdd9a6750b1676f78dc6ac3",
|
||||
"JobTrackerApi.Tests\\MigrationChainTests.cs": "b49c8490a89f2d980a3f9283e9c7a9f3a28caa5ac7ce14080adda272e8eaeb02",
|
||||
"JobTrackerApi.Tests\\NotificationSettingsControllerTests.cs": "d411cc091623e9ec55315e677bb78984f97369287cb167bed748054506e55a95",
|
||||
"JobTrackerApi.Tests\\OperationsControllerTests.cs": "06d6f91b70af4e1e98fe67246a76a9084d22a4050667295349a14c4d520c8e75",
|
||||
"JobTrackerApi.Tests\\OwnershipGuardTests.cs": "f2b17cf32e12a6c964f5fd44f321baf7042715901db6e7307d385ecb5e4a3179",
|
||||
"JobTrackerApi.Tests\\PdfBrowserStub\\PdfBrowserStub.csproj": "933bfd93fb4668ecf73c60797396a2f0e200a7b82512e75de5ba7bdd5be269de",
|
||||
"JobTrackerApi.Tests\\PdfBrowserStub\\Program.cs": "941fc179de977fa0c1038e67770720b4a71552c7c758d93ad94bad746fa4f090",
|
||||
"JobTrackerApi.Tests\\PdfRenderAdmissionTests.cs": "902359cfee8ff1467c7980220626509cccf0f29a91f8a4915376e600d5e2f7c1",
|
||||
"JobTrackerApi.Tests\\ProEntitlementAuthorizationTests.cs": "86e0dc24c017bf7b141251787fce66bceb58e04d8ff4979446b7ded5a6212f87",
|
||||
"JobTrackerApi.Tests\\ProductionConfigTests.cs": "c3be7df38debd6c8117205e912bd3a7a402c130c6f66c169e2beb964dd71137a",
|
||||
"JobTrackerApi.Tests\\ProfileCvControllerTests.cs": "f7075de96846e9044f90654b5c0e634d41c846716445d99e4bab26636740748b",
|
||||
"JobTrackerApi.Tests\\PublicCvControllerTests.cs": "94bbb54fcfce091db453ddb0584f71d9a3f5936232a5c9c460c2692d933c4374",
|
||||
"JobTrackerApi.Tests\\RateLimitPartitionKeysTests.cs": "578314976674bbba6f5fb28a46f42145fe497a53e9029af5d1308f8540907477",
|
||||
"JobTrackerApi.Tests\\RouteUniquenessTests.cs": "eaa70af418b1fe6316cffaf4490dd76e2fb8688f74f8e95ba623273555071189",
|
||||
"JobTrackerApi.Tests\\RulesEngineProspectTests.cs": "d92b4312f9937b4238a86150648958c531f5581a61f9ebaf0f8a4acc597614f2",
|
||||
"JobTrackerApi.Tests\\SavedJobSearchAlertTests.cs": "a1ea58ac16cfb504bb73d48bd214603587282d8b830a19ab9de7d89a8c728a70",
|
||||
"JobTrackerApi.Tests\\SavedJobSearchSqliteTests.cs": "cce4ec28606b53a4756f6056afbc4ae626cdcb6f624a85ede0268cacd6c1bf50",
|
||||
"JobTrackerApi.Tests\\SessionContextMiddlewareTests.cs": "457ee0d37b50dea156d0dca80583fbda2721ccbc0d942af80714976131610e24",
|
||||
"JobTrackerApi.Tests\\SessionsControllerTests.cs": "5cbb946f4341528e535ba0ef44aca1e4c9500861b5266ac4777f53d4bc7bdef0",
|
||||
"JobTrackerApi.Tests\\SqliteDateTimeOffsetCompatibilityTests.cs": "be2a965d71ffdb2b17cc220e682d50fc65af97d6da3ad571f082a0c9aac5a758",
|
||||
"JobTrackerApi.Tests\\SqliteMigrationHelperTests.cs": "6eb8e5e73d916935afb0d1621788b35bde01e1412bebe20162be34756166ff6b",
|
||||
"JobTrackerApi.Tests\\SqliteStartupConcurrencyTests.cs": "536a801f74b9499340e53c2dcf1e138d00498bc1413558d64db69732f80adc1a",
|
||||
"JobTrackerApi.Tests\\StageAnalyticsTests.cs": "231ada7b7e0766eacbe2c6753949cd667678f95c1163cc37be38ceb9cf24e2d0",
|
||||
"JobTrackerApi.Tests\\StartupSchemaOwnershipTests.cs": "6cb7829ce25a28a9c715a99fe3a1677b5679e576fbd94fcad3ce7c5c86ada2d1",
|
||||
"JobTrackerApi.Tests\\StrategySnapshotOperationTests.cs": "75d2a336a451356dfefdc0e0d210b7c10ae95ae6ed632a85238a66043bf9e74e",
|
||||
"JobTrackerApi.Tests\\SubmittedApplicationPackageTests.cs": "1b3d71b32b3394cd855cd040afb5433da2ba64bd6ba4e96ab3fe4215b97a6b7d",
|
||||
"JobTrackerApi.Tests\\SummarizerServiceTests.cs": "2bd8b479ea653f2ec5f1c0d2e5a7aa0c216be58ac42e41ba0785c316172bbe47",
|
||||
"JobTrackerApi.Tests\\TestSupport\\TestHostFactory.cs": "c151d2311b884ef4a982c8fd1b703470df5448fe32a8faeea25e0b1bc93b9804",
|
||||
"JobTrackerApi.Tests\\TwoFactorControllerTests.cs": "06090015be9b60569987dbeee0c867653252671a9f07fb3f4b7dd3aef4a4846f",
|
||||
"JobTrackerApi.Tests\\UserOperationStoreTests.cs": "f72a76bf949fdecd7f6b923800aa2b9de052accbdea3723886e88c05836c9e57",
|
||||
"JobTrackerApi.Tests\\UsersControllerTests.cs": "3c49aae7fbfd7abe25c6c67c301c77a08adb614c61c018a9139278a12bbd129d",
|
||||
"JobTrackerApi.Tests\\packages.lock.json": "87b844f0ea71a7f8351b1a04a2db5c7f8c2b9fb1685f8d3850b6743acd21b9e1",
|
||||
"JobTrackerApi\\.dockerignore": "a8ebaa567f938e5d0f9d81d29a36c686aeabadbc37385344d8dfcbe04cecba8e",
|
||||
"JobTrackerApi\\Controllers\\AccountLifecycleController.cs": "1356d72cc5c794ffead7df9db14b274d2dbc1c29e84a5c86cb3062f65d3583da",
|
||||
"JobTrackerApi\\Controllers\\AdminAuditController.cs": "8e6a9d813869bcc44b221536e46fbd682b2e740511c03fc896b3529975d20c0b",
|
||||
"JobTrackerApi\\Controllers\\AdminSystemController.cs": "7bcae45b3843ebcbcf0bfcedec0e4d8ec6d3f12660565b5cdb09770f0ed12eaa",
|
||||
"JobTrackerApi\\Controllers\\AiSettingsController.cs": "26a21070484b35c9b447209177f9c22a3223005e8e74301ac9ae5144c2ebd13b",
|
||||
"JobTrackerApi\\Controllers\\AiUsageController.cs": "ef49c0d935cfe8846c9c3d691dab196dfada32f473629ee9d027ad61e46b032c",
|
||||
"JobTrackerApi\\Controllers\\AiWorkspaceController.cs": "8a0fc1c71d68a61e4f1143a97ffce13f5745d26c2e4c6bc07d8ca2f867029ead",
|
||||
"JobTrackerApi\\Controllers\\ApplicationAssetsController.cs": "ffc199ad27194f4b6601b98922808d660159f73ba798d5368284a50832e41bd9",
|
||||
"JobTrackerApi\\Controllers\\ApplicationCalendarController.cs": "05a607f33220d42ec099a1c1db16ac474c5382f101e83f863da89f3402d89fbe",
|
||||
"JobTrackerApi\\Controllers\\ApplicationChecklistController.cs": "89728ccfecc7ed8ec7b88ef1c5e34886c1094b94e8884e28f5f4fa99aa65871a",
|
||||
"JobTrackerApi\\Controllers\\ApplicationIntelligenceController.cs": "af5027572b1fc30a6ebf8fabe786b82f2e7837ea16fca6f10e25fd01fef4695f",
|
||||
"JobTrackerApi\\Controllers\\ApplicationWorkspaceController.cs": "20ca35a0f33965b664dd3d34b2aa4e4f08d406f7868c08068efb1be2e9468ad9",
|
||||
"JobTrackerApi\\Controllers\\AttachmentsController.cs": "ac5de30857d39bd9f03409d43e1bf2272af4d5a98bd6505c913405d2bde5b82f",
|
||||
"JobTrackerApi\\Controllers\\AuthController.cs": "f8ada6382c3051954f7ea9977fa54680fee7c709f23aec8593485eda92bfb509",
|
||||
"JobTrackerApi\\Controllers\\BackupController.cs": "1e99eb05f1644755feceea76af2155543458f7a4a30d5269a49081ccd1baa8eb",
|
||||
"JobTrackerApi\\Controllers\\BillingController.cs": "a7c46d83bd6e0e6d7d5983fd83ae8662b36fd65f1280765d6536fb476045e99c",
|
||||
"JobTrackerApi\\Controllers\\CareerEvidenceController.cs": "46b9f9a78aa486d0870fe86a85156f87820798454d2e4f5e96c02208b47cd117",
|
||||
"JobTrackerApi\\Controllers\\CareerProfileController.cs": "d4c3a7a639032e7df178cfcc5d7b0943c6ef84051466018d592a82821bcc4334",
|
||||
"JobTrackerApi\\Controllers\\ClientErrorsController.cs": "26c8315205d8557700e3ee2fa001ee4ac77e66b0cfe9b2d979e693f81141ac1c",
|
||||
"JobTrackerApi\\Controllers\\CompaniesController.cs": "a913bcd1e9993bc4f8fd88b5554b92eade263967c4027b2179244354ed96f7ec",
|
||||
"JobTrackerApi\\Controllers\\CorrespondenceController.cs": "0c9c782ad91875df872d74a6f70edf8baa6b756b9c526f46346eedf6afd365fd",
|
||||
"JobTrackerApi\\Controllers\\CvVariantController.cs": "21c71ae693bbe6dd541e40d48ae6939a9fd912a9e6f56d31b5b27a64546eee62",
|
||||
"JobTrackerApi\\Controllers\\DocumentsController.cs": "44633b84fbd33116e7f79f0fcd77cc8c15aea86d752464c6940b7dcd5506548b",
|
||||
"JobTrackerApi\\Controllers\\EmailController.cs": "75b01564daa046c51a28bf15688770c6f71d168cf17efe6ed52d5e7894d88929",
|
||||
"JobTrackerApi\\Controllers\\EmailDraftsController.cs": "bcbb26145668bb82666d43e1721418864220a15149487282af40397cd86784a5",
|
||||
"JobTrackerApi\\Controllers\\ExportController.cs": "0152e4735777870d61b6e8f580c12ce87eecfb339640756a892e1836bfe4cba0",
|
||||
"JobTrackerApi\\Controllers\\GmailController.cs": "05df8fea68b8c1d3a6b4a17bcd7c4b281b01a86fabf4c853eb495820ee3f4d2a",
|
||||
"JobTrackerApi\\Controllers\\GmailDtos.cs": "69ac68aa9d9a24d57984892cd46b94917420240d3421af45069c44bf83b4bbc9",
|
||||
"JobTrackerApi\\Controllers\\ImapController.cs": "470db9b775f16945c1c9954a4cfdf8eacf2f67d1328c963c1c5b1072cb2d3139",
|
||||
"JobTrackerApi\\Controllers\\InterviewPrepController.cs": "77c298a760959f0b782e3e4c6db3366df94db7157c3b8c45ac34e6c4c2392a53",
|
||||
"JobTrackerApi\\Controllers\\JobApplicationAnalyticsController.cs": "81f183ece72c97e5af5e3e26f13803851ad12baaeddd98e4f7a05a2d7fc18f88",
|
||||
"JobTrackerApi\\Controllers\\JobApplicationDtos.cs": "835121a822d1eaaba03381bbd890c643d2e05c5df90630ad88e7081170ff73ce",
|
||||
"JobTrackerApi\\Controllers\\JobApplicationLifecycleController.cs": "127a5cda45ea4813332a85e174d5451091437a2477db9102fa1944c80ab4b165",
|
||||
"JobTrackerApi\\Controllers\\JobApplicationsController.cs": "b6cab1db19181005afded95a768a8c789cab22d5b5cc65cc31a30af564b8af98",
|
||||
"JobTrackerApi\\Controllers\\JobDiscoveryController.cs": "06dece853bf6a09662692058fa166dc748c3a86dc0aba0a73e7db583b5cc36b6",
|
||||
"JobTrackerApi\\Controllers\\JobImportController.cs": "860c30251feb302a35e3ccb4dd7f4eff2183cda50877ae92a690c4809e024e61",
|
||||
"JobTrackerApi\\Controllers\\MicrosoftGraphController.cs": "b386c2e7413fd78ec252b01480e9163acafac24474b48c797e64fc34f660b6f0",
|
||||
"JobTrackerApi\\Controllers\\NotificationSettingsController.cs": "f668e19c344f506d577dc2b8ec6be19439cef2bea2ad098680e57075456f0299",
|
||||
"JobTrackerApi\\Controllers\\OperationsController.cs": "a2a38aee1d23c36f3cec617f1537f73d35639542087d4c1e5e7fb65f227440e2",
|
||||
"JobTrackerApi\\Controllers\\ProfileCvController.Parsing.cs": "5def3865c98277cc0b4d891b80c8680a0ce38e9864d6dc35570ece902a657aac",
|
||||
"JobTrackerApi\\Controllers\\ProfileCvController.Pipeline.cs": "ba26995f12b43d6a7cffef35a553d76d44df49de64739e9685f5f1daa841e0f7",
|
||||
"JobTrackerApi\\Controllers\\ProfileCvController.cs": "2bcb4166e7f42510ae1c5a553943e2db036be66eff6ca996ce85e3a032708be8",
|
||||
"JobTrackerApi\\Controllers\\ProfileCvDtos.cs": "73f91d597b06ee03ee56d5d0f2432a1d90e3d849f142e56d85c061feeddb5ffd",
|
||||
"JobTrackerApi\\Controllers\\PublicCvController.cs": "bff6562d7f8cc0ac0396300fa31b73f66912c1cfb7b75b8bae458e37db7cd051",
|
||||
"JobTrackerApi\\Controllers\\RulesController.cs": "526c5c7edfc2c226862067be1480d149728a8c0544eb9a3d00113a981145e3d0",
|
||||
"JobTrackerApi\\Controllers\\SessionsController.cs": "f28da30c3010bfa9c132c5f5e3da5bcf6a888a706521d909ee335d91dadf93b8",
|
||||
"JobTrackerApi\\Controllers\\StrategySnapshotController.cs": "c299dcb4c9dc3405ef7e30b40b0201356e62e51d835ed285f924c3835dbffeb2",
|
||||
"JobTrackerApi\\Controllers\\TwoFactorController.cs": "a7e90676cb92d2c1dcaffe235886a57badd951b69e4fe70a20bb794d95be429a",
|
||||
"JobTrackerApi\\Controllers\\UsersController.cs": "be96ac00554471985fec2879dd10c70adea099c431fb3c2668d585fa12109821",
|
||||
"JobTrackerApi\\Data\\JobTrackerContext.cs": "61e2007b54d93e82293884ae490bb7526086bb18b73f1ef237d06f7b54742a37",
|
||||
"JobTrackerApi\\Dockerfile": "8f02fa5b257ee5ce6e19e2a21ad90b880db469969662388c952e579690bf8cec",
|
||||
"JobTrackerApi\\JobTrackerApi.csproj": "edb22eb35e08756722b4cb99f61012ee7df70d57fb19368a0d66b673bc261f7a",
|
||||
"JobTrackerApi\\JobTrackerApi.http": "85d899af072f1f5baf6c70e111d22347c750d764b33f4321f1e13899b83f1852",
|
||||
"JobTrackerApi\\Migrations\\20260310174114_AddCorrespondence.Designer.cs": "a29cc183485bf3e39a9e9db24b65080065559c69e81b064dc6fab4ebaba9cc5d",
|
||||
"JobTrackerApi\\Migrations\\20260310174114_AddCorrespondence.cs": "11270f5b42eb49c74a8239324a13f507da4ee12e812240c060f89df2fbd5752b",
|
||||
"JobTrackerApi\\Migrations\\20260310195000_AddJobFieldsAndSoftDelete.Designer.cs": "f775935d1336c9d1c6d86f74211f0fbbf359f2bb3946420dc08ac9cb569c78e2",
|
||||
"JobTrackerApi\\Migrations\\20260310195000_AddJobFieldsAndSoftDelete.cs": "a382342a25dd6b3881ccda15f69a01c9da2b7e43637715f76a8175a0cca6125c",
|
||||
"JobTrackerApi\\Migrations\\20260311090000_AddAutomationRulesAndFields.Designer.cs": "36a92d88a91a01881bd80208a18ba2b43f09c5937b9902c2e993a885bfe562a6",
|
||||
"JobTrackerApi\\Migrations\\20260311090000_AddAutomationRulesAndFields.cs": "00525354e9802ddd33886f7b6249eb351f646a7c5f117044e34a07fe005c9a0d",
|
||||
"JobTrackerApi\\Migrations\\20260311093000_AddAttachmentsAndJobEvents.Designer.cs": "d35247759e7fdd78a2b8b1841005da8976fa622f6815173f814944c0094f962f",
|
||||
"JobTrackerApi\\Migrations\\20260311093000_AddAttachmentsAndJobEvents.cs": "326fe813c82a372bf2078da35aced5d597687a82f5bead81d86c7fd2c8d9a883",
|
||||
"JobTrackerApi\\Migrations\\20260311103000_AddCompanyCrmAndChecklistAndIndexes.Designer.cs": "e2bf69f1db0aa1bb8dd21161f93587fa6ba51032b536751634474aa13c960756",
|
||||
"JobTrackerApi\\Migrations\\20260311103000_AddCompanyCrmAndChecklistAndIndexes.cs": "82bbdbb451d35e2f83e54c7c887ae46879febd204c8872004489dd20b5d15471",
|
||||
"JobTrackerApi\\Migrations\\20260311121000_AddCorrespondenceEmailFields.cs": "aa472d198b4c0bc2ff7d1b1b119b3e4e24c4a5ac078824877b85f6152131b3cb",
|
||||
"JobTrackerApi\\Migrations\\20260311140000_AddJobImportFields.cs": "7409c0336e73577e4cf268acc6af93bfaa4db35d5ec7e09165e9c771187d2976",
|
||||
"JobTrackerApi\\Migrations\\20260311180000_AddShortSummary.cs": "ff8378e5a601083167901559318c4a577240d502008fd8a78ea39af64605759c",
|
||||
"JobTrackerApi\\Migrations\\20260711181039_SyncModelSnapshot.Designer.cs": "e8ac97d481b3796c2e8aa10efd01b4ac5f3cce7a50baeef7a76ada89339e4044",
|
||||
"JobTrackerApi\\Migrations\\20260711181039_SyncModelSnapshot.cs": "5e275596a1c68ae5c83c6fe1dfca5d57e07ce7007d40b171cb597ddb7570a4e5",
|
||||
"JobTrackerApi\\Migrations\\20260717071417_AddJobEntityAndProspectStages.Designer.cs": "6ece69c3b06312388bd344ef4ace962c8b437d67b81591eb7f0f37fc3f5c0b7f",
|
||||
"JobTrackerApi\\Migrations\\20260717071417_AddJobEntityAndProspectStages.cs": "b68ec6035af8d6c849c3018d725461d2f8336c9a6c17b114516d48d540797bdd",
|
||||
"JobTrackerApi\\Migrations\\20260717222917_AddCareerProfileRelationalChildren.Designer.cs": "114362ec4f5dcde9b265d265db1e25833deaed5e8c9185ef3095856bd61daa72",
|
||||
"JobTrackerApi\\Migrations\\20260717222917_AddCareerProfileRelationalChildren.cs": "3e3ec75d08af69de950c2dfd5d080a1da8f966e4bf9312cbffd40caf5c56be0a",
|
||||
"JobTrackerApi\\Migrations\\20260718074509_AddCvVariants.Designer.cs": "ede71f01ab9d60d4c7ec770666b26850ec848cf2b2875b2d9f548b309194d221",
|
||||
"JobTrackerApi\\Migrations\\20260718074509_AddCvVariants.cs": "2d459f231d9eba4f085f5cef5c4e4713b564c5863c4cc2606ac18f0a6a8c68ec",
|
||||
"JobTrackerApi\\Migrations\\20260718131138_AddAiInteractions.Designer.cs": "fe753ca170f13e5efa09cd9e0b0f763b5b3800f5cf830bcdf9ccb70c21238157",
|
||||
"JobTrackerApi\\Migrations\\20260718131138_AddAiInteractions.cs": "e85df4557a729f09c3fad8e9ad4dd0b1688e399e1b38f3bcf333a38c9020c28b",
|
||||
"JobTrackerApi\\Migrations\\20260719085904_AddApplicationChecklistItems.Designer.cs": "cbfac5859220710890acd8eba6728eba0e79260d3f1d3c452c1a166790c2aec0",
|
||||
"JobTrackerApi\\Migrations\\20260719085904_AddApplicationChecklistItems.cs": "434a610136f1618fce59a30d158d729e13d4906c1044b30e535665901d75a9ec",
|
||||
"JobTrackerApi\\Migrations\\20260719094728_SyncCareerChildKeyLengths.Designer.cs": "90af9b365f6e53a0f8a4a7e625a4e38db002ce55b2c68d8ce242f3f3983180d0",
|
||||
"JobTrackerApi\\Migrations\\20260719094728_SyncCareerChildKeyLengths.cs": "eadffafde32d3779a0c2e0ae2849c43378121ce0b67b421a09df974acbb86500",
|
||||
"JobTrackerApi\\Migrations\\20260719120954_AddCoverLetterVersions.Designer.cs": "d803d4aa302ca78011df59cddba3feef2bd7640a9d7166bde129b4bc457a0e6e",
|
||||
"JobTrackerApi\\Migrations\\20260719120954_AddCoverLetterVersions.cs": "a7f7ab31aa4d1b1ba1b9b5588efade6c02936ef459aa5a453d59ad87ed2634ac",
|
||||
"JobTrackerApi\\Migrations\\20260719145044_AddInterviewPrepItems.Designer.cs": "deafd04870d562604b925a1830054d2f75bb0e1b3bca1e58ef629e901abd8ec7",
|
||||
"JobTrackerApi\\Migrations\\20260719145044_AddInterviewPrepItems.cs": "97063333c7c98f4f3829f6dab3e1a1266c776e0652a4f75523952dc54b0b299f",
|
||||
"JobTrackerApi\\Migrations\\20260730203422_AddAiUsageMetering.Designer.cs": "28b9c591eb9b68316b55e01e3ca680cac295c3b58ca7356349a6617a7a8925e3",
|
||||
"JobTrackerApi\\Migrations\\20260730203422_AddAiUsageMetering.cs": "4e9915b5d0c55500b0117f9c9adf80ce667498b29980ece164b23f9d840758d3",
|
||||
"JobTrackerApi\\Migrations\\20260731115022_AddStripeBillingState.Designer.cs": "3c135d557202db5e17eabbd42df19b8cf147b46bc27e38b46b014ae2b4ed0133",
|
||||
"JobTrackerApi\\Migrations\\20260731115022_AddStripeBillingState.cs": "520aad6d0a88afa734fab46d98a6eac59484911f05a156ef935f928394029f5e",
|
||||
"JobTrackerApi\\Migrations\\20260802205800_AddPendingEmailChange.Designer.cs": "f69a87580d123371b8f0d20b889ba024f42b2cfc4fb9cf5c9140ff778347151f",
|
||||
"JobTrackerApi\\Migrations\\20260802205800_AddPendingEmailChange.cs": "9c96f54d41ec74d6473d613af10d9e71d72223491c16e16e6453390635ba7a56",
|
||||
"JobTrackerApi\\Migrations\\20260802212509_AddCanonicalMicrosoftIdentity.Designer.cs": "63f83932f99cfdeb9afa74cc2e3ab82b3de459b883aea415fda10b97685f60c9",
|
||||
"JobTrackerApi\\Migrations\\20260802212509_AddCanonicalMicrosoftIdentity.cs": "09f7103bd80a1f8aeda0a94bbd3df9bac7e1a6d4008dd945a94a26d8584a3baa",
|
||||
"JobTrackerApi\\Migrations\\20260802224646_AddUserOperations.Designer.cs": "706f6aee1c92140d544883ea1ee2b2761b2d8bbf2d4b5e66669660cadfd20b5d",
|
||||
"JobTrackerApi\\Migrations\\20260802224646_AddUserOperations.cs": "878199eaa343fda0e5452551867c070a2d8563afa7c36bd0ade80d5785b376af",
|
||||
"JobTrackerApi\\Migrations\\20260802225941_AddUserNotifications.Designer.cs": "2d390033d15d8b1f34f6198add823a7e2c7b5dcff88dc3d3fd5d60715cf73641",
|
||||
"JobTrackerApi\\Migrations\\20260802225941_AddUserNotifications.cs": "77902fa9ccfd0cfe68bb75415d08ced48196076a1f73228eb7fde505e3392977",
|
||||
"JobTrackerApi\\Migrations\\20260803001138_AddAiPrivacyPreferences.Designer.cs": "091e20bfe756a4e8c3c28bf1d90693e3aade3ec93fc0b7bb4dc3418eeba19793",
|
||||
"JobTrackerApi\\Migrations\\20260803001138_AddAiPrivacyPreferences.cs": "412bdfa1f52596ac6444062c773dd3d7508c23b6ce4a374734725944d6dd1647",
|
||||
"JobTrackerApi\\Migrations\\20260809195014_AddEmailSendAttempts.Designer.cs": "6684c4c9759f68a947b85f6ec8a001d7843061ed2cc886c6ade780e636ca4d0d",
|
||||
"JobTrackerApi\\Migrations\\20260809195014_AddEmailSendAttempts.cs": "e86d026456856a94e1327e7552dec743430d3b1016f656a731805a286f30e8ff",
|
||||
"JobTrackerApi\\Migrations\\20260810075206_AddEmailDrafts.Designer.cs": "e6223721a2f7fa480ea6e126af47d4e8fc2076b56d83cfaa1f33949d671b8347",
|
||||
"JobTrackerApi\\Migrations\\20260810075206_AddEmailDrafts.cs": "691616c54b95e2f3329533f422cdcad4f96772c9710194b5f92e1f0457ae5e54",
|
||||
"JobTrackerApi\\Migrations\\20260810080858_AddEmailDraftClientRequestId.Designer.cs": "4e3dc9df9884b2abbfb0c73521b999b2ed9f351ba3010d7de85f3a5f6560edd8",
|
||||
"JobTrackerApi\\Migrations\\20260810080858_AddEmailDraftClientRequestId.cs": "656bf59861847e8a1bb566527e4e9c217728023f145ca106e04f3366fc0ad02e",
|
||||
"JobTrackerApi\\Migrations\\20260815164027_AddAccountDeletionLifecycle.Designer.cs": "b20c9417dace582b6f632102c23c24314144c715110b4b397bc6a0bf8bcc9c7d",
|
||||
"JobTrackerApi\\Migrations\\20260815164027_AddAccountDeletionLifecycle.cs": "abd71f97e27d9891a2921c889df069b8e7f1cdb419b4c87f11884fbd9f394277",
|
||||
"JobTrackerApi\\Migrations\\20260815175236_AddCrossFeatureAiUsage.Designer.cs": "cbacda55423ce9c7601962d4225039cc05be4aa0498db8b07e6a386b1b7be917",
|
||||
"JobTrackerApi\\Migrations\\20260815175236_AddCrossFeatureAiUsage.cs": "40962a8fd323b06141032930aa27f5e994e97aa21d87054797ca1f6e9e7f16c4",
|
||||
"JobTrackerApi\\Migrations\\20260824090000_AddNotificationPreferences.cs": "857268d18a617bd48830f873e8e29626c04fd7ff8f2f8a2993a0dfdb73d9e0eb",
|
||||
"JobTrackerApi\\Migrations\\20260828090000_AddUiLanguagePreference.cs": "d1b88829c18c618ffa8b2e5a3c43dc7f12f1e93fb14483252eb5a1597f9aa5ec",
|
||||
"JobTrackerApi\\Migrations\\20260830120000_AdoptSystemEmailSettingsSchema.cs": "7685fd48b96aaeee3563af08aad9e63b9688607abc51f48cc2074eaa4e7377ce",
|
||||
"JobTrackerApi\\Migrations\\20260830121000_AdoptUserRuleSettingsSchema.cs": "a738fd35fd61b67e2b5a3da104768fd6d881262814a53284d09b21e801515c4b",
|
||||
"JobTrackerApi\\Migrations\\20260830122000_AdoptGmailReviewDecisionsSchema.cs": "bbf8fd9cb3ddcfc350650f028089770bfa53ad4f25d441cc7b88e878434bace2",
|
||||
"JobTrackerApi\\Migrations\\20260830123000_AdoptAuthenticationSupportSchema.cs": "703aaa7d95724c89ce2780c362232bb1946106b38349554b55c1bfe8d9a7da3e",
|
||||
"JobTrackerApi\\Migrations\\20260830124000_AdoptEmailConnectionSchema.cs": "8ede53875c67da18d7da7b26f858c61c85c5740ae6d67f4bae66f3a6fe3c61bd",
|
||||
"JobTrackerApi\\Migrations\\20260830125000_AdoptCvExtractionSchema.cs": "b1ef095506b3d052a421e49e19801543c6077b0a88204958a6de3f6feb043980",
|
||||
"JobTrackerApi\\Migrations\\20260830126000_AdoptTailoredCvDraftSchema.cs": "f45b9de91775bfc180025b41bf2eaaa73d040d611e2fe7957562101564394cf7",
|
||||
"JobTrackerApi\\Migrations\\20260830127000_AdoptJobWorkspaceNotesSchema.cs": "158f32ec9ae24d2ecdb910fc52ee0891a324274ae9e962657f3a44775eb9ae09",
|
||||
"JobTrackerApi\\Migrations\\20260830128000_AdoptCvVariantSchema.cs": "4c833c1f30398b786b9a46d224a9ba6efb3e53b3e5275fe3dc80e69fabb76551",
|
||||
"JobTrackerApi\\Migrations\\20260830129000_AdoptAiInteractionSchema.cs": "2663db99bb10634f4730c3ce03c9414faee2420bac7b7242d3c212755ea915f5",
|
||||
"JobTrackerApi\\Migrations\\20260830130000_AdoptApplicationChecklistSchema.cs": "2f94dfcb6d067fb2b62db642f2f66d3c3cb60778c95add9fa24d6617adc044e1",
|
||||
"JobTrackerApi\\Migrations\\20260830131000_AdoptCoverLetterVersionSchema.cs": "6da3c4dbf054e8d9dd3199267584908610fcde4125d420827d0c23164cc2de2a",
|
||||
"JobTrackerApi\\Migrations\\20260830132000_AdoptInterviewPrepItemSchema.cs": "2efc9f5cba04d7d38f72e0528dc8a95fa14b9d579da2f83dc680941e541c24f9",
|
||||
"JobTrackerApi\\Migrations\\20260830133000_AdoptCareerProfileSchema.cs": "4cc0c1def238cc918a5256b22bd364eb17112d27b84c2fd54666b8d7a1391792",
|
||||
"JobTrackerApi\\Migrations\\20260830134000_AdoptIdentitySchema.cs": "c08bef42550ec91df6c476392392c2c487a6867cbb16924f0304bb5026ce9bda",
|
||||
"JobTrackerApi\\Migrations\\20260830135000_AdoptSubmittedApplicationPackageSchema.cs": "408fc4de4cecfcb1556139ef548ef735d8383bd75853253528a3105621a2feaa",
|
||||
"JobTrackerApi\\Migrations\\20260830136000_AdoptCareerEvidenceSchema.cs": "3a4e1b6e4daade3edb1be3452c66e144b675cc13977d4b64f660fb38ce68d7f6",
|
||||
"JobTrackerApi\\Migrations\\20260830137000_AdoptSavedJobSearchSchema.cs": "cc200a1b55ad512eb823687a7479b2636b31148e2ce443414fc81926d6ecc808",
|
||||
"JobTrackerApi\\Migrations\\20261021090000_AddDashboardLayoutPreference.cs": "16682ac34da761065918528571c253f3cafb7a9acc08860374885cb038686b33",
|
||||
"JobTrackerApi\\Migrations\\JobTrackerContextModelSnapshot.cs": "bcb596507956a89ccd243901e337c89407897b479cfe6e20dd59f42584fc5f2d",
|
||||
"JobTrackerApi\\Models\\AccountDeletion.cs": "88e634a1dd1c360f0ab9621b3a28e3412408b4d682fc827b5871a397f8e8e28c",
|
||||
"JobTrackerApi\\Models\\AccountPlans.cs": "c4db37f54d0c86ab3edb75718449efa00a435c3e7ad3fdcac4e8982b58e00db8",
|
||||
"JobTrackerApi\\Models\\AiInteraction.cs": "39ccf03d81c690d9452fba0e73b11ba03b26b0f982b52ea70c0fe334109fb669",
|
||||
"JobTrackerApi\\Models\\AiUsageRecord.cs": "43b40e521d9c469dd343ef74fdb92417345b83f8b9397158b9abf39eacc9ec0f",
|
||||
"JobTrackerApi\\Models\\AiWorkspaceNote.cs": "3bef6b380950d09b666a41ba34aaefcb0cddc14d3a1f7210874507d6287b75d1",
|
||||
"JobTrackerApi\\Models\\AnalyticsDtos.cs": "9f19e592c812f74d0087a3b3e1d84d584ebf521842711edec391f60ba2455dc4",
|
||||
"JobTrackerApi\\Models\\ApplicationChecklistItem.cs": "c652fe5a7dbe264877189d0485b4b8d218c591634c5106e11b2c5e3407c65322",
|
||||
"JobTrackerApi\\Models\\ApplicationUser.cs": "0369bfa87fe51547231c3f5287fe9989cb08b3ca498d6a0667cd59c3668e6837",
|
||||
"JobTrackerApi\\Models\\Attachments.cs": "23dc6834f533b319fcdb7d908bdc67b2d77702a7b888aaed51f80e30ba6ca581",
|
||||
"JobTrackerApi\\Models\\CareerEntities.cs": "aac8e4bbaf9323a8e3f9243d625efbb346b0f8abed295245496c2cfdeb357751",
|
||||
"JobTrackerApi\\Models\\CareerEvidence.cs": "d016cebfb1097e3710742c6ac7aac79e2bed74bbe15f1607f79c086f82c46e53",
|
||||
"JobTrackerApi\\Models\\CareerProfile.cs": "761bc284290e6af4f4d0e7a6344c02df8235b2dd94cac15b83bfc98b6d15fb17",
|
||||
"JobTrackerApi\\Models\\Company.cs": "775619203c7b9dabbc52a7d96a837f11c8083c47075c63a037ea778bb1c34fcd",
|
||||
"JobTrackerApi\\Models\\Correspondence.cs": "f15760d1566b7ad31bc8e6d81486a509b71d4f939875d79ec7218e5fe71d952a",
|
||||
"JobTrackerApi\\Models\\CoverLetterVersion.cs": "96adb0b37a80533a65688ab28b0e9432616ef67d2b2eec0224a76fb3c6caf0bb",
|
||||
"JobTrackerApi\\Models\\CvExtraction.cs": "b698183c7f6186e4dd67b5219bd5825bc0da5ad5521cb0295d141bb9e106d65a",
|
||||
"JobTrackerApi\\Models\\CvTheme.cs": "03604a29432c5924e533db935ec3d48ca042a8704b5f3e9e4cfe7c2795c97ec8",
|
||||
"JobTrackerApi\\Models\\CvVariant.cs": "9032bd64091d5162e8998b4d117cd0a653323b22ae9653edf0e33566c25807ba",
|
||||
"JobTrackerApi\\Models\\CvVariantSettings.cs": "06cc5e1a969aea498d333d751b31fe54d0bc6e778e39d225a7f23c2ea2f8314f",
|
||||
"JobTrackerApi\\Models\\DashboardLayout.cs": "11dc1b97347b726b903faee4edf4bdcbc16f7fe6c940ddc6d0df5024f64511a3",
|
||||
"JobTrackerApi\\Models\\DiscoveredJob.cs": "d7e3c5ba67bd47a0a38c58608323c89e0e30ae13e6fc4eafedae5a97e2d05684",
|
||||
"JobTrackerApi\\Models\\EmailDraft.cs": "546f51251d3743cae68149c656366c0ead74aa1ea22d35318508b5ec2ce17671",
|
||||
"JobTrackerApi\\Models\\EmailSendAttempt.cs": "47c1f579fd72d37dd9e020b4dd11d8c10cbfa5741e04076fcbd9c73ef579e062",
|
||||
"JobTrackerApi\\Models\\ExternalCalendarEvent.cs": "3a9c0b09314f32f1cf1e83c97236a63627b4885c06425cba8268e96ad7d0bc73",
|
||||
"JobTrackerApi\\Models\\GmailConnection.cs": "f741b7192eedb4ecb93ad616a132e5909bf62e3c531caa1334fffd30ccb54cb4",
|
||||
"JobTrackerApi\\Models\\GmailReviewDecision.cs": "9590c585e6de694520395752903ffcd686d90b8a4b4b1f826392335857c121f2",
|
||||
"JobTrackerApi\\Models\\HumanLanguageCatalog.cs": "e0ec269511d6d1722a3a48e0c6f410b7559aed319f527a0619352720f48e4203",
|
||||
"JobTrackerApi\\Models\\ImapConnection.cs": "6e03530f96df2d44eb5f161497dd39f6f3189023430248676c692eb7d05bf3bd",
|
||||
"JobTrackerApi\\Models\\InterviewPrepItem.cs": "4775f98b76f517abd123820452aa0c7bad623c2e6798204c73c858d933d29635",
|
||||
"JobTrackerApi\\Models\\InterviewPrepNote.cs": "afa02925af9eba8cd4ecc00119912015a67d10ea6c7c0b1d8d94d9728edfff0d",
|
||||
"JobTrackerApi\\Models\\Job.cs": "074246e2af9e308009562bb6cf04bffb441f3f4e3c506f0a990ea593809484bf",
|
||||
"JobTrackerApi\\Models\\JobApplication.cs": "dd75f1e33d5094e0c244938365e1a3ff63a1ecf1895247a8008dd0ec667904d1",
|
||||
"JobTrackerApi\\Models\\JobEvent.cs": "f1e925a840d206f33a616e73ecf7efb29a015ab2ccbc2bb2a7b820776c960974",
|
||||
"JobTrackerApi\\Models\\MicrosoftGraphConnection.cs": "9b5d9c7e8ab99f70f7ea0b6bf298768fc169ea6fcd58e28e819120cb6963758e",
|
||||
"JobTrackerApi\\Models\\RuleSettings.cs": "abe1b0d412d5a53614d5042e1e910f0daf7439a76357ce5fef80849359266d57",
|
||||
"JobTrackerApi\\Models\\SavedJobSearch.cs": "7385b52232fc2434123b3e89045b5380f0a7185881c4a19fbbda8b4c410f2f9b",
|
||||
"JobTrackerApi\\Models\\StructuredCvProfile.cs": "01447f53ef36b4ff308a1dfb563bdca8387cce19398300b1aa81f02341e54d9e",
|
||||
"JobTrackerApi\\Models\\StructuredCvProfileJson.cs": "a86142c6a911156f19f18f0067f9db8d74da766aa8ac5c2d1bd4695a47dcb327",
|
||||
"JobTrackerApi\\Models\\SubmittedApplicationPackage.cs": "5baac5a52cadda819ce10c818226f6debcf5c4c8845dc3418430c870ae3cbb0a",
|
||||
"JobTrackerApi\\Models\\SystemEmailSettings.cs": "cdbac7482ee7c18439acfbc007f781b2d7192b9edfe2f3004d0336b5e2a94692",
|
||||
"JobTrackerApi\\Models\\TailoredCvDraft.cs": "5d3427ce02cc5a5763b752c27b338eae6f561f6b00f6902e6bf8481d1f7f5e90",
|
||||
"JobTrackerApi\\Models\\TailoredCvDraftJson.cs": "6929e1d071138429944ee883eae4e18e9ad2d4667c4e692c95ea1c4314bb9171",
|
||||
"JobTrackerApi\\Models\\TailoredCvTemplate.cs": "7269bc96285b8e3411cfcda4609db13a9ddeb4d3a3d04f0158199ca52de7d5e6",
|
||||
"JobTrackerApi\\Models\\TrustedDevice.cs": "60b98a6b6d95f361fbad93e68535ee0bc0d9a3f7ad1d5477f59b86a942f20737",
|
||||
"JobTrackerApi\\Models\\TwoFactorRecoveryCode.cs": "d79d5070ba1ee0c4062e01b40807513abf64e37ac4377d04f4f4fa8a743e825d",
|
||||
"JobTrackerApi\\Models\\UserNotification.cs": "e22f40794806f12d781566ce5426da9473ed9db7b532e6f3e0cbed252c2ef811",
|
||||
"JobTrackerApi\\Models\\UserOperation.cs": "f6d4d36c47cfe9a5492e95af65a7dc5fd841b0b6a3f44b00b305450c0f4ddcb9",
|
||||
"JobTrackerApi\\Models\\UserRuleSettings.cs": "bf4debf788108d828d63fc1ef4b0be2cc4c4f211a5b1fd99d335d1c1714ddfd3",
|
||||
"JobTrackerApi\\Models\\UserSession.cs": "d67f2419308ff613ff47388a2dbb260b86b119627aec92d3d0efb98ed598f140",
|
||||
"JobTrackerApi\\Program.cs": "e906d23d8d4195a8e361428b26a9a713a77981f4d6636c80f68f6706c47e4d53",
|
||||
"JobTrackerApi\\Properties\\AssemblyInfo.cs": "31a4fe6953e334c82f5c541d4b396cd18572e64e0ab936c9cdfd3605f4e8877b",
|
||||
"JobTrackerApi\\Properties\\launchSettings.json": "1eadb72e8554c03f90166961c90c33dddf3dabef6ae178667e9181c46ddee190",
|
||||
"JobTrackerApi\\Services\\AccountDataExportService.cs": "e50907a073f2d4a2ffd46600e108de174b08eaae5c87b8e11dc80fb683345bdf",
|
||||
"JobTrackerApi\\Services\\AccountDeletionService.cs": "f28914aa7a1a2b6894a5f9cf71490f66d50ddda97bc4591238920560ac97fa2a",
|
||||
"JobTrackerApi\\Services\\AccountDeletionTombstoneStore.cs": "e32353f47f7fff972af0700cfd31a5115b82ed2397fc97fe05a6aa9df09fbe7f",
|
||||
"JobTrackerApi\\Services\\AccountOwnedFileInventory.cs": "22cc112f4bce0d711de9d93a9cdc3b7119b069eec16a3b0f7c03b1598d46f717",
|
||||
"JobTrackerApi\\Services\\AiOperationQueue.cs": "dc16f5cb057f5958f0b557d78168ec749b6d58a1659431422c6e0d880a24278f",
|
||||
"JobTrackerApi\\Services\\AiPrivacyPolicy.cs": "d45642b00d65dba489cf27185fc7f17e9f4db95bf75bf56d90b4baa0ad0dc609",
|
||||
"JobTrackerApi\\Services\\AiSidecarCachePurger.cs": "823e8ac47f9bb29b8caa08cc07bf417037cfee9c6ee0c1088e4263c88b785116",
|
||||
"JobTrackerApi\\Services\\AiUsageExecutionScope.cs": "e91fad9300b206d2e8de57176dd9481d1e262fdcbb2f553f18eb4666089d7603",
|
||||
"JobTrackerApi\\Services\\AiUsageLimitExceptionHandler.cs": "a2504f08ba0777aa788cc0a9779d9d821e9e12985e7dbaefce422d50064b12fa",
|
||||
"JobTrackerApi\\Services\\AiUsageMeter.cs": "42cfcdc54aa49defb53a9a2350d07f6e0c3b4673cccdc7f5db74b8dda93b3196",
|
||||
"JobTrackerApi\\Services\\AiWorkspaceService.cs": "4e4050544d31134370748381d8c60940460784ce387d8d57e4fd1cf55056ee94",
|
||||
"JobTrackerApi\\Services\\AnalyticsService.cs": "3027295ac354d9ef0d9dfb40c204081e5f3f423b2d0218afe69ff7919969395d",
|
||||
"JobTrackerApi\\Services\\AppPaths.cs": "307792a8a162ab3c1ff74d11b0c7989444510d6234f1b45edddef1eb3519d413",
|
||||
"JobTrackerApi\\Services\\AppSessionIssuer.cs": "f73923248baf7116a590cb2ba79ad7fb43c6faf12913d967fe45d61e32f20ac9",
|
||||
"JobTrackerApi\\Services\\ApplicationAssetsService.cs": "0eeb42c046bc0724acc93eff31275b156f69f7e8d6c6200ee620a3d5011bacb4",
|
||||
"JobTrackerApi\\Services\\ApplicationChecklistService.cs": "b9b0bf8c362f4cecf4586e221b9e003cf241bfcbcf3baea45519d3227aeb4c4c",
|
||||
"JobTrackerApi\\Services\\ApplicationIntelligenceService.cs": "4ee426d66e325a03196d88e3a952b337fc0d93847e5b2c9c952997307fb69aee",
|
||||
"JobTrackerApi\\Services\\ApplicationTimelineService.cs": "0a42d6e2dc2647c8f878a5c21b17f3635771a1fe79933d1a5adca216c04ea7a7",
|
||||
"JobTrackerApi\\Services\\ApplicationWorkspaceService.cs": "89804f852b410b6b5f357ee202ba4d11974f5b795679ef6b6978fb140fb936a5",
|
||||
"JobTrackerApi\\Services\\AttachmentStorage.cs": "ef0645d23d53c09dbbb82b8c4d4e834aa66fdc0f2077f2ee0cfd295e9e4d97b0",
|
||||
"JobTrackerApi\\Services\\AuthSessionOptions.cs": "d21939343cfeffdfdbc820d93d9031716566f8000087e2080b658fc78f35f592",
|
||||
"JobTrackerApi\\Services\\AvatarStorage.cs": "3317bdbbee4b62b2f94b75cbe6e60ffadfd721d16512c2ca0154812094fc2487",
|
||||
"JobTrackerApi\\Services\\BackgroundTenantRunner.cs": "e44b126f2035abfbfd8485b68f6667f7595ca8beb82c77d12a8384bb55571332",
|
||||
"JobTrackerApi\\Services\\BuildMetadata.cs": "e8ed979017c48e65bd450feee3d61bd0bcf913d2aa5843369e9a3f24fbcf0e3f",
|
||||
"JobTrackerApi\\Services\\CareerCompleteness.cs": "0f91cc50cacb87b240126a4c516efb508aee8e615930be6be02652790b83d785",
|
||||
"JobTrackerApi\\Services\\CareerProfileMapper.cs": "d8677ca1f99a9b604521ad3f00c143337c23b8d7f00af45a529115e3ed379496",
|
||||
"JobTrackerApi\\Services\\CareerProfileService.cs": "96d7ff93fe8efc5a26d378ca067812beac4c1742a0c58459c3b2899748929549",
|
||||
"JobTrackerApi\\Services\\CareerProfileValidator.cs": "b630c0e7fad24690003c4e4021b9ad6fea506bf4b91a5dbbf5bbbefcbe40d39b",
|
||||
"JobTrackerApi\\Services\\CsvCellSafety.cs": "ba9d2d0d99d236dcd25d244135a864c4702f62f35a298f48a32ca5ecd95ac44b",
|
||||
"JobTrackerApi\\Services\\CurrentUserService.cs": "81ce46e3b907a25a44ba7f9814b04eb00bb4aa97618a31c6c842bf296d50732a",
|
||||
"JobTrackerApi\\Services\\CvAiClassifier.cs": "db300b23fd6b07d506b2f886e5162c78a5f841997f448d9f1fa88d5b75c7f58c",
|
||||
"JobTrackerApi\\Services\\CvAiGrounding.cs": "cf4b5b66b8577918fb7b75012d9a0b5a8d33e47f362d23b3476712c10ef7b231",
|
||||
"JobTrackerApi\\Services\\CvAiNormalizer.cs": "cd3190b2e895da3121322a564d7d1529881c77d0aa591b2d893a0c46bbcc7671",
|
||||
"JobTrackerApi\\Services\\CvAiSuggestionService.cs": "73d6523feba27bfcde375cbc2541f21481fdec6bd1d92d5060a8e730637e9181",
|
||||
"JobTrackerApi\\Services\\CvImportDiff.cs": "89ae084ab1ed685338e375b208b3e9377dd222990ec06b27ea659d9ec6b2b93a",
|
||||
"JobTrackerApi\\Services\\CvProcessingQueue.cs": "fc84c9ca50cad59416980c3b4ce378cf2f172742309406d2096ee48e588773e6",
|
||||
"JobTrackerApi\\Services\\CvRenderModel.cs": "8c18035e5bfba230d6fb0f4200103e3a4b9bf4170e0edcfcfcb4f9506d36b6cb",
|
||||
"JobTrackerApi\\Services\\CvTemplateRenderer.cs": "688127435023ecca7f7abb867cf4ec8d6d34886442254307c2d3b6f543330448",
|
||||
"JobTrackerApi\\Services\\CvVariantService.cs": "97f450fc3613c03de064816e6388a8cfab60d46db5d2739ed3e9ba5da7a71bc2",
|
||||
"JobTrackerApi\\Services\\DailyExportHostedService.cs": "03a811acef1ef3af481d5ce91e8600089a009d5b339dedcc672aabee9e9e4a5a",
|
||||
"JobTrackerApi\\Services\\DatabaseBackupHostedService.cs": "b1ca4cd26c692069fefc361a1b50d5bd63a19368cc2ce7c3c2cef23d05cd1bfd",
|
||||
"JobTrackerApi\\Services\\DatabaseBackupRunner.cs": "31634059bb25a5e9a9d438f927e533c33acbdb860e1c933cfc40198eb15c3579",
|
||||
"JobTrackerApi\\Services\\DocumentsIndexService.cs": "def370bd0dbe90260e42800fded99302e56f8ac6b7693e73dd0a60cf0a3f7fd1",
|
||||
"JobTrackerApi\\Services\\EmailProviders\\GmailProvider.cs": "a82da88e1427d38a7468f7edbc5d42e44369905abfebdd1fa0141ddfcba39087",
|
||||
"JobTrackerApi\\Services\\EmailProviders\\IEmailProvider.cs": "ec8c1ff4d4b2ef3dea6f3abb8c8be483bd47d7aa80c0b5f0ce75b14bb1ccd99e",
|
||||
"JobTrackerApi\\Services\\EmailProviders\\ImapProvider.cs": "e04fd5ef64e18c82aaf90712aaf50f4fef5d9009757f3483777f65283b30b7b0",
|
||||
"JobTrackerApi\\Services\\EmailProviders\\MicrosoftGraphProvider.cs": "2d2984d679ff0ead5411fc13cbd201fc0891f27df83be4dc1a9214a84efaf884",
|
||||
"JobTrackerApi\\Services\\EmailSendAttemptRecoveryHostedService.cs": "6e096c7b2ee33d07f305bc7db3bfd05f693369498d2e82d620f6e4361c49def4",
|
||||
"JobTrackerApi\\Services\\EmailSendAttemptStore.cs": "4fb3e7ad8a8534cfbc081c42114bbf2fce65b954d561f6cff96d3548bc346f13",
|
||||
"JobTrackerApi\\Services\\EmailSettingsResolver.cs": "0fdc358f9de21fc48ca002711fb5d73613a5185c074c4ea0b408f802bc579460",
|
||||
"JobTrackerApi\\Services\\EmailStatusClassifier.cs": "5f7e352c8e35989d899f954ef4915abc290499edfffcc003a214bc5babe31f6e",
|
||||
"JobTrackerApi\\Services\\ExternalOrigin.cs": "c6ce5d12b0bd14828c1f17703cd23ac609f8cd71367d6fd3758df46d977538c2",
|
||||
"JobTrackerApi\\Services\\FollowUpReminderHostedService.cs": "63671d11d41ad6339bf2499044502cea9a2260bdf101647438f16ef49454641f",
|
||||
"JobTrackerApi\\Services\\ForwardedProxyConfiguration.cs": "7f514586a082b5004056ae4e02afbd083bce4ec4181dd516cc7fe5ef454eacf4",
|
||||
"JobTrackerApi\\Services\\GmailCorrespondenceEnrichment.cs": "4e6cc0900f27d7c5be0d6b756b430124eb57a7169bcc685969384a132fde1e2d",
|
||||
"JobTrackerApi\\Services\\GmailJobMatchingService.cs": "97c05ffbb5792dfa0dab3f1642666066e087acfb2ab1ae1760437481cb3f343c",
|
||||
"JobTrackerApi\\Services\\GmailOAuthService.cs": "b15e05c17ef6b3578455a9344d7a688d19355246e741c1cc202d9aec4e6346a2",
|
||||
"JobTrackerApi\\Services\\GmailParsing.cs": "c9da9f20b7fa2e64f4555eab103b073728d47be17c52b9be8149a55f6eebaad5",
|
||||
"JobTrackerApi\\Services\\GoogleTokenValidator.cs": "56448c5faa0acbc3155ab3df1b3c71b73be969972c5f149e3992ab3e91a49518",
|
||||
"JobTrackerApi\\Services\\ImapService.cs": "65e0aeead7cb36cb263acd0fe878ae87077be62e417ea4be143a8c1ae542e174",
|
||||
"JobTrackerApi\\Services\\InterviewPrepService.cs": "b236f78bc8db4c2bea8fc796ec55b77890aac0151cbdcaa3ba368ec0ee1c649c",
|
||||
"JobTrackerApi\\Services\\JobApplicationHelpers.cs": "61b04b3d3bd040129510eaef66eef6f14fd4455593ffbecb42f34319aff16d5d",
|
||||
"JobTrackerApi\\Services\\JobCvMatchService.cs": "0b851d20374f3205b9abb37c755b3189c5bb6f47863a36f1dca45ab88aff9b8f",
|
||||
"JobTrackerApi\\Services\\JobDiscoverySearchService.cs": "7c7295f33b16d4c3d1e04f8fe7ac0d7eb2318c9f1fc191cc2aeb7b88d33d4057",
|
||||
"JobTrackerApi\\Services\\JobEnrichmentHostedService.cs": "e2dd67e8e84e11a510c29404b3ecf82371d6790f3481254426ce004cfd2df401",
|
||||
"JobTrackerApi\\Services\\JobImport\\IHostAddressResolver.cs": "c4b0799782988e983d3c350c5224dffaeae2d95d0f0d69f01d6cab52ffa9ccc8",
|
||||
"JobTrackerApi\\Services\\JobImport\\IJobSitePlugin.cs": "6d8d2389df570e79ab34f1ea83098dcbcb9097dbf662697359d0522872b399c9",
|
||||
"JobTrackerApi\\Services\\JobImport\\JobImportHttpTransport.cs": "db3f8fb8e97b1d721dfda9915bfb53945fbe0aa497d32593733adef0684d0243",
|
||||
"JobTrackerApi\\Services\\JobImport\\JobImportResult.cs": "2a5a8923a0cc361c2476b196e5c6fc3d86359b7529f21dede5950448498e98e2",
|
||||
"JobTrackerApi\\Services\\JobImport\\JobImportService.cs": "8995aa2ce62c163c2b2a950a8c335ec655a07db03246aac3d8ee415a5a841273",
|
||||
"JobTrackerApi\\Services\\JobImport\\LanguageDetector.cs": "b4547478d4926614a50b90f5ee69e42ef3c64580e38bc8e1969323e68ea904a3",
|
||||
"JobTrackerApi\\Services\\JobImport\\Plugins\\FinnPlugin.cs": "332ecfbe910a6fa2843a7169b285ca15ebd280dd3c2651c178edc7b1a84aef2c",
|
||||
"JobTrackerApi\\Services\\JobImport\\Plugins\\JobbnorgePlugin.cs": "7e57f0980e424e2248b295455e12e173b5e34feb6e89ecbf1b70219bd1cb4dd8",
|
||||
"JobTrackerApi\\Services\\JobImport\\Plugins\\LinkedInPlugin.cs": "7494c50d43de306d94fd826c3477c146d7b40126eb85a132c5c0874c6fcaeab9",
|
||||
"JobTrackerApi\\Services\\JobImport\\Plugins\\NavPlugin.cs": "5ddb894ce0351e9bdc8829eabbf5e2aa04634b7d18993c6b606d82069a10406a",
|
||||
"JobTrackerApi\\Services\\JobImport\\SkillTagger.cs": "02a8f2aa2dd302342aa8f6d929a2a1aa552f7e765a405c805876e1004e4fd51f",
|
||||
"JobTrackerApi\\Services\\JobImport\\Translation\\ITranslationService.cs": "ac9589f6ae3df8704749cd7f9e8a58e7e7b18ac0ea071238652e20c791cfc8d4",
|
||||
"JobTrackerApi\\Services\\JobImport\\Translation\\LibreTranslateService.cs": "dc586592aaa155bdf7ae4a94cbd5d7323ffbf28210ccbe340c5a94bf7b16142f",
|
||||
"JobTrackerApi\\Services\\JobImport\\Translation\\NoOpTranslationService.cs": "3d02f424057c2ff4e036fccda12b5c68b7d9de1ad8ad238c06c5734e76d4ee24",
|
||||
"JobTrackerApi\\Services\\JobImport\\UniversalJobParser.cs": "d75b2b6180fe733d370c95710983fcd7f18a000405a469a2f25100be2cc9e62f",
|
||||
"JobTrackerApi\\Services\\JobLifecycleEvents.cs": "6e8c4cfadd7bf6ec85a4e226a4b865b48c94293911875743d67936e32a4cb9a3",
|
||||
"JobTrackerApi\\Services\\JobOpportunitySync.cs": "9d4d23155e039d4316d393bbdcdcfdb24f05707a1f81becaefdc40167541ae04",
|
||||
"JobTrackerApi\\Services\\JobPipeline.cs": "8d6ac0e36abcc7aa54215001fd55c98dbdda6c8b6ff22d4e9e538c769aa5b58a",
|
||||
"JobTrackerApi\\Services\\LocalAuthIdentity.cs": "b482eb62e6d2642da1e0e20a54f3251a1d8c5ddc7e349d01dd2f4e309b8809e0",
|
||||
"JobTrackerApi\\Services\\LocalSessionValidator.cs": "b71f53ffbd52b577fbf7e8d29e111b11ae7ad1268fab923e64bd795e5625fe31",
|
||||
"JobTrackerApi\\Services\\MaintenanceJobDispatcher.cs": "528a346bdc6431ec3a1670226a4da9f541c9905c43ead27d905aad8b0ed25a1c",
|
||||
"JobTrackerApi\\Services\\MeteredSummarizerService.cs": "6b0af014fcf36fb054a2abd880eba7bff302054709ebb2c65a35628f2aab7a5c",
|
||||
"JobTrackerApi\\Services\\MicrosoftGraphOAuthService.cs": "e104297ad40b9573b62ff638bbb99c20c8cea6b5a67f71d8ca8fd2c0c8915f42",
|
||||
"JobTrackerApi\\Services\\MicrosoftTokenValidator.cs": "029b436cb88a64773fecaaa152a11a2290d5d43be4945edc6229175c97a32799",
|
||||
"JobTrackerApi\\Services\\PdfRenderAdmission.cs": "8052d01f48b0eb4c3a32d8659842a15925c63df07435b85004b867b7991a0e82",
|
||||
"JobTrackerApi\\Services\\PdfRenderExceptionHandler.cs": "caad50b2fea488661cfc5741f287f033c880529db8fdcc74a50f684fc8e931aa",
|
||||
"JobTrackerApi\\Services\\PlaywrightCvPdfExporter.cs": "63ebc55787cd1f340205c52f94a79ae20f9b09cb964b57cd61c921a853edc9b3",
|
||||
"JobTrackerApi\\Services\\ProEntitlementAuthorization.cs": "bfb888aedba08b46059d5306105fc67836c516208861f29146091d38e7edc823",
|
||||
"JobTrackerApi\\Services\\RateLimitPartitionKeys.cs": "087feed0668e22927ee238933b4dba6807cb59819752756690947ae77f391f2e",
|
||||
"JobTrackerApi\\Services\\RulesEngine.cs": "a65df7c9f1e359051c2a749e69aa0665f4a8fe2e57a4e95919d3ed737ab242cc",
|
||||
"JobTrackerApi\\Services\\RulesHostedService.cs": "bca993c4993bee18707fdcf6b7a557360589a7209fc6ecdedc5dff93308c52f2",
|
||||
"JobTrackerApi\\Services\\SavedJobSearchAlertHostedService.cs": "20b33c143c946ea36fa2009f31789d6b420a5fb13445999eb7910fb5b58fe4ba",
|
||||
"JobTrackerApi\\Services\\SavedJobSearchRunner.cs": "2b6de59608d1d259629839aa391047b699148119344a0f716db6f5aca417cb9d",
|
||||
"JobTrackerApi\\Services\\SessionContextMiddleware.cs": "16e26f6a38b5b5ca8ed054d1a93475f908ec2f0fa4c26efd125ee888a0fe9d8e",
|
||||
"JobTrackerApi\\Services\\SessionRevocation.cs": "42d017c634970dfcba25b8dbff4e944d2afeefc6ecde879ea2cadf9e9810034c",
|
||||
"JobTrackerApi\\Services\\SmtpEmailSender.cs": "dcba060eedf623cbb5ccb58abba9b3acb547b21c7f6ffed825d6f66086c51581",
|
||||
"JobTrackerApi\\Services\\StageAnalytics.cs": "af3940979b7688755e14ce6d9aa47e99ff3874b1e109cd6c738114335f039ff9",
|
||||
"JobTrackerApi\\Services\\StartupInitializationExtensions.cs": "f5c4f37b4f98aa15f1a666f985fc80901df2337ffe3a9c76258e126bad0477a0",
|
||||
"JobTrackerApi\\Services\\StartupReadiness.cs": "0afb319c4f7bdc97d3b767f33e32ec1e6d1ddcb98e2778055e9da2cd7f0a8934",
|
||||
"JobTrackerApi\\Services\\StartupSchemaOwnership.cs": "07b873ceb129756a4dc1aca92249a1d0e4d23ee11d7696f05ab93e8a982540a1",
|
||||
"JobTrackerApi\\Services\\StrategySnapshotService.cs": "cff0853c571db0eb9537fee294e986b0dd0bf31f579c5395f4241ed7c6645d20",
|
||||
"JobTrackerApi\\Services\\StripeBillingGateway.cs": "55af587ebb6a42a4add137d3f038a6629b906ad9492449ade5166a74f0697c77",
|
||||
"JobTrackerApi\\Services\\SubmittedApplicationPackageService.cs": "4f1b6a8f1f72dfe1f3f77bcd1b68e326d2b9eed5cacd72e6b3e54263ff15da5c",
|
||||
"JobTrackerApi\\Services\\SummarizerService.cs": "b3b2184ea7c3c7676a6059559ff602ec99be03d503302a4515127578f33d89f6",
|
||||
"JobTrackerApi\\Services\\ThemedCvRenderer.cs": "67fc2220b25f1d3eacbe4d0ec6706220c428542a6b193d71c5725d97db093c7b",
|
||||
"JobTrackerApi\\Services\\TokenService.cs": "efa967c93ec99704af30184c534d086151db0da32bbdc3bf630372168c9cbbce",
|
||||
"JobTrackerApi\\Services\\TrustedDeviceService.cs": "9ea9c002066ff07ee6339720b2533da3b5bd66a48e26196dc8fa3e23e3b3d0a1",
|
||||
"JobTrackerApi\\Services\\TwoFactorPendingTokenService.cs": "053ea80a6a9236d50e87e6864adabcdf8b1c3571fcfd5018f1bb225c87be2a07",
|
||||
"JobTrackerApi\\Services\\UserNotificationStore.cs": "befd2179c46acd0d09d583d83afbc9288b43485a01e3292c3bef6aa798a2e984",
|
||||
"JobTrackerApi\\Services\\UserOperationStore.cs": "83721ad7bc99d70b9d4b55e0381e51e7449ff1c08a0005a36341d31b52a42693",
|
||||
"JobTrackerApi\\appsettings.Development.json": "c5fceaa5113f8106146879005a8e00c13b3bc0438fb83e2c162e99cec1e166a8",
|
||||
"JobTrackerApi\\appsettings.json": "7c5696451094393edd60777d23363776575244508ff54cbd0e6942ab856ffd6d",
|
||||
"JobTrackerApi\\packages.lock.json": "657de6a2093f849029f2aa60de7df3939b4e13057972f0637aba1fb4d143bfdd",
|
||||
"job-tracker-ui\\e2e\\accessibility.spec.ts": "8cd724183b6c1dd4e5b47f358afe9bd71e2ee1fac889a8c1ac01f93ab7e23f81",
|
||||
"job-tracker-ui\\e2e\\account-isolation.spec.ts": "c1029dfb8f684cceff238925c91fc6690f14edfa84e1071ad8a843badf01bd24",
|
||||
"job-tracker-ui\\e2e\\analytics-auth-repair.spec.ts": "03560be9bfff4d410866b5498f2a0b05ba1d9733a7ccbf8cde24f6d8e08cd4eb",
|
||||
"job-tracker-ui\\e2e\\analytics-presentation.spec.ts": "eacb2ec856f406cbb1642044a5cca4105507c8c6ce8cf53bd19dd69d4e847c54",
|
||||
"job-tracker-ui\\e2e\\board-presentation.spec.ts": "5f99a88d9d49a1749449390fa7380c04fbc972ac57086ebeffeb5ec5a9b738f0",
|
||||
"job-tracker-ui\\e2e\\browser-zoom-audit.spec.ts": "d2e14c8fb86a068456c1bcebf090b707d6448561b8643f13204bcd22e1515b37",
|
||||
"job-tracker-ui\\e2e\\builder-preview-keyboard.spec.ts": "abb2bfbf9a10f8fd717c3b438c9fb794a9fe63f25331f48acf4ab6eb0dd7f392",
|
||||
"job-tracker-ui\\e2e\\csv-export.spec.ts": "8c02ff73f975b1073038b81f2b2d5dde187b8d25ed81b195f1006562c6b2b082",
|
||||
"job-tracker-ui\\e2e\\cv-editor-frame.spec.ts": "560044042c97f71f1e8d6dc2f0fc2c8106f29d3fd3557324c9cc7c5569c58ecd",
|
||||
"job-tracker-ui\\e2e\\cv-pagination.spec.ts": "507829402816ea4d977f2b46d95f71c90c75fc5abd60cd6f3dddeefbfb83d6f8",
|
||||
"job-tracker-ui\\e2e\\cv-preview-presentation.spec.ts": "ed2d8676daa8101de53bd49c4dc1e27fc4e75db2b522a9cdd8d246ea8b42abaf",
|
||||
"job-tracker-ui\\e2e\\cv-readiness.spec.ts": "fa5815d8fe735230d78f8857fed30b7231295e7312b59961d06f11f2d1de3b73",
|
||||
"job-tracker-ui\\e2e\\cv-responsive-workflow.spec.ts": "315fc480cef1fda3dcfb6aa339ecb49a8709650fe4a142388446756fe5519ee8",
|
||||
"job-tracker-ui\\e2e\\cv-section-scope.spec.ts": "d6d1eaf7d0fec859abac4f16d3ae64280c9813b391b2555f6b4235bb11502f4c",
|
||||
"job-tracker-ui\\e2e\\cv-settings-presentation.spec.ts": "b395da8de320e18a55df4deb75373773fcac54f41e9a7b9752b3f16ce2a4b3f6",
|
||||
"job-tracker-ui\\e2e\\dashboard-presentation.spec.ts": "964f50aa7e212282c7c244f383ec50ae82b04562d1dbaf741da49c5b37243524",
|
||||
"job-tracker-ui\\e2e\\functional-acceptance.spec.ts": "fbba35f4ccdae767e5a4d87623f190bb348ce3ecd1eb7a4248316d6822fa3dd0",
|
||||
"job-tracker-ui\\e2e\\helpers\\seed-career-review.py": "ffab6b9f8a31fdba30e1ab70a55807defed44951878d4a634ada82001ebe39fd",
|
||||
"job-tracker-ui\\e2e\\helpers\\seed-operation.py": "1a786c3b6794f15234426b0ae956ef300f0c55aee990ac2cb427b6191555af45",
|
||||
"job-tracker-ui\\e2e\\job-discovery.spec.ts": "c6bb9fe3c5e11f732e5f0c9bbb7096268b02c2c7540f58d5ef84bc537b5a6142",
|
||||
"job-tracker-ui\\e2e\\job-overview-presentation.spec.ts": "8167fee1a0e98c6b477ba5326c98116f79982e061e6db8b757167959693fb9b9",
|
||||
"job-tracker-ui\\e2e\\job-sections-presentation.spec.ts": "bd3e08daecc48d4fb99e9b3d46bfc3ce0e2f9ada19c2fc521c5a1cadbea33a54",
|
||||
"job-tracker-ui\\e2e\\kanban-theme.spec.ts": "3df2d2e6e3d459492331ff429c7bfc19064563110df39ac53d40093a0d8f3225",
|
||||
"job-tracker-ui\\e2e\\named-controls-modal-state.spec.ts": "c03cd2ce5f3b8c68292403f59f42189933e59e55f255a9c8303b2e89700fd529",
|
||||
"job-tracker-ui\\e2e\\phase7-account-security.spec.ts": "99bfb44568ae506e5505bcf4ff0a26722a7cf3a98c5eaf2d1c3697be172d1330",
|
||||
"job-tracker-ui\\e2e\\phase7-admin-audit.spec.ts": "3737f20446d56bc6e61ba249b2c13316314aa0bfde6e7ff2202a781d3f793d6f",
|
||||
"job-tracker-ui\\e2e\\phase7-admin-system.spec.ts": "1e4dc4a33587adcc8f2f4cc23f83a2d7f47b589d0fa4b81977187d5849dbce37",
|
||||
"job-tracker-ui\\e2e\\phase7-admin-users.spec.ts": "242418271363e218790bee5cfe2f8b2ebdf1b5e437601333bdf7162ca2e966d1",
|
||||
"job-tracker-ui\\e2e\\phase7-application-forms.spec.ts": "f9bcf5d8369a2cc726f09b2e19f2880f02f0eda03dd5b6a978445ddcf0f3d8f9",
|
||||
"job-tracker-ui\\e2e\\phase7-authentication.spec.ts": "62aa7d5941d047a764d06653641e5dc714c5ce5b335b017b385622cc629a7f9f",
|
||||
"job-tracker-ui\\e2e\\phase7-career-editor.spec.ts": "dfc696107e8ad9adf265a5bae885407afd09f1be3b507de8ff03739b39a7f0b1",
|
||||
"job-tracker-ui\\e2e\\phase7-career-import-review.spec.ts": "9d71da36f2a3c6f66034f6a810a3d8ea370eb7b492cb9de1527133a04388196f",
|
||||
"job-tracker-ui\\e2e\\phase7-career-overview.spec.ts": "40370ab8871b6f0d1b52123aecde692a9e1b9bac67d84f56b230334b687336de",
|
||||
"job-tracker-ui\\e2e\\phase7-career-source-ids.spec.ts": "92bc8f1b4c82210207071c56940813dfece3136832ba09ebd1e6c3471d8d7945",
|
||||
"job-tracker-ui\\e2e\\phase7-companies.spec.ts": "44937bc1e0471e3ce76e947b3b462b818701a93ccfc8f746632c640dec4a0f6a",
|
||||
"job-tracker-ui\\e2e\\phase7-correspondence-compose.spec.ts": "5cb7a8f67926eb8abaf87e740eef5192921a52d39d14cbdad556476d4e818f0d",
|
||||
"job-tracker-ui\\e2e\\phase7-correspondence-read.spec.ts": "5bedf8e8d62b2482afe27883fb2b3d77981eaab0f3abfc3f265122b55c19938e",
|
||||
"job-tracker-ui\\e2e\\phase7-cv-library.spec.ts": "7bed3271ebd74ddf0f69f5c2ec6c75a5dc34b360fb593a32650b0d6e4e644896",
|
||||
"job-tracker-ui\\e2e\\phase7-discovery.spec.ts": "8d18edaad34462517eca31cf334dfd894dc0f1ab0a9ffce3440bf9716bd1ab8d",
|
||||
"job-tracker-ui\\e2e\\phase7-documents-contract.spec.ts": "aca359ec044332ed688bd108352c28d6f4c960c8201dd4a7853b6281d341f483",
|
||||
"job-tracker-ui\\e2e\\phase7-documents.spec.ts": "e1d62f8c76b8fd3b80232a27232d2de55b7137ed898f26156b029d794751dd59",
|
||||
"job-tracker-ui\\e2e\\phase7-job-import.spec.ts": "c16a82463eeeeee52430fff4cf71139586225c150db8cee1f1c572c9e17635b2",
|
||||
"job-tracker-ui\\e2e\\phase7-notifications.spec.ts": "e6cd67b489057ea7ce9ff2db27ba6f08e9641e516d97ebac27f1a24ab43fdf86",
|
||||
"job-tracker-ui\\e2e\\phase7-oauth-sender.spec.ts": "ae3600200122855ca7daf2bbf10c26f0eddc5600502b5d697fb6bdfd95b590c1",
|
||||
"job-tracker-ui\\e2e\\phase7-offers.spec.ts": "b4a33fd4cccdc6cb50fc4667f430b82f3dd7fedc3c6f95453b0dde7719febcef",
|
||||
"job-tracker-ui\\e2e\\phase7-onboarding.spec.ts": "73639d3ab25c8c0b9dd3c1d637ab0adf2c9844385a35399376a85f56f9e3b466",
|
||||
"job-tracker-ui\\e2e\\phase7-operations.spec.ts": "9a3ef4c0c6cd484349241b8ce1e378a2e456db152d2ab2cc2669a0b8fea29514",
|
||||
"job-tracker-ui\\e2e\\phase7-public-cv.spec.ts": "f3dfebaf8bae716e3b824d6778c7581690393812d93a3b0b15b9af105252f019",
|
||||
"job-tracker-ui\\e2e\\phase7-reminders.spec.ts": "d44acacdab18749183000630001714f1aa603bddc89f2178437f5cc3e847c371",
|
||||
"job-tracker-ui\\e2e\\phase7-settings-preferences.spec.ts": "6269a229d0caecc5b248490bfd7e1c820220494c278f204cca3085db2096f9cc",
|
||||
"job-tracker-ui\\e2e\\phase8-dashboard-customisation.spec.ts": "a1638b7a1c92db68862dc1e7f0a2adf2cbfeecba5369b9b15e13a1840de5efa2",
|
||||
"job-tracker-ui\\e2e\\redesign-preservation.spec.ts": "997b01c7e2a313e5f590820bad6b630af7eec67e782df7a786ccce6ebd569da0",
|
||||
"job-tracker-ui\\e2e\\responsive-accessibility-audit.spec.ts": "b69420b00c516855b4664807f2ceb174c004f1491a77bd4a2670c7dfee47a026",
|
||||
"job-tracker-ui\\e2e\\rules-draft-repair.spec.ts": "62399159f0c2fc7a748eb1157d0fedda03d2d96f53787273539023f37be8b473",
|
||||
"job-tracker-ui\\e2e\\saved-search-repair.spec.ts": "d18d064da6ebba4ba9096076c061addfdf103c9ed4cc504d07e3263a494d61f8",
|
||||
"job-tracker-ui\\e2e\\semantic-foundations.spec.ts": "72f549ac7595cb3c148fd887e3f27fd14d9b8776ffb6b12bc59b18ac614e581b",
|
||||
"job-tracker-ui\\e2e\\settings-page-layout.spec.ts": "2db9a988be4664539d78250c93cc2ca98b85379b6294e6762b8ffb9213396acd",
|
||||
"job-tracker-ui\\e2e\\shell-readiness.spec.ts": "3cbcda9504535a707f25f9813fdd435218cc5a7605ab665cd753faeac7f9a5c4",
|
||||
"job-tracker-ui\\e2e\\smoke.spec.ts": "5ef62fb4db1dbb86a1128954136297605dd3117ecca2344bb724d7541aabd836",
|
||||
"job-tracker-ui\\e2e\\table-density.spec.ts": "73f2376c87d839c5579907e313e33d65bd87887d6c99bda17c6199589a53b3b5",
|
||||
"job-tracker-ui\\src\\App.css": "c5ac42e56bf8c34eb741d752bc879144f186d7ca0a48fcbb73b967177f7a9240",
|
||||
"job-tracker-ui\\src\\App.test.tsx": "f3b39f696867c1027df473821005be312205ff9a9d6a4183882227efa1190fa9",
|
||||
"job-tracker-ui\\src\\App.tsx": "d82a01fa21d9153bc0a7dbd1be0acbf2182b801eaef570cd2191e0a0440e9138",
|
||||
"job-tracker-ui\\src\\ClientApp.tsx": "dc9f144d1d3614569829a39ac86421d779c37a5ddfe58dcdd7094bfd00a92a89",
|
||||
"job-tracker-ui\\src\\LandingPage.authRedirect.test.tsx": "e687f34f1a0d33b696b168b441ca46689c55a27cdf6240bd5b9ac5ce568413ad",
|
||||
"job-tracker-ui\\src\\ThemeProvider.tsx": "77bbed5fc1cbbba13a3c613a28c4c6116cde3c09a921be1065a7baeb54c83cb3",
|
||||
"job-tracker-ui\\src\\account-data-export.test.tsx": "0494c6d384f18c6444986d912dd9de0cdf2715a9e38e436b19d525c6bd7af293",
|
||||
"job-tracker-ui\\src\\account-providers.test.tsx": "fbb7748829fe01fa33e977a0c77bd8bc5b850a33fb43866a37c81962c090c664",
|
||||
"job-tracker-ui\\src\\account-scoped-state.test.tsx": "498f2ba2078c74b483791e1f02f55c80cf7bd4f8311a3c3177784c99fabeb42c",
|
||||
"job-tracker-ui\\src\\account-security-flows.test.tsx": "8ff99509f50342fbb8ada67ec5f646404ac60773f2d72c84b772aca063c7cc8b",
|
||||
"job-tracker-ui\\src\\account-security-presentation.test.tsx": "12f8fc05eea372c6b1b7d60907f14845a23dcf79b7f44848ad04eb80ae5a0d35",
|
||||
"job-tracker-ui\\src\\accountPlan.tsx": "f3efd0ecfdad4b1a5aff34203290b25b880fa9c57735ad3f2ed771a4e8b1f876",
|
||||
"job-tracker-ui\\src\\admin-audit-page.test.tsx": "45f2a6274f465aa0c2b805d1ef163cee920b806827b2f3199ef7f89d76b1de3f",
|
||||
"job-tracker-ui\\src\\admin-system-page.test.tsx": "0bca15c42206c31e2bdee9f81a362fa1c75045892b410f87688af3927530f216",
|
||||
"job-tracker-ui\\src\\admin-users-page.test.tsx": "a148723fe696cea93dcd4cf5c59cd5f7654d94c34cfdd25f7e30b2915ad8ef1a",
|
||||
"job-tracker-ui\\src\\ai-privacy-settings.test.tsx": "a53eda1c305cc9e3ab16a4f52603a8cb6d881aa58e88a22e112eb33103c5a404",
|
||||
"job-tracker-ui\\src\\ai-section-assistant.test.tsx": "a883f25f2139b3104df84f86330e92684a19ad37be456a6291ed0fd921ef6631",
|
||||
"job-tracker-ui\\src\\ai-usage-card.test.tsx": "922d3c18cce51df0ed0ba89bf1dd383bc8611f2323fbcb51677c3562808a87a9",
|
||||
"job-tracker-ui\\src\\aiWorkspace.ts": "55312d91057b4299833874d331c1ce6bc99b3ee51123e20b488a35181bad5a4d",
|
||||
"job-tracker-ui\\src\\analytics-presentation.test.tsx": "9f5d37200f780f972a80970f886957bb11e2f49e4d7b87b1b6dc05a066b3e049",
|
||||
"job-tracker-ui\\src\\api-error-localisation.test.ts": "7d0ebc8a171c21506e0f941ec2b3e3e93daf1437c1dc67f987e56869c9106d46",
|
||||
"job-tracker-ui\\src\\api.ts": "bec8dc665fef61f3c88938e9a61dda786a16008d6af9bc7688bdd615e7ba337c",
|
||||
"job-tracker-ui\\src\\app-shell-navigation.test.tsx": "b2bd174c967176f246487386a4a7b1f267ef5f390bed3d53d9be3ce9d3fe8abc",
|
||||
"job-tracker-ui\\src\\app-shell-notifications.test.tsx": "82182e0c4efbf888898d5571af8482cdfa42b4f53840cce1eef44e408f754136",
|
||||
"job-tracker-ui\\src\\application-assets.test.tsx": "bcbbd31dfe6327a54c490609bbb859759e1cdb37fa896bbe43f62a155e145965",
|
||||
"job-tracker-ui\\src\\application-calendar.test.tsx": "1c309b19a69121d81e6007f185899c87394f4ddc756b92c05891d9d608a4b1bc",
|
||||
"job-tracker-ui\\src\\application-checklist.test.tsx": "023d5a264ca2cb955c25955d576dfd98142c135eaa7c50190665a879fc081761",
|
||||
"job-tracker-ui\\src\\application-drafts.test.ts": "e78dc7bc7c0ccbf8ca959796a57d0e4fad798a886e31af2e647e469848c7732c",
|
||||
"job-tracker-ui\\src\\application-intelligence.test.tsx": "fb4e35e1e177807dbbdef35a94608e464484ccb3d2be4335436bb62677ae321a",
|
||||
"job-tracker-ui\\src\\application-route-contracts.test.ts": "75602c769a9274a898c48e6238c729a6ab35557c8bb15d470fdb04cf5453289b",
|
||||
"job-tracker-ui\\src\\application-workflow-assist.test.tsx": "d67868035189a11c00d195c0a6f47702744be1b7a0d91b89f8681085e870848d",
|
||||
"job-tracker-ui\\src\\application-workspace-overlay.test.tsx": "d51a6fa0f1884048b5a13e4935184edc36ce50527bec42303fd7f265a9a8fdf5",
|
||||
"job-tracker-ui\\src\\applicationDrafts.ts": "2a5578f58e2ca00d345a04db039d61c5af28c094ef0e976b1106173e137a6027",
|
||||
"job-tracker-ui\\src\\applicationWorkspace.test.ts": "bd068a208d40f782f66f77e6196378f4fa2a5be6f0eacd71e4f6271276e89362",
|
||||
"job-tracker-ui\\src\\applicationWorkspace.ts": "73b5b8c894e1ff053a41cc8c8beb6baf66ca03146abaec5866377602345f6981",
|
||||
"job-tracker-ui\\src\\assets\\JobbjaktMark.tsx": "5a539514876a66eaf17ec68eb7ce3d28c558384bc24ab76b61f73be8df754473",
|
||||
"job-tracker-ui\\src\\attachments.test.tsx": "1519f67d3e3015d4a2709fa5d5bbc7da3b8208214e28cf20096b84192fdcadc5",
|
||||
"job-tracker-ui\\src\\auth.ts": "0282a19bf34a915d6c4cd3bb32c041103206b1741b737e8a5e7b3f083a839acb",
|
||||
"job-tracker-ui\\src\\backup-lifetime.test.tsx": "e67d22d13a5edaedc29d57e3afc36614cdf4844d510d1ecd661d5254d39e2ca3",
|
||||
"job-tracker-ui\\src\\captureUrl.test.ts": "abf0a68b3727e7f78a8de2bee8ad28c2a6187a2a4305ad2e092351183e63ea7b",
|
||||
"job-tracker-ui\\src\\captureUrl.ts": "108d8260c90d97296f67f9bda87ff6e3c5ddb60d8641a57223eb15bcf4c44f6d",
|
||||
"job-tracker-ui\\src\\career-editor.test.tsx": "65ca2f09815b1cedfe693e772839655717e705d2c88795344c3a114eb21b1b01",
|
||||
"job-tracker-ui\\src\\career-evidence.test.tsx": "511341eec5e6dcdad7d0f9f63e959b7fb8ac7fdf43aa2dfcfa32fcff33540985",
|
||||
"job-tracker-ui\\src\\career-history-view.test.tsx": "dfed6b3ccce38aba1492bc39bd94b1427ed307ea3ef1221f34095d8441e2cb59",
|
||||
"job-tracker-ui\\src\\career-import-review.test.tsx": "2ec396e29bacccacc7f9cac675894625474c99d68be33d88eaa15c93fb4ee43c",
|
||||
"job-tracker-ui\\src\\career-workspace-page.test.tsx": "628512985a14e71d0d34bf1e3e0e77d948ad7d5c3eb608de482051f2aedf3052",
|
||||
"job-tracker-ui\\src\\companies-relationship.test.tsx": "0471d3def86afbabdd683ab98d99fe07fe59f1bdc23767b916e087716117202f",
|
||||
"job-tracker-ui\\src\\components\\AddJobModal.tsx": "a572ab197526a7b8263ba369b7230db7781176f3e820d255e89d7f71bab94c63",
|
||||
"job-tracker-ui\\src\\components\\AiPrivacySettingsCard.tsx": "7534422effa4b8720e27ee34e3ff1c9fb5c74e6d5d6db3fe3810028aff7d1e7e",
|
||||
"job-tracker-ui\\src\\components\\AiUsageCard.tsx": "0592eea7ea9c43f72a9685e03a2e9911f96dc760e8d60ba17ac754956f9d700e",
|
||||
"job-tracker-ui\\src\\components\\ApplicationAssets.tsx": "453476dca53b2bbb898d082fdc6088426924774e21fb4114e118968d26573391",
|
||||
"job-tracker-ui\\src\\components\\ApplicationCalendarActions.tsx": "fd79d4bdc3fecd1431989e5c0f2e4e6ee6c7ae3d300f8162ca3082d67c67b0a1",
|
||||
"job-tracker-ui\\src\\components\\ApplicationChecklist.tsx": "2c58d440669a76ec1882326e1039ca604f5bf7e38fd71ac6800d22e260a1fcc2",
|
||||
"job-tracker-ui\\src\\components\\ApplicationIntelligence.tsx": "335bb76ae09ef5c119be360024d5bb99276e131f80ce02bd07bd813ade423db5",
|
||||
"job-tracker-ui\\src\\components\\ApplicationWorkflowAssist.tsx": "7a9cbb1f38879bb9e9156d19675e9db40c466d06008cc5495dfd99b6b4dc088b",
|
||||
"job-tracker-ui\\src\\components\\Attachment.tsx": "caa72cbe9038a60e19e94f2c6793c134a1bdefd33644940655532472bd66e962",
|
||||
"job-tracker-ui\\src\\components\\Attachments.tsx": "86f6c3d174d07e0d73f69af2bbde28124f8aae3625d32788d9f962d6a26e304b",
|
||||
"job-tracker-ui\\src\\components\\AuthStatusCard.tsx": "cf33dd501dc3d4b5db925d8d3eb445c86fb98dfbe65043b0af2ac2848fdefa9f",
|
||||
"job-tracker-ui\\src\\components\\BackupCard.tsx": "b356961a48d35a4631f145f3b34cf7997bba20f4a1d2bb25508b2305e7e7b111",
|
||||
"job-tracker-ui\\src\\components\\CompaniesTable.tsx": "fbff1c14f98472d5f6b208bd669dd0fb947807fc8dec1096b9850927f2a38c0c",
|
||||
"job-tracker-ui\\src\\components\\Correspondence.tsx": "f42e7e549c9060f9d4f84111fc5d430eac353832c9cd6f6a56fa14b20c013251",
|
||||
"job-tracker-ui\\src\\components\\CropImageDialog.tsx": "9c13b8f9d45fa3cf1875a8630f6694d0f9c31e58f8d4a7d091694b4745fd241a",
|
||||
"job-tracker-ui\\src\\components\\CvTemplateGallery.tsx": "51d082fe381a1ad7dcd17ddc374c7252e7ed1b5ac807174da8e5ac6a65acd239",
|
||||
"job-tracker-ui\\src\\components\\CvTemplateThumbnail.tsx": "2aa5fd8da00d1002c331e3bf476c7047b2431ccafadc6f5ab96ce6921aa78b1b",
|
||||
"job-tracker-ui\\src\\components\\DashboardLayoutEditor.tsx": "c712616e190fa5335c685548aaedaedba89701b9a47cfaf4d67d5f5b3c827130",
|
||||
"job-tracker-ui\\src\\components\\DashboardView.tsx": "44aba2e2b78da5b43f5a2ee6b308220c6e98e39b57371d1ef7074ddfa2a2c67c",
|
||||
"job-tracker-ui\\src\\components\\EditJobDialog.tsx": "d2bc438b66b3280dc7cd40909b578032407f7735b3e25957e2ea532056a0000b",
|
||||
"job-tracker-ui\\src\\components\\EmailProviderConnections.tsx": "1009d1d5ee514b67dcb66738d1a4fad256b2dee049f88d4c748766b7360357b2",
|
||||
"job-tracker-ui\\src\\components\\ErrorBoundary.tsx": "4dc26f26f60c349b2a13f63a6f06e685fb625dcf9755b7094761ffb175d7c4ca",
|
||||
"job-tracker-ui\\src\\components\\GoogleAuthCard.tsx": "5dc802fa3eb3618534d90c625a365d2a361c3ce1ab1b3877923b86f84efc4ab3",
|
||||
"job-tracker-ui\\src\\components\\GradientButton.tsx": "6b8725e284ac02127edbf93ee92c276918833f98d0a7a1cfd44cf7c61c59e8bc",
|
||||
"job-tracker-ui\\src\\components\\ImportExportJobs.tsx": "037c58c30a0fa0f7a3082613ec751872f6f0ff86c8720b1a108dc711dd3c02fe",
|
||||
"job-tracker-ui\\src\\components\\InterviewPrep.tsx": "454c97a2cad3a4045fbc301c5da717db79fa41b53a231e9f3f3cc437eb5881d1",
|
||||
"job-tracker-ui\\src\\components\\JobDetailsPanels.tsx": "d13b05a2c531df2722fdc3fbcab7c78dedee75581ac8fda994ec677cf643ea08",
|
||||
"job-tracker-ui\\src\\components\\JobFlowBar.tsx": "643053f6ac883f23ef74c66def7a42678cd7917ed11ca802b11be25ec6df0cb2",
|
||||
"job-tracker-ui\\src\\components\\JobTable.tsx": "055e3be009a816e19d2b66db4db8aeec801bb23a2d24b73475c3c895028bd8ed",
|
||||
"job-tracker-ui\\src\\components\\KanbanBoard.tsx": "21f518c4b91d5c37b73e2f544aebcc3a4a5d2b151384dc25eacbbb27b2933a4f",
|
||||
"job-tracker-ui\\src\\components\\Markdown.tsx": "d3288d410d3757d195180521b898af804fdb37634a6eaf8a380995452251dd3d",
|
||||
"job-tracker-ui\\src\\components\\MicrosoftAuthCard.tsx": "fcb84c79afca5a2c585f73336f2ce74f33f0059a9bcd257e1b452f8d4f67dcc4",
|
||||
"job-tracker-ui\\src\\components\\NotificationsPopover.tsx": "68de79aeafac5dba9e1688678ef92c3a8a77b03dfaf72cc51a4019762641d20e",
|
||||
"job-tracker-ui\\src\\components\\OnboardingChecklist.tsx": "b661e362a5d44ab48d5df5715d697bf2c8be54136429d4aecd0b43c98cd8e15b",
|
||||
"job-tracker-ui\\src\\components\\ProFeatureNotice.tsx": "fd1bbe72adf90893b12115ff6a693b7fc721b5550cd1be4917ad741046a6fea8",
|
||||
"job-tracker-ui\\src\\components\\QuickCaptureCard.tsx": "0b0da2d773a7323a3341f082a803e6cd3cef8079d7c2101100b706b64c305bbb",
|
||||
"job-tracker-ui\\src\\components\\QuickCommandDialog.tsx": "27cbbb8b8596fdf4e7474eccfdb6d9a8a219f4baff6a9b8013b825ce5fc2c411",
|
||||
"job-tracker-ui\\src\\components\\RemindersView.tsx": "1eedbc443761001e86be1f322691ab4058553033e764b0c6cf9c93ce4fab05c0",
|
||||
"job-tracker-ui\\src\\components\\RichTextField.tsx": "dcd2ae783722f870c2d2170015e0a2e35ac6d05421bab5dda7b59aab12c6f85a",
|
||||
"job-tracker-ui\\src\\components\\RulesSettingsCard.tsx": "15cb36d8ef43a0b74b263bf2adc95f8982761b3cb92f44df3b9caf67c309a0c9",
|
||||
"job-tracker-ui\\src\\components\\SavedViewsMenu.tsx": "333466a7d2e64ed464bcdd70d8e662db3014dabb9c6b7e9c6f8d0d3306f93b9e",
|
||||
"job-tracker-ui\\src\\components\\SessionsSettingsCard.tsx": "b3568ca9f71cfb623617f00c584e8851a813e7c72faf85dc852d0fa65668038e",
|
||||
"job-tracker-ui\\src\\components\\SettingsView.tsx": "dbd611dd58b18bd9b48683f08a6d4af973374107d678c93fa148925c94037ed7",
|
||||
"job-tracker-ui\\src\\components\\TagsInput.tsx": "2e72e75fd7c6e0d0aac84a7b9e1450a881d0efb71dd06ed990afd047537a629c",
|
||||
"job-tracker-ui\\src\\components\\TurnstileWidget.tsx": "86fbfd0e52d13f467d6ab84ed352dc95cdd97ab9e46c4e5add11b113e655389f",
|
||||
"job-tracker-ui\\src\\components\\TwoFactorChallenge.tsx": "1a9505889651f4dd4764fb04cbd3f31722d3de69d7dbdc159b55dff545ec5d9e",
|
||||
"job-tracker-ui\\src\\components\\TwoFactorSettingsCard.tsx": "4977001fd64d50666668b2324cfc3712b6545406241d01c79087c9443da78568",
|
||||
"job-tracker-ui\\src\\components\\UserManagementCard.tsx": "0e5b59f95ea8cf49b30cd3f2d4d78fef0e678bc261ad3514748aac53a686eeb2",
|
||||
"job-tracker-ui\\src\\components\\ViewStateNotice.tsx": "fd4bb314346a1603d9e01a15fd02f01576b9fd959e83f8ff0b1dfe5ec94378e9",
|
||||
"job-tracker-ui\\src\\components\\cv\\AiSectionAssistant.tsx": "76eaf48df27b922699805398f2ab80f0fe27886dd796843295d0f7b1a0b2d7e1",
|
||||
"job-tracker-ui\\src\\components\\cv\\CvCustomizeTab.tsx": "be5463aca9ecf68b7c38daaccf5c661882bc174a66cae5fe0368ca228d48ac1e",
|
||||
"job-tracker-ui\\src\\components\\job-workspace\\useJobWorkspaceBaseData.ts": "fe6b0c0f1f0edc6db64fd7c57b66901d37fc1f75f880c42d441e38a387c82630",
|
||||
"job-tracker-ui\\src\\components\\job-workspace\\useWorkspaceTabCache.ts": "07fc1ecda997d57c265030d05d0f917f2687c8d44b0e7343b1de83e50f70408d",
|
||||
"job-tracker-ui\\src\\confirm-flow.test.tsx": "79e61f0d98f81d273e48f89e2bdd4c669d0bf6ec15c7859b19fe354c3e19a3a3",
|
||||
"job-tracker-ui\\src\\confirm.test.tsx": "e3f60e8253504865509cdbda62ffc82b65c17c4c87a1866b37cc7654c8150161",
|
||||
"job-tracker-ui\\src\\confirm.tsx": "d52663d7a6f5822bb4dc8735260e21c0ee3f99649fc741e7239e1831cad5888e",
|
||||
"job-tracker-ui\\src\\correspondence-gmail-import.test.tsx": "eb1a3172c528df0f9876785e4ef4ae25220227c0801d247f76a61bd779c54653",
|
||||
"job-tracker-ui\\src\\correspondence-inbox-page.test.tsx": "6b0bb3714de97b208ae74838a3c7938f0b13b6e226d7e743698f4ae63831632f",
|
||||
"job-tracker-ui\\src\\correspondence-search-context.test.ts": "91dfb843add49d1d68f083f22c5106dffa04b1322decb88f79de2ca140ac23ad",
|
||||
"job-tracker-ui\\src\\csv-export.test.tsx": "a12080de3f1c571fa27874d72ffcfa2212aa4a4f4b7cb6abab277b4ce8d0a1e2",
|
||||
"job-tracker-ui\\src\\cv-builder-deep-link.test.tsx": "7fa1a68280cf3fc47f43848eb5002e89afe9f5a952056b925d243ed9c15ebbd8",
|
||||
"job-tracker-ui\\src\\cv-builder-page.test.tsx": "ef0b63ba95633cca81f1b391113a4ddbb27844574d4230c36e5f7de2e0218834",
|
||||
"job-tracker-ui\\src\\cv-document-controls.test.tsx": "8dd279a6328c2fc57b0c35b3179ee53b8b6a90c819d31d981ae535fdc67ad284",
|
||||
"job-tracker-ui\\src\\cv-template-gallery.test.tsx": "379baeb733fb9da8e7b3cc3ae6b89f745178e67be61ba2184055fb1632aa9402",
|
||||
"job-tracker-ui\\src\\cv-template-thumbnail.test.tsx": "9eacec2285edb753ae337c3b06adde157ce95114b438ecee45e5cdd66f1f27fb",
|
||||
"job-tracker-ui\\src\\cvBuilder.test.ts": "61c17e64ac6f1a1bb5fe99f66426d9eee9b5f65853fded451462470ab0732dd5",
|
||||
"job-tracker-ui\\src\\cvBuilder.ts": "efff2eb126377ad6bc84a73d90c8a062be35789d1655ceba58aba359b2d82c36",
|
||||
"job-tracker-ui\\src\\cvPreview.ts": "38d0aadb4a080c72a8c4f04b6972b4252593c5e2d07af95a421abc47dcacb88d",
|
||||
"job-tracker-ui\\src\\daily-control-loop.test.tsx": "7e0c4b1b1ad90d510c1f163efcb5f7c8be1407fc41efac18a6712c70e52b7eac",
|
||||
"job-tracker-ui\\src\\dashboard-customisation.test.tsx": "ba339988e780e769501b25785f237634e6e23a51237ce68f2ef8a801db263013",
|
||||
"job-tracker-ui\\src\\dashboard-presentation.test.tsx": "d9451f86dc77b2689afac345ec752ad9946c0341777e9959df316abb53367286",
|
||||
"job-tracker-ui\\src\\dashboardLayout.test.ts": "db9bfa701f9626c39316add3e3137390de3ae5f8962ef8aca6c757e92421112a",
|
||||
"job-tracker-ui\\src\\dashboardLayout.ts": "8c9b99e227097a1d8371785938dc31e1cf88544aa5a5ea37b4c7b116d9f1b06d",
|
||||
"job-tracker-ui\\src\\dialog-flow.test.tsx": "ca84737b9946a356be59b2de83077fcf9134535c1531d6db18775bb2065ff8dd",
|
||||
"job-tracker-ui\\src\\dialogs.ts": "c817739a302e765709b747a4fed7cb28f229920b6720f4096000a997c02bd200",
|
||||
"job-tracker-ui\\src\\documents-page.test.tsx": "ef5ce47720327112a239c699b36117da7990a91a1903830299e7631be71bde9a",
|
||||
"job-tracker-ui\\src\\email-provider-connections.test.tsx": "764315a88dd7becbcda26b647c5258915f8e380f0ff2f9c031f7fb322b339b87",
|
||||
"job-tracker-ui\\src\\emailOAuth.ts": "dfd0f0ac46d295a2c5a0b9a6bb6bfffcceef6b6871cec7c92c429418cdbce8a9",
|
||||
"job-tracker-ui\\src\\end-to-end-trust-loop.test.tsx": "facd14c80ed6e2e819727d666b53fceee738e7d78d0a29acc8a962f89cefa809",
|
||||
"job-tracker-ui\\src\\file-picker-activation.test.tsx": "accb87f94a7b36373eec6583a391a17ede7e7779cf18d81d4a86b43e0bd6afbe",
|
||||
"job-tracker-ui\\src\\gmail-review-page.test.tsx": "e67ca4f4f5bbd38043c4420159124dd98cad9789ebbfc380ebc1c2772a46b2a9",
|
||||
"job-tracker-ui\\src\\hooks\\useCompanies.ts": "4ff9d86615439caad2fae10cc5c64267fdc8084ecb3ccc2c359821609622aaaa",
|
||||
"job-tracker-ui\\src\\hooks\\useDashboardLayout.test.tsx": "e3dca373dd3558a5635e38e5ada3201e7eceda9023c43af7a2c1fa4053c3f3a4",
|
||||
"job-tracker-ui\\src\\hooks\\useDashboardLayout.ts": "09810a81952a63562bad5be778a982596696d4ffd022c528825e2bd4a3c4468c",
|
||||
"job-tracker-ui\\src\\hooks\\useDebouncedValue.ts": "b704d1c40387dd4d668dab0abb59927ba86a377095a6cba9583c3493512b3f25",
|
||||
"job-tracker-ui\\src\\hooks\\useDragReorder.ts": "367e064052df27171f34ed638f1542319fa223f3ecf419000811c92e809ccb5a",
|
||||
"job-tracker-ui\\src\\hooks\\useViewResource.ts": "8c73a2fd845b8bc4c4c10b7c69f8d12dae8e83c434c537558a78b74377511b27",
|
||||
"job-tracker-ui\\src\\i18n-provider.test.tsx": "6bf5e80e721383290aef3b73ddaa31026d562596cc05d31712a7f1aef23ae824",
|
||||
"job-tracker-ui\\src\\i18n\\I18nProvider.tsx": "d22b5d033d0e405ffb5d581c752d61a24056d1e496f5f6a2f1baecef500f2113",
|
||||
"job-tracker-ui\\src\\i18n\\translations.ts": "fd4b5fab56ed4612f3ee5c32546ed2eaa0daee401898f58eeddd5f6d27ff2772",
|
||||
"job-tracker-ui\\src\\index.css": "daf22c296c801d3d533083361cc59fbdc22e5bfe528aa4bad1973b54cc5448a4",
|
||||
"job-tracker-ui\\src\\interview-prep.test.tsx": "16d91a606f361e158a75fbaf013e3e473ccc864abaea00f00dbf3f7b3cf97650",
|
||||
"job-tracker-ui\\src\\job-discovery.test.tsx": "2a23fe6c082bbc7dc0fb3ba17a8b39b400ea6ce8606b899a80c778a972fb5e87",
|
||||
"job-tracker-ui\\src\\job-import.test.tsx": "9c69000a6f8ca744fb425c2007ce48247be46ecad01a177c2f002ba2ac9a0d73",
|
||||
"job-tracker-ui\\src\\job-table-density.test.tsx": "53780a07f47415b2b242e386356b11e7c7ccce1bc0e7b54e8d31515a3f6f472d",
|
||||
"job-tracker-ui\\src\\jobWorkflowSignals.ts": "98e4d5ec4d898e19383ce113a1ad51ce4696458b7e25a0785e28e4f38938c6d7",
|
||||
"job-tracker-ui\\src\\jobWorkspaceRoute.ts": "ac1a8e850732942cbf32bc6aa3035b561da4cf285879c5ee4621e4fe9ad243d5",
|
||||
"job-tracker-ui\\src\\jobs-view-integration.test.tsx": "ad7a5c84dcb7cda29f3d276201de794379f0552827116f88339dd172d47961c9",
|
||||
"job-tracker-ui\\src\\jobsQuery.ts": "ef2b8e804162f9d059cc087f7f57b2c4ccb5760b59e2d3d586e753871b6a0490",
|
||||
"job-tracker-ui\\src\\kanban-grouped-board.test.tsx": "e9641bf43046e9ae75e8dc7083ab089c7e60d36286872e44d2b86b16e6cd31be",
|
||||
"job-tracker-ui\\src\\landing-page.test.tsx": "88b990761d109eefb0041ae2975f32e0ca58e3f58162ed0f99603302bb570961",
|
||||
"job-tracker-ui\\src\\layout\\AppShell.tsx": "e75a58f2984bf47d7edc3c2c51439242aba2df32675a30b1f17088095ea1d634",
|
||||
"job-tracker-ui\\src\\layout\\AuthPageFrame.tsx": "cdd4b0f238d1c5dffdb13fd0f0dd0ea08f89238ccd83858e10eed58a0a8911a7",
|
||||
"job-tracker-ui\\src\\layout\\PageLayout.tsx": "9d7d8b3df0f533cc608c8b44519e12f1c9a8547fe91401c45023c6d2c34e111d",
|
||||
"job-tracker-ui\\src\\loaded-theme-states.test.tsx": "a54e28943c2219842fdb5066b95490c4663568b2adbfcb02cb1984c62e9fe6a3",
|
||||
"job-tracker-ui\\src\\locale-fetch-stability.test.tsx": "e647fcd387c54ffd50f45f1f8ae3f4133a2858fd0594ec9c0df51ce328a95814",
|
||||
"job-tracker-ui\\src\\login-page.test.tsx": "f0f50a39ce7428241dd306e602c12b0cb8cf4b06dd81d0412bf01ac7ac32f439",
|
||||
"job-tracker-ui\\src\\logo.svg": "6000b0e9b0b05b3f112de04f0d039768a1db63588ff9b6ef7099dbd71632f383",
|
||||
"job-tracker-ui\\src\\microsoft-legacy-relink-page.test.tsx": "ec89420a80db1e93ff622fa259b9ca71cb503706ce15b79d6f592b3fbb4fb3c3",
|
||||
"job-tracker-ui\\src\\modal-state.test.tsx": "6f7f32cde109bad6df5d3fae40566a692aecb4739f97fa9d302b41a603b64258",
|
||||
"job-tracker-ui\\src\\mui-theme.d.ts": "b9e96b260cec865afbdef2669a2d7741d772a282c7569dacb01d6cf23ac37bb4",
|
||||
"job-tracker-ui\\src\\named-controls.test.tsx": "c8b519701685c434a729a50aeb5e0dfafbaf8a166fb223ab400b28554c5c445f",
|
||||
"job-tracker-ui\\src\\nginx-config.test.ts": "195fcc1d96f4e886c569bcb4affb941d788c9f0d175a4bb3d656f4213ae8a2f6",
|
||||
"job-tracker-ui\\src\\not-found-page.test.tsx": "24b2c53441f13dd6d9b9faeb36033ba625d14be101096cfa44b8fc4502f2628d",
|
||||
"job-tracker-ui\\src\\notifications-popover.test.tsx": "c654a00f312a98c0a74cbe2058f9550e84621731cb018287bca2e9888ed471c7",
|
||||
"job-tracker-ui\\src\\notifications.ts": "3a688a7ef6c394c4a810b6b0c2fc4d20be61ec8e7737b3b3a2c30ac54c882a22",
|
||||
"job-tracker-ui\\src\\offer-comparison.test.tsx": "68fbd25b22101c464e5aaeddfbb44f298c2359dea9e7f7404bb515cabdbef3ad",
|
||||
"job-tracker-ui\\src\\onboarding-checklist.test.tsx": "b19d3b4ba3f033e839fb49c008f798c90e1c195aecab5542ba6d3dc5f64e22ef",
|
||||
"job-tracker-ui\\src\\operations-page.test.tsx": "eb4292aff41866206c97d39caf84d4247da4bd8aafba7c1c88b63c5cd717e22c",
|
||||
"job-tracker-ui\\src\\page-layout.test.tsx": "a224a684f69c59043509c3cd7120783b76494ca50520110b9dee01ef89f92807",
|
||||
"job-tracker-ui\\src\\pipeline.test.ts": "9f49b69ab2039abaa71629ba24f300273f8ff8c70ef0786b998ee6c547f59bdd",
|
||||
"job-tracker-ui\\src\\pipeline.ts": "fa755ced07a90cef79ceff1622049adc7ed7df3794c942512d9675ef04af8843",
|
||||
"job-tracker-ui\\src\\planCatalog.test.ts": "680e0c6c3cd5e760ffd752668284400857f7abb5e17fbc72b106e606ffbb6c00",
|
||||
"job-tracker-ui\\src\\planCatalog.ts": "14eb6a27455289e7231cc1a67f2c8acd7fe89d038d8ac93ce1a774f2bbec9f12",
|
||||
"job-tracker-ui\\src\\pro-feature-notice.test.tsx": "3b59ee9f52853a0bb6213cd9c886bbc638d1b689a89c27e77bac60aeede6833b",
|
||||
"job-tracker-ui\\src\\profile-page.test.tsx": "2fdff01be0956e2dc232ca6381d82c129b2a3522c30c6b14441d893fc899dc7f",
|
||||
"job-tracker-ui\\src\\profileCv.test.ts": "c1c482bed380429489f1757852811e0d1e7f48b71af2bd7899693d2e8475ac9d",
|
||||
"job-tracker-ui\\src\\profileCv.ts": "190b3e15ab00741f1e6bea868a79c3ca221e059936406098b693d13f3ef3f1d7",
|
||||
"job-tracker-ui\\src\\prompt.test.tsx": "dc499a145812564d2dd67d708052e5fe46e0bb77e33a098d1599cf033c97de95",
|
||||
"job-tracker-ui\\src\\prompt.tsx": "eb42e7f400d65090301d681b22965dd30cc3701c7378ef28e7e8fa08cab98457",
|
||||
"job-tracker-ui\\src\\public-auth-presentation.test.tsx": "2f40091b13a252a45f4bd2c86da542b5f4648e08c4fffa070946de7f5c4a9811",
|
||||
"job-tracker-ui\\src\\public-cv-page.test.tsx": "34eea91b2d9c3222ab8c79bc9506361fb136ad81e578d73809e4afe19808b756",
|
||||
"job-tracker-ui\\src\\quick-capture.test.tsx": "070aad6a939f314f8e4bd5d24bd4d78882b5f609ae7fb939a02fddf23f1c52fd",
|
||||
"job-tracker-ui\\src\\quick-command-preservation.test.tsx": "86ef134be10b85bddd980d09672a07cb4ef43807ac740e9283364d784d709bca",
|
||||
"job-tracker-ui\\src\\reminders-presentation.test.tsx": "0ad289228a60bf715cc6d8c44f7c50f2b7366def96bf6d7bd43474d5bf738d2d",
|
||||
"job-tracker-ui\\src\\salary.ts": "931f29ef51c4812ad60252ef4ec61b3928a39c38a92dec050b3006561db7e7ed",
|
||||
"job-tracker-ui\\src\\session-boundary.test.ts": "3c78849fc0966bcfeac81126b24d01adc592f66e51dc6a087ebd27bf3ab1b10e",
|
||||
"job-tracker-ui\\src\\sessionBoundary.ts": "09edca77c98294818d2ad0c409ce595f1b6ca0c1f60419fc1078b70f96f5b7b9",
|
||||
"job-tracker-ui\\src\\settings-import-lifetime.test.tsx": "24af2a5954533087ddbff40659ff9f8dcf70805435dca772314c2be7d8bf6133",
|
||||
"job-tracker-ui\\src\\settings-view.test.tsx": "1b5dfa2e75554ea53c462ec21c041f9761884390aace42d4f3a02a218b90db17",
|
||||
"job-tracker-ui\\src\\setupTests.ts": "659f6e3e98dd475bcec35cfc8c24f5685eed2b2b5de8cbd3fa06dbea8ca0d459",
|
||||
"job-tracker-ui\\src\\shell-readiness.test.tsx": "7947c326ae1e97dc72a67c776df681824c36943388db50b10f1479bc87d05868",
|
||||
"job-tracker-ui\\src\\sidebar-active-nav.test.ts": "300c19db2c9eac9e680ed9edd9e99aef4ceeb234814ac4fe7245a07b44fa36ce",
|
||||
"job-tracker-ui\\src\\tailoredCvDraft.ts": "568cecba158504787c4710675e77276b719df64786dbce6da6d4daa38d9aa7bc",
|
||||
"job-tracker-ui\\src\\theme-contrast.test.ts": "f17b594bb9fd64d9fcf0c8f84bc19551052d6ba2eae9d2c2c031376f35a6be66",
|
||||
"job-tracker-ui\\src\\theme-state.test.tsx": "c63f66982db6cadc973dae4ce3339c32eaea8c8429048a8588528f69f0fffe88",
|
||||
"job-tracker-ui\\src\\theme.ts": "bcfc98ee692fda1d03ed23b952d2b2de35b16a45388bc43ad9885b6882df0302",
|
||||
"job-tracker-ui\\src\\themeBootstrap.ts": "d5e1667c2224f0e6f05ea92083a9705e9572c00f64930cde99a07234596593f5",
|
||||
"job-tracker-ui\\src\\themePrefs.ts": "2c9edd17b6b17f112ef3a786a607ff6ffd5bdf5c3e2e3dda5e83d4fa5d1ab51b",
|
||||
"job-tracker-ui\\src\\toast.tsx": "83ff8de595c459b9fc403ff1ff9cd91bbdfaad9b85df197418aedbb9b26f5770",
|
||||
"job-tracker-ui\\src\\turnstile-widget.test.tsx": "1afed75d79a9e771936dcfd30738d3c5fc4c7f61119940c4594c332b05a94d69",
|
||||
"job-tracker-ui\\src\\two-factor-settings.test.tsx": "646c0ff92371650a563ad7f3f4698655f254aae57f3a36552da5d7dead98502d",
|
||||
"job-tracker-ui\\src\\types.ts": "a45779b0b7868c4a69ed4a17316899074416fc8c6f8f6c083827e949f790d7b1",
|
||||
"job-tracker-ui\\src\\use-drag-reorder.test.tsx": "7c6717759b4afbfd28737e6c5e4cbe74bb21825650262dbf70f17de3e47b0716",
|
||||
"job-tracker-ui\\src\\use-view-resource.test.tsx": "e7509ee8a95e3174f4b0b1cc002c365f605c8b1aa6e791fe89cc559db4396541",
|
||||
"job-tracker-ui\\src\\verify-email-page.test.tsx": "e3a1543725febe01d5a08ada70223e9255a8b22821de2f3270f576ba4535890b",
|
||||
"job-tracker-ui\\src\\view-state-announcements.test.tsx": "680dddfb12de6b06c566c4e1c476758ce3a43f1365c9a947270ac96af720b24d",
|
||||
"job-tracker-ui\\src\\views\\AdminAuditPage.tsx": "1c7011fa935efe2e532f6ab8f917005e486aa6fb525ad3a28aa1f67f28e461c4",
|
||||
"job-tracker-ui\\src\\views\\AdminSystemPage.tsx": "8459906c2b2c98d5302d3f5ada287374fa0edbbd19e04afed92f22196143640b",
|
||||
"job-tracker-ui\\src\\views\\AdminUsersPage.tsx": "c015f87184fb9ab7e66303d9c293214d5fe249013954030ca64da48d657a8bd2",
|
||||
"job-tracker-ui\\src\\views\\AnalyticsPage.tsx": "40100ef5cf7765221897a4bd0ceb18a964423e122965bee21d278d113fdb01dd",
|
||||
"job-tracker-ui\\src\\views\\ApplicationWorkspacePage.tsx": "7a0fb891f2632b35a8267ca0143a68522334f20bd2b2eda57b3ed2661e357934",
|
||||
"job-tracker-ui\\src\\views\\CareerProfilePage.tsx": "03d4b7d4bc9aa55d597e70980f99449e4da0a5ab8c1f4a06aff6bca79d45abfd",
|
||||
"job-tracker-ui\\src\\views\\CareerWorkspacePage.tsx": "a30ba8d9a8ea8dff40f71709b0cc8fa0b62a7080339461cd802c87d8d3ab0b05",
|
||||
"job-tracker-ui\\src\\views\\ConnectedAccountsPage.tsx": "1deb20b05c221c0749537f8cb02a4be26959e72f26bc447e838704be625ac3b6",
|
||||
"job-tracker-ui\\src\\views\\CorrespondenceInboxPage.tsx": "2d3a44b6e7ef665503a58506413fc9b67b6da797d31db97ef88d6c0d4a196a1e",
|
||||
"job-tracker-ui\\src\\views\\CvBuilderEditor.tsx": "2ce2ff220e65be23ae46ec68327380733ee4157884db6e6a10450840692d2942",
|
||||
"job-tracker-ui\\src\\views\\CvBuilderPage.tsx": "61feb76fa6bc425497cd05aefb1f7630106b1a9bdc2b44e744dce2bb242da831",
|
||||
"job-tracker-ui\\src\\views\\DocumentsPage.tsx": "a6158c4df802264df21a15747d235df57b7424222f6fd5fc59f4dc15b298aacf",
|
||||
"job-tracker-ui\\src\\views\\ForgotPasswordPage.tsx": "05e291f6f12dfaee611a0370a2dbb9a60e40c75f916834a3ae03ffd848297ab1",
|
||||
"job-tracker-ui\\src\\views\\GmailReviewPage.tsx": "b3674c1d5e0776a86af6996eecfe7ef9f707fe5e9d7f9ac94891ea93d3bd5d9e",
|
||||
"job-tracker-ui\\src\\views\\JobDiscoveryPage.tsx": "e15f5183fe6d41ecb7a53ec9806872a67f0d6ce683354d0abef53527bd397b94",
|
||||
"job-tracker-ui\\src\\views\\LandingPage.tsx": "1ca2c4c3c9e74839af1a38a1c549f6e49a284be868a3a30611ea9eb6b4b5acbe",
|
||||
"job-tracker-ui\\src\\views\\LoginPage.tsx": "8ed5366378500cd09fea41a7730ff2fd641412026246dae268fb091e687057d8",
|
||||
"job-tracker-ui\\src\\views\\MicrosoftLegacyRelinkPage.tsx": "a053aabccc08797bf19a39b41eba960fdea863ef82816365ad90999b678c2879",
|
||||
"job-tracker-ui\\src\\views\\NotFoundPage.tsx": "7564f69301e84de2b8c88e2099d4667b7d438e459ac5f02009a090b36118207a",
|
||||
"job-tracker-ui\\src\\views\\OfferComparisonPage.tsx": "e3c268cfb7427dac1056a4d36e4a091a68c9970daae8180cc493520c62dcd86c",
|
||||
"job-tracker-ui\\src\\views\\OperationsPage.tsx": "8cff1e08b2009ff683957849af71d6e76916a28ae218e647d9e3c04a12ad5f9b",
|
||||
"job-tracker-ui\\src\\views\\ProfilePage.tsx": "2b521c464331c10f0680b2b75c5e6065089cfcf6a40e5db3392c7a25b61b916f",
|
||||
"job-tracker-ui\\src\\views\\PublicCvPage.tsx": "ab48f5a8f59e9232ca0238c6c729449e18e83237cda34971d256a92e88a12e26",
|
||||
"job-tracker-ui\\src\\views\\ResetPasswordPage.tsx": "184674006da849322a49d64262569837d47667ef83b72056c9bf1394097dc2a7",
|
||||
"job-tracker-ui\\src\\views\\RouteErrorPage.tsx": "5c305728bdf18458cb16f481b68e3bf07fe76b66110aaf19c551ef5d7851578f",
|
||||
"job-tracker-ui\\src\\views\\VerifyEmailPage.tsx": "63b7d8ea8c0537ea093850306c7a09a17d49a585377deba99d0597384850c30a",
|
||||
"job-tracker-ui\\src\\views\\career\\CareerEvidenceBank.tsx": "fc85ccfc1addd48af3a29bc263b089858765b2ac3ec7db3b66dedc55ddfab625",
|
||||
"job-tracker-ui\\src\\views\\career\\CareerProfileSections.tsx": "0c772feb6bde8324676b0884aaf343283465c5cd3b3318128363063df4ea22ab",
|
||||
"job-tracker-ui\\src\\views\\career\\CareerWorkspaceOverview.tsx": "e7ff8bdd8e541cf972f448298cc22e435a741e60a85222a85aafc12cf6536f5e",
|
||||
"job-tracker-ui\\src\\views\\career\\ProfileCompleteness.tsx": "7a6aeb6cfdd790ec67177620711711319642d9e283fb00902b343cf4fa8a1886",
|
||||
"job-tracker-ui\\src\\views\\career\\careerCompletenessLabels.ts": "61386b97a184b8fb8e75c5e4c122950fe3fbc7e98420adc1f8107c510373c7c8",
|
||||
"job-tracker-ui\\src\\workflow-trust-signals.test.tsx": "dd4384e91cdaeac62339aa79f1ea37631801432f050d55dfb1dc508fc1581712",
|
||||
"tools\\summarizer\\.dockerignore": "bb2ae3e760f6dd01d9332f60ec386699d4cd4fc0ad107ec961102acdef0dbd2d",
|
||||
"tools\\summarizer\\Dockerfile": "d0b8777e81a74ed7099fe1d93f608b6601da39efc43b50ec0b3b058613207443",
|
||||
"tools\\summarizer\\README.md": "9819bee2323e514b502a2e5bf8476b48e0fa7721531c9ad1a9cb87ffd2deac57",
|
||||
"tools\\summarizer\\app.py": "a721a7633f7ed93b7353f2135536a168e096494629506634c96b456b7ae713c2",
|
||||
"tools\\summarizer\\parser_child.py": "0074a05e9a0d511e26a329a057f98568cdc1723b2cc37168e0b59975b5424451",
|
||||
"tools\\summarizer\\pytest.ini": "a7807c5eb6605b011dcd25172dc41218152a8f770296530553516ba5b9ce550f",
|
||||
"tools\\summarizer\\requirements-dev.txt": "99597e9fe57388ff58a2b6582c41eea38854f9536987fcd9673116d68722e3d5",
|
||||
"tools\\summarizer\\requirements-linux.lock": "36f2912965d183e59f2d8320f3817ca8dd0fee6d76e05cfd63ac92defe6c6031",
|
||||
"tools\\summarizer\\requirements.txt": "0565afe7443fb60531af2938f9e34cf402f2db86eb23141e0e59182d48fa739a",
|
||||
"tools\\summarizer\\scripts\\bootstrap-and-test.sh": "ea1210abc4fcd830ae8cd58b62ad695590fbc24d006acd7c5723c2b6c20f5b52",
|
||||
"tools\\summarizer\\tests\\test_app.py": "b725c7ef682a46748136ca2048772067ee00722467ab47201b97179687657637",
|
||||
"tools\\summarizer\\tests\\test_cv_grounding.py": "f13febeec5038ec96f121566fa6e5e22857075cec9f56b55def4d413d5127fae",
|
||||
"tools\\summarizer\\uvicorn.err": "b03a759d0627646713696c5bc0e13475ea83f3eb840cc72a29c0f97da0e6a7f8",
|
||||
"tools\\summarizer\\uvicorn.out": "23b596cbdefaac3284b41ea2d8773159480477eff5ef50435ead41390eed8dd5",
|
||||
"JobTrackerApi\\bin\\Release\\net9.0\\JobTrackerApi.dll": "2db36ecfec61c37d536db1248f145aee3b34dff038917004c6b6e983f5c0b95c",
|
||||
"JobTrackerApi.Tests\\bin\\Release\\net9.0\\JobTrackerApi.Tests.dll": "acc615ec88cc06a0c64b818cd59c27e5316ec70c3ff99471851b0f7cc0631633",
|
||||
"job-tracker-ui\\out\\404.html": "5401cc4ca6237edec4faa06386621e1f4e8be7c6dcf66f06b6a4891e8c1b300b",
|
||||
"job-tracker-ui\\out\\favicon.ico": "8d4923e2c487805ea9f852a685b2cf0fb10512ded8290d2a5c37d1cc8fd0702a",
|
||||
"job-tracker-ui\\out\\favicon.svg": "f46bb75bd17edaa86713a92b40774be5055bc2bd5f49223b3891fbe9a5d068cb",
|
||||
"job-tracker-ui\\out\\index.html": "ee5a5c9005c85be4e7574e8152b3ca719c3b052597824efeccd82bb7d1553cb5",
|
||||
"job-tracker-ui\\out\\index.txt": "7287494e0b06f971a12fad25dd54ca5940d13d1714829ee5edc834fb447f5539",
|
||||
"job-tracker-ui\\out\\logo192.png": "bc652031597fabd19b9f14e22a71f809490d218037521b3b64d013418c1390b4",
|
||||
"job-tracker-ui\\out\\logo512.png": "5129e9bc1ba812b711fd9a646d3a38a906d365a26ee685df183b8752458944d4",
|
||||
"job-tracker-ui\\out\\manifest.json": "2667ce63c2e0835b7007239816a5597639321d333da396a78638dfa9e8a7c935",
|
||||
"job-tracker-ui\\out\\robots.txt": "90d24bc3bf698ac1e173739502298ccca72adf1f564fab05f484b8c48d1cadd2",
|
||||
"job-tracker-ui\\out\\_not-found.html": "5401cc4ca6237edec4faa06386621e1f4e8be7c6dcf66f06b6a4891e8c1b300b",
|
||||
"job-tracker-ui\\out\\_not-found.txt": "00b419fa63b4d14a7cc0173773c3b6888a8af8f4ea772e7a6fa7c87c3d87964c",
|
||||
"job-tracker-ui\\out\\__next._full.txt": "7287494e0b06f971a12fad25dd54ca5940d13d1714829ee5edc834fb447f5539",
|
||||
"job-tracker-ui\\out\\__next._tree.txt": "ebdf4fbda78b2b918f56879f4c0fb217b906855a1f36f671f5e08f08eae0dfaa",
|
||||
"job-tracker-ui\\out\\__next.__PAGE__.txt": "9ab41b1081df571dc6eca6568be0901c8868c4683a8aab6fd58740fe0d6fea90",
|
||||
"job-tracker-ui\\out\\mockups\\dashboard.svg": "318be3adb1324c26eb30f05572a29528a3e38331c6a604652eef75b54f8c29dc",
|
||||
"job-tracker-ui\\out\\mockups\\pipeline.svg": "db2ef41faabeb90e7ce85413e168581686c54535d970fc7d8deda3888a479de4",
|
||||
"job-tracker-ui\\out\\mockups\\workspace.svg": "9bbab22349147c7b01d6e18c93c622a6f8aed6dedaf23715189ab54d60e1ddbd",
|
||||
"job-tracker-ui\\out\\_not-found\\__next._full.txt": "00b419fa63b4d14a7cc0173773c3b6888a8af8f4ea772e7a6fa7c87c3d87964c",
|
||||
"job-tracker-ui\\out\\_not-found\\__next._tree.txt": "879268e5b10ebe324f9a82c701fa2b8c3038fe3ce9f3013e5c9a22309ff5d4dd",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\035759_jwo3d2.js": "d130d6ae20af93e585ff79b3c22443e790f69c3c0bdbd035b8505c9bd70bd8b7",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\04ne4s00wst-i.js": "c254b7f1fa8df945d7c333e331cec95e6d7dc4620490170ed64703f8f0162255",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0690wjwt7bfbl.js": "4ac559d5a8eeb9b69ae8368c4451b105fc32a527aec05d0ce62aca7df399bd1a",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\069gggqf9ja_6.js": "8e8ead5ec17cf660d98fa33de335184d7baa59f6c434318a5f4e802067407e81",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0cz1d0mv5g_q7.js": "0973c1d64c88adc8e3c950410cb58b288f72118d5965b78049438deb8f2f9683",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0f20amr0-m-zl.js": "70b0fb6db2fa0749d01768823746641975bfc42a65e133a2b138e4aae0ba660b",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0g8jdam99b7-z.js": "94264307de5eb80b0e87dfd263607fe24b5cb19d6008184ab6b107a4873b49ac",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0j0knhby2z3dq.js": "e365e828e8c106199b7e7239a90a7741dba00cb223d923897b78cfc92c285baf",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0lfuwd98rim9r.js": "db3216f3501d53c517031483d281466eb08c5054d83bb0d5009731e5fbaf3a1f",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0m7vi4hs61md9.js": "b098d60b98930bfa895489a42ae534227e2e44e2c888aaa1a139fadbe700d119",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0myaa6-57ckp2.js": "878352584a7287933b261fb99ab8f1e56543aa2fd3af56110e51557f269116c5",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0swqvzc1gm8tm.js": "ace5778cc960bb904ab2b77085d6c6ee4b9efc9d95023d3100a8f850af6512b0",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0vdypuasxr50b.js": "619d2e04b9c1a2fc2976a3fbdcedf7ec716aa0cc5b0f4061da4d9ef220504ce9",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0w3y5uv0hr947.js": "94b0774abaaa0aaf1f6f324d72982fcec39061a97602b8d27d7550e2076ab0c0",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0xtkescovpyfz.js": "67650f803110e67853412c9f48d70fb7120535d5c1706cde914e9297db357c78",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0zwp_bvf6-sm5.js": "2285f236f115f36a48f71ec91993d967725f33f208fe5c0e02036f03ea8cb6f3",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\0_5_ijn4eu7en.js": "3598e5856880252529d722e786648d2ada0cf8f018d69019bbbd16bb40810e95",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\115kc7_5ymkx5.js": "10f46f281ab1e151b96d93b374fb311236ad9e090c492f26fd78b9201e2ebab9",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\16ofit5cz_ak3.js": "3f17e8f9df8783b47bed0a7632e9e75c144c0528dfc5c056c1df1cadec9505ea",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\1bo5vruln9ro7.js": "d3c3fd8cd9086e741993371e2a1c847f9262a11c5ff4ac15ca956143a8ad68e2",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\1gjjzemw1bkq9.js": "f21eb1091455a5a61e2b11c10d55aa51303ca50341bff82ce33dc17819f0a92f",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\1k_5axiqq7um2.js": "d4cc9b89e854d02a65428f540dbda1845849d96e994a56a20c5e441db99e2f23",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\1owxpom4r3xpr.js": "372368d3aba84cb4a23eca2694bc554d50575b341a8b73e2074c4fac820beb7b",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\1pqrh1ggibl7x.js": "23fed1e18eee99f5875f60a599c236fb527f046fff7fe0488aeb4b2441f54908",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\1pvs_7i8s24h_.js": "e00d3cb92b81cf1572f00175d3b25bf3805946d5bd189e8d268c23af449473f3",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\1qh6a3h1ymggl.js": "d978e1cedc7f1a23cdf6f4f26da473fc5815b700a01721bd74de2c40a6247871",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\1rllbyp7b_ttp.js": "9fecabb65fb48cc039c00aad586a0987f994b0f0e88c28185eb7a3a6e402eb65",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\1vjm2fk23r5kh.js": "a5720dd1ceb2b511ce89f809366c8ce4b63318b52a90368f50fa1e17f2e14237",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\1xwc-b795syag.css": "f73c002233f931ca14d0ae58815c47ca9f2659f25b1547a6d1fdca12a2a00b3f",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2aoqedway9mw0.js": "8931dbd823bb6b3bd3d74e2c1353c934c4b65307969e66450c1750943406ba7a",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2e2n0wy891_-d.js": "3436d9be1827de975577e7fcc84eaf322dcd2ab163c3534fd4b485814e7e831f",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2k-42mv5hkemq.js": "2bce5837ffbea9aab73903639e7e69e79ddce2243feb44a9d020334ba0149a9f",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2kxux5nynitrn.js": "caf3877cfaa369a777710fed7120c04b9a8eecade88cf9cbecb1a15a7ef1df0e",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2ldabjmkkhsqt.js": "474d9633a35895595c5f416ff14791fdc5a087e1966aa389d6289bf3615c4c35",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2lppknr1nvqch.js": "fccd6b742b8831c7ddbc8905df7f8882d73c4d3e644b63ea1c1203a3119f4ac3",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2onuriq_1xzzc.js": "7b32cf09d736d33566e4bca97571ca38cbd66794b8008d6275495de6f2cafad4",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2pbbh0hc6t9wu.js": "a7294ee04c85689d3030c037b85423c5606f7a00bff0e2df2136383f0f3beec2",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2qdw9ugzxwxat.js": "3ab1b2b469e65b653b59ce7bc5e8241443ac6d87f9b096a79d5b42fa5ac81d89",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2vlttipo05xfv.js": "a17d6235f5f3d618b8d0c03fcafe6ee624088571c8b29f1f6d8800f8f57bccf2",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\2yj1t8mjxjl5o.js": "54e05f4836355eae5caa458de0b52da91b73a7a4bd2bffaacb2d5c8c20fce9cf",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\30u_w9im0zfmz.js": "56f17b0c9102fc1418a8adec566e878e34a8772ae1b7c808659560e17cd5ccc6",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\351q7ugo59r3m.js": "dffbb098e5ee3376f6215d986202915f17fbb4d7de1f00e01c9cc304ff57fd0f",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\3ax4vrz5w_gjt.js": "16dbe61792aa6fa70ec3cc4f5634a88ac4cbfbb943b80cae1087cfd64dc6c008",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\3ffc2l449srya.js": "b8385d6638c4c0f117e442ebec4ff39eec5187ea72f090885314b4ee06d66d33",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\3gtyu4uube_dy.js": "ea92a44850c31a0b15f597c04d4ab0dadda28d3403d0ed742af7f70353e8b238",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\3i298g3ipj6-h.js": "f6f1ce0c2cd1956fef9cbb9c7802d5a11cd5137357374175d60324d8f8fed496",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\3q9pcshluw1av.js": "d8acce18001ee0232c728d93c5f4a9f01021850455399b542e5317d715754cd6",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\3t4zlerpupcoq.js": "b1b1cc211c58922939677aaad111dd106a98ffd9e08a40dff208f8c4d9e7a95b",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\3wi4ei8dsihay.js": "56ff128469b14721092d27a5644f1eb532bd3d81430edccc854817ce0cfaedc5",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\3xrk4q-css54o.js": "b5357e142a83e4913f1f6f6f863da55319943938074b2e0d6fa7860106c8a48f",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\3_am-fyclheic.js": "978e7e774695b20fb9591326ff3eedd3d060eb046f8fcf00d89efe20dc01ffb9",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\3_caw2ltz6rml.js": "8acbe66a92b7e4e09c7fd4d846d7947af78bfc96a82aa39fd95e51b4ea0b1952",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\406gm9r2rgjcf.js": "c165b5e9dced109c6faf32b8bdcbe2050471f67c5bc37c8349404827d4575ca1",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\42p8__52-eg_x.js": "9951df23b044b5b1e4b68691f8e343ff5a6d2c9fc7c10f6f9a558dcee78381ce",
|
||||
"job-tracker-ui\\out\\_next\\static\\chunks\\turbopack-1oyia4ingkihb.js": "230baf90335e80ece7560ebc8326cbf8dc22c572b13ce7c96521afdacb4260c9",
|
||||
"job-tracker-ui\\out\\_next\\static\\n0nrmxiiOalQ-P6xbAyV4\\_buildManifest.js": "9476e0afd3f9da68a64e6ec58631191fd3eaca62c9f5a377e7517fe9a89e59ea",
|
||||
"job-tracker-ui\\out\\_next\\static\\n0nrmxiiOalQ-P6xbAyV4\\_clientMiddlewareManifest.js": "5483e3295dc35dfe29ea932efc85341c916f10ff78ff5b0e965e003710893a4a",
|
||||
"job-tracker-ui\\out\\_next\\static\\n0nrmxiiOalQ-P6xbAyV4\\_ssgManifest.js": "678f6ce2cb80b1fe72fc67e7412be6e2ab6ada083111b64f7c40d35e3cba5e00",
|
||||
"job-tracker-ui\\out\\_not-found\\__next._not-found\\__PAGE__.txt": "cc130c83b14edfa7ea749e69a9029dc0e7436775846744486be23d8618e90206"
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
{
|
||||
"observedUtc": "2026-10-21T22:35:26.758975+00:00",
|
||||
"sourceCommit": "2f6227fb4f9a6cc61838e8afde9e2dafd00bd428",
|
||||
"sourceIdentity": {
|
||||
"inputs": 815,
|
||||
"differences": [],
|
||||
"manifestSha256": "e33691d540947e624870655d64a4662cb5c9df9195fc30d28722c412fdf1efec",
|
||||
"qualification": "The selected product/test, Release assembly and static-export inputs equal the manifest captured at entry to the uninterrupted accepted final ordinary browser run. Documentation and separately hashed sizing diagnostics are outside that entry manifest."
|
||||
},
|
||||
"supportedSizingSources": {
|
||||
"docs\\redesign\\phase-8\\p8-04\\diagnostics\\performance.spec.ts": "d7570cc07cb2c2ee20ea1ff1fcbd2d1ab866291924453d9d2372583a972c1756",
|
||||
"docs\\redesign\\phase-8\\p8-04\\diagnostics\\playwright.config.ts": "7a054ea86ff91535781023a99e29497b891120a99474c2eab7c160576a742eb4",
|
||||
"docs\\redesign\\phase-8\\p8-04\\diagnostics\\sizes.spec.ts": "97d5cd3c13a8ad6cbd9b019fcf46d51267efbf53fdb5433dd96d76ba64914345"
|
||||
},
|
||||
"baseline": {
|
||||
"backend": {
|
||||
"total": 1060,
|
||||
"executed": 1060,
|
||||
"passed": 1060,
|
||||
"failed": 0,
|
||||
"error": 0,
|
||||
"timeout": 0,
|
||||
"aborted": 0,
|
||||
"inconclusive": 0,
|
||||
"passedButRunAborted": 0,
|
||||
"notRunnable": 0,
|
||||
"notExecuted": 0,
|
||||
"disconnected": 0,
|
||||
"warning": 0,
|
||||
"completed": 0,
|
||||
"inProgress": 0,
|
||||
"pending": 0
|
||||
},
|
||||
"frontend": {
|
||||
"numPassedTests": 899,
|
||||
"numFailedTests": 0,
|
||||
"numPendingTests": 0,
|
||||
"numTotalTests": 899,
|
||||
"numPassedTestSuites": 101,
|
||||
"numFailedTestSuites": 0,
|
||||
"numPendingTestSuites": 0,
|
||||
"numTotalTestSuites": 101
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
"total": 1084,
|
||||
"executed": 1084,
|
||||
"passed": 1084,
|
||||
"failed": 0,
|
||||
"error": 0,
|
||||
"timeout": 0,
|
||||
"aborted": 0,
|
||||
"inconclusive": 0,
|
||||
"passedButRunAborted": 0,
|
||||
"notRunnable": 0,
|
||||
"notExecuted": 0,
|
||||
"disconnected": 0,
|
||||
"warning": 0,
|
||||
"completed": 0,
|
||||
"inProgress": 0,
|
||||
"pending": 0
|
||||
},
|
||||
"frontend": {
|
||||
"numPassedTests": 917,
|
||||
"numFailedTests": 0,
|
||||
"numPendingTests": 0,
|
||||
"numTotalTests": 917,
|
||||
"numPassedTestSuites": 104,
|
||||
"numFailedTestSuites": 0,
|
||||
"numPendingTestSuites": 0,
|
||||
"numTotalTestSuites": 104
|
||||
},
|
||||
"ordinaryBrowser": {
|
||||
"startTime": "2026-10-21T21:44:07.945Z",
|
||||
"duration": 2908356.727,
|
||||
"expected": 360,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
"separateBrowserContracts": {
|
||||
"contract-ai": {
|
||||
"startTime": "2026-10-21T19:08:41.951Z",
|
||||
"duration": 12168.474999999999,
|
||||
"expected": 2,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
"contract-auth-recovery": {
|
||||
"startTime": "2026-10-21T19:13:46.786Z",
|
||||
"duration": 12110.351,
|
||||
"expected": 2,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
"contract-diagnostics": {
|
||||
"startTime": "2026-10-21T19:14:21.053Z",
|
||||
"duration": 29266.784,
|
||||
"expected": 3,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
"contract-ingress": {
|
||||
"startTime": "2026-10-21T19:14:08.647Z",
|
||||
"duration": 5113.259,
|
||||
"expected": 6,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
"contract-pdf-admission": {
|
||||
"startTime": "2026-10-21T19:14:14.382Z",
|
||||
"duration": 6138.74,
|
||||
"expected": 1,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
"contract-rate-limits": {
|
||||
"startTime": "2026-10-21T19:13:59.507Z",
|
||||
"duration": 8601.232,
|
||||
"expected": 9,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
}
|
||||
},
|
||||
"separatePassing": 23,
|
||||
"sidecar": {
|
||||
"tests": 56,
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"skipped": 0
|
||||
},
|
||||
"additionalSupportedSizing": {
|
||||
"startTime": "2026-10-21T21:43:24.878Z",
|
||||
"duration": 12407.408,
|
||||
"expected": 2,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
"additionalPerformance": {
|
||||
"startTime": "2026-10-21T22:33:39.875Z",
|
||||
"duration": 12790.557,
|
||||
"expected": 1,
|
||||
"skipped": 0,
|
||||
"unexpected": 0,
|
||||
"flaky": 0
|
||||
},
|
||||
"delta": {
|
||||
"backend": 24,
|
||||
"frontend": 18,
|
||||
"browser": "New Phase 8 spec adds nine cases; all prior ordinary files remain."
|
||||
},
|
||||
"accessibility": {
|
||||
"targetedScans": 10,
|
||||
"violations": 0,
|
||||
"incompleteNodeOccurrences": {
|
||||
"color-contrast": 63,
|
||||
"aria-hidden-focus": 1
|
||||
},
|
||||
"manualQualification": "Human screen reader, physical touch/software keyboard, complete true zoom/reflow, layered contrast, other browsers and production output remain outstanding."
|
||||
},
|
||||
"preservation": {
|
||||
"groups": [
|
||||
"PRES-E01",
|
||||
"PRES-E02",
|
||||
"PRES-E03",
|
||||
"PRES-E04",
|
||||
"PRES-E05",
|
||||
"PRES-E06",
|
||||
"PRES-E07",
|
||||
"PRES-E08",
|
||||
"PRES-E09",
|
||||
"PRES-J01",
|
||||
"PRES-J02",
|
||||
"PRES-J03",
|
||||
"PRES-J04",
|
||||
"PRES-J05",
|
||||
"PRES-J06",
|
||||
"PRES-J07",
|
||||
"PRES-J08",
|
||||
"PRES-J09",
|
||||
"PRES-J10",
|
||||
"PRES-W01",
|
||||
"PRES-W02",
|
||||
"PRES-W03",
|
||||
"PRES-W04",
|
||||
"PRES-W05",
|
||||
"PRES-W06",
|
||||
"PRES-W07",
|
||||
"PRES-W08",
|
||||
"PRES-W09",
|
||||
"PRES-W10",
|
||||
"PRES-W11",
|
||||
"PRES-W12",
|
||||
"PRES-C01",
|
||||
"PRES-C02",
|
||||
"PRES-C03",
|
||||
"PRES-C04",
|
||||
"PRES-C05",
|
||||
"PRES-C06",
|
||||
"PRES-C07",
|
||||
"PRES-C08",
|
||||
"PRES-C09",
|
||||
"PRES-C10",
|
||||
"PRES-C11",
|
||||
"PRES-C12",
|
||||
"PRES-M01",
|
||||
"PRES-M02",
|
||||
"PRES-M03",
|
||||
"PRES-M04",
|
||||
"PRES-M05",
|
||||
"PRES-M06",
|
||||
"PRES-S01",
|
||||
"PRES-S02",
|
||||
"PRES-S03",
|
||||
"PRES-S04",
|
||||
"PRES-S05",
|
||||
"PRES-A01",
|
||||
"PRES-A02",
|
||||
"PRES-A03"
|
||||
],
|
||||
"count": 57,
|
||||
"partial": 56,
|
||||
"deferred": [
|
||||
"PRES-C11"
|
||||
],
|
||||
"routePatterns": [
|
||||
"/",
|
||||
"/admin/audit",
|
||||
"/admin/system",
|
||||
"/admin/users",
|
||||
"/analytics",
|
||||
"/applications/:id",
|
||||
"/career",
|
||||
"/career/builder",
|
||||
"/career/builder/:id",
|
||||
"/companies",
|
||||
"/confirm-email-change",
|
||||
"/correspondence",
|
||||
"/correspondence/review",
|
||||
"/cv/:slug",
|
||||
"/dashboard",
|
||||
"/discover",
|
||||
"/documents",
|
||||
"/forgot-password",
|
||||
"/jobs",
|
||||
"/jobs/:id",
|
||||
"/kanban",
|
||||
"/login",
|
||||
"/microsoft-legacy-relink",
|
||||
"/offers",
|
||||
"/operations",
|
||||
"/profile",
|
||||
"/register",
|
||||
"/reminders",
|
||||
"/reset-password",
|
||||
"/settings",
|
||||
"/settings/connected-accounts",
|
||||
"/trash",
|
||||
"/verify-email"
|
||||
],
|
||||
"routesUnchanged": true
|
||||
},
|
||||
"security": {
|
||||
"secretScan": "PASS",
|
||||
"newDependencies": 0,
|
||||
"liveAdvisoryRefresh": "NOT RUN; dependency manifests unchanged; inherited NuGet Pkcs high/moderate and development advisory qualifications retained.",
|
||||
"dockerTopology": "BLOCKED: Docker Desktop Linux engine pipe unavailable. Static ingress helpers passed; no runtime topology acceptance.",
|
||||
"mariaDb": "Provider SQL generation PASS; runtime NOT RUN because Docker was unavailable."
|
||||
},
|
||||
"decisions": {
|
||||
"phase8": "CONDITIONAL GO",
|
||||
"phase9Readiness": "CONDITIONAL GO",
|
||||
"production": "NO-GO"
|
||||
},
|
||||
"rawEvidence": [
|
||||
{
|
||||
"path": ".artifacts/phase8/baseline/p8-baseline.trx",
|
||||
"sha256": "8db8dd9302c77ba3744344dea2032fa7626d26e6a3f9703b464b85725d1cd5ce"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/baseline-frontend.json",
|
||||
"sha256": "2c07ca75307822a3dfc5aa9b017c94d9974dac841b71e2720a828b564895b522"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/backend-toctou-full/dashboard-full.trx",
|
||||
"sha256": "6bc1c6214ccb0942a756ff9cdd4f48dd10d939dc495ca5ad60b82fdac4122087"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/frontend-qualified.json",
|
||||
"sha256": "8be275d9b032e62f54a98bd425185944e14df00ff43fe19e5a8c33c2041f9f19"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/browser-accepted.json",
|
||||
"sha256": "4efc11b87046220ad72808c209d3b755e56de70a18f2d1e8d0ba4a1dc22b2e30"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/browser-accepted.log",
|
||||
"sha256": "e3e2142bea69432b5eb18fdcdf72be258236978f1b99133789c708fd46777773"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/sidecar.xml",
|
||||
"sha256": "ddef21534096ea5b37141a2d89a9fe5d2f8a1f054d9ec8784e2fac6fddd70deb"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/qualified-build.log",
|
||||
"sha256": "cfd1e6634c5190b0c3cdbf3fd894396b8770cc0c198e50b20a87490c0445eb10"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/lint-accepted.log",
|
||||
"sha256": "b0b4ecec1cf5dc5c55222efbcffeaa0860c6d7ac9afa36fc3641afb43c9b2ef4"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/typecheck-accepted.log",
|
||||
"sha256": "e32803caa3bb621d546f5d3989f980f674d06c2228c795ee558e5fbeada37edf"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/browser-typecheck-accepted.log",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/secrets-accepted.log",
|
||||
"sha256": "be5119e6057287d397e87759f855c91e634e3bfac61c8be7584d05592ba2dfcd"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/supply-chain-tests.log",
|
||||
"sha256": "d2e3874a15d74211e3bf7832c13b4cbf9adc76c2f7c3f2870b0e09672df96a3a"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/ollama-evaluation-tests.log",
|
||||
"sha256": "7de4891a3a15e882ddbd1aee5bcadfec6a11ea4fcda5f5b264df337197802439"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/browser-runtime-tests.log",
|
||||
"sha256": "de580fed1e93be0b2e96e187f443bebc580e45d5a50330237bc5c647255d548a"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/ingress-tests.log",
|
||||
"sha256": "b123b54b41d16f08f7a2ee1272ddcd45799c1bd69ad52ef27a61fb3d0c510a4b"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/ingress-topology-tests.log",
|
||||
"sha256": "100b36f3954f4c0823117c2cab42694135128dcac29f78097d0c696df2165312"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/extension-tests.log",
|
||||
"sha256": "f569c9737b3d5dc4ee6f8680c2b9d3f71364e2f12c84fa9f84ebde63fdb7ea82"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/node-contracts.log",
|
||||
"sha256": "afc063fe3391b6e6818f27f89e1b4bd2a6ccd87fceb2c040fc60884c205aba7a"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/sbom.json",
|
||||
"sha256": "ea51f96cfdef0eca2a1dc653951a4412111ea6fc898afaac8874b493025689e2"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/browser-focused.json",
|
||||
"sha256": "94c992116fcb80c419c9aa438ccaaf7eea7059b2d5bde4e662e4a8654ae76518"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/browser-full.json",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/browser-qualification.json",
|
||||
"sha256": "a95c88e43e33316bc108c901a1e977bdff20566bc95528b88b78dc2708a84d77"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/focus-red.json",
|
||||
"sha256": "fc2940444b3db9305959be501dd7a9626364351338ee1814c7fd23204b4d6b52"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/preview-semantics-red.log",
|
||||
"sha256": "f2733cbd05d142cb7251b3dfc08b9b3a2c13c62ef77d9cf59477e844ce892b1c"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/dashboard-final.json",
|
||||
"sha256": "12fc7b8171109837a6801419ee1e87b1eb302a8740154c70a2296608e39e8abe"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/contract-ai.json",
|
||||
"sha256": "15f2381809f53d07ec120f4954f4754204790f376e28497f4f85b7e91fc50d8a"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/contract-auth-recovery.json",
|
||||
"sha256": "8bfbef3f5f2f729ce2a52d32b56868e8859f21a187dcec494326b2989b8d045a"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/contract-diagnostics.json",
|
||||
"sha256": "5658804c3503123dbc9258a0abcce1b553607ff6a78d32f7605c4ab8fdf04d7e"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/contract-exits.json",
|
||||
"sha256": "1fd18e2726c8081b0153eb6ba7046f4e4c8dbed95f82e09cabc806b26440c980"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/contract-ingress.json",
|
||||
"sha256": "916f6c24140fd7ac18161d85a8dfcf79779ee62d61e03824cb96e0a9a38c18cc"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/contract-pdf-admission.json",
|
||||
"sha256": "3632f64993e0a0da2fda496a62253e94641870ac484db771ef1d110524df00d9"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/contract-rate-limits.json",
|
||||
"sha256": "1e67a2cacaf0be9881fe8324a4ce83033f158f345edde039e63a74c648187851"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/browser-salary-final.json",
|
||||
"sha256": "559903f57c4fa9f2367f0e8f3c15a7b09d74f8f84375a4cb9b6b8526a81bad45"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/browser-salary-final.log",
|
||||
"sha256": "8ef90461ff7fcd3ace7a3aaf3892c1a9fba299c1a9632d896101a425408dde9c"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/frontend-salary.json",
|
||||
"sha256": "b92cdd86a4c7e6eaa50e530964d7eec2691d6a0e7422234d7d4824b26866e38b"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/frontend-salary.log",
|
||||
"sha256": "299a98115d2be67b0820cbebabb5b41b6df733c8f8024b65742b72da0742e07f"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/salary-build.log",
|
||||
"sha256": "1bd7ea81357d5583d1fc1205c484300926d8bbd06a6f76580dadf169c83bf811"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/typecheck-salary.log",
|
||||
"sha256": "e32803caa3bb621d546f5d3989f980f674d06c2228c795ee558e5fbeada37edf"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/lint-salary.log",
|
||||
"sha256": "b0b4ecec1cf5dc5c55222efbcffeaa0860c6d7ac9afa36fc3641afb43c9b2ef4"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/browser-typecheck-salary.log",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/salary-red.json",
|
||||
"sha256": "bd5ab3df376a6b34593fa1789eca479c9e08af4ae3995171623cdf01a87060b8"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/salary-green.json",
|
||||
"sha256": "97bcdd2da2ee2961c05c386c612f6f97114bb1bed80ddd1d128a81218de2748b"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/salary-red.log",
|
||||
"sha256": "a30f76b0cbda1b0d2b8ee7fa07e7db86db0bb0706271030a4ccca56aa3b78b37"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/salary-green.log",
|
||||
"sha256": "a5b9ffaf80e37c0c5b41ba1d80416e6e296ca94dfd8bab07d1892f4a324e5bd5"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/secrets-final.log",
|
||||
"sha256": "be5119e6057287d397e87759f855c91e634e3bfac61c8be7584d05592ba2dfcd"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/performance-final.json",
|
||||
"sha256": "2f3eba90dd631397b9aba9180d3ad61a190f5fbc17e540348aa855f13115f9eb"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/performance-final.log",
|
||||
"sha256": "928a0167aabe0fc927d0533f77310619a21f2d3e2f7b72c7616b08eb3b107fc0"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/performance-successor.json",
|
||||
"sha256": "b38787d2c16be5c41ba9b25a278784b2a249a008a4191230b349fd7568b27d73"
|
||||
},
|
||||
{
|
||||
"path": ".artifacts/phase8/performance-successor.log",
|
||||
"sha256": "aae4b210832f5891c3dc25be488867336768c1bcf3e410c5f70cd856910da276"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -134,3 +134,6 @@ Recent-notification presentation committed and normally pushed **40cfe04df51654c
|
||||
### P7-08 publication and P7-09 entry
|
||||
|
||||
Operations **c5176e3251c869e13284b994b59b2f7aa9220eb1** is normally pushed and directly verified **2026-10-17T02:03:53.815275UTC**: clean HEAD=origin=live development, local/origin/live main27657f3 unchanged. Both P7-08 UI boundaries remain implemented **CONDITIONAL GO and published**; manual/provider/output/release conditions remain. Earlier pending-publication text is superseded by this exact receipt in Operations results. P7-09 follows its approved two boundaries; a separately bounded OAuth sender repair first protects its two existing callback consumers. Fresh entry23/3 frontend and40 unchanged-binary backend cases pass;791/95,323,1054 remain derived inventories, not full-suite passes. No production or Phase8 action.
|
||||
# Phase 8 acceptance checkpoint
|
||||
|
||||
- [x] Dashboard customisation P8-00–P8-05 implemented and locally validated on `feature/phase8-dashboard-customisation`; [final acceptance and Phase9 handoff](../redesign/phase-8/p8-05/FINAL_ACCEPTANCE.md). Phase8 CONDITIONAL GO; Phase9 readiness CONDITIONAL GO; production NO-GO. Human/device/provider/performance/release qualifications remain; no main merge, deployment or Phase9/10 execution.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, test, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import { defaultDashboardLayout } from '../src/dashboardLayout';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
@@ -79,8 +80,11 @@ test('[PRES-S01 S02 E07 E08 E09 J05 J09] Analytics native reports, direct entry,
|
||||
await page.getByRole('link', { name: 'Nordlys langsiktige karrieremuligheter æøå', exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/jobs\\?companyId=${companyId}$`));
|
||||
await expect(page.locator('[data-job-row-id]')).toHaveCount(6); await page.goBack();
|
||||
// Retained Dashboard flags must not remove the independent report destination.
|
||||
// A fully hidden saved Dashboard must not remove the independent report destination.
|
||||
await page.evaluate(owner => localStorage.setItem(`dashboardPrefs:${owner}`, JSON.stringify({ cards: false, activity: false, funnel: false, companies: false, skills: false })), me.id);
|
||||
const hidden = defaultDashboardLayout();
|
||||
hidden.widgets.forEach(widget => { widget.visible = false; });
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/dashboard`, { headers, data: hidden })).status()).toBe(200);
|
||||
await page.goto('/dashboard'); await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Analytics', exact: true }).click();
|
||||
await expect(activity.getByRole('table')).toBeVisible();
|
||||
|
||||
@@ -89,16 +89,18 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
|
||||
}
|
||||
const customize = page.getByRole('button', { name: t.dashboardCustomize, exact: true });
|
||||
await customize.focus(); await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('menuitemcheckbox').first()).toBeFocused();
|
||||
const editor = page.getByRole('region', { name: t.dashboardEditing, exact: true });
|
||||
await expect(editor).toBeVisible();
|
||||
await expect(editor.locator('..')).toBeFocused();
|
||||
await expect(editor.getByRole('list', { name: t.dashboardManageWidgets })).toBeVisible();
|
||||
await expect(editor.getByRole('checkbox')).toHaveCount(9);
|
||||
if (language === 'en') {
|
||||
// Measure settled colors, not the menu's entry-opacity transition.
|
||||
await expect.poll(() => page.getByRole('menu').evaluate(menu => getComputedStyle(menu.parentElement!).opacity)).toBe('1');
|
||||
const menuScan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`dashboard-${mode}-menu-axe`, { body: JSON.stringify({ violations: menuScan.violations, incomplete: menuScan.incomplete }), contentType: 'application/json' });
|
||||
expect(menuScan.violations).toEqual([]);
|
||||
const editorScan = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`dashboard-${mode}-editor-axe`, { body: JSON.stringify({ violations: editorScan.violations, incomplete: editorScan.incomplete }), contentType: 'application/json' });
|
||||
expect(editorScan.violations).toEqual([]);
|
||||
}
|
||||
await page.keyboard.press('Escape'); await expect(customize).toBeFocused();
|
||||
await expect(page.locator('[role="menuitemcheckbox"]')).toHaveCount(0);
|
||||
await expect(editor).toHaveCount(0);
|
||||
if (language === 'en') {
|
||||
const result = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`dashboard-${mode}-axe`, { body: JSON.stringify({ violations: result.violations, incomplete: result.incomplete }), contentType: 'application/json' });
|
||||
@@ -115,8 +117,9 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers, data: { language: 'en' } })).status()).toBe(204);
|
||||
await page.evaluate(() => localStorage.setItem('uiLanguage', 'en')); await page.reload();
|
||||
await page.getByRole('button', { name: 'Customize dashboard' }).click();
|
||||
for (const item of await page.getByRole('menuitemcheckbox').all()) await item.click();
|
||||
await page.keyboard.press('Escape');
|
||||
for (const item of await page.getByRole('region', { name: 'Editing dashboard', exact: true }).getByRole('checkbox').all()) await item.uncheck();
|
||||
await page.getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||
await expect(page.getByRole('region', { name: 'Editing dashboard', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
|
||||
await page.reload();
|
||||
await expect(page.getByText('Your dashboard sections are hidden.')).toBeVisible();
|
||||
@@ -137,7 +140,9 @@ test('[PRES-S01 S02 E07 E08 E09] Dashboard native data, visibility and responsiv
|
||||
await expect(page.getByText('Nordlys teknologi og fellestjenester æøå', { exact: true })).toHaveCount(0);
|
||||
await capture(page, info, 'dashboard-new-account');
|
||||
await page.getByRole('button', { name: 'Customize dashboard' }).click();
|
||||
for (const item of await page.getByRole('menuitemcheckbox').all()) await expect(item).toHaveAttribute('aria-checked', 'true');
|
||||
const newAccountEditor = page.getByRole('region', { name: 'Editing dashboard', exact: true });
|
||||
await expect(newAccountEditor.getByRole('checkbox')).toHaveCount(9);
|
||||
for (const item of await newAccountEditor.getByRole('checkbox').all()) await expect(item).toBeChecked();
|
||||
await page.keyboard.press('Escape');
|
||||
const b = await login(page.request, emailB);
|
||||
const bCompany = await (await page.request.post(`${apiUrl}/companies`, { headers: b.headers, data: { name: 'New search' } })).json();
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { expect, test as base, type APIRequestContext, type BrowserContext, type Page, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import type { DashboardLayout } from '../src/dashboardLayout';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const layoutUrl = `${apiUrl}/auth/preferences/dashboard`;
|
||||
const password = 'E2ePassword123!';
|
||||
const ids = ['cards', 'attention', 'funnel', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills'];
|
||||
type Account = { email: string; headers: Record<string, string> };
|
||||
async function login(api: APIRequestContext, email: string) {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await api.get(`${apiUrl}/auth/me`)).json();
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
|
||||
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
|
||||
}
|
||||
async function localOnly(context: BrowserContext) {
|
||||
await context.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
}
|
||||
async function readLayout(api: APIRequestContext): Promise<DashboardLayout> {
|
||||
const response = await api.get(layoutUrl);
|
||||
expect(response.status()).toBe(200);
|
||||
return response.json();
|
||||
}
|
||||
const editor = (page: Page) => page.getByRole('region', { name: 'Editing dashboard', exact: true });
|
||||
async function openEditor(page: Page) {
|
||||
await page.getByRole('button', { name: 'Customize dashboard', exact: true }).click();
|
||||
await expect(editor(page).getByRole('checkbox')).toHaveCount(9);
|
||||
}
|
||||
async function save(page: Page) {
|
||||
const response = page.waitForResponse(response => response.url() === layoutUrl && response.request().method() === 'PUT');
|
||||
await editor(page).getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||
expect((await response).status()).toBe(200);
|
||||
await expect(editor(page)).toHaveCount(0);
|
||||
}
|
||||
async function confirm(page: Page, name: string) {
|
||||
await page.getByRole('dialog', { name, exact: true }).getByRole('button', { name, exact: true }).click();
|
||||
}
|
||||
async function capture(page: Page, info: TestInfo, name: string) {
|
||||
const path = info.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
await info.attach(name, { path, contentType: 'image/png' });
|
||||
}
|
||||
async function signOut(page: Page) {
|
||||
await page.getByRole('button', { name: 'User', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: 'Sign out', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
|
||||
}
|
||||
async function signIn(page: Page, email: string) {
|
||||
await page.getByLabel('Username or email', { exact: true }).fill(email);
|
||||
await page.getByLabel('Current password', { exact: true }).fill(password);
|
||||
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
}
|
||||
const test = base.extend<{ account: Account }>({
|
||||
account: async ({ page, playwright }, provide) => {
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `phase8-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const headers = await login(page.request, email);
|
||||
const company = await page.request.post(`${apiUrl}/companies`, { headers, data: { name: 'Phase eight fixture' } });
|
||||
expect(company.status()).toBe(201);
|
||||
const companyId = (await company.json()).id;
|
||||
expect((await page.request.post(`${apiUrl}/jobapplications`, { headers, data: { companyId, jobTitle: 'Local fixture developer', status: 'Applied', dateApplied: new Date().toISOString(), tags: 'TypeScript', source: 'NAV' } })).status()).toBe(201);
|
||||
await localOnly(page.context());
|
||||
await page.goto('/dashboard');
|
||||
await expect(page.getByRole('link', { name: 'Tracked jobs: 1', exact: true })).toBeVisible();
|
||||
const dismiss = page.getByRole('button', { name: 'Dismiss setup checklist' });
|
||||
if (await dismiss.isVisible()) await dismiss.click();
|
||||
await provide({ email, headers });
|
||||
} finally { await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
|
||||
test('[P8] Saved order, size and visibility survive HTTP readback, a fresh browser and logout/login', async ({ page, browser, account }, info) => {
|
||||
await openEditor(page);
|
||||
await editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true }).click();
|
||||
await editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full');
|
||||
await editor(page).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck();
|
||||
await save(page);
|
||||
const saved = await readLayout(page.request);
|
||||
expect(saved.version).toBe(1); expect(saved.revision).toBe(1);
|
||||
expect(saved.widgets.map(widget => widget.id)).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']);
|
||||
expect(saved.widgets.find(widget => widget.id === 'funnel')).toEqual({ id: 'funnel', visible: true, size: 'full' });
|
||||
expect(saved.widgets.find(widget => widget.id === 'activity')?.visible).toBe(false);
|
||||
const assertSaved = async (target: Page) => {
|
||||
await expect(target.locator('[data-dashboard-widget="activity"]')).toHaveCount(0);
|
||||
await expect(target.locator('[data-dashboard-widget="funnel"]')).toHaveAttribute('data-widget-size', 'full');
|
||||
await expect.poll(() => target.locator('[data-dashboard-widget]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-dashboard-widget')))).toEqual(saved.widgets.filter(widget => widget.visible).map(widget => widget.id));
|
||||
};
|
||||
await page.reload(); await assertSaved(page);
|
||||
const fresh = await browser.newContext({ baseURL: 'http://localhost:3300' });
|
||||
try {
|
||||
await localOnly(fresh);
|
||||
await login(fresh.request, account.email);
|
||||
const other = await fresh.newPage(); await other.goto('/dashboard'); await assertSaved(other);
|
||||
expect(await readLayout(fresh.request)).toEqual(saved);
|
||||
await capture(other, info, 'fresh-browser-server-layout');
|
||||
} finally { await fresh.close(); }
|
||||
await signOut(page); await signIn(page, account.email); await assertSaved(page);
|
||||
expect(await readLayout(page.request)).toEqual(saved);
|
||||
});
|
||||
|
||||
test('[P8] Entering edit mode keeps its heading and save controls below the fixed app header', async ({ page, account }) => {
|
||||
expect(account.email).toContain('phase8-');
|
||||
for (const width of [320, 768, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await openEditor(page);
|
||||
const heading = editor(page).getByRole('heading', { name: 'Editing dashboard', exact: true });
|
||||
await expect.poll(() => heading.evaluate(element => {
|
||||
const header = document.querySelector('.MuiAppBar-root');
|
||||
return element.getBoundingClientRect().top >= (header?.getBoundingClientRect().bottom ?? 0);
|
||||
})).toBe(true);
|
||||
await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
}
|
||||
});
|
||||
|
||||
test('[P8] Cancel, rejected navigation and reset change only the draft until explicit Save', async ({ page, account }) => {
|
||||
void account;
|
||||
await openEditor(page);
|
||||
await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck();
|
||||
await save(page);
|
||||
const before = await readLayout(page.request);
|
||||
let writes = 0;
|
||||
page.on('request', request => { if (request.url() === layoutUrl && request.method() === 'PUT') writes++; });
|
||||
await openEditor(page);
|
||||
await editor(page).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck();
|
||||
await page.getByRole('link', { name: 'Tracked jobs: 1', exact: true }).click();
|
||||
await page.getByRole('dialog', { name: 'Discard dashboard changes?', exact: true }).getByRole('button', { name: 'Keep editing', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
await expect(editor(page).getByRole('checkbox', { name: 'Application activity', exact: true })).not.toBeChecked();
|
||||
await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await page.getByRole('dialog', { name: 'Discard dashboard changes?', exact: true }).getByRole('button', { name: 'Discard changes', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Customize dashboard', exact: true })).toBeFocused();
|
||||
expect(await readLayout(page.request)).toEqual(before); expect(writes).toBe(0);
|
||||
await openEditor(page);
|
||||
await editor(page).getByRole('button', { name: 'Reset to default', exact: true }).click();
|
||||
await page.getByRole('dialog', { name: 'Reset to default', exact: true }).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked();
|
||||
await editor(page).getByRole('button', { name: 'Reset to default', exact: true }).click(); await confirm(page, 'Reset to default');
|
||||
for (const checkbox of await editor(page).getByRole('checkbox').all()) await expect(checkbox).toBeChecked();
|
||||
expect(await readLayout(page.request)).toEqual(before); expect(writes).toBe(0);
|
||||
await save(page);
|
||||
const reset = await readLayout(page.request);
|
||||
expect(reset.revision).toBe(before.revision + 1); expect(reset.widgets.map(widget => widget.id)).toEqual(ids);
|
||||
expect(reset.widgets.every(widget => widget.visible)).toBe(true); expect(writes).toBe(1);
|
||||
});
|
||||
|
||||
test('[P8] HTTP 503 and offline transport failure retain draft and support an acknowledged retry', async ({ page, account }, info) => {
|
||||
void account;
|
||||
const before = await readLayout(page.request);
|
||||
await openEditor(page);
|
||||
await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck();
|
||||
await editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true }).selectOption('full');
|
||||
let failure: '503' | 'offline' | null = '503';
|
||||
await page.route(layoutUrl, route => route.request().method() !== 'PUT' || !failure ? route.continue()
|
||||
: failure === '503' ? route.fulfill({ status: 503, json: { message: 'Controlled unavailable' } }) : route.abort('internetdisconnected'));
|
||||
for (const mode of ['503', 'offline'] as const) {
|
||||
failure = mode;
|
||||
const failed = mode === '503'
|
||||
? page.waitForResponse(response => response.url() === layoutUrl && response.status() === 503)
|
||||
: page.waitForEvent('requestfailed', request => request.url() === layoutUrl && request.method() === 'PUT');
|
||||
await editor(page).getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||
await failed;
|
||||
await expect(editor(page).getByRole('alert')).toContainText('Your layout could not be saved');
|
||||
await expect(editor(page).getByRole('button', { name: 'Save layout', exact: true })).toBeEnabled();
|
||||
await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked();
|
||||
await expect(editor(page).getByRole('combobox', { name: 'Size for Current pipeline', exact: true })).toHaveValue('full');
|
||||
expect(await readLayout(page.request)).toEqual(before);
|
||||
await capture(page, info, `draft-retained-${mode}`);
|
||||
}
|
||||
failure = null;
|
||||
let release!: () => void;
|
||||
const acknowledgement = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route(layoutUrl, async route => {
|
||||
if (route.request().method() === 'PUT') await acknowledgement;
|
||||
await route.continue();
|
||||
});
|
||||
const saving = save(page);
|
||||
try {
|
||||
await expect(editor(page).getByRole('button', { name: 'Saving layout…', exact: true })).toBeDisabled();
|
||||
await expect(editor(page).getByRole('button', { name: 'Cancel', exact: true })).toBeDisabled();
|
||||
for (const checkbox of await editor(page).getByRole('checkbox').all()) await expect(checkbox).toBeDisabled();
|
||||
for (const select of await editor(page).getByRole('combobox').all()) await expect(select).toBeDisabled();
|
||||
expect(await readLayout(page.request)).toEqual(before);
|
||||
} finally { release(); }
|
||||
await saving;
|
||||
const saved = await readLayout(page.request);
|
||||
expect(saved.revision).toBe(1); expect(saved.widgets.find(widget => widget.id === 'skills')?.visible).toBe(false);
|
||||
});
|
||||
|
||||
test('[P8] Two authenticated sessions cannot overwrite a stale revision and explicitly reload before saving', async ({ page, browser, account }, info) => {
|
||||
const second = await browser.newContext({ baseURL: 'http://localhost:3300' });
|
||||
try {
|
||||
await localOnly(second); await login(second.request, account.email);
|
||||
const other = await second.newPage(); await other.goto('/dashboard');
|
||||
await openEditor(page); await openEditor(other);
|
||||
await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck(); await save(page);
|
||||
const firstSaved = await readLayout(page.request);
|
||||
await editor(other).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck();
|
||||
const conflict = other.waitForResponse(response => response.url() === layoutUrl && response.request().method() === 'PUT');
|
||||
await editor(other).getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||
expect((await conflict).status()).toBe(409);
|
||||
await expect(editor(other).getByRole('button', { name: 'Reload latest layout', exact: true })).toBeVisible();
|
||||
expect(await readLayout(second.request)).toEqual(firstSaved);
|
||||
await capture(other, info, 'conflicting-layout-draft');
|
||||
await editor(other).getByRole('button', { name: 'Reload latest layout', exact: true }).click(); await confirm(other, 'Reload latest layout');
|
||||
await expect(editor(other).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked();
|
||||
await expect(editor(other).getByRole('checkbox', { name: 'Application activity', exact: true })).toBeChecked();
|
||||
await editor(other).getByRole('checkbox', { name: 'Application activity', exact: true }).uncheck(); await save(other);
|
||||
const final = await readLayout(page.request);
|
||||
expect(final.revision).toBe(2);
|
||||
expect(final.widgets.filter(widget => !widget.visible).map(widget => widget.id)).toEqual(['activity', 'skills']);
|
||||
} finally { await second.close(); }
|
||||
});
|
||||
|
||||
test('[P8] Anonymous requests fail, ordinary accounts stay independent and administrator data stays forbidden', async ({ page, playwright, account }) => {
|
||||
const anonymous = await playwright.request.newContext();
|
||||
const administrator = await playwright.request.newContext();
|
||||
const other = await playwright.request.newContext();
|
||||
try {
|
||||
expect((await anonymous.get(layoutUrl)).status()).toBe(401);
|
||||
expect((await anonymous.put(layoutUrl, { data: { version: 1, revision: 0, widgets: [] } })).status()).toBe(401);
|
||||
const adminHeaders = await login(administrator, 'e2e@example.test');
|
||||
const emailB = `phase8-b-${Date.now()}@example.test`;
|
||||
expect((await administrator.post(`${apiUrl}/users`, { headers: adminHeaders, data: { email: emailB, password, roles: [] } })).status()).toBe(200);
|
||||
const headersB = await login(other, emailB);
|
||||
const untouchedB = await readLayout(other);
|
||||
expect(untouchedB).toEqual({ version: 1, revision: 0, widgets: [] });
|
||||
await openEditor(page); await editor(page).getByRole('checkbox', { name: 'Job tags', exact: true }).uncheck(); await save(page);
|
||||
const savedA = await readLayout(page.request);
|
||||
expect(await readLayout(other)).toEqual(untouchedB);
|
||||
expect((await other.put(layoutUrl, { headers: headersB, data: { ...savedA, revision: 0, widgets: savedA.widgets.map(widget => ({ ...widget, visible: widget.id === 'cards' })) } })).status()).toBe(200);
|
||||
expect(await readLayout(page.request)).toEqual(savedA);
|
||||
expect((await page.request.get(`${apiUrl}/admin/system`)).status()).toBe(403);
|
||||
expect((await other.get(`${apiUrl}/admin/system`)).status()).toBe(403);
|
||||
await signOut(page); await signIn(page, emailB); await openEditor(page);
|
||||
await expect(editor(page).getByRole('checkbox', { name: 'Summary cards', exact: true })).toBeChecked();
|
||||
await expect(editor(page).getByRole('checkbox', { name: 'Current pipeline', exact: true })).not.toBeChecked();
|
||||
await expect(editor(page).getByRole('checkbox', { name: 'Job tags', exact: true })).not.toBeChecked();
|
||||
expect((await readLayout(page.request)).widgets.filter(widget => widget.visible).map(widget => widget.id)).toEqual(['cards']);
|
||||
await editor(page).getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
await signOut(page); await signIn(page, account.email);
|
||||
expect(await readLayout(page.request)).toEqual(savedA);
|
||||
} finally { await anonymous.dispose(); await administrator.dispose(); await other.dispose(); }
|
||||
});
|
||||
|
||||
test('[P8] Five legacy hidden flags migrate only on explicit save; all nine hidden have recovery', async ({ page, account }, info) => {
|
||||
void account;
|
||||
expect(await readLayout(page.request)).toEqual({ version: 1, revision: 0, widgets: [] });
|
||||
await page.evaluate(() => {
|
||||
const owner = localStorage.getItem('authUserKey');
|
||||
if (!owner || owner === 'anon') throw new Error('Expected authenticated per-account legacy owner');
|
||||
localStorage.setItem(`dashboardPrefs:${owner}`, JSON.stringify({ cards: false, activity: false, funnel: false, companies: false, skills: false }));
|
||||
});
|
||||
await page.reload(); await openEditor(page);
|
||||
for (const name of ['Summary cards', 'Application activity', 'Current pipeline', 'Top companies by activity', 'Job tags']) await expect(editor(page).getByRole('checkbox', { name, exact: true })).not.toBeChecked();
|
||||
expect(await readLayout(page.request)).toEqual({ version: 1, revision: 0, widgets: [] });
|
||||
await save(page);
|
||||
expect((await readLayout(page.request)).widgets.filter(widget => !widget.visible).map(widget => widget.id)).toEqual(['cards', 'funnel', 'activity', 'companies', 'skills']);
|
||||
await page.evaluate(() => { for (const key of Object.keys(localStorage)) if (key.startsWith('dashboardPrefs:')) localStorage.removeItem(key); });
|
||||
await page.reload(); await openEditor(page);
|
||||
await expect(editor(page).getByRole('checkbox', { name: 'Summary cards', exact: true })).not.toBeChecked();
|
||||
for (const checkbox of await editor(page).getByRole('checkbox').all()) await checkbox.uncheck();
|
||||
await save(page); await page.reload();
|
||||
await expect(page.getByText('Your dashboard sections are hidden.', { exact: true })).toBeVisible();
|
||||
await expect(page.locator('[data-dashboard-widget]')).toHaveCount(0);
|
||||
expect((await readLayout(page.request)).widgets.every(widget => !widget.visible)).toBe(true);
|
||||
await capture(page, info, 'all-nine-hidden');
|
||||
await openEditor(page); await editor(page).getByRole('checkbox', { name: 'Current pipeline', exact: true }).check(); await save(page);
|
||||
await expect(page.locator('[data-dashboard-widget="funnel"]')).toBeVisible();
|
||||
await expect(page.getByText('Your dashboard sections are hidden.', { exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('[P8] Editor reflows across EN/NB themes and desktop/tablet/mobile with native accessible controls', async ({ page, account }, info) => {
|
||||
test.setTimeout(180000);
|
||||
for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark'] as const) {
|
||||
const t = translations[language];
|
||||
await page.evaluate(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: account.headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.reload();
|
||||
await page.getByRole('button', { name: t.dashboardCustomize, exact: true }).click();
|
||||
const panel = page.getByRole('region', { name: t.dashboardEditing, exact: true });
|
||||
await expect(panel.getByRole('list', { name: t.dashboardManageWidgets })).toBeVisible();
|
||||
await expect(panel.getByRole('checkbox')).toHaveCount(9);
|
||||
await expect(panel.getByRole('combobox')).toHaveCount(9);
|
||||
await expect(panel.getByRole('combobox').first()).toBeDisabled();
|
||||
expect(await panel.getByRole('combobox').evaluateAll(nodes => nodes.every(node => node.tagName === 'SELECT'))).toBe(true);
|
||||
for (const [width, height] of [[1440, 900], [768, 1024], [390, 844], [320, 740]]) {
|
||||
await page.setViewportSize({ width, height });
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await capture(page, info, `editor-${language}-${mode}-${width}`);
|
||||
}
|
||||
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`editor-${language}-${mode}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete }), contentType: 'application/json' });
|
||||
expect(axe.violations).toEqual([]);
|
||||
expect(axe.incomplete.filter(result => result.id === 'aria-prohibited-attr')).toEqual([]);
|
||||
await panel.getByRole('button', { name: t.cancel, exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: t.dashboardCustomize, exact: true })).toBeFocused();
|
||||
}
|
||||
});
|
||||
|
||||
test('[P8] Keyboard move focus/live status, pointer drag and emulated touch buttons preserve DOM order', async ({ page, browser, account }, info) => {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
await openEditor(page);
|
||||
const earlier = editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true });
|
||||
await earlier.focus(); await page.keyboard.press('Enter');
|
||||
await expect(earlier).toBeFocused();
|
||||
await expect(editor(page).getByRole('status')).toHaveText('Current pipeline moved to position 2 of 9.');
|
||||
await expect.poll(() => editor(page).locator('[data-layout-row]').evaluateAll(nodes => nodes.map(node => node.getAttribute('data-layout-row')))).toEqual(['cards', 'funnel', 'attention', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']);
|
||||
await editor(page).getByRole('button', { name: 'Drag Current pipeline to reorder', exact: true }).dragTo(editor(page).locator('[data-layout-row="cards"]'));
|
||||
await expect(editor(page).getByRole('status')).toHaveText('Current pipeline moved to position 1 of 9.');
|
||||
await expect(editor(page).getByRole('button', { name: 'Move Current pipeline earlier', exact: true })).toBeDisabled();
|
||||
await save(page);
|
||||
expect((await readLayout(page.request)).widgets[0].id).toBe('funnel');
|
||||
const touch = await browser.newContext({ baseURL: 'http://localhost:3300', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true });
|
||||
try {
|
||||
await localOnly(touch); await login(touch.request, account.email);
|
||||
const mobile = await touch.newPage(); await mobile.goto('/dashboard');
|
||||
await mobile.getByRole('button', { name: 'Customize dashboard', exact: true }).tap();
|
||||
await editor(mobile).getByRole('button', { name: 'Move Current pipeline later', exact: true }).tap();
|
||||
await expect(editor(mobile).getByRole('status')).toHaveText('Current pipeline moved to position 2 of 9.');
|
||||
await save(mobile);
|
||||
expect((await readLayout(touch.request)).widgets.slice(0, 2).map(widget => widget.id)).toEqual(['cards', 'funnel']);
|
||||
await capture(mobile, info, 'emulated-touch-button-order');
|
||||
await info.attach('device-evidence-limit', { body: 'Chromium desktop pointer drag and emulated touch button input only. Physical touch, software keyboards and human screen reader acceptance remain untested. Axe incomplete items require review.', contentType: 'text/plain' });
|
||||
} finally { await touch.close(); }
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useBlocker, useBeforeUnload } from "react-router-dom";
|
||||
import { Alert, Box, Button, Checkbox, FormControlLabel, Stack, TextField, Typography } from "@mui/material";
|
||||
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
|
||||
import { DashboardLayout, defaultDashboardLayout, WIDGETS } from "../dashboardLayout";
|
||||
import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useConfirm } from "../confirm";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
|
||||
export function DashboardNavigationGuard({ dirty, saving }: { dirty: boolean; saving: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirm();
|
||||
const blocker = useBlocker(dirty || saving);
|
||||
const [waiting, setWaiting] = useState(false);
|
||||
const pending = useRef(false);
|
||||
useBeforeUnload(React.useCallback(event => {
|
||||
if (!dirty) return;
|
||||
event.preventDefault(); event.returnValue = "";
|
||||
}, [dirty]));
|
||||
useEffect(() => {
|
||||
if (blocker.state !== "blocked") { pending.current = false; return; }
|
||||
if (saving) { setWaiting(true); blocker.reset(); return; }
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
void confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), cancelLabel: t("workspaceKeepEditing"), destructive: true })
|
||||
.then(approved => { if (approved) blocker.proceed(); else blocker.reset(); });
|
||||
}, [blocker, confirm, saving, t]);
|
||||
return waiting && saving ? <Typography role="status">{t("dashboardWaitForSave")}</Typography> : null;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
draft: DashboardLayout;
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
saveError: string | null;
|
||||
conflict: boolean;
|
||||
onChange: (next: DashboardLayout) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onReload: () => void;
|
||||
};
|
||||
|
||||
export default function DashboardLayoutEditor({ draft, dirty, saving, saveError, conflict, onChange, onSave, onCancel, onReload }: Props) {
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirm();
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const pendingFocus = useRef<{ id: string; direction: number } | null>(null);
|
||||
const [announcement, announce] = useState("");
|
||||
useEffect(() => {
|
||||
panel.current?.focus({ preventScroll: true });
|
||||
panel.current?.scrollIntoView?.({ block: "start" });
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
const target = pendingFocus.current;
|
||||
if (!target) return;
|
||||
pendingFocus.current = null;
|
||||
const row = panel.current?.querySelector(`[data-layout-row="${target.id}"]`);
|
||||
const preferred = row?.querySelector<HTMLButtonElement>(`button[data-move="${target.direction}"]:not(:disabled)`);
|
||||
(preferred ?? row?.querySelector<HTMLButtonElement>("button[data-move]:not(:disabled)"))?.focus();
|
||||
}, [draft]);
|
||||
|
||||
const reorder = (from: number, to: number) => {
|
||||
if (saving || from === to || to < 0 || to >= draft.widgets.length) return;
|
||||
const widgets = [...draft.widgets];
|
||||
const [moved] = widgets.splice(from, 1); widgets.splice(to, 0, moved);
|
||||
onChange({ ...draft, widgets });
|
||||
const name = t(WIDGETS.find(widget => widget.id === moved.id)!.titleKey);
|
||||
announce(t("dashboardMoved", { name, position: to + 1, total: widgets.length }));
|
||||
};
|
||||
const drag = useDragReorder(reorder);
|
||||
const change = (id: string, patch: Partial<DashboardLayout["widgets"][number]>) => onChange({ ...draft, widgets: draft.widgets.map(widget => widget.id === id ? { ...widget, ...patch } : widget) });
|
||||
const cancel = async () => {
|
||||
if (!dirty || await confirm({ title: t("dashboardDiscardTitle"), message: t("dashboardDiscardBody"), confirmLabel: t("dashboardDiscard"), destructive: true })) onCancel();
|
||||
};
|
||||
const reset = async () => {
|
||||
if (await confirm({ title: t("dashboardReset"), message: t("dashboardResetBody"), confirmLabel: t("dashboardReset") })) onChange({ ...defaultDashboardLayout(), revision: draft.revision });
|
||||
};
|
||||
const reload = async () => {
|
||||
if (await confirm({ title: t("dashboardReload"), message: t("dashboardReloadBody"), confirmLabel: t("dashboardReload"), destructive: true })) onReload();
|
||||
};
|
||||
return <Box ref={panel} tabIndex={-1} role="group" aria-label={t("dashboardEditing")} sx={{ scrollMarginTop: "calc(var(--app-shell-header-height, 124px) + 16px)" }} onKeyDown={event => {
|
||||
if (event.key === "Escape" && !saving && !event.defaultPrevented) { event.preventDefault(); void cancel(); }
|
||||
}}>
|
||||
<PageSection surface title={t("dashboardEditing")} description={t("dashboardEditingBody")}>
|
||||
<Stack direction="row" useFlexGap flexWrap="wrap" spacing={1} sx={{ mb: 2 }}>
|
||||
<Button variant="contained" disabled={saving || conflict} onClick={onSave}>{t(saving ? "dashboardSaving" : "dashboardSave")}</Button>
|
||||
<Button disabled={saving} onClick={() => void cancel()}>{t("cancel")}</Button>
|
||||
<Button disabled={saving} onClick={() => void reset()}>{t("dashboardReset")}</Button>
|
||||
</Stack>
|
||||
{saveError ? <Alert severity="error" sx={{ mb: 2 }}>{t(conflict ? "dashboardConflict" : "dashboardSaveFailed")}
|
||||
{conflict ? <Button disabled={saving} onClick={() => void reload()}>{t("dashboardReload")}</Button> : null}
|
||||
</Alert> : null}
|
||||
<Typography role="status" aria-live="polite" variant="body2" sx={{ mb: 1 }}>{announcement || t(dirty ? "assetsUnsavedChanges" : "dashboardDraftSaved")}</Typography>
|
||||
<Box component="ol" aria-label={t("dashboardManageWidgets")} sx={{ listStyle: "none", m: 0, p: 0, display: "grid", gap: 1 }}>
|
||||
{draft.widgets.map((widget, index) => {
|
||||
const entry = WIDGETS.find(item => item.id === widget.id)!;
|
||||
const name = t(entry.titleKey);
|
||||
const gesture = drag.getItemProps(index);
|
||||
return <Box component="li" key={widget.id} data-layout-row={widget.id} onDragOver={saving ? undefined : gesture.onDragOver} onDrop={saving ? undefined : gesture.onDrop}
|
||||
sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1, p: 1, border: "1px solid", borderColor: drag.overIndex === index ? "primary.main" : "divider", borderRadius: 1, bgcolor: drag.overIndex === index ? "action.hover" : "background.paper", opacity: drag.dragIndex === index ? 0.6 : 1, minWidth: 0 }}>
|
||||
<Button {...gesture} disabled={saving} draggable={!saving} aria-label={t("dashboardDrag", { name })} aria-describedby="dashboard-drag-help" sx={{ minWidth: 44, minHeight: 44, cursor: "grab" }}><DragIndicatorIcon /></Button>
|
||||
<FormControlLabel sx={{ flex: "1 1 14rem", minWidth: 0, mr: 0, overflowWrap: "anywhere" }} control={<Checkbox checked={widget.visible} disabled={saving} onChange={event => change(widget.id, { visible: event.target.checked })} />} label={name} />
|
||||
<Stack direction="row" spacing={0.5} useFlexGap flexWrap="wrap">
|
||||
{([-1, 1] as const).map(direction => <Button key={direction} data-move={direction} disabled={saving || index + direction < 0 || index + direction >= draft.widgets.length}
|
||||
aria-label={t(direction === -1 ? "dashboardMoveEarlierNamed" : "dashboardMoveLaterNamed", { name })}
|
||||
onClick={() => { pendingFocus.current = { id: widget.id, direction }; reorder(index, index + direction); }} sx={{ minHeight: 44 }}>{t(direction === -1 ? "dashboardEarlier" : "dashboardLater")}</Button>)}
|
||||
</Stack>
|
||||
<TextField select size="small" label={t("dashboardSize")} value={widget.size} disabled={saving || entry.sizes.length === 1}
|
||||
onChange={event => change(widget.id, { size: event.target.value as "standard" | "full" })}
|
||||
slotProps={{ select: { native: true }, htmlInput: { "aria-label": t("dashboardSizeFor", { name }) } }} sx={{ width: { xs: "100%", sm: 190 }, minWidth: 0, "& select": { minHeight: 28 } }}>
|
||||
{entry.sizes.map(size => <option key={size} value={size}>{t(size === "full" ? "dashboardSizeFull" : "dashboardSizeStandard")}</option>)}
|
||||
</TextField>
|
||||
</Box>;
|
||||
})}
|
||||
</Box>
|
||||
<Typography id="dashboard-drag-help" variant="body2" color="text.secondary" sx={{ mt: 2 }}>{t("dashboardReorderHelp")}</Typography>
|
||||
</PageSection>
|
||||
</Box>;
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { Link as RouterLink, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import React, { useCallback, useContext, useMemo, useRef, useState } from "react";
|
||||
import { Link as RouterLink, useNavigate, useSearchParams, UNSAFE_DataRouterContext } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Menu,
|
||||
MenuItem,
|
||||
Alert,
|
||||
Stack,
|
||||
Link,
|
||||
Table, TableBody, TableCell, TableHead, TableRow,
|
||||
@@ -13,13 +12,14 @@ import {
|
||||
} from "@mui/material";
|
||||
import { useTheme } from "@mui/material/styles";
|
||||
import TuneIcon from "@mui/icons-material/Tune";
|
||||
import CheckBoxIcon from "@mui/icons-material/CheckBox";
|
||||
import CheckBoxOutlineBlankIcon from "@mui/icons-material/CheckBoxOutlineBlank";
|
||||
|
||||
import { api } from "../api";
|
||||
import ViewStateNotice from "./ViewStateNotice";
|
||||
import OnboardingChecklist from "./OnboardingChecklist";
|
||||
import { getUserKeyFromToken } from "../themePrefs";
|
||||
import { WIDGETS, defaultDashboardLayout, WidgetId } from "../dashboardLayout";
|
||||
import { useDashboardLayout } from "../hooks/useDashboardLayout";
|
||||
import DashboardLayoutEditor, { DashboardNavigationGuard } from "./DashboardLayoutEditor";
|
||||
import { useOptionalToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { statusLabel, statusTone } from "../pipeline";
|
||||
import { PageSection } from "../layout/PageLayout";
|
||||
@@ -52,32 +52,6 @@ type OverviewAnalytics = {
|
||||
};
|
||||
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
|
||||
|
||||
type Prefs = {
|
||||
cards: boolean;
|
||||
activity: boolean;
|
||||
funnel: boolean;
|
||||
companies: boolean;
|
||||
skills: boolean;
|
||||
};
|
||||
|
||||
function prefsKey() {
|
||||
return `dashboardPrefs:${getUserKeyFromToken()}`;
|
||||
}
|
||||
|
||||
function loadPrefs(): Prefs {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(prefsKey());
|
||||
if (!raw) return { cards: true, activity: true, funnel: true, companies: true, skills: true };
|
||||
return { cards: true, activity: true, funnel: true, companies: true, skills: true, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return { cards: true, activity: true, funnel: true, companies: true, skills: true };
|
||||
}
|
||||
}
|
||||
|
||||
function savePrefs(next: Prefs) {
|
||||
window.localStorage.setItem(prefsKey(), JSON.stringify(next));
|
||||
}
|
||||
|
||||
// "Needs your attention" row accent -- red for overdue follow-ups, amber for interview prep,
|
||||
// blue/info for everything else (package work, readiness review). No "positive/offer" tier
|
||||
// exists in the real reminder feed (see DashboardView report), so only these three appear.
|
||||
@@ -103,9 +77,21 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
next.set("months", String(value));
|
||||
setQuery(next);
|
||||
};
|
||||
const [dashboardPrefs, setPrefs] = useState<Prefs>(() => reporting ? { cards: true, activity: true, funnel: true, companies: true, skills: true } : loadPrefs());
|
||||
const prefs = reporting ? { cards: true, activity: true, funnel: true, companies: true, skills: true } : dashboardPrefs;
|
||||
const [prefsAnchor, setPrefsAnchor] = useState<HTMLElement | null>(null);
|
||||
const layoutState = useDashboardLayout(!reporting);
|
||||
const activeLayout = reporting ? defaultDashboardLayout() : layoutState.draft ?? layoutState.layout;
|
||||
const prefs = Object.fromEntries(activeLayout.widgets.map(widget => [widget.id, widget.visible]));
|
||||
const dataRouter = useContext(UNSAFE_DataRouterContext);
|
||||
const customizeButton = useRef<HTMLButtonElement>(null);
|
||||
const optionalToast = useOptionalToast();
|
||||
const [savedMessage, setSavedMessage] = useState(false);
|
||||
const cancelLayout = () => { layoutState.cancelEditing(); requestAnimationFrame(() => customizeButton.current?.focus()); };
|
||||
const saveLayout = async () => {
|
||||
if (await layoutState.save()) {
|
||||
setSavedMessage(true);
|
||||
optionalToast?.toast(t("dashboardSaved"), "success");
|
||||
requestAnimationFrame(() => customizeButton.current?.focus());
|
||||
}
|
||||
};
|
||||
const loadSummary = useCallback(async () => {
|
||||
const [statsResponse, overviewResponse, remindersResponse] = await Promise.all([
|
||||
api.get<JobStats>("/jobapplications/stats"),
|
||||
@@ -177,14 +163,6 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
const monthSpan = analytics.length ? `${monthLabel(analytics[0].month)} – ${monthLabel(analytics[analytics.length - 1].month)}` : t("dashboardNoWindowData");
|
||||
const stageCount = (stage: string) => overview?.funnel.find(item => item.label === stage)?.count ?? 0;
|
||||
|
||||
const togglePref = useCallback((key: keyof Prefs) => {
|
||||
setPrefs(current => {
|
||||
const next = { ...current, [key]: !current[key] };
|
||||
savePrefs(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Both series share a zero baseline and maximum; independent scaling implies false equality.
|
||||
const chart = useMemo(() => {
|
||||
const max = Math.max(1, ...analytics.flatMap(point => [point.applied, point.responses]));
|
||||
@@ -197,7 +175,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
<Box sx={{ width: `${Math.max(0, Math.min(100, value / Math.max(1, max) * 100))}%`, height: "100%", bgcolor: color, borderRadius: 1 }} />
|
||||
</Box>;
|
||||
const detailsSx = { mt: 2, "& summary": { cursor: "pointer", color: "text.primary", fontWeight: 600, py: 1 }, "& th, & td": { px: 1, py: 0.75, overflowWrap: "anywhere" } };
|
||||
const prefsButton = <Button variant="outlined" startIcon={<TuneIcon />} aria-haspopup="menu" aria-expanded={Boolean(prefsAnchor)} onClick={event => setPrefsAnchor(event.currentTarget)}>{t("dashboardCustomize")}</Button>;
|
||||
const prefsButton = !layoutState.editing ? <Button ref={customizeButton} variant="outlined" startIcon={<TuneIcon />} disabled={layoutState.loading || Boolean(layoutState.loadError)} onClick={() => { setSavedMessage(false); layoutState.startEditing(); }}>{t("dashboardCustomize")}</Button> : null;
|
||||
const pipelineReport = <PageSection surface title={t("dashboardCurrentPipeline")} description={t("dashboardPipelineBody")}>
|
||||
<Box sx={reporting ? grid : undefined}>
|
||||
<Stack spacing={1.5}>
|
||||
@@ -219,35 +197,8 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
</Box>
|
||||
</PageSection>;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
|
||||
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
|
||||
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
|
||||
</Box>
|
||||
<Menu anchorEl={prefsAnchor} open={Boolean(prefsAnchor)} onClose={() => setPrefsAnchor(null)}>
|
||||
{([
|
||||
["cards", "dashboardSummaryCards"], ["activity", "dashboardActivityChart"],
|
||||
["funnel", "dashboardConversionFunnel"], ["companies", "dashboardTopCompanies"], ["skills", "dashboardSkillsInsights"],
|
||||
] as const).map(([key, label]) => (
|
||||
<MenuItem key={key} role="menuitemcheckbox" aria-checked={prefs[key]} onClick={() => togglePref(key)}>
|
||||
{prefs[key] ? <CheckBoxIcon aria-hidden sx={{ m: 1, color: "primary.main" }} /> : <CheckBoxOutlineBlankIcon aria-hidden sx={{ m: 1, color: "text.secondary" }} />}
|
||||
{t(label)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
<ViewStateNotice loading={summaryResource.loading || summaryResource.refreshing} error={summaryResource.error}
|
||||
title={t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle")} description={t(reporting ? "analyticsSnapshotUnavailableBody" : "dashboardSummaryUnavailableBody")} onRetry={summaryResource.reload} />
|
||||
{summaryReady && !hasJobs ? <PageSection title={t(reporting ? "analyticsEmptyTitle" : "dashboardStartTitle")} description={t(reporting ? "analyticsEmptyBody" : "dashboardStartBody")}
|
||||
actions={<Button component={RouterLink} to="/jobs" variant="contained">{t("onboardingStepJobAction")}</Button>}>
|
||||
<Link component={RouterLink} to="/career">{t("careerWorkspaceProfileTab")}</Link>
|
||||
</PageSection> : null}
|
||||
{!reporting && allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
|
||||
|
||||
{!reporting && summaryReady && hasJobs && prefs.cards ? (
|
||||
<Box component="dl" aria-label={t("dashboardSummaryCards")} sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, borderBlock: "1px solid", borderColor: "divider", py: 1, gap: 1 }}>
|
||||
const reports: Record<WidgetId, React.ReactNode> = {
|
||||
cards: summaryReady && hasJobs ? <Box component="dl" aria-label={t("dashboardSummaryCards")} sx={{ m: 0, display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", lg: "repeat(4, minmax(0, 1fr))" }, borderBlock: "1px solid", borderColor: "divider", py: 1, gap: 1 }}>
|
||||
{[
|
||||
{ label: t("dashboardTrackedJobs"), value: stats!.active, caption: t("dashboardTrackedBody"), to: "/jobs" },
|
||||
{ label: t("dashboardApplied30Days"), value: stats!.appliedLast30Days, caption: t("dashboardRecordedDates") },
|
||||
@@ -260,11 +211,8 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
</Typography>
|
||||
<Typography component="dd" variant="caption" color="text.secondary" sx={{ m: 0 }}>{item.caption}</Typography>
|
||||
</Box>)}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{!reporting && summaryReady && hasJobs ? <Box sx={grid}>
|
||||
{!reporting ? <PageSection title={t("dashboardTodayTitle")} description={t("dashboardTodayBody")} actions={<Button component={RouterLink} to="/reminders">{t("reminders")}</Button>}>
|
||||
</Box> : null,
|
||||
attention: summaryReady && hasJobs ? <PageSection title={t("dashboardTodayTitle")} description={t("dashboardTodayBody")} actions={<Button component={RouterLink} to="/reminders">{t("reminders")}</Button>}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardAttentionSample", { count: number(reminderJobs.length), shown: number(Math.min(5, reminderJobs.length)) })}</Typography>
|
||||
<Typography variant="caption" component="p" color="text.secondary" sx={{ mb: 1.5 }}>{t("dashboardAttentionBreakdown", {
|
||||
followUp: number(reminderJobs.filter(job => job.workflowSignal?.actionKey === "follow-up").length),
|
||||
@@ -285,20 +233,9 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
})}
|
||||
{!reminderJobs.length ? <Typography color="text.secondary">{t("remindersNothing")}</Typography> : null}
|
||||
</Stack>
|
||||
</PageSection> : null}
|
||||
{prefs.funnel ? pipelineReport : null}
|
||||
</Box> : null}
|
||||
|
||||
<ViewStateNotice loading={trendsResource.loading || trendsResource.refreshing} error={trendsResource.error}
|
||||
title={t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
|
||||
|
||||
{/* Period affects only activity and tag trends, not the current summary or all-time tags. */}
|
||||
{(reporting || hasJobs || hasActivity || tags.length > 0) && (prefs.activity || prefs.skills) ? <Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }} role="group" aria-label={t("dashboardTrendPeriod")}>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>{t("dashboardTrendPeriod")}</Typography>
|
||||
{([6, 12, 24] as const).map(value => <Button key={value} size="small" aria-pressed={months === value} variant={months === value ? "contained" : "outlined"} onClick={() => setMonths(value)}>{t("dashboardMonthsShort", { count: value })}</Button>)}
|
||||
</Box> : null}
|
||||
|
||||
{trendsReady && (reporting || hasJobs || hasActivity) && prefs.activity ? <PageSection surface title={t("dashboardApplicationActivity")} description={t("dashboardActivityDefinition")}>
|
||||
</PageSection> : null,
|
||||
funnel: summaryReady && hasJobs ? pipelineReport : null,
|
||||
activity: trendsReady && (reporting || hasJobs || hasActivity) ? <PageSection surface title={t("dashboardApplicationActivity")} description={t("dashboardActivityDefinition")}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
|
||||
<Typography variant="body2" color="text.secondary">{monthSpan}</Typography>
|
||||
{totalApplied > 0 ? <Typography variant="body2">{t("dashboardResponseSample", { responses: number(totalResponses), total: number(totalApplied) })} ({number(totalResponses / totalApplied * 100)}%)</Typography> : null}
|
||||
@@ -322,26 +259,21 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
<TableBody>{analytics.map(point => <TableRow key={point.month}><TableCell component="th" scope="row">{monthLabel(point.month)}</TableCell><TableCell>{number(point.applied)}</TableCell><TableCell>{number(point.responses)}</TableCell></TableRow>)}</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
</PageSection> : null}
|
||||
|
||||
{reporting && summaryReady && hasJobs ? pipelineReport : null}
|
||||
{summaryReady && hasJobs ? <Box sx={grid}>
|
||||
{(overview?.timeInStage?.length ?? 0) > 0 ? <PageSection title={t("dashboardStageAge")} description={t("dashboardStageAgeBody")}>
|
||||
</PageSection> : null,
|
||||
stageAge: summaryReady && hasJobs && (overview?.timeInStage?.length ?? 0) > 0 ? <PageSection title={t("dashboardStageAge")} description={t("dashboardStageAgeBody")}>
|
||||
<Stack spacing={1.5}>{overview!.timeInStage!.map(item => <Box key={item.stage}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, flexWrap: "wrap" }}><Typography variant="body2">{statusLabel(t, item.stage)}</Typography><Typography variant="body2">{t("dashboardTimeInStageValue", { days: number(item.medianDays), count: number(item.count) })}</Typography></Box>
|
||||
{bar(item.medianDays, Math.max(1, ...overview!.timeInStage!.map(stage => stage.medianDays)), "info.main")}
|
||||
</Box>)}</Stack>
|
||||
</PageSection> : null}
|
||||
{(overview?.responseRateBySource.length ?? 0) > 0 ? <PageSection title={t("dashboardResponseSources")} description={t("dashboardSourceBody")}>
|
||||
</PageSection> : null,
|
||||
sources: summaryReady && hasJobs && (overview?.responseRateBySource.length ?? 0) > 0 ? <PageSection title={t("dashboardResponseSources")} description={t("dashboardSourceBody")}>
|
||||
<Stack spacing={1.5}>{overview!.responseRateBySource.map(item => <Box key={item.label}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{item.label}</Typography><Typography variant="body2" sx={{ fontWeight: 700 }}>{number(item.rate)}%</Typography></Box>
|
||||
<Typography variant="caption" color="text.secondary">{t("dashboardRecordedResponseSample", { responses: number(item.responses), total: number(item.total) })}</Typography>
|
||||
</Box>)}</Stack>
|
||||
</PageSection> : null}
|
||||
</Box> : null}
|
||||
|
||||
{summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
|
||||
</PageSection> : null,
|
||||
salary: summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: reporting || activeLayout.widgets.find(widget => widget.id === "salary")?.size === "full" ? "repeat(3, minmax(0, 1fr))" : "1fr" }, gap: 2 }}>
|
||||
{overview!.salaryInsights!.map(item => {
|
||||
const money = new Intl.NumberFormat(locale, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
|
||||
return <Box key={item.currency + item.period} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||
@@ -351,17 +283,15 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
</Box>;
|
||||
})}
|
||||
</Box>
|
||||
</PageSection> : null}
|
||||
|
||||
<Box sx={grid}>
|
||||
{summaryReady && hasJobs && prefs.companies ? <PageSection title={t("dashboardTopCompaniesByActivity")} description={t("dashboardCompaniesBody")}>
|
||||
</PageSection> : null,
|
||||
companies: summaryReady && hasJobs ? <PageSection title={t("dashboardTopCompaniesByActivity")} description={t("dashboardCompaniesBody")}>
|
||||
<Stack spacing={1.5}>{(overview?.topCompanies ?? []).map(item => <Box key={item.companyId} sx={{ borderBottom: "1px solid", borderColor: "divider", pb: 1.5 }}>
|
||||
<Link component={RouterLink} to={`/jobs?companyId=${item.companyId}`} sx={{ fontWeight: 600, overflowWrap: "anywhere" }}>{item.company}</Link>
|
||||
<Typography variant="body2" color="text.secondary">{t("dashboardCompanyJobsResponses", { jobs: number(item.count), responses: number(item.responses) })} ({number(item.responseRate)}%)</Typography>
|
||||
</Box>)}</Stack>
|
||||
{!overview?.topCompanies.length ? <Typography color="text.secondary">{t("companiesEmpty")}</Typography> : null}
|
||||
</PageSection> : null}
|
||||
{trendsReady && (reporting || hasJobs || tags.length > 0) && prefs.skills ? <PageSection title={t("dashboardJobTags")} description={t(reporting ? "analyticsTagsBody" : "dashboardTagsBody")}>
|
||||
</PageSection> : null,
|
||||
skills: trendsReady && (reporting || hasJobs || tags.length > 0) ? <PageSection title={t("dashboardJobTags")} description={t(reporting ? "analyticsTagsBody" : "dashboardTagsBody")}>
|
||||
<Stack spacing={1.5}>{tags.slice(0, reporting ? 10 : 8).map(tag => <Box key={tag.tag}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1 }}><Typography variant="body2" sx={{ overflowWrap: "anywhere" }}>{tag.tag}</Typography><Typography variant="body2">{number(tag.count)}</Typography></Box>
|
||||
{bar(tag.count, Math.max(1, ...tags.map(item => item.count)), "primary.main")}
|
||||
@@ -388,8 +318,61 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
||||
</Table>
|
||||
</Box>)}
|
||||
</Box>
|
||||
</PageSection> : null}
|
||||
</PageSection> : null,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "grid", gap: 3, minWidth: 0, "& p, & h2, & h3": { overflowWrap: "anywhere" } }}>
|
||||
{!reporting ? <OnboardingChecklist hasJobs={summaryReady ? hasJobs : null} /> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Typography variant="body2" color="text.secondary">{t(reporting ? "analyticsScope" : "dashboardSnapshotScope")}</Typography>
|
||||
{reporting ? <Button component={RouterLink} to="/dashboard">{t("goToDashboard")}</Button> : prefsButton}
|
||||
</Box>
|
||||
{!reporting && layoutState.editing && layoutState.draft ? <>
|
||||
<DashboardLayoutEditor draft={layoutState.draft} dirty={layoutState.dirty} saving={layoutState.saving || layoutState.loading}
|
||||
saveError={layoutState.saveError} conflict={layoutState.conflict} onChange={layoutState.setDraft}
|
||||
onSave={() => void saveLayout()} onCancel={cancelLayout} onReload={() => void layoutState.reload()} />
|
||||
{dataRouter ? <DashboardNavigationGuard dirty={layoutState.dirty} saving={layoutState.saving} /> : null}
|
||||
</> : null}
|
||||
{!reporting && layoutState.loadError ? <Alert severity="warning" action={<Button onClick={() => void layoutState.reload()}>{t("retry")}</Button>}>
|
||||
{t(layoutState.loadError === "unsupportedVersion" ? "dashboardUnsupportedVersion" : "dashboardLoadFailed")}
|
||||
</Alert> : null}
|
||||
{!reporting && savedMessage ? <Typography role="status">{t("dashboardSaved")}</Typography> : null}
|
||||
|
||||
|
||||
<ViewStateNotice loading={summaryResource.loading || summaryResource.refreshing} error={summaryResource.error}
|
||||
title={t(reporting ? "analyticsSnapshotUnavailableTitle" : "dashboardSummaryUnavailableTitle")} description={t(reporting ? "analyticsSnapshotUnavailableBody" : "dashboardSummaryUnavailableBody")} onRetry={summaryResource.reload} />
|
||||
{summaryReady && !hasJobs ? <PageSection title={t(reporting ? "analyticsEmptyTitle" : "dashboardStartTitle")} description={t(reporting ? "analyticsEmptyBody" : "dashboardStartBody")}
|
||||
actions={<Button component={RouterLink} to="/jobs" variant="contained">{t("onboardingStepJobAction")}</Button>}>
|
||||
<Link component={RouterLink} to="/career">{t("careerWorkspaceProfileTab")}</Link>
|
||||
</PageSection> : null}
|
||||
{!reporting && allHidden ? <Typography role="status" color="text.secondary">{t("dashboardAllHidden")}</Typography> : null}
|
||||
|
||||
<ViewStateNotice loading={trendsResource.loading || trendsResource.refreshing} error={trendsResource.error}
|
||||
title={t(reporting ? "analyticsTrendsUnavailableTitle" : "dashboardTrendsUnavailableTitle")} description={t("dashboardTrendsUnavailableBody")} onRetry={trendsResource.reload} compact />
|
||||
{/* Period affects only activity and tag trends, not the current summary or all-time tags. */}
|
||||
{(reporting || hasJobs || hasActivity || tags.length > 0) && (prefs.activity || prefs.skills) ? <Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }} role="group" aria-label={t("dashboardTrendPeriod")}>
|
||||
<Typography variant="body2" sx={{ mr: 1 }}>{t("dashboardTrendPeriod")}</Typography>
|
||||
{([6, 12, 24] as const).map(value => <Button key={value} size="small" aria-pressed={months === value} variant={months === value ? "contained" : "outlined"} onClick={() => setMonths(value)}>{t("dashboardMonthsShort", { count: value })}</Button>)}
|
||||
</Box> : null}
|
||||
|
||||
|
||||
{reporting ? <>
|
||||
{reports.activity}
|
||||
{reports.funnel}
|
||||
{summaryReady && hasJobs ? <Box sx={grid}>{reports.stageAge}{reports.sources}</Box> : null}
|
||||
{reports.salary}
|
||||
<Box sx={grid}>{reports.companies}{reports.skills}</Box>
|
||||
</> : <Box role={layoutState.editing ? "region" : undefined} aria-label={layoutState.editing ? t("dashboardPreview") : undefined} sx={{ display: "grid", gridTemplateColumns: { xs: "minmax(0, 1fr)", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "start" }}>
|
||||
{activeLayout.widgets.filter(widget => widget.visible).map(widget => {
|
||||
const content = reports[widget.id];
|
||||
const ready = widget.id === "activity" || widget.id === "skills" ? trendsReady : summaryReady;
|
||||
if (!content && !(ready && (hasJobs || layoutState.editing))) return null;
|
||||
return <Box key={widget.id} data-dashboard-widget={widget.id} data-widget-size={widget.size} sx={{ minWidth: 0, gridColumn: { xs: "auto", md: widget.size === "full" ? "1 / -1" : "auto" } }}>
|
||||
{content ?? <PageSection title={t(WIDGETS.find(item => item.id === widget.id)!.titleKey)}><Typography color="text.secondary">{t("dashboardNoSample")}</Typography></PageSection>}
|
||||
</Box>;
|
||||
})}
|
||||
</Box>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import DashboardView from './components/DashboardView';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
import { api } from './api';
|
||||
|
||||
const mocked = api as jest.Mocked<typeof api>;
|
||||
let stored: any;
|
||||
const stats = { total: 2, active: 2, byStatus: { Applied: 2 }, appliedLast30Days: 2 };
|
||||
function response(url: string) {
|
||||
if (url.endsWith('/preferences/dashboard')) return stored;
|
||||
if (url.endsWith('/stats')) return stats;
|
||||
if (url.endsWith('/analytics-overview')) return { funnel: [{ label: 'Applied', count: 2 }], totalActive: 2, totalResponses: 0, responseRateBySource: [], topCompanies: [], timeInStage: [], salaryInsights: [] };
|
||||
if (url.endsWith('/tag-trends')) return { months: [], series: [] };
|
||||
return [];
|
||||
}
|
||||
function open() {
|
||||
const router = createMemoryRouter([{ path: '/dashboard', element: <DashboardView /> }, { path: '/jobs', element: <div>Jobs destination</div> }], { initialEntries: ['/dashboard'] });
|
||||
return render(<I18nProvider><ConfirmProvider><RouterProvider router={router} /></ConfirmProvider></I18nProvider>);
|
||||
}
|
||||
beforeEach(() => {
|
||||
localStorage.clear(); stored = { version: 1, revision: 0, widgets: [] };
|
||||
mocked.get.mockImplementation((url: string) => Promise.resolve({ data: response(url) }) as any);
|
||||
mocked.put.mockReset(); mocked.put.mockImplementation((_url: string, body: any) => { stored = { ...body, revision: body.revision + 1 }; return Promise.resolve({ data: stored }) as any; });
|
||||
});
|
||||
async function edit() { fireEvent.click(await screen.findByRole('button', { name: 'Customize dashboard' })); return screen.findByRole('region', { name: 'Editing dashboard' }); }
|
||||
|
||||
test('visibility is a preview; cancel restores the saved layout without a write', async () => {
|
||||
open(); await screen.findByText('Tracked jobs');
|
||||
const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Summary cards' }));
|
||||
expect(screen.getByRole('region', { name: 'Dashboard preview' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: 'Cancel' }));
|
||||
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Discard changes' }));
|
||||
await screen.findByText('Tracked jobs'); expect(mocked.put).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Customize dashboard' })).toHaveFocus());
|
||||
});
|
||||
|
||||
test('failed save keeps the draft and retry persists visibility, order and size', async () => {
|
||||
open(); const editor = await edit();
|
||||
fireEvent.click(within(editor).getByRole('checkbox', { name: 'Summary cards' }));
|
||||
fireEvent.click(within(editor).getByRole('button', { name: 'Move Today earlier' }));
|
||||
fireEvent.change(within(editor).getByLabelText('Size for Today'), { target: { value: 'full' } });
|
||||
mocked.put.mockRejectedValueOnce(new Error('offline'));
|
||||
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
|
||||
await screen.findByText('Your layout could not be saved. Your changes are still here. Try again.');
|
||||
expect(within(editor).getByRole('checkbox', { name: 'Summary cards' })).not.toBeChecked();
|
||||
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Editing dashboard' })).not.toBeInTheDocument());
|
||||
expect(stored.widgets[0]).toEqual({ id: 'attention', visible: true, size: 'full' });
|
||||
expect(stored.widgets.find((widget: any) => widget.id === 'cards').visible).toBe(false);
|
||||
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('all hidden widgets leave an accessible recovery and reset remains cancellable', async () => {
|
||||
open(); const editor = await edit();
|
||||
for (const checkbox of within(editor).getAllByRole('checkbox')) fireEvent.click(checkbox);
|
||||
await screen.findByText('Your dashboard sections are hidden.');
|
||||
fireEvent.click(within(editor).getByRole('button', { name: 'Reset to default' }));
|
||||
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Reset to default' }));
|
||||
await waitFor(() => expect(within(editor).getAllByRole('checkbox').every(item => (item as HTMLInputElement).checked)).toBe(true));
|
||||
expect(mocked.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('keyboard ordering keeps focus and announces the resulting position without refetching reports', async () => {
|
||||
open(); const editor = await edit(); const before = mocked.get.mock.calls.length;
|
||||
const move = within(editor).getByRole('button', { name: 'Move Today earlier' }); move.focus(); fireEvent.click(move);
|
||||
await waitFor(() => expect(within(editor).getByRole('button', { name: 'Move Today later' })).toHaveFocus());
|
||||
expect(screen.getByText('Today moved to position 1 of 9.')).toBeInTheDocument();
|
||||
expect(mocked.get.mock.calls).toHaveLength(before);
|
||||
});
|
||||
|
||||
test('dirty route navigation uses the existing confirmation and can stay on the dashboard', async () => {
|
||||
open(); const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Job tags' }));
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Tracked jobs: 2' }));
|
||||
const dialog = await screen.findByRole('dialog'); fireEvent.click(within(dialog).getByRole('button', { name: 'Keep editing' }));
|
||||
expect(await screen.findByRole('region', { name: 'Editing dashboard' })).toBeVisible();
|
||||
expect(mocked.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('navigation waits for an in-flight save instead of promising to discard a possible commit', async () => {
|
||||
open(); const editor = await edit(); fireEvent.click(within(editor).getByRole('checkbox', { name: 'Job tags' }));
|
||||
let complete!: (value: any) => void;
|
||||
mocked.put.mockImplementationOnce((_url: string, body: any) => new Promise(resolve => { complete = resolve; stored = { ...body, revision: 1 }; }) as any);
|
||||
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Tracked jobs: 2' }));
|
||||
expect(await screen.findByText('Please wait for your layout to finish saving before leaving.')).toBeVisible();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Jobs destination')).not.toBeInTheDocument();
|
||||
await act(async () => { complete({ data: stored }); });
|
||||
expect(await screen.findByRole('button', { name: 'Customize dashboard' })).toBeVisible();
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import DashboardView from './components/DashboardView';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import { api } from './api';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
|
||||
jest.mock('./components/OnboardingChecklist', () => ({ hasJobs }: { hasJobs: boolean | null }) => <><div>Setup retained</div><output aria-label="Setup job status">{hasJobs === null ? 'unknown' : String(hasJobs)}</output></>);
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
@@ -12,8 +13,10 @@ const stats = { total: 9, active: 8, deleted: 1, byStatus: { Applied: 3, Intervi
|
||||
const overview = { funnel: [{ label: 'Applied', count: 3 }, { label: 'Interview', count: 2 }, { label: 'Offer', count: 1 }], totalActive: 8, totalResponses: 3, responseRateBySource: [{ label: 'NAV', total: 8, responses: 3, rate: 37.5 }], topCompanies: [{ companyId: 7, company: 'Nordlys AS', count: 8, responses: 3, responseRate: 37.5 }], timeInStage: [{ stage: 'Applied', medianDays: 4, count: 3 }], salaryInsights: [{ currency: 'NOK', period: 'year', count: 2, minimum: 600000, maximum: 800000, averageMidpoint: 700000 }] };
|
||||
const monthly = [{ month: '2026-08', applied: 4, responses: 1 }, { month: '2026-09', applied: 2, responses: 1 }];
|
||||
function Location() { return <output aria-label="Destination">{useLocation().pathname}{useLocation().search}</output>; }
|
||||
function open() { return render(<I18nProvider><MemoryRouter><Location /><DashboardView /></MemoryRouter></I18nProvider>); }
|
||||
function open() { return render(<I18nProvider><ConfirmProvider><MemoryRouter><Location /><DashboardView /></MemoryRouter></ConfirmProvider></I18nProvider>); }
|
||||
let layout: any;
|
||||
function data(url: string) {
|
||||
if (url.endsWith('/preferences/dashboard')) return layout;
|
||||
if (url.endsWith('/stats')) return stats;
|
||||
if (url.endsWith('/analytics-overview')) return overview;
|
||||
if (url.endsWith('/analytics')) return monthly;
|
||||
@@ -21,7 +24,11 @@ function data(url: string) {
|
||||
if (url.endsWith('/tag-trends')) return { months: ['2026-08', '2026-09'], series: [{ tag: 'TypeScript', counts: [3, 2] }] };
|
||||
return [];
|
||||
}
|
||||
beforeEach(() => { localStorage.clear(); mockedApi.get.mockReset(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: data(url) }) as any); });
|
||||
beforeEach(() => {
|
||||
localStorage.clear(); layout = { version: 1, revision: 0, widgets: [] };
|
||||
mockedApi.get.mockReset(); mockedApi.get.mockImplementation((url: string) => Promise.resolve({ data: data(url) }) as any);
|
||||
mockedApi.put.mockImplementation((_url: string, body: any) => { layout = { ...body, revision: body.revision + 1 }; return Promise.resolve({ data: layout }) as any; });
|
||||
});
|
||||
|
||||
test('[PRES-S01] exact monthly values, sample, custom statuses and separate salary group remain readable', async () => {
|
||||
open();
|
||||
@@ -37,11 +44,14 @@ test('[PRES-S01] exact monthly values, sample, custom statuses and separate sala
|
||||
expect(screen.queryByText(/upcoming|conversion funnel|active applications/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('[PRES-S02] all five stored visibility flags hide their groups and survive remount', async () => {
|
||||
test('[PRES-S02] all widget visibility choices save and survive remount', async () => {
|
||||
let view = open(); await screen.findByText('Tracked jobs');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Customize dashboard' }));
|
||||
for (const item of screen.getAllByRole('menuitemcheckbox')) fireEvent.click(item);
|
||||
expect(screen.getAllByRole('menuitemcheckbox').every(item => item.getAttribute('aria-checked') === 'false')).toBe(true);
|
||||
const editor = await screen.findByRole('region', { name: 'Editing dashboard' });
|
||||
for (const item of within(editor).getAllByRole('checkbox')) fireEvent.click(item);
|
||||
expect(within(editor).getAllByRole('checkbox').every(item => !(item as HTMLInputElement).checked)).toBe(true);
|
||||
fireEvent.click(within(editor).getByRole('button', { name: 'Save layout' }));
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'Editing dashboard' })).not.toBeInTheDocument());
|
||||
view.unmount(); view = open();
|
||||
await screen.findByText('Your dashboard sections are hidden.');
|
||||
expect(screen.queryByText('Tracked jobs')).not.toBeInTheDocument();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { defaultDashboardLayout, normalizeDashboardLayout, WIDGETS } from './dashboardLayout';
|
||||
|
||||
test('default catalogue preserves every section and supported widths', () => {
|
||||
const layout = defaultDashboardLayout();
|
||||
expect(layout.widgets.map(w => w.id)).toEqual(['cards', 'attention', 'funnel', 'activity', 'stageAge', 'sources', 'salary', 'companies', 'skills']);
|
||||
expect(layout.widgets.every(w => w.visible)).toBe(true);
|
||||
expect(WIDGETS[0].sizes).toEqual(['full']);
|
||||
layout.widgets[0].visible = false;
|
||||
expect(defaultDashboardLayout().widgets[0].visible).toBe(true);
|
||||
});
|
||||
|
||||
test('saved layouts retain known order, reject duplicates and append missing sections hidden', () => {
|
||||
const result = normalizeDashboardLayout({ version: 1, revision: 7, widgets: [
|
||||
{ id: 'skills', visible: false, size: 'full' },
|
||||
{ id: 'unknown', visible: true, size: 'full' },
|
||||
{ id: 'skills', visible: true, size: 'standard' },
|
||||
{ id: 'cards', visible: 'yes', size: 'standard' },
|
||||
] });
|
||||
expect(result.revision).toBe(7);
|
||||
expect(result.widgets[0]).toEqual({ id: 'skills', visible: false, size: 'full' });
|
||||
expect(result.widgets[1]).toEqual({ id: 'cards', visible: false, size: 'full' });
|
||||
expect(result.widgets.slice(2).every(w => !w.visible)).toBe(true);
|
||||
expect(result.widgets).toHaveLength(9);
|
||||
});
|
||||
|
||||
test.each([null, {}, { version: 2, revision: 8, widgets: [] }, { version: 1, revision: -1, widgets: [] }])('invalid input has safe defaults: %j', input => {
|
||||
expect(normalizeDashboardLayout(input)).toEqual(defaultDashboardLayout());
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export type WidgetSize = 'standard' | 'full';
|
||||
|
||||
export const WIDGETS = [
|
||||
{ id: 'cards', titleKey: 'dashboardSummaryCards', defaultSize: 'full', sizes: ['full'] },
|
||||
{ id: 'attention', titleKey: 'dashboardTodayTitle', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||
{ id: 'funnel', titleKey: 'dashboardCurrentPipeline', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||
{ id: 'activity', titleKey: 'dashboardApplicationActivity', defaultSize: 'full', sizes: ['standard', 'full'] },
|
||||
{ id: 'stageAge', titleKey: 'dashboardStageAge', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||
{ id: 'sources', titleKey: 'dashboardResponseSources', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||
{ id: 'salary', titleKey: 'dashboardSalaryInsights', defaultSize: 'full', sizes: ['standard', 'full'] },
|
||||
{ id: 'companies', titleKey: 'dashboardTopCompaniesByActivity', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||
{ id: 'skills', titleKey: 'dashboardJobTags', defaultSize: 'standard', sizes: ['standard', 'full'] },
|
||||
] as const;
|
||||
|
||||
export type WidgetId = typeof WIDGETS[number]['id'];
|
||||
export type DashboardWidget = { id: WidgetId; visible: boolean; size: WidgetSize };
|
||||
export type DashboardLayout = { version: 1; revision: number; widgets: DashboardWidget[] };
|
||||
|
||||
export function defaultDashboardLayout(): DashboardLayout {
|
||||
return { version: 1, revision: 0, widgets: WIDGETS.map(w => ({ id: w.id, visible: true, size: w.defaultSize })) };
|
||||
}
|
||||
|
||||
export function normalizeDashboardLayout(input: unknown): DashboardLayout {
|
||||
if (!input || typeof input !== 'object') return defaultDashboardLayout();
|
||||
const value = input as Record<string, unknown>;
|
||||
if (value.version !== 1 || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) return defaultDashboardLayout();
|
||||
const revision = value.revision as number;
|
||||
const widgets: DashboardWidget[] = [];
|
||||
const seen = new Set<WidgetId>();
|
||||
for (const item of value.widgets) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const definition = WIDGETS.find(w => w.id === item.id);
|
||||
if (!definition || seen.has(definition.id)) continue;
|
||||
seen.add(definition.id);
|
||||
widgets.push({ id: definition.id, visible: typeof item.visible === 'boolean' ? item.visible : revision === 0,
|
||||
size: (definition.sizes as readonly string[]).includes(item.size) ? item.size : definition.defaultSize });
|
||||
}
|
||||
for (const definition of WIDGETS) {
|
||||
if (!seen.has(definition.id)) widgets.push({ id: definition.id, visible: revision === 0, size: definition.defaultSize });
|
||||
}
|
||||
return { version: 1, revision, widgets };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { api } from '../api';
|
||||
import { setAuthUserKey } from '../auth';
|
||||
import { defaultDashboardLayout } from '../dashboardLayout';
|
||||
import { useDashboardLayout } from './useDashboardLayout';
|
||||
|
||||
jest.mock('../api', () => ({ api: { get: jest.fn(), put: jest.fn() } }));
|
||||
const get = api.get as jest.Mock;
|
||||
const put = api.put as jest.Mock;
|
||||
const unsaved = { version: 1, revision: 0, widgets: [] };
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorage.clear();
|
||||
setAuthUserKey('a', false);
|
||||
get.mockResolvedValue({ data: unsaved });
|
||||
});
|
||||
|
||||
test('loads once, migrates only owner legacy flags, and cancels without writing', async () => {
|
||||
localStorage.setItem('dashboardPrefs:a', JSON.stringify({ cards: false, activity: false }));
|
||||
const { result, rerender } = renderHook(() => useDashboardLayout(true));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.layout.widgets[0].visible).toBe(false);
|
||||
expect(result.current.layout.widgets[1].visible).toBe(true);
|
||||
act(() => result.current.startEditing());
|
||||
act(() => result.current.setDraft(d => d && ({ ...d, widgets: [...d.widgets].reverse() })));
|
||||
rerender();
|
||||
expect(result.current.dirty).toBe(true);
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
act(() => result.current.cancelEditing());
|
||||
expect(result.current.draft).toBeNull();
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('save waits for acknowledgement and retains draft on conflict until explicit reload', async () => {
|
||||
const { result } = renderHook(() => useDashboardLayout(true));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.startEditing());
|
||||
act(() => result.current.setDraft(d => d && ({ ...d, widgets: d.widgets.map(w => ({ ...w, visible: false })) })));
|
||||
put.mockRejectedValueOnce({ response: { status: 409 } });
|
||||
await act(async () => { expect(await result.current.save()).toBe(false); });
|
||||
expect(result.current.conflict).toBe(true);
|
||||
expect(result.current.draft?.widgets.every(w => !w.visible)).toBe(true);
|
||||
expect(result.current.layout.widgets.every(w => w.visible)).toBe(true);
|
||||
get.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 3 } });
|
||||
await act(async () => result.current.reload());
|
||||
expect(result.current.editing).toBe(true);
|
||||
expect(result.current.draft?.revision).toBe(3);
|
||||
expect(result.current.conflict).toBe(false);
|
||||
put.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 4 } });
|
||||
await act(async () => { expect(await result.current.save()).toBe(true); });
|
||||
expect(result.current.layout.revision).toBe(4);
|
||||
expect(result.current.editing).toBe(false);
|
||||
});
|
||||
|
||||
test('failed or future-version reads cannot enable editing or saves', async () => {
|
||||
get.mockRejectedValueOnce(new Error('offline'));
|
||||
const { result } = renderHook(() => useDashboardLayout(true));
|
||||
await waitFor(() => expect(result.current.loadError).toBe('request'));
|
||||
act(() => result.current.startEditing());
|
||||
expect(result.current.editing).toBe(false);
|
||||
get.mockResolvedValueOnce({ data: { version: 2, revision: 5, widgets: [] } });
|
||||
await act(async () => result.current.reload());
|
||||
expect(result.current.loadError).toBe('unsupportedVersion');
|
||||
act(() => result.current.startEditing());
|
||||
await act(async () => { expect(await result.current.save()).toBe(false); });
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('old save handlers and delayed reads cannot affect a new owner', async () => {
|
||||
let resolveRead!: (value: unknown) => void;
|
||||
const { result } = renderHook(() => useDashboardLayout(true));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.startEditing());
|
||||
const oldSave = result.current.save;
|
||||
get.mockImplementationOnce(() => new Promise(resolve => { resolveRead = resolve; }));
|
||||
let oldRead!: Promise<void>;
|
||||
act(() => { oldRead = result.current.reload(); });
|
||||
act(() => setAuthUserKey('b'));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => { resolveRead({ data: { ...defaultDashboardLayout(), revision: 99 } }); await oldRead; });
|
||||
await act(async () => { expect(await oldSave()).toBe(false); });
|
||||
expect(result.current.layout.revision).toBe(0);
|
||||
expect(result.current.editing).toBe(false);
|
||||
expect(put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('pending save acknowledgement is ignored after identity changes', async () => {
|
||||
let acknowledge!: (value: unknown) => void;
|
||||
put.mockImplementationOnce(() => new Promise(resolve => { acknowledge = resolve; }));
|
||||
const { result } = renderHook(() => useDashboardLayout(true));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.startEditing());
|
||||
let save!: Promise<boolean>;
|
||||
act(() => { save = result.current.save(); });
|
||||
expect(result.current.saving).toBe(true);
|
||||
expect(result.current.layout.revision).toBe(0);
|
||||
act(() => setAuthUserKey('b'));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => {
|
||||
acknowledge({ data: { ...defaultDashboardLayout(), revision: 10 } });
|
||||
expect(await save).toBe(false);
|
||||
});
|
||||
expect(result.current.layout.revision).toBe(0);
|
||||
expect(result.current.saving).toBe(false);
|
||||
});
|
||||
|
||||
test('does not import anonymous legacy flags or replace saved server choices', async () => {
|
||||
localStorage.setItem('dashboardPrefs:anon', JSON.stringify({ cards: false }));
|
||||
const { result } = renderHook(() => useDashboardLayout(true));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.layout.widgets[0].visible).toBe(true);
|
||||
localStorage.setItem('dashboardPrefs:a', JSON.stringify({ cards: false }));
|
||||
get.mockResolvedValueOnce({ data: { ...defaultDashboardLayout(), revision: 1 } });
|
||||
await act(async () => result.current.reload());
|
||||
expect(result.current.layout.widgets[0].visible).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
import { api } from '../api';
|
||||
import { AUTH_USER_CHANGED_EVENT, getAuthUserKey } from '../auth';
|
||||
import { getUserKeyFromToken } from '../themePrefs';
|
||||
import { defaultDashboardLayout, normalizeDashboardLayout, type DashboardLayout } from '../dashboardLayout';
|
||||
|
||||
type LayoutError = 'request' | 'unsupportedVersion' | null;
|
||||
type LayoutState = {
|
||||
owner: string; layout: DashboardLayout; draft: DashboardLayout | null; editing: boolean;
|
||||
loading: boolean; loaded: boolean; loadError: LayoutError; saveError: LayoutError; conflict: boolean; saving: boolean;
|
||||
};
|
||||
|
||||
function initialState(owner: string, loading: boolean): LayoutState {
|
||||
return { owner, layout: defaultDashboardLayout(), draft: null, editing: false, loading, loaded: false,
|
||||
loadError: null, saveError: null, conflict: false, saving: false };
|
||||
}
|
||||
|
||||
function readLayout(input: unknown): DashboardLayout {
|
||||
const value = input as Partial<DashboardLayout> | null;
|
||||
if (value && value.version !== 1) throw new Error('unsupportedVersion');
|
||||
if (!value || !Number.isSafeInteger(value.revision) || (value.revision as number) < 0 || !Array.isArray(value.widgets)) throw new Error('request');
|
||||
const layout = normalizeDashboardLayout(input);
|
||||
if (layout.revision === 0 && layout.widgets.length && value.widgets.length === 0 && getUserKeyFromToken() !== 'anon') {
|
||||
try {
|
||||
const legacy = JSON.parse(localStorage.getItem(`dashboardPrefs:${getUserKeyFromToken()}`) || 'null');
|
||||
for (const widget of layout.widgets) {
|
||||
if (['cards', 'activity', 'funnel', 'companies', 'skills'].includes(widget.id) && typeof legacy?.[widget.id] === 'boolean') widget.visible = legacy[widget.id];
|
||||
}
|
||||
} catch { /* Corrupt or unavailable local preferences leave defaults intact. */ }
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
|
||||
const clone = (layout: DashboardLayout): DashboardLayout => ({ ...layout, widgets: layout.widgets.map(w => ({ ...w })) });
|
||||
|
||||
export function useDashboardLayout(enabled: boolean) {
|
||||
const [state, setState] = useState<LayoutState>(() => initialState(getAuthUserKey(), enabled));
|
||||
const mounted = useRef(false);
|
||||
const generation = useRef(0);
|
||||
const controller = useRef<AbortController | null>(null);
|
||||
const savePending = useRef(false);
|
||||
const stateOwner = useRef(state.owner);
|
||||
stateOwner.current = state.owner;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
const owner = getAuthUserKey();
|
||||
const request = ++generation.current;
|
||||
controller.current?.abort();
|
||||
const abort = new AbortController();
|
||||
controller.current = abort;
|
||||
savePending.current = false;
|
||||
setState(s => enabled && s.owner === owner ? { ...s, loading: true, loaded: false, loadError: null, saveError: null, conflict: false, saving: false } : initialState(owner, enabled));
|
||||
if (!enabled) return;
|
||||
const current = () => mounted.current && generation.current === request && getAuthUserKey() === owner;
|
||||
try {
|
||||
const response = await api.get('/auth/preferences/dashboard', { signal: abort.signal });
|
||||
if (!current()) return;
|
||||
const layout = readLayout(response.data);
|
||||
setState(s => ({ ...s, layout, draft: s.editing ? clone(layout) : null, loading: false, loaded: true }));
|
||||
} catch (error) {
|
||||
if (current()) setState(s => ({ ...s, loading: false, loaded: false, loadError: error instanceof Error && error.message === 'unsupportedVersion' ? 'unsupportedVersion' : 'request' }));
|
||||
}
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
void reload();
|
||||
const identityChanged = () => {
|
||||
if (getAuthUserKey() !== stateOwner.current) {
|
||||
stateOwner.current = getAuthUserKey();
|
||||
void reload();
|
||||
}
|
||||
};
|
||||
window.addEventListener(AUTH_USER_CHANGED_EVENT, identityChanged);
|
||||
window.addEventListener('auth-changed', identityChanged);
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
controller.current?.abort();
|
||||
window.removeEventListener(AUTH_USER_CHANGED_EVENT, identityChanged);
|
||||
window.removeEventListener('auth-changed', identityChanged);
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
const startEditing = () => {
|
||||
if (enabled && state.loaded && !state.loading && !state.saving && state.owner === getAuthUserKey()) setState(s => ({ ...s, draft: clone(s.layout), editing: true, saveError: null, conflict: false }));
|
||||
};
|
||||
const setDraft: Dispatch<SetStateAction<DashboardLayout | null>> = update => {
|
||||
setState(s => s.editing && !s.saving && s.owner === getAuthUserKey()
|
||||
? { ...s, draft: (typeof update === 'function' ? update(s.draft) : update) ?? s.draft } : s);
|
||||
};
|
||||
const cancelEditing = () => {
|
||||
if (!savePending.current) setState(s => ({ ...s, draft: null, editing: false, saveError: null, conflict: false }));
|
||||
};
|
||||
const save = async (): Promise<boolean> => {
|
||||
if (!enabled || !state.loaded || state.loading || !state.editing || !state.draft || state.conflict || savePending.current || state.owner !== getAuthUserKey()) return false;
|
||||
const owner = state.owner;
|
||||
const request = ++generation.current;
|
||||
savePending.current = true;
|
||||
const abort = new AbortController();
|
||||
controller.current?.abort();
|
||||
controller.current = abort;
|
||||
const current = () => mounted.current && generation.current === request && getAuthUserKey() === owner;
|
||||
setState(s => ({ ...s, saving: true, saveError: null }));
|
||||
try {
|
||||
const response = await api.put('/auth/preferences/dashboard', { ...normalizeDashboardLayout(state.draft), revision: state.layout.revision }, { signal: abort.signal });
|
||||
if (!current()) return false;
|
||||
const layout = readLayout(response.data);
|
||||
setState(s => ({ ...s, layout, draft: null, editing: false, saving: false, conflict: false }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (current()) {
|
||||
const conflict = (error as { response?: { status?: number } })?.response?.status === 409;
|
||||
setState(s => ({ ...s, saving: false, conflict, saveError: error instanceof Error && error.message === 'unsupportedVersion' ? 'unsupportedVersion' : 'request' }));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (current()) savePending.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
return { layout: state.layout, draft: state.draft, editing: state.editing, loading: state.loading,
|
||||
loadError: state.loadError, saveError: state.saveError, conflict: state.conflict, saving: state.saving,
|
||||
dirty: state.editing && JSON.stringify(state.draft) !== JSON.stringify(state.layout),
|
||||
startEditing, setDraft, cancelEditing, save, reload };
|
||||
}
|
||||
@@ -1743,6 +1743,38 @@ export const translations = {
|
||||
dashboardCompanyJobsResponses: "{jobs} jobs · {responses} responses",
|
||||
dashboardOverviewBody: "A quick view of your application activity, follow-ups, and momentum.",
|
||||
dashboardCustomize: "Customize dashboard",
|
||||
dashboardEditing: "Editing dashboard",
|
||||
dashboardEditingBody: "Choose what matters to you. Changes appear below; save to use this layout on your other devices.",
|
||||
dashboardManageWidgets: "Manage dashboard widgets",
|
||||
dashboardSave: "Save layout",
|
||||
dashboardSaving: "Saving layout…",
|
||||
dashboardWaitForSave: "Please wait for your layout to finish saving before leaving.",
|
||||
dashboardSaved: "Dashboard layout saved.",
|
||||
dashboardDraftSaved: "Your saved layout. Changes are only applied when you save.",
|
||||
dashboardSaveFailed: "Your layout could not be saved. Your changes are still here. Try again.",
|
||||
dashboardLoadFailed: "Your saved layout could not be loaded. Retry before editing to protect your settings.",
|
||||
dashboardUnsupportedVersion: "This layout was saved by a newer version. Reload or update the app before editing.",
|
||||
dashboardConflict: "Your dashboard changed in another session. Reload the latest layout before applying your changes again.",
|
||||
dashboardReload: "Reload latest layout",
|
||||
dashboardReloadBody: "Replace your unsaved changes with the latest saved layout?",
|
||||
dashboardReset: "Reset to default",
|
||||
dashboardResetBody: "Restore the default dashboard widgets, order and sizes? This only changes your draft. Your jobs and other settings are kept.",
|
||||
dashboardDiscardTitle: "Discard dashboard changes?",
|
||||
dashboardDiscardBody: "Your layout has unsaved changes. Discard them and return to the saved layout?",
|
||||
dashboardDiscard: "Discard changes",
|
||||
dashboardEarlier: "Earlier",
|
||||
dashboardLater: "Later",
|
||||
dashboardMoveEarlierNamed: "Move {name} earlier",
|
||||
dashboardMoveLaterNamed: "Move {name} later",
|
||||
dashboardDrag: "Drag {name} to reorder",
|
||||
dashboardMoved: "{name} moved to position {position} of {total}.",
|
||||
dashboardSizeFor: "Size for {name}",
|
||||
dashboardSize: "Size",
|
||||
dashboardSizeStandard: "Standard",
|
||||
dashboardSizeFull: "Full width",
|
||||
dashboardReorderHelp: "Drag a handle to reorder, or use Earlier and Later with keyboard or touch. Hidden widgets keep their position and size. On small screens every widget fills the width.",
|
||||
dashboardPreview: "Dashboard preview",
|
||||
dashboardNoSample: "Not enough recorded data for this widget yet.",
|
||||
dashboardSummaryCards: "Summary cards",
|
||||
dashboardActivityChart: "Activity chart",
|
||||
dashboardConversionFunnel: "Current pipeline",
|
||||
@@ -4584,6 +4616,38 @@ export const translations = {
|
||||
dashboardCompanyJobsResponses: "{jobs} jobber · {responses} svar",
|
||||
dashboardOverviewBody: "En rask oversikt over aktivitet, oppfølginger og fremdrift i jobbsøket ditt.",
|
||||
dashboardCustomize: "Tilpass dashboard",
|
||||
dashboardEditing: "Tilpasser dashboard",
|
||||
dashboardEditingBody: "Velg det som er viktig for deg. Endringene vises nedenfor. Lagre for å bruke oppsettet på de andre enhetene dine.",
|
||||
dashboardManageWidgets: "Administrer dashboard-widgeter",
|
||||
dashboardSave: "Lagre oppsett",
|
||||
dashboardSaving: "Lagrer oppsett…",
|
||||
dashboardWaitForSave: "Vent til oppsettet er ferdig lagret før du forlater siden.",
|
||||
dashboardSaved: "Dashboard-oppsettet er lagret.",
|
||||
dashboardDraftSaved: "Ditt lagrede oppsett. Endringene tas i bruk når du lagrer.",
|
||||
dashboardSaveFailed: "Oppsettet kunne ikke lagres. Endringene dine er beholdt. Prøv igjen.",
|
||||
dashboardLoadFailed: "Det lagrede oppsettet kunne ikke lastes. Prøv igjen før du redigerer, slik at innstillingene dine bevares.",
|
||||
dashboardUnsupportedVersion: "Oppsettet ble lagret av en nyere versjon. Last inn på nytt eller oppdater appen før du redigerer.",
|
||||
dashboardConflict: "Dashboardet ble endret i en annen økt. Last inn det nyeste oppsettet før du gjør endringene på nytt.",
|
||||
dashboardReload: "Last inn nyeste oppsett",
|
||||
dashboardReloadBody: "Erstatte endringene du ikke har lagret, med det nyeste lagrede oppsettet?",
|
||||
dashboardReset: "Tilbakestill til standard",
|
||||
dashboardResetBody: "Gjenopprette standard widgeter, rekkefølge og størrelser? Dette endrer bare utkastet. Jobbene og de andre innstillingene dine beholdes.",
|
||||
dashboardDiscardTitle: "Forkaste dashboard-endringene?",
|
||||
dashboardDiscardBody: "Oppsettet har endringer som ikke er lagret. Vil du forkaste dem og gå tilbake til det lagrede oppsettet?",
|
||||
dashboardDiscard: "Forkast endringer",
|
||||
dashboardEarlier: "Tidligere",
|
||||
dashboardLater: "Senere",
|
||||
dashboardMoveEarlierNamed: "Flytt {name} tidligere",
|
||||
dashboardMoveLaterNamed: "Flytt {name} senere",
|
||||
dashboardDrag: "Dra {name} for å endre rekkefølgen",
|
||||
dashboardMoved: "{name} er flyttet til plass {position} av {total}.",
|
||||
dashboardSizeFor: "Størrelse for {name}",
|
||||
dashboardSize: "Størrelse",
|
||||
dashboardSizeStandard: "Standard",
|
||||
dashboardSizeFull: "Full bredde",
|
||||
dashboardReorderHelp: "Dra i håndtaket for å endre rekkefølgen, eller bruk Tidligere og Senere med tastatur eller berøring. Skjulte widgeter beholder plass og størrelse. På små skjermer fyller alle widgeter bredden.",
|
||||
dashboardPreview: "Forhåndsvisning av dashboard",
|
||||
dashboardNoSample: "Det er ikke nok registrerte data for denne widgeten ennå.",
|
||||
dashboardSummaryCards: "Oppsummeringskort",
|
||||
dashboardActivityChart: "Aktivitetsgraf",
|
||||
dashboardConversionFunnel: "Nåværende statusfordeling",
|
||||
|
||||
@@ -37,6 +37,9 @@ jest.mock('./components/MicrosoftAuthCard', () => () => null);
|
||||
beforeEach(() => {
|
||||
const { api } = require('./api');
|
||||
api.get.mockImplementation((url: string) => {
|
||||
if (url === '/auth/preferences/dashboard') {
|
||||
return Promise.resolve({ data: { version: 1, revision: 0, widgets: [] } });
|
||||
}
|
||||
if (url === '/auth/config') {
|
||||
return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user