Skip to content

Commit 7b86a5d

Browse files
Jasragsclaude
andcommitted
fix(sync): Show fresh sync option when migration wizard has no baseline snapshot
When a character references a ruleset snapshot that doesn't exist on disk, the drift analyzer now sets noBaseline: true on the empty report. The MigrationWizard detects this and shows a "Sync to Current Rules" button instead of an empty changes list, allowing the user to create a baseline snapshot and resolve the red stability shield. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7a241c4 commit 7b86a5d

5 files changed

Lines changed: 117 additions & 23 deletions

File tree

app/api/characters/[characterId]/sync/migrate/route.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212

1313
import { NextRequest, NextResponse } from "next/server";
1414
import { getSession } from "@/lib/auth/session";
15-
import { getCharacterById, rollbackMigration } from "@/lib/storage/characters";
15+
import { getCharacterById, updateCharacter, rollbackMigration } from "@/lib/storage/characters";
16+
import { captureRulesetSnapshot } from "@/lib/storage/ruleset-snapshots";
1617
import { executeMigration, validateMigrationPlan } from "@/lib/rules/sync/migration-engine";
1718
import {
1819
recordMigrationStart,
1920
recordMigrationComplete,
2021
recordMigrationRollback,
22+
recordManualResync,
2123
} from "@/lib/rules/sync/sync-audit";
2224
import type { MigrationPlan } from "@/lib/types";
2325

@@ -55,6 +57,26 @@ export async function POST(request: NextRequest, { params }: RouteParams): Promi
5557

5658
// Parse request body
5759
const body = await request.json();
60+
61+
// Fresh sync: create baseline snapshot for characters with no existing snapshot
62+
if (body.freshSync === true) {
63+
const versionRef = await captureRulesetSnapshot(character.editionCode);
64+
const now = new Date().toISOString();
65+
66+
await updateCharacter(userId, characterId, {
67+
rulesetSnapshotId: versionRef.snapshotId,
68+
rulesetVersion: versionRef,
69+
syncStatus: "synchronized",
70+
legalityStatus: "rules-legal",
71+
lastSyncAt: now,
72+
lastSyncCheck: now,
73+
});
74+
75+
await recordManualResync(userId, character, versionRef);
76+
77+
return NextResponse.json({ success: true, freshSync: true });
78+
}
79+
5880
const plan = body.plan as MigrationPlan;
5981

