refactor: use abstract ITEM placeholders with dynamic property mapping

Changed from fixed TASK placeholders to flexible ITEM placeholders that
automatically map all CSV fields to template placeholders.

Key changes:
- Renamed task-template → item-template in all templates
- Changed TASK_* → ITEM_* placeholder naming
- Implemented dynamic placeholder generation from item properties
- Any field in fieldMapping now creates ITEM_{FIELD} placeholder
- Updated all tests and documentation

Naming convention: CSV field → item.property → ITEM_PROPERTY
Example: "assignee" → item.assignee → {{ITEM_ASSIGNEE}}

This enables users to add custom fields without modifying generator code:
- Add "assignee": "Assignee" to fieldMapping
- Use {{ITEM_ASSIGNEE}} in template
- No code changes required

Benefits:
- More flexible and extensible
- Clearer abstraction (items vs tasks)
- Consistent naming convention
- Better documented

All 56 tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-27 23:17:34 +01:00
parent dd3ba4df58
commit 2bab447fa8
7 changed files with 86 additions and 57 deletions

View File

@@ -12,7 +12,7 @@ A valid template-v2.svg file must contain:
2. **`<defs>` section** containing three required template elements:
- `<g id="month-template">` - Defines how each month column is rendered
- `<g id="lane-template">` - Defines how each lane (epic/swimlane) is rendered
- `<g id="task-template">` - Defines how each task item is rendered
- `<g id="item-template">` - Defines how each task item is rendered
3. **Main content area** with `{{MONTHS}}` and `{{LANES}}` placeholders
4. **Optional styling** (gradients, filters, patterns, etc.)
@@ -37,11 +37,11 @@ A valid template-v2.svg file must contain:
font-family="Arial" font-size="14" font-weight="bold" fill="#212121">{{LANE_NAME}}</text>
</g>
<!-- Task template -->
<g id="task-template" style="display:none">
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6" fill="#1976D2"/>
<!-- Item template -->
<g id="item-template" style="display:none">
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6" fill="#1976D2"/>
<text x="{{TEXT_X}}" y="{{TEXT_Y}}"
font-family="Arial" font-size="11" fill="#424242">{{TASK_ID}} {{TASK_TITLE}}</text>
font-family="Arial" font-size="11" fill="#424242">{{ITEM_ID}} {{ITEM_TITLE}}</text>
</g>
</defs>
@@ -80,16 +80,38 @@ A valid template-v2.svg file must contain:
| `{{LABEL_Y}}` | Y position for lane label | 140 |
| `{{LANE_NAME}}` | Lane name text (XML-escaped) | "Development" |
### Task Template Placeholders
### Item Template Placeholders
| Placeholder | Description | Example Value |
|------------|-------------|---------------|
| `{{TASK_X}}` | X position of task marker | 400 |
| `{{TASK_Y}}` | Y position of task marker | 150 |
| `{{TEXT_X}}` | X position for task text | 412 |
| `{{TEXT_Y}}` | Y position for task text | 154 |
| `{{TASK_ID}}` | Task ID (XML-escaped) | "T-123" |
| `{{TASK_TITLE}}` | Task title (XML-escaped) | "Implement feature" |
| `{{ITEM_X}}` | X position of item marker | 400 |
| `{{ITEM_Y}}` | Y position of item marker | 150 |
| `{{TEXT_X}}` | X position for item text | 412 |
| `{{TEXT_Y}}` | Y position for item text | 154 |
| `{{ITEM_ID}}` | Item ID (XML-escaped) | "T-123" |
| `{{ITEM_TITLE}}` | Item title (XML-escaped) | "Implement feature" |
**Dynamic Data Placeholders:**
The generator automatically creates placeholders for **all properties** in your CSV data using the naming convention: `ITEM_{PROPERTY_UPPERCASE}`.
For example, if your `fieldMapping` includes:
```json
{
"id": "ID",
"title": "Title",
"assignee": "Assignee",
"priority": "Priority"
}
```
The following placeholders become available:
- `{{ITEM_ID}}` - from the `id` field
- `{{ITEM_TITLE}}` - from the `title` field
- `{{ITEM_ASSIGNEE}}` - from the `assignee` field
- `{{ITEM_PRIORITY}}` - from the `priority` field
**Note:** The `due` field is used for positioning and is not available as a placeholder (use `{{MONTH_LABEL}}` for date display).
### Global Placeholders
@@ -107,7 +129,7 @@ These appear in the main template body (not in template elements):
1. Open template-v2.svg in Inkscape
2. Locate template elements in the Layers panel (inside `<defs>`)
3. Edit shapes, colors, fonts, etc. as needed
4. **Important**: Keep `id="month-template"`, `id="lane-template"`, `id="task-template"` unchanged
4. **Important**: Keep `id="month-template"`, `id="lane-template"`, `id="item-template"` unchanged
5. **Important**: Keep `{{PLACEHOLDER}}` text exactly as is - these are replaced at runtime
6. Save file (keep SVG format, avoid Inkscape-specific extensions)
@@ -190,8 +212,8 @@ Use SVG filters:
</feMerge>
</filter>
<g id="task-template" style="display:none">
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6" fill="#1976D2" filter="url(#dropShadow)"/>
<g id="item-template" style="display:none">
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6" fill="#1976D2" filter="url(#dropShadow)"/>
...
</g>
</defs>
@@ -229,7 +251,7 @@ To change the overall layout, you would need to modify generator.js. Templates c
- Ensure template has all three required elements in `<defs>`:
- `<g id="month-template">`
- `<g id="lane-template">`
- `<g id="task-template">`
- `<g id="item-template">`
- Check that IDs are exactly as shown (case-sensitive)
- Verify elements are inside `<defs>` section

