Add fixes for + button to hide

This commit is contained in:
Chris Veleris 2025-06-27 22:36:04 +03:00
parent b5d96a6eff
commit c3d05633d3
25 changed files with 251 additions and 361 deletions

5
.gitignore vendored
View file

@ -16,3 +16,8 @@ backend/coverage/
# User uploaded files
backend/uploads/
# Log files
*.log
server.log
backend/server.log

View file

@ -1,256 +0,0 @@
# 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

@ -1,30 +0,0 @@
> backend@1.0.0 dev
> nodemon app.js
[nodemon] 3.1.10
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,cjs,json
[nodemon] starting `node app.js`
Loaded 20 quotes from configuration
Server error: Error: listen EADDRINUSE: address already in use 0.0.0.0:3002
at Server.setupListenHandle [as _listen2] (node:net:1939:16)
at listenInCluster (node:net:1996:12)
at node:net:2205:7
at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
code: 'EADDRINUSE',
errno: -48,
syscall: 'listen',
address: '0.0.0.0',
port: 3002
}
Error getting updates for user 1: Error: Request timeout
at ClientRequest.<anonymous> (/Users/chris/c0deLab/ProjectLand/tududi/backend/services/telegramPoller.js:93:14)
at ClientRequest.emit (node:events:518:28)
at TLSSocket.emitRequestTimeout (node:_http_client:863:9)
at Object.onceWrapper (node:events:632:28)
at TLSSocket.emit (node:events:530:35)
at Socket._onTimeout (node:net:609:8)
at listOnTimeout (node:internal/timers:588:17)
at process.processTimers (node:internal/timers:523:7)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 358 KiB

2
dist/index.html vendored
View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<title>Tududi</title>
<script defer src="/main.272db06f0669b56179b9.js"></script></head>
<script defer src="/main.be344336e9db0dc716cb.js"></script></head>
<body>
<div id="root"></div>
</body>

File diff suppressed because one or more lines are too long

View file