6082
if (!plan) {

components/sync/MigrationWizard.tsx

Lines changed: 85 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ interface MigrationWizardProps {
3939
export function MigrationWizard({ characterId, onClose, onComplete }: MigrationWizardProps) {
4040
const wizard = useMigrationWizard(characterId);
4141
const [isApplying, setIsApplying] = useState(false);
42+
const [isSyncing, setIsSyncing] = useState(false);
4243

4344
// Get breaking changes that need decisions
4445
const breakingChanges = wizard.report?.changes.filter((c) => c.severity === "breaking") || [];
@@ -61,6 +62,24 @@ export function MigrationWizard({ characterId, onClose, onComplete }: MigrationW
6162
}
6263
};
6364

65+
const handleFreshSync = async () => {
66+
setIsSyncing(true);
67+
try {
68+
const response = await fetch(`/api/characters/${characterId}/sync/migrate`, {
69+
method: "POST",
70+
headers: { "Content-Type": "application/json" },
71+
body: JSON.stringify({ freshSync: true }),
72+
});
73+
74+
if (response.ok) {
75+
onComplete?.();
76+
onClose();
77+
}
78+
} finally {
79+
setIsSyncing(false);
80+
}
81+
};
82+
6483
if (!wizard.report) {
6584
return (
6685
<WizardContainer onClose={onClose}>
@@ -71,19 +90,28 @@ export function MigrationWizard({ characterId, onClose, onComplete }: MigrationW
7190
);
7291
}
7392

93+
const isNoBaseline = wizard.report.noBaseline && wizard.report.changes.length === 0;
94+
7495
return (
7596
<WizardContainer onClose={onClose}>
7697
{/* Progress indicator */}
77-
<WizardProgress
78-
currentStep={wizard.currentStep}
79-
totalSteps={wizard.totalSteps}
80-
breakingChanges={breakingChanges.length}
81-
/>
98+
{!isNoBaseline && (
99+
<WizardProgress
100+
currentStep={wizard.currentStep}
101+
totalSteps={wizard.totalSteps}
102+
breakingChanges={breakingChanges.length}
103+
/>
104+
)}
82105

83106
{/* Content area */}
84107
<div className="p-6">
85108
{wizard.currentStep === 0 && (
86-
<ReviewStep report={wizard.report} breakingCount={breakingChanges.length} />
109+
<ReviewStep
110+
report={wizard.report}
111+
breakingCount={breakingChanges.length}
112+
onFreshSync={isNoBaseline ? handleFreshSync : undefined}
113+
isSyncing={isSyncing}
114+
/>
87115
)}
88116

89117
{currentChange && (
@@ -101,17 +129,19 @@ export function MigrationWizard({ characterId, onClose, onComplete }: MigrationW
101129
)}
102130
</div>
103131

104-
{/* Footer with navigation */}
105-
<WizardFooter
106-
currentStep={wizard.currentStep}
107-
totalSteps={wizard.totalSteps}
108-
canApply={wizard.canApply}
109-
isApplying={isApplying}
110-
onPrev={wizard.prevStep}
111-
onNext={wizard.nextStep}
112-
onApply={handleApply}
113-
onCancel={onClose}
114-
/>
132+
{/* Footer with navigation (hidden when showing fresh sync UI) */}
133+
{!isNoBaseline && (
134+
<WizardFooter
135+
currentStep={wizard.currentStep}
136+
totalSteps={wizard.totalSteps}
137+
canApply={wizard.canApply}
138+
isApplying={isApplying}
139+
onPrev={wizard.prevStep}
140+
onNext={wizard.nextStep}
141+
onApply={handleApply}
142+
onCancel={onClose}
143+
/>
144+
)}
115145
</WizardContainer>
116146
);
117147
}
@@ -226,11 +256,47 @@ function WizardProgress({ currentStep, totalSteps, breakingChanges }: WizardProg
226256
// =============================================================================
227257

228258
interface ReviewStepProps {
229-
report: { changes: DriftChange[] };
259+
report: { changes: DriftChange[]; noBaseline?: boolean };
230260
breakingCount: number;
261+
onFreshSync?: () => void;
262+
isSyncing?: boolean;
231263
}
232264

233-
function ReviewStep({ report, breakingCount }: ReviewStepProps) {
265+
function ReviewStep({ report, breakingCount, onFreshSync, isSyncing }: ReviewStepProps) {
266+
// No baseline: show fresh sync UI
267+
if (report.noBaseline && report.changes.length === 0 && onFreshSync) {
268+
return (
269+
<div className="space-y-6">
270+
<div>
271+
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
272+
Ruleset Snapshot Missing
273+
</h3>
274+
<p className="text-sm text-gray-500 dark:text-gray-400">
275+
This character references a ruleset snapshot that no longer exists. This can happen when
276+
a character was created before the snapshot system was set up.
277+
</p>
278+
</div>
279+
280+
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
281+
<p className="text-sm text-blue-700 dark:text-blue-300 mb-4">
282+
Sync to the current rules to create a baseline snapshot. Future ruleset changes will be
283+
tracked against this baseline.
284+
</p>
285+
<button
286+
onClick={onFreshSync}
287+
disabled={isSyncing}
288+
className={`
289+
px-4 py-2 text-sm font-medium text-white rounded-lg
290+
${isSyncing ? "bg-gray-400 cursor-not-allowed" : "bg-blue-600 hover:bg-blue-700"}
291+
`}
292+
>
293+
{isSyncing ? "Syncing..." : "Sync to Current Rules"}
294+
</button>
295+
</div>
296+
</div>
297+
);
298+
}
299+
234300
const nonBreaking = report.changes.filter((c) => c.severity !== "breaking");
235301
const breaking = report.changes.filter((c) => c.severity === "breaking");
236302

lib/rules/sync/__tests__/drift-analyzer.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ describe("Drift Analyzer", () => {
165165
// ===========================================================================
166166

167167
describe("analyzeCharacterDrift", () => {
168-
it("should return empty report when no current snapshot exists for edition", async () => {
168+
it("should return empty report with noBaseline when no current snapshot exists for edition", async () => {
169169
const character = createMockCharacter({
170170
editionCode: "sr5",
171171
rulesetSnapshotId: "old-snapshot",
@@ -177,9 +177,10 @@ describe("Drift Analyzer", () => {
177177

178178
expect(report.changes).toEqual([]);
179179
expect(report.overallSeverity).toBe("none");
180+
expect(report.noBaseline).toBe(true);
180181
});
181182

182-
it("should return empty report when character already on current snapshot", async () => {
183+
it("should return empty report without noBaseline when character already on current snapshot", async () => {
183184
const character = createMockCharacter({
184185
editionCode: "sr5",
185186
rulesetSnapshotId: "current-snapshot",
@@ -193,11 +194,12 @@ describe("Drift Analyzer", () => {
193194

194195
expect(report.changes).toEqual([]);
195196
expect(report.overallSeverity).toBe("none");
197+
expect(report.noBaseline).toBeFalsy();
196198
// Should not load full snapshots
197199
expect(getRulesetSnapshot).not.toHaveBeenCalled();
198200
});
199201

200-
it("should return empty report when character snapshot not found", async () => {
202+
it("should return empty report with noBaseline when character snapshot not found", async () => {
201203
const character = createMockCharacter({
202204
editionCode: "sr5",
203205
rulesetSnapshotId: "missing-snapshot",
@@ -212,6 +214,7 @@ describe("Drift Analyzer", () => {
212214

213215
expect(report.changes).toEqual([]);
214216
expect(report.overallSeverity).toBe("none");
217+
expect(report.noBaseline).toBe(true);
215218
});
216219

217220
it("should throw when current ruleset snapshot not found", async () => {

lib/rules/sync/drift-analyzer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -669,6 +669,7 @@ function createEmptyDriftReportNoBaseline(character: Character): DriftReport {
669669
overallSeverity: "none",
670670
changes: [],
671671
recommendations: [],
672+
noBaseline: true,
672673
};
673674
}
674675

lib/types/synchronization.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ export interface DriftReport {
9696
changes: DriftChange[];
9797
/** Recommended migration actions */
9898
recommendations: MigrationRecommendation[];
99+
/** True when no baseline snapshot exists (missing or never captured) */
100+
noBaseline?: boolean;
99101
}
100102

101103
/**

0 commit comments

Comments
 (0)