diff --git a/src/ui/renderers/tagRenderer.ts b/src/ui/renderers/tagRenderer.ts index c231800c..48fb82b9 100644 --- a/src/ui/renderers/tagRenderer.ts +++ b/src/ui/renderers/tagRenderer.ts @@ -74,8 +74,9 @@ export function renderContextsValue( if (typeof value === "string") { const normalized = normalizeContext(value); if (normalized) { + const colorClass = getContextColorClass(normalized); const el = container.createEl("span", { - cls: "context-tag", + cls: `context-tag ${colorClass}`, text: normalized, attr: { role: "button", @@ -112,8 +113,9 @@ export function renderContextsValue( const normalized = normalizeContext(context); if (normalized) { + const colorClass = getContextColorClass(normalized); const el = container.createEl("span", { - cls: "context-tag", + cls: `context-tag ${colorClass}`, text: normalized, attr: { role: "button", @@ -165,7 +167,40 @@ export function normalizeTag(raw: string): string | null { } return cleaned ? `#${cleaned}` : null; -} /** +}/** + * Generate a simple hash from a string for consistent color mapping. + * Uses djb2 algorithm for good distribution with short strings. + */ +function simpleHash(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return hash >>> 0; // Convert to unsigned 32-bit integer +} + +/** + * Generate a CSS class for context coloring based on the context name. + * Returns a BEM modifier class like "context-tag--color-0" through "context-tag--color-19" (20 colors). + * The same context name will always produce the same color class. + */ +export function getContextColorClass(contextName: string): string { + if (!contextName || typeof contextName !== "string") { + return "context-tag--color-0"; + } + + // Remove the @ prefix if present, normalize to lowercase for consistent hashing + const name = contextName.replace(/^@/, "").toLowerCase(); + if (!name) { + return "context-tag--color-0"; + } + + const hash = simpleHash(name); + const colorIndex = hash % 20; // 20 distinct color classes + return `context-tag--color-${colorIndex}`; +} + +/** * Normalize context strings into @context form * Enhanced to handle spaces and special characters including Unicode */ diff --git a/tests/unit/tagRenderer.test.ts b/tests/unit/tagRenderer.test.ts index 17a1e7c0..d324e48e 100644 --- a/tests/unit/tagRenderer.test.ts +++ b/tests/unit/tagRenderer.test.ts @@ -1,4 +1,4 @@ -import { normalizeTag, normalizeContext } from '../../src/ui/renderers/tagRenderer'; +import { normalizeTag, normalizeContext, getContextColorClass } from '../../src/ui/renderers/tagRenderer'; describe('normalizeTag', () => { it('adds # prefix when missing and preserves slash in hierarchical tags', () => { @@ -39,3 +39,85 @@ describe('normalizeContext', () => { expect(normalizeContext('@')).toBeNull(); }); }); + +describe('getContextColorClass', () => { + it('returns a BEM modifier class in the expected format', () => { + const result = getContextColorClass('@work'); + expect(result).toMatch(/^context-tag--color-\d+$/); + }); + + it('returns consistent color class for the same context name', () => { + const first = getContextColorClass('@office'); + const second = getContextColorClass('@office'); + const third = getContextColorClass('@office'); + expect(first).toBe(second); + expect(second).toBe(third); + }); + + it('handles context names with and without @ prefix consistently', () => { + const withPrefix = getContextColorClass('@home'); + const withoutPrefix = getContextColorClass('home'); + expect(withPrefix).toBe(withoutPrefix); + }); + + it('is case-insensitive for consistent coloring', () => { + const lower = getContextColorClass('@work'); + const upper = getContextColorClass('@WORK'); + const mixed = getContextColorClass('@Work'); + expect(lower).toBe(upper); + expect(upper).toBe(mixed); + }); + + it('returns color index within valid range (0-19)', () => { + const testContexts = [ + '@home', '@work', '@office', '@phone', '@computer', + '@errands', '@waiting', '@someday', '@next', '@project', + '@meeting', '@email', '@focus', '@routine', '@travel' + ]; + + for (const ctx of testContexts) { + const result = getContextColorClass(ctx); + const match = result.match(/^context-tag--color-(\d+)$/); + expect(match).not.toBeNull(); + const index = parseInt(match![1], 10); + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(20); + } + }); + + it('produces different colors for different context names', () => { + // Note: Due to hash collisions, not all contexts will have different colors, + // but most common ones should be distinguishable + const contexts = ['@home', '@work', '@office', '@phone', '@computer']; + const colors = contexts.map(getContextColorClass); + const uniqueColors = new Set(colors); + + // At least 3 different colors for 5 different contexts + expect(uniqueColors.size).toBeGreaterThanOrEqual(3); + }); + + it('handles hierarchical context names', () => { + const result = getContextColorClass('@home/computer'); + expect(result).toMatch(/^context-tag--color-\d+$/); + + // Hierarchical should produce different color than parent + const parentResult = getContextColorClass('@home'); + // They may or may not be different due to hashing, but both should be valid + expect(parentResult).toMatch(/^context-tag--color-\d+$/); + }); + + it('handles empty and invalid inputs gracefully', () => { + expect(getContextColorClass('')).toBe('context-tag--color-0'); + expect(getContextColorClass('@')).toBe('context-tag--color-0'); + expect(getContextColorClass(null as unknown as string)).toBe('context-tag--color-0'); + expect(getContextColorClass(undefined as unknown as string)).toBe('context-tag--color-0'); + }); + + it('handles Unicode context names', () => { + const result = getContextColorClass('@büro'); + expect(result).toMatch(/^context-tag--color-\d+$/); + + const japaneseResult = getContextColorClass('@仕事'); + expect(japaneseResult).toMatch(/^context-tag--color-\d+$/); + }); +});