Merge branch 'main' into pro/user-perms
This commit is contained in:
commit
e134ad96dc
16 changed files with 649 additions and 120 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
212
e2e/tests/recurring-task.spec.ts
Normal file
212
e2e/tests/recurring-task.spec.ts
Normal 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');
|
||||
});
|
||||
37
e2e/tests/upcoming-task.spec.ts
Normal file
37
e2e/tests/upcoming-task.spec.ts
Normal 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();
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue