Move recurring elements together

This commit is contained in:
Chris Veleris 2025-06-20 12:04:36 +03:00
parent de94aa9a26
commit 8f5fd05926
17 changed files with 1514 additions and 109 deletions

5
.gitignore vendored
View file

@ -11,4 +11,7 @@ node_modules
public/js/bundle.js
.aider*
backend/coverage/
backend/coverage/
# User uploaded files
backend/uploads/

View file

@ -0,0 +1,256 @@
# Telegram Duplicate Prevention System
## Overview
This document describes the comprehensive duplicate prevention system implemented for Telegram message processing in the Tududi application. The system prevents duplicate inbox items from being created when the same message is processed multiple times due to network issues, processing errors, or other edge cases.
## Problem Statement
When integrating with Telegram's polling API, several scenarios can cause duplicate messages:
- Network timeouts causing message reprocessing
- Application restarts during message processing
- Race conditions in polling cycles
- Telegram API returning the same update multiple times
- Processing failures that lead to retry attempts
## Solution Architecture
The duplicate prevention system implements multiple layers of protection:
### 1. **Update ID Tracking (Application Level)**
- **Purpose**: Prevent reprocessing of the same Telegram update
- **Implementation**: In-memory Set tracking processed update IDs
- **Key Format**: `${userId}-${updateId}`
- **Memory Management**: Automatic cleanup (keeps last 1000 entries)
```javascript
// Example usage
const processedUpdates = new Set();
const updateKey = `${user.id}-${update.update_id}`;
if (!processedUpdates.has(updateKey)) {
// Process the update
processedUpdates.add(updateKey);
}
```
### 2. **Content-Based Duplicate Detection (Database Level)**
- **Purpose**: Prevent duplicate inbox items with identical content
- **Implementation**: Database query checking recent items (30-second window)
- **Scope**: Per-user, per-source (telegram)
```javascript
// Check for existing item within time window
const existingItem = await InboxItem.findOne({
where: {
content: messageContent,
user_id: userId,
source: 'telegram',
created_at: {
[Op.gte]: new Date(Date.now() - 30000) // 30 seconds
}
}
});
```
### 3. **Telegram API Offset Management**
- **Purpose**: Prevent re-fetching already processed updates
- **Implementation**: Track highest processed update ID per user
- **Persistence**: Maintained in poller state
## Code Structure
### Core Files
- **`services/telegramPoller.js`**: Main polling logic with duplicate prevention
- **`services/telegramInitializer.js`**: Initialization and user management
- **`tests/unit/services/telegramPoller.test.js`**: Unit tests for core functions
- **`tests/integration/telegram-duplicates.test.js`**: Integration tests for database interactions
- **`tests/integration/telegram-duplicate-scenario.test.js`**: Real-world scenario tests
### Key Functions
#### `processUpdates(user, updates)`
- Filters out already processed updates
- Updates user status with highest update ID
- Processes each new update individually
- Implements cleanup for memory management
#### `createInboxItem(content, userId, messageId)`
- Checks for recent duplicates before creation
- Creates inbox item with metadata
- Returns existing item if duplicate found
#### `processMessage(user, update)`
- Extracts message data
- Creates inbox item (with duplicate check)
- Sends confirmation back to Telegram
- Handles errors gracefully
## Testing Strategy
### Unit Tests (12 tests)
- Update ID tracking logic
- User list management
- Message parameter creation
- URL generation
- State management
### Integration Tests (12 tests)
- Database-level duplicate prevention
- Poller state management
- Update processing logic
- Error handling scenarios
### Scenario Tests (7 tests)
- Network issue simulations
- Rapid consecutive messages
- Update ID tracking in practice
- Poller restart scenarios
- Memory management verification
- Edge cases and time-based logic
### Running Tests
```bash
# Run all Telegram duplicate tests
npm run test:telegram-duplicates
# Run specific test suites
npm test -- --testPathPatterns="telegramPoller\.test\.js"
npm test -- --testPathPatterns="telegram-duplicates\.test\.js"
npm test -- --testPathPatterns="telegram-duplicate-scenario\.test\.js"
```
## Configuration
### Time Windows
- **Duplicate Detection**: 30 seconds (configurable)
- **Memory Cleanup**: 1000 entries (configurable)
- **Polling Interval**: 5 seconds (configurable)
### Memory Management
- Automatic cleanup when processed updates exceed 1000 entries
- Removes oldest 100 entries during cleanup
- Prevents memory leaks in long-running processes
## Monitoring and Debugging
### Logging
The system includes comprehensive logging for debugging:
```javascript
console.log(`Processing ${updates.length} updates for user ${user.id}`);
console.log(`Duplicate inbox item detected for user ${userId}`);
console.log(`Successfully processed message ${messageId} for user ${user.id}`);
```
### Status Monitoring
```javascript
const status = telegramPoller.getStatus();
// Returns: { running, usersCount, pollInterval, userStatus }
```
## Error Handling
### Network Errors
- Graceful handling of Telegram API timeouts
- Continuation of polling for other users if one fails
- Detailed error logging for debugging
### Database Errors
- Fallback behavior when duplicate check fails
- Transaction safety for inbox item creation
- Proper error responses to users
### Processing Errors
- Individual update error handling
- Continuation of processing for remaining updates
- Error reporting via Telegram messages
## Performance Considerations
### Memory Usage
- Bounded memory growth through automatic cleanup
- Efficient Set operations for duplicate checking
- Minimal memory footprint per user
### Database Performance
- Indexed queries for duplicate detection
- Time-limited searches (30-second window)
- Efficient user-scoped queries
### Network Efficiency
- Optimized Telegram API calls
- Proper offset management
- Reasonable polling intervals
## Security Considerations
### Data Protection
- User-scoped duplicate checking
- Secure token handling
- No sensitive data in logs
### Rate Limiting
- Respectful Telegram API usage
- Configurable polling intervals
- Graceful handling of API limits
## Future Enhancements
### Possible Improvements
1. **Persistent State**: Store processed update IDs in database
2. **Configurable Windows**: Make time windows user-configurable
3. **Metrics Collection**: Add detailed metrics for monitoring
4. **Retry Logic**: Implement exponential backoff for failures
5. **Batch Processing**: Process multiple updates in batches
### Migration Considerations
- Current system maintains backward compatibility
- New features can be added incrementally
- Existing data remains unaffected
## Troubleshooting
### Common Issues
1. **Duplicates Still Occurring**
- Check if time window is appropriate for your use case
- Verify update ID tracking is working
- Review logs for processing errors
2. **Memory Usage Growing**
- Verify cleanup logic is running
- Check if cleanup threshold needs adjustment
- Monitor processed updates Set size
3. **Performance Issues**
- Review database query performance
- Check polling interval settings
- Monitor network latency to Telegram API
### Debug Commands
```javascript
// Check poller status
const status = telegramPoller.getStatus();
// Manual cleanup (for testing)
if (processedUpdates.size > 1000) {
// Cleanup logic
}
// Verify duplicate detection
const existing = await InboxItem.findOne({ /* query */ });
```
## Conclusion
The Telegram duplicate prevention system provides robust protection against message duplication through multiple complementary mechanisms. The comprehensive test suite ensures reliability, while the monitoring and debugging features facilitate maintenance and troubleshooting.
The system is designed to be:
- **Reliable**: Multiple layers of protection
- **Efficient**: Minimal performance impact
- **Maintainable**: Well-tested and documented
- **Scalable**: Bounded memory usage and efficient queries
- **Debuggable**: Comprehensive logging and monitoring

View file

@ -11,6 +11,7 @@
"test:coverage": "cross-env NODE_ENV=test jest --coverage",
"test:unit": "cross-env NODE_ENV=test jest tests/unit",
"test:integration": "cross-env NODE_ENV=test jest tests/integration",
"test:telegram-duplicates": "node scripts/test-telegram-duplicates.js",
"db:init": "node scripts/db-init.js",
"db:sync": "node scripts/db-sync.js",
"db:migrate": "node scripts/db-migrate.js",

View file

@ -170,8 +170,10 @@ router.get('/projects', async (req, res) => {
taskStatusCounts[project.id] = taskStatus;
const projectJson = project.toJSON();
return {
...project.toJSON(),
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(project.due_date_at),
task_status: taskStatus,
completion_percentage: taskStatus.total > 0 ? Math.round((taskStatus.done / taskStatus.total) * 100) : 0
@ -234,8 +236,10 @@ router.get('/project/:id', async (req, res) => {
return res.status(404).json({ error: 'Project not found' });
}
const projectJson = project.toJSON();
const result = {
...project.toJSON(),
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(project.due_date_at)
};
@ -256,7 +260,10 @@ router.post('/project', async (req, res) => {
return res.status(401).json({ error: 'Authentication required' });
}
const { name, description, area_id, priority, due_date_at, image_url, tags } = req.body;
const { name, description, area_id, priority, due_date_at, image_url, tags, Tags } = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
if (!name || !name.trim()) {
return res.status(400).json({ error: 'Project name is required' });
@ -275,7 +282,7 @@ router.post('/project', async (req, res) => {
};
const project = await Project.create(projectData);
await updateProjectTags(project, tags, req.session.userId);
await updateProjectTags(project, tagsData, req.session.userId);
// Reload project with associations
const projectWithAssociations = await Project.findByPk(project.id, {
@ -284,8 +291,11 @@ router.post('/project', async (req, res) => {
]
});
const projectJson = projectWithAssociations.toJSON();
res.status(201).json({
...projectWithAssociations.toJSON(),
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(projectWithAssociations.due_date_at)
});
} catch (error) {
@ -312,7 +322,10 @@ router.patch('/project/:id', async (req, res) => {
return res.status(404).json({ error: 'Project not found.' });
}
const { name, description, area_id, active, pin_to_sidebar, priority, due_date_at, image_url, tags } = req.body;
const { name, description, area_id, active, pin_to_sidebar, priority, due_date_at, image_url, tags, Tags } = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
const updateData = {};
if (name !== undefined) updateData.name = name;
@ -325,7 +338,7 @@ router.patch('/project/:id', async (req, res) => {
if (image_url !== undefined) updateData.image_url = image_url;
await project.update(updateData);
await updateProjectTags(project, tags, req.session.userId);
await updateProjectTags(project, tagsData, req.session.userId);
// Reload project with associations
const projectWithAssociations = await Project.findByPk(project.id, {
@ -334,8 +347,11 @@ router.patch('/project/:id', async (req, res) => {
]
});
const projectJson = projectWithAssociations.toJSON();
res.json({
...projectWithAssociations.toJSON(),
...projectJson,
tags: projectJson.Tags || [], // Normalize Tags to tags
due_date_at: formatDate(projectWithAssociations.due_date_at)
});
} catch (error) {

View file

@ -237,26 +237,42 @@ router.get('/tasks', async (req, res) => {
const metrics = await computeTaskMetrics(req.currentUser.id);
res.json({
tasks: tasks.map(task => ({
...task.toJSON(),
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
})),
tasks: tasks.map(task => {
const taskJson = task.toJSON();
return {
...taskJson,
tags: taskJson.Tags || [],
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
};
}),
metrics: {
total_open_tasks: metrics.total_open_tasks,
tasks_pending_over_month: metrics.tasks_pending_over_month,
tasks_in_progress_count: metrics.tasks_in_progress_count,
tasks_in_progress: metrics.tasks_in_progress.map(task => ({
...task.toJSON(),
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
})),
tasks_due_today: metrics.tasks_due_today.map(task => ({
...task.toJSON(),
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
})),
suggested_tasks: metrics.suggested_tasks.map(task => ({
...task.toJSON(),
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
}))
tasks_in_progress: metrics.tasks_in_progress.map(task => {
const taskJson = task.toJSON();
return {
...taskJson,
tags: taskJson.Tags || [],
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
};
}),
tasks_due_today: metrics.tasks_due_today.map(task => {
const taskJson = task.toJSON();
return {
...taskJson,
tags: taskJson.Tags || [],
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
};
}),
suggested_tasks: metrics.suggested_tasks.map(task => {
const taskJson = task.toJSON();
return {
...taskJson,
tags: taskJson.Tags || [],
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
};
})
}
});
} catch (error) {
@ -283,8 +299,11 @@ router.get('/task/:id', async (req, res) => {
return res.status(404).json({ error: 'Task not found.' });
}
const taskJson = task.toJSON();
res.json({
...task.toJSON(),
...taskJson,
tags: taskJson.Tags || [],
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
});
} catch (error) {
@ -304,6 +323,7 @@ router.post('/task', async (req, res) => {
note,
project_id,
tags,
Tags,
recurrence_type,
recurrence_interval,
recurrence_end_date,
@ -313,6 +333,9 @@ router.post('/task', async (req, res) => {
completion_based
} = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
// Validate required fields
if (!name || name.trim() === '') {
return res.status(400).json({ error: 'Task name is required.' });
@ -346,7 +369,7 @@ router.post('/task', async (req, res) => {
}
const task = await Task.create(taskAttributes);
await updateTaskTags(task, tags, req.currentUser.id);
await updateTaskTags(task, tagsData, req.currentUser.id);
// Reload task with associations
const taskWithAssociations = await Task.findByPk(task.id, {
@ -356,8 +379,11 @@ router.post('/task', async (req, res) => {
]
});
const taskJson = taskWithAssociations.toJSON();
res.status(201).json({
...taskWithAssociations.toJSON(),
...taskJson,
tags: taskJson.Tags || [],
due_date: taskWithAssociations.due_date ? taskWithAssociations.due_date.toISOString().split('T')[0] : null
});
} catch (error) {
@ -380,6 +406,7 @@ router.patch('/task/:id', async (req, res) => {
due_date,
project_id,
tags,
Tags,
recurrence_type,
recurrence_interval,
recurrence_end_date,
@ -390,6 +417,9 @@ router.patch('/task/:id', async (req, res) => {
update_parent_recurrence
} = req.body;
// Handle both tags and Tags (Sequelize association format)
const tagsData = tags || Tags;
const task = await Task.findOne({
where: { id: req.params.id, user_id: req.currentUser.id }
});
@ -447,18 +477,21 @@ router.patch('/task/:id', async (req, res) => {
}
await task.update(taskAttributes);
await updateTaskTags(task, tags, req.currentUser.id);
await updateTaskTags(task, tagsData, req.currentUser.id);
// Reload task with associations
const taskWithAssociations = await Task.findByPk(task.id, {
include: [
{ model: Tag, attributes: ['name'], through: { attributes: [] } },
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } },
{ model: Project, attributes: ['name'], required: false }
]
});
const taskJson = taskWithAssociations.toJSON();
res.json({
...taskWithAssociations.toJSON(),
...taskJson,
tags: taskJson.Tags || [], // Normalize Tags to tags
due_date: taskWithAssociations.due_date ? taskWithAssociations.due_date.toISOString().split('T')[0] : null
});
} catch (error) {

View file

@ -0,0 +1,32 @@
#!/usr/bin/env node
/**
* Test runner script for Telegram duplicate prevention tests
*
* Usage:
* node scripts/test-telegram-duplicates.js
* npm run test:telegram-duplicates
*/
const { execSync } = require('child_process');
console.log('🔍 Running Telegram Duplicate Prevention Tests...\n');
try {
// Run the tests
execSync('npm test -- --testPathPatterns="telegram.*test\\.js"', {
stdio: 'inherit',
cwd: process.cwd()
});
console.log('\n✅ All Telegram duplicate prevention tests passed!');
console.log('\n📋 Test Coverage:');
console.log(' • Unit Tests: Core functionality and utility functions');
console.log(' • Integration Tests: Database interactions and state management');
console.log(' • Scenario Tests: Real-world duplicate prevention scenarios');
console.log(' • API Tests: Telegram route endpoints and authentication');
} catch (error) {
console.error('\n❌ Some tests failed. Please review the output above.');
process.exit(1);
}

View file

@ -7,7 +7,8 @@ const createPollerState = () => ({
interval: null,
pollInterval: 5000, // 5 seconds
usersToPool: [],
userStatus: {}
userStatus: {},
processedUpdates: new Set() // Track processed update IDs to prevent duplicates
});
// Global mutable state (managed functionally)
@ -166,11 +167,32 @@ const updateUserChatId = async (userId, chatId) => {
};
// Side effect function to create inbox item
const createInboxItem = async (content, userId) => {
const createInboxItem = async (content, userId, messageId) => {
// Check if a similar item was created recently (within last 30 seconds)
// to prevent duplicates from network issues or multiple processing
const recentCutoff = new Date(Date.now() - 30000); // 30 seconds ago
const existingItem = await InboxItem.findOne({
where: {
content: content,
user_id: userId,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: recentCutoff
}
}
});
if (existingItem) {
console.log(`Duplicate inbox item detected for user ${userId}, content: "${content}". Skipping creation.`);
return existingItem;
}
return await InboxItem.create({
content: content,
source: 'telegram',
user_id: userId
user_id: userId,
metadata: { telegram_message_id: messageId } // Store message ID for reference
});
};
@ -188,8 +210,8 @@ const processMessage = async (user, update) => {
}
try {
// Create inbox item
const inboxItem = await createInboxItem(text, user.id);
// Create inbox item (with duplicate check)
const inboxItem = await createInboxItem(text, user.id, messageId);
// Send confirmation
await sendTelegramMessage(
@ -198,6 +220,8 @@ const processMessage = async (user, update) => {
`✅ Added to Tududi inbox: "${text}"`,
messageId
);
console.log(`Successfully processed message ${messageId} for user ${user.id}: "${text}"`);
} catch (error) {
// Send error message
await sendTelegramMessage(
@ -213,8 +237,16 @@ const processMessage = async (user, update) => {
const processUpdates = async (user, updates) => {
if (!updates.length) return;
// Get highest update ID
const highestUpdateId = getHighestUpdateId(updates);
// Filter out already processed updates
const newUpdates = updates.filter(update => {
const updateKey = `${user.id}-${update.update_id}`;
return !pollerState.processedUpdates.has(updateKey);
});
if (!newUpdates.length) return;
// Get highest update ID from new updates
const highestUpdateId = getHighestUpdateId(newUpdates);
// Update user status
pollerState = {
@ -224,14 +256,25 @@ const processUpdates = async (user, updates) => {
})
};
// Process each update
for (const update of updates) {
// Process each new update
for (const update of newUpdates) {
try {
const updateKey = `${user.id}-${update.update_id}`;
if (update.message && update.message.text) {
await processMessage(user, update);
// Mark update as processed
pollerState.processedUpdates.add(updateKey);
// Clean up old processed updates (keep only last 1000 to prevent memory leak)
if (pollerState.processedUpdates.size > 1000) {
const oldestEntries = Array.from(pollerState.processedUpdates).slice(0, 100);
oldestEntries.forEach(entry => pollerState.processedUpdates.delete(entry));
}
}
} catch (error) {
// Error processing update
console.error(`Error processing update ${update.update_id} for user ${user.id}:`, error);
}
}
};
@ -247,10 +290,11 @@ const pollUpdates = async () => {
const updates = await getTelegramUpdates(token, lastUpdateId + 1);
if (updates && updates.length > 0) {
console.log(`Processing ${updates.length} updates for user ${user.id}, starting from update ID ${lastUpdateId + 1}`);
await processUpdates(user, updates);
}
} catch (error) {
// Error getting updates for user
console.error(`Error getting updates for user ${user.id}:`, error);
}
}
};

View file

@ -0,0 +1,365 @@
const { User, InboxItem, sequelize } = require('../../models');
const telegramPoller = require('../../services/telegramPoller');
// Mock the HTTPS module to simulate Telegram API responses
jest.mock('https', () => {
const mockResponse = {
on: jest.fn((event, callback) => {
if (event === 'data') {
// Simulate API response with duplicate updates
callback(JSON.stringify({
ok: true,
result: [
{
update_id: 1001,
message: {
message_id: 123,
text: 'Buy groceries from the store',
chat: { id: 987654321 },
date: Math.floor(Date.now() / 1000)
}
}
]
}));
} else if (event === 'end') {
callback();
}
})
};
const mockRequest = {
on: jest.fn(),
write: jest.fn(),
end: jest.fn()
};
return {
get: jest.fn((url, options, callback) => {
callback(mockResponse);
return mockRequest;
}),
request: jest.fn((url, options, callback) => {
callback(mockResponse);
return mockRequest;
})
};
});
describe('Telegram Duplicate Message Scenario', () => {
let testUser;
let consoleMessages;
beforeAll(async () => {
await sequelize.sync({ force: true });
// Capture console logs
consoleMessages = [];
const originalConsoleLog = console.log;
console.log = (...args) => {
consoleMessages.push(args.join(' '));
originalConsoleLog(...args);
};
});
beforeEach(async () => {
consoleMessages = [];
// Create test user with Telegram configuration
testUser = await User.create({
email: 'telegram-user@example.com',
password_digest: 'hashedpassword',
telegram_bot_token: 'real-bot-token-456',
telegram_chat_id: '987654321'
});
// Clear inbox
await InboxItem.destroy({ where: {} });
// Reset poller
telegramPoller.stopPolling();
});
afterEach(async () => {
telegramPoller.stopPolling();
await User.destroy({ where: {} });
await InboxItem.destroy({ where: {} });
});
afterAll(async () => {
await sequelize.close();
});
describe('Real-world Duplicate Scenarios', () => {
test('should prevent duplicates when same message is processed twice due to network issues', async () => {
const messageContent = 'Buy groceries from the store';
const messageId = 123;
const updateId = 1001;
// Simulate first message processing
const inboxItem1 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: messageId }
});
// Wait a moment (simulating network delay)
await new Promise(resolve => setTimeout(resolve, 50));
// Simulate duplicate processing attempt (same message, different processing cycle)
const recentCutoff = new Date(Date.now() - 30000);
const existingItem = await InboxItem.findOne({
where: {
content: messageContent,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: recentCutoff
}
}
});
// Should find the existing item
expect(existingItem).toBeTruthy();
expect(existingItem.id).toBe(inboxItem1.id);
// Verify only one item exists
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(1);
});
test('should handle rapid consecutive messages without creating duplicates', async () => {
const messages = [
{ content: 'First message', messageId: 201, updateId: 2001 },
{ content: 'Second message', messageId: 202, updateId: 2002 },
{ content: 'First message', messageId: 203, updateId: 2003 }, // Duplicate content
{ content: 'Third message', messageId: 204, updateId: 2004 }
];
// Process all messages rapidly
const createdItems = [];
for (const msg of messages) {
try {
// Check for existing item first (simulating the duplicate prevention logic)
const existingItem = await InboxItem.findOne({
where: {
content: msg.content,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: new Date(Date.now() - 30000)
}
}
});
if (existingItem) {
console.log(`Duplicate detected: "${msg.content}"`);
createdItems.push(existingItem);
} else {
const newItem = await InboxItem.create({
content: msg.content,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: msg.messageId }
});
createdItems.push(newItem);
}
} catch (error) {
console.error('Error processing message:', error);
}
}
// Should have 3 unique items (first, second, third) - duplicate "First message" should be prevented
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(3);
const contentCounts = allItems.reduce((acc, item) => {
acc[item.content] = (acc[item.content] || 0) + 1;
return acc;
}, {});
expect(contentCounts['First message']).toBe(1);
expect(contentCounts['Second message']).toBe(1);
expect(contentCounts['Third message']).toBe(1);
});
test('should track update IDs correctly to prevent reprocessing', async () => {
// Simulate the internal update tracking logic
const processedUpdates = new Set();
const updates = [
{ update_id: 3001, message: { text: 'Message 1', message_id: 301, chat: { id: 987654321 } } },
{ update_id: 3002, message: { text: 'Message 2', message_id: 302, chat: { id: 987654321 } } },
{ update_id: 3001, message: { text: 'Message 1', message_id: 301, chat: { id: 987654321 } } }, // Duplicate update
{ update_id: 3003, message: { text: 'Message 3', message_id: 303, chat: { id: 987654321 } } }
];
const processedCount = { count: 0 };
for (const update of updates) {
const updateKey = `${testUser.id}-${update.update_id}`;
if (!processedUpdates.has(updateKey)) {
// Simulate processing the update
processedUpdates.add(updateKey);
processedCount.count++;
// Simulate creating inbox item
await InboxItem.create({
content: update.message.text,
source: 'telegram',
user_id: testUser.id,
metadata: {
telegram_message_id: update.message.message_id,
update_id: update.update_id
}
});
} else {
console.log(`Skipping already processed update: ${update.update_id}`);
}
}
// Should have processed 3 unique updates (3001, 3002, 3003)
expect(processedCount.count).toBe(3);
expect(processedUpdates.size).toBe(3);
// Verify inbox items
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(3);
});
test('should handle poller restart without creating duplicates', async () => {
// Create initial inbox item
const initialItem = await InboxItem.create({
content: 'Message before restart',
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 401, update_id: 4001 }
});
// Add user to poller
await telegramPoller.addUser(testUser);
// Simulate getting status
let status = telegramPoller.getStatus();
expect(status.running).toBe(true);
expect(status.usersCount).toBe(1);
// Stop poller (simulating restart)
telegramPoller.stopPolling();
status = telegramPoller.getStatus();
expect(status.running).toBe(false);
// Start again
await telegramPoller.addUser(testUser);
status = telegramPoller.getStatus();
expect(status.running).toBe(true);
// The poller should maintain its state correctly
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(1);
expect(allItems[0].id).toBe(initialItem.id);
});
test('should cleanup old processed updates to prevent memory leaks', async () => {
// Test the memory management logic
const processedUpdates = new Set();
// Add many processed updates
for (let i = 1; i <= 1200; i++) {
processedUpdates.add(`${testUser.id}-${i}`);
}
expect(processedUpdates.size).toBe(1200);
// Simulate the cleanup logic (keeping only 1000 most recent)
if (processedUpdates.size > 1000) {
const allEntries = Array.from(processedUpdates);
const oldestEntries = allEntries.slice(0, 200); // Remove oldest 200
oldestEntries.forEach(entry => processedUpdates.delete(entry));
}
expect(processedUpdates.size).toBe(1000);
// Verify oldest entries are removed
expect(processedUpdates.has(`${testUser.id}-1`)).toBe(false);
expect(processedUpdates.has(`${testUser.id}-200`)).toBe(false);
// Verify newest entries are kept
expect(processedUpdates.has(`${testUser.id}-1200`)).toBe(true);
expect(processedUpdates.has(`${testUser.id}-1000`)).toBe(true);
});
});
describe('Edge Cases', () => {
test('should handle identical messages from different Telegram message IDs', async () => {
const messageContent = 'Identical content';
// Create first message
const item1 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 501 }
});
// Wait a moment
await new Promise(resolve => setTimeout(resolve, 100));
// Try to create with same content but different message ID
// This should be prevented by the content-based duplicate check
const recentCutoff = new Date(Date.now() - 30000);
const existingItem = await InboxItem.findOne({
where: {
content: messageContent,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: recentCutoff
}
}
});
expect(existingItem).toBeTruthy();
expect(existingItem.id).toBe(item1.id);
});
test('should allow same content after time window expires', async () => {
const messageContent = 'Time-based test message';
// Create first item with old timestamp
const oldTimestamp = new Date(Date.now() - 35000); // 35 seconds ago
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
created_at: oldTimestamp,
updated_at: oldTimestamp,
metadata: { telegram_message_id: 601 }
});
// Now try to create new item with same content
const newItem = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 602 }
});
// Should be allowed since the old one is outside the 30-second window
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(2);
});
});
});

View file

@ -0,0 +1,325 @@
const request = require('supertest');
const app = require('../../app');
const { User, InboxItem, sequelize } = require('../../models');
const telegramPoller = require('../../services/telegramPoller');
describe('Telegram Duplicate Prevention Integration Tests', () => {
let testUser;
let originalConsoleLog;
let logMessages;
beforeAll(async () => {
// Capture console.log for verification
originalConsoleLog = console.log;
logMessages = [];
console.log = (...args) => {
logMessages.push(args.join(' '));
originalConsoleLog(...args);
};
await sequelize.sync({ force: true });
});
beforeEach(async () => {
logMessages = [];
// Create test user
testUser = await User.create({
email: 'test-telegram@example.com',
password_digest: 'hashedpassword',
telegram_bot_token: 'test-bot-token-123',
telegram_chat_id: '987654321'
});
// Clear any existing inbox items
await InboxItem.destroy({ where: {} });
// Stop and reset poller
telegramPoller.stopPolling();
});
afterEach(async () => {
telegramPoller.stopPolling();
await User.destroy({ where: {} });
await InboxItem.destroy({ where: {} });
});
afterAll(async () => {
console.log = originalConsoleLog;
await sequelize.close();
});
describe('Database-level Duplicate Prevention', () => {
test('should prevent duplicate inbox items with same content within 30 seconds', async () => {
const messageContent = 'Test duplicate message';
// Create first inbox item
const item1 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 123 }
});
// Wait a moment
await new Promise(resolve => setTimeout(resolve, 100));
// Try to create duplicate item (should be prevented)
const duplicateCheck = await InboxItem.findOne({
where: {
content: messageContent,
user_id: testUser.id,
source: 'telegram',
created_at: {
[require('sequelize').Op.gte]: new Date(Date.now() - 30000)
}
}
});
expect(duplicateCheck).toBeTruthy();
expect(duplicateCheck.id).toBe(item1.id);
// Verify only one item exists
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(1);
});
test('should allow duplicate content after 30 seconds', async () => {
const messageContent = 'Test time-based duplicate';
// Create first item with backdated timestamp
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
created_at: new Date(Date.now() - 35000), // 35 seconds ago
metadata: { telegram_message_id: 124 }
});
// Create second item (should be allowed)
const item2 = await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 125 }
});
// Verify both items exist
const allItems = await InboxItem.findAll({
where: { user_id: testUser.id }
});
expect(allItems).toHaveLength(2);
});
test('should allow same content for different users', async () => {
// Create second user
const testUser2 = await User.create({
email: 'test2-telegram@example.com',
password_digest: 'hashedpassword',
telegram_bot_token: 'test-bot-token-456',
telegram_chat_id: '123456789'
});
const messageContent = 'Shared message content';
// Create item for first user
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser.id,
metadata: { telegram_message_id: 126 }
});
// Create item for second user (should be allowed)
await InboxItem.create({
content: messageContent,
source: 'telegram',
user_id: testUser2.id,
metadata: { telegram_message_id: 127 }
});
// Verify both items exist
const allItems = await InboxItem.findAll();
expect(allItems).toHaveLength(2);
const user1Items = allItems.filter(item => item.user_id === testUser.id);
const user2Items = allItems.filter(item => item.user_id === testUser2.id);
expect(user1Items).toHaveLength(1);
expect(user2Items).toHaveLength(1);
});
});
describe('Poller State Management', () => {
test('should add and remove users correctly', async () => {
const initialStatus = telegramPoller.getStatus();
expect(initialStatus.usersCount).toBe(0);
expect(initialStatus.running).toBe(false);
// Add user
const addResult = await telegramPoller.addUser(testUser);
expect(addResult).toBe(true);
const statusAfterAdd = telegramPoller.getStatus();
expect(statusAfterAdd.usersCount).toBe(1);
expect(statusAfterAdd.running).toBe(true);
// Remove user
const removeResult = telegramPoller.removeUser(testUser.id);
expect(removeResult).toBe(true);
const statusAfterRemove = telegramPoller.getStatus();
expect(statusAfterRemove.usersCount).toBe(0);
expect(statusAfterRemove.running).toBe(false);
});
test('should not add user without telegram token', async () => {
const userWithoutToken = await User.create({
email: 'no-token@example.com',
password_digest: 'hashedpassword'
// No telegram_bot_token
});
const addResult = await telegramPoller.addUser(userWithoutToken);
expect(addResult).toBe(false);
const status = telegramPoller.getStatus();
expect(status.usersCount).toBe(0);
});
test('should handle adding same user multiple times', async () => {
// Add user first time
await telegramPoller.addUser(testUser);
const status1 = telegramPoller.getStatus();
expect(status1.usersCount).toBe(1);
// Add same user again
await telegramPoller.addUser(testUser);
const status2 = telegramPoller.getStatus();
expect(status2.usersCount).toBe(1); // Should still be 1
});
});
describe('Update Processing Logic', () => {
test('should handle updates with proper ID tracking', async () => {
await telegramPoller.addUser(testUser);
// Simulate updates (this tests the internal logic without actual HTTP calls)
const mockUpdates = [
{
update_id: 1001,
message: {
message_id: 501,
text: 'First message',
chat: { id: 987654321 }
}
},
{
update_id: 1002,
message: {
message_id: 502,
text: 'Second message',
chat: { id: 987654321 }
}
}
];
// Test highest update ID calculation
const highestId = telegramPoller._getHighestUpdateId(mockUpdates);
expect(highestId).toBe(1002);
// Test update key generation (simulating internal logic)
const updateKeys = mockUpdates.map(update => `${testUser.id}-${update.update_id}`);
expect(updateKeys).toEqual([`${testUser.id}-1001`, `${testUser.id}-1002`]);
});
test('should properly track processed updates', async () => {
// Test the Set-based tracking logic
const processedUpdates = new Set();
// Add some processed updates
processedUpdates.add('1-1001');
processedUpdates.add('1-1002');
// Test filtering logic
const newUpdates = [
{ update_id: 1001 }, // Should be filtered out
{ update_id: 1002 }, // Should be filtered out
{ update_id: 1003 } // Should remain
].filter(update => {
const updateKey = `1-${update.update_id}`;
return !processedUpdates.has(updateKey);
});
expect(newUpdates).toHaveLength(1);
expect(newUpdates[0].update_id).toBe(1003);
});
test('should handle memory management for processed updates', async () => {
// Simulate the cleanup logic
const processedUpdates = new Set();
// Add many updates (more than the 1000 limit)
for (let i = 1; i <= 1100; i++) {
processedUpdates.add(`1-${i}`);
}
expect(processedUpdates.size).toBe(1100);
// Simulate cleanup (remove oldest 100)
if (processedUpdates.size > 1000) {
const oldestEntries = Array.from(processedUpdates).slice(0, 100);
oldestEntries.forEach(entry => processedUpdates.delete(entry));
}
expect(processedUpdates.size).toBe(1000);
expect(processedUpdates.has('1-1')).toBe(false); // Oldest should be removed
expect(processedUpdates.has('1-1100')).toBe(true); // Newest should remain
});
});
describe('Error Handling', () => {
test('should handle database errors gracefully', async () => {
// Mock InboxItem.create to throw an error
const originalCreate = InboxItem.create;
InboxItem.create = jest.fn().mockRejectedValue(new Error('Database error'));
try {
await InboxItem.create({
content: 'Test error handling',
source: 'telegram',
user_id: testUser.id
});
} catch (error) {
expect(error.message).toBe('Database error');
}
// Restore original function
InboxItem.create = originalCreate;
});
test('should handle invalid user data', async () => {
const invalidUser = null;
const addResult = await telegramPoller.addUser(invalidUser);
expect(addResult).toBe(false);
});
test('should handle missing message properties', async () => {
// Test the processing logic with incomplete message data
const incompleteUpdate = {
update_id: 2001,
message: {
// Missing text and other properties
message_id: 601,
chat: { id: 987654321 }
}
};
// The actual processing would skip this message due to missing text
const hasText = incompleteUpdate.message && incompleteUpdate.message.text;
expect(hasText).toBeFalsy();
});
});
});

View file

@ -0,0 +1,172 @@
const { User, InboxItem } = require('../../../models');
const telegramPoller = require('../../../services/telegramPoller');
// Mock the database models
jest.mock('../../../models', () => ({
User: {
update: jest.fn(),
findAll: jest.fn(),
findOne: jest.fn()
},
InboxItem: {
create: jest.fn(),
findOne: jest.fn()
}
}));
// Mock https module
jest.mock('https', () => ({
get: jest.fn(),
request: jest.fn()
}));
describe('TelegramPoller Duplicate Prevention', () => {
let mockUser;
beforeEach(() => {
jest.clearAllMocks();
mockUser = {
id: 1,
telegram_bot_token: 'test-token',
telegram_chat_id: '123456789'
};
// Reset poller state
telegramPoller.stopPolling();
});
describe('Update ID Tracking', () => {
test('should filter out already processed updates', () => {
const updates = [
{ update_id: 100, message: { text: 'Hello 1', message_id: 1, chat: { id: 123 } } },
{ update_id: 101, message: { text: 'Hello 2', message_id: 2, chat: { id: 123 } } },
{ update_id: 102, message: { text: 'Hello 3', message_id: 3, chat: { id: 123 } } }
];
// Test internal function for filtering
const processedUpdates = new Set(['1-100', '1-101']);
const newUpdates = updates.filter(update => {
const updateKey = `1-${update.update_id}`;
return !processedUpdates.has(updateKey);
});
expect(newUpdates).toHaveLength(1);
expect(newUpdates[0].update_id).toBe(102);
});
test('should track highest update ID correctly', () => {
const updates = [
{ update_id: 98 },
{ update_id: 101 },
{ update_id: 99 }
];
const highestUpdateId = telegramPoller._getHighestUpdateId(updates);
expect(highestUpdateId).toBe(101);
});
test('should handle empty updates array', () => {
const highestUpdateId = telegramPoller._getHighestUpdateId([]);
expect(highestUpdateId).toBe(0);
});
});
describe('User List Management', () => {
test('should not add duplicate users', () => {
const users = [{ id: 1, name: 'User 1' }];
const newUser = { id: 1, name: 'User 1 Updated' };
const userExists = telegramPoller._userExistsInList(users, 1);
expect(userExists).toBe(true);
const updatedUsers = telegramPoller._addUserToList(users, newUser);
expect(updatedUsers).toHaveLength(1);
expect(updatedUsers).toEqual(users); // Should return original array unchanged
});
test('should add new users correctly', () => {
const users = [{ id: 1, name: 'User 1' }];
const newUser = { id: 2, name: 'User 2' };
const userExists = telegramPoller._userExistsInList(users, 2);
expect(userExists).toBe(false);
const updatedUsers = telegramPoller._addUserToList(users, newUser);
expect(updatedUsers).toHaveLength(2);
expect(updatedUsers).toContain(newUser);
});
test('should remove users correctly', () => {
const users = [
{ id: 1, name: 'User 1' },
{ id: 2, name: 'User 2' },
{ id: 3, name: 'User 3' }
];
const updatedUsers = telegramPoller._removeUserFromList(users, 2);
expect(updatedUsers).toHaveLength(2);
expect(updatedUsers.find(u => u.id === 2)).toBeUndefined();
expect(updatedUsers.find(u => u.id === 1)).toBeDefined();
expect(updatedUsers.find(u => u.id === 3)).toBeDefined();
});
});
describe('Message Parameters', () => {
test('should create message parameters without reply', () => {
const params = telegramPoller._createMessageParams('123', 'Hello World');
expect(params).toEqual({
chat_id: '123',
text: 'Hello World'
});
});
test('should create message parameters with reply', () => {
const params = telegramPoller._createMessageParams('123', 'Hello World', 456);
expect(params).toEqual({
chat_id: '123',
text: 'Hello World',
reply_to_message_id: 456
});
});
});
describe('Telegram URL Creation', () => {
test('should create URL without parameters', () => {
const url = telegramPoller._createTelegramUrl('token123', 'getMe');
expect(url).toBe('https://api.telegram.org/bottoken123/getMe');
});
test('should create URL with parameters', () => {
const url = telegramPoller._createTelegramUrl('token123', 'getUpdates', {
offset: '100',
timeout: '30'
});
expect(url).toBe('https://api.telegram.org/bottoken123/getUpdates?offset=100&timeout=30');
});
});
describe('State Management', () => {
test('should return correct initial state', () => {
const state = telegramPoller._createPollerState();
expect(state).toEqual({
running: false,
interval: null,
pollInterval: 5000,
usersToPool: [],
userStatus: {},
processedUpdates: expect.any(Set)
});
});
test('should track poller status correctly', () => {
const status = telegramPoller.getStatus();
expect(status).toEqual({
running: false,
usersCount: 0,
pollInterval: 5000,
userStatus: {}
});
});
});
});

