Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 38 additions & 3 deletions src/ui/renderers/tagRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
*/
Expand Down
84 changes: 83 additions & 1 deletion tests/unit/tagRenderer.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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+$/);
});
});