2026-06-01 09:03:59 +01:00

304 lines
14 KiB
Plaintext

@model AnalyticsViewModel
@{
ViewData["Title"] = "Story Health";
var openThreads = Model.ThreadHealth.Where(x => x.IsOpenStatus).ToList();
var dormantThreads = Model.ThreadHealth.Where(x => x.IsDormant).ToList();
var unresolvedAssets = Model.AssetHealth.Where(x => !x.IsResolved).ToList();
var metricScenes = Model.MetricGraphs
.SelectMany(graph => graph.Points)
.GroupBy(point => point.SceneID)
.Select(group => group.OrderBy(point => point.GlobalSceneIndex).First())
.OrderBy(point => point.GlobalSceneIndex)
.Select(point => new
{
sceneId = point.SceneID,
axisLabel = $"{point.ChapterNumber:g}.{point.SceneNumber:g}",
chapterLabel = $"Chapter {point.ChapterNumber:g}",
sceneNumber = point.SceneNumber.ToString("g"),
sceneTitle = point.SceneTitle,
bookTitle = point.BookTitle,
timelineUrl = Url.Action("Index", "Timeline", new { projectId = Model.Project.ProjectID, bookId = point.BookID, selectedSceneId = point.SceneID })
})
.ToList();
var metricShapePayload = new
{
scenes = metricScenes,
metrics = Model.MetricGraphs.Select(graph => new
{
metricName = graph.MetricName,
points = graph.Points.Select(point => new { sceneId = point.SceneID, value = point.Value })
})
};
}
<section class="context-header">
<div class="context-title">
<span class="eyebrow">Workspace context</span>
<strong>@Model.Project.ProjectName</strong>
</div>
<nav class="context-nav" aria-label="Workspace navigation">
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">Project</a>
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID">Timeline</a>
<a asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID">Warnings</a>
<a asp-controller="Characters" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Characters</a>
<a asp-controller="StoryAssets" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Assets</a>
<a asp-controller="Locations" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Locations</a>
<a asp-controller="PlotThreads" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID">Threads</a>
</nav>
</section>
<div class="page-heading compact">
<div>
<p class="eyebrow">Analytics / Story health</p>
<h1>@Model.Project.ProjectName</h1>
</div>
<div class="button-row">
<a class="btn btn-outline-primary" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID">Timeline</a>
<a class="btn btn-outline-secondary" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID">Warnings</a>
</div>
</div>
<section class="warning-filter-panel">
<form method="get" class="row g-2 align-items-end">
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<div class="col-md-3">
<label class="form-label">Book</label>
<select name="bookId" asp-items="Model.BookOptions" class="form-select form-select-sm">
<option value="">All books</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">Start chapter</label>
<input class="form-control form-control-sm" name="startChapterNumber" value="@Model.StartChapterNumber" />
</div>
<div class="col-md-2">
<label class="form-label">End chapter</label>
<input class="form-control form-control-sm" name="endChapterNumber" value="@Model.EndChapterNumber" />
</div>
<div class="col-md-2">
<label class="form-label">Start scene</label>
<input class="form-control form-control-sm" name="startSceneNumber" value="@Model.StartSceneNumber" />
</div>
<div class="col-md-2">
<label class="form-label">End scene</label>
<input class="form-control form-control-sm" name="endSceneNumber" value="@Model.EndSceneNumber" />
</div>
<div class="col-md-1">
<button class="btn btn-outline-primary btn-sm" type="submit">View</button>
</div>
</form>
</section>
<section class="health-kpi-grid">
<article class="plain-card"><span class="eyebrow">Open threads</span><strong>@openThreads.Count</strong></article>
<article class="plain-card"><span class="eyebrow">Dormant threads</span><strong>@dormantThreads.Count</strong></article>
<article class="plain-card"><span class="eyebrow">Unresolved assets</span><strong>@unresolvedAssets.Count</strong></article>
<article class="plain-card"><span class="eyebrow">Warning hotspots</span><strong>@Model.WarningHotspots.Count</strong></article>
</section>
<section class="analytics-section">
<div class="analytics-section-heading">
<h2>Scene metric shape</h2>
<span class="muted">@Model.MetricGraphs.Count metric@(Model.MetricGraphs.Count == 1 ? "" : "s")</span>
</div>
@if (!Model.MetricGraphs.Any() || !metricScenes.Any())
{
<p class="muted">Add scene metrics to begin seeing peaks and troughs across the story.</p>
}
else
{
<div class="metric-shape-chart" data-metric-shape-chart>
<p class="metric-shape-intro">Compare selected pacing and emotional metrics across scenes in narrative order. Click a point to open that scene on the timeline.</p>
<div class="metric-toggle-row" data-metric-toggles></div>
<div class="metric-chart-frame">
<canvas aria-label="Comparative scene metric line graph"></canvas>
<p class="muted d-none" data-chart-fallback>Metric chart could not load. The raw metric rows are still available below.</p>
</div>
<script type="application/json">@Html.Raw(System.Text.Json.JsonSerializer.Serialize(metricShapePayload))</script>
</div>
<details class="metric-raw-details">
<summary>Show raw metric rows</summary>
<div class="analytics-metric-stack mt-2">
@foreach (var graph in Model.MetricGraphs)
{
<div class="analytics-metric-row">
<div class="analytics-metric-label">@graph.MetricName</div>
<div class="analytics-sparkline" style="--point-count:@Math.Max(graph.Points.Count, 1)">
@foreach (var point in graph.Points)
{
<a class="analytics-bar"
style="--bar-height:@Math.Max(point.Percent, 4)%"
title="@graph.MetricName - Scene @point.SceneNumber: @point.SceneTitle (@point.Value)"
asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@point.BookID"
asp-route-selectedSceneId="@point.SceneID">
<span>@point.Value</span>
</a>
}
</div>
</div>
}
</div>
</details>
}
</section>
<section class="analytics-grid-two">
<article class="plain-card">
<h2>Pacing review</h2>
@if (!Model.PacingReviewNotes.Any())
{
<p class="muted">No sustained pacing runs found in this scope.</p>
}
else
{
@foreach (var note in Model.PacingReviewNotes)
{
<div class="analytics-list-item">
<strong>@note.MetricName</strong>
<span>@note.SceneRange</span>
<span class="muted">@note.ValuePattern</span>
<p>@note.ReviewNote</p>
</div>
}
}
</article>
<article class="plain-card">
<h2>Revision summary</h2>
@foreach (var status in Model.RevisionSummary.Where(x => x.SceneCount > 0 || x.ChapterCount > 0))
{
<div class="analytics-count-row">
<span>@status.StatusName</span>
<strong>@status.SceneCount scenes</strong>
</div>
}
<h3>Needs attention</h3>
@foreach (var scene in Model.RevisionAttentionScenes.Take(8))
{
<a class="analytics-link-row" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@scene.BookID" asp-route-selectedSceneId="@scene.SceneID">
<span>@scene.RevisionStatusName</span>
<strong>Ch @scene.ChapterNumber / Scene @scene.SceneNumber: @scene.SceneTitle</strong>
</a>
}
</article>
</section>
<section class="analytics-grid-two">
<article class="plain-card">
<h2>Scene purpose balance</h2>
@foreach (var purpose in Model.PurposeSummary)
{
<div class="purpose-stat-row">
<span>@purpose.PurposeName</span>
<div class="purpose-stat-bar"><span style="width:@Math.Min(100, purpose.PercentOfScenes)%"></span></div>
<strong>@purpose.SceneCount</strong>
</div>
}
<h3>Missing purpose/outcome</h3>
@foreach (var gap in Model.SceneGaps.Take(8))
{
<a class="analytics-link-row" asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@gap.BookID" asp-route-selectedSceneId="@gap.SceneID">
<span>@(gap.MissingPurpose ? "No purpose" : "") @(gap.MissingOutcome ? "No outcome" : "")</span>
<strong>Ch @gap.ChapterNumber / Scene @gap.SceneNumber: @gap.SceneTitle</strong>
</a>
}
</article>
<article class="plain-card">
<h2>Warning hotspots</h2>
@foreach (var count in Model.WarningSeverityCounts)
{
<div class="warning-count-row"><span class="severity-pill @count.SeverityName.ToLowerInvariant()">@count.SeverityName</span><strong>@count.WarningCount</strong></div>
}
@foreach (var hotspot in Model.WarningHotspots.Take(8))
{
<a class="analytics-link-row" asp-controller="Warnings" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-bookId="@Model.BookID" asp-route-sceneId="@hotspot.SceneID">
<span>@hotspot.WarningCount warning@(hotspot.WarningCount == 1 ? "" : "s")</span>
<strong>Ch @hotspot.ChapterNumber / Scene @hotspot.SceneNumber: @hotspot.SceneTitle</strong>
</a>
}
</article>
</section>
<section class="analytics-grid-two">
<article class="plain-card">
<h2>Plot thread health</h2>
@foreach (var thread in Model.ThreadHealth.Where(x => x.IsHighImportanceOpen || x.IsDormant || x.HasClueWithoutPayoff || x.HasQuestionWithoutAnswer).Take(12))
{
<div class="analytics-list-item">
<strong>@thread.ThreadTitle</strong>
<span>@thread.PlotLineName / @thread.ThreadStatusName / importance @thread.Importance</span>
<span class="muted">@thread.LastTouchedSceneLabel</span>
<div class="purpose-chip-row">
@if (thread.IsDormant) { <span class="purpose-chip">Dormant</span> }
@if (thread.HasClueWithoutPayoff) { <span class="purpose-chip">Clue without payoff</span> }
@if (thread.HasQuestionWithoutAnswer) { <span class="purpose-chip">Question open</span> }
</div>
</div>
}
</article>
<article class="plain-card">
<h2>Story asset health</h2>
@foreach (var asset in Model.AssetHealth.Where(x => !x.IsResolved || x.HasDependencies || x.Importance >= 8).Take(12))
{
<a class="analytics-link-row" asp-controller="StoryAssets" asp-action="Details" asp-route-id="@asset.StoryAssetID">
<span>@asset.KindName / importance @asset.Importance</span>
<strong>@asset.AssetName</strong>
<span>@(asset.CurrentStateName ?? "No state") / @(asset.CurrentLocationPath ?? "No location")</span>
</a>
}
</article>
</section>
<section class="analytics-grid-three">
<article class="plain-card">
<h2>POV distribution</h2>
@foreach (var pov in Model.PovSummary)
{
<div class="analytics-count-row"><span>@pov.POVName</span><strong>@pov.SceneCount</strong></div>
}
@foreach (var chapter in Model.MultiPovChapters.Take(6))
{
<p class="muted">Ch @chapter.ChapterNumber: @chapter.ChapterTitle has @chapter.POVCount POVs.</p>
}
</article>
<article class="plain-card">
<h2>Character appearances</h2>
@foreach (var character in Model.CharacterAppearances.Take(10))
{
<a class="analytics-link-row" asp-controller="Characters" asp-action="Details" asp-route-id="@character.CharacterID">
<span>@character.SceneCount scenes</span>
<strong>@character.CharacterName</strong>
</a>
}
@foreach (var busy in Model.BusyScenes.Take(4))
{
<p class="muted">Busy scene: Ch @busy.ChapterNumber / Scene @busy.SceneNumber has @busy.CharacterCount characters.</p>
}
</article>
<article class="plain-card">
<h2>Location usage</h2>
@foreach (var location in Model.LocationUsage.Take(10))
{
@if (location.LocationID.HasValue)
{
<a class="analytics-link-row" asp-controller="Locations" asp-action="Details" asp-route-id="@location.LocationID">
<span>@location.SceneCount scenes</span>
<strong>@location.LocationName</strong>
</a>
}
else
{
<div class="analytics-count-row"><span>@location.LocationName</span><strong>@location.SceneCount</strong></div>
}
}
</article>
</section>