Upgrade task intelligence
This commit is contained in:
parent
7f5b259dbb
commit
6d1a82cc0a
5 changed files with 232 additions and 104 deletions
|
|
@ -15,6 +15,7 @@ import TaskModal from '../Task/TaskModal';
|
|||
import { fetchTaskById, updateTask, deleteTask } from '../../utils/tasksService';
|
||||
import { fetchProjects, createProject } from '../../utils/projectsService';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import { getVagueTasks } from '../../utils/taskIntelligenceService';
|
||||
|
||||
interface ProductivityInsight {
|
||||
type: 'stalled_projects' | 'completed_no_next' | 'tasks_are_projects' | 'vague_tasks' | 'overdue_tasks' | 'stuck_projects';
|
||||
|
|
@ -53,7 +54,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
const newInsights: ProductivityInsight[] = [];
|
||||
|
||||
// Filter to only include non-completed tasks
|
||||
const activeTasks = tasks.filter(task => task.status !== 'done' && task.status !== 'completed');
|
||||
const activeTasks = tasks.filter(task => task.status !== 'done' && task.status !== 'archived');
|
||||
|
||||
// 1. Stalled Projects (no tasks/actions)
|
||||
const stalledProjects = projects.filter(project =>
|
||||
|
|
@ -74,7 +75,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
// 2. Projects with completed tasks but no next action
|
||||
const projectsNeedingNextAction = projects.filter(project => {
|
||||
const projectTasks = tasks.filter(task => task.project_id === project.id);
|
||||
const hasCompletedTasks = projectTasks.some(task => task.status === 'done' || task.status === 'completed');
|
||||
const hasCompletedTasks = projectTasks.some(task => task.status === 'done' || task.status === 'archived');
|
||||
const hasNextAction = activeTasks.some(task =>
|
||||
task.project_id === project.id && (task.status === 'not_started' || task.status === 'in_progress')
|
||||
);
|
||||
|
|
@ -111,86 +112,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
}
|
||||
|
||||
// 4. Tasks without clear verbs
|
||||
const vagueTasks = activeTasks.filter(task => {
|
||||
const taskName = task.name.toLowerCase().trim();
|
||||
|
||||
// Skip if it's already a next action (contains →)
|
||||
if (taskName.includes('→')) return false;
|
||||
|
||||
// More comprehensive action verb patterns
|
||||
const actionVerbPatterns = [
|
||||
// Direct action verbs at start
|
||||
/^(call|email|text|message|phone|contact)/,
|
||||
/^(write|draft|compose|type|create|make)/,
|
||||
/^(read|review|check|examine|study|analyze)/,
|
||||
/^(buy|purchase|order|get|obtain|acquire)/,
|
||||
/^(schedule|book|arrange|plan|set up|setup)/,
|
||||
/^(meet|discuss|talk|speak|chat)/,
|
||||
/^(send|deliver|ship|mail|forward)/,
|
||||
/^(update|edit|modify|change|fix|correct)/,
|
||||
/^(finish|complete|finalize|wrap up)/,
|
||||
/^(submit|file|upload|post|publish)/,
|
||||
/^(organize|sort|clean|tidy|arrange)/,
|
||||
/^(research|find|search|look up|investigate)/,
|
||||
/^(prepare|gather|collect|assemble)/,
|
||||
/^(install|download|set up|configure)/,
|
||||
/^(test|try|experiment|validate)/,
|
||||
/^(backup|save|export|archive)/,
|
||||
/^(delete|remove|uninstall|cancel)/,
|
||||
|
||||
// Gerund forms (-ing verbs) which are often good actions
|
||||
/^(calling|emailing|writing|reading|buying|scheduling|meeting|sending|updating|finishing|submitting|organizing|researching|preparing|installing|testing|backing)/,
|
||||
|
||||
// Question patterns (usually clear next actions)
|
||||
/^(what|how|when|where|why|which)/,
|
||||
/\?$/,
|
||||
|
||||
// Imperative patterns with objects
|
||||
/^(add|remove|insert|attach|include|exclude)/,
|
||||
/^(start|begin|initiate|launch|kick off)/,
|
||||
/^(stop|end|terminate|close|shut)/,
|
||||
|
||||
// Common task patterns
|
||||
/^(follow up|followup)/,
|
||||
/^(sign up|signup)/,
|
||||
/^(log in|login)/,
|
||||
/^(pick up|pickup)/,
|
||||
/^(drop off|dropoff)/,
|
||||
/^(set up|setup)/,
|
||||
/^(clean up|cleanup)/,
|
||||
/^(wrap up|wrapup)/
|
||||
];
|
||||
|
||||
// Check if task starts with any action verb pattern
|
||||
const hasActionVerb = actionVerbPatterns.some(pattern => pattern.test(taskName));
|
||||
if (hasActionVerb) return false;
|
||||
|
||||
// Check for common non-actionable patterns (these are vague)
|
||||
const vaguePatterns = [
|
||||
// Single words without context
|
||||
/^[a-zA-Z]+$/,
|
||||
// Just names without action
|
||||
/^[A-Z][a-z]+ [A-Z][a-z]+$/,
|
||||
// Just project/area names
|
||||
/^(project|website|app|system|process|issue|problem|bug|feature)$/i,
|
||||
// Very short tasks without clear action (less than 3 words)
|
||||
/^(\w+\s+\w+|^\w+)$/
|
||||
];
|
||||
|
||||
// Only flag as vague if it matches vague patterns AND is not clearly actionable
|
||||
const isVague = vaguePatterns.some(pattern => pattern.test(taskName));
|
||||
|
||||
// Additional checks for good tasks that shouldn't be flagged
|
||||
const hasGoodStructure = (
|
||||
taskName.length > 15 || // Longer tasks are usually more specific
|
||||
taskName.split(' ').length > 3 || // More than 3 words usually means more specific
|
||||
/\b(for|with|to|from|about|regarding|re:|fwd:)\b/.test(taskName) || // Prepositions indicate context
|
||||
/\b(tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|next week|this week)\b/.test(taskName) || // Time references
|
||||
/\b(project|meeting|appointment|deadline|due|urgent|important)\b/.test(taskName) // Context indicators
|
||||
);
|
||||
|
||||
return isVague && !hasGoodStructure;
|
||||
});
|
||||
const vagueTasks = getVagueTasks(activeTasks);
|
||||
|
||||
if (vagueTasks.length > 0) {
|
||||
newInsights.push({
|
||||
|
|
@ -208,8 +130,8 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
const thresholdDate = new Date(now.getTime() - (OVERDUE_THRESHOLD_DAYS * 24 * 60 * 60 * 1000));
|
||||
|
||||
const staleTasks = activeTasks.filter(task => {
|
||||
const taskDate = task.updated_at ? new Date(task.updated_at) :
|
||||
task.created_at ? new Date(task.created_at) : null;
|
||||
// Only use created_at since updated_at doesn't exist in the interface
|
||||
const taskDate = task.created_at ? new Date(task.created_at) : null;
|
||||
|
||||
return taskDate && taskDate < thresholdDate;
|
||||
});
|
||||
|
|
@ -229,10 +151,19 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
const stuckProjects = projects.filter(project => {
|
||||
if (!project.active) return false;
|
||||
|
||||
const projectDate = project.updated_at ? new Date(project.updated_at) :
|
||||
project.created_at ? new Date(project.created_at) : null;
|
||||
// Projects don't have date fields in the interface, so we'll check if they have recent tasks
|
||||
const projectTasks = activeTasks.filter(task => task.project_id === project.id);
|
||||
|
||||
return projectDate && projectDate < thresholdDate;
|
||||
if (projectTasks.length === 0) return false; // Empty projects are handled by "stalled projects"
|
||||
|
||||
// Find the most recent task date for this project
|
||||
const mostRecentTaskDate = projectTasks.reduce((latest, task) => {
|
||||
const taskDate = task.created_at ? new Date(task.created_at) : null;
|
||||
if (!taskDate) return latest;
|
||||
return !latest || taskDate > latest ? taskDate : latest;
|
||||
}, null as Date | null);
|
||||
|
||||
return mostRecentTaskDate && mostRecentTaskDate < thresholdDate;
|
||||
});
|
||||
|
||||
if (stuckProjects.length > 0) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { useStore } from "../../store/useStore";
|
|||
import { fetchTags } from '../../utils/tagsService';
|
||||
import { fetchTaskById } from '../../utils/tasksService';
|
||||
import { getTaskIntelligenceEnabled } from '../../utils/profileService';
|
||||
import { analyzeTaskName, TaskAnalysis } from '../../utils/taskIntelligenceService';
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TaskModalProps {
|
||||
|
|
@ -49,7 +50,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
const [tagsLoading, setTagsLoading] = useState(false);
|
||||
const [parentTask, setParentTask] = useState<Task | null>(null);
|
||||
const [parentTaskLoading, setParentTaskLoading] = useState(false);
|
||||
const [showNameLengthHelper, setShowNameLengthHelper] = useState(false);
|
||||
const [taskAnalysis, setTaskAnalysis] = useState<TaskAnalysis | null>(null);
|
||||
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] = useState(true);
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -58,10 +59,12 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
setFormData(task);
|
||||
setTags(task.tags?.map((tag) => tag.name) || []);
|
||||
|
||||
// Check if task name is short and show helper when modal opens (only if intelligence is enabled)
|
||||
// Analyze task name and show helper when modal opens (only if intelligence is enabled)
|
||||
if (isOpen && task.name && taskIntelligenceEnabled) {
|
||||
const trimmedName = task.name.trim();
|
||||
setShowNameLengthHelper(trimmedName.length > 0 && trimmedName.length < 10);
|
||||
const analysis = analyzeTaskName(task.name);
|
||||
setTaskAnalysis(analysis);
|
||||
} else {
|
||||
setTaskAnalysis(null);
|
||||
}
|
||||
|
||||
// Safely find the current project, handling the case where projects might be undefined
|
||||
|
|
@ -167,10 +170,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
|
||||
// Show helper message for task name if it's too short (only if intelligence is enabled)
|
||||
// Analyze task name in real-time (only if intelligence is enabled)
|
||||
if (name === 'name' && taskIntelligenceEnabled) {
|
||||
const trimmedValue = value.trim();
|
||||
setShowNameLengthHelper(trimmedValue.length > 0 && trimmedValue.length < 10);
|
||||
const analysis = analyzeTaskName(value);
|
||||
setTaskAnalysis(analysis);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -312,21 +315,51 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder={t('forms.task.namePlaceholder', 'Add Task Name')}
|
||||
/>
|
||||
{showNameLengthHelper && taskIntelligenceEnabled && (
|
||||
<div className="mt-2 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-md">
|
||||
{taskAnalysis && taskAnalysis.isVague && taskIntelligenceEnabled && (
|
||||
<div className={`mt-2 p-3 rounded-md border ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
|
||||
}`}>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="h-4 w-4 text-blue-400 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg className={`h-4 w-4 mt-0.5 ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-400'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-400'
|
||||
: 'text-blue-400'
|
||||
}`} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
<strong>{t('task.nameHelper.title', 'Make it more descriptive!')}</strong>
|
||||
</p>
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||
{t('task.nameHelper.suggestion', 'Try adding more details like "Call dentist to schedule cleaning appointment" instead of just "Call dentist"')}
|
||||
<p className={`text-sm ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-800 dark:text-red-200'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-800 dark:text-yellow-200'
|
||||
: 'text-blue-800 dark:text-blue-200'
|
||||
}`}>
|
||||
<strong>
|
||||
{taskAnalysis.reason === 'short' && t('task.nameHelper.short', 'Make it more descriptive!')}
|
||||
{taskAnalysis.reason === 'no_verb' && t('task.nameHelper.noVerb', 'Add an action verb!')}
|
||||
{taskAnalysis.reason === 'vague_pattern' && t('task.nameHelper.vague', 'Be more specific!')}
|
||||
</strong>
|
||||
</p>
|
||||
{taskAnalysis.suggestion && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-700 dark:text-yellow-300'
|
||||
: 'text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
{taskAnalysis.suggestion}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
158
frontend/utils/taskIntelligenceService.ts
Normal file
158
frontend/utils/taskIntelligenceService.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { Task } from '../entities/Task';
|
||||
|
||||
/**
|
||||
* Analyzes a task name to determine if it's vague or needs improvement
|
||||
* Returns an object with analysis results and suggestions
|
||||
*/
|
||||
export interface TaskAnalysis {
|
||||
isVague: boolean;
|
||||
reason: 'short' | 'no_verb' | 'vague_pattern' | 'good';
|
||||
suggestion?: string;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
}
|
||||
|
||||
export const analyzeTaskName = (taskName: string): TaskAnalysis => {
|
||||
const trimmedName = taskName.toLowerCase().trim();
|
||||
|
||||
// Very short tasks (less than 10 chars) - original logic
|
||||
if (trimmedName.length > 0 && trimmedName.length < 10) {
|
||||
return {
|
||||
isVague: true,
|
||||
reason: 'short',
|
||||
suggestion: 'Try to be more specific about what needs to be done',
|
||||
severity: 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
// Skip if it's already a next action (contains →)
|
||||
if (trimmedName.includes('→')) {
|
||||
return {
|
||||
isVague: false,
|
||||
reason: 'good',
|
||||
severity: 'low'
|
||||
};
|
||||
}
|
||||
|
||||
// More comprehensive action verb patterns
|
||||
const actionVerbPatterns = [
|
||||
// Direct action verbs at start
|
||||
/^(call|email|text|message|phone|contact)/,
|
||||
/^(write|draft|compose|type|create|make)/,
|
||||
/^(read|review|check|examine|study|analyze)/,
|
||||
/^(buy|purchase|order|get|obtain|acquire)/,
|
||||
/^(schedule|book|arrange|plan|set up|setup)/,
|
||||
/^(meet|discuss|talk|speak|chat)/,
|
||||
/^(send|deliver|ship|mail|forward)/,
|
||||
/^(update|edit|modify|change|fix|correct)/,
|
||||
/^(finish|complete|finalize|wrap up)/,
|
||||
/^(submit|file|upload|post|publish)/,
|
||||
/^(organize|sort|clean|tidy|arrange)/,
|
||||
/^(research|find|search|look up|investigate)/,
|
||||
/^(prepare|gather|collect|assemble)/,
|
||||
/^(install|download|set up|configure)/,
|
||||
/^(test|try|experiment|validate)/,
|
||||
/^(backup|save|export|archive)/,
|
||||
/^(delete|remove|uninstall|cancel)/,
|
||||
|
||||
// Gerund forms (-ing verbs) which are often good actions
|
||||
/^(calling|emailing|writing|reading|buying|scheduling|meeting|sending|updating|finishing|submitting|organizing|researching|preparing|installing|testing|backing)/,
|
||||
|
||||
// Question patterns (usually clear next actions)
|
||||
/^(what|how|when|where|why|which)/,
|
||||
/\?$/,
|
||||
|
||||
// Imperative patterns with objects
|
||||
/^(add|remove|insert|attach|include|exclude)/,
|
||||
/^(start|begin|initiate|launch|kick off)/,
|
||||
/^(stop|end|terminate|close|shut)/,
|
||||
|
||||
// Common task patterns
|
||||
/^(follow up|followup)/,
|
||||
/^(sign up|signup)/,
|
||||
/^(log in|login)/,
|
||||
/^(pick up|pickup)/,
|
||||
/^(drop off|dropoff)/,
|
||||
/^(set up|setup)/,
|
||||
/^(clean up|cleanup)/,
|
||||
/^(wrap up|wrapup)/
|
||||
];
|
||||
|
||||
// Check if task starts with any action verb pattern
|
||||
const hasActionVerb = actionVerbPatterns.some(pattern => pattern.test(trimmedName));
|
||||
if (hasActionVerb) {
|
||||
return {
|
||||
isVague: false,
|
||||
reason: 'good',
|
||||
severity: 'low'
|
||||
};
|
||||
}
|
||||
|
||||
// Check for common non-actionable patterns (these are vague)
|
||||
const vaguePatterns = [
|
||||
// Single words without context
|
||||
/^[a-zA-Z]+$/,
|
||||
// Just names without action
|
||||
/^[A-Z][a-z]+ [A-Z][a-z]+$/,
|
||||
// Just project/area names
|
||||
/^(project|website|app|system|process|issue|problem|bug|feature)$/i,
|
||||
// Very short tasks without clear action (less than 3 words)
|
||||
/^(\w+\s+\w+|^\w+)$/
|
||||
];
|
||||
|
||||
// Only flag as vague if it matches vague patterns AND is not clearly actionable
|
||||
const matchesVaguePattern = vaguePatterns.some(pattern => pattern.test(trimmedName));
|
||||
|
||||
// Additional checks for good tasks that shouldn't be flagged
|
||||
const hasGoodStructure = (
|
||||
trimmedName.length > 15 || // Longer tasks are usually more specific
|
||||
trimmedName.split(' ').length > 3 || // More than 3 words usually means more specific
|
||||
/\b(for|with|to|from|about|regarding|re:|fwd:)\b/.test(trimmedName) || // Prepositions indicate context
|
||||
/\b(tomorrow|today|monday|tuesday|wednesday|thursday|friday|saturday|sunday|next week|this week)\b/.test(trimmedName) || // Time references
|
||||
/\b(project|meeting|appointment|deadline|due|urgent|important)\b/.test(trimmedName) // Context indicators
|
||||
);
|
||||
|
||||
if (matchesVaguePattern && !hasGoodStructure) {
|
||||
return {
|
||||
isVague: true,
|
||||
reason: 'vague_pattern',
|
||||
suggestion: 'Try starting with an action verb like "Call", "Write", "Schedule", or "Research"',
|
||||
severity: 'high'
|
||||
};
|
||||
}
|
||||
|
||||
// Check for missing action verbs (less strict)
|
||||
if (!hasActionVerb && trimmedName.split(' ').length <= 2) {
|
||||
return {
|
||||
isVague: true,
|
||||
reason: 'no_verb',
|
||||
suggestion: 'What specific action do you need to take? Try starting with a verb.',
|
||||
severity: 'medium'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isVague: false,
|
||||
reason: 'good',
|
||||
severity: 'low'
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters tasks to find vague ones using the enhanced logic
|
||||
*/
|
||||
export const getVagueTasks = (tasks: Task[]): Task[] => {
|
||||
return tasks.filter(task => {
|
||||
if (task.status === 'done' || task.status === 'archived') return false;
|
||||
|
||||
const analysis = analyzeTaskName(task.name);
|
||||
return analysis.isVague;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets a user-friendly suggestion for improving a task name
|
||||
*/
|
||||
export const getTaskNameSuggestion = (taskName: string): string | null => {
|
||||
const analysis = analyzeTaskName(taskName);
|
||||
return analysis.isVague ? (analysis.suggestion || null) : null;
|
||||
};
|
||||
|
|
@ -350,7 +350,10 @@
|
|||
"saveAsTask": "Αποθήκευση ως Εργασία",
|
||||
"nameHelper": {
|
||||
"title": "Κάντε το πιο περιγραφικό!",
|
||||
"suggestion": "Δοκιμάστε να προσθέσετε περισσότερες λεπτομέρειες όπως \"Καλέστε τον οδοντίατρο για ραντεβού καθαρισμού\" αντί για απλά \"Καλέστε οδοντίατρο\""
|
||||
"suggestion": "Δοκιμάστε να προσθέσετε περισσότερες λεπτομέρειες όπως \"Καλέστε τον οδοντίατρο για ραντεβού καθαρισμού\" αντί για απλά \"Καλέστε οδοντίατρο\"",
|
||||
"short": "Κάντε το πιο περιγραφικό!",
|
||||
"noVerb": "Προσθέστε ένα ρήμα ενέργειας!",
|
||||
"vague": "Να είστε πιο συγκεκριμένοι!"
|
||||
}
|
||||
},
|
||||
"dateFormats": {
|
||||
|
|
|
|||
|
|
@ -258,7 +258,10 @@
|
|||
"task": {
|
||||
"nameHelper": {
|
||||
"title": "Make it more descriptive!",
|
||||
"suggestion": "Try adding more details like \"Call dentist to schedule cleaning appointment\" instead of just \"Call dentist\""
|
||||
"suggestion": "Try adding more details like \"Call dentist to schedule cleaning appointment\" instead of just \"Call dentist\"",
|
||||
"short": "Make it more descriptive!",
|
||||
"noVerb": "Add an action verb!",
|
||||
"vague": "Be more specific!"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue