@codervisor/devlog-core
Version:
Core devlog management functionality
276 lines (275 loc) • 9.18 kB
JavaScript
/**
* Comprehensive field change tracking utilities
* Extends the existing acceptance criteria tracking to all devlog fields
*/
import { TRACKABLE_FIELDS } from '../types/change-tracking.js';
/**
* Compare two values and generate a field change record
*/
export function createFieldChange(fieldName, previousValue, newValue) {
const fieldConfig = TRACKABLE_FIELDS[fieldName];
if (!fieldConfig || !fieldConfig.shouldTrack) {
return null; // Skip untrackable fields
}
// Skip if values are the same
if (JSON.stringify(previousValue) === JSON.stringify(newValue)) {
return null;
}
const change = {
fieldName,
fieldDisplayName: fieldConfig.displayName,
category: fieldConfig.category,
previousValue,
newValue,
changeType: determineChangeType(previousValue, newValue, fieldConfig.diffStrategy),
diff: generateDiff(previousValue, newValue, fieldConfig.diffStrategy),
};
return change;
}
/**
* Determine the type of change based on previous and new values
*/
function determineChangeType(previousValue, newValue, diffStrategy) {
if (previousValue === undefined || previousValue === null) {
return 'added';
}
if (newValue === undefined || newValue === null) {
return 'removed';
}
if (diffStrategy === 'array' && Array.isArray(previousValue) && Array.isArray(newValue)) {
// Check if it's just reordering
const prevSorted = [...previousValue].sort();
const newSorted = [...newValue].sort();
if (JSON.stringify(prevSorted) === JSON.stringify(newSorted)) {
return 'reordered';
}
}
return 'modified';
}
/**
* Generate human-readable diff for different field types
*/
function generateDiff(previousValue, newValue, diffStrategy) {
switch (diffStrategy) {
case 'simple':
return `Changed from "${previousValue}" to "${newValue}"`;
case 'text':
return generateTextDiff(previousValue, newValue);
case 'array':
return generateArrayDiff(previousValue, newValue);
case 'object':
return generateObjectDiff(previousValue, newValue);
default:
return `Updated value`;
}
}
/**
* Generate diff for text fields (description, context fields)
*/
function generateTextDiff(previousValue, newValue) {
if (!previousValue)
return `Added: "${newValue}"`;
if (!newValue)
return `Removed: "${previousValue}"`;
// For long text, show a summary
const prevLines = previousValue.split('\n').length;
const newLines = newValue.split('\n').length;
const prevLength = previousValue.length;
const newLength = newValue.length;
if (prevLines !== newLines || Math.abs(prevLength - newLength) > 50) {
return `Text updated (${prevLines} → ${newLines} lines, ${prevLength} → ${newLength} chars)`;
}
return `Text modified`;
}
/**
* Generate diff for array fields (acceptance criteria, insights, etc.)
*/
function generateArrayDiff(previousValue, newValue) {
if (!previousValue)
previousValue = [];
if (!newValue)
newValue = [];
const prevSet = new Set(previousValue);
const newSet = new Set(newValue);
const added = newValue.filter((item) => !prevSet.has(item));
const removed = previousValue.filter((item) => !newSet.has(item));
// For detailed diff (especially useful for acceptance criteria)
let diff = '';
if (added.length > 0) {
diff += `**Added:**\n${added.map((item) => `+ ${item}`).join('\n')}\n\n`;
}
if (removed.length > 0) {
diff += `**Removed:**\n${removed.map((item) => `- ${item}`).join('\n')}\n\n`;
}
if (added.length === 0 && removed.length === 0) {
// Items were reordered
diff = 'Items reordered';
}
else if (diff) {
// Add summary
const parts = [];
if (added.length > 0)
parts.push(`+${added.length} added`);
if (removed.length > 0)
parts.push(`-${removed.length} removed`);
diff = `${parts.join(', ')}\n\n${diff}`;
}
return diff.trim();
}
/**
* Generate diff for object fields
*/
function generateObjectDiff(previousValue, newValue) {
if (!previousValue)
return 'Object added';
if (!newValue)
return 'Object removed';
return 'Object updated';
}
/**
* Detect all field changes between two devlog entries
*/
export function detectFieldChanges(previousEntry, updatedEntry) {
const changes = [];
// Check all trackable fields
for (const [fieldName, fieldConfig] of Object.entries(TRACKABLE_FIELDS)) {
if (!fieldConfig.shouldTrack)
continue;
const change = createFieldChange(fieldName, previousEntry[fieldName], updatedEntry[fieldName]);
if (change) {
changes.push(change);
}
}
return changes;
}
/**
* Create a comprehensive change record
*/
export function createChangeRecord(devlogId, changes, changeType, source, options = {}) {
return {
id: crypto.randomUUID(),
devlogId,
timestamp: new Date().toISOString(),
changeType,
source,
sourceDetails: options.sourceDetails,
changes,
reason: options.reason,
metadata: options.metadata,
};
}
/**
* Create a change tracking note from field changes
*/
export function createFieldChangeNote(changes, changeRecord) {
const content = generateChangeNoteContent(changes, changeRecord);
const category = determineNoteCategory(changes);
const metadata = {
changeRecord,
fieldChanges: changes,
changeSource: changeRecord.source,
changeReason: changeRecord.reason,
};
return {
category,
content,
metadata,
};
}
/**
* Generate human-readable content for change notes
*/
function generateChangeNoteContent(changes, changeRecord) {
let content = '**Field Changes**\n\n';
if (changeRecord.reason) {
content += `**Reason:** ${changeRecord.reason}\n\n`;
}
if (changeRecord.sourceDetails) {
content += `**Source:** ${changeRecord.sourceDetails}\n\n`;
}
// Group changes by category
const changesByCategory = changes.reduce((acc, change) => {
if (!acc[change.category])
acc[change.category] = [];
acc[change.category].push(change);
return acc;
}, {});
for (const [category, categoryChanges] of Object.entries(changesByCategory)) {
content += `**${category.charAt(0).toUpperCase() + category.slice(1)} Changes:**\n`;
for (const change of categoryChanges) {
content += `- **${change.fieldDisplayName}**: ${change.diff}\n`;
}
content += '\n';
}
return content.trim();
}
/**
* Determine the appropriate note category for field changes
*/
function determineNoteCategory(changes) {
// Check if any workflow changes (status, archived, etc.)
const hasWorkflowChanges = changes.some((c) => c.category === 'workflow');
if (hasWorkflowChanges) {
return 'progress';
}
// Check if any content changes
const hasContentChanges = changes.some((c) => c.category === 'content');
if (hasContentChanges) {
return 'progress';
}
// Default to progress for most field changes
return 'progress';
}
/**
* Extract change tracking context from update request
*/
export function extractChangeContext(updateRequest) {
return {
source: updateRequest._changeSource || 'user',
sourceDetails: updateRequest._sourceDetails,
reason: updateRequest._changeReason,
trackChanges: updateRequest._trackChanges !== false, // Default to true
};
}
/**
* Remove change tracking metadata from update request
*/
export function cleanUpdateRequest(updateRequest) {
const cleaned = { ...updateRequest };
delete cleaned._changeSource;
delete cleaned._changeReason;
delete cleaned._sourceDetails;
delete cleaned._trackChanges;
return cleaned;
}
/**
* Check if a field should be tracked based on configuration
*/
export function shouldTrackField(fieldName) {
const fieldConfig = TRACKABLE_FIELDS[fieldName];
return fieldConfig?.shouldTrack || false;
}
/**
* Get all trackable field names
*/
export function getTrackableFields() {
return Object.entries(TRACKABLE_FIELDS)
.filter(([_, config]) => config.shouldTrack)
.map(([fieldName]) => fieldName);
}
/**
* Validate that a rollback is safe (no conflicts with newer changes)
*/
export function validateRollback(targetEntry, changesSinceTarget) {
const conflicts = [];
// Check for conflicts in changed fields
const targetFields = new Set(changesSinceTarget.flatMap((record) => record.changes.map((change) => change.fieldName)));
// Simple validation - can be enhanced with more sophisticated conflict detection
if (targetFields.size > 0) {
conflicts.push(`Fields modified since target: ${Array.from(targetFields).join(', ')}`);
}
return {
isValid: conflicts.length === 0,
conflicts,
};
}