diff --git a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs index d6a7632..5c026a8 100644 --- a/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs +++ b/PlotLine/Services/StoryIntelligenceVisualisationSnapshotService.cs @@ -52,15 +52,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return null; } - var results = (await repository.ListSceneResultsAsync(runId)) - .OrderBy(result => result.TemporarySceneNumber) - .ThenBy(result => result.SceneResultID) - .ToList(); + var results = (await repository.ListSceneResultsAsync(runId)).ToList(); var parsed = results - .Select(result => new ParsedSceneResult(result, TryReadScene(result))) + .Select(result => ReadSceneResult(runId, result)) + .OrderBy(item => SceneSortChapter(item)) + .ThenBy(item => SceneSortNumber(item)) + .ThenBy(item => item.Result.SceneResultID) .ToList(); var completed = parsed - .Where(item => item.Scene is not null && item.Result.ValidationErrorsCount == 0) + .Where(item => item.Scene is not null) .ToList(); var current = PickCurrentScene(run, completed); @@ -79,6 +79,7 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( ChangeToken = ChangeToken(run, results), IsTerminal = IsTerminal(run.Status), SnapshotMessage = SnapshotMessage(run, completed.Count), + Diagnostics = BuildDiagnostics(run, results, parsed, completed, current), ProjectName = DisplayTitle(run.ProjectTitle, "Untitled project"), BookTitle = DisplayTitle(run.BookTitle, "Untitled book"), Scenes = scenes @@ -158,13 +159,14 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return null; } - return completed.LastOrDefault(item => SceneNumber(item) <= (run.CompletedScenes ?? int.MaxValue)) ?? completed.Last(); + return completed.Last(); } private static IReadOnlyList BuildCharacters(SceneIntelligenceScene scene) { var povName = Clean(scene.PointOfView?.CharacterName); var states = new List(); + var reservedCodes = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var group in (scene.Characters ?? []) .Where(character => !string.IsNullOrWhiteSpace(character.Name)) .GroupBy(character => Clean(character.Name), StringComparer.OrdinalIgnoreCase) @@ -172,10 +174,12 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( { var character = group.OrderByDescending(item => item.Confidence ?? 0).First(); var isPov = !string.IsNullOrWhiteSpace(povName) && string.Equals(group.Key, povName, StringComparison.OrdinalIgnoreCase); + var libraryCode = CharacterLibraryCode(group.Key, character, scene, reservedCodes); + reservedCodes.Add(libraryCode); states.Add(new StoryIntelligenceExperienceCharacterState { Id = CharacterId(group.Key), - LibraryCode = CharacterLibraryCode(group.Key, character), + LibraryCode = libraryCode, Name = group.Key, Role = isPov ? "POV character" : FirstConfigured(character.RoleInScene, character.MentionedOnly == true ? "Referenced" : "Scene character"), Relevance = Trim(FirstConfigured(character.Notes, string.Join(", ", character.Actions ?? []), character.MentionedOnly == true ? "Mentioned only" : "Present"), 60), @@ -186,10 +190,11 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( if (!string.IsNullOrWhiteSpace(povName) && states.All(character => !string.Equals(character.Name, povName, StringComparison.OrdinalIgnoreCase))) { + var libraryCode = CharacterLibraryCode(povName, null, scene, reservedCodes); states.Insert(0, new StoryIntelligenceExperienceCharacterState { Id = CharacterId(povName), - LibraryCode = CharacterLibraryCode(povName, null), + LibraryCode = libraryCode, Name = povName, Role = "POV character", Relevance = "Point of view", @@ -327,10 +332,27 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( yield return $"Run status: {run.Status}"; } - private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result) + private ParsedSceneResult ReadSceneResult(int runId, StoryIntelligenceSavedSceneResult result) { + var scene = TryReadScene(result, out var error); + if (scene is null) + { + logger.LogWarning( + "Story Intelligence visualisation skipped unparseable scene result {SceneResultId} for run {RunId}: {ParseError}", + result.SceneResultID, + runId, + error ?? "Parsed scene was empty or missing schema version."); + } + + return new ParsedSceneResult(result, scene, error); + } + + private static SceneIntelligenceScene? TryReadScene(StoryIntelligenceSavedSceneResult result, out string? safeError) + { + safeError = null; if (string.IsNullOrWhiteSpace(result.ParsedJson)) { + safeError = "Parsed JSON was empty."; return null; } @@ -348,19 +370,82 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return parsedScene.Deserialize(JsonOptions); } } - catch (JsonException) + catch (JsonException ex) { + safeError = ex.Message; } + safeError ??= "Parsed JSON did not contain a scene payload."; return null; } private static string ChangeToken(StoryIntelligenceSavedRun run, IReadOnlyList results) { - var raw = $"{run.StoryIntelligenceRunID}|{run.Status}|{run.UpdatedUtc:O}|{results.Count}|{results.Select(item => item.SceneResultID).DefaultIfEmpty().Max()}"; + var resultToken = string.Join(';', results + .OrderBy(result => result.SceneResultID) + .Select(result => string.Join('|', + result.SceneResultID, + result.ChapterID, + result.SceneID, + result.TemporarySceneNumber, + result.ValidationErrorsCount, + result.ValidationWarningsCount, + result.InputTokens, + result.OutputTokens, + result.TotalTokens, + result.DurationMs, + result.CreatedUtc.ToUniversalTime().Ticks, + StableContentHash(result.ParsedJson)))); + var raw = string.Join('|', + run.StoryIntelligenceRunID, + run.Status, + run.CurrentStage, + run.CurrentMessage, + run.CompletedScenes, + run.FailedScenes, + run.TotalDetectedScenes, + run.CompletedUtc?.ToUniversalTime().Ticks, + run.UpdatedUtc.ToUniversalTime().Ticks, + run.TotalInputTokens, + run.TotalOutputTokens, + run.TotalTokens, + run.TotalDurationMs, + run.ErrorMessage, + run.ErrorDetail is null ? string.Empty : StableContentHash(run.ErrorDetail), + results.Count, + resultToken); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)))[..16].ToLowerInvariant(); } + private static StoryIntelligenceExperienceSnapshotDiagnostics BuildDiagnostics( + StoryIntelligenceSavedRun run, + IReadOnlyList results, + IReadOnlyList parsed, + IReadOnlyList completed, + ParsedSceneResult? current) + { + var failed = parsed.Where(item => item.Scene is null).ToList(); + var warning = failed.Count == 0 + ? null + : $"{failed.Count:N0} scene result(s) skipped because they could not be parsed."; + + return new() + { + RunStatus = run.Status, + PersistedCompletedSceneCount = run.CompletedScenes ?? 0, + TotalPersistedSceneResultCount = results.Count, + SuccessfullyParsedSceneResultCount = completed.Count, + FailedSceneResultCount = failed.Count, + HighestPersistedSceneResultId = results.Count == 0 ? null : results.Max(result => result.SceneResultID), + HighestParsedSceneResultId = completed.Count == 0 ? null : completed.Max(item => item.Result.SceneResultID), + SelectedCurrentSceneResultId = current?.Result.SceneResultID, + SelectedChapterAndSceneOrder = current is null ? "none" : $"Chapter {SceneSortChapter(current):0.##}, Scene {SceneSortNumber(current):0.##}", + CurrentChangeToken = ChangeToken(run, results), + AnalysisCursor = FirstConfigured(run.CurrentMessage, run.CurrentStage), + Warning = warning + }; + } + private static string SnapshotMessage(StoryIntelligenceSavedRun run, int completedScenes) => run.Status switch { @@ -377,6 +462,13 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( private static int SceneNumber(ParsedSceneResult item) => item.Scene?.SceneReference?.SceneNumber.HasValue == true ? Convert.ToInt32(item.Scene.SceneReference.SceneNumber.Value) : item.Result.TemporarySceneNumber; + private static decimal SceneSortChapter(ParsedSceneResult item) + => item.Scene?.SceneReference?.ChapterNumber + ?? (item.Result.ChapterID.HasValue ? item.Result.ChapterID.Value : 0); + + private static decimal SceneSortNumber(ParsedSceneResult item) + => item.Scene?.SceneReference?.SceneNumber ?? item.Result.TemporarySceneNumber; + private static string SceneTitle(SceneIntelligenceScene scene, StoryIntelligenceSavedSceneResult result, int sceneNumber) => FirstConfigured(scene.SceneReference?.SourceLabel, result.SourceLabel, scene.Summary?.Short, $"Scene {sceneNumber:N0}"); @@ -459,13 +551,21 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( || value is "door" or "table" or "chair"; } - private static string CharacterLibraryCode(string name, SceneIntelligenceCharacter? character) + private static string CharacterLibraryCode( + string name, + SceneIntelligenceCharacter? character, + SceneIntelligenceScene? scene = null, + IReadOnlySet? reservedCodes = null) { - var value = $"{name} {character?.RoleInScene} {character?.Notes}".ToLowerInvariant(); - if (value.Contains("red") || value.Contains("auburn") || value.Contains("witness")) return "char-young-adult-red-haired-witness"; - if (value.Contains("older") || value.Contains("mother") || value.Contains("remember")) return "char-older-soft-presence"; - if (value.Contains("suspect") || value.Contains("threat") || value.Contains("man")) return "char-middle-aged-weathered-man"; - return "char-young-adult-brunette-observer"; + var profile = CharacterProfile.From(name, character, scene); + var candidates = CharacterCodeProfiles + .OrderByDescending(candidate => candidate.Score(profile)) + .ThenBy(candidate => reservedCodes?.Contains(candidate.Code) == true ? 1 : 0) + .ThenBy(candidate => StableHash($"{name}|{candidate.Code}")) + .ToList(); + + return candidates.FirstOrDefault(candidate => reservedCodes?.Contains(candidate.Code) != true)?.Code + ?? candidates.First().Code; } private static string LocationLibraryCode(string name, SceneIntelligenceLocation? location, SceneIntelligenceSetting? setting) @@ -498,6 +598,14 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( "char-young-adult-red-haired-witness" => $"{FallbackRoot}/character-rosie.svg", "char-middle-aged-weathered-man" => $"{FallbackRoot}/character-graham.svg", "char-older-soft-presence" => $"{FallbackRoot}/character-maggie.svg", + "char-young-helper" => $"{FallbackRoot}/character-beth.svg", + "char-archival-contact" => $"{FallbackRoot}/character-graham.svg", + "char-professional-investigator" => $"{FallbackRoot}/character-graham.svg", + "char-neighbourhood-friend" => $"{FallbackRoot}/character-beth.svg", + "char-family-relative" => $"{FallbackRoot}/character-maggie.svg", + "char-quiet-antagonist" => $"{FallbackRoot}/character-graham.svg", + "char-authorial-voice" => $"{FallbackRoot}/character-maggie.svg", + "char-unknown-figure" => $"{FallbackRoot}/character-beth.svg", _ => $"{FallbackRoot}/character-beth.svg" }; @@ -546,6 +654,15 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return builder.ToString().Trim('-'); } + private static int StableHash(string? value) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)); + return BitConverter.ToInt32(bytes, 0) & int.MaxValue; + } + + private static string StableContentHash(string? value) + => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value ?? string.Empty)))[..16].ToLowerInvariant(); + private static string Clean(string? value) => string.Join(' ', (value ?? string.Empty).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).Trim(); @@ -561,5 +678,104 @@ public sealed class StoryIntelligenceVisualisationSnapshotService( return clean.Length <= max ? clean : clean[..Math.Max(0, max - 3)].TrimEnd() + "..."; } - private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene); + private sealed record ParsedSceneResult(StoryIntelligenceSavedSceneResult Result, SceneIntelligenceScene? Scene, string? ParseError); + + private sealed record CharacterCodeProfile( + string Code, + string AgeBand, + string Presentation, + string HairColour, + string HairLength, + IReadOnlyList Signals) + { + public int Score(CharacterProfile profile) + { + var score = 0; + if (profile.AgeBand == AgeBand) score += 80; + else if (CompatibleAge(profile.AgeBand, AgeBand)) score += 30; + else score -= 45; + + if (!string.IsNullOrWhiteSpace(profile.Presentation) && Presentation.Equals(profile.Presentation, StringComparison.OrdinalIgnoreCase)) score += 24; + if (!string.IsNullOrWhiteSpace(profile.HairColour) && HairColour.Equals(profile.HairColour, StringComparison.OrdinalIgnoreCase)) score += 18; + if (!string.IsNullOrWhiteSpace(profile.HairLength) && HairLength.Equals(profile.HairLength, StringComparison.OrdinalIgnoreCase)) score += 10; + score += Signals.Count(signal => profile.Text.Contains(signal, StringComparison.OrdinalIgnoreCase)) * 14; + return score; + } + + private static bool CompatibleAge(string required, string candidate) + => (required, candidate) switch + { + ("Teen", "YoungAdult") => true, + ("YoungAdult", "Teen") => true, + ("MiddleAged", "Older") => true, + ("Older", "MiddleAged") => true, + _ => false + }; + } + + private sealed record CharacterProfile(string Text, string AgeBand, string Presentation, string HairColour, string HairLength) + { + public static CharacterProfile From(string name, SceneIntelligenceCharacter? character, SceneIntelligenceScene? scene) + { + var relationshipText = string.Join(' ', (scene?.Relationships ?? []) + .Where(relationship => string.Equals(Clean(relationship.CharacterA), Clean(name), StringComparison.OrdinalIgnoreCase) + || string.Equals(Clean(relationship.CharacterB), Clean(name), StringComparison.OrdinalIgnoreCase)) + .Select(relationship => $"{relationship.RelationshipSignal} {relationship.Evidence}")); + var text = $"{name} {character?.RoleInScene} {character?.Notes} {string.Join(' ', character?.Actions ?? [])} {relationshipText}".ToLowerInvariant(); + return new( + text, + DetectAgeBand(text), + DetectPresentation(text), + DetectHairColour(text), + DetectHairLength(text)); + } + + private static string DetectAgeBand(string text) + { + if (text.Contains("grandmother") || text.Contains("grandfather") || text.Contains("elderly") || text.Contains("older") || text.Contains("retired")) return "Older"; + if (text.Contains("mother") || text.Contains("father") || text.Contains("parent") || text.Contains("teacher") || text.Contains("aunt") || text.Contains("uncle") || text.Contains("middle-aged") || text.Contains("middle aged")) return "MiddleAged"; + if (text.Contains("teen") || text.Contains("teenage") || text.Contains("15") || text.Contains("fifteen") || text.Contains("schoolgirl") || text.Contains("schoolboy") || text.Contains("child") || text.Contains("daughter") || text.Contains("son")) return "Teen"; + if (text.Contains("young") || text.Contains("student")) return "YoungAdult"; + return "YoungAdult"; + } + + private static string DetectPresentation(string text) + { + if (text.Contains("mother") || text.Contains("daughter") || text.Contains("woman") || text.Contains("girl") || text.Contains("female") || text.Contains("sister") || text.Contains("aunt")) return "Feminine"; + if (text.Contains("father") || text.Contains("son") || text.Contains("man") || text.Contains("male") || text.Contains("brother") || text.Contains("uncle")) return "Masculine"; + return string.Empty; + } + + private static string DetectHairColour(string text) + { + if (text.Contains("red") || text.Contains("auburn") || text.Contains("ginger")) return "Red"; + if (text.Contains("grey") || text.Contains("gray") || text.Contains("white-haired") || text.Contains("white hair")) return "Grey"; + if (text.Contains("black hair") || text.Contains("black-haired")) return "Black"; + if (text.Contains("dark") || text.Contains("brown") || text.Contains("brunette")) return "Brown"; + return string.Empty; + } + + private static string DetectHairLength(string text) + { + if (text.Contains("long hair") || text.Contains("long-haired")) return "Long"; + if (text.Contains("short hair") || text.Contains("short-haired")) return "Short"; + return string.Empty; + } + } + + private static readonly IReadOnlyList CharacterCodeProfiles = + [ + new("char-young-helper", "Teen", "Androgynous", "Black", "Short", ["teen", "child", "daughter", "son", "helper"]), + new("char-young-adult-red-haired-witness", "YoungAdult", "Feminine", "Red", "Long", ["red", "auburn", "witness", "protective"]), + new("char-young-adult-brunette-observer", "YoungAdult", "Feminine", "Brown", "Medium", ["young", "observer", "student"]), + new("char-neighbourhood-friend", "YoungAdult", "Androgynous", "Brown", "Medium", ["friend", "neighbour", "neighbor"]), + new("char-family-relative", "MiddleAged", "Feminine", "Brown", "Medium", ["mother", "parent", "aunt", "family", "relative"]), + new("char-professional-investigator", "MiddleAged", "Androgynous", "Brown", "Short", ["teacher", "professional", "investigator", "authority"]), + new("char-middle-aged-weathered-man", "MiddleAged", "Masculine", "DarkBrown", "Short", ["father", "man", "suspect", "threat", "weathered"]), + new("char-quiet-antagonist", "MiddleAged", "Androgynous", "DarkBrown", "Short", ["antagonist", "threat", "pressure", "controlled"]), + new("char-older-soft-presence", "Older", "Feminine", "Grey", "Short", ["grandmother", "older", "remembered", "mother"]), + new("char-archival-contact", "Older", "Masculine", "White", "Short", ["grandfather", "older", "archive", "records"]), + new("char-authorial-voice", "Older", "Androgynous", "Grey", "Medium", ["voice", "memory", "recorded"]), + new("char-unknown-figure", "YoungAdult", "Androgynous", "Brown", "Medium", ["unknown", "unidentified"]) + ]; } diff --git a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs index 687d045..5da5fd2 100644 --- a/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs +++ b/PlotLine/ViewModels/StoryIntelligenceExperiencePrototypeViewModels.cs @@ -9,12 +9,29 @@ public sealed class StoryIntelligenceExperiencePrototypeViewModel public DateTime GeneratedUtc { get; init; } = DateTime.UtcNow; public bool IsTerminal { get; init; } public string? SnapshotMessage { get; init; } + public StoryIntelligenceExperienceSnapshotDiagnostics Diagnostics { get; init; } = new(); public IReadOnlyList AvailableRuns { get; init; } = []; public string ProjectName { get; init; } = string.Empty; public string BookTitle { get; init; } = string.Empty; public IReadOnlyList Scenes { get; init; } = []; } +public sealed class StoryIntelligenceExperienceSnapshotDiagnostics +{ + public string RunStatus { get; init; } = string.Empty; + public int PersistedCompletedSceneCount { get; init; } + public int TotalPersistedSceneResultCount { get; init; } + public int SuccessfullyParsedSceneResultCount { get; init; } + public int FailedSceneResultCount { get; init; } + public int? HighestPersistedSceneResultId { get; init; } + public int? HighestParsedSceneResultId { get; init; } + public int? SelectedCurrentSceneResultId { get; init; } + public string SelectedChapterAndSceneOrder { get; init; } = string.Empty; + public string CurrentChangeToken { get; init; } = string.Empty; + public string? AnalysisCursor { get; init; } + public string? Warning { get; init; } +} + public sealed class StoryIntelligenceExperienceRunOption { public int RunId { get; init; } diff --git a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml index 4058cc1..c0da603 100644 --- a/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml +++ b/PlotLine/Views/Development/StoryIntelligenceExperience.cshtml @@ -189,6 +189,74 @@
Operations detected
InitialRender
+
+
Run status
+
@Model.Diagnostics.RunStatus
+
+
+
Persisted completed scenes
+
@Model.Diagnostics.PersistedCompletedSceneCount
+
+
+
Total persisted scene results
+
@Model.Diagnostics.TotalPersistedSceneResultCount
+
+
+
Successfully parsed scene results
+
@Model.Diagnostics.SuccessfullyParsedSceneResultCount
+
+
+
Failed/unparseable scene results
+
@Model.Diagnostics.FailedSceneResultCount
+
+
+
Highest persisted SceneResultID
+
@Model.Diagnostics.HighestPersistedSceneResultId
+
+
+
Highest parsed SceneResultID
+
@Model.Diagnostics.HighestParsedSceneResultId
+
+
+
Selected current SceneResultID
+
@Model.Diagnostics.SelectedCurrentSceneResultId
+
+
+
Selected chapter and scene order
+
@Model.Diagnostics.SelectedChapterAndSceneOrder
+
+
+
Analysis cursor
+
@Model.Diagnostics.AnalysisCursor
+
+
+
Snapshot warning
+
@Model.Diagnostics.Warning
+
+
+
Last successful snapshot fetch
+
Initial
+
+
+
Last snapshot HTTP status
+
Initial
+
+
+
Consecutive unchanged polls
+
0
+
+
+
Consecutive failed polls
+
0
+
+
+
Polling
+
Initialising
+
+
+
Polling stopped reason
+
none
+
diff --git a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js index 359dcba..7d10576 100644 --- a/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js +++ b/PlotLine/wwwroot/js/story-intelligence-experience-prototype.js @@ -22,6 +22,11 @@ polling: false, changeToken: model.changeToken || root.dataset.changeToken || "", terminal: model.isTerminal || root.dataset.terminal === "true", + pollingStoppedReason: "", + lastSnapshotHttpStatus: "initial", + consecutiveUnchangedPolls: 0, + consecutiveFailedPolls: 0, + lastSuccessfulSnapshotFetch: "Initial", currentSnapshot: model, previousSnapshot: null, currentVersion: 1, @@ -79,7 +84,8 @@ diagnosticsQueue: root.querySelector("[data-diagnostics-queue]"), diagnosticsRefresh: root.querySelector("[data-diagnostics-refresh]"), diagnosticsBuild: root.querySelector("[data-diagnostics-build]"), - diagnosticsOperations: root.querySelector("[data-diagnostics-operations]") + diagnosticsOperations: root.querySelector("[data-diagnostics-operations]"), + diagnosticValues: root.querySelectorAll("[data-diagnostic-key]") }; function loadSceneState(index) { @@ -138,7 +144,7 @@ text(book, model.bookTitle); } transitionToNextState(state.index, operations); - if (state.terminal) stopPolling(); + if (state.terminal) stopPolling("terminal model received"); } function updateProgress(scene) { @@ -1081,14 +1087,16 @@ function startPolling() { if (!realMode || !snapshotUrl || state.terminal) return; stopPolling(); + state.pollingStoppedReason = ""; state.pollTimer = window.setInterval(pollSnapshot, document.hidden ? 15000 : 7000); } - function stopPolling() { + function stopPolling(reason) { if (state.pollTimer) { window.clearInterval(state.pollTimer); state.pollTimer = null; } + if (reason) state.pollingStoppedReason = reason; } async function pollSnapshot() { @@ -1097,17 +1105,30 @@ const requestStarted = performance.now(); try { const response = await fetch(snapshotUrl, { headers: { Accept: "application/json" } }); - if (!response.ok) return; + state.lastSnapshotHttpStatus = String(response.status); + if (!response.ok) { + state.consecutiveFailedPolls += 1; + updateDiagnostics(); + return; + } const snapshot = await response.json(); + state.consecutiveFailedPolls = 0; + state.lastSuccessfulSnapshotFetch = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); if (snapshot.changeToken && snapshot.changeToken !== state.changeToken) { + state.consecutiveUnchangedPolls = 0; replaceModel(snapshot, Math.round(performance.now() - requestStarted)); } else if (snapshot.isTerminal) { state.terminal = true; - stopPolling(); + stopPolling("terminal snapshot received"); + updateDiagnostics(); + } else { + state.consecutiveUnchangedPolls += 1; updateDiagnostics(); } } catch { - // Development snapshot polling is best-effort. + state.lastSnapshotHttpStatus = "fetch failed"; + state.consecutiveFailedPolls += 1; + updateDiagnostics(); } finally { state.polling = false; } @@ -1226,6 +1247,23 @@ text(dom.diagnosticsRefresh, state.lastRefreshTime); text(dom.diagnosticsBuild, `${state.snapshotBuildMs}ms`); text(dom.diagnosticsOperations, state.lastOperations.join(", ")); + const diagnostics = model.diagnostics || {}; + dom.diagnosticValues.forEach((node) => { + const key = node.dataset.diagnosticKey; + const value = pollingDiagnosticValue(key, diagnostics); + text(node, value); + }); + } + + function pollingDiagnosticValue(key, diagnostics) { + if (key === "lastSuccessfulSnapshotFetch") return state.lastSuccessfulSnapshotFetch; + if (key === "lastSnapshotHttpStatus") return state.lastSnapshotHttpStatus; + if (key === "consecutiveUnchangedPolls") return state.consecutiveUnchangedPolls; + if (key === "consecutiveFailedPolls") return state.consecutiveFailedPolls; + if (key === "pollingActive") return state.pollTimer ? "active" : "stopped"; + if (key === "pollingStoppedReason") return state.pollingStoppedReason || "none"; + const value = diagnostics[key]; + return value === null || value === undefined || value === "" ? "none" : value; } function animateNumber(node, nextValue, suffix) { @@ -1362,4 +1400,5 @@ transitionToNextState(realMode ? Math.max(0, scenes.length - 1) : 0); startTimer(); startPolling(); + if (realMode && !state.terminal) pollSnapshot(); })();