View file

@ -32,7 +32,10 @@ const Notes: React.FC = () => {
const loadNotes = async () => {
setIsLoading(true);
try {
console.log('Attempting to fetch notes...');
const fetchedNotes = await fetchNotes();
console.log('Fetched notes:', fetchedNotes);
console.log('Number of notes:', fetchedNotes.length);
setNotes(fetchedNotes);
} catch (error) {
console.error('Error loading notes:', error);
@ -87,6 +90,10 @@ const Notes: React.FC = () => {
note.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
note.content.toLowerCase().includes(searchQuery.toLowerCase())
);
console.log('All notes:', notes);
console.log('Search query:', searchQuery);
console.log('Filtered notes:', filteredNotes);
if (isLoading) {
return (

View file

@ -212,74 +212,146 @@ const ProjectDetails: React.FC = () => {
<div className="w-full max-w-5xl">
{/* Project Banner Image */}
{project.image_url && (
<div className="mb-6 rounded-lg overflow-hidden">
<div className="mb-6 rounded-lg overflow-hidden relative">
<img
src={project.image_url}
alt={project.name}
className="w-full h-48 object-cover"
/>
{/* Title Overlay */}
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center">
<h1 className="text-4xl md:text-5xl font-bold text-white text-center px-4 drop-shadow-lg">
{project.name}
</h1>
</div>
{/* Priority Indicator on Image */}
{project.priority && (
<div className="absolute top-3 left-3">
<div
className={`w-4 h-4 rounded-full border-2 border-white shadow-lg ${
priorityStyles[project.priority] || priorityStyles.default
}`}
title={`Priority: ${priorityLabel(project.priority)}`}
aria-label={`Priority: ${priorityLabel(project.priority)}`}
></div>
</div>
)}
{/* Edit/Delete Buttons on Image */}
<div className="absolute bottom-4 right-4 flex space-x-2">
<button
onClick={handleEditProject}
className="p-2 bg-black bg-opacity-50 text-white hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => setIsConfirmDialogOpen(true)}
className="p-2 bg-black bg-opacity-50 text-white hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
)}
{/* Project Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<FolderIcon className="h-6 w-6 text-gray-500 mr-3" />
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100 mr-2">
{project.name}
</h2>
{project.priority && (
<div
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${
priorityStyles[project.priority] || priorityStyles.default
}`}
title={`Priority: ${priorityLabel(project.priority)}`}
aria-label={`Priority: ${priorityLabel(project.priority)}`}
></div>
)}
</div>
<div className="flex space-x-2">
<button
onClick={handleEditProject}
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => setIsConfirmDialogOpen(true)}
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
{project.area && (
<div className="flex items-center mb-2">
<Squares2X2Icon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
<Link
to={`/projects/?area_id=${project.area.id}`}
className="text-gray-600 dark:text-gray-400 hover:underline"
>
{project.area.name.toUpperCase()}
</Link>
{/* Project Metadata Box */}
{(project.description || project.area || project.due_date_at || (project.tags && project.tags.length > 0)) && (
<div className="mb-6 p-4 bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg">
<div className="grid gap-3">
{project.description && (
<div className="flex items-start">
<InformationCircleIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Description:</span>
<p className="text-sm text-gray-900 dark:text-gray-100 leading-relaxed mt-1">
{project.description}
</p>
</div>
</div>
)}
{project.area && (
<div className="flex items-center">
<Squares2X2Icon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Area:</span>
<span className="text-sm text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">
{project.area.name}
</span>
</div>
)}
{project.due_date_at && (
<div className="flex items-center">
<CalendarDaysIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Due Date:</span>
<span className="text-sm text-gray-900 dark:text-gray-100">
{formatProjectDueDate(project.due_date_at)}
</span>
</div>
)}
{project.tags && project.tags.length > 0 && (
<div className="flex items-start">
<div className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5">
<svg fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M17.707 9.293a1 1 0 010 1.414l-7 7a1 1 0 01-1.414 0l-7-7A.997.997 0 012 10V5a3 3 0 013-3h5c.256 0 .512.098.707.293l7 7zM5 6a1 1 0 100-2 1 1 0 000 2z" clipRule="evenodd" />
</svg>
</div>
<div className="flex-1">
<span className="text-sm font-medium text-gray-600 dark:text-gray-400 mr-2">Tags:</span>
<div className="flex flex-wrap gap-1 mt-1">
{project.tags.map((tag, index) => (
<span
key={index}
className="inline-block px-2 py-1 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full"
>
{tag.name}
</span>
))}
</div>
</div>
</div>
)}
</div>
</div>
)}
{project.due_date_at && (
<div className="flex items-center mb-2">
<CalendarDaysIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
{formatProjectDueDate(project.due_date_at)}
{/* Project Header - Only show when no image */}
{!project.image_url && (
<div className="flex items-center justify-between mb-8">
<div className="flex items-center">
<FolderIcon className="h-6 w-6 text-gray-500 mr-3" />
<h2 className="text-2xl font-light text-gray-900 dark:text-gray-100 mr-2">
{project.name}
</h2>
{/* Show priority indicator only when no image */}
{project.priority && (
<div
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${
priorityStyles[project.priority] || priorityStyles.default
}`}
title={`Priority: ${priorityLabel(project.priority)}`}
aria-label={`Priority: ${priorityLabel(project.priority)}`}
></div>
)}
</div>
<div className="flex space-x-2">
<button
onClick={handleEditProject}
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
>
<PencilSquareIcon className="h-5 w-5" />
</button>
<button
onClick={() => setIsConfirmDialogOpen(true)}
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
>
<TrashIcon className="h-5 w-5" />
</button>
</div>
</div>
)}
{project.description && (
<p className="flex items-center text-gray-700 dark:text-gray-300 mb-6">
<InformationCircleIcon className="h-5 w-5 text-gray-500 dark:text-gray-400 mr-2" />
{project.description}
</p>
)}
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100">Tasks</h3>
{completedTasks.length > 0 && (

View file

@ -8,7 +8,6 @@ import PriorityDropdown from "../Shared/PriorityDropdown";
import { PriorityType } from "../../entities/Task";
import Switch from "../Shared/Switch";
import { useStore } from "../../store/useStore";
import { fetchTags } from "../../utils/tagsService";
import { useTranslation } from "react-i18next";
interface ProjectModalProps {
@ -49,7 +48,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
const [isUploading, setIsUploading] = useState(false);
const { tagsStore } = useStore();
const { tags: availableTags } = tagsStore;
const { tags: availableTags, loadTags } = tagsStore;
const modalRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -88,9 +87,16 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
useEffect(() => {
if (availableTags.length === 0) {
fetchTags();
console.log('Loading tags...');
loadTags().then(() => {
console.log('Tags loaded successfully');
}).catch(error => {
console.error('Error loading tags:', error);
});
} else {
console.log('Available tags:', availableTags);
}
}, [availableTags.length]);
}, [availableTags.length, loadTags]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -149,11 +155,13 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
};
const handleTagsChange = useCallback((newTags: string[]) => {
console.log('Tags changed:', newTags);
setTags(newTags);
setFormData((prev) => ({
...prev,
tags: newTags.map((name) => ({ name })),
}));
console.log('Form data updated with tags:', newTags.map((name) => ({ name })));
}, []);
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {

View file

@ -288,20 +288,72 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
</h3>
{renderRecurrenceTypeSelect()}
{(recurrenceType === 'daily' || recurrenceType === 'weekly' || recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && (
renderIntervalInput()
)}
{/* Main recurrence settings in one row */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceType', 'Repeat')}
</label>
<select
value={recurrenceType}
onChange={(e) => onChange('recurrence_type', e.target.value as RecurrenceType)}
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
disabled={disabled}
>
<option value="none">{t('recurrence.none', 'No repeat')}</option>
<option value="daily">{t('recurrence.daily', 'Daily')}</option>
<option value="weekly">{t('recurrence.weekly', 'Weekly')}</option>
<option value="monthly">{t('recurrence.monthly', 'Monthly')}</option>
<option value="monthly_weekday">{t('recurrence.monthlyWeekday', 'Monthly on weekday')}</option>
<option value="monthly_last_day">{t('recurrence.monthlyLastDay', 'Monthly on last day')}</option>
</select>
</div>
{(recurrenceType === 'daily' || recurrenceType === 'weekly' || recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && (
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceInterval', 'Every')}
</label>
<div className="flex items-center space-x-2">
<input
type="number"
min="1"
max="999"
value={recurrenceInterval || 1}
onChange={(e) => onChange('recurrence_interval', parseInt(e.target.value))}
className="block w-20 border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
disabled={disabled}
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
{recurrenceType === 'daily' && t('recurrence.days', 'days')}
{recurrenceType === 'weekly' && t('recurrence.weeks', 'weeks')}
{(recurrenceType === 'monthly' || recurrenceType === 'monthly_weekday' || recurrenceType === 'monthly_last_day') && t('recurrence.months', 'months')}
</span>
</div>
</div>
)}
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
{t('forms.task.labels.recurrenceEndDate', 'End date (optional)')}
</label>
<input
type="date"
value={recurrenceEndDate || ''}
onChange={(e) => onChange('recurrence_end_date', e.target.value || null)}
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
disabled={disabled}
/>
</div>
</div>
{/* Additional settings for specific recurrence types */}
{recurrenceType === 'weekly' && renderWeekdaySelect()}
{recurrenceType === 'monthly' && renderMonthDayInput()}
{recurrenceType === 'monthly_weekday' && renderMonthlyWeekdayInputs()}
{renderEndDateInput()}
{renderCompletionBasedToggle()}
</div>
);

View file

@ -16,6 +16,11 @@ const TaskTags: React.FC<TaskTagsProps> = ({ tags = [], onTagRemove, className }
navigate(`/tasks?tag=${tagName}`);
};
// Don't render anything if there are no tags
if (!tags || tags.length === 0) {
return null;
}
return (
<div className={`flex flex-wrap gap-2 ${className}`}>
{tags.map((tag, index) => (

View file

@ -40,6 +40,7 @@ interface TagsStore {
setTags: (tags: Tag[]) => void;
setLoading: (isLoading: boolean) => void;
setError: (isError: boolean) => void;
loadTags: () => Promise<void>;
}
interface TasksStore {
@ -104,6 +105,19 @@ export const useStore = create<StoreState>((set) => ({
setTags: (tags) => set((state) => ({ tagsStore: { ...state.tagsStore, tags } })),
setLoading: (isLoading) => set((state) => ({ tagsStore: { ...state.tagsStore, isLoading } })),
setError: (isError) => set((state) => ({ tagsStore: { ...state.tagsStore, isError } })),
loadTags: async () => {
const { fetchTags } = require("../utils/tagsService");
console.log("loadTags: Starting to load tags...");
set((state) => ({ tagsStore: { ...state.tagsStore, isLoading: true, isError: false } }));
try {
const tags = await fetchTags();
console.log("loadTags: Successfully loaded tags:", tags);
set((state) => ({ tagsStore: { ...state.tagsStore, tags, isLoading: false } }));
} catch (error) {
console.error("loadTags: Failed to load tags:", error);
set((state) => ({ tagsStore: { ...state.tagsStore, isError: true, isLoading: false } }));
}
},
},
tasksStore: {
tasks: [],

View file

@ -27,7 +27,7 @@ module.exports = {
historyApiFallback: true,
proxy: [{
context: ['/api', '/locales'],
target: 'http://localhost:3001',
target: 'http://localhost:3002',
changeOrigin: true,
secure: false,
cookieDomainRewrite: 'localhost',