Fix upcoming completed issue (#404)

* Fix upcoming completed issue

* fixup! Fix upcoming completed issue

* fixup! fixup! Fix upcoming completed issue

* Fix completed icon plscement

* fixup! Fix completed icon plscement

* Add upcoming section tests
This commit is contained in:
Chris 2025-10-11 00:08:13 +03:00 committed by GitHub
parent 6efb565a4e
commit a81ca2f2b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 265 additions and 111 deletions

View file

@ -2,24 +2,51 @@
module.exports = {
up: async (queryInterface, Sequelize) => {
// Update all projects: active=true -> state='in_progress', active=false -> state='idea'
await queryInterface.sequelize.query(`
UPDATE projects
SET state = CASE
WHEN active = 1 THEN 'in_progress'
ELSE 'idea'
END
`);
try {
const tableInfo = await queryInterface.describeTable('projects');
// Only migrate if 'active' column exists
if ('active' in tableInfo) {
await queryInterface.sequelize.query(`
UPDATE projects
SET state = CASE
WHEN active = 1 THEN 'in_progress'
ELSE 'idea'
END
`);
} else {
console.log(
'Column active does not exist in projects table, skipping data migration'
);
}
} catch (error) {
console.log(
'Migration error converting active to states:',
error.message
);
// Don't throw - allow migration to continue since state column already exists
}
},
down: async (queryInterface, Sequelize) => {
// Reverse the conversion: state='in_progress' -> active=true, others -> active=false
await queryInterface.sequelize.query(`
UPDATE projects
SET active = CASE
WHEN state = 'in_progress' THEN 1
ELSE 0
END
`);
try {
const tableInfo = await queryInterface.describeTable('projects');
// Only rollback if 'active' column exists
if ('active' in tableInfo) {
await queryInterface.sequelize.query(`
UPDATE projects
SET active = CASE
WHEN state = 'in_progress' THEN 1
ELSE 0
END
`);
}
} catch (error) {
console.log(
'Migration rollback error for active column:',
error.message
);
}
},
};

View file

@ -457,7 +457,8 @@ async function filterTasksByParams(params, userId, userTimezone) {
// Disable search functionality for upcoming view
if (params.type === 'upcoming') {
// Remove search-related parameters to prevent search functionality
params = { ...params, client_side_filtering: false };
// Keep client_side_filtering to allow frontend to control completed task visibility
params = { ...params };
delete params.search;
}
@ -563,16 +564,24 @@ async function filterTasksByParams(params, userId, userTimezone) {
const safeTimezone = getSafeTimezone(userTimezone);
const upcomingRange = getUpcomingRangeInUTC(safeTimezone, 7);
console.log('📅 UPCOMING RANGE DEBUG:');
console.log(` Timezone: ${safeTimezone}`);
console.log(
` Range Start (UTC): ${upcomingRange.start.toISOString()}`
);
console.log(
` Range End (UTC): ${upcomingRange.end.toISOString()}`
);
console.log(` User ID: ${userId}`);
// For upcoming view, we want to show recurring instances (children) with due dates
// Override the default whereClause to include recurring instances
// NOTE: Search functionality is disabled for upcoming view - ignore client_side_filtering
whereClause = {
user_id: userId,
parent_task_id: null, // Exclude subtasks from main task lists
due_date: {
[Op.between]: [upcomingRange.start, upcomingRange.end],
},
status: { [Op.notIn]: [Task.STATUS.DONE, 'done'] },
[Op.or]: [
// Include non-recurring tasks
{
@ -595,6 +604,15 @@ async function filterTasksByParams(params, userId, userTimezone) {
},
],
};
// Apply status filter based on client_side_filtering
if (params.status === 'done') {
whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] };
} else if (!params.client_side_filtering) {
// Only exclude completed tasks if not doing client-side filtering
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
}
// If client_side_filtering is true, don't add any status filter (include all)
break;
}
case 'next':
@ -1178,11 +1196,16 @@ router.get('/tasks', async (req, res) => {
// Debug logging for upcoming view
if (req.query.type === 'upcoming') {
console.log('🔍 UPCOMING TASKS DEBUG:');
tasks.forEach((task) => {
console.log(
`- ID: ${task.id}, Name: "${task.name}", Due: ${task.due_date}, Recur: ${task.recurrence_type}, Parent: ${task.recurring_parent_id}`
);
});
console.log(` Total tasks returned: ${tasks.length}`);
if (tasks.length > 0) {
tasks.forEach((task) => {
console.log(
`- ID: ${task.id}, Name: "${task.name}", Due: ${task.due_date}, Recur: ${task.recurrence_type}, Parent: ${task.recurring_parent_id}, Status: ${task.status}`
);
});
} else {
console.log(' ⚠️ No tasks matched the query!');
}
}
// Group upcoming tasks by day of week if requested

View file

@ -71,6 +71,7 @@ function getTodayBoundsInUTC(userTimezone) {
/**
* Get date range for "upcoming" tasks (next N days) in user timezone
* Includes today through N days ahead
* @param {string} userTimezone - User's timezone
* @param {number} days - Number of days to look ahead (default: 7)
* @returns {Object} { start: Date, end: Date } - UTC Date objects

View file

@ -0,0 +1,37 @@
import { test, expect, Page } from '@playwright/test';
// Shared login function
async function login(page: Page, baseURL: string | undefined) {
const appUrl = baseURL ?? process.env.APP_URL ?? 'http://localhost:8080';
await page.goto(appUrl + '/login');
const email = process.env.E2E_EMAIL || 'test@tududi.com';
const password = process.env.E2E_PASSWORD || 'password123';
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: /login/i }).click();
await expect(page).toHaveURL(/\/today$/);
return appUrl;
}
test('upcoming view loads and displays upcoming section', async ({ page, baseURL }) => {
const appUrl = await login(page, baseURL);
// Navigate to upcoming view
await page.goto(appUrl + '/upcoming');
await expect(page).toHaveURL(/\/upcoming/);
// Verify the page heading is visible
await expect(page.getByRole('heading', { name: 'Upcoming' })).toBeVisible();
// Wait for content to load
await page.waitForTimeout(1000);
// Verify we don't have the task creation input (upcoming view is read-only)
const taskInput = page.locator('[data-testid="new-task-input"]');
await expect(taskInput).not.toBeVisible();
});

View file

@ -240,27 +240,33 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
{/* Day column tasks */}
<div className="space-y-1.5">
{dayTasks.map((task) => (
<TaskItem
key={task.id}
task={task}
onTaskUpdate={
onTaskUpdate
}
onTaskCompletionToggle={
onTaskCompletionToggle
}
onTaskDelete={
onTaskDelete
}
projects={projects}
hideProjectName={
hideProjectName
}
onToggleToday={
onToggleToday
}
isUpcomingView={true}
/>
<div key={task.id}>
<TaskItem
task={task}
onTaskUpdate={
onTaskUpdate
}
onTaskCompletionToggle={
onTaskCompletionToggle
}
onTaskDelete={
onTaskDelete
}
projects={projects}
hideProjectName={
hideProjectName
}
onToggleToday={
onToggleToday
}
isUpcomingView={
true
}
showCompletedTasks={
showCompletedTasks
}
/>
</div>
))}
{/* Empty state for columns with no tasks */}

View file

@ -140,6 +140,7 @@ interface TaskItemProps {
hideProjectName?: boolean;
onToggleToday?: (taskId: number) => Promise<void>;
isUpcomingView?: boolean;
showCompletedTasks?: boolean;
}
const TaskItem: React.FC<TaskItemProps> = ({
@ -151,6 +152,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
hideProjectName = false,
onToggleToday,
isUpcomingView = false,
showCompletedTasks = false,
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
@ -162,6 +164,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
const [parentTaskModalOpen, setParentTaskModalOpen] = useState(false);
const [parentTask, setParentTask] = useState<Task | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
// Subtasks state
const [showSubtasks, setShowSubtasks] = useState(false);
@ -313,6 +316,20 @@ const TaskItem: React.FC<TaskItemProps> = ({
const handleToggleCompletion = async () => {
if (task.id) {
try {
// Check if task is being completed (not uncompleted)
const isCompletingTask =
task.status !== 'done' &&
task.status !== 2 &&
task.status !== 'archived' &&
task.status !== 3;
// If completing the task in upcoming view and not showing completed tasks, trigger animation
if (isCompletingTask && isUpcomingView && !showCompletedTasks) {
setIsAnimatingOut(true);
// Wait for animation to complete before updating state
await new Promise((resolve) => setTimeout(resolve, 300));
}
const response = await toggleTaskCompletion(task.id);
// Handle the updated task
@ -366,6 +383,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
}
} catch (error) {
console.error('Error toggling task completion:', error);
setIsAnimatingOut(false); // Reset animation state on error
}
}
};
@ -411,11 +429,11 @@ const TaskItem: React.FC<TaskItemProps> = ({
return (
<>
<div
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-all duration-200 ease-in-out ${
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 relative overflow-visible transition-opacity duration-300 ease-in-out ${
isInProgress
? 'border-2 border-green-400/60 dark:border-green-500/60'
: ''
}`}
} ${isAnimatingOut ? 'opacity-0' : 'opacity-100'}`}
>
<TaskHeader
task={task}

View file

@ -83,8 +83,13 @@ const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({
) {
return (
<CheckCircleIcon
className={`h-4 w-4 ${colorClass} cursor-pointer flex-shrink-0`}
style={{ width: '16px', height: '16px' }}
className={`${colorClass} cursor-pointer flex-shrink-0 transition-all duration-300 ease-in-out animate-scale-in`}
style={{
width: '20px',
height: '20px',
marginLeft: '-2px',
marginRight: '-2px',
}}
onClick={handleClick}
title={getPriorityText()}
role="checkbox"
@ -95,7 +100,7 @@ const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({
} else {
return (
<div
className={`h-4 w-4 ${colorClass} cursor-pointer border-2 border-current rounded-full flex-shrink-0`}
className={`${colorClass} cursor-pointer border-2 border-current rounded-full flex-shrink-0 transition-all duration-300 ease-in-out hover:border-green-500 hover:bg-green-50 dark:hover:bg-green-900/20`}
style={{ width: '16px', height: '16px' }}
onClick={handleClick}
title={getPriorityText()}

View file

@ -67,36 +67,42 @@ const Tasks: React.FC = () => {
const displayTasks = useMemo(() => {
let filteredTasks;
// First filter by completion status
if (showCompleted) {
// Show only completed tasks (done=2 or archived=3)
filteredTasks = tasks.filter(
(task) =>
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3
);
// For upcoming view, don't filter by completion status here
// Let GroupedTaskList handle it
if (isUpcomingView) {
filteredTasks = tasks;
} else {
// Show only non-completed tasks - exclude done(2) and archived(3)
filteredTasks = tasks.filter(
(task) =>
task.status !== 'done' &&
task.status !== 'archived' &&
task.status !== 2 &&
task.status !== 3
);
}
// First filter by completion status
if (showCompleted) {
// Show only completed tasks (done=2 or archived=3)
filteredTasks = tasks.filter(
(task) =>
task.status === 'done' ||
task.status === 'archived' ||
task.status === 2 ||
task.status === 3
);
} else {
// Show only non-completed tasks - exclude done(2) and archived(3)
filteredTasks = tasks.filter(
(task) =>
task.status !== 'done' &&
task.status !== 'archived' &&
task.status !== 2 &&
task.status !== 3
);
}
// Then filter by search query if provided (skip for upcoming view)
if (taskSearchQuery.trim() && !isUpcomingView) {
const query = taskSearchQuery.toLowerCase();
filteredTasks = filteredTasks.filter(
(task) =>
task.name.toLowerCase().includes(query) ||
task.original_name?.toLowerCase().includes(query) ||
task.note?.toLowerCase().includes(query)
);
// Then filter by search query if provided (skip for upcoming view)
if (taskSearchQuery.trim()) {
const query = taskSearchQuery.toLowerCase();
filteredTasks = filteredTasks.filter(
(task) =>
task.name.toLowerCase().includes(query) ||
task.original_name?.toLowerCase().includes(query) ||
task.note?.toLowerCase().includes(query)
);
}
}
return filteredTasks;
@ -328,6 +334,23 @@ const Tasks: React.FC = () => {
task.id === updatedTask.id ? updatedTask : task
)
);
// Also update groupedTasks if they exist
if (groupedTasks) {
setGroupedTasks((prevGroupedTasks) => {
if (!prevGroupedTasks) return null;
const newGroupedTasks: GroupedTasks = {};
Object.entries(prevGroupedTasks).forEach(
([groupName, tasks]) => {
newGroupedTasks[groupName] = tasks.map((task) =>
task.id === updatedTask.id ? updatedTask : task
);
}
);
return newGroupedTasks;
});
}
};
const handleTaskDelete = async (taskId: number) => {
@ -507,40 +530,38 @@ const Tasks: React.FC = () => {
</span>
</button>
)}
{!isUpcomingView && (
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">
Show completed
</span>
<button
onClick={() => setShowCompleted((v) => !v)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">
Show completed
</span>
<button
onClick={() => setShowCompleted((v) => !v)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
showCompleted
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-600'
}`}
aria-pressed={showCompleted}
aria-label={
showCompleted
? 'Hide completed tasks'
: 'Show completed tasks'
}
title={
showCompleted
? 'Hide completed tasks'
: 'Show completed tasks'
}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showCompleted
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-600'
? 'translate-x-4'
: 'translate-x-0.5'
}`}
aria-pressed={showCompleted}
aria-label={
showCompleted
? 'Hide completed tasks'
: 'Show completed tasks'
}
title={
showCompleted
? 'Hide completed tasks'
: 'Show completed tasks'
}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
showCompleted
? 'translate-x-4'
: 'translate-x-0.5'
}`}
/>
</button>
</div>
)}
/>
</button>
</div>
<SortFilter
sortOptions={sortOptions}
sortValue={orderBy}

View file

@ -6,7 +6,23 @@ module.exports = {
'./app/views/**/*.erb', // Any .erb templates that might remain
],
theme: {
extend: {},
extend: {
keyframes: {
'scale-in': {
'0%': { transform: 'scale(0.8)', opacity: '0.5' },
'50%': { transform: 'scale(1.1)' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
},
animation: {
'scale-in': 'scale-in 0.3s ease-out',
'fade-in': 'fade-in 0.3s ease-out',
},
},
},
plugins: [],
// theme: {