Phase 10F: Sync Current Scene Progress

This commit is contained in:
Nick Beckley 2026-06-13 20:10:54 +01:00
parent 7c9e9b0b6f
commit b813b187d7
7 changed files with 176 additions and 7 deletions

View File

@ -14,7 +14,7 @@ public interface IWordCompanionRepository
Task<WordCompanionSyncSceneResponse?> SyncSceneAsync(int bookId, int userId, WordCompanionSyncSceneRequest request);
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, int userId, WordCompanionSyncManuscriptRequest request);
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, int userId, WordCompanionUpdateSceneLinksRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords);
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn);
}
public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFactory) : IWordCompanionRepository
@ -179,12 +179,12 @@ public sealed class WordCompanionRepository(ISqlConnectionFactory connectionFact
commandType: CommandType.StoredProcedure);
}
public async Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords)
public async Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, int userId, int actualWords, DateTime? lastWorkedOn)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleOrDefaultAsync<WordCompanionUpdateWordCountResponse>(
"dbo.WordCompanion_Scene_UpdateWordCount",
new { SceneID = sceneId, UserID = userId, ActualWords = actualWords },
new { SceneID = sceneId, UserID = userId, ActualWords = actualWords, LastWorkedOn = lastWorkedOn },
commandType: CommandType.StoredProcedure);
}

View File

@ -163,12 +163,15 @@ public sealed class WordCompanionUpdateSceneLinksResponse
public sealed class WordCompanionUpdateWordCountRequest
{
public int ActualWords { get; set; }
public int? ActualWords { get; set; }
public int? ActualWordCount { get; set; }
public DateTime? LastWorkedOn { get; set; }
}
public sealed class WordCompanionUpdateWordCountResponse
{
public int SceneId { get; init; }
public int ActualWords { get; init; }
public DateTime? LastWorkedOn { get; init; }
public string Message { get; init; } = "Word count updated.";
}

View File

@ -107,9 +107,10 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC
public Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request)
{
return sceneId <= 0 || request.ActualWords < 0
var actualWords = request.ActualWordCount ?? request.ActualWords;
return sceneId <= 0 || actualWords is null or < 0
? Task.FromResult<WordCompanionUpdateWordCountResponse?>(null)
: repository.UpdateWordCountAsync(sceneId, RequireUserId(), request.ActualWords);
: repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn);
}
private async Task<WordCompanionResolveSceneResponse?> ResolveSceneCoreAsync(int bookId, WordCompanionResolveSceneRequest request)
@ -124,4 +125,3 @@ public sealed class WordCompanionService(IWordCompanionRepository repository, IC
private static bool HasInvalidIds(IEnumerable<int> ids) => ids.Any(id => id <= 0);
}

View File

@ -0,0 +1,52 @@
CREATE OR ALTER PROCEDURE dbo.WordCompanion_Scene_UpdateWordCount
@SceneID int,
@UserID int,
@ActualWords int,
@LastWorkedOn datetime2 = NULL
AS
BEGIN
SET NOCOUNT ON;
IF @ActualWords < 0
RETURN;
IF NOT EXISTS
(
SELECT 1
FROM dbo.Scenes s
INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID
INNER JOIN dbo.Books b ON b.BookID = c.BookID
INNER JOIN dbo.Projects p ON p.ProjectID = b.ProjectID
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
WHERE s.SceneID = @SceneID
AND s.IsArchived = 0
AND c.IsArchived = 0
AND b.IsArchived = 0
AND p.IsArchived = 0
AND pua.UserID = @UserID
AND pua.IsActive = 1
)
RETURN;
DECLARE @LastWorkedOnDate date = COALESCE(CONVERT(date, @LastWorkedOn), CONVERT(date, SYSUTCDATETIME()));
IF EXISTS (SELECT 1 FROM dbo.SceneWorkflow WHERE SceneID = @SceneID)
BEGIN
UPDATE dbo.SceneWorkflow
SET ActualWordCount = @ActualWords,
LastWorkedOn = @LastWorkedOnDate,
UpdatedDate = SYSUTCDATETIME()
WHERE SceneID = @SceneID;
END
ELSE
BEGIN
INSERT dbo.SceneWorkflow (SceneID, ActualWordCount, LastWorkedOn)
VALUES (@SceneID, @ActualWords, @LastWorkedOnDate);
END;
SELECT @SceneID AS SceneId,
@ActualWords AS ActualWords,
@LastWorkedOnDate AS LastWorkedOn,
N'Word count updated.' AS Message;
END;
GO

View File

@ -154,6 +154,30 @@
<strong data-plotdirector-blocked-reason>-</strong>
</div>
</div>
<div class="word-companion-progress-sync">
<div class="word-companion-section-header">
<span>Progress Sync</span>
<button type="button" data-sync-scene-progress disabled>Sync Scene Progress</button>
</div>
<div class="word-companion-progress-grid">
<div>
<span>Word Document Count</span>
<strong data-sync-word-count>-</strong>
</div>
<div>
<span>PlotDirector Count</span>
<strong data-sync-plotdirector-count>-</strong>
</div>
<div>
<span>Last Synced</span>
<strong data-sync-last-synced>-</strong>
</div>
<div>
<span>Sync Status</span>
<strong data-sync-status>No resolved PlotDirector scene.</strong>
</div>
</div>
</div>
<div class="word-companion-brief" data-writing-brief-block hidden>
<span>Writing Brief</span>
<p data-writing-brief></p>

View File

