feat: añade paleta determinista por group_id y en TaskItem; añade tests

Co-authored-by: aider (openrouter/openai/gpt-5) <aider@aider.chat>
webui
brobert 2 weeks ago
parent f0eb723020
commit 5f03caace6

@ -10,6 +10,7 @@
import { tick, onDestroy } from "svelte"; import { tick, onDestroy } from "svelte";
import Popover from "$lib/ui/feedback/Popover.svelte"; import Popover from "$lib/ui/feedback/Popover.svelte";
import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone"; import { normalizeDigits, buildWaMeUrl } from "$lib/utils/phone";
import { colorForGroup } from "$lib/utils/groupColor";
export let id: number; export let id: number;
export let description: string; export let description: string;
@ -20,6 +21,7 @@
export let completed: boolean = false; export let completed: boolean = false;
export let completed_at: string | null = null; export let completed_at: string | null = null;
export let groupName: string | null = null; export let groupName: string | null = null;
export let groupId: string | null = null;
const code = display_code ?? id; const code = display_code ?? id;
const codeStr = String(code).padStart(4, "0"); const codeStr = String(code).padStart(4, "0");
@ -33,6 +35,7 @@
$: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date)); $: imminent = !!due_date && (isToday(due_date) || isTomorrow(due_date));
$: dateDmy = due_date ? ymdToDmy(due_date) : ""; $: dateDmy = due_date ? ymdToDmy(due_date) : "";
$: groupLabel = groupName != null ? groupName : "Personal"; $: groupLabel = groupName != null ? groupName : "Personal";
$: gc = groupId ? colorForGroup(groupId) : null;
let editing = false; let editing = false;
let dateValue: string = due_date ?? ""; let dateValue: string = due_date ?? "";
@ -270,7 +273,11 @@
</div> </div>
<div class="meta"> <div class="meta">
<span class="group-badge" title="Grupo">{groupLabel}</span> <span
class="group-badge"
title="Grupo"
style={gc ? `--gc-border: ${gc.border}; --gc-bg: ${gc.bg}; --gc-text: ${gc.text};` : undefined}
>{groupLabel}</span>
{#if due_date} {#if due_date}
<span <span
class="date-badge" class="date-badge"
@ -504,7 +511,9 @@
.group-badge { .group-badge {
padding: 2px 6px; padding: 2px 6px;
border-radius: 999px; border-radius: 999px;
border: 1px solid var(--color-border); border: 1px solid var(--gc-border, var(--color-border));
background: var(--gc-bg, transparent);
color: var(--gc-text, inherit);
font-size: 12px; font-size: 12px;
} }
.date-badge { .date-badge {

@ -0,0 +1,53 @@
export type GroupColor = {
border: string;
bg: string;
text: string;
};
const PALETTE: GroupColor[] = [
// 1) Blue
{ border: '#2563EB', bg: '#DBEAFE', text: '#1E3A8A' },
// 2) Indigo
{ border: '#4F46E5', bg: '#E0E7FF', text: '#312E81' },
// 3) Violet
{ border: '#7C3AED', bg: '#EDE9FE', text: '#4C1D95' },
// 4) Purple
{ border: '#9333EA', bg: '#F3E8FF', text: '#581C87' },
// 5) Fuchsia
{ border: '#C026D3', bg: '#FAE8FF', text: '#701A75' },
// 6) Pink
{ border: '#DB2777', bg: '#FCE7F3', text: '#831843' },
// 7) Rose
{ border: '#E11D48', bg: '#FFE4E6', text: '#881337' },
// 8) Red
{ border: '#DC2626', bg: '#FEE2E2', text: '#7F1D1D' },
// 9) Orange
{ border: '#EA580C', bg: '#FFE7D1', text: '#7C2D12' },
// 10) Amber
{ border: '#D97706', bg: '#FEF3C7', text: '#78350F' },
// 11) Green
{ border: '#16A34A', bg: '#DCFCE7', text: '#14532D' },
// 12) Teal
{ border: '#0D9488', bg: '#CCFBF1', text: '#134E4A' }
];
function hashString(input: string): number {
// Hash sencillo y rápido (similar a multiplicador 31)
let h = 0;
for (let i = 0; i < input.length; i++) {
h = (h * 31 + input.charCodeAt(i)) | 0;
}
// Convertir a entero positivo de 32 bits
return h >>> 0;
}
/**
* Devuelve un esquema de color determinista para un groupId dado.
* - Si groupId es falsy o vacío, devuelve null (usar estilos neutros por defecto).
*/
export function colorForGroup(groupId: string | null | undefined): GroupColor | null {
const s = String(groupId || '').trim();
if (!s) return null;
const idx = hashString(s) % PALETTE.length;
return PALETTE[idx];
}

@ -85,7 +85,7 @@
<Card> <Card>
<ul class="list"> <ul class="list">
{#each data.openTasks as t} {#each data.openTasks as t}
<TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} /> <TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} groupId={t.group_id} />
{/each} {/each}
</ul> </ul>
</Card> </Card>
@ -108,7 +108,7 @@
<Card> <Card>
<ul class="list"> <ul class="list">
{#each g.tasks as t} {#each g.tasks as t}
<TaskItem {...t} currentUserId={data.userId} groupName={g.name} /> <TaskItem {...t} currentUserId={data.userId} groupName={g.name} groupId={t.group_id} />
{/each} {/each}
</ul> </ul>
</Card> </Card>
@ -117,7 +117,7 @@
<Card> <Card>
<ul class="list"> <ul class="list">
{#each data.unassignedOpen as t} {#each data.unassignedOpen as t}
<TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} /> <TaskItem {...t} currentUserId={data.userId} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} groupId={t.group_id} />
{/each} {/each}
</ul> </ul>
</Card> </Card>
@ -134,6 +134,7 @@
<TaskItem <TaskItem
{...t} {...t}
currentUserId={data.userId} currentUserId={data.userId}
groupId={t.group_id}
completed={true} completed={true}
completed_at={t.completed_at ?? null} completed_at={t.completed_at ?? null}
groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'} groupName={t.group_id ? (data.groupNames[t.group_id] || t.group_id) : 'Personal'}

@ -131,6 +131,7 @@
assignees={t.assignees || []} assignees={t.assignees || []}
currentUserId={data.userId} currentUserId={data.userId}
groupName={g.name ?? g.id} groupName={g.name ?? g.id}
groupId={t.group_id ?? g.id}
/> />
{/each} {/each}
</ul> </ul>

@ -0,0 +1,43 @@
import { describe, it, expect } from 'bun:test';
import { colorForGroup } from '../../../apps/web/src/lib/utils/groupColor';
function isHexColor(s: string): boolean {
return /^#[0-9A-Fa-f]{6}$/.test(s);
}
describe('groupColor - colorForGroup', () => {
it('devuelve null para groupId vacío o nulo', () => {
expect(colorForGroup(null)).toBeNull();
expect(colorForGroup(undefined)).toBeNull();
expect(colorForGroup('')).toBeNull();
expect(colorForGroup(' ')).toBeNull();
});
it('es determinista: misma entrada → misma salida', () => {
const a = colorForGroup('123@g.us');
const b = colorForGroup('123@g.us');
expect(a).not.toBeNull();
expect(b).not.toBeNull();
expect(a?.border).toBe(b?.border);
expect(a?.bg).toBe(b?.bg);
expect(a?.text).toBe(b?.text);
});
it('devuelve colores hex válidos', () => {
const c = colorForGroup('group-xyz@g.us');
expect(c).not.toBeNull();
expect(isHexColor(c!.border)).toBe(true);
expect(isHexColor(c!.bg)).toBe(true);
expect(isHexColor(c!.text)).toBe(true);
});
it('tiene distribución razonable en distintos IDs', () => {
const uniq = new Set<string>();
for (let i = 0; i < 30; i++) {
const c = colorForGroup(`group-${i}@g.us`);
if (c) uniq.add(`${c.border}|${c.bg}|${c.text}`);
}
// Con 12 paletas, deberíamos cubrir bastantes índices con 30 IDs
expect(uniq.size).toBeGreaterThan(8);
});
});
Loading…
Cancel
Save