Merge branch 'main' into pro/user-perms

This commit is contained in:
Chris 2025-10-11 14:46:14 +03:00 committed by GitHub
commit e134ad96dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 649 additions and 120 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

@ -464,7 +464,8 @@ async function filterTasksByParams(params, userId, userTimezone) {
);
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;
}
let whereClause = {
@ -568,15 +569,23 @@ 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 = {
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
{
@ -599,6 +608,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':
@ -1189,11 +1207,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
@ -1372,7 +1395,8 @@ router.get('/task', async (req, res) => {
const serializedTask = await serializeTask(
task,
req.currentUser.timezone
req.currentUser.timezone,
{ skipDisplayNameTransform: true }
);
res.json(serializedTask);

View file

@ -146,6 +146,82 @@ describe('Inbox Routes', () => {
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should support pagination with limit and offset', async () => {
// Create 25 inbox items
const items = [];
for (let i = 1; i <= 25; i++) {
const item = await InboxItem.create({
content: `Item ${i}`,
status: 'added',
source: 'test',
user_id: user.id,
});
items.push(item);
// Small delay to ensure different timestamps
await new Promise((resolve) => setTimeout(resolve, 5));
}
// Test first page (default limit of 20)
const response1 = await agent.get('/api/inbox?limit=20&offset=0');
expect(response1.status).toBe(200);
expect(response1.body.items.length).toBe(20);
expect(response1.body.pagination.total).toBe(26); // 25 + 1 from beforeEach
expect(response1.body.pagination.hasMore).toBe(true);
expect(response1.body.pagination.offset).toBe(0);
expect(response1.body.pagination.limit).toBe(20);
// Test second page
const response2 = await agent.get('/api/inbox?limit=20&offset=20');
expect(response2.status).toBe(200);
expect(response2.body.items.length).toBe(6); // Remaining items
expect(response2.body.pagination.total).toBe(26);
expect(response2.body.pagination.hasMore).toBe(false);
expect(response2.body.pagination.offset).toBe(20);
});
it('should support loading more than 20 items at once', async () => {
// Create 30 inbox items
for (let i = 1; i <= 30; i++) {
await InboxItem.create({
content: `Item ${i}`,
status: 'added',
source: 'test',
user_id: user.id,
});
await new Promise((resolve) => setTimeout(resolve, 5));
}
// Request 40 items (should get all 31: 30 + 1 from beforeEach)
const response = await agent.get('/api/inbox?limit=40&offset=0');
expect(response.status).toBe(200);
expect(response.body.items.length).toBe(31);
expect(response.body.pagination.total).toBe(31);
expect(response.body.pagination.hasMore).toBe(false);
expect(response.body.pagination.limit).toBe(40);
});
it('should return items in newest-first order when paginating', async () => {
// Create 25 inbox items
for (let i = 1; i <= 25; i++) {
await InboxItem.create({
content: `Item ${i}`,
status: 'added',
source: 'test',
user_id: user.id,
});
await new Promise((resolve) => setTimeout(resolve, 5));
}
// Get first page
const response = await agent.get('/api/inbox?limit=20&offset=0');
expect(response.status).toBe(200);
// Verify newest items are first
const items = response.body.items;
expect(items[0].content).toBe('Item 25'); // Newest
expect(items[19].content).toBe('Item 6'); // 20th item
});
});
describe('GET /api/inbox/:uid', () => {

View file

@ -274,4 +274,46 @@ describe('Recurring Task Display Fixes', () => {
expect(taskNames).toContain('Daily');
});
});
describe('Task By UID Endpoint', () => {
it('should return actual task name when fetching recurring task by UID', async () => {
const recurringTask = await Task.create({
name: 'My Weekly Review',
user_id: user.id,
recurrence_type: 'weekly',
recurring_parent_id: null,
status: Task.STATUS.NOT_STARTED,
priority: Task.PRIORITY.MEDIUM,
});
const response = await agent.get(
`/api/task?uid=${recurringTask.uid}`
);
expect(response.status).toBe(200);
expect(response.body.name).toBe('My Weekly Review');
expect(response.body.original_name).toBe('My Weekly Review');
expect(response.body.recurrence_type).toBe('weekly');
});
it('should return actual task name for monthly recurring task by UID', async () => {
const monthlyTask = await Task.create({
name: 'Monthly Budget Review',
user_id: user.id,
recurrence_type: 'monthly',
recurring_parent_id: null,
status: Task.STATUS.NOT_STARTED,
priority: Task.PRIORITY.MEDIUM,
});
const response = await agent.get(
`/api/task?uid=${monthlyTask.uid}`
);
expect(response.status).toBe(200);
expect(response.body.name).toBe('Monthly Budget Review');
expect(response.body.original_name).toBe('Monthly Budget Review');
expect(response.body.recurrence_type).toBe('monthly');
});
});
});

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,212 @@
import { test, expect } from '@playwright/test';
// Shared login function
async function loginAndNavigateToTasks(page, baseURL) {
const appUrl = baseURL ?? process.env.APP_URL ?? 'http://localhost:8080';
// Go directly to login page first
await page.goto(appUrl + '/login');
// Fill credentials and 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();
// Wait for redirect to Today view
await expect(page).toHaveURL(/\/today$/);
// Navigate to tasks page
await page.goto(appUrl + '/tasks');
await expect(page).toHaveURL(/\/tasks/);
// Wait for the tasks page to fully load by waiting for the task input to be visible
await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 });
return appUrl;
}
// Helper function to create a recurring task via API
async function createRecurringTaskViaAPI(page, taskName: string, recurrenceType: string) {
// Use the browser context to make an API call
const response = await page.request.post('/api/task', {
data: {
name: taskName,
recurrence_type: recurrenceType,
status: 'not_started',
priority: 'medium'
}
});
expect(response.ok()).toBeTruthy();
const task = await response.json();
return task;
}
test('recurring task displays actual name (not "Weekly") after page refresh', async ({ page, baseURL }) => {
const appUrl = await loginAndNavigateToTasks(page, baseURL);
// Create a unique recurring task
const timestamp = Date.now();
const taskName = `My Weekly Review ${timestamp}`;
// Create a weekly recurring task via API
const task = await createRecurringTaskViaAPI(page, taskName, 'weekly');
// Refresh the page to simulate the bug scenario
await page.reload();
await page.waitForLoadState('networkidle');
// Wait for the tasks page to fully load
await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 });
// Click on the recurring task in the task list
const taskInList = page.locator('[data-testid*="task-item"]').filter({ hasText: taskName });
await expect(taskInList).toBeVisible({ timeout: 10000 });
await taskInList.click();
// Wait for the task modal/details page to open
// The task modal should show the task name input or a heading with the task name
await page.waitForTimeout(1000);
// Check if we're on the task detail page (URL-based) or modal (overlay)
const currentUrl = page.url();
if (currentUrl.includes(`/task/${task.uid}`)) {
// We're on the task detail page
// The title should be displayed correctly in the page heading or task name field
const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]');
// The task name should be the actual name, not "Weekly"
const displayedText = await taskNameElement.first().textContent();
expect(displayedText).toContain(taskName);
expect(displayedText).not.toBe('Weekly');
} else {
// We're in a modal
await expect(page.locator('[data-testid="task-name-input"]')).toBeVisible({ timeout: 5000 });
const taskNameInput = page.locator('[data-testid="task-name-input"]');
// The task name input should show the actual name, not "Weekly"
await expect(taskNameInput).toHaveValue(taskName);
await expect(taskNameInput).not.toHaveValue('Weekly');
}
});
test('monthly recurring task displays actual name (not "Monthly") after page refresh', async ({ page, baseURL }) => {
const appUrl = await loginAndNavigateToTasks(page, baseURL);
// Create a unique recurring task
const timestamp = Date.now();
const taskName = `Monthly Budget Review ${timestamp}`;
// Create a monthly recurring task via API
const task = await createRecurringTaskViaAPI(page, taskName, 'monthly');
// Refresh the page to simulate the bug scenario
await page.reload();
await page.waitForLoadState('networkidle');
// Wait for the tasks page to fully load
await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 });
// Click on the recurring task in the task list
const taskInList = page.locator('[data-testid*="task-item"]').filter({ hasText: taskName });
await expect(taskInList).toBeVisible({ timeout: 10000 });
await taskInList.click();
// Wait for the task modal/details page to open
await page.waitForTimeout(1000);
// Check if we're on the task detail page (URL-based) or modal (overlay)
const currentUrl = page.url();
if (currentUrl.includes(`/task/${task.uid}`)) {
// We're on the task detail page
const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]');
// The task name should be the actual name, not "Monthly"
const displayedText = await taskNameElement.first().textContent();
expect(displayedText).toContain(taskName);
expect(displayedText).not.toBe('Monthly');
} else {
// We're in a modal
await expect(page.locator('[data-testid="task-name-input"]')).toBeVisible({ timeout: 5000 });
const taskNameInput = page.locator('[data-testid="task-name-input"]');
// The task name input should show the actual name, not "Monthly"
await expect(taskNameInput).toHaveValue(taskName);
await expect(taskNameInput).not.toHaveValue('Monthly');
}
});
test('daily recurring task displays actual name (not "Daily") after page refresh', async ({ page, baseURL }) => {
const appUrl = await loginAndNavigateToTasks(page, baseURL);
// Create a unique recurring task
const timestamp = Date.now();
const taskName = `Daily Standup ${timestamp}`;
// Create a daily recurring task via API
const task = await createRecurringTaskViaAPI(page, taskName, 'daily');
// Refresh the page to simulate the bug scenario
await page.reload();
await page.waitForLoadState('networkidle');
// Wait for the tasks page to fully load
await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 });
// Click on the recurring task in the task list
const taskInList = page.locator('[data-testid*="task-item"]').filter({ hasText: taskName });
await expect(taskInList).toBeVisible({ timeout: 10000 });
await taskInList.click();
// Wait for the task modal/details page to open
await page.waitForTimeout(1000);
// Check if we're on the task detail page (URL-based) or modal (overlay)
const currentUrl = page.url();
if (currentUrl.includes(`/task/${task.uid}`)) {
// We're on the task detail page
const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]');
// The task name should be the actual name, not "Daily"
const displayedText = await taskNameElement.first().textContent();
expect(displayedText).toContain(taskName);
expect(displayedText).not.toBe('Daily');
} else {
// We're in a modal
await expect(page.locator('[data-testid="task-name-input"]')).toBeVisible({ timeout: 5000 });
const taskNameInput = page.locator('[data-testid="task-name-input"]');
// The task name input should show the actual name, not "Daily"
await expect(taskNameInput).toHaveValue(taskName);
await expect(taskNameInput).not.toHaveValue('Daily');
}
});
test('recurring task shows correct name without visiting Today page first', async ({ page, baseURL }) => {
const appUrl = await loginAndNavigateToTasks(page, baseURL);
// Create a unique recurring task
const timestamp = Date.now();
const taskName = `Weekly Planning ${timestamp}`;
// Create a weekly recurring task via API
const task = await createRecurringTaskViaAPI(page, taskName, 'weekly');
// Navigate directly to the task detail page using the task UID
await page.goto(`${appUrl}/task/${task.uid}`);
await page.waitForLoadState('networkidle');
// The task detail page should show the actual name, not "Weekly"
const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]');
await expect(taskNameElement.first()).toBeVisible({ timeout: 5000 });
const displayedText = await taskNameElement.first().textContent();
expect(displayedText).toContain(taskName);
expect(displayedText).not.toBe('Weekly');
});

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