View File

@@ -36,12 +36,12 @@
font-size="14" font-weight="700">{{LANE_NAME}}</text>
</g>
<g id="task-template" style="display: none;">
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6"
<g id="item-template" style="display: none;">
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6"
fill="#2d8659" stroke="#4a9b6b" stroke-width="2"/>
<text x="{{TEXT_X}}" y="{{TEXT_Y}}" font-size="12" fill="#2d8659" font-weight="500">
<tspan class="item-id">{{TASK_ID}}: </tspan>
<tspan class="item-title" fill="#1e5a3d">{{TASK_TITLE}}</tspan>
<tspan class="item-id">{{ITEM_ID}}: </tspan>
<tspan class="item-title" fill="#1e5a3d">{{ITEM_TITLE}}</tspan>
</text>
</g>
</defs>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -54,13 +54,13 @@ window.timelineGenerator = {
validateTemplate(template) {
const hasMonthTemplate = template.includes('id="month-template"');
const hasLaneTemplate = template.includes('id="lane-template"');
const hasTaskTemplate = template.includes('id="task-template"');
const hasItemTemplate = template.includes('id="item-template"');
if (!hasMonthTemplate || !hasLaneTemplate || !hasTaskTemplate) {
if (!hasMonthTemplate || !hasLaneTemplate || !hasItemTemplate) {
const missing = [];
if (!hasMonthTemplate) missing.push('month-template');
if (!hasLaneTemplate) missing.push('lane-template');
if (!hasTaskTemplate) missing.push('task-template');
if (!hasItemTemplate) missing.push('item-template');
throw new Error(
`Template is missing required elements: ${missing.join(', ')}. ` +
@@ -97,7 +97,7 @@ window.timelineGenerator = {
// Extract template elements (will throw if not found)
const monthTemplate = this.extractTemplate(template, 'month-template');
const laneTemplate = this.extractTemplate(template, 'lane-template');
const taskTemplate = this.extractTemplate(template, 'task-template');
const itemTemplate = this.extractTemplate(template, 'item-template');
const monthLabelY = 90;
const gridTop = top - 20;
@@ -156,7 +156,7 @@ window.timelineGenerator = {
laneElement = laneElement.replace(/id="lane-template"/, '');
laneBlocks += laneElement;
// Generate tasks for this lane
// Generate items for this lane
const laneItems = laneMap.get(laneName).sort((a, b) => a.due - b.due);
laneItems.forEach((it, idx) => {
const mi = monthIndexForDate(it.due);
@@ -164,19 +164,26 @@ window.timelineGenerator = {
const cx = left + clampedMi * monthWidth + monthWidth * 0.5;
const cy = laneY + 10 + idx * 18;
const taskValues = {
TASK_X: cx,
TASK_Y: cy,
// Start with layout placeholders
const itemValues = {
ITEM_X: cx,
ITEM_Y: cy,
TEXT_X: cx + 12,
TEXT_Y: cy + 4,
TASK_ID: this.escapeXml(it.id || ""),
TASK_TITLE: this.escapeXml(it.title || "")
TEXT_Y: cy + 4
};
let taskElement = this.replacePlaceholders(taskTemplate, taskValues);
taskElement = taskElement.replace(/style="display:\s*none;?"/, '');
taskElement = taskElement.replace(/id="task-template"/, '');
laneBlocks += taskElement;
// Dynamically add data placeholders from all item properties
for (const [key, value] of Object.entries(it)) {
if (key !== 'due') { // Skip due date as it's used for positioning
const placeholderName = `ITEM_${key.toUpperCase()}`;
itemValues[placeholderName] = this.escapeXml(value || "");
}
}
let itemElement = this.replacePlaceholders(itemTemplate, itemValues);
itemElement = itemElement.replace(/style="display:\s*none;?"/, '');
itemElement = itemElement.replace(/id="item-template"/, '');
laneBlocks += itemElement;
});
});
@@ -193,7 +200,7 @@ window.timelineGenerator = {
processedTemplate = processedTemplate
.replace(/<g id="month-template"[^>]*>[\s\S]*?<\/g>/, '')
.replace(/<g id="lane-template"[^>]*>[\s\S]*?<\/g>/, '')
.replace(/<g id="task-template"[^>]*>[\s\S]*?<\/g>/, '');
.replace(/<g id="item-template"[^>]*>[\s\S]*?<\/g>/, '');
// Add width and height attributes to the SVG element
processedTemplate = processedTemplate.replace(

View File

@@ -36,12 +36,12 @@
font-size="14" font-weight="700">{{LANE_NAME}}</text>
</g>
<g id="task-template" style="display: none;">
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6"
<g id="item-template" style="display: none;">
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6"
fill="#3b82f6" stroke="#60a5fa" stroke-width="2"/>
<text x="{{TEXT_X}}" y="{{TEXT_Y}}" font-size="12" fill="#3b82f6" font-weight="500">
<tspan class="item-id">{{TASK_ID}}: </tspan>
<tspan class="item-title" fill="#1e40af">{{TASK_TITLE}}</tspan>
<tspan class="item-id">{{ITEM_ID}}: </tspan>
<tspan class="item-title" fill="#1e40af">{{ITEM_TITLE}}</tspan>
</text>
</g>
</defs>

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -66,12 +66,12 @@ describe('Timeline Generator', () => {
}).toThrow('Template is missing required elements: lane-template')
})
it('should throw error when template is missing task-template', () => {
const malformedTemplate = createMalformedTemplate('task-template')
it('should throw error when template is missing item-template', () => {
const malformedTemplate = createMalformedTemplate('item-template')
expect(() => {
timelineGenerator.generate(items, config, malformedTemplate)
}).toThrow('Template is missing required elements: task-template')
}).toThrow('Template is missing required elements: item-template')
})
it('should validate template with proper error message', () => {
@@ -179,8 +179,8 @@ describe('Timeline Generator', () => {
expect(result).not.toContain('{{MONTH_X}}')
expect(result).not.toContain('{{LANE_Y}}')
expect(result).not.toContain('{{TASK_X}}')
expect(result).not.toContain('{{TASK_TITLE}}')
expect(result).not.toContain('{{ITEM_X}}')
expect(result).not.toContain('{{ITEM_TITLE}}')
})
it('should contain task data in generated SVG', () => {
@@ -248,7 +248,7 @@ describe('Timeline Generator', () => {
expect(result).not.toContain('id="month-template"')
expect(result).not.toContain('id="lane-template"')
expect(result).not.toContain('id="task-template"')
expect(result).not.toContain('id="item-template"')
expect(result).not.toContain('style="display:none"')
})

View File

@@ -58,7 +58,7 @@ describe('Timeline Integration', () => {
// Verify no template placeholders remain
expect(viewer.innerHTML).not.toContain('{{MONTH_X}}')
expect(viewer.innerHTML).not.toContain('{{LANE_Y}}')
expect(viewer.innerHTML).not.toContain('{{TASK_X}}')
expect(viewer.innerHTML).not.toContain('{{ITEM_X}}')
// Verify download button enabled
const downloadBtn = document.getElementById('downloadSvg')
@@ -257,9 +257,9 @@ describe('Timeline Integration', () => {
expect(viewer).toBeTruthy()
})
it('should reject malformed template-v2 (missing task-template)', async () => {
it('should reject malformed template-v2 (missing item-template)', async () => {
const config = createSampleProject()
const malformedTemplate = createMalformedTemplate('task-template')
const malformedTemplate = createMalformedTemplate('item-template')
mockFetch(malformedTemplate)
mockFetch(createSampleCSV())

View File

@@ -61,9 +61,9 @@ export const createSampleTemplate = () => `<svg xmlns="http://www.w3.org/2000/sv
</g>
<!-- Task template -->
<g id="task-template" style="display:none">
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6" fill="#1976D2"/>
<text x="{{TEXT_X}}" y="{{TEXT_Y}}" font-family="Arial" font-size="11" fill="#424242">{{TASK_ID}} {{TASK_TITLE}}</text>
<g id="item-template" style="display:none">
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6" fill="#1976D2"/>
<text x="{{TEXT_X}}" y="{{TEXT_Y}}" font-family="Arial" font-size="11" fill="#424242">{{ITEM_ID}} {{ITEM_TITLE}}</text>
</g>
</defs>
@@ -80,8 +80,8 @@ export const createMalformedTemplate = (missingElement = 'month-template') => {
<g id="lane-template" style="display:none">
<rect x="{{LANE_X}}" y="{{LANE_Y}}" width="{{LANE_WIDTH}}" height="{{LANE_HEIGHT}}" fill="#FAFAFA"/>
</g>
<g id="task-template" style="display:none">
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6" fill="#1976D2"/>
<g id="item-template" style="display:none">
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6" fill="#1976D2"/>
</g>
</defs>
{{MONTHS}}
@@ -92,14 +92,14 @@ export const createMalformedTemplate = (missingElement = 'month-template') => {
<g id="month-template" style="display:none">
<line x1="{{MONTH_X}}" y1="{{GRID_TOP}}" x2="{{MONTH_X}}" y2="{{GRID_BOTTOM}}" stroke="#E0E0E0"/>
</g>
<g id="task-template" style="display:none">
<circle cx="{{TASK_X}}" cy="{{TASK_Y}}" r="6" fill="#1976D2"/>
<g id="item-template" style="display:none">
<circle cx="{{ITEM_X}}" cy="{{ITEM_Y}}" r="6" fill="#1976D2"/>
</g>
</defs>
{{MONTHS}}
{{LANES}}
</svg>`,
'task-template': `<svg xmlns="http://www.w3.org/2000/svg">
'item-template': `<svg xmlns="http://www.w3.org/2000/svg">
<defs>
<g id="month-template" style="display:none">
<line x1="{{MONTH_X}}" y1="{{GRID_TOP}}" x2="{{MONTH_X}}" y2="{{GRID_BOTTOM}}" stroke="#E0E0E0"/>