Fix inbox items refresh (#398)

* Fix inbox items refresh

* fixup! Fix inbox items refresh
This commit is contained in:
Chris 2025-10-07 17:10:33 +03:00 committed by GitHub
parent 27032b5594
commit 119b04acff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 374 additions and 7 deletions

View file

@ -1349,7 +1349,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

@ -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

@ -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

@ -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) {