@ -197,6 +197,27 @@ body {
font-size: 0.86rem;
}
.word-companion-progress-sync {
display: grid;
gap: 8px;
border-top: 1px solid var(--companion-line);
padding-top: 8px;
}
.word-companion-progress-grid {
display: grid;
gap: 8px;
}
.word-companion-progress-grid div {
display: grid;
gap: 3px;
}
.word-companion-progress-grid strong {
font-size: 0.88rem;
}
.word-companion-brief {
display: grid;
gap: 5px;

View File

@ -31,6 +31,11 @@
const plotDirectorBlockedStatus = document.querySelector("[data-plotdirector-blocked-status]");
const plotDirectorBlockedReasonRow = document.querySelector("[data-plotdirector-blocked-reason-row]");
const plotDirectorBlockedReason = document.querySelector("[data-plotdirector-blocked-reason]");
const syncSceneProgressButton = document.querySelector("[data-sync-scene-progress]");
const syncWordCount = document.querySelector("[data-sync-word-count]");
const syncPlotDirectorCount = document.querySelector("[data-sync-plotdirector-count]");
const syncLastSynced = document.querySelector("[data-sync-last-synced]");
const syncStatus = document.querySelector("[data-sync-status]");
const writingBriefBlock = document.querySelector("[data-writing-brief-block]");
const writingBrief = document.querySelector("[data-writing-brief]");
const plotDirectorLinkGroups = document.querySelector("[data-plotdirector-link-groups]");
@ -66,6 +71,8 @@
let officePlatform = "Unknown";
let detectedChapterTitle = "";
let detectedSceneTitle = "";
let detectedSceneWordCount = null;
let resolvedSceneId = null;
const setOfficeStatus = (message) => {
if (officeStatus) {
@ -109,6 +116,7 @@
const setCurrentStructure = (chapterTitle, sceneTitle, wordCount) => {
detectedChapterTitle = chapterTitle || "";
detectedSceneTitle = sceneTitle || "";
detectedSceneWordCount = Number.isInteger(wordCount) ? wordCount : null;
if (currentChapter) {
currentChapter.textContent = chapterTitle || "Not detected";
@ -119,6 +127,7 @@
if (currentWordCount) {
currentWordCount.textContent = Number.isInteger(wordCount) ? String(wordCount) : "-";
}
setText(syncWordCount, Number.isInteger(wordCount) ? String(wordCount) : "-");
};
const setHidden = (element, hidden) => {
@ -127,6 +136,18 @@
}
};
const setSyncStatus = (message) => {
setText(syncStatus, message);
};
const setResolvedScene = (sceneId) => {
resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null;
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = !resolvedSceneId;
}
setSyncStatus(resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene.");
};
const displayValue = (value) => {
if (value === null || value === undefined || value === "") {
return "-";
@ -140,6 +161,8 @@
setHidden(plotDirectorSceneDetails, true);
setHidden(writingBriefBlock, true);
setHidden(plotDirectorLinkGroups, true);
setResolvedScene(null);
setText(syncPlotDirectorCount, "-");
};
const renderLinkedItems = (container, items) => {
@ -170,6 +193,7 @@
setText(plotDirectorRevisionStatus, displayValue(data?.revisionStatus || data?.revisionStatusName || "Not provided"));
setText(plotDirectorEstimatedWords, displayValue(data?.estimatedWords));
setText(plotDirectorActualWords, displayValue(data?.actualWords));
setText(syncPlotDirectorCount, displayValue(data?.actualWords));
setText(plotDirectorBlockedStatus, data?.blocked ? "Blocked" : "Not blocked");
if (data?.blockedReason) {
@ -567,6 +591,7 @@
structure.currentScene?.title,
structure.currentScene?.wordCount
);
resetPlotDirectorScene(selectedBook() ? "No resolved PlotDirector scene." : "Select a PlotDirector book.");
renderOutline(structure);
setDocumentMessage("");
setDiagnostics({
@ -654,6 +679,48 @@
body: JSON.stringify(body)
});
const patchJson = (url, body) => fetchJson(url, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
const syncSceneProgress = async () => {
if (!resolvedSceneId) {
setSyncStatus("No resolved PlotDirector scene.");
return;
}
if (!Number.isInteger(detectedSceneWordCount)) {
setSyncStatus("Unable to sync progress.");
return;
}
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = true;
syncSceneProgressButton.textContent = "Syncing...";
}
const lastWorkedOn = new Date().toISOString();
try {
const response = await patchJson(`/api/word-companion/scenes/${resolvedSceneId}/word-count`, {
actualWordCount: detectedSceneWordCount,
lastWorkedOn
});
setText(syncPlotDirectorCount, displayValue(response?.actualWords ?? detectedSceneWordCount));
setText(plotDirectorActualWords, displayValue(response?.actualWords ?? detectedSceneWordCount));
setText(syncLastSynced, new Date().toLocaleString());
setSyncStatus("Progress synced successfully.");
} catch {
setSyncStatus("Unable to sync progress.");
} finally {
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = !resolvedSceneId;
syncSceneProgressButton.textContent = "Sync Scene Progress";
}
}
};
const refreshPlotDirectorScene = async () => {
const project = selectedProject();
const book = selectedBook();
@ -738,6 +805,7 @@
}
setPlotDirectorSceneStatus("Linked to PlotDirector");
renderCompanionData(companionData);
setResolvedScene(resolvedSceneId);
} catch {
resetPlotDirectorScene("Unable to load PlotDirector scene data.");
setResolveDiagnostics({ result: "Error" });
@ -854,6 +922,7 @@
refreshDocumentButton?.addEventListener("click", refreshDocumentStructure);
refreshPlotDirectorSceneButton?.addEventListener("click", refreshPlotDirectorScene);
syncSceneProgressButton?.addEventListener("click", syncSceneProgress);
runDiagnosticsButton?.addEventListener("click", runDiagnostics);
if (isAuthenticated) {