@ -43,6 +43,7 @@ const Layout: React.FC<LayoutProps> = ({
const { t } = useTranslation();
const { showSuccessToast } = useToast();
const [isSidebarOpen, setIsSidebarOpen] = useState(window.innerWidth >= 1024);
const [globalModalCount, setGlobalModalCount] = useState(0);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
@ -101,6 +102,26 @@ const Layout: React.FC<LayoutProps> = ({
setTaskModalType(type);
};
// Listen for global modal events from child components
useEffect(() => {
const handleModalOpen = () => {
setGlobalModalCount(prev => prev + 1);
};
const handleModalClose = () => {
setGlobalModalCount(prev => Math.max(0, prev - 1));
};
window.addEventListener('modalOpen', handleModalOpen);
window.addEventListener('modalClose', handleModalClose);
return () => {
window.removeEventListener('modalOpen', handleModalOpen);
window.removeEventListener('modalClose', handleModalClose);
};
}, []);
useEffect(() => {
const handleResize = () => {
setIsSidebarOpen(window.innerWidth >= 1024);
@ -449,6 +470,8 @@ const Layout: React.FC<LayoutProps> = ({
</div>
</div>
{/* Hide floating + button when any modal is open to prevent overlap with save buttons */}
{!isTaskModalOpen && !isProjectModalOpen && !isNoteModalOpen && !isAreaModalOpen && !isTagModalOpen && globalModalCount === 0 && (
<button
onClick={() => openTaskModal('simplified')}
className="fixed bottom-6 right-6 bg-blue-500 hover:bg-blue-600 text-white rounded-full p-4 shadow-lg focus:outline-none transform transition-transform duration-200 hover:scale-110 z-50"
@ -457,6 +480,7 @@ const Layout: React.FC<LayoutProps> = ({
>
<PlusIcon className="h-6 w-6" />
</button>
)}
{isTaskModalOpen && (
taskModalType === 'simplified' ? (

View file

@ -17,6 +17,7 @@ import { el, enUS, es, ja, uk, de } from 'date-fns/locale';
import CalendarMonthView from './Calendar/CalendarMonthView';
import CalendarWeekView from './Calendar/CalendarWeekView';
import CalendarDayView from './Calendar/CalendarDayView';
import { useModalEvents } from '../hooks/useModalEvents';
const getLocale = (language: string) => {
switch (language) {
@ -58,6 +59,9 @@ const Calendar: React.FC = () => {
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isEventDetailModalOpen, setIsEventDetailModalOpen] = useState(false);
// Dispatch global modal events
useModalEvents(isTaskModalOpen || isEventDetailModalOpen);
const locale = getLocale(i18n.language);
// Load Google Calendar status and tasks on component mount

View file

@ -1,8 +1,8 @@
import React, { useState } from 'react';
import { InboxItem } from '../../entities/InboxItem';
import { useTranslation } from 'react-i18next';
import { format } from 'date-fns';
import { TrashIcon, PencilIcon, DocumentTextIcon, FolderIcon, ClipboardDocumentListIcon } from '@heroicons/react/24/outline';
import { TagIcon } from '@heroicons/react/24/solid';
import { Task } from '../../entities/Task';
import { Project } from '../../entities/Project';
import { Note } from '../../entities/Note';
@ -118,9 +118,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
}
};
const formattedDate = item.created_at
? format(new Date(item.created_at), 'MMM dd, yyyy HH:mm')
: '';
const handleDelete = () => {
setShowConfirmDialog(true);
@ -139,20 +136,20 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex items-center justify-between px-4 py-2">
<div className="flex-1 mr-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between px-4 py-2 gap-2">
<div className="flex-1">
<p className="text-base font-medium text-gray-900 dark:text-gray-300 break-words">
{item.content}
<span className="ml-3 text-xs text-gray-500 dark:text-gray-600">
{formattedDate}
</span>
<span className="ml-2 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-100 rounded p-1">
</p>
<div className="flex items-center mt-1">
<TagIcon className="h-3 w-3 mr-1 text-gray-500 dark:text-gray-400" />
<span className="text-xs text-gray-500 dark:text-gray-400">
{item.source}
</span>
</p>
</div>
</div>
<div className="flex items-center space-x-1">
<div className="flex items-center justify-start space-x-1 shrink-0">
{loading && <div className="spinner" />}
{/* Edit Button */}
@ -162,7 +159,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
onUpdate(item.id, item.content);
}
}}
className={`p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
className={`p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-opacity opacity-100 sm:${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('common.edit')}
>
<PencilIcon className="h-4 w-4" />
@ -171,7 +168,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{/* Convert to Task Button */}
<button
onClick={handleConvertToTask}
className={`p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
className={`p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-full transition-opacity opacity-100 sm:${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('inbox.createTask')}
>
<ClipboardDocumentListIcon className="h-4 w-4" />
@ -180,7 +177,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{/* Convert to Project Button */}
<button
onClick={handleConvertToProject}
className={`p-2 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
className={`p-2 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900 rounded-full transition-opacity opacity-100 sm:${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('inbox.createProject')}
>
<FolderIcon className="h-4 w-4" />
@ -189,7 +186,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{/* Convert to Note Button */}
<button
onClick={handleConvertToNote}
className={`p-2 text-purple-600 dark:text-purple-400 hover:bg-purple-100 dark:hover:bg-purple-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
className={`p-2 text-purple-600 dark:text-purple-400 hover:bg-purple-100 dark:hover:bg-purple-900 rounded-full transition-opacity opacity-100 sm:${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('inbox.createNote', 'Create Note')}
>
<DocumentTextIcon className="h-4 w-4" />
@ -198,7 +195,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
{/* Delete Button */}
<button
onClick={handleDelete}
className={`p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
className={`p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-full transition-opacity opacity-100 sm:${isHovered ? 'opacity-100' : 'opacity-0'}`}
title={t('common.delete')}
>
<TrashIcon className="h-4 w-4" />

View file

@ -6,6 +6,7 @@ import NoteModal from './NoteModal';
import MarkdownRenderer from '../Shared/MarkdownRenderer';
import { Note } from '../../entities/Note';
import { fetchNotes, deleteNote as apiDeleteNote, updateNote as apiUpdateNote } from '../../utils/notesService';
import { useModalEvents } from '../../hooks/useModalEvents';
const NoteDetails: React.FC = () => {
const { id } = useParams<{ id: string }>();
@ -17,6 +18,9 @@ const NoteDetails: React.FC = () => {
const [isError, setIsError] = useState(false);
const navigate = useNavigate();
// Dispatch global modal events
useModalEvents(isNoteModalOpen);
useEffect(() => {
const fetchNote = async () => {
try {

View file

@ -17,6 +17,7 @@ import {
updateNote,
deleteNote as apiDeleteNote,
} from '../utils/notesService';
import { useModalEvents } from '../hooks/useModalEvents';
const Notes: React.FC = () => {
const { t } = useTranslation();
@ -28,6 +29,9 @@ const Notes: React.FC = () => {
const [noteToDelete, setNoteToDelete] = useState<Note | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(true);
// Dispatch global modal events
useModalEvents(isNoteModalOpen);
const [isError, setIsError] = useState(false);
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);

View file

@ -22,6 +22,7 @@ import { isAuthError } from "../../utils/authUtils";
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/react/24/solid";
import { getAutoSuggestNextActionsEnabled } from "../../utils/profileService";
import AutoSuggestNextActionBox from "./AutoSuggestNextActionBox";
import { useModalEvents } from "../../hooks/useModalEvents";
type PriorityStyles = Record<PriorityType, string> & { default: string };
@ -49,6 +50,9 @@ const ProjectDetails: React.FC = () => {
const [showCompleted, setShowCompleted] = useState(false);
const [showAutoSuggestForm, setShowAutoSuggestForm] = useState(false);
// Dispatch global modal events
useModalEvents(isModalOpen);
useEffect(() => {
const loadProjectData = async () => {
if (!id) {

View file

@ -13,6 +13,7 @@ import { fetchAreas } from "../utils/areasService";
import { useTranslation } from "react-i18next";
import { Project } from "../entities/Project";
import { useModalEvents } from "../hooks/useModalEvents";
import { PriorityType } from "../entities/Task";
import { useSearchParams } from "react-router-dom";
import ProjectItem from "./Project/ProjectItem";
@ -48,6 +49,9 @@ const Projects: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const activeFilter = searchParams.get("active") || "all";
// Dispatch global modal events
useModalEvents(isProjectModalOpen);
const areaFilter = searchParams.get("area_id") || "";
useEffect(() => {

View file

@ -4,6 +4,8 @@ import { useToast } from "../Shared/ToastContext";
import { useTranslation } from "react-i18next";
import { createInboxItemWithStore } from "../../utils/inboxService";
import { isAuthError } from "../../utils/authUtils";
import { XMarkIcon } from "@heroicons/react/24/outline";
import { useModalEvents } from "../../hooks/useModalEvents";
// import UrlPreview from "../Shared/UrlPreview";
// import { UrlTitleResult } from "../../utils/urlService";
@ -34,12 +36,29 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
const [saveMode, setSaveMode] = useState<'task' | 'inbox'>('inbox');
// const [urlPreview, setUrlPreview] = useState<UrlTitleResult | null>(null);
// Dispatch global modal events to hide floating + button
useModalEvents(isOpen);
useEffect(() => {
if (isOpen && nameInputRef.current) {
nameInputRef.current.focus();
}
}, [isOpen]);
// Prevent body scroll when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = 'unset';
}
// Cleanup function to restore scroll when component unmounts
return () => {
document.body.style.overflow = 'unset';
};
}, [isOpen]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputText(e.target.value);
};
@ -147,18 +166,28 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
return (
<div
className={`fixed top-16 left-0 right-0 bottom-0 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-40 transition-opacity duration-300 ${
className={`fixed top-16 left-0 right-0 bottom-0 sm:top-16 flex items-start sm:items-center justify-center bg-gray-900 bg-opacity-80 z-[45] transition-opacity duration-300 ${
isClosing ? "opacity-0" : "opacity-100"
}`}
>
<div
ref={modalRef}
className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full sm:max-w-2xl md:max-w-3xl overflow-hidden transform transition-transform duration-300 ${
className={`relative bg-white dark:bg-gray-800 border-0 sm:border border-gray-200 dark:border-gray-800 sm:rounded-lg sm:shadow-2xl w-full h-full sm:h-auto sm:max-w-2xl md:max-w-3xl transform transition-transform duration-300 ${
isClosing ? "scale-95" : "scale-100"
} flex flex-col`}
>
<div className="p-6 px-8">
<div className="flex items-center">
{/* Close button - only visible on mobile */}
<button
onClick={handleClose}
className="absolute top-4 right-4 z-10 p-2 text-gray-600 hover:text-gray-800 dark:text-gray-300 dark:hover:text-gray-100 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-full shadow-lg transition-colors duration-200 sm:hidden"
aria-label="Close"
>
<XMarkIcon className="h-6 w-6" />
</button>
<div className="flex-1 flex items-center justify-center sm:block sm:flex-none">
<div className="w-full p-6 px-8">
<div className="flex flex-col sm:flex-row sm:items-center">
<input
ref={nameInputRef}
type="text"
@ -179,7 +208,7 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
type="button"
onClick={handleSubmit}
disabled={!inputText.trim() || isSaving}
className={`ml-4 inline-flex justify-center px-4 py-2 text-sm font-medium text-white rounded-md shadow-sm focus:outline-none ${
className={`mt-4 sm:mt-0 sm:ml-4 inline-flex justify-center px-4 py-2 text-sm font-medium text-white rounded-md shadow-sm focus:outline-none ${
inputText.trim() && !isSaving
? "bg-blue-600 hover:bg-blue-700"
: "bg-blue-400 cursor-not-allowed"
@ -196,6 +225,7 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
</div>
</div>
</div>
</div>
);
};

View file

@ -269,7 +269,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{onToggleToday && (
<button
onClick={handleTodayToggle}
className={`items-center justify-center ${task.today_move_count && task.today_move_count > 1 ? 'px-2 h-6' : 'w-6 h-6'} rounded-full transition-all duration-200 opacity-0 group-hover:opacity-100 ${
className={`items-center justify-center ${Number(task.today_move_count) > 1 ? 'px-2 h-6' : 'w-6 h-6'} rounded-full transition-all duration-200 opacity-0 group-hover:opacity-100 ${
task.today
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 flex'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 hidden group-hover:flex'
@ -281,9 +281,9 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
) : (
<CalendarIcon className="h-3 w-3" />
)}
{task.today_move_count && task.today_move_count > 1 && (
{Number(task.today_move_count) > 1 && (
<span className="ml-1 text-xs font-medium">
{task.today_move_count}
{Number(task.today_move_count)}
</span>
)}
</button>

View file

@ -5,6 +5,7 @@ import TaskHeader from './TaskHeader';
import TaskModal from './TaskModal';
import { toggleTaskCompletion } from '../../utils/tasksService';
import { isTaskOverdue } from '../../utils/dateUtils';
import { useModalEvents } from '../../hooks/useModalEvents';
interface TaskItemProps {
task: Task;
@ -26,6 +27,9 @@ const TaskItem: React.FC<TaskItemProps> = ({
const [isModalOpen, setIsModalOpen] = useState(false);
const [projectList, setProjectList] = useState<Project[]>(projects);
// Dispatch global modal events
useModalEvents(isModalOpen);
const handleTaskClick = () => {
setIsModalOpen(true);
};

View file

@ -0,0 +1,42 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
interface ModalContextType {
isAnyModalOpen: boolean;
openModal: () => void;
closeModal: () => void;
modalCount: number;
}
const ModalContext = createContext<ModalContextType | undefined>(undefined);
export const useModal = () => {
const context = useContext(ModalContext);
if (context === undefined) {
throw new Error('useModal must be used within a ModalProvider');
}
return context;
};
interface ModalProviderProps {
children: ReactNode;
}
export const ModalProvider: React.FC<ModalProviderProps> = ({ children }) => {
const [modalCount, setModalCount] = useState(0);
const openModal = () => {
setModalCount(prev => prev + 1);
};
const closeModal = () => {
setModalCount(prev => Math.max(0, prev - 1));
};
const isAnyModalOpen = modalCount > 0;
return (
<ModalContext.Provider value={{ isAnyModalOpen, openModal, closeModal, modalCount }}>
{children}
</ModalContext.Provider>
);
};

View file

@ -0,0 +1,15 @@
import { useEffect } from 'react';
/**
* Hook to dispatch global modal events when local modal state changes
* @param isOpen - Whether the modal is currently open
*/
export const useModalEvents = (isOpen: boolean) => {
useEffect(() => {
if (isOpen) {
window.dispatchEvent(new CustomEvent('modalOpen'));
} else {
window.dispatchEvent(new CustomEvent('modalClose'));
}
}, [isOpen]);
};

View file

@ -0,0 +1,28 @@
import { useEffect } from 'react';
import { useModal } from '../contexts/ModalContext';
/**
* Hook to automatically manage modal state with the global modal context
* @param isOpen - Whether the modal is currently open
* @returns Object with the modal context functions
*/
export const useModalManager = (isOpen: boolean) => {
const modalContext = useModal();
useEffect(() => {
if (isOpen) {
modalContext.openModal();
} else {
modalContext.closeModal();
}
// Cleanup function to ensure we close the modal if component unmounts while open
return () => {
if (isOpen) {
modalContext.closeModal();
}
};
}, [isOpen, modalContext]);
return modalContext;
};

View file

@ -27,15 +27,15 @@ echo "Setting up Docker buildx for multi-architecture builds"
docker buildx ls | grep -q mybuilder || docker buildx create --name mybuilder --use
docker buildx inspect --bootstrap
# Build and push multi-architecture images (AMD64, ARM64, ARMv7) with the version tag
# Build and push multi-architecture images (AMD64, ARM64) with the version tag
echo "Building and pushing multi-architecture docker image: chrisvel/tududi:$docker_tag"
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
docker buildx build --platform linux/amd64,linux/arm64 \
-t chrisvel/tududi:"$docker_tag" \
--push .
# Build and push multi-architecture images (AMD64, ARM64, ARMv7) with the latest tag
# Build and push multi-architecture images (AMD64, ARM64) with the latest tag
echo "Building and pushing multi-architecture docker image: chrisvel/tududi:latest"
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 \
docker buildx build --platform linux/amd64,linux/arm64 \
-t chrisvel/tududi:latest \
--push .