feat: add primary tag functionality to tasks

- Introduced `primaryTagId` to `Task` model and updated related components to support selecting a primary tag.
- Enhanced `TaskCard`, `EditTaskForm`, and `TagInput` to handle primary tag selection and display.
- Updated `TasksService` to manage primary tag data during task creation and updates.
- Added `emoji-regex` dependency for improved emoji handling in task titles.
This commit is contained in:
Julien Froidefond
2025-10-01 21:11:50 +02:00
parent 014b0269dc
commit e2527ca88a
12 changed files with 168 additions and 42 deletions

View File

@@ -93,6 +93,7 @@ export async function updateTask(data: {
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
primaryTagId?: string;
dueDate?: Date;
}): Promise<ActionResult> {
try {
@@ -109,6 +110,7 @@ export async function updateTask(data: {
if (data.status !== undefined) updateData.status = data.status;
if (data.priority !== undefined) updateData.priority = data.priority;
if (data.tags !== undefined) updateData.tags = data.tags;
if (data.primaryTagId !== undefined) updateData.primaryTagId = data.primaryTagId;
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate;
const task = await tasksService.updateTask(data.taskId, updateData);
@@ -136,6 +138,7 @@ export async function createTask(data: {
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
primaryTagId?: string;
}): Promise<ActionResult> {
try {
if (!data.title.trim()) {
@@ -147,7 +150,8 @@ export async function createTask(data: {
description: data.description?.trim() || '',
status: data.status || 'todo',
priority: data.priority || 'medium',
tags: data.tags || []
tags: data.tags || [],
primaryTagId: data.primaryTagId
});
// Revalidation automatique du cache

View File

@@ -19,6 +19,7 @@ interface EditTaskFormProps {
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
primaryTagId?: string;
dueDate?: Date;
}) => Promise<void>;
task: Task | null;
@@ -38,6 +39,7 @@ export function EditTaskForm({
status: TaskStatus;
priority: TaskPriority;
tags: string[];
primaryTagId?: string;
dueDate?: Date;
}>({
title: '',
@@ -45,6 +47,7 @@ export function EditTaskForm({
status: 'todo' as TaskStatus,
priority: 'medium' as TaskPriority,
tags: [],
primaryTagId: undefined,
dueDate: undefined,
});
@@ -59,6 +62,7 @@ export function EditTaskForm({
status: task.status,
priority: task.priority,
tags: task.tags || [],
primaryTagId: task.primaryTagId,
dueDate: task.dueDate,
});
}
@@ -148,7 +152,9 @@ export function EditTaskForm({
<TaskTagsSection
taskId={task.id}
tags={formData.tags}
primaryTagId={formData.primaryTagId}
onTagsChange={(tags) => setFormData((prev) => ({ ...prev, tags }))}
onPrimaryTagChange={(primaryTagId) => setFormData((prev) => ({ ...prev, primaryTagId }))}
/>
{/* Actions */}

View File

@@ -6,20 +6,33 @@ import { RelatedTodos } from '@/components/forms/RelatedTodos';
interface TaskTagsSectionProps {
taskId: string;
tags: string[];
primaryTagId?: string;
onTagsChange: (tags: string[]) => void;
onPrimaryTagChange: (tagId: string | undefined) => void;
}
export function TaskTagsSection({ taskId, tags, onTagsChange }: TaskTagsSectionProps) {
export function TaskTagsSection({
taskId,
tags,
primaryTagId,
onTagsChange,
onPrimaryTagChange
}: TaskTagsSectionProps) {
return (
<>
{/* Tags */}
<div className="space-y-3">
<label className="block text-sm font-mono font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
Tags
<span className="text-xs normal-case ml-2 text-[var(--muted-foreground)]">
(cliquer sur un tag pour le sélectionner comme prioritaire)
</span>
</label>
<TagInput
tags={tags || []}
primaryTagId={primaryTagId}
onPrimaryTagChange={onPrimaryTagChange}
onChange={onTagsChange}
placeholder="Ajouter des tags..."
maxTags={10}

View File

@@ -73,6 +73,7 @@ export function TaskCard({ task, onEdit, compactView = false }: TaskCardProps) {
title={task.title}
description={task.description}
tags={task.tags}
primaryTagId={task.primaryTagId}
priority={task.priority}
status={task.status}
dueDate={task.dueDate}

View File

@@ -156,4 +156,4 @@ export function TagList({
))}
</div>
);
}
}

View File

@@ -4,11 +4,14 @@ import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Tag } from '@/lib/types';
import { useTagsAutocomplete } from '@/hooks/useTags';
import { tagsClient } from '@/clients/tags-client';
import { Badge } from './Badge';
interface TagInputProps {
tags: string[];
onChange: (tags: string[]) => void;
primaryTagId?: string;
onPrimaryTagChange?: (tagId: string | undefined) => void;
placeholder?: string;
maxTags?: number;
className?: string;
@@ -18,6 +21,8 @@ interface TagInputProps {
export function TagInput({
tags,
onChange,
primaryTagId,
onPrimaryTagChange,
placeholder = "Ajouter des tags...",
maxTags = 10,
className = "",
@@ -32,6 +37,21 @@ export function TagInput({
const containerRef = useRef<HTMLDivElement>(null);
const { suggestions, loading, searchTags, clearSuggestions, loadPopularTags } = useTagsAutocomplete();
const [allTags, setAllTags] = useState<Tag[]>([]);
// Charger tous les tags au début pour pouvoir identifier le tag prioritaire
useEffect(() => {
const loadTags = async () => {
try {
const response = await tagsClient.getPopularTags(100);
setAllTags(response.data);
} catch (error) {
console.error('Erreur lors du chargement des tags:', error);
setAllTags([]);
}
};
loadTags();
}, []);
// Calculer la position du dropdown
const updateDropdownPosition = () => {
@@ -87,6 +107,28 @@ export function TagInput({
const removeTag = (tagToRemove: string) => {
onChange(tags.filter(tag => tag !== tagToRemove));
// Si on supprime le tag prioritaire, le désélectionner
if (primaryTagId && onPrimaryTagChange && allTags) {
const tagToRemoveObj = allTags.find(tag => tag.name === tagToRemove);
if (tagToRemoveObj && tagToRemoveObj.id === primaryTagId) {
onPrimaryTagChange(undefined);
}
}
};
const handleTagClick = (tagName: string) => {
if (!onPrimaryTagChange || !allTags) return;
const tagObj = allTags.find(tag => tag.name === tagName);
if (!tagObj) return;
if (primaryTagId === tagObj.id) {
// Désélectionner si c'est déjà sélectionné
onPrimaryTagChange(undefined);
} else {
// Sélectionner comme prioritaire
onPrimaryTagChange(tagObj.id);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -147,23 +189,40 @@ export function TagInput({
<div className="min-h-[42px] p-2 border border-[var(--border)] rounded-lg bg-[var(--input)] focus-within:border-[var(--primary)] focus-within:ring-1 focus-within:ring-[var(--primary)]/20 transition-colors">
<div className="flex flex-wrap gap-1 items-center">
{/* Tags existants */}
{tags.map((tag, index) => (
<Badge
key={index}
variant="default"
className="flex items-center gap-1 px-2 py-1 text-xs"
>
<span>{tag}</span>
<button
type="button"
onClick={() => removeTag(tag)}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] ml-1"
aria-label={`Supprimer le tag ${tag}`}
{tags.map((tag, index) => {
const tagObj = allTags?.find(t => t.name === tag);
const isPrimary = tagObj && primaryTagId === tagObj.id;
return (
<Badge
key={index}
variant="default"
className={`flex items-center gap-1 px-2 py-1 text-xs transition-all ${
isPrimary
? 'ring-2 ring-[var(--primary)] bg-[var(--primary)]/10'
: onPrimaryTagChange
? 'cursor-pointer hover:ring-1 hover:ring-[var(--primary)]/50'
: ''
}`}
onClick={onPrimaryTagChange ? () => handleTagClick(tag) : undefined}
title={onPrimaryTagChange ? (isPrimary ? 'Tag prioritaire (cliquer pour désélectionner)' : 'Cliquer pour sélectionner comme prioritaire') : undefined}
>
×
</button>
</Badge>
))}
<span>{tag}</span>
{isPrimary && <span className="text-[var(--primary)] font-bold"></span>}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTag(tag);
}}
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] ml-1"
aria-label={`Supprimer le tag ${tag}`}
>
×
</button>
</Badge>
);
})}
{/* Input pour nouveau tag */}
{tags.length < maxTags && (

View File

@@ -4,6 +4,7 @@ import { Card } from './Card';
import { Badge } from './Badge';
import { TagDisplay } from './TagDisplay';
import { formatDateForDisplay } from '@/lib/date-utils';
import emojiRegex from 'emoji-regex';
interface TaskCardProps extends HTMLAttributes<HTMLDivElement> {
// Variants
@@ -14,6 +15,7 @@ interface TaskCardProps extends HTMLAttributes<HTMLDivElement> {
title: string;
description?: string;
tags?: string[];
primaryTagId?: string; // ID du tag prioritaire
priority?: 'low' | 'medium' | 'high' | 'urgent';
// Status & metadata
@@ -56,6 +58,7 @@ const TaskCard = forwardRef<HTMLDivElement, TaskCardProps>(
title,
description,
tags = [],
primaryTagId,
priority = 'medium',
status,
dueDate,
@@ -203,18 +206,30 @@ const TaskCard = forwardRef<HTMLDivElement, TaskCardProps>(
return colors[priority as keyof typeof colors] || colors.medium;
};
// Extraire les emojis du titre
const emojiRegex = /(?:[\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}])(?:[\u{200D}][\u{1F600}-\u{1F64F}]|[\u{1F300}-\u{1F5FF}]|[\u{1F680}-\u{1F6FF}]|[\u{1F900}-\u{1F9FF}]|[\u{1F1E0}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}])*/gu;
const titleEmojis = title.match(emojiRegex) || [];
const titleWithoutEmojis = title.replace(emojiRegex, '').trim();
// Fonction pour extraire les emojis avec la lib emoji-regex
const extractEmojis = (text: string): string[] => {
const regex = emojiRegex();
return text.match(regex) || [];
};
// Si pas d'emoji dans le titre, utiliser l'emoji du premier tag
const titleEmojis = extractEmojis(title);
const titleWithoutEmojis = title.replace(emojiRegex(), '').trim();
// Si pas d'emoji dans le titre, utiliser l'emoji du tag prioritaire ou du premier tag
let displayEmojis: string[] = titleEmojis;
if (displayEmojis.length === 0 && tags && tags.length > 0) {
const firstTag = availableTags.find((tag) => tag.name === tags[0]);
if (firstTag) {
const tagEmojis = firstTag.name.match(emojiRegex);
if (tagEmojis && tagEmojis.length > 0) {
// Priorité au tag prioritaire, sinon premier tag
let tagToUse = null;
if (primaryTagId && availableTags) {
tagToUse = availableTags.find((tag) => tag.id === primaryTagId);
}
if (!tagToUse) {
tagToUse = availableTags.find((tag) => tag.name === tags[0]);
}
if (tagToUse) {
const tagEmojis = extractEmojis(tagToUse.name);
if (tagEmojis.length > 0) {
displayEmojis = [tagEmojis[0]]; // Prendre seulement le premier emoji du tag
}
}

View File

@@ -43,6 +43,8 @@ export interface Task {
sourceId?: string;
tags: string[];
tagDetails?: Tag[]; // Tags avec informations complètes (isPinned, etc.)
primaryTagId?: string; // ID du tag prioritaire à afficher en premier
primaryTag?: Tag; // Tag prioritaire avec informations complètes
dueDate?: Date;
completedAt?: Date;
createdAt: Date;

View File

@@ -38,6 +38,7 @@ export class TasksService {
tag: true
}
},
primaryTag: true,
_count: {
select: {
dailyCheckboxes: true
@@ -65,6 +66,7 @@ export class TasksService {
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
primaryTagId?: string;
dueDate?: Date;
}): Promise<Task> {
const status = taskData.status || 'todo';
@@ -75,6 +77,7 @@ export class TasksService {
status: status,
priority: taskData.priority || 'medium',
dueDate: taskData.dueDate,
primaryTagId: taskData.primaryTagId,
source: 'manual', // Source manuelle
sourceId: `manual-${Date.now()}`, // ID unique
// Si créée directement en done/archived, définir completedAt
@@ -85,7 +88,8 @@ export class TasksService {
include: {
tag: true
}
}
},
primaryTag: true
}
});
@@ -102,7 +106,8 @@ export class TasksService {
include: {
tag: true
}
}
},
primaryTag: true
}
});
@@ -118,6 +123,7 @@ export class TasksService {
status?: TaskStatus;
priority?: TaskPriority;
tags?: string[];
primaryTagId?: string;
dueDate?: Date;
}): Promise<Task> {
const task = await prisma.task.findUnique({
@@ -129,12 +135,13 @@ export class TasksService {
}
// Logique métier : si on marque comme terminé, on ajoute la date
const updateData: Prisma.TaskUpdateInput = {
const updateData: Prisma.TaskUpdateInput & { primaryTagId?: string } = {
title: updates.title,
description: updates.description,
status: updates.status,
priority: updates.priority,
dueDate: updates.dueDate,
primaryTagId: updates.primaryTagId,
updatedAt: getToday()
};
@@ -151,7 +158,7 @@ export class TasksService {
await prisma.task.update({
where: { id: taskId },
data: updateData
data: updateData as Prisma.TaskUpdateInput
});
// Mettre à jour les relations avec les tags
@@ -167,7 +174,8 @@ export class TasksService {
include: {
tag: true
}
}
},
primaryTag: true
}
});
@@ -389,6 +397,13 @@ export class TasksService {
sourceId: prismaTask.sourceId ?? undefined,
tags: tags,
tagDetails: tagDetails,
primaryTagId: prismaTask.primaryTagId ?? undefined,
primaryTag: ('primaryTag' in prismaTask && prismaTask.primaryTag) ? {
id: (prismaTask.primaryTag as { id: string }).id,
name: (prismaTask.primaryTag as { name: string }).name,
color: (prismaTask.primaryTag as { color: string }).color,
isPinned: (prismaTask.primaryTag as { isPinned: boolean }).isPinned
} : undefined,
dueDate: prismaTask.dueDate ?? undefined,
completedAt: prismaTask.completedAt ?? undefined,
createdAt: prismaTask.createdAt,