@ -1,4 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import { Note } from '../../entities/Note';
@ -29,6 +30,10 @@ import { useStore } from '../../store/useStore';
const InboxItems: React.FC = () => {
const { t } = useTranslation();
const { showSuccessToast, showErrorToast } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
// Track if we've done the initial load from URL
const [hasInitialized, setHasInitialized] = useState(false);
// Access store data
const { inboxItems, isLoading, pagination } = useStore(
@ -80,8 +85,13 @@ const InboxItems: React.FC = () => {
);
useEffect(() => {
// Initial data loading
loadInboxItemsToStore(true);
// Read the page size from URL parameter or use default
const urlPageSize = searchParams.get('loaded');
const currentLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 20;
// Initial data loading - load the amount specified in URL
loadInboxItemsToStore(true, currentLoadedCount);
setHasInitialized(true);
// Load projects initially
const loadInitialProjects = async () => {
@ -122,7 +132,9 @@ const InboxItems: React.FC = () => {
const handleForceReload = () => {
// Wait a short time to ensure the backend has processed the new item
setTimeout(() => {
loadInboxItemsToStore(false); // Don't show loading state during forced reload
const currentInboxStore = useStore.getState().inboxStore;
const currentCount = currentInboxStore.inboxItems.length;
loadInboxItemsToStore(false, currentCount); // Preserve current loaded count
}, 500);
};
@ -162,7 +174,9 @@ const InboxItems: React.FC = () => {
// This ensures real-time updates when items are added externally
// Use a reasonable interval that balances responsiveness with performance
const pollInterval = setInterval(() => {
loadInboxItemsToStore(false); // Don't show loading state during polling
const currentInboxStore = useStore.getState().inboxStore;
const currentCount = currentInboxStore.inboxItems.length;
loadInboxItemsToStore(false, currentCount); // Preserve current loaded count
}, 15000); // Check for new items every 15 seconds
// Add event listeners
@ -182,6 +196,26 @@ const InboxItems: React.FC = () => {
};
}, [t, showSuccessToast]); // Include dependencies that are actually used
// Update URL when inboxItems count changes (but only after initialization)
useEffect(() => {
// Don't update URL until we've done the initial load from URL
if (!hasInitialized) return;
const urlPageSize = searchParams.get('loaded');
const urlLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 0;
// Only update URL if the count has actually changed from what's in the URL
if (inboxItems.length > 20 && inboxItems.length !== urlLoadedCount) {
setSearchParams(
{ loaded: inboxItems.length.toString() },
{ replace: true }
);
} else if (inboxItems.length <= 20 && urlLoadedCount > 0) {
// Remove the parameter if we're at the default page size
setSearchParams({}, { replace: true });
}
}, [inboxItems.length, hasInitialized]); // Track hasInitialized to prevent premature URL updates
const handleProcessItem = async (
uid: string,
showToast: boolean = true

View file

@ -61,7 +61,10 @@ const Projects: React.FC = () => {
}>({ isOpen: false, project: null });
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [viewMode, setViewMode] = useState<'cards' | 'list'>('cards');
const [viewMode, setViewMode] = useState<'cards' | 'list'>(() => {
const saved = localStorage.getItem('projectsViewMode');
return saved === 'list' || saved === 'cards' ? saved : 'cards';
});
const [isSearchExpanded, setIsSearchExpanded] = useState<boolean>(false);
const [orderBy, setOrderBy] = useState<string>('created_at:desc');
@ -126,6 +129,11 @@ const Projects: React.FC = () => {
loadAreas();
}, []);
// Persist viewMode to localStorage
useEffect(() => {
localStorage.setItem('projectsViewMode', viewMode);
}, [viewMode]);
// Projects are now loaded by Layout component into global store
// Modal state tracking removed after fixing the issue

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

@ -141,6 +141,7 @@ interface TaskItemProps {
hideProjectName?: boolean;
onToggleToday?: (taskId: number) => Promise<void>;
isUpcomingView?: boolean;
showCompletedTasks?: boolean;
}
const TaskItem: React.FC<TaskItemProps> = ({
@ -152,6 +153,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
hideProjectName = false,
onToggleToday,
isUpcomingView = false,
showCompletedTasks = false,
}) => {
const navigate = useNavigate();
const { t } = useTranslation();
@ -164,6 +166,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
const [parentTask, setParentTask] = useState<Task | null>(null);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
const { showErrorToast } = useToast();
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
// Subtasks state
const [showSubtasks, setShowSubtasks] = useState(false);
@ -327,6 +330,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
@ -380,6 +397,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
}
} catch (error) {
console.error('Error toggling task completion:', error);
setIsAnimatingOut(false); // Reset animation state on error
}
}
};
@ -425,11 +443,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

@ -118,7 +118,8 @@ const lastCheckTimestamp = Date.now();
// Store-aware functions
export const loadInboxItemsToStore = async (
isInitialLoad: boolean = false
isInitialLoad: boolean = false,
requestedCount: number = 20
): Promise<void> => {
const inboxStore = useStore.getState().inboxStore;
// Only show loading for initial load, not for polling
@ -128,7 +129,8 @@ export const loadInboxItemsToStore = async (
}
try {
const { items, pagination } = await fetchInboxItems(20, 0); // Always load first page for refresh
// Load the requested number of items (for pagination preservation)
const { items, pagination } = await fetchInboxItems(requestedCount, 0);
// Check for new items since last check (only for non-initial loads)
if (!isInitialLoad) {

View file

@ -1,6 +1,6 @@
{
"name": "tududi",
"version": "v0.83.1",
"version": "v0.83.2",
"description": "Self-hosted task management with hierarchical organization, multi-language support, and Telegram integration.",
"directories": {
"test": "test"

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: {