Setup intelligence (#84)
* Add next suggestions and remove console logs * Add pomodoro timer * Add pomodoro switch in settings * Fix pomodoro setting * Add timezones to settings * Fix an issue with password reset * Cleanup * Sort tags alphabetically * Clean up today's view * Add an indicator for repeatedly added to today * Refactor tags * Add due date today item * Move recurrence to the subtitle area * Fix today layout * Add a badge to Inbox items * Move inbox badge to sidebar * Add quotes and progress bar * Add translations for quotes * Fix test issues * Add helper script for docker local * Set up overdue tasks * Add linux/arm/v7 build to deploy script * Add linux/arm/v7 build to deploy script pt2 * Fix an issue with helmet and SSL * Add volume db persistence * Fix cog icon issues
This commit is contained in:
parent
3affbe9baf
commit
03f38f05dc
143 changed files with 80005 additions and 21674 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,6 +10,7 @@ node_modules
|
|||
|
||||
public/js/bundle.js
|
||||
.aider*
|
||||
.claude*
|
||||
|
||||
backend/coverage/
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ RUN npm install --production --no-audit --no-fund && \
|
|||
find node_modules -name ".github" -type d -exec rm -rf {} + 2>/dev/null || true && \
|
||||
find node_modules -name "test" -type d -exec rm -rf {} + 2>/dev/null || true && \
|
||||
find node_modules -name "tests" -type d -exec rm -rf {} + 2>/dev/null || true && \
|
||||
find node_modules -name "docs" -type d -exec rm -rf {} + 2>/dev/null || true && \
|
||||
find node_modules -name "docs" -type d ! -path "*/googleapis/*" -exec rm -rf {} + 2>/dev/null || true && \
|
||||
find node_modules -name "examples" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# Stage 3: Test Stage (run tests before production)
|
||||
|
|
@ -130,7 +130,7 @@ COPY --from=frontend-builder --chown=app:app /app/dist ./backend/dist
|
|||
COPY --from=frontend-builder --chown=app:app /app/public/locales ./backend/dist/locales
|
||||
|
||||
# Create ultra-minimal startup script (before switching to non-root user)
|
||||
RUN printf '#!/bin/sh\nset -e\ncd backend\nmkdir -p db certs\nDB_FILE="db/production.sqlite3"\n[ "$NODE_ENV" = "development" ] && DB_FILE="db/development.sqlite3"\nif [ ! -f "$DB_FILE" ]; then\n node -e "require(\\"./models\\").sequelize.sync({force:true}).then(()=>{console.log(\\"✅ DB ready\\");process.exit(0)}).catch(e=>{console.error(\\"❌\\",e.message);process.exit(1)})"\nelse\n node -e "require(\\"./models\\").sequelize.authenticate().then(()=>{console.log(\\"✅ DB OK\\");process.exit(0)}).catch(e=>{console.error(\\"❌\\",e.message);process.exit(1)})"\nfi\nif [ -n "$TUDUDI_USER_EMAIL" ]&&[ -n "$TUDUDI_USER_PASSWORD" ]; then\n node -e "const{User}=require(\\"./models\\");const bcrypt=require(\\"bcrypt\\");(async()=>{try{const[u,c]=await User.findOrCreate({where:{email:process.env.TUDUDI_USER_EMAIL},defaults:{email:process.env.TUDUDI_USER_EMAIL,password_digest:await bcrypt.hash(process.env.TUDUDI_USER_PASSWORD,10)}});console.log(c?\\"✅ User created\\":\\"ℹ️ User exists\\");process.exit(0)}catch(e){console.error(\\"❌\\",e.message);process.exit(1)}})();"||exit 1\nfi\n[ "$TUDUDI_INTERNAL_SSL_ENABLED" = "true" ]&&[ ! -f "certs/server.crt" ]&&openssl req -x509 -newkey rsa:2048 -keyout certs/server.key -out certs/server.crt -days 365 -nodes -subj "/CN=localhost" 2>/dev/null||true\nexec node app.js\n' > start.sh && chmod +x start.sh
|
||||
RUN printf '#!/bin/sh\nset -e\ncd backend\n# Check and create directories with proper permissions\nif [ ! -d "db" ]; then\n mkdir -p db\nfi\nif [ ! -w "db" ]; then\n echo "❌ ERROR: Database directory /app/backend/db is not writable by user app (1001:1001)"\n echo "ℹ️ If using Docker volumes, ensure the host directory has proper ownership:"\n echo " sudo chown -R 1001:1001 /host/path/to/db"\n echo " Or use an anonymous volume: docker run -v /app/backend/db ..."\n exit 1\nfi\nmkdir -p certs\nDB_FILE="db/production.sqlite3"\n[ "$NODE_ENV" = "development" ] && DB_FILE="db/development.sqlite3"\nif [ ! -f "$DB_FILE" ]; then\n node -e "require(\\"./models\\").sequelize.sync({force:true}).then(()=>{console.log(\\"✅ DB ready\\");process.exit(0)}).catch(e=>{console.error(\\"❌\\",e.message);process.exit(1)})"\nelse\n node -e "require(\\"./models\\").sequelize.authenticate().then(()=>{console.log(\\"✅ DB OK\\");process.exit(0)}).catch(e=>{console.error(\\"❌\\",e.message);process.exit(1)})"\nfi\nif [ -n "$TUDUDI_USER_EMAIL" ]&&[ -n "$TUDUDI_USER_PASSWORD" ]; then\n node -e "const{User}=require(\\"./models\\");const bcrypt=require(\\"bcrypt\\");(async()=>{try{const[u,c]=await User.findOrCreate({where:{email:process.env.TUDUDI_USER_EMAIL},defaults:{email:process.env.TUDUDI_USER_EMAIL,password_digest:await bcrypt.hash(process.env.TUDUDI_USER_PASSWORD,10)}});console.log(c?\\"✅ User created\\":\\"ℹ️ User exists\\");process.exit(0)}catch(e){console.error(\\"❌\\",e.message);process.exit(1)}})();"||exit 1\nfi\n[ "$TUDUDI_INTERNAL_SSL_ENABLED" = "true" ]&&[ ! -f "certs/server.crt" ]&&openssl req -x509 -newkey rsa:2048 -keyout certs/server.key -out certs/server.crt -days 365 -nodes -subj "/CN=localhost" 2>/dev/null||true\nexec node app.js\n' > start.sh && chmod +x start.sh
|
||||
|
||||
# Create necessary directories and final cleanup
|
||||
RUN mkdir -p ./backend/db ./backend/certs && \
|
||||
|
|
@ -141,6 +141,9 @@ RUN mkdir -p ./backend/db ./backend/certs && \
|
|||
rm -rf /usr/local/lib/node_modules/npm/docs /usr/local/lib/node_modules/npm/man && \
|
||||
rm -rf /root/.npm /tmp/* /var/tmp/* /var/cache/apk/*
|
||||
|
||||
# Declare volume for database persistence
|
||||
VOLUME ["/app/backend/db"]
|
||||
|
||||
# Switch to non-root user
|
||||
USER app
|
||||
|
||||
|
|
|
|||
9
backend/.env.example
Normal file
9
backend/.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# Google Calendar Integration
|
||||
GOOGLE_CLIENT_ID=your_google_client_id_here
|
||||
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
|
||||
GOOGLE_REDIRECT_URI=http://localhost:3002/api/calendar/oauth/callback
|
||||
|
||||
# Other environment variables (if needed)
|
||||
# TUDUDI_SESSION_SECRET=your_session_secret_here
|
||||
# TUDUDI_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:9292
|
||||
# NODE_ENV=development
|
||||
|
|
@ -19,7 +19,12 @@ const sessionStore = new SequelizeStore({
|
|||
});
|
||||
|
||||
// Middlewares
|
||||
app.use(helmet());
|
||||
const sslEnabled = process.env.NODE_ENV === 'production' && process.env.TUDUDI_INTERNAL_SSL_ENABLED === 'true';
|
||||
app.use(helmet({
|
||||
hsts: sslEnabled, // Only enable HSTS when SSL is enabled
|
||||
forceHTTPS: sslEnabled, // Only force HTTPS when SSL is enabled
|
||||
contentSecurityPolicy: false // Disable CSP for now to avoid conflicts
|
||||
}));
|
||||
app.use(compression());
|
||||
app.use(morgan('combined'));
|
||||
|
||||
|
|
@ -31,7 +36,7 @@ const allowedOrigins = process.env.TUDUDI_ALLOWED_ORIGINS
|
|||
app.use(cors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: ['Authorization', 'Content-Type', 'Accept', 'X-Requested-With'],
|
||||
exposedHeaders: ['Content-Type'],
|
||||
maxAge: 1728000
|
||||
|
|
@ -98,6 +103,8 @@ app.use('/api', requireAuth, require('./routes/inbox'));
|
|||
app.use('/api', requireAuth, require('./routes/url'));
|
||||
app.use('/api', requireAuth, require('./routes/telegram'));
|
||||
app.use('/api', requireAuth, require('./routes/quotes'));
|
||||
app.use('/api', requireAuth, require('./routes/task-events'));
|
||||
app.use('/api/calendar', require('./routes/calendar'));
|
||||
|
||||
// SPA fallback
|
||||
app.get('*', (req, res) => {
|
||||
|
|
@ -138,7 +145,7 @@ async function startServer() {
|
|||
where: { email: process.env.TUDUDI_USER_EMAIL },
|
||||
defaults: {
|
||||
email: process.env.TUDUDI_USER_EMAIL,
|
||||
password: await bcrypt.hash(process.env.TUDUDI_USER_PASSWORD, 10)
|
||||
password_digest: await bcrypt.hash(process.env.TUDUDI_USER_PASSWORD, 10)
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add completed_at column to tasks table
|
||||
await queryInterface.addColumn('tasks', 'completed_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
});
|
||||
|
||||
// Add an index for better query performance
|
||||
await queryInterface.addIndex('tasks', ['completed_at']);
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove the index first
|
||||
await queryInterface.removeIndex('tasks', ['completed_at']);
|
||||
|
||||
// Remove the completed_at column
|
||||
await queryInterface.removeColumn('tasks', 'completed_at');
|
||||
}
|
||||
};
|
||||
79
backend/migrations/20250621223000-create-calendar-tokens.js
Normal file
79
backend/migrations/20250621223000-create-calendar-tokens.js
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
up: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.createTable('calendar_tokens', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
provider: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'google'
|
||||
},
|
||||
access_token: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
refresh_token: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
token_type: {
|
||||
type: Sequelize.STRING,
|
||||
defaultValue: 'Bearer'
|
||||
},
|
||||
expires_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
scope: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
connected_email: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
|
||||
// Add unique index for user_id + provider combination
|
||||
await queryInterface.addIndex('calendar_tokens', {
|
||||
fields: ['user_id', 'provider'],
|
||||
unique: true,
|
||||
name: 'calendar_tokens_user_provider_unique'
|
||||
});
|
||||
|
||||
// Add index for faster lookups by user_id
|
||||
await queryInterface.addIndex('calendar_tokens', {
|
||||
fields: ['user_id'],
|
||||
name: 'calendar_tokens_user_id_index'
|
||||
});
|
||||
},
|
||||
|
||||
down: async (queryInterface, Sequelize) => {
|
||||
await queryInterface.dropTable('calendar_tokens');
|
||||
}
|
||||
};
|
||||
88
backend/migrations/20250622000001-create-task-events.js
Normal file
88
backend/migrations/20250622000001-create-task-events.js
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Create task_events table
|
||||
await queryInterface.createTable('task_events', {
|
||||
id: {
|
||||
type: Sequelize.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
allowNull: false
|
||||
},
|
||||
task_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
user_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
event_type: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: false,
|
||||
// Common event types: 'created', 'status_changed', 'priority_changed',
|
||||
// 'due_date_changed', 'project_changed', 'name_changed', 'description_changed',
|
||||
// 'completed', 'archived', 'deleted', 'restored'
|
||||
},
|
||||
old_value: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
// JSON string of the old value(s) - for tracking what changed from
|
||||
},
|
||||
new_value: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
// JSON string of the new value(s) - for tracking what changed to
|
||||
},
|
||||
field_name: {
|
||||
type: Sequelize.STRING,
|
||||
allowNull: true,
|
||||
// The name of the field that was changed (status, priority, due_date, etc.)
|
||||
},
|
||||
metadata: {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
// Additional context as JSON string (e.g., source of change: 'web', 'api', 'telegram')
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
|
||||
// Add indexes for better query performance
|
||||
await queryInterface.addIndex('task_events', ['task_id']);
|
||||
await queryInterface.addIndex('task_events', ['user_id']);
|
||||
await queryInterface.addIndex('task_events', ['event_type']);
|
||||
await queryInterface.addIndex('task_events', ['created_at']);
|
||||
await queryInterface.addIndex('task_events', ['task_id', 'event_type']);
|
||||
await queryInterface.addIndex('task_events', ['task_id', 'created_at']);
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove indexes first
|
||||
await queryInterface.removeIndex('task_events', ['task_id', 'created_at']);
|
||||
await queryInterface.removeIndex('task_events', ['task_id', 'event_type']);
|
||||
await queryInterface.removeIndex('task_events', ['created_at']);
|
||||
await queryInterface.removeIndex('task_events', ['event_type']);
|
||||
await queryInterface.removeIndex('task_events', ['user_id']);
|
||||
await queryInterface.removeIndex('task_events', ['task_id']);
|
||||
|
||||
// Drop the table
|
||||
await queryInterface.dropTable('task_events');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up (queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn('users', 'pomodoro_enabled', {
|
||||
type: Sequelize.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
});
|
||||
},
|
||||
|
||||
async down (queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('users', 'pomodoro_enabled');
|
||||
}
|
||||
};
|
||||
41
backend/migrations/20250623000001-add-uuid-to-tasks.js
Normal file
41
backend/migrations/20250623000001-add-uuid-to-tasks.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
'use strict';
|
||||
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add UUID column to tasks table (without unique constraint initially)
|
||||
await queryInterface.addColumn('tasks', 'uuid', {
|
||||
type: Sequelize.UUID,
|
||||
allowNull: true
|
||||
});
|
||||
|
||||
// Backfill existing tasks with UUIDs
|
||||
const tasks = await queryInterface.sequelize.query(
|
||||
'SELECT id FROM tasks WHERE uuid IS NULL',
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
for (const task of tasks) {
|
||||
const uuid = uuidv4();
|
||||
await queryInterface.sequelize.query(
|
||||
'UPDATE tasks SET uuid = ? WHERE id = ?',
|
||||
{ replacements: [uuid, task.id] }
|
||||
);
|
||||
}
|
||||
|
||||
// Add unique index for UUID
|
||||
await queryInterface.addIndex('tasks', ['uuid'], {
|
||||
unique: true,
|
||||
name: 'tasks_uuid_unique'
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove index first
|
||||
await queryInterface.removeIndex('tasks', 'tasks_uuid_unique');
|
||||
|
||||
// Remove UUID column
|
||||
await queryInterface.removeColumn('tasks', 'uuid');
|
||||
}
|
||||
};
|
||||
48
backend/migrations/20250623000003-create-notes-tags-table.js
Normal file
48
backend/migrations/20250623000003-create-notes-tags-table.js
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Check if notes_tags table exists
|
||||
const tables = await queryInterface.showAllTables();
|
||||
if (!tables.includes('notes_tags')) {
|
||||
await queryInterface.createTable('notes_tags', {
|
||||
note_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
references: {
|
||||
model: 'notes',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
tag_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
references: {
|
||||
model: 'tags',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
created_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
allowNull: false,
|
||||
type: Sequelize.DATE,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
|
||||
// Add unique index
|
||||
await queryInterface.addIndex('notes_tags', ['note_id', 'tag_id'], {
|
||||
unique: true,
|
||||
name: 'notes_tags_unique_idx'
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.dropTable('notes_tags');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Add created_at and updated_at columns to notes_tags table
|
||||
try {
|
||||
await queryInterface.addColumn('notes_tags', 'created_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
});
|
||||
|
||||
await queryInterface.addColumn('notes_tags', 'updated_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
});
|
||||
|
||||
console.log('Successfully added timestamps to notes_tags table');
|
||||
} catch (error) {
|
||||
console.error('Error adding timestamps to notes_tags:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn('notes_tags', 'created_at');
|
||||
await queryInterface.removeColumn('notes_tags', 'updated_at');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
// Create the projects_tags table if it doesn't exist
|
||||
const tableExists = await queryInterface.showAllTables()
|
||||
.then(tables => tables.includes('projects_tags'));
|
||||
|
||||
if (!tableExists) {
|
||||
await queryInterface.createTable('projects_tags', {
|
||||
project_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'projects',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
tag_id: {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tags',
|
||||
key: 'id'
|
||||
},
|
||||
onUpdate: 'CASCADE',
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
created_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
},
|
||||
updated_at: {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
}
|
||||
});
|
||||
|
||||
// Add composite primary key
|
||||
await queryInterface.addConstraint('projects_tags', {
|
||||
fields: ['project_id', 'tag_id'],
|
||||
type: 'primary key',
|
||||
name: 'projects_tags_pkey'
|
||||
});
|
||||
} else {
|
||||
// Add timestamps if table exists but doesn't have them
|
||||
try {
|
||||
await queryInterface.addColumn('projects_tags', 'created_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
});
|
||||
} catch (error) {
|
||||
// Column might already exist
|
||||
}
|
||||
|
||||
try {
|
||||
await queryInterface.addColumn('projects_tags', 'updated_at', {
|
||||
type: Sequelize.DATE,
|
||||
allowNull: false,
|
||||
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP')
|
||||
});
|
||||
} catch (error) {
|
||||
// Column might already exist
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
// Remove timestamps or drop table if needed
|
||||
try {
|
||||
await queryInterface.removeColumn('projects_tags', 'created_at');
|
||||
await queryInterface.removeColumn('projects_tags', 'updated_at');
|
||||
} catch (error) {
|
||||
// Columns might not exist
|
||||
}
|
||||
}
|
||||
};
|
||||
77
backend/models/calendar_token.js
Normal file
77
backend/models/calendar_token.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../config/database');
|
||||
|
||||
const CalendarToken = sequelize.define('CalendarToken', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'Users',
|
||||
key: 'id'
|
||||
},
|
||||
onDelete: 'CASCADE'
|
||||
},
|
||||
provider: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'google'
|
||||
},
|
||||
access_token: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false
|
||||
},
|
||||
refresh_token: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
token_type: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'Bearer'
|
||||
},
|
||||
expires_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
scope: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true
|
||||
},
|
||||
connected_email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'calendar_tokens',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['user_id', 'provider']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Associations
|
||||
CalendarToken.associate = function(models) {
|
||||
CalendarToken.belongsTo(models.User, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'user'
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = CalendarToken;
|
||||
|
|
@ -5,10 +5,11 @@ const path = require('path');
|
|||
let dbConfig;
|
||||
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// Use in-memory database for tests
|
||||
// Use temporary file database for tests to allow external script access
|
||||
const testDbPath = path.join(__dirname, '../db', 'test.sqlite3');
|
||||
dbConfig = {
|
||||
dialect: 'sqlite',
|
||||
storage: ':memory:',
|
||||
storage: testDbPath,
|
||||
logging: false,
|
||||
define: {
|
||||
timestamps: true,
|
||||
|
|
@ -45,6 +46,7 @@ const Task = require('./task')(sequelize);
|
|||
const Tag = require('./tag')(sequelize);
|
||||
const Note = require('./note')(sequelize);
|
||||
const InboxItem = require('./inbox_item')(sequelize);
|
||||
const TaskEvent = require('./task_event')(sequelize);
|
||||
|
||||
// Define associations
|
||||
User.hasMany(Area, { foreignKey: 'user_id' });
|
||||
|
|
@ -71,6 +73,12 @@ Project.hasMany(Note, { foreignKey: 'project_id' });
|
|||
User.hasMany(InboxItem, { foreignKey: 'user_id' });
|
||||
InboxItem.belongsTo(User, { foreignKey: 'user_id' });
|
||||
|
||||
// TaskEvent associations
|
||||
User.hasMany(TaskEvent, { foreignKey: 'user_id', as: 'TaskEvents' });
|
||||
TaskEvent.belongsTo(User, { foreignKey: 'user_id', as: 'User' });
|
||||
Task.hasMany(TaskEvent, { foreignKey: 'task_id', as: 'TaskEvents' });
|
||||
TaskEvent.belongsTo(Task, { foreignKey: 'task_id', as: 'Task' });
|
||||
|
||||
// Many-to-many associations
|
||||
Task.belongsToMany(Tag, { through: 'tasks_tags', foreignKey: 'task_id', otherKey: 'tag_id' });
|
||||
Tag.belongsToMany(Task, { through: 'tasks_tags', foreignKey: 'tag_id', otherKey: 'task_id' });
|
||||
|
|
@ -89,5 +97,6 @@ module.exports = {
|
|||
Task,
|
||||
Tag,
|
||||
Note,
|
||||
InboxItem
|
||||
InboxItem,
|
||||
TaskEvent
|
||||
};
|
||||
|
|
@ -7,6 +7,12 @@ module.exports = (sequelize) => {
|
|||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
uuid: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
defaultValue: DataTypes.UUIDV4
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
|
|
@ -115,6 +121,10 @@ module.exports = (sequelize) => {
|
|||
model: 'tasks',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
completed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
}
|
||||
}, {
|
||||
tableName: 'tasks',
|
||||
|
|
|
|||
211
backend/models/task_event.js
Normal file
211
backend/models/task_event.js
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const TaskEvent = sequelize.define('TaskEvent', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
task_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
event_type: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
validate: {
|
||||
isIn: [['created', 'status_changed', 'priority_changed', 'due_date_changed',
|
||||
'project_changed', 'name_changed', 'description_changed', 'note_changed',
|
||||
'completed', 'archived', 'deleted', 'restored', 'today_changed',
|
||||
'tags_changed', 'recurrence_changed', 'recurrence_type_changed',
|
||||
'completion_based_changed', 'recurrence_end_date_changed']]
|
||||
}
|
||||
},
|
||||
old_value: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('old_value');
|
||||
return rawValue ? JSON.parse(rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('old_value', value ? JSON.stringify(value) : null);
|
||||
}
|
||||
},
|
||||
new_value: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('new_value');
|
||||
return rawValue ? JSON.parse(rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('new_value', value ? JSON.stringify(value) : null);
|
||||
}
|
||||
},
|
||||
field_name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
validate: {
|
||||
isIn: [['status', 'priority', 'due_date', 'project_id', 'name', 'description',
|
||||
'note', 'today', 'tags', 'recurrence_type', 'recurrence_interval',
|
||||
'recurrence_end_date', 'recurrence_weekday', 'recurrence_month_day',
|
||||
'recurrence_week_of_month', 'completion_based']]
|
||||
}
|
||||
},
|
||||
metadata: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
get() {
|
||||
const rawValue = this.getDataValue('metadata');
|
||||
return rawValue ? JSON.parse(rawValue) : null;
|
||||
},
|
||||
set(value) {
|
||||
this.setDataValue('metadata', value ? JSON.stringify(value) : null);
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'task_events',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false, // We don't need updated_at for events (they're immutable)
|
||||
indexes: [
|
||||
{
|
||||
fields: ['task_id']
|
||||
},
|
||||
{
|
||||
fields: ['user_id']
|
||||
},
|
||||
{
|
||||
fields: ['event_type']
|
||||
},
|
||||
{
|
||||
fields: ['created_at']
|
||||
},
|
||||
{
|
||||
fields: ['task_id', 'event_type']
|
||||
},
|
||||
{
|
||||
fields: ['task_id', 'created_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// Define associations
|
||||
TaskEvent.associate = function(models) {
|
||||
// TaskEvent belongs to Task
|
||||
TaskEvent.belongsTo(models.Task, {
|
||||
foreignKey: 'task_id',
|
||||
as: 'Task'
|
||||
});
|
||||
|
||||
// TaskEvent belongs to User
|
||||
TaskEvent.belongsTo(models.User, {
|
||||
foreignKey: 'user_id',
|
||||
as: 'User'
|
||||
});
|
||||
};
|
||||
|
||||
// Helper methods for common event types
|
||||
TaskEvent.createStatusChangeEvent = async function(taskId, userId, oldStatus, newStatus, metadata = {}) {
|
||||
return await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: 'status_changed',
|
||||
field_name: 'status',
|
||||
old_value: { status: oldStatus },
|
||||
new_value: { status: newStatus },
|
||||
metadata: metadata
|
||||
});
|
||||
};
|
||||
|
||||
TaskEvent.createTaskCreatedEvent = async function(taskId, userId, taskData, metadata = {}) {
|
||||
return await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: 'created',
|
||||
field_name: null,
|
||||
old_value: null,
|
||||
new_value: taskData,
|
||||
metadata: metadata
|
||||
});
|
||||
};
|
||||
|
||||
TaskEvent.createFieldChangeEvent = async function(taskId, userId, fieldName, oldValue, newValue, metadata = {}) {
|
||||
const eventType = fieldName === 'status' && newValue === 2 ? 'completed' :
|
||||
fieldName === 'status' && newValue === 3 ? 'archived' :
|
||||
`${fieldName}_changed`;
|
||||
|
||||
return await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: eventType,
|
||||
field_name: fieldName,
|
||||
old_value: { [fieldName]: oldValue },
|
||||
new_value: { [fieldName]: newValue },
|
||||
metadata: metadata
|
||||
});
|
||||
};
|
||||
|
||||
// Query helpers
|
||||
TaskEvent.getTaskTimeline = async function(taskId) {
|
||||
return await TaskEvent.findAll({
|
||||
where: { task_id: taskId },
|
||||
order: [['created_at', 'ASC']],
|
||||
include: [{
|
||||
model: sequelize.models.User,
|
||||
as: 'User',
|
||||
attributes: ['id', 'name', 'email']
|
||||
}]
|
||||
});
|
||||
};
|
||||
|
||||
TaskEvent.getCompletionTime = async function(taskId) {
|
||||
const events = await TaskEvent.findAll({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
event_type: ['status_changed', 'created', 'completed']
|
||||
},
|
||||
order: [['created_at', 'ASC']]
|
||||
});
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const startEvent = events.find(e =>
|
||||
e.event_type === 'created' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 1) // in_progress
|
||||
);
|
||||
|
||||
const completedEvent = events.find(e =>
|
||||
e.event_type === 'completed' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 2) // done
|
||||
);
|
||||
|
||||
if (!startEvent || !completedEvent) return null;
|
||||
|
||||
const startTime = new Date(startEvent.created_at);
|
||||
const endTime = new Date(completedEvent.created_at);
|
||||
|
||||
return {
|
||||
started_at: startTime,
|
||||
completed_at: endTime,
|
||||
duration_ms: endTime - startTime,
|
||||
duration_hours: (endTime - startTime) / (1000 * 60 * 60)
|
||||
};
|
||||
};
|
||||
|
||||
return TaskEvent;
|
||||
};
|
||||
|
|
@ -20,6 +20,10 @@ module.exports = (sequelize) => {
|
|||
isEmail: true
|
||||
}
|
||||
},
|
||||
password: {
|
||||
type: DataTypes.VIRTUAL,
|
||||
allowNull: true
|
||||
},
|
||||
password_digest: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
|
|
@ -85,6 +89,24 @@ module.exports = (sequelize) => {
|
|||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: false
|
||||
},
|
||||
pomodoro_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
allowNull: false,
|
||||
defaultValue: true
|
||||
},
|
||||
today_settings: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: {
|
||||
showMetrics: false,
|
||||
showProductivity: false,
|
||||
showIntelligence: false,
|
||||
showDueToday: true,
|
||||
showCompleted: true,
|
||||
showProgressBar: true,
|
||||
showDailyQuote: true
|
||||
}
|
||||
}
|
||||
}, {
|
||||
tableName: 'users',
|
||||
|
|
|
|||
1004
backend/package-lock.json
generated
1004
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Functional programming Express.js backend for tududi task management application",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
|
|
@ -22,27 +22,32 @@
|
|||
"migration:run": "npx sequelize-cli db:migrate",
|
||||
"migration:undo": "npx sequelize-cli db:migrate:undo",
|
||||
"migration:undo:all": "npx sequelize-cli db:migrate:undo:all",
|
||||
"migration:status": "npx sequelize-cli db:migrate:status"
|
||||
"migration:status": "npx sequelize-cli db:migrate:status",
|
||||
"seed:dev": "node scripts/seed-dev-data.js"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"cheerio": "^1.1.0",
|
||||
"compression": "^1.8.0",
|
||||
"connect-session-sequelize": "^7.1.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.5.0",
|
||||
"express": "^4.18.2",
|
||||
"express-session": "^1.18.1",
|
||||
"googleapis": "^144.0.0",
|
||||
"helmet": "^8.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.0.1",
|
||||
"node-cron": "^4.1.0",
|
||||
"recharts": "^2.15.4",
|
||||
"sequelize": "^6.37.7",
|
||||
"sqlite3": "^5.1.7"
|
||||
"sqlite3": "^5.1.7",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
|
|
|
|||
183
backend/routes/calendar.js
Normal file
183
backend/routes/calendar.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { google } = require('googleapis');
|
||||
const { requireAuth } = require('../middleware/auth');
|
||||
|
||||
// Google Calendar configuration
|
||||
const SCOPES = ['https://www.googleapis.com/auth/calendar.readonly'];
|
||||
|
||||
// OAuth2 client setup
|
||||
const getOAuth2Client = () => {
|
||||
return new google.auth.OAuth2(
|
||||
process.env.GOOGLE_CLIENT_ID,
|
||||
process.env.GOOGLE_CLIENT_SECRET,
|
||||
process.env.GOOGLE_REDIRECT_URI || 'http://localhost:3002/api/calendar/oauth/callback'
|
||||
);
|
||||
};
|
||||
|
||||
// GET /api/calendar/auth - Start OAuth flow (Demo mode)
|
||||
router.get('/auth', requireAuth, (req, res) => {
|
||||
try {
|
||||
// Check if Google credentials are configured
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
||||
// Demo mode - simulate successful connection
|
||||
console.log('Demo mode: Simulating Google Calendar connection for user:', req.currentUser.id);
|
||||
|
||||
// Simulate the callback redirect with success
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:8080';
|
||||
return res.json({
|
||||
authUrl: `${frontendUrl}/calendar?demo=true&connected=true`,
|
||||
demo: true,
|
||||
message: 'Demo mode: Google Calendar integration simulated'
|
||||
});
|
||||
}
|
||||
|
||||
// Production mode with real Google OAuth
|
||||
const oauth2Client = getOAuth2Client();
|
||||
|
||||
const authUrl = oauth2Client.generateAuthUrl({
|
||||
access_type: 'offline',
|
||||
scope: SCOPES,
|
||||
state: JSON.stringify({ userId: req.currentUser.id })
|
||||
});
|
||||
|
||||
res.json({ authUrl });
|
||||
} catch (error) {
|
||||
console.error('Error generating auth URL:', error);
|
||||
res.status(500).json({ error: 'Failed to generate authorization URL' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/calendar/oauth/callback - Handle OAuth callback
|
||||
router.get('/oauth/callback', async (req, res) => {
|
||||
try {
|
||||
const { code, state } = req.query;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: 'Authorization code not provided' });
|
||||
}
|
||||
|
||||
const oauth2Client = getOAuth2Client();
|
||||
const { tokens } = await oauth2Client.getToken(code);
|
||||
|
||||
// Parse state to get user ID
|
||||
const { userId } = JSON.parse(state);
|
||||
|
||||
// Here you would typically save the tokens to the database
|
||||
// For now, we'll just return them (in production, store securely)
|
||||
console.log('Google Calendar tokens received for user:', userId);
|
||||
console.log('Tokens:', tokens);
|
||||
|
||||
// TODO: Save tokens to database associated with user
|
||||
// await saveGoogleTokensForUser(userId, tokens);
|
||||
|
||||
// Redirect to frontend with success
|
||||
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:8080'}/calendar?connected=true`);
|
||||
} catch (error) {
|
||||
console.error('Error handling OAuth callback:', error);
|
||||
res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:8080'}/calendar?error=auth_failed`);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/calendar/status - Check connection status
|
||||
router.get('/status', requireAuth, async (req, res) => {
|
||||
try {
|
||||
// Check if we're in demo mode or have real Google integration
|
||||
if (!process.env.GOOGLE_CLIENT_ID || !process.env.GOOGLE_CLIENT_SECRET) {
|
||||
// Demo mode - check if user has been "connected" in this session
|
||||
// For demo purposes, we'll simulate connection status
|
||||
res.json({
|
||||
connected: false, // Will be set to true after demo connection
|
||||
email: null,
|
||||
demo: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Check if user has valid Google Calendar tokens in database
|
||||
// const tokens = await getGoogleTokensForUser(req.currentUser.id);
|
||||
|
||||
res.json({
|
||||
connected: false, // Change to true when tokens exist and are valid
|
||||
email: null // Return connected Google account email when available
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking calendar status:', error);
|
||||
res.status(500).json({ error: 'Failed to check calendar status' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/calendar/events - Get events from Google Calendar
|
||||
router.get('/events', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { start, end } = req.query;
|
||||
|
||||
// TODO: Get tokens from database
|
||||
// const tokens = await getGoogleTokensForUser(req.currentUser.id);
|
||||
// if (!tokens) {
|
||||
// return res.status(401).json({ error: 'Google Calendar not connected' });
|
||||
// }
|
||||
|
||||
// For now, return sample data
|
||||
const sampleEvents = [
|
||||
{
|
||||
id: 'google-1',
|
||||
title: 'Google Calendar Event',
|
||||
start: new Date().toISOString(),
|
||||
end: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
|
||||
type: 'google',
|
||||
color: '#ea4335'
|
||||
}
|
||||
];
|
||||
|
||||
res.json({ events: sampleEvents });
|
||||
|
||||
// TODO: Implement actual Google Calendar API call
|
||||
/*
|
||||
const oauth2Client = getOAuth2Client();
|
||||
oauth2Client.setCredentials(tokens);
|
||||
|
||||
const calendar = google.calendar({ version: 'v3', auth: oauth2Client });
|
||||
|
||||
const response = await calendar.events.list({
|
||||
calendarId: 'primary',
|
||||
timeMin: start || new Date().toISOString(),
|
||||
timeMax: end || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
maxResults: 100,
|
||||
singleEvents: true,
|
||||
orderBy: 'startTime',
|
||||
});
|
||||
|
||||
const events = response.data.items.map(event => ({
|
||||
id: event.id,
|
||||
title: event.summary,
|
||||
start: event.start.dateTime || event.start.date,
|
||||
end: event.end.dateTime || event.end.date,
|
||||
type: 'google',
|
||||
color: '#ea4335',
|
||||
description: event.description,
|
||||
location: event.location
|
||||
}));
|
||||
|
||||
res.json({ events });
|
||||
*/
|
||||
} catch (error) {
|
||||
console.error('Error fetching calendar events:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch calendar events' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/calendar/disconnect - Disconnect Google Calendar
|
||||
router.post('/disconnect', requireAuth, async (req, res) => {
|
||||
try {
|
||||
// TODO: Remove tokens from database
|
||||
// await removeGoogleTokensForUser(req.currentUser.id);
|
||||
|
||||
res.json({ success: true, message: 'Google Calendar disconnected' });
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting calendar:', error);
|
||||
res.status(500).json({ error: 'Failed to disconnect calendar' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -182,7 +182,6 @@ router.get('/projects', async (req, res) => {
|
|||
|
||||
// If grouped=true, return grouped format
|
||||
if (grouped === 'true') {
|
||||
console.log('Returning grouped format');
|
||||
const groupedProjects = {};
|
||||
enhancedProjects.forEach(project => {
|
||||
const areaName = project.Area ? project.Area.name : 'No Area';
|
||||
|
|
@ -191,10 +190,8 @@ router.get('/projects', async (req, res) => {
|
|||
}
|
||||
groupedProjects[areaName].push(project);
|
||||
});
|
||||
console.log('Grouped projects structure:', Object.keys(groupedProjects).map(key => `${key}: ${groupedProjects[key].length} projects`));
|
||||
res.json(groupedProjects);
|
||||
} else {
|
||||
console.log('Returning flat array format');
|
||||
res.json({
|
||||
projects: enhancedProjects
|
||||
});
|
||||
|
|
@ -243,9 +240,6 @@ router.get('/project/:id', async (req, res) => {
|
|||
due_date_at: formatDate(project.due_date_at)
|
||||
};
|
||||
|
||||
console.log("Project API result:", JSON.stringify(result, null, 2));
|
||||
console.log("Tasks found:", result.Tasks ? result.Tasks.length : 'No Tasks property');
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Error fetching project:', error);
|
||||
|
|
|
|||
|
|
@ -1,16 +1,12 @@
|
|||
const express = require('express');
|
||||
const { Tag } = require('../models');
|
||||
const { Tag, Task, Note, Project, sequelize } = require('../models');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/tags
|
||||
router.get('/tags', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const tags = await Tag.findAll({
|
||||
where: { user_id: req.session.userId },
|
||||
where: { user_id: req.currentUser.id },
|
||||
attributes: ['id', 'name'],
|
||||
order: [['name', 'ASC']]
|
||||
});
|
||||
|
|
@ -25,12 +21,8 @@ router.get('/tags', async (req, res) => {
|
|||
// GET /api/tag/:id
|
||||
router.get('/tag/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const tag = await Tag.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId },
|
||||
where: { id: req.params.id, user_id: req.currentUser.id },
|
||||
attributes: ['id', 'name']
|
||||
});
|
||||
|
||||
|
|
@ -48,10 +40,6 @@ router.get('/tag/:id', async (req, res) => {
|
|||
// POST /api/tag
|
||||
router.post('/tag', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name || !name.trim()) {
|
||||
|
|
@ -60,7 +48,7 @@ router.post('/tag', async (req, res) => {
|
|||
|
||||
const tag = await Tag.create({
|
||||
name: name.trim(),
|
||||
user_id: req.session.userId
|
||||
user_id: req.currentUser.id
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
|
|
@ -76,12 +64,8 @@ router.post('/tag', async (req, res) => {
|
|||
// PATCH /api/tag/:id
|
||||
router.patch('/tag/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const tag = await Tag.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
where: { id: req.params.id, user_id: req.currentUser.id }
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
|
|
@ -108,22 +92,61 @@ router.patch('/tag/:id', async (req, res) => {
|
|||
|
||||
// DELETE /api/tag/:id
|
||||
router.delete('/tag/:id', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
const transaction = await sequelize.transaction();
|
||||
|
||||
try {
|
||||
const tag = await Tag.findOne({
|
||||
where: { id: req.params.id, user_id: req.session.userId }
|
||||
where: { id: req.params.id, user_id: req.currentUser.id }
|
||||
});
|
||||
|
||||
if (!tag) {
|
||||
await transaction.rollback();
|
||||
return res.status(404).json({ error: 'Tag not found' });
|
||||
}
|
||||
|
||||
await tag.destroy();
|
||||
// Use transaction to ensure all deletions happen atomically
|
||||
// Remove all associations before deleting the tag by manually deleting from junction tables
|
||||
// Only delete from tables that exist
|
||||
try {
|
||||
await sequelize.query('DELETE FROM tasks_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('tasks_tags table not found, skipping');
|
||||
}
|
||||
|
||||
try {
|
||||
await sequelize.query('DELETE FROM notes_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('notes_tags table not found, skipping');
|
||||
}
|
||||
|
||||
try {
|
||||
await sequelize.query('DELETE FROM projects_tags WHERE tag_id = ?', {
|
||||
replacements: [tag.id],
|
||||
type: sequelize.QueryTypes.DELETE,
|
||||
transaction
|
||||
});
|
||||
} catch (error) {
|
||||
// Ignore if table doesn't exist
|
||||
console.log('projects_tags table not found, skipping');
|
||||
}
|
||||
|
||||
// Now safely delete the tag
|
||||
await tag.destroy({ transaction });
|
||||
|
||||
await transaction.commit();
|
||||
res.json({ message: 'Tag successfully deleted' });
|
||||
} catch (error) {
|
||||
await transaction.rollback();
|
||||
console.error('Error deleting tag:', error);
|
||||
res.status(400).json({ error: 'There was a problem deleting the tag.' });
|
||||
}
|
||||
|
|
|
|||
153
backend/routes/task-events.js
Normal file
153
backend/routes/task-events.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
const express = require('express');
|
||||
const { TaskEvent } = require('../models');
|
||||
const TaskEventService = require('../services/taskEventService');
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/task/:id/timeline - Get task event timeline
|
||||
router.get('/task/:id/timeline', async (req, res) => {
|
||||
try {
|
||||
const timeline = await TaskEventService.getTaskTimeline(req.params.id);
|
||||
|
||||
// Filter to only show events for tasks owned by the current user
|
||||
const userTimeline = timeline.filter(event => event.user_id === req.currentUser.id);
|
||||
|
||||
res.json(userTimeline);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task timeline:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch task timeline' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/task/:id/completion-time - Get task completion analytics
|
||||
router.get('/task/:id/completion-time', async (req, res) => {
|
||||
try {
|
||||
const completionTime = await TaskEventService.getTaskCompletionTime(req.params.id);
|
||||
|
||||
if (!completionTime) {
|
||||
return res.status(404).json({ error: 'Task completion data not found' });
|
||||
}
|
||||
|
||||
res.json(completionTime);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task completion time:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch task completion time' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/user/productivity-metrics - Get user productivity metrics
|
||||
router.get('/user/productivity-metrics', async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
const metrics = await TaskEventService.getUserProductivityMetrics(
|
||||
req.currentUser.id,
|
||||
startDate ? new Date(startDate) : null,
|
||||
endDate ? new Date(endDate) : null
|
||||
);
|
||||
|
||||
res.json(metrics);
|
||||
} catch (error) {
|
||||
console.error('Error fetching productivity metrics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch productivity metrics' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/user/activity-summary - Get task activity summary
|
||||
router.get('/user/activity-summary', async (req, res) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return res.status(400).json({ error: 'startDate and endDate are required' });
|
||||
}
|
||||
|
||||
const activitySummary = await TaskEventService.getTaskActivitySummary(
|
||||
req.currentUser.id,
|
||||
new Date(startDate),
|
||||
new Date(endDate)
|
||||
);
|
||||
|
||||
res.json(activitySummary);
|
||||
} catch (error) {
|
||||
console.error('Error fetching activity summary:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch activity summary' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks/completion-analytics - Get completion time analytics for multiple tasks
|
||||
router.get('/tasks/completion-analytics', async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, offset = 0, projectId } = req.query;
|
||||
|
||||
// Get completed tasks for the user
|
||||
const { Task, Project } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
const whereClause = {
|
||||
user_id: req.currentUser.id,
|
||||
status: 2 // completed
|
||||
};
|
||||
|
||||
if (projectId) {
|
||||
whereClause.project_id = projectId;
|
||||
}
|
||||
|
||||
const completedTasks = await Task.findAll({
|
||||
where: whereClause,
|
||||
include: [
|
||||
{ model: Project, attributes: ['name'], required: false }
|
||||
],
|
||||
order: [['completed_at', 'DESC']],
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset)
|
||||
});
|
||||
|
||||
// Get completion time analytics for each task
|
||||
const analytics = [];
|
||||
for (const task of completedTasks) {
|
||||
const completionTime = await TaskEventService.getTaskCompletionTime(task.id);
|
||||
if (completionTime) {
|
||||
analytics.push({
|
||||
task_id: task.id,
|
||||
task_name: task.name,
|
||||
project_name: task.Project?.name || null,
|
||||
...completionTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate summary statistics
|
||||
const summary = {
|
||||
total_tasks: analytics.length,
|
||||
average_completion_hours: analytics.length > 0
|
||||
? analytics.reduce((sum, a) => sum + a.duration_hours, 0) / analytics.length
|
||||
: 0,
|
||||
median_completion_hours: 0,
|
||||
fastest_completion: analytics.length > 0
|
||||
? Math.min(...analytics.map(a => a.duration_hours))
|
||||
: 0,
|
||||
slowest_completion: analytics.length > 0
|
||||
? Math.max(...analytics.map(a => a.duration_hours))
|
||||
: 0
|
||||
};
|
||||
|
||||
// Calculate median
|
||||
if (analytics.length > 0) {
|
||||
const sorted = analytics.map(a => a.duration_hours).sort((a, b) => a - b);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
summary.median_completion_hours = sorted.length % 2 === 0
|
||||
? (sorted[middle - 1] + sorted[middle]) / 2
|
||||
: sorted[middle];
|
||||
}
|
||||
|
||||
res.json({
|
||||
tasks: analytics,
|
||||
summary
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching completion analytics:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch completion analytics' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,9 +1,24 @@
|
|||
const express = require('express');
|
||||
const { Task, Tag, Project, sequelize } = require('../models');
|
||||
const { Task, Tag, Project, TaskEvent, sequelize } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const RecurringTaskService = require('../services/recurringTaskService');
|
||||
const TaskEventService = require('../services/taskEventService');
|
||||
const moment = require('moment-timezone');
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to serialize task with today move count
|
||||
async function serializeTask(task) {
|
||||
const taskJson = task.toJSON();
|
||||
const todayMoveCount = await TaskEventService.getTaskTodayMoveCount(task.id);
|
||||
|
||||
return {
|
||||
...taskJson,
|
||||
tags: taskJson.Tags || [],
|
||||
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null,
|
||||
today_move_count: todayMoveCount
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to update task tags
|
||||
async function updateTaskTags(task, tagsData, userId) {
|
||||
if (!tagsData) return;
|
||||
|
|
@ -118,7 +133,8 @@ async function filterTasksByParams(params, userId) {
|
|||
}
|
||||
|
||||
// Compute task metrics
|
||||
async function computeTaskMetrics(userId) {
|
||||
async function computeTaskMetrics(userId, userTimezone = 'UTC') {
|
||||
console.log('Computing metrics for user', userId, 'with timezone:', userTimezone);
|
||||
const totalOpenTasks = await Task.count({
|
||||
where: { user_id: userId, status: { [Op.ne]: Task.STATUS.DONE } }
|
||||
});
|
||||
|
|
@ -135,7 +151,7 @@ async function computeTaskMetrics(userId) {
|
|||
const tasksInProgress = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: Task.STATUS.IN_PROGRESS
|
||||
status: { [Op.in]: [Task.STATUS.IN_PROGRESS, 'in_progress'] }
|
||||
},
|
||||
include: [
|
||||
{
|
||||
|
|
@ -153,6 +169,29 @@ async function computeTaskMetrics(userId) {
|
|||
order: [['priority', 'DESC']]
|
||||
});
|
||||
|
||||
// Get tasks in today plan
|
||||
const todayPlanTasks = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
today: true,
|
||||
status: { [Op.notIn]: [Task.STATUS.DONE, Task.STATUS.ARCHIVED, 'done', 'archived'] }
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active'],
|
||||
required: false
|
||||
}
|
||||
],
|
||||
order: [['priority', 'DESC'], ['created_at', 'ASC']]
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
|
||||
|
|
@ -196,11 +235,27 @@ async function computeTaskMetrics(userId) {
|
|||
...tasksDueToday.map(t => t.id)
|
||||
];
|
||||
|
||||
suggestedTasks = await Task.findAll({
|
||||
// Get task IDs that have "someday" tag
|
||||
const somedayTaskIds = await sequelize.query(
|
||||
`SELECT DISTINCT task_id FROM tasks_tags
|
||||
JOIN tags ON tasks_tags.tag_id = tags.id
|
||||
WHERE tags.name = 'someday' AND tags.user_id = ?`,
|
||||
{
|
||||
replacements: [userId],
|
||||
type: sequelize.QueryTypes.SELECT
|
||||
}
|
||||
).then(results => results.map(r => r.task_id));
|
||||
|
||||
// Get tasks without projects (excluding someday tagged tasks)
|
||||
const nonProjectTasks = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: Task.STATUS.NOT_STARTED,
|
||||
id: { [Op.notIn]: excludedTaskIds }
|
||||
status: { [Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING] },
|
||||
id: { [Op.notIn]: [...excludedTaskIds, ...somedayTaskIds] },
|
||||
[Op.or]: [
|
||||
{ project_id: null },
|
||||
{ project_id: '' }
|
||||
]
|
||||
},
|
||||
include: [
|
||||
{
|
||||
|
|
@ -215,9 +270,150 @@ async function computeTaskMetrics(userId) {
|
|||
required: false
|
||||
}
|
||||
],
|
||||
order: [['priority', 'DESC']],
|
||||
limit: 10
|
||||
order: [['priority', 'DESC'], ['created_at', 'ASC']],
|
||||
limit: 6
|
||||
});
|
||||
|
||||
// Get tasks with projects (excluding someday tagged tasks)
|
||||
const projectTasks = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: { [Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING] },
|
||||
id: { [Op.notIn]: [...excludedTaskIds, ...somedayTaskIds] },
|
||||
project_id: { [Op.not]: null, [Op.ne]: '' }
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active'],
|
||||
required: false
|
||||
}
|
||||
],
|
||||
order: [['priority', 'DESC'], ['created_at', 'ASC']],
|
||||
limit: 6
|
||||
});
|
||||
|
||||
// Check if we have enough suggestions (at least 6 total)
|
||||
let combinedTasks = [...nonProjectTasks, ...projectTasks];
|
||||
|
||||
// If we don't have enough suggestions, include someday tasks as fallback
|
||||
if (combinedTasks.length < 6) {
|
||||
const usedTaskIds = [...excludedTaskIds, ...combinedTasks.map(t => t.id)];
|
||||
|
||||
const somedayFallbackTasks = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: { [Op.in]: [Task.STATUS.NOT_STARTED, Task.STATUS.WAITING] },
|
||||
id: {
|
||||
[Op.notIn]: usedTaskIds,
|
||||
[Op.in]: somedayTaskIds
|
||||
}
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active'],
|
||||
required: false
|
||||
}
|
||||
],
|
||||
order: [['priority', 'DESC'], ['created_at', 'ASC']],
|
||||
limit: 12 - combinedTasks.length
|
||||
});
|
||||
|
||||
combinedTasks = [...combinedTasks, ...somedayFallbackTasks];
|
||||
}
|
||||
|
||||
suggestedTasks = combinedTasks;
|
||||
|
||||
}
|
||||
|
||||
// Get tasks completed today - use user's timezone
|
||||
const todayInUserTz = moment.tz(userTimezone);
|
||||
const todayStart = todayInUserTz.clone().startOf('day').utc().toDate();
|
||||
const todayEnd = todayInUserTz.clone().endOf('day').utc().toDate();
|
||||
|
||||
const tasksCompletedToday = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: Task.STATUS.DONE,
|
||||
completed_at: {
|
||||
[Op.between]: [todayStart, todayEnd]
|
||||
}
|
||||
},
|
||||
include: [
|
||||
{
|
||||
model: Tag,
|
||||
attributes: ['id', 'name'],
|
||||
through: { attributes: [] },
|
||||
required: false
|
||||
},
|
||||
{
|
||||
model: Project,
|
||||
attributes: ['id', 'name', 'active'],
|
||||
required: false
|
||||
}
|
||||
],
|
||||
order: [['completed_at', 'DESC']]
|
||||
});
|
||||
|
||||
// Get weekly completion data (last 7 days) - use user's timezone
|
||||
const weekStartInUserTz = moment.tz(userTimezone).subtract(6, 'days');
|
||||
const weekStart = weekStartInUserTz.clone().startOf('day').utc().toDate();
|
||||
const weekEnd = todayInUserTz.clone().endOf('day').utc().toDate();
|
||||
|
||||
// For SQLite, we'll fetch the raw data and process it in JavaScript
|
||||
const weeklyCompletionsRaw = await Task.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
status: Task.STATUS.DONE,
|
||||
completed_at: {
|
||||
[Op.between]: [weekStart, weekEnd]
|
||||
}
|
||||
},
|
||||
attributes: ['completed_at'],
|
||||
raw: true
|
||||
});
|
||||
|
||||
// Process the data in JavaScript to group by date in user's timezone
|
||||
const dateCountMap = {};
|
||||
weeklyCompletionsRaw.forEach(task => {
|
||||
// Parse the completed_at field more reliably - convert to Date first, then to moment
|
||||
const completedDate = new Date(task.completed_at);
|
||||
const dateInUserTz = moment(completedDate).tz(userTimezone).format('YYYY-MM-DD');
|
||||
dateCountMap[dateInUserTz] = (dateCountMap[dateInUserTz] || 0) + 1;
|
||||
});
|
||||
|
||||
// Convert to the format expected by the rest of the code
|
||||
const weeklyCompletions = Object.entries(dateCountMap).map(([date, count]) => ({
|
||||
date,
|
||||
count: count.toString()
|
||||
}));
|
||||
|
||||
// Process weekly completion data to ensure all 7 days are represented
|
||||
const weeklyData = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const dateInUserTz = moment.tz(userTimezone).subtract(i, 'days');
|
||||
const dateString = dateInUserTz.format('YYYY-MM-DD');
|
||||
|
||||
const found = weeklyCompletions.find(item => item.date === dateString);
|
||||
const dayData = {
|
||||
date: dateString,
|
||||
count: found ? parseInt(found.count) : 0,
|
||||
dayName: dateInUserTz.format('ddd') // Short day name
|
||||
};
|
||||
weeklyData.push(dayData);
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -226,7 +422,10 @@ async function computeTaskMetrics(userId) {
|
|||
tasks_in_progress_count: tasksInProgress.length,
|
||||
tasks_in_progress: tasksInProgress,
|
||||
tasks_due_today: tasksDueToday,
|
||||
suggested_tasks: suggestedTasks
|
||||
today_plan_tasks: todayPlanTasks,
|
||||
suggested_tasks: suggestedTasks,
|
||||
tasks_completed_today: tasksCompletedToday,
|
||||
weekly_completions: weeklyData
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -234,45 +433,26 @@ async function computeTaskMetrics(userId) {
|
|||
router.get('/tasks', async (req, res) => {
|
||||
try {
|
||||
const tasks = await filterTasksByParams(req.query, req.currentUser.id);
|
||||
const metrics = await computeTaskMetrics(req.currentUser.id);
|
||||
const metrics = await computeTaskMetrics(req.currentUser.id, req.currentUser.timezone);
|
||||
|
||||
res.json({
|
||||
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
|
||||
};
|
||||
}),
|
||||
tasks: await Promise.all(tasks.map(task => serializeTask(task))),
|
||||
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 => {
|
||||
const taskJson = task.toJSON();
|
||||
tasks_in_progress: await Promise.all(metrics.tasks_in_progress.map(task => serializeTask(task))),
|
||||
tasks_due_today: await Promise.all(metrics.tasks_due_today.map(task => serializeTask(task))),
|
||||
today_plan_tasks: await Promise.all(metrics.today_plan_tasks.map(task => serializeTask(task))),
|
||||
suggested_tasks: await Promise.all(metrics.suggested_tasks.map(task => serializeTask(task))),
|
||||
tasks_completed_today: await Promise.all(metrics.tasks_completed_today.map(async task => {
|
||||
const serialized = await serializeTask(task);
|
||||
return {
|
||||
...taskJson,
|
||||
tags: taskJson.Tags || [],
|
||||
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
|
||||
...serialized,
|
||||
completed_at: task.completed_at ? task.completed_at.toISOString() : 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
|
||||
};
|
||||
})
|
||||
})),
|
||||
weekly_completions: metrics.weekly_completions
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -284,6 +464,30 @@ router.get('/tasks', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// GET /api/task/uuid/:uuid
|
||||
router.get('/task/uuid/:uuid', async (req, res) => {
|
||||
try {
|
||||
const task = await Task.findOne({
|
||||
where: { uuid: req.params.uuid, user_id: req.currentUser.id },
|
||||
include: [
|
||||
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } },
|
||||
{ model: Project, attributes: ['name'], required: false }
|
||||
]
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found.' });
|
||||
}
|
||||
|
||||
const serializedTask = await serializeTask(task);
|
||||
|
||||
res.json(serializedTask);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task by UUID:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/task/:id
|
||||
router.get('/task/:id', async (req, res) => {
|
||||
try {
|
||||
|
|
@ -299,13 +503,9 @@ router.get('/task/:id', async (req, res) => {
|
|||
return res.status(404).json({ error: 'Task not found.' });
|
||||
}
|
||||
|
||||
const taskJson = task.toJSON();
|
||||
const serializedTask = await serializeTask(task);
|
||||
|
||||
res.json({
|
||||
...taskJson,
|
||||
tags: taskJson.Tags || [],
|
||||
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
|
||||
});
|
||||
res.json(serializedTask);
|
||||
} catch (error) {
|
||||
console.error('Error fetching task:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
|
@ -324,6 +524,7 @@ router.post('/task', async (req, res) => {
|
|||
project_id,
|
||||
tags,
|
||||
Tags,
|
||||
today,
|
||||
recurrence_type,
|
||||
recurrence_interval,
|
||||
recurrence_end_date,
|
||||
|
|
@ -343,10 +544,11 @@ router.post('/task', async (req, res) => {
|
|||
|
||||
const taskAttributes = {
|
||||
name: name.trim(),
|
||||
priority: priority || Task.PRIORITY.LOW,
|
||||
priority: priority !== undefined ? (typeof priority === 'string' ? Task.getPriorityValue(priority) : priority) : Task.PRIORITY.LOW,
|
||||
due_date: due_date || null,
|
||||
status: status || Task.STATUS.NOT_STARTED,
|
||||
status: status !== undefined ? (typeof status === 'string' ? Task.getStatusValue(status) : status) : Task.STATUS.NOT_STARTED,
|
||||
note,
|
||||
today: today !== undefined ? today : false,
|
||||
user_id: req.currentUser.id,
|
||||
recurrence_type: recurrence_type || 'none',
|
||||
recurrence_interval: recurrence_interval || null,
|
||||
|
|
@ -371,6 +573,20 @@ router.post('/task', async (req, res) => {
|
|||
const task = await Task.create(taskAttributes);
|
||||
await updateTaskTags(task, tagsData, req.currentUser.id);
|
||||
|
||||
// Log task creation event
|
||||
try {
|
||||
await TaskEventService.logTaskCreated(task.id, req.currentUser.id, {
|
||||
name: task.name,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
due_date: task.due_date,
|
||||
project_id: task.project_id
|
||||
}, { source: 'web' });
|
||||
} catch (eventError) {
|
||||
console.error('Error logging task creation event:', eventError);
|
||||
// Don't fail the request if event logging fails
|
||||
}
|
||||
|
||||
// Reload task with associations
|
||||
const taskWithAssociations = await Task.findByPk(task.id, {
|
||||
include: [
|
||||
|
|
@ -407,6 +623,7 @@ router.patch('/task/:id', async (req, res) => {
|
|||
project_id,
|
||||
tags,
|
||||
Tags,
|
||||
today,
|
||||
recurrence_type,
|
||||
recurrence_interval,
|
||||
recurrence_end_date,
|
||||
|
|
@ -421,13 +638,34 @@ router.patch('/task/:id', async (req, res) => {
|
|||
const tagsData = tags || Tags;
|
||||
|
||||
const task = await Task.findOne({
|
||||
where: { id: req.params.id, user_id: req.currentUser.id }
|
||||
where: { id: req.params.id, user_id: req.currentUser.id },
|
||||
include: [
|
||||
{ model: Tag, attributes: ['id', 'name'], through: { attributes: [] } }
|
||||
]
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found.' });
|
||||
}
|
||||
|
||||
// Capture old values for event logging
|
||||
const oldValues = {
|
||||
name: task.name,
|
||||
status: task.status,
|
||||
priority: task.priority,
|
||||
due_date: task.due_date,
|
||||
project_id: task.project_id,
|
||||
note: task.note,
|
||||
recurrence_type: task.recurrence_type,
|
||||
recurrence_interval: task.recurrence_interval,
|
||||
recurrence_end_date: task.recurrence_end_date,
|
||||
recurrence_weekday: task.recurrence_weekday,
|
||||
recurrence_month_day: task.recurrence_month_day,
|
||||
recurrence_week_of_month: task.recurrence_week_of_month,
|
||||
completion_based: task.completion_based,
|
||||
tags: task.Tags ? task.Tags.map(tag => ({ id: tag.id, name: tag.name })) : []
|
||||
};
|
||||
|
||||
// Handle updating parent recurrence settings if this is a child task
|
||||
if (update_parent_recurrence && task.recurring_parent_id) {
|
||||
const parentTask = await Task.findOne({
|
||||
|
|
@ -450,10 +688,11 @@ router.patch('/task/:id', async (req, res) => {
|
|||
|
||||
const taskAttributes = {
|
||||
name,
|
||||
priority,
|
||||
status: status || Task.STATUS.NOT_STARTED,
|
||||
priority: priority !== undefined ? (typeof priority === 'string' ? Task.getPriorityValue(priority) : priority) : undefined,
|
||||
status: status !== undefined ? (typeof status === 'string' ? Task.getStatusValue(status) : status) : Task.STATUS.NOT_STARTED,
|
||||
note,
|
||||
due_date: due_date || null,
|
||||
today: today !== undefined ? today : task.today,
|
||||
recurrence_type: recurrence_type !== undefined ? recurrence_type : task.recurrence_type,
|
||||
recurrence_interval: recurrence_interval !== undefined ? recurrence_interval : task.recurrence_interval,
|
||||
recurrence_end_date: recurrence_end_date !== undefined ? recurrence_end_date : task.recurrence_end_date,
|
||||
|
|
@ -463,6 +702,20 @@ router.patch('/task/:id', async (req, res) => {
|
|||
completion_based: completion_based !== undefined ? completion_based : task.completion_based
|
||||
};
|
||||
|
||||
// Set completed_at when task is marked as done
|
||||
if (status !== undefined) {
|
||||
const newStatus = typeof status === 'string' ? Task.getStatusValue(status) : status;
|
||||
const oldStatus = typeof task.status === 'string' ? Task.getStatusValue(task.status) : task.status;
|
||||
|
||||
if (newStatus === Task.STATUS.DONE && oldStatus !== Task.STATUS.DONE) {
|
||||
// Task is being completed
|
||||
taskAttributes.completed_at = new Date();
|
||||
} else if (newStatus !== Task.STATUS.DONE && oldStatus === Task.STATUS.DONE) {
|
||||
// Task is being uncompleted
|
||||
taskAttributes.completed_at = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle project assignment
|
||||
if (project_id && project_id.toString().trim()) {
|
||||
const project = await Project.findOne({
|
||||
|
|
@ -479,6 +732,87 @@ router.patch('/task/:id', async (req, res) => {
|
|||
await task.update(taskAttributes);
|
||||
await updateTaskTags(task, tagsData, req.currentUser.id);
|
||||
|
||||
// Log task update events
|
||||
try {
|
||||
const changes = {};
|
||||
|
||||
// Check for changes in each field
|
||||
if (name !== undefined && name !== oldValues.name) {
|
||||
changes.name = { oldValue: oldValues.name, newValue: name };
|
||||
}
|
||||
if (status !== undefined && status !== oldValues.status) {
|
||||
changes.status = { oldValue: oldValues.status, newValue: status };
|
||||
}
|
||||
if (priority !== undefined && priority !== oldValues.priority) {
|
||||
changes.priority = { oldValue: oldValues.priority, newValue: priority };
|
||||
}
|
||||
if (due_date !== undefined) {
|
||||
// Normalize dates for comparison (convert to YYYY-MM-DD format)
|
||||
const oldDateStr = oldValues.due_date ? oldValues.due_date.toISOString().split('T')[0] : null;
|
||||
const newDateStr = due_date || null;
|
||||
|
||||
if (oldDateStr !== newDateStr) {
|
||||
changes.due_date = { oldValue: oldValues.due_date, newValue: due_date };
|
||||
}
|
||||
}
|
||||
if (project_id !== undefined && project_id !== oldValues.project_id) {
|
||||
changes.project_id = { oldValue: oldValues.project_id, newValue: project_id };
|
||||
}
|
||||
if (note !== undefined && note !== oldValues.note) {
|
||||
changes.note = { oldValue: oldValues.note, newValue: note };
|
||||
}
|
||||
|
||||
// Check recurrence field changes
|
||||
if (recurrence_type !== undefined && recurrence_type !== oldValues.recurrence_type) {
|
||||
changes.recurrence_type = { oldValue: oldValues.recurrence_type, newValue: recurrence_type };
|
||||
}
|
||||
if (recurrence_interval !== undefined && recurrence_interval !== oldValues.recurrence_interval) {
|
||||
changes.recurrence_interval = { oldValue: oldValues.recurrence_interval, newValue: recurrence_interval };
|
||||
}
|
||||
if (recurrence_end_date !== undefined && recurrence_end_date !== oldValues.recurrence_end_date) {
|
||||
changes.recurrence_end_date = { oldValue: oldValues.recurrence_end_date, newValue: recurrence_end_date };
|
||||
}
|
||||
if (recurrence_weekday !== undefined && recurrence_weekday !== oldValues.recurrence_weekday) {
|
||||
changes.recurrence_weekday = { oldValue: oldValues.recurrence_weekday, newValue: recurrence_weekday };
|
||||
}
|
||||
if (recurrence_month_day !== undefined && recurrence_month_day !== oldValues.recurrence_month_day) {
|
||||
changes.recurrence_month_day = { oldValue: oldValues.recurrence_month_day, newValue: recurrence_month_day };
|
||||
}
|
||||
if (recurrence_week_of_month !== undefined && recurrence_week_of_month !== oldValues.recurrence_week_of_month) {
|
||||
changes.recurrence_week_of_month = { oldValue: oldValues.recurrence_week_of_month, newValue: recurrence_week_of_month };
|
||||
}
|
||||
if (completion_based !== undefined && completion_based !== oldValues.completion_based) {
|
||||
changes.completion_based = { oldValue: oldValues.completion_based, newValue: completion_based };
|
||||
}
|
||||
|
||||
// Log all changes
|
||||
if (Object.keys(changes).length > 0) {
|
||||
await TaskEventService.logTaskUpdate(task.id, req.currentUser.id, changes, { source: 'web' });
|
||||
}
|
||||
|
||||
// Check for tag changes (this is more complex due to the array comparison)
|
||||
if (tagsData) {
|
||||
const newTags = tagsData.map(tag => ({ id: tag.id, name: tag.name }));
|
||||
const oldTagNames = oldValues.tags.map(tag => tag.name).sort();
|
||||
const newTagNames = newTags.map(tag => tag.name).sort();
|
||||
|
||||
if (JSON.stringify(oldTagNames) !== JSON.stringify(newTagNames)) {
|
||||
await TaskEventService.logEvent({
|
||||
taskId: task.id,
|
||||
userId: req.currentUser.id,
|
||||
eventType: 'tags_changed',
|
||||
fieldName: 'tags',
|
||||
oldValue: oldValues.tags,
|
||||
newValue: newTags,
|
||||
metadata: { source: 'web', action: 'tags_update' }
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (eventError) {
|
||||
console.error('Error logging task update events:', eventError);
|
||||
// Don't fail the request if event logging fails
|
||||
}
|
||||
|
||||
// Reload task with associations
|
||||
const taskWithAssociations = await Task.findByPk(task.id, {
|
||||
include: [
|
||||
|
|
@ -528,7 +862,15 @@ router.patch('/task/:id/toggle_completion', async (req, res) => {
|
|||
|
||||
console.log('📝 Status changing from', task.status, 'to', newStatus);
|
||||
|
||||
await task.update({ status: newStatus });
|
||||
// Set completed_at when task is completed/uncompleted
|
||||
const updateData = { status: newStatus };
|
||||
if (newStatus === Task.STATUS.DONE) {
|
||||
updateData.completed_at = new Date();
|
||||
} else if (task.status === Task.STATUS.DONE || task.status === 'done') {
|
||||
updateData.completed_at = null;
|
||||
}
|
||||
|
||||
await task.update(updateData);
|
||||
|
||||
// Handle recurring task completion
|
||||
let nextTask = null;
|
||||
|
|
@ -569,7 +911,98 @@ router.delete('/task/:id', async (req, res) => {
|
|||
return res.status(404).json({ error: 'Task not found.' });
|
||||
}
|
||||
|
||||
await task.destroy();
|
||||
console.log(`Attempting to delete task ${req.params.id}`);
|
||||
|
||||
// Check for child tasks - prevent deletion of parent tasks with children
|
||||
const childTasks = await Task.findAll({
|
||||
where: { recurring_parent_id: req.params.id }
|
||||
});
|
||||
console.log(`Found ${childTasks.length} child tasks`);
|
||||
|
||||
// If this is a recurring parent task with children, prevent deletion
|
||||
if (childTasks.length > 0) {
|
||||
console.log(`Cannot delete task ${req.params.id} - has ${childTasks.length} child tasks`);
|
||||
return res.status(400).json({ error: 'There was a problem deleting the task.' });
|
||||
}
|
||||
|
||||
const taskEvents = await TaskEvent.findAll({
|
||||
where: { task_id: req.params.id }
|
||||
});
|
||||
console.log(`Found ${taskEvents.length} task events`);
|
||||
|
||||
const tagAssociations = await sequelize.query(
|
||||
'SELECT COUNT(*) as count FROM tasks_tags WHERE task_id = ?',
|
||||
{ replacements: [req.params.id], type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
console.log(`Found ${tagAssociations[0].count} tag associations`);
|
||||
|
||||
// Check SQLite foreign key list for tasks table
|
||||
const foreignKeys = await sequelize.query(
|
||||
'PRAGMA foreign_key_list(tasks)',
|
||||
{ type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
console.log('Foreign keys in tasks table:', foreignKeys);
|
||||
|
||||
// Find all tables that reference tasks
|
||||
const allTables = await sequelize.query(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'",
|
||||
{ type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
|
||||
for (const table of allTables) {
|
||||
if (table.name !== 'tasks') {
|
||||
try {
|
||||
const fks = await sequelize.query(
|
||||
`PRAGMA foreign_key_list(${table.name})`,
|
||||
{ type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
const taskRefs = fks.filter(fk => fk.table === 'tasks');
|
||||
if (taskRefs.length > 0) {
|
||||
console.log(`Table ${table.name} references tasks:`, taskRefs);
|
||||
// Check if this table has any records referencing our task
|
||||
for (const fk of taskRefs) {
|
||||
const count = await sequelize.query(
|
||||
`SELECT COUNT(*) as count FROM ${table.name} WHERE ${fk.from} = ?`,
|
||||
{ replacements: [req.params.id], type: sequelize.QueryTypes.SELECT }
|
||||
);
|
||||
console.log(` ${table.name}.${fk.from} -> tasks.${fk.to}: ${count[0].count} references`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Skip tables that might not exist or have issues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Temporarily disable foreign key constraints for this operation
|
||||
await sequelize.query('PRAGMA foreign_keys = OFF');
|
||||
|
||||
try {
|
||||
// Use force delete to bypass foreign key constraints
|
||||
await TaskEvent.destroy({
|
||||
where: { task_id: req.params.id },
|
||||
force: true
|
||||
});
|
||||
|
||||
await sequelize.query(
|
||||
'DELETE FROM tasks_tags WHERE task_id = ?',
|
||||
{ replacements: [req.params.id] }
|
||||
);
|
||||
|
||||
await Task.update(
|
||||
{ recurring_parent_id: null },
|
||||
{ where: { recurring_parent_id: req.params.id } }
|
||||
);
|
||||
|
||||
// Delete the task itself
|
||||
await task.destroy({ force: true });
|
||||
|
||||
} finally {
|
||||
// Re-enable foreign key constraints
|
||||
await sequelize.query('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
|
||||
console.log(`Successfully deleted task ${req.params.id}`);
|
||||
res.json({ message: 'Task successfully deleted' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
|
|
@ -595,4 +1028,45 @@ router.post('/tasks/generate-recurring', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// PATCH /api/task/:id/toggle-today
|
||||
router.patch('/task/:id/toggle-today', async (req, res) => {
|
||||
try {
|
||||
const task = await Task.findOne({
|
||||
where: { id: req.params.id, user_id: req.currentUser.id }
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Task not found.' });
|
||||
}
|
||||
|
||||
// Toggle the today flag
|
||||
const newTodayValue = !task.today;
|
||||
await task.update({ today: newTodayValue });
|
||||
|
||||
// Log the change
|
||||
try {
|
||||
await TaskEventService.logEvent({
|
||||
taskId: task.id,
|
||||
userId: req.currentUser.id,
|
||||
eventType: 'today_changed',
|
||||
fieldName: 'today',
|
||||
oldValue: !newTodayValue,
|
||||
newValue: newTodayValue,
|
||||
metadata: { source: 'web', action: 'toggle_today' }
|
||||
});
|
||||
} catch (eventError) {
|
||||
console.error('Error logging today toggle event:', eventError);
|
||||
// Don't fail the request if event logging fails
|
||||
}
|
||||
|
||||
res.json({
|
||||
...task.toJSON(),
|
||||
due_date: task.due_date ? task.due_date.toISOString().split('T')[0] : null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error toggling task today flag:', error);
|
||||
res.status(500).json({ error: 'Failed to update task today flag' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -4,20 +4,88 @@ const http = require('http');
|
|||
const { URL } = require('url');
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to extract title from HTML
|
||||
function extractTitleFromHtml(html) {
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (titleMatch && titleMatch[1]) {
|
||||
// Decode HTML entities and clean up
|
||||
return titleMatch[1]
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.trim();
|
||||
// Fast regex-based metadata extraction (much faster than cheerio for head content)
|
||||
function extractMetadataFromHtml(html) {
|
||||
try {
|
||||
// Extract title with priority: og:title > twitter:title > title tag
|
||||
let title = null;
|
||||
|
||||
// Try og:title first
|
||||
const ogTitleMatch = html.match(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (ogTitleMatch) {
|
||||
title = ogTitleMatch[1];
|
||||
} else {
|
||||
// Try twitter:title
|
||||
const twitterTitleMatch = html.match(/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (twitterTitleMatch) {
|
||||
title = twitterTitleMatch[1];
|
||||
} else {
|
||||
// Fallback to title tag
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
title = titleMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up title
|
||||
if (title) {
|
||||
title = title.trim();
|
||||
// Decode common HTML entities
|
||||
title = title
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'");
|
||||
|
||||
if (title.length > 100) {
|
||||
title = title.substring(0, 100) + '...';
|
||||
}
|
||||
}
|
||||
|
||||
// Extract image with priority: og:image > twitter:image
|
||||
let image = null;
|
||||
const ogImageMatch = html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (ogImageMatch) {
|
||||
image = ogImageMatch[1];
|
||||
} else {
|
||||
const twitterImageMatch = html.match(/<meta[^>]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (twitterImageMatch) {
|
||||
image = twitterImageMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Extract description
|
||||
let description = null;
|
||||
const ogDescMatch = html.match(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (ogDescMatch) {
|
||||
description = ogDescMatch[1];
|
||||
} else {
|
||||
const twitterDescMatch = html.match(/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (twitterDescMatch) {
|
||||
description = twitterDescMatch[1];
|
||||
} else {
|
||||
const metaDescMatch = html.match(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
|
||||
if (metaDescMatch) {
|
||||
description = metaDescMatch[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (description && description.length > 150) {
|
||||
description = description.substring(0, 150) + '...';
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
image,
|
||||
description
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error parsing HTML:', error);
|
||||
return { title: null, image: null, description: null };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to check if text is a URL
|
||||
|
|
@ -26,68 +94,155 @@ function isUrl(text) {
|
|||
return urlRegex.test(text.trim());
|
||||
}
|
||||
|
||||
// Helper function to fetch URL title
|
||||
async function fetchUrlTitle(url) {
|
||||
// Helper function to resolve relative URLs to absolute URLs
|
||||
function resolveUrl(baseUrl, relativeUrl) {
|
||||
try {
|
||||
return new URL(relativeUrl, baseUrl).href;
|
||||
} catch {
|
||||
return relativeUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to handle YouTube URLs specially
|
||||
function handleYouTubeUrl(url) {
|
||||
const youtubeRegex = /(?:youtube\.com\/watch\?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
|
||||
const match = url.match(youtubeRegex);
|
||||
|
||||
if (match) {
|
||||
const videoId = match[1];
|
||||
|
||||
// For now, return basic YouTube info - this is fast and reliable
|
||||
return {
|
||||
title: 'YouTube Video',
|
||||
image: `https://img.youtube.com/vi/${videoId}/maxresdefault.jpg`,
|
||||
description: 'YouTube video'
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper function to fetch URL metadata with redirect handling
|
||||
async function fetchUrlMetadata(url, maxRedirects = 5) {
|
||||
return new Promise((resolve) => {
|
||||
// Add protocol if missing
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'http://' + url;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
// Handle YouTube URLs specially to avoid anti-bot issues
|
||||
if (url.includes('youtube.com') || url.includes('youtu.be')) {
|
||||
const youtubeMetadata = handleYouTubeUrl(url);
|
||||
if (youtubeMetadata) {
|
||||
resolve(youtubeMetadata);
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
timeout: 5000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
};
|
||||
// Global timeout for the entire operation
|
||||
const globalTimeout = setTimeout(() => {
|
||||
resolve(null);
|
||||
}, 3000); // 3 second max for entire operation
|
||||
|
||||
const req = client.request(options, (res) => {
|
||||
let data = '';
|
||||
let totalBytes = 0;
|
||||
const maxBytes = 50000;
|
||||
function makeRequest(currentUrl, redirectCount = 0) {
|
||||
if (redirectCount > maxRedirects) {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
req.destroy();
|
||||
try {
|
||||
const urlObj = new URL(currentUrl);
|
||||
const isHttps = urlObj.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const options = {
|
||||
hostname: urlObj.hostname,
|
||||
port: urlObj.port || (isHttps ? 443 : 80),
|
||||
path: urlObj.pathname + urlObj.search,
|
||||
method: 'GET',
|
||||
timeout: 2000, // Reduced from 5000ms to 2000ms
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
||||
}
|
||||
};
|
||||
|
||||
const req = client.request(options, (res) => {
|
||||
// Handle redirects (301, 302, 303, 307, 308)
|
||||
if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location) {
|
||||
const redirectUrl = new URL(res.headers.location, currentUrl).href;
|
||||
makeRequest(redirectUrl, redirectCount + 1);
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
|
||||
// Stop if we find the title tag
|
||||
if (data.includes('</title>')) {
|
||||
req.destroy();
|
||||
// If not a successful response, resolve with null
|
||||
if (res.statusCode < 200 || res.statusCode >= 400) {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = '';
|
||||
let totalBytes = 0;
|
||||
const maxBytes = 20000; // Reduced from 100KB to 20KB - most meta tags are in head
|
||||
let foundMeta = false;
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
clearTimeout(globalTimeout);
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
data += chunk;
|
||||
|
||||
// Early termination if we've found essential meta tags and closed head
|
||||
if (!foundMeta && (data.includes('og:title') || data.includes('twitter:title') || data.includes('</title>'))) {
|
||||
foundMeta = true;
|
||||
}
|
||||
|
||||
// Stop early if we have meta tags and hit end of head
|
||||
if (foundMeta && data.includes('</head>')) {
|
||||
clearTimeout(globalTimeout);
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
clearTimeout(globalTimeout);
|
||||
const metadata = extractMetadataFromHtml(data);
|
||||
|
||||
// Resolve relative image URLs to absolute
|
||||
if (metadata.image && !metadata.image.startsWith('http')) {
|
||||
metadata.image = resolveUrl(currentUrl, metadata.image);
|
||||
}
|
||||
|
||||
resolve(metadata);
|
||||
});
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
const title = extractTitleFromHtml(data);
|
||||
resolve(title);
|
||||
req.on('error', (err) => {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
req.on('timeout', () => {
|
||||
clearTimeout(globalTimeout);
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.end();
|
||||
} catch (error) {
|
||||
clearTimeout(globalTimeout);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
req.end();
|
||||
} catch (error) {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
|
||||
makeRequest(url);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -104,12 +259,17 @@ router.get('/url/title', async (req, res) => {
|
|||
return res.status(400).json({ error: 'URL parameter is required' });
|
||||
}
|
||||
|
||||
const title = await fetchUrlTitle(url);
|
||||
const metadata = await fetchUrlMetadata(url);
|
||||
|
||||
if (title) {
|
||||
res.json({ url, title });
|
||||
if (metadata && metadata.title) {
|
||||
res.json({
|
||||
url,
|
||||
title: metadata.title,
|
||||
image: metadata.image,
|
||||
description: metadata.description
|
||||
});
|
||||
} else {
|
||||
res.json({ url, title: null, error: 'Could not extract title' });
|
||||
res.json({ url, title: null, image: null, description: null, error: 'Could not extract metadata' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error extracting URL title:', error);
|
||||
|
|
@ -130,20 +290,44 @@ router.post('/url/extract-from-text', async (req, res) => {
|
|||
return res.status(400).json({ error: 'Text parameter is required' });
|
||||
}
|
||||
|
||||
// Simple URL extraction - look for URLs in text
|
||||
const urlRegex = /(https?:\/\/[^\s]+)/gi;
|
||||
const urls = text.match(urlRegex);
|
||||
// Enhanced URL extraction - look for URLs with or without protocol
|
||||
const urlWithProtocolRegex = /(https?:\/\/[^\s]+)/gi;
|
||||
const urlWithoutProtocolRegex = /(?:^|\s)((?:www\.)?[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]{2,5}(?::[0-9]{1,5})?(?:\/[^\s]*)?)/gi;
|
||||
|
||||
let urls = text.match(urlWithProtocolRegex);
|
||||
|
||||
// If no URLs with protocol found, look for URLs without protocol
|
||||
if (!urls) {
|
||||
const matches = text.match(urlWithoutProtocolRegex);
|
||||
if (matches) {
|
||||
// Clean up the matches (remove leading whitespace)
|
||||
urls = matches.map(match => match.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if (urls && urls.length > 0) {
|
||||
const firstUrl = urls[0];
|
||||
const title = await fetchUrlTitle(firstUrl);
|
||||
const metadata = await fetchUrlMetadata(firstUrl);
|
||||
|
||||
res.json({
|
||||
found: true,
|
||||
url: firstUrl,
|
||||
title: title,
|
||||
originalText: text
|
||||
});
|
||||
if (metadata && metadata.title) {
|
||||
res.json({
|
||||
found: true,
|
||||
url: firstUrl,
|
||||
title: metadata.title,
|
||||
image: metadata.image,
|
||||
description: metadata.description,
|
||||
originalText: text
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
found: true,
|
||||
url: firstUrl,
|
||||
title: null,
|
||||
image: null,
|
||||
description: null,
|
||||
originalText: text
|
||||
});
|
||||
}
|
||||
} else {
|
||||
res.json({ found: false });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ router.get('/profile', async (req, res) => {
|
|||
'id', 'email', 'appearance', 'language', 'timezone',
|
||||
'avatar_image', 'telegram_bot_token', 'telegram_chat_id',
|
||||
'task_summary_enabled', 'task_summary_frequency', 'task_intelligence_enabled',
|
||||
'auto_suggest_next_actions_enabled'
|
||||
'auto_suggest_next_actions_enabled', 'pomodoro_enabled', 'today_settings'
|
||||
]
|
||||
});
|
||||
|
||||
|
|
@ -25,6 +25,16 @@ router.get('/profile', async (req, res) => {
|
|||
return res.status(404).json({ error: 'Profile not found.' });
|
||||
}
|
||||
|
||||
// Parse today_settings if it's a string
|
||||
if (user.today_settings && typeof user.today_settings === 'string') {
|
||||
try {
|
||||
user.today_settings = JSON.parse(user.today_settings);
|
||||
} catch (error) {
|
||||
console.error('Error parsing today_settings:', error);
|
||||
user.today_settings = null;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('Error fetching profile:', error);
|
||||
|
|
@ -44,7 +54,7 @@ router.patch('/profile', async (req, res) => {
|
|||
return res.status(404).json({ error: 'Profile not found.' });
|
||||
}
|
||||
|
||||
const { appearance, language, timezone, avatar_image, telegram_bot_token, task_intelligence_enabled, task_summary_enabled, task_summary_frequency, auto_suggest_next_actions_enabled } = req.body;
|
||||
const { appearance, language, timezone, avatar_image, telegram_bot_token, task_intelligence_enabled, task_summary_enabled, task_summary_frequency, auto_suggest_next_actions_enabled, pomodoro_enabled, currentPassword, newPassword } = req.body;
|
||||
|
||||
const allowedUpdates = {};
|
||||
if (appearance !== undefined) allowedUpdates.appearance = appearance;
|
||||
|
|
@ -56,12 +66,36 @@ router.patch('/profile', async (req, res) => {
|
|||
if (task_summary_enabled !== undefined) allowedUpdates.task_summary_enabled = task_summary_enabled;
|
||||
if (task_summary_frequency !== undefined) allowedUpdates.task_summary_frequency = task_summary_frequency;
|
||||
if (auto_suggest_next_actions_enabled !== undefined) allowedUpdates.auto_suggest_next_actions_enabled = auto_suggest_next_actions_enabled;
|
||||
if (pomodoro_enabled !== undefined) allowedUpdates.pomodoro_enabled = pomodoro_enabled;
|
||||
|
||||
// Handle password change if provided
|
||||
if (currentPassword && newPassword) {
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({
|
||||
field: 'newPassword',
|
||||
error: 'Password must be at least 6 characters'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await User.checkPassword(currentPassword, user.password_digest);
|
||||
if (!isValidPassword) {
|
||||
return res.status(400).json({
|
||||
field: 'currentPassword',
|
||||
error: 'Current password is incorrect'
|
||||
});
|
||||
}
|
||||
|
||||
// Hash and include new password in updates
|
||||
const hashedNewPassword = await User.hashPassword(newPassword);
|
||||
allowedUpdates.password_digest = hashedNewPassword;
|
||||
}
|
||||
|
||||
await user.update(allowedUpdates);
|
||||
|
||||
// Return updated user with limited fields
|
||||
const updatedUser = await User.findByPk(user.id, {
|
||||
attributes: ['id', 'email', 'appearance', 'language', 'timezone', 'avatar_image', 'telegram_bot_token', 'telegram_chat_id', 'task_intelligence_enabled', 'task_summary_enabled', 'task_summary_frequency', 'auto_suggest_next_actions_enabled']
|
||||
attributes: ['id', 'email', 'appearance', 'language', 'timezone', 'avatar_image', 'telegram_bot_token', 'telegram_chat_id', 'task_intelligence_enabled', 'task_summary_enabled', 'task_summary_frequency', 'auto_suggest_next_actions_enabled', 'pomodoro_enabled']
|
||||
});
|
||||
|
||||
res.json(updatedUser);
|
||||
|
|
@ -74,6 +108,51 @@ router.patch('/profile', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// POST /api/profile/change-password
|
||||
router.post('/profile/change-password', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Current password and new password are required' });
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
return res.status(400).json({
|
||||
field: 'newPassword',
|
||||
error: 'Password must be at least 6 characters'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await User.checkPassword(currentPassword, user.password_digest);
|
||||
if (!isValidPassword) {
|
||||
return res.status(400).json({
|
||||
field: 'currentPassword',
|
||||
error: 'Current password is incorrect'
|
||||
});
|
||||
}
|
||||
|
||||
// Hash and update new password
|
||||
const hashedNewPassword = await User.hashPassword(newPassword);
|
||||
await user.update({ password_digest: hashedNewPassword });
|
||||
|
||||
res.json({ message: 'Password changed successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error changing password:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/profile/task-summary/toggle
|
||||
router.post('/profile/task-summary/toggle', async (req, res) => {
|
||||
try {
|
||||
|
|
@ -208,4 +287,48 @@ router.get('/profile/task-summary/status', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// PUT /api/profile/today-settings
|
||||
router.put('/profile/today-settings', async (req, res) => {
|
||||
try {
|
||||
if (!req.session || !req.session.userId) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const user = await User.findByPk(req.session.userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
const {
|
||||
showMetrics,
|
||||
showProductivity,
|
||||
showIntelligence,
|
||||
showDueToday,
|
||||
showCompleted,
|
||||
showProgressBar,
|
||||
showDailyQuote
|
||||
} = req.body;
|
||||
|
||||
const todaySettings = {
|
||||
showMetrics: showMetrics !== undefined ? showMetrics : user.today_settings?.showMetrics || false,
|
||||
showProductivity: showProductivity !== undefined ? showProductivity : user.today_settings?.showProductivity || false,
|
||||
showIntelligence: showIntelligence !== undefined ? showIntelligence : user.today_settings?.showIntelligence || false,
|
||||
showDueToday: showDueToday !== undefined ? showDueToday : user.today_settings?.showDueToday || true,
|
||||
showCompleted: showCompleted !== undefined ? showCompleted : user.today_settings?.showCompleted || true,
|
||||
showProgressBar: true, // Always enabled - ignore any attempts to disable it
|
||||
showDailyQuote: showDailyQuote !== undefined ? showDailyQuote : user.today_settings?.showDailyQuote || true
|
||||
};
|
||||
|
||||
await user.update({ today_settings: todaySettings });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
today_settings: todaySettings
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating today settings:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
18
backend/scripts/seed-dev-data.js
Normal file
18
backend/scripts/seed-dev-data.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const path = require('path');
|
||||
const { seedDatabase } = require('../seeders/dev-seeder');
|
||||
|
||||
// Set up the environment
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
|
||||
// Ensure we're using the correct database path
|
||||
if (!process.env.DATABASE_URL) {
|
||||
process.env.DATABASE_URL = `sqlite:///${path.join(__dirname, '../db/development.sqlite3')}`;
|
||||
}
|
||||
|
||||
console.log('🌱 Starting development data seeding...');
|
||||
console.log(`📁 Database: ${process.env.DATABASE_URL}`);
|
||||
console.log(`🌍 Environment: ${process.env.NODE_ENV}`);
|
||||
|
||||
seedDatabase();
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
#!/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);
|
||||
}
|
||||
|
|
@ -13,22 +13,33 @@ const bcrypt = require('bcrypt');
|
|||
async function createUser() {
|
||||
const [email, password] = process.argv.slice(2);
|
||||
|
||||
if (!email || !password) {
|
||||
if (!email || password === undefined) {
|
||||
console.error('❌ Usage: npm run user:create <email> <password>');
|
||||
console.error('Example: npm run user:create admin@example.com mypassword123');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
console.error('❌ Invalid email format');
|
||||
// Basic password validation (check for empty or short passwords)
|
||||
if (!password || password.length < 6) {
|
||||
console.error('❌ Password must be at least 6 characters long');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Basic password validation
|
||||
if (password.length < 6) {
|
||||
console.error('❌ Password must be at least 6 characters long');
|
||||
// Enhanced email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
||||
|
||||
// Check for common invalid patterns
|
||||
if (!email.includes('@') ||
|
||||
!email.includes('.') ||
|
||||
email.includes('@@') ||
|
||||
email.includes(' ') ||
|
||||
email.startsWith('@') ||
|
||||
email.endsWith('@') ||
|
||||
email.endsWith('.') ||
|
||||
email.includes('@.') ||
|
||||
email.includes('.@') ||
|
||||
!emailRegex.test(email)) {
|
||||
console.error('❌ Invalid email format');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
|
@ -42,13 +53,13 @@ async function createUser() {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
// Hash the password manually since the hook might not be working in this context
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
// Create the user
|
||||
const user = await User.create({
|
||||
email,
|
||||
password: hashedPassword
|
||||
password_digest: hashedPassword
|
||||
});
|
||||
|
||||
console.log('✅ User created successfully');
|
||||
|
|
|
|||
682
backend/seeders/dev-seeder.js
Normal file
682
backend/seeders/dev-seeder.js
Normal file
|
|
@ -0,0 +1,682 @@
|
|||
const { User, Area, Project, Task, Tag, Note, InboxItem } = require('../models');
|
||||
const bcrypt = require('bcrypt');
|
||||
const { createMassiveTaskData } = require('./massive-tasks');
|
||||
|
||||
async function seedDatabase() {
|
||||
try {
|
||||
console.log('🌱 Starting database seeding...');
|
||||
|
||||
// Create SEPARATE test user for seeding (never overwrite existing users!)
|
||||
console.log('👤 Creating separate test user for testing...');
|
||||
const testEmail = 'test@tududi.com';
|
||||
|
||||
let testUser = await User.findOne({ where: { email: testEmail } });
|
||||
|
||||
if (!testUser) {
|
||||
testUser = await User.create({
|
||||
name: 'Test User',
|
||||
email: testEmail,
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
appearance: 'light',
|
||||
language: 'en',
|
||||
timezone: 'Europe/Athens'
|
||||
});
|
||||
console.log('✅ Created new test user with ID:', testUser.id);
|
||||
} else {
|
||||
console.log('✅ Found existing test user with ID:', testUser.id);
|
||||
// Clear ONLY the test user's data to refresh it
|
||||
console.log('🧹 Clearing test user data for fresh seeding...');
|
||||
await Task.destroy({ where: { user_id: testUser.id } });
|
||||
await Project.destroy({ where: { user_id: testUser.id } });
|
||||
await Area.destroy({ where: { user_id: testUser.id } });
|
||||
await Tag.destroy({ where: { user_id: testUser.id } });
|
||||
await Note.destroy({ where: { user_id: testUser.id } });
|
||||
await InboxItem.destroy({ where: { user_id: testUser.id } });
|
||||
}
|
||||
|
||||
// Create areas
|
||||
console.log('📁 Creating areas...');
|
||||
const areas = await Area.bulkCreate([
|
||||
{ name: 'Personal', user_id: testUser.id },
|
||||
{ name: 'Work', user_id: testUser.id },
|
||||
{ name: 'Health & Fitness', user_id: testUser.id },
|
||||
{ name: 'Learning', user_id: testUser.id },
|
||||
{ name: 'Home & Family', user_id: testUser.id },
|
||||
{ name: 'Finance', user_id: testUser.id },
|
||||
{ name: 'Travel', user_id: testUser.id },
|
||||
{ name: 'Hobbies', user_id: testUser.id },
|
||||
{ name: 'Social', user_id: testUser.id },
|
||||
{ name: 'Career', user_id: testUser.id }
|
||||
]);
|
||||
|
||||
// Create projects
|
||||
console.log('📂 Creating projects...');
|
||||
const projects = await Project.bulkCreate([
|
||||
{
|
||||
name: 'Website Redesign',
|
||||
description: 'Complete overhaul of company website',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[1].id,
|
||||
active: true,
|
||||
due_date_at: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) // 30 days from now
|
||||
},
|
||||
{
|
||||
name: 'Learn React Native',
|
||||
description: 'Master mobile app development',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[3].id,
|
||||
active: true
|
||||
},
|
||||
{
|
||||
name: 'Home Renovation',
|
||||
description: 'Kitchen and bathroom updates',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[4].id,
|
||||
active: true,
|
||||
due_date_at: new Date(Date.now() + 60 * 24 * 60 * 60 * 1000) // 60 days from now
|
||||
},
|
||||
{
|
||||
name: 'Fitness Challenge',
|
||||
description: '90-day fitness transformation',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[2].id,
|
||||
active: true,
|
||||
due_date_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000) // 90 days from now
|
||||
},
|
||||
{
|
||||
name: 'Side Business',
|
||||
description: 'Launch online consulting service',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[1].id,
|
||||
active: true
|
||||
},
|
||||
{
|
||||
name: 'Investment Portfolio',
|
||||
description: 'Build diversified investment portfolio',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[5].id,
|
||||
active: true,
|
||||
due_date_at: new Date(Date.now() + 120 * 24 * 60 * 60 * 1000) // 120 days from now
|
||||
},
|
||||
{
|
||||
name: 'Europe Trip 2024',
|
||||
description: 'Plan and execute 3-week European vacation',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[6].id,
|
||||
active: true,
|
||||
due_date_at: new Date(Date.now() + 180 * 24 * 60 * 60 * 1000) // 180 days from now
|
||||
},
|
||||
{
|
||||
name: 'Photography Mastery',
|
||||
description: 'Learn advanced photography techniques',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[7].id,
|
||||
active: true
|
||||
},
|
||||
{
|
||||
name: 'Professional Certification',
|
||||
description: 'Get AWS Solutions Architect certification',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[9].id,
|
||||
active: true,
|
||||
due_date_at: new Date(Date.now() + 150 * 24 * 60 * 60 * 1000) // 150 days from now
|
||||
},
|
||||
{
|
||||
name: 'Garden Makeover',
|
||||
description: 'Transform backyard into productive garden',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[4].id,
|
||||
active: true,
|
||||
due_date_at: new Date(Date.now() + 45 * 24 * 60 * 60 * 1000) // 45 days from now
|
||||
},
|
||||
{
|
||||
name: 'Blog Launch',
|
||||
description: 'Start personal tech blog',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[0].id,
|
||||
active: true
|
||||
},
|
||||
{
|
||||
name: 'Language Learning Spanish',
|
||||
description: 'Become conversational in Spanish',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[3].id,
|
||||
active: false // Paused project
|
||||
},
|
||||
{
|
||||
name: 'Wedding Planning',
|
||||
description: 'Plan and organize wedding ceremony',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[8].id,
|
||||
active: true,
|
||||
due_date_at: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000) // 1 year from now
|
||||
},
|
||||
{
|
||||
name: 'Meal Prep System',
|
||||
description: 'Establish weekly meal preparation routine',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[2].id,
|
||||
active: true
|
||||
},
|
||||
{
|
||||
name: 'Smart Home Setup',
|
||||
description: 'Install and configure smart home devices',
|
||||
user_id: testUser.id,
|
||||
area_id: areas[4].id,
|
||||
active: true,
|
||||
due_date_at: new Date(Date.now() + 21 * 24 * 60 * 60 * 1000) // 21 days from now
|
||||
}
|
||||
]);
|
||||
|
||||
// Create tags
|
||||
console.log('🏷️ Creating tags...');
|
||||
const tags = await Tag.bulkCreate([
|
||||
{ name: 'urgent', user_id: testUser.id },
|
||||
{ name: 'quick-win', user_id: testUser.id },
|
||||
{ name: 'research', user_id: testUser.id },
|
||||
{ name: 'meeting', user_id: testUser.id },
|
||||
{ name: 'creative', user_id: testUser.id },
|
||||
{ name: 'phone-call', user_id: testUser.id },
|
||||
{ name: 'online', user_id: testUser.id },
|
||||
{ name: 'weekend', user_id: testUser.id },
|
||||
{ name: 'shopping', user_id: testUser.id },
|
||||
{ name: 'admin', user_id: testUser.id },
|
||||
{ name: 'waiting-for', user_id: testUser.id },
|
||||
{ name: 'someday-maybe', user_id: testUser.id },
|
||||
{ name: 'high-energy', user_id: testUser.id },
|
||||
{ name: 'low-energy', user_id: testUser.id },
|
||||
{ name: 'collaboration', user_id: testUser.id },
|
||||
{ name: 'learning', user_id: testUser.id },
|
||||
{ name: 'maintenance', user_id: testUser.id },
|
||||
{ name: 'financial', user_id: testUser.id },
|
||||
{ name: 'health', user_id: testUser.id },
|
||||
{ name: 'outdoor', user_id: testUser.id },
|
||||
{ name: 'planning', user_id: testUser.id },
|
||||
{ name: 'review', user_id: testUser.id },
|
||||
{ name: 'automation', user_id: testUser.id },
|
||||
{ name: 'documentation', user_id: testUser.id },
|
||||
{ name: 'bug-fix', user_id: testUser.id }
|
||||
]);
|
||||
|
||||
// Helper function to get random date
|
||||
const getRandomDate = (daysFromNow) => {
|
||||
const randomDays = Math.floor(Math.random() * daysFromNow);
|
||||
return new Date(Date.now() + randomDays * 24 * 60 * 60 * 1000);
|
||||
};
|
||||
|
||||
const getPastDate = (daysAgo) => {
|
||||
const randomDays = Math.floor(Math.random() * daysAgo);
|
||||
return new Date(Date.now() - randomDays * 24 * 60 * 60 * 1000);
|
||||
};
|
||||
|
||||
// Create tasks
|
||||
console.log('✅ Creating massive task dataset...');
|
||||
const taskData = createMassiveTaskData(projects, getRandomDate, getPastDate);
|
||||
|
||||
const tasks = [];
|
||||
for (const taskInfo of taskData) {
|
||||
const task = await Task.create({
|
||||
...taskInfo,
|
||||
user_id: testUser.id,
|
||||
note: taskInfo.note || null
|
||||
});
|
||||
tasks.push(task);
|
||||
}
|
||||
|
||||
// Create additional backlog tasks with old creation dates for realistic metrics
|
||||
console.log('📅 Creating backlog tasks with older dates...');
|
||||
const backlogTaskNames = [
|
||||
'Organize old photo albums',
|
||||
'Learn French language basics',
|
||||
'Research retirement planning options',
|
||||
'Clean out basement storage',
|
||||
'Update professional portfolio',
|
||||
'Plan career development goals',
|
||||
'Research home security systems',
|
||||
'Organize digital file system',
|
||||
'Plan vacation for next year',
|
||||
'Research new investment strategies',
|
||||
'Update emergency preparedness kit',
|
||||
'Learn new cooking techniques',
|
||||
'Research sustainable living options',
|
||||
'Plan home office reorganization',
|
||||
'Update professional headshots',
|
||||
'Research online course options',
|
||||
'Plan garden landscaping project',
|
||||
'Research new technology trends',
|
||||
'Update financial planning documents',
|
||||
'Plan family history research',
|
||||
'Research health and wellness programs',
|
||||
'Plan hobby room organization',
|
||||
'Research eco-friendly home improvements',
|
||||
'Update professional networking',
|
||||
'Plan creative writing project',
|
||||
'Research mindfulness practices',
|
||||
'Plan workshop or shed organization',
|
||||
'Research travel planning tools',
|
||||
'Update subscription management',
|
||||
'Plan digital decluttering project'
|
||||
];
|
||||
|
||||
for (let i = 0; i < backlogTaskNames.length; i++) {
|
||||
const daysAgo = Math.floor(Math.random() * 120) + 31; // 31-150 days ago
|
||||
const oldDate = new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000);
|
||||
|
||||
const backlogTask = await Task.create({
|
||||
name: backlogTaskNames[i],
|
||||
priority: Math.floor(Math.random() * 3),
|
||||
status: Math.random() < 0.9 ? 0 : 1, // 90% not started, 10% in progress
|
||||
user_id: testUser.id,
|
||||
project_id: Math.random() < 0.3 ? projects[Math.floor(Math.random() * projects.length)].id : null,
|
||||
due_date: Math.random() < 0.2 ? getRandomDate(30) : null,
|
||||
created_at: oldDate,
|
||||
updated_at: oldDate
|
||||
});
|
||||
tasks.push(backlogTask);
|
||||
}
|
||||
|
||||
// Create tasks due today for realistic "Due Today" section
|
||||
console.log('📅 Creating tasks due today...');
|
||||
const todayTaskNames = [
|
||||
'Submit weekly status report',
|
||||
'Call insurance company about claim',
|
||||
'Pick up prescription medication',
|
||||
'Schedule appointment with accountant',
|
||||
'Review and approve team proposals',
|
||||
'Prepare presentation slides for Monday',
|
||||
'Complete expense report submission',
|
||||
'Follow up on pending client emails',
|
||||
'Review contract terms and conditions',
|
||||
'Update project timeline document'
|
||||
];
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0); // Set to start of today
|
||||
|
||||
for (let i = 0; i < todayTaskNames.length; i++) {
|
||||
const todayTask = await Task.create({
|
||||
name: todayTaskNames[i],
|
||||
priority: Math.floor(Math.random() * 3),
|
||||
status: Math.random() < 0.8 ? 0 : 1, // 80% not started, 20% in progress
|
||||
user_id: testUser.id,
|
||||
project_id: Math.random() < 0.4 ? projects[Math.floor(Math.random() * projects.length)].id : null,
|
||||
due_date: today,
|
||||
created_at: getPastDate(7), // Created within last week
|
||||
updated_at: getPastDate(7)
|
||||
});
|
||||
tasks.push(todayTask);
|
||||
}
|
||||
|
||||
// Create intelligent task-tag associations
|
||||
console.log('🔗 Creating intelligent task-tag associations...');
|
||||
|
||||
// Helper function to tag tasks based on keywords and patterns
|
||||
const addIntelligentTags = async () => {
|
||||
for (let i = 0; i < tasks.length; i++) {
|
||||
const task = tasks[i];
|
||||
const taskName = task.name.toLowerCase();
|
||||
const taskTags = [];
|
||||
|
||||
// Pattern-based tagging for AI trigger recognition
|
||||
if (taskName.includes('urgent') || taskName.includes('asap') || task.due_date && new Date(task.due_date) < new Date()) {
|
||||
taskTags.push(tags[0]); // urgent
|
||||
}
|
||||
|
||||
if (taskName.includes('call') || taskName.includes('phone')) {
|
||||
taskTags.push(tags[5]); // phone-call
|
||||
}
|
||||
|
||||
if (taskName.includes('meeting') || taskName.includes('standup') || taskName.includes('conference')) {
|
||||
taskTags.push(tags[3]); // meeting
|
||||
}
|
||||
|
||||
if (taskName.includes('research') || taskName.includes('study') || taskName.includes('learn')) {
|
||||
taskTags.push(tags[2]); // research
|
||||
taskTags.push(tags[15]); // learning
|
||||
}
|
||||
|
||||
if (taskName.includes('buy') || taskName.includes('purchase') || taskName.includes('shop')) {
|
||||
taskTags.push(tags[8]); // shopping
|
||||
}
|
||||
|
||||
if (taskName.includes('design') || taskName.includes('create') || taskName.includes('write') || taskName.includes('paint')) {
|
||||
taskTags.push(tags[4]); // creative
|
||||
}
|
||||
|
||||
if (taskName.includes('health') || taskName.includes('doctor') || taskName.includes('medical') || taskName.includes('fitness') || taskName.includes('workout')) {
|
||||
taskTags.push(tags[18]); // health
|
||||
}
|
||||
|
||||
if (taskName.includes('financial') || taskName.includes('budget') || taskName.includes('invest') || taskName.includes('money') || taskName.includes('pay')) {
|
||||
taskTags.push(tags[17]); // financial
|
||||
}
|
||||
|
||||
if (taskName.includes('outdoor') || taskName.includes('garden') || taskName.includes('hiking') || taskName.includes('park')) {
|
||||
taskTags.push(tags[19]); // outdoor
|
||||
}
|
||||
|
||||
if (taskName.includes('plan') || taskName.includes('schedule') || taskName.includes('organize')) {
|
||||
taskTags.push(tags[20]); // planning
|
||||
}
|
||||
|
||||
if (taskName.includes('review') || taskName.includes('check') || taskName.includes('audit')) {
|
||||
taskTags.push(tags[21]); // review
|
||||
}
|
||||
|
||||
if (taskName.includes('fix') || taskName.includes('repair') || taskName.includes('maintain') || taskName.includes('clean')) {
|
||||
taskTags.push(tags[16]); // maintenance
|
||||
}
|
||||
|
||||
if (taskName.includes('weekend') || task.due_date && [0, 6].includes(new Date(task.due_date).getDay())) {
|
||||
taskTags.push(tags[7]); // weekend
|
||||
}
|
||||
|
||||
if (taskName.includes('online') || taskName.includes('website') || taskName.includes('digital') || taskName.includes('app')) {
|
||||
taskTags.push(tags[6]); // online
|
||||
}
|
||||
|
||||
if (task.status === 4) { // waiting status
|
||||
taskTags.push(tags[10]); // waiting-for
|
||||
}
|
||||
|
||||
if (task.priority === 0 && !task.due_date) {
|
||||
taskTags.push(tags[11]); // someday-maybe
|
||||
}
|
||||
|
||||
if (taskName.includes('team') || taskName.includes('group') || taskName.includes('collaborate')) {
|
||||
taskTags.push(tags[14]); // collaboration
|
||||
}
|
||||
|
||||
if (taskName.includes('quick') || taskName.includes('fast') || taskName.includes('simple')) {
|
||||
taskTags.push(tags[1]); // quick-win
|
||||
}
|
||||
|
||||
if (taskName.includes('energy') || taskName.includes('intensive') || taskName.includes('focus')) {
|
||||
taskTags.push(tags[12]); // high-energy
|
||||
}
|
||||
|
||||
if (taskName.includes('relax') || taskName.includes('easy') || taskName.includes('light')) {
|
||||
taskTags.push(tags[13]); // low-energy
|
||||
}
|
||||
|
||||
if (taskName.includes('automate') || taskName.includes('script') || taskName.includes('automation')) {
|
||||
taskTags.push(tags[22]); // automation
|
||||
}
|
||||
|
||||
if (taskName.includes('document') || taskName.includes('write') || taskName.includes('manual')) {
|
||||
taskTags.push(tags[23]); // documentation
|
||||
}
|
||||
|
||||
if (taskName.includes('bug') || taskName.includes('fix') || taskName.includes('error')) {
|
||||
taskTags.push(tags[24]); // bug-fix
|
||||
}
|
||||
|
||||
// Apply tags if any were identified
|
||||
if (taskTags.length > 0) {
|
||||
await task.setTags(taskTags);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await addIntelligentTags();
|
||||
|
||||
// Create task events for AI pattern learning
|
||||
console.log('📊 Creating task events for AI pattern recognition...');
|
||||
const TaskEventService = require('../services/taskEventService');
|
||||
|
||||
// Create events for completed tasks to show user patterns
|
||||
const completedTasks = tasks.filter(t => t.status === 2);
|
||||
for (const task of completedTasks.slice(0, 20)) { // Just first 20 to avoid too much data
|
||||
try {
|
||||
// Create task creation event
|
||||
await TaskEventService.logTaskCreated(task.id, testUser.id, {
|
||||
name: task.name,
|
||||
status: 0,
|
||||
priority: task.priority,
|
||||
project_id: task.project_id
|
||||
}, { source: 'web' });
|
||||
|
||||
// Create status change to in_progress
|
||||
if (Math.random() < 0.7) { // 70% had in_progress phase
|
||||
await TaskEventService.logStatusChange(task.id, testUser.id, 0, 1, { source: 'web' });
|
||||
}
|
||||
|
||||
// Create completion event
|
||||
await TaskEventService.logStatusChange(task.id, testUser.id, 1, 2, { source: 'web' });
|
||||
} catch (eventError) {
|
||||
console.log(`Skipping event creation for task ${task.id}: ${eventError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create events for some in-progress tasks
|
||||
const inProgressTasks = tasks.filter(t => t.status === 1);
|
||||
for (const task of inProgressTasks.slice(0, 10)) {
|
||||
try {
|
||||
await TaskEventService.logTaskCreated(task.id, testUser.id, {
|
||||
name: task.name,
|
||||
status: 0,
|
||||
priority: task.priority,
|
||||
project_id: task.project_id
|
||||
}, { source: 'web' });
|
||||
|
||||
await TaskEventService.logStatusChange(task.id, testUser.id, 0, 1, { source: 'web' });
|
||||
} catch (eventError) {
|
||||
console.log(`Skipping event creation for task ${task.id}: ${eventError.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Create notes
|
||||
console.log('📝 Creating notes...');
|
||||
await Note.bulkCreate([
|
||||
{
|
||||
title: 'Meeting Notes - Website Redesign',
|
||||
content: 'Key decisions:\n- Use blue and white color scheme\n- Include customer testimonials\n- Mobile-first approach\n- Launch date: End of next month\n\nAction items:\n- Get approval from stakeholders\n- Create prototype by Friday\n- Schedule user testing session',
|
||||
user_id: testUser.id,
|
||||
project_id: projects[0].id
|
||||
},
|
||||
{
|
||||
title: 'React Native Learning Resources',
|
||||
content: 'Useful links:\n- Official documentation\n- Expo.dev for quick prototyping\n- React Navigation library\n- AsyncStorage for local data\n\nTutorials to check out:\n- React Native School\n- The Net Ninja series\n- React Native Express',
|
||||
user_id: testUser.id,
|
||||
project_id: projects[1].id
|
||||
},
|
||||
{
|
||||
title: 'Home Renovation Budget',
|
||||
content: 'Budget breakdown:\n- Kitchen: $15,000\n- Bathroom: $8,000\n- Contingency: $3,000\n- Total: $26,000\n\nContractors to contact:\n- ABC Construction: 555-0123\n- Quality Home Builders: 555-0456\n- Elite Renovations: 555-0789',
|
||||
user_id: testUser.id,
|
||||
project_id: projects[2].id
|
||||
},
|
||||
{
|
||||
title: 'Investment Strategy Notes',
|
||||
content: 'Portfolio allocation goals:\n- 60% Stock index funds\n- 30% Bond index funds\n- 10% International funds\n\nPlatforms to consider:\n- Vanguard\n- Fidelity\n- Charles Schwab\n\nMonthly investment: $1,000',
|
||||
user_id: testUser.id,
|
||||
project_id: projects[5].id
|
||||
},
|
||||
{
|
||||
title: 'Europe Trip Planning',
|
||||
content: 'Destinations:\n1. Paris, France (5 days)\n2. Rome, Italy (4 days)\n3. Barcelona, Spain (4 days)\n4. Amsterdam, Netherlands (3 days)\n5. Prague, Czech Republic (3 days)\n\nEstimated costs:\n- Flights: $1,200\n- Hotels: $2,500\n- Food: $1,000\n- Activities: $800\n- Total: $5,500',
|
||||
user_id: testUser.id,
|
||||
project_id: projects[6].id
|
||||
},
|
||||
{
|
||||
title: 'Photography Equipment Wishlist',
|
||||
content: 'Camera gear to consider:\n- Canon EOS R6 Mark II\n- 24-70mm f/2.8 lens\n- 85mm f/1.8 portrait lens\n- Tripod: Manfrotto MT055CXPRO4\n- Editing software: Lightroom + Photoshop\n\nLearning resources:\n- Sean Tucker YouTube channel\n- Peter McKinnon tutorials\n- Local photography meetups',
|
||||
user_id: testUser.id,
|
||||
project_id: projects[7].id
|
||||
},
|
||||
{
|
||||
title: 'Book Recommendations',
|
||||
content: 'To read:\n- "Deep Work" by Cal Newport\n- "The Lean Startup" by Eric Ries\n- "Atomic Habits" by James Clear\n- "Clean Code" by Robert Martin\n- "The Psychology of Money" by Morgan Housel\n- "Educated" by Tara Westover\n- "Sapiens" by Yuval Noah Harari',
|
||||
user_id: testUser.id
|
||||
},
|
||||
{
|
||||
title: 'Recipe Ideas',
|
||||
content: 'Meals to try:\n- Mediterranean quinoa bowl\n- Thai green curry\n- Homemade pizza\n- Greek lemon chicken\n- Mushroom risotto\n- Korean bulgogi\n- Mexican street corn salad\n- Indian butter chicken\n- Japanese ramen',
|
||||
user_id: testUser.id
|
||||
},
|
||||
{
|
||||
title: 'Business Ideas',
|
||||
content: 'Potential side businesses:\n- Web development consulting\n- Online course creation\n- Photography services\n- Productivity coaching\n- Technical writing\n\nRevenue streams to explore:\n- Subscription services\n- One-time consulting\n- Product sales\n- Affiliate marketing',
|
||||
user_id: testUser.id,
|
||||
project_id: projects[4].id
|
||||
},
|
||||
{
|
||||
title: 'Fitness Goals & Progress',
|
||||
content: 'Current stats:\n- Weight: 180 lbs\n- Body fat: 18%\n- Bench press: 185 lbs\n- Squat: 225 lbs\n- Deadlift: 275 lbs\n\nGoals (90 days):\n- Weight: 175 lbs\n- Body fat: 15%\n- Bench press: 205 lbs\n- Squat: 255 lbs\n- Deadlift: 315 lbs',
|
||||
user_id: testUser.id,
|
||||
project_id: projects[3].id
|
||||
},
|
||||
{
|
||||
title: 'Weekly Meal Prep Ideas',
|
||||
content: 'Prep schedule:\nSunday: Protein prep (chicken, fish, tofu)\nMonday: Vegetable chopping\nWednesday: Mid-week refresh\n\nMeal rotation:\n- Breakfast: Overnight oats, egg muffins\n- Lunch: Buddha bowls, salads\n- Dinner: Stir-fries, sheet pan meals\n- Snacks: Greek yogurt, nuts, fruit',
|
||||
user_id: testUser.id,
|
||||
project_id: projects[13].id
|
||||
},
|
||||
{
|
||||
title: 'Smart Home Device List',
|
||||
content: 'Devices to install:\n- Smart thermostat (Nest)\n- Smart doorbell (Ring)\n- Smart locks (August)\n- Smart lights (Philips Hue)\n- Smart speakers (Echo Dot)\n- Security cameras (Arlo)\n- Smart switches (TP-Link Kasa)\n\nEstimated cost: $2,500\nInstallation timeline: 3 weeks',
|
||||
user_id: testUser.id,
|
||||
project_id: projects[14].id
|
||||
}
|
||||
]);
|
||||
|
||||
// Create inbox items
|
||||
console.log('📥 Creating inbox items...');
|
||||
await InboxItem.bulkCreate([
|
||||
{
|
||||
content: 'Research new project management tools',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Plan team building activity for Q4',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Look into cloud storage solutions',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Consider learning TypeScript',
|
||||
user_id: testUser.id,
|
||||
processed: true
|
||||
},
|
||||
{
|
||||
content: 'Update emergency contact information',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Research sustainable investing options',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Look into ergonomic desk setup',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Consider getting a pet',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Research meditation retreats',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Look into renewable energy for home',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Consider starting a podcast',
|
||||
user_id: testUser.id,
|
||||
processed: true
|
||||
},
|
||||
{
|
||||
content: 'Research local volunteer opportunities',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Look into professional coaching',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Consider learning a musical instrument',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Research minimalism lifestyle',
|
||||
user_id: testUser.id,
|
||||
processed: true
|
||||
},
|
||||
{
|
||||
content: 'Look into starting a garden',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Consider learning sign language',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Research passive income strategies',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Look into digital nomad lifestyle',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
},
|
||||
{
|
||||
content: 'Consider getting professional headshots',
|
||||
user_id: testUser.id,
|
||||
processed: false
|
||||
}
|
||||
]);
|
||||
|
||||
console.log('✨ Database seeding completed successfully!');
|
||||
console.log(`📊 Created test data for SEPARATE test user:
|
||||
- 1 test user (test@tududi.com / password123)
|
||||
- ${areas.length} areas
|
||||
- ${projects.length} projects
|
||||
- ${tasks.length} tasks (including 30 backlog tasks and 10 due today)
|
||||
- ${tags.length} tags
|
||||
- 12 notes
|
||||
- 20 inbox items`);
|
||||
|
||||
console.log('\n🚀 You can now:');
|
||||
console.log('- Login with test@tududi.com / password123 to see test data');
|
||||
console.log('- Your original account data is preserved and untouched');
|
||||
console.log('- Explore the Today view with various task statuses');
|
||||
console.log('- Test task editing, priority changes, etc.');
|
||||
console.log('- View projects with different completion states');
|
||||
console.log('- Test the task timeline feature');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error seeding database:', error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { seedDatabase };
|
||||
|
||||
// Allow running directly
|
||||
if (require.main === module) {
|
||||
seedDatabase().then(() => {
|
||||
console.log('🏁 Seeding finished');
|
||||
process.exit(0);
|
||||
}).catch(error => {
|
||||
console.error('💥 Seeding failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
252
backend/seeders/expanded-tasks.js
Normal file
252
backend/seeders/expanded-tasks.js
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
// Helper function to create expanded task data
|
||||
function createExpandedTaskData(projects, getRandomDate, getPastDate) {
|
||||
return [
|
||||
// Website Redesign Project Tasks (Project 0)
|
||||
{ name: 'Research competitor websites', project_id: projects[0].id, priority: 1, status: 2, completed_at: getPastDate(5) },
|
||||
{ name: 'Create wireframes for homepage', project_id: projects[0].id, priority: 2, status: 1 },
|
||||
{ name: 'Design new color palette', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Write content for About page', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Set up staging environment', project_id: projects[0].id, priority: 2, status: 0, due_date: getRandomDate(7) },
|
||||
{ name: 'Optimize images for web', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Implement responsive design', project_id: projects[0].id, priority: 2, status: 0 },
|
||||
{ name: 'Test cross-browser compatibility', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Setup Google Analytics', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Create contact form', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Write SEO meta descriptions', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Design mobile navigation', project_id: projects[0].id, priority: 2, status: 0 },
|
||||
{ name: 'Create footer section', project_id: projects[0].id, priority: 0, status: 0 },
|
||||
{ name: 'Add social media icons', project_id: projects[0].id, priority: 0, status: 0 },
|
||||
{ name: 'Setup SSL certificate', project_id: projects[0].id, priority: 2, status: 0, due_date: getRandomDate(5) },
|
||||
|
||||
// Learn React Native Project Tasks (Project 1)
|
||||
{ name: 'Complete React Native tutorial', project_id: projects[1].id, priority: 2, status: 1 },
|
||||
{ name: 'Build first mobile app', project_id: projects[1].id, priority: 2, status: 0 },
|
||||
{ name: 'Learn about navigation', project_id: projects[1].id, priority: 1, status: 0 },
|
||||
{ name: 'Study state management', project_id: projects[1].id, priority: 1, status: 0 },
|
||||
{ name: 'Practice with APIs', project_id: projects[1].id, priority: 1, status: 0 },
|
||||
{ name: 'Setup development environment', project_id: projects[1].id, priority: 2, status: 2, completed_at: getPastDate(10) },
|
||||
{ name: 'Learn about debugging tools', project_id: projects[1].id, priority: 1, status: 0 },
|
||||
{ name: 'Study push notifications', project_id: projects[1].id, priority: 1, status: 0 },
|
||||
{ name: 'Learn about app deployment', project_id: projects[1].id, priority: 1, status: 0 },
|
||||
{ name: 'Practice with AsyncStorage', project_id: projects[1].id, priority: 1, status: 0 },
|
||||
|
||||
// Home Renovation Project Tasks (Project 2)
|
||||
{ name: 'Get quotes from contractors', project_id: projects[2].id, priority: 2, status: 0, due_date: getRandomDate(14) },
|
||||
{ name: 'Choose kitchen tiles', project_id: projects[2].id, priority: 1, status: 0 },
|
||||
{ name: 'Order new appliances', project_id: projects[2].id, priority: 2, status: 0, due_date: getRandomDate(21) },
|
||||
{ name: 'Plan bathroom layout', project_id: projects[2].id, priority: 1, status: 0 },
|
||||
{ name: 'Select paint colors', project_id: projects[2].id, priority: 1, status: 1 },
|
||||
{ name: 'Research flooring options', project_id: projects[2].id, priority: 1, status: 2, completed_at: getPastDate(3) },
|
||||
{ name: 'Schedule plumbing inspection', project_id: projects[2].id, priority: 2, status: 0, due_date: getRandomDate(10) },
|
||||
{ name: 'Order cabinet hardware', project_id: projects[2].id, priority: 0, status: 0 },
|
||||
{ name: 'Plan electrical upgrades', project_id: projects[2].id, priority: 2, status: 0 },
|
||||
{ name: 'Choose lighting fixtures', project_id: projects[2].id, priority: 1, status: 0 },
|
||||
|
||||
// Fitness Challenge Project Tasks (Project 3)
|
||||
{ name: 'Create workout schedule', project_id: projects[3].id, priority: 2, status: 2, completed_at: getPastDate(10) },
|
||||
{ name: 'Track daily calories', project_id: projects[3].id, priority: 1, status: 1 },
|
||||
{ name: 'Join gym membership', project_id: projects[3].id, priority: 2, status: 2, completed_at: getPastDate(15) },
|
||||
{ name: 'Buy workout equipment', project_id: projects[3].id, priority: 1, status: 0 },
|
||||
{ name: 'Plan meal prep schedule', project_id: projects[3].id, priority: 1, status: 1 },
|
||||
{ name: 'Find workout buddy', project_id: projects[3].id, priority: 0, status: 0 },
|
||||
{ name: 'Set up fitness tracking app', project_id: projects[3].id, priority: 1, status: 2, completed_at: getPastDate(8) },
|
||||
{ name: 'Schedule body composition test', project_id: projects[3].id, priority: 1, status: 0, due_date: getRandomDate(7) },
|
||||
{ name: 'Research supplements', project_id: projects[3].id, priority: 0, status: 0 },
|
||||
{ name: 'Plan recovery routine', project_id: projects[3].id, priority: 1, status: 0 },
|
||||
|
||||
// Side Business Project Tasks (Project 4)
|
||||
{ name: 'Define service offerings', project_id: projects[4].id, priority: 2, status: 1 },
|
||||
{ name: 'Create business website', project_id: projects[4].id, priority: 2, status: 0 },
|
||||
{ name: 'Set up payment processing', project_id: projects[4].id, priority: 1, status: 0 },
|
||||
{ name: 'Network with potential clients', project_id: projects[4].id, priority: 1, status: 0 },
|
||||
{ name: 'Register business name', project_id: projects[4].id, priority: 2, status: 2, completed_at: getPastDate(12) },
|
||||
{ name: 'Open business bank account', project_id: projects[4].id, priority: 2, status: 0, due_date: getRandomDate(5) },
|
||||
{ name: 'Create marketing materials', project_id: projects[4].id, priority: 1, status: 0 },
|
||||
{ name: 'Set up accounting system', project_id: projects[4].id, priority: 1, status: 0 },
|
||||
{ name: 'Research competitors', project_id: projects[4].id, priority: 1, status: 1 },
|
||||
{ name: 'Create pricing strategy', project_id: projects[4].id, priority: 2, status: 0 },
|
||||
|
||||
// Investment Portfolio Project Tasks (Project 5)
|
||||
{ name: 'Research investment platforms', project_id: projects[5].id, priority: 2, status: 1 },
|
||||
{ name: 'Open brokerage account', project_id: projects[5].id, priority: 2, status: 0, due_date: getRandomDate(10) },
|
||||
{ name: 'Study different asset classes', project_id: projects[5].id, priority: 1, status: 0 },
|
||||
{ name: 'Set investment goals', project_id: projects[5].id, priority: 2, status: 2, completed_at: getPastDate(7) },
|
||||
{ name: 'Create risk assessment', project_id: projects[5].id, priority: 1, status: 1 },
|
||||
{ name: 'Research ETFs and mutual funds', project_id: projects[5].id, priority: 1, status: 0 },
|
||||
{ name: 'Set up automatic investing', project_id: projects[5].id, priority: 1, status: 0 },
|
||||
{ name: 'Learn about tax implications', project_id: projects[5].id, priority: 1, status: 0 },
|
||||
{ name: 'Create emergency fund', project_id: projects[5].id, priority: 2, status: 1 },
|
||||
{ name: 'Review portfolio monthly', project_id: projects[5].id, priority: 1, status: 0 },
|
||||
|
||||
// Europe Trip 2024 Project Tasks (Project 6)
|
||||
{ name: 'Research destinations', project_id: projects[6].id, priority: 2, status: 1 },
|
||||
{ name: 'Book flights', project_id: projects[6].id, priority: 2, status: 0, due_date: getRandomDate(30) },
|
||||
{ name: 'Reserve accommodations', project_id: projects[6].id, priority: 2, status: 0, due_date: getRandomDate(45) },
|
||||
{ name: 'Apply for passport renewal', project_id: projects[6].id, priority: 2, status: 0, due_date: getRandomDate(60) },
|
||||
{ name: 'Plan itinerary', project_id: projects[6].id, priority: 1, status: 0 },
|
||||
{ name: 'Research local customs', project_id: projects[6].id, priority: 0, status: 0 },
|
||||
{ name: 'Learn basic phrases', project_id: projects[6].id, priority: 0, status: 0 },
|
||||
{ name: 'Check visa requirements', project_id: projects[6].id, priority: 2, status: 0, due_date: getRandomDate(90) },
|
||||
{ name: 'Get travel insurance', project_id: projects[6].id, priority: 1, status: 0, due_date: getRandomDate(21) },
|
||||
{ name: 'Plan budget', project_id: projects[6].id, priority: 1, status: 1 },
|
||||
|
||||
// Photography Mastery Project Tasks (Project 7)
|
||||
{ name: 'Learn camera basics', project_id: projects[7].id, priority: 2, status: 2, completed_at: getPastDate(14) },
|
||||
{ name: 'Practice composition rules', project_id: projects[7].id, priority: 1, status: 1 },
|
||||
{ name: 'Study lighting techniques', project_id: projects[7].id, priority: 1, status: 0 },
|
||||
{ name: 'Learn photo editing', project_id: projects[7].id, priority: 1, status: 0 },
|
||||
{ name: 'Build portfolio', project_id: projects[7].id, priority: 1, status: 0 },
|
||||
{ name: 'Join photography community', project_id: projects[7].id, priority: 0, status: 0 },
|
||||
{ name: 'Experiment with different styles', project_id: projects[7].id, priority: 1, status: 0 },
|
||||
{ name: 'Learn about gear', project_id: projects[7].id, priority: 0, status: 0 },
|
||||
{ name: 'Practice street photography', project_id: projects[7].id, priority: 1, status: 0 },
|
||||
{ name: 'Study famous photographers', project_id: projects[7].id, priority: 0, status: 0 },
|
||||
|
||||
// Non-project tasks - Personal productivity and life management
|
||||
{ name: 'Call dentist for appointment', priority: 1, status: 0, due_date: getRandomDate(3) },
|
||||
{ name: 'Buy groceries for the week', priority: 0, status: 0 },
|
||||
{ name: 'Clean garage', priority: 0, status: 0 },
|
||||
{ name: 'Update resume', priority: 1, status: 0 },
|
||||
{ name: 'Read "Atomic Habits" book', priority: 0, status: 0 },
|
||||
{ name: 'Organize digital photos', priority: 0, status: 0 },
|
||||
{ name: 'Schedule car maintenance', priority: 1, status: 0, due_date: getRandomDate(7) },
|
||||
{ name: 'Plan weekend trip', priority: 0, status: 0 },
|
||||
{ name: 'Learn basic Spanish', priority: 0, status: 0 },
|
||||
{ name: 'Backup computer files', priority: 1, status: 0 },
|
||||
{ name: 'Donate old clothes', priority: 0, status: 0 },
|
||||
{ name: 'Research investment options', priority: 1, status: 0 },
|
||||
{ name: 'Call mom and dad', priority: 1, status: 0, due_date: getRandomDate(2) },
|
||||
{ name: 'Fix leaky faucet', priority: 0, status: 0 },
|
||||
{ name: 'Try new restaurant', priority: 0, status: 0 },
|
||||
{ name: 'Update LinkedIn profile', priority: 1, status: 0 },
|
||||
{ name: 'Review monthly expenses', priority: 1, status: 0, due_date: getRandomDate(5) },
|
||||
{ name: 'Organize desk workspace', priority: 0, status: 0 },
|
||||
{ name: 'Plan birthday party', priority: 1, status: 0 },
|
||||
{ name: 'Research new phone', priority: 0, status: 0 },
|
||||
{ name: 'Schedule eye exam', priority: 1, status: 0, due_date: getRandomDate(14) },
|
||||
{ name: 'Update emergency contacts', priority: 1, status: 0 },
|
||||
{ name: 'Clean out email inbox', priority: 0, status: 0 },
|
||||
{ name: 'Research vacation destinations', priority: 0, status: 0 },
|
||||
{ name: 'Update computer software', priority: 1, status: 0 },
|
||||
{ name: 'Plan meal prep for week', priority: 1, status: 0 },
|
||||
{ name: 'Research online courses', priority: 0, status: 0 },
|
||||
{ name: 'Update password manager', priority: 1, status: 0 },
|
||||
{ name: 'Organize physical documents', priority: 0, status: 0 },
|
||||
{ name: 'Research new coffee maker', priority: 0, status: 0 },
|
||||
{ name: 'Schedule oil change', priority: 1, status: 0, due_date: getRandomDate(10) },
|
||||
{ name: 'Plan gift for anniversary', priority: 1, status: 0 },
|
||||
{ name: 'Research home security system', priority: 0, status: 0 },
|
||||
{ name: 'Update will and testament', priority: 2, status: 0, due_date: getRandomDate(30) },
|
||||
{ name: 'Learn keyboard shortcuts', priority: 0, status: 0 },
|
||||
{ name: 'Research meditation apps', priority: 0, status: 0 },
|
||||
{ name: 'Plan date night', priority: 1, status: 0 },
|
||||
{ name: 'Research side income ideas', priority: 0, status: 0 },
|
||||
{ name: 'Update insurance policies', priority: 1, status: 0, due_date: getRandomDate(21) },
|
||||
{ name: 'Learn new cooking recipe', priority: 0, status: 0 },
|
||||
{ name: 'Research productivity tools', priority: 0, status: 0 },
|
||||
{ name: 'Plan garden for spring', priority: 0, status: 0 },
|
||||
{ name: 'Research home improvement ideas', priority: 0, status: 0 },
|
||||
{ name: 'Update social media profiles', priority: 0, status: 0 },
|
||||
{ name: 'Plan weekend activities', priority: 0, status: 0 },
|
||||
{ name: 'Research new podcast', priority: 0, status: 0 },
|
||||
{ name: 'Schedule annual checkup', priority: 1, status: 0, due_date: getRandomDate(45) },
|
||||
{ name: 'Learn new Excel functions', priority: 0, status: 0 },
|
||||
{ name: 'Research retirement planning', priority: 1, status: 0 },
|
||||
{ name: 'Plan family reunion', priority: 1, status: 0 },
|
||||
{ name: 'Research new book to read', priority: 0, status: 0 },
|
||||
{ name: 'Update contact information', priority: 0, status: 0 },
|
||||
{ name: 'Plan workout routine', priority: 1, status: 0 },
|
||||
|
||||
// Completed tasks for metrics - spread across different dates
|
||||
{ name: 'Pay monthly bills', priority: 1, status: 2, completed_at: getPastDate(1) },
|
||||
{ name: 'Submit expense reports', priority: 1, status: 2, completed_at: getPastDate(1) },
|
||||
{ name: 'Weekly team meeting', priority: 1, status: 2, completed_at: getPastDate(2) },
|
||||
{ name: 'Review project proposal', priority: 2, status: 2, completed_at: getPastDate(3) },
|
||||
{ name: 'Update LinkedIn profile', priority: 0, status: 2, completed_at: getPastDate(4) },
|
||||
{ name: 'Clean kitchen', priority: 0, status: 2, completed_at: getPastDate(5) },
|
||||
{ name: 'Water plants', priority: 0, status: 2, completed_at: getPastDate(6) },
|
||||
{ name: 'Grocery shopping', priority: 1, status: 2, completed_at: getPastDate(1) },
|
||||
{ name: 'Call insurance company', priority: 1, status: 2, completed_at: getPastDate(2) },
|
||||
{ name: 'Send birthday card', priority: 0, status: 2, completed_at: getPastDate(3) },
|
||||
{ name: 'Fix printer issue', priority: 1, status: 2, completed_at: getPastDate(1) },
|
||||
{ name: 'Review budget', priority: 1, status: 2, completed_at: getPastDate(4) },
|
||||
{ name: 'Attend networking event', priority: 1, status: 2, completed_at: getPastDate(5) },
|
||||
{ name: 'Complete online training', priority: 1, status: 2, completed_at: getPastDate(6) },
|
||||
{ name: 'Schedule vet appointment', priority: 1, status: 2, completed_at: getPastDate(2) },
|
||||
{ name: 'Buy gift for colleague', priority: 0, status: 2, completed_at: getPastDate(3) },
|
||||
{ name: 'Update calendar', priority: 0, status: 2, completed_at: getPastDate(1) },
|
||||
{ name: 'Research vacation spots', priority: 0, status: 2, completed_at: getPastDate(4) },
|
||||
{ name: 'Backup important files', priority: 1, status: 2, completed_at: getPastDate(5) },
|
||||
{ name: 'Clean bathroom', priority: 0, status: 2, completed_at: getPastDate(1) },
|
||||
|
||||
// Recurring tasks
|
||||
{
|
||||
name: 'Daily standup meeting',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 1,
|
||||
due_date: new Date(),
|
||||
project_id: projects[0].id
|
||||
},
|
||||
{
|
||||
name: 'Weekly grocery shopping',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 6, // Saturday
|
||||
due_date: getRandomDate(7)
|
||||
},
|
||||
{
|
||||
name: 'Monthly budget review',
|
||||
priority: 2,
|
||||
status: 0,
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_month_day: 1,
|
||||
due_date: getRandomDate(30)
|
||||
},
|
||||
{
|
||||
name: 'Weekly meal prep',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 0, // Sunday
|
||||
due_date: getRandomDate(7)
|
||||
},
|
||||
{
|
||||
name: 'Daily workout',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 1,
|
||||
due_date: new Date(),
|
||||
project_id: projects[3].id
|
||||
},
|
||||
{
|
||||
name: 'Weekly house cleaning',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 6, // Saturday
|
||||
due_date: getRandomDate(7)
|
||||
},
|
||||
|
||||
// Waiting and someday tasks
|
||||
{ name: 'Wait for contractor estimate', priority: 1, status: 4, project_id: projects[2].id },
|
||||
{ name: 'Learn advanced photography', priority: 0, status: 0 },
|
||||
{ name: 'Write a book', priority: 0, status: 0 },
|
||||
{ name: 'Learn to play guitar', priority: 0, status: 0 },
|
||||
{ name: 'Take pottery class', priority: 0, status: 0 },
|
||||
{ name: 'Visit Japan', priority: 0, status: 0 },
|
||||
{ name: 'Learn rock climbing', priority: 0, status: 0 },
|
||||
{ name: 'Start a podcast', priority: 0, status: 0 },
|
||||
{ name: 'Learn wine tasting', priority: 0, status: 0 },
|
||||
{ name: 'Take dance lessons', priority: 0, status: 0 }
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = { createExpandedTaskData };
|
||||
631
backend/seeders/massive-tasks.js
Normal file
631
backend/seeders/massive-tasks.js
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
// Helper function to create massive task data with AI feature triggers
|
||||
function createMassiveTaskData(projects, getRandomDate, getPastDate) {
|
||||
// Helper to get random items from array
|
||||
const getRandomItems = (arr, count) => {
|
||||
const shuffled = [...arr].sort(() => 0.5 - Math.random());
|
||||
return shuffled.slice(0, count);
|
||||
};
|
||||
|
||||
// Helper to get random priority
|
||||
const getRandomPriority = () => Math.floor(Math.random() * 3); // 0, 1, or 2
|
||||
|
||||
// Helper to get random status
|
||||
const getRandomStatus = () => {
|
||||
const statuses = [0, 0, 0, 0, 0, 0, 1, 1, 1, 2, 2, 3, 4]; // More weighted towards active tasks
|
||||
return statuses[Math.floor(Math.random() * statuses.length)];
|
||||
};
|
||||
|
||||
// Productivity and work tasks
|
||||
const workTasks = [
|
||||
'Review quarterly performance metrics',
|
||||
'Update project documentation',
|
||||
'Prepare presentation for board meeting',
|
||||
'Conduct code review for new feature',
|
||||
'Write technical specification document',
|
||||
'Schedule one-on-one meetings with team',
|
||||
'Update project timeline and milestones',
|
||||
'Research new development tools',
|
||||
'Optimize database queries',
|
||||
'Create automated testing suite',
|
||||
'Refactor legacy code modules',
|
||||
'Implement security audit recommendations',
|
||||
'Design API documentation',
|
||||
'Setup continuous integration pipeline',
|
||||
'Create user acceptance testing plan',
|
||||
'Migrate data to new system',
|
||||
'Setup monitoring and alerting',
|
||||
'Write deployment procedures',
|
||||
'Create backup and recovery plan',
|
||||
'Conduct performance testing',
|
||||
'Update coding standards documentation',
|
||||
'Setup development environment',
|
||||
'Create onboarding documentation',
|
||||
'Review and update dependencies',
|
||||
'Implement feature toggles',
|
||||
'Setup load balancing',
|
||||
'Create disaster recovery plan',
|
||||
'Conduct security penetration testing',
|
||||
'Setup SSL certificates',
|
||||
'Implement caching strategy',
|
||||
'Create analytics dashboard',
|
||||
'Setup error tracking',
|
||||
'Implement rate limiting',
|
||||
'Create API versioning strategy',
|
||||
'Setup database replication',
|
||||
'Implement search functionality',
|
||||
'Create notification system',
|
||||
'Setup file upload handling',
|
||||
'Implement user authentication',
|
||||
'Create password reset functionality',
|
||||
'Setup email templates',
|
||||
'Implement data validation',
|
||||
'Create audit logging',
|
||||
'Setup health checks',
|
||||
'Implement graceful shutdowns'
|
||||
];
|
||||
|
||||
// Personal development and learning tasks
|
||||
const learningTasks = [
|
||||
'Complete online course on machine learning',
|
||||
'Read "Clean Architecture" book',
|
||||
'Practice coding challenges on LeetCode',
|
||||
'Learn advanced Git techniques',
|
||||
'Study microservices architecture',
|
||||
'Complete Docker certification',
|
||||
'Learn Kubernetes fundamentals',
|
||||
'Study system design patterns',
|
||||
'Practice algorithm problems',
|
||||
'Learn about database optimization',
|
||||
'Study network security principles',
|
||||
'Complete AWS certification',
|
||||
'Learn about blockchain technology',
|
||||
'Study DevOps best practices',
|
||||
'Learn advanced JavaScript features',
|
||||
'Study React performance optimization',
|
||||
'Learn about GraphQL',
|
||||
'Study mobile app development',
|
||||
'Learn about AI and neural networks',
|
||||
'Study cloud computing concepts',
|
||||
'Learn about containerization',
|
||||
'Study API design principles',
|
||||
'Learn about testing strategies',
|
||||
'Study agile methodologies',
|
||||
'Learn about project management',
|
||||
'Study user experience design',
|
||||
'Learn about data visualization',
|
||||
'Study cybersecurity fundamentals',
|
||||
'Learn about scalability patterns',
|
||||
'Study database design principles'
|
||||
];
|
||||
|
||||
// Health and fitness tasks
|
||||
const healthTasks = [
|
||||
'Schedule annual physical exam',
|
||||
'Book dental cleaning appointment',
|
||||
'Schedule eye exam',
|
||||
'Get blood work done',
|
||||
'Schedule dermatologist appointment',
|
||||
'Book massage therapy session',
|
||||
'Schedule physical therapy session',
|
||||
'Get flu vaccination',
|
||||
'Schedule mammogram',
|
||||
'Book nutrition consultation',
|
||||
'Schedule mental health counseling',
|
||||
'Get hearing test',
|
||||
'Schedule chiropractor appointment',
|
||||
'Book acupuncture session',
|
||||
'Schedule sleep study',
|
||||
'Get allergy testing done',
|
||||
'Schedule colonoscopy',
|
||||
'Book podiatrist appointment',
|
||||
'Schedule orthopedic consultation',
|
||||
'Get heart health screening',
|
||||
'Complete 30-minute cardio workout',
|
||||
'Do strength training session',
|
||||
'Practice yoga for 45 minutes',
|
||||
'Go for 5-mile run',
|
||||
'Complete HIIT workout',
|
||||
'Do pilates session',
|
||||
'Practice meditation for 20 minutes',
|
||||
'Track daily water intake',
|
||||
'Meal prep for the week',
|
||||
'Plan healthy breakfast options',
|
||||
'Research new workout routines',
|
||||
'Update fitness goals',
|
||||
'Track daily steps (10,000 goal)',
|
||||
'Practice breathing exercises',
|
||||
'Do stretching routine',
|
||||
'Plan weekly workout schedule',
|
||||
'Research healthy recipes',
|
||||
'Update meal planning app',
|
||||
'Schedule workout with trainer',
|
||||
'Join new fitness class'
|
||||
];
|
||||
|
||||
// Home and family tasks
|
||||
const homeTasks = [
|
||||
'Deep clean living room',
|
||||
'Organize bedroom closet',
|
||||
'Clean out garage',
|
||||
'Wash and fold laundry',
|
||||
'Vacuum all carpets',
|
||||
'Mop kitchen and bathroom floors',
|
||||
'Clean windows inside and out',
|
||||
'Organize pantry and kitchen cabinets',
|
||||
'Clean out refrigerator',
|
||||
'Wash bedsheets and pillowcases',
|
||||
'Dust all furniture',
|
||||
'Clean bathroom thoroughly',
|
||||
'Organize home office',
|
||||
'Sort through old documents',
|
||||
'Clean out car interior',
|
||||
'Wash car exterior',
|
||||
'Organize basement storage',
|
||||
'Clean air conditioning filters',
|
||||
'Test smoke detector batteries',
|
||||
'Check and clean gutters',
|
||||
'Trim bushes and hedges',
|
||||
'Water indoor plants',
|
||||
'Plant vegetables in garden',
|
||||
'Mow lawn and edge walkways',
|
||||
'Repair leaky faucet',
|
||||
'Fix squeaky door hinges',
|
||||
'Replace burnt out light bulbs',
|
||||
'Caulk bathroom tiles',
|
||||
'Touch up paint on walls',
|
||||
'Clean grout in shower',
|
||||
'Organize tool shed',
|
||||
'Season cast iron cookware',
|
||||
'Clean oven and stovetop',
|
||||
'Descale coffee maker',
|
||||
'Clean dishwasher filter',
|
||||
'Replace water filter',
|
||||
'Clean dryer vent',
|
||||
'Organize medicine cabinet',
|
||||
'Check expiration dates on medications',
|
||||
'Update emergency contact list'
|
||||
];
|
||||
|
||||
// Financial and administrative tasks
|
||||
const financialTasks = [
|
||||
'Review monthly budget',
|
||||
'Pay credit card bills',
|
||||
'Transfer money to savings',
|
||||
'Update investment portfolio',
|
||||
'Review insurance policies',
|
||||
'File tax documents',
|
||||
'Update will and testament',
|
||||
'Review retirement contributions',
|
||||
'Check credit report',
|
||||
'Update beneficiary information',
|
||||
'Review bank statements',
|
||||
'Cancel unused subscriptions',
|
||||
'Negotiate lower cable bill',
|
||||
'Shop for better car insurance',
|
||||
'Review cell phone plan',
|
||||
'Update emergency fund',
|
||||
'Research investment options',
|
||||
'Meet with financial advisor',
|
||||
'Review mortgage rates',
|
||||
'Update home insurance',
|
||||
'File warranty claims',
|
||||
'Organize receipts for taxes',
|
||||
'Update accounting software',
|
||||
'Review business expenses',
|
||||
'Pay quarterly taxes',
|
||||
'Update PayPal account',
|
||||
'Review online banking security',
|
||||
'Setup automatic bill pay',
|
||||
'Research high-yield savings',
|
||||
'Update direct deposit info'
|
||||
];
|
||||
|
||||
// Social and relationship tasks
|
||||
const socialTasks = [
|
||||
'Call parents to check in',
|
||||
'Send birthday card to friend',
|
||||
'Plan date night with partner',
|
||||
'Schedule coffee with colleague',
|
||||
'Write thank you note',
|
||||
'Plan family reunion',
|
||||
'Organize game night with friends',
|
||||
'Send holiday cards',
|
||||
'Plan surprise party',
|
||||
'Schedule lunch with mentor',
|
||||
'Join local community group',
|
||||
'Volunteer at local charity',
|
||||
'Attend networking event',
|
||||
'Plan weekend getaway',
|
||||
'Organize book club meeting',
|
||||
'Schedule video call with family',
|
||||
'Plan group hiking trip',
|
||||
'Organize potluck dinner',
|
||||
'Plan movie night',
|
||||
'Schedule catch-up with old friend',
|
||||
'Write recommendation letter',
|
||||
'Plan anniversary celebration',
|
||||
'Organize children\'s playdate',
|
||||
'Schedule babysitter',
|
||||
'Plan family photo session',
|
||||
'Organize neighborhood BBQ',
|
||||
'Plan holiday gathering',
|
||||
'Schedule couple\'s therapy',
|
||||
'Plan birthday celebration',
|
||||
'Organize team building activity'
|
||||
];
|
||||
|
||||
// Creative and hobby tasks
|
||||
const creativeeTasks = [
|
||||
'Practice guitar for 30 minutes',
|
||||
'Work on oil painting',
|
||||
'Write in journal',
|
||||
'Take photography workshop',
|
||||
'Learn new recipe',
|
||||
'Practice calligraphy',
|
||||
'Work on knitting project',
|
||||
'Write short story',
|
||||
'Learn new song on piano',
|
||||
'Practice drawing portraits',
|
||||
'Work on pottery project',
|
||||
'Edit video footage',
|
||||
'Write blog post',
|
||||
'Practice singing',
|
||||
'Work on woodworking project',
|
||||
'Learn new dance moves',
|
||||
'Practice photography techniques',
|
||||
'Work on scrapbook',
|
||||
'Write poetry',
|
||||
'Learn origami',
|
||||
'Practice sketching',
|
||||
'Work on embroidery',
|
||||
'Learn new cooking technique',
|
||||
'Practice watercolor painting',
|
||||
'Work on jewelry making',
|
||||
'Learn magic tricks',
|
||||
'Practice stand-up comedy',
|
||||
'Work on graphic design',
|
||||
'Learn new language phrases',
|
||||
'Practice mindful writing'
|
||||
];
|
||||
|
||||
// Travel and adventure tasks
|
||||
const travelTasks = [
|
||||
'Research vacation destinations',
|
||||
'Book flight tickets',
|
||||
'Reserve hotel accommodation',
|
||||
'Plan daily itinerary',
|
||||
'Apply for passport renewal',
|
||||
'Get travel insurance',
|
||||
'Exchange currency',
|
||||
'Pack suitcase',
|
||||
'Check visa requirements',
|
||||
'Update travel emergency contacts',
|
||||
'Download offline maps',
|
||||
'Research local customs',
|
||||
'Learn basic phrases',
|
||||
'Book airport parking',
|
||||
'Arrange pet sitting',
|
||||
'Stop mail delivery',
|
||||
'Set house security system',
|
||||
'Pack travel first aid kit',
|
||||
'Research local restaurants',
|
||||
'Book tours and activities',
|
||||
'Print boarding passes',
|
||||
'Check weather forecast',
|
||||
'Pack travel documents',
|
||||
'Arrange airport transportation',
|
||||
'Update travel blog'
|
||||
];
|
||||
|
||||
// All task categories combined
|
||||
const allTaskCategories = [
|
||||
...workTasks,
|
||||
...learningTasks,
|
||||
...healthTasks,
|
||||
...homeTasks,
|
||||
...financialTasks,
|
||||
...socialTasks,
|
||||
...creativeeTasks,
|
||||
...travelTasks
|
||||
];
|
||||
|
||||
// Create base task data with existing project tasks
|
||||
const baseTaskData = [
|
||||
// Website Redesign Project (triggers collaboration, urgent deadlines)
|
||||
{ name: 'Research competitor websites', project_id: projects[0].id, priority: 1, status: 2, completed_at: getPastDate(5) },
|
||||
{ name: 'Create wireframes for homepage', project_id: projects[0].id, priority: 2, status: 1 },
|
||||
{ name: 'Design new color palette', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Write content for About page', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Set up staging environment', project_id: projects[0].id, priority: 2, status: 0, due_date: getRandomDate(3) }, // Urgent deadline
|
||||
{ name: 'Optimize images for web', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Implement responsive design', project_id: projects[0].id, priority: 2, status: 0, due_date: getRandomDate(7) },
|
||||
{ name: 'Test cross-browser compatibility', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Setup Google Analytics', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Create contact form', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Write SEO meta descriptions', project_id: projects[0].id, priority: 1, status: 0 },
|
||||
{ name: 'Design mobile navigation', project_id: projects[0].id, priority: 2, status: 0 },
|
||||
{ name: 'Create footer section', project_id: projects[0].id, priority: 0, status: 0 },
|
||||
{ name: 'Add social media icons', project_id: projects[0].id, priority: 0, status: 0 },
|
||||
{ name: 'Setup SSL certificate', project_id: projects[0].id, priority: 2, status: 0, due_date: getRandomDate(2) }, // Very urgent
|
||||
|
||||
// Europe Trip 2024 - triggers travel planning AI features
|
||||
{ name: 'Research flight options to Paris', project_id: projects[6].id, priority: 2, status: 1 },
|
||||
{ name: 'Book hotel in Rome', project_id: projects[6].id, priority: 2, status: 0, due_date: getRandomDate(14) },
|
||||
{ name: 'Apply for European travel insurance', project_id: projects[6].id, priority: 2, status: 0, due_date: getRandomDate(30) },
|
||||
{ name: 'Learn basic Italian phrases', project_id: projects[6].id, priority: 1, status: 0 },
|
||||
{ name: 'Research train routes between cities', project_id: projects[6].id, priority: 1, status: 0 },
|
||||
{ name: 'Plan museum visits in Paris', project_id: projects[6].id, priority: 1, status: 0 },
|
||||
{ name: 'Book restaurant reservations', project_id: projects[6].id, priority: 1, status: 0 },
|
||||
{ name: 'Pack European travel adapter', project_id: projects[6].id, priority: 0, status: 0 },
|
||||
|
||||
// Fitness Challenge - triggers health/wellness AI features
|
||||
{ name: 'Track daily protein intake', project_id: projects[3].id, priority: 1, status: 1 },
|
||||
{ name: 'Complete morning cardio workout', project_id: projects[3].id, priority: 1, status: 2, completed_at: getPastDate(1) },
|
||||
{ name: 'Plan weekly meal prep', project_id: projects[3].id, priority: 1, status: 0 },
|
||||
{ name: 'Schedule body composition scan', project_id: projects[3].id, priority: 1, status: 0, due_date: getRandomDate(7) },
|
||||
{ name: 'Research new workout routines', project_id: projects[3].id, priority: 0, status: 0 },
|
||||
{ name: 'Update fitness tracker goals', project_id: projects[3].id, priority: 1, status: 0 },
|
||||
|
||||
// Investment Portfolio - triggers financial AI features
|
||||
{ name: 'Research ESG investment options', project_id: projects[5].id, priority: 1, status: 0 },
|
||||
{ name: 'Rebalance portfolio allocation', project_id: projects[5].id, priority: 2, status: 0, due_date: getRandomDate(5) },
|
||||
{ name: 'Review quarterly performance', project_id: projects[5].id, priority: 1, status: 1 },
|
||||
{ name: 'Set up automatic dividend reinvestment', project_id: projects[5].id, priority: 1, status: 0 },
|
||||
{ name: 'Research international market exposure', project_id: projects[5].id, priority: 0, status: 0 },
|
||||
|
||||
// Side Business - triggers entrepreneurship AI features
|
||||
{ name: 'Create business plan document', project_id: projects[4].id, priority: 2, status: 1 },
|
||||
{ name: 'Research target market demographics', project_id: projects[4].id, priority: 2, status: 0 },
|
||||
{ name: 'Design logo and branding', project_id: projects[4].id, priority: 1, status: 0 },
|
||||
{ name: 'Setup business social media accounts', project_id: projects[4].id, priority: 1, status: 0 },
|
||||
{ name: 'Register domain name', project_id: projects[4].id, priority: 2, status: 2, completed_at: getPastDate(3) },
|
||||
{ name: 'Create pricing strategy', project_id: projects[4].id, priority: 2, status: 0 },
|
||||
{ name: 'Draft service agreements', project_id: projects[4].id, priority: 1, status: 0 },
|
||||
|
||||
// Home Renovation - triggers home improvement AI features
|
||||
{ name: 'Get electrical work permit', project_id: projects[2].id, priority: 2, status: 0, due_date: getRandomDate(10) },
|
||||
{ name: 'Choose bathroom tile pattern', project_id: projects[2].id, priority: 1, status: 1 },
|
||||
{ name: 'Schedule plumbing inspection', project_id: projects[2].id, priority: 2, status: 0, due_date: getRandomDate(14) },
|
||||
{ name: 'Order kitchen countertops', project_id: projects[2].id, priority: 2, status: 0, due_date: getRandomDate(21) },
|
||||
{ name: 'Research energy-efficient appliances', project_id: projects[2].id, priority: 1, status: 0 },
|
||||
{ name: 'Plan kitchen lighting layout', project_id: projects[2].id, priority: 1, status: 0 },
|
||||
|
||||
// Photography Mastery - triggers creative learning AI features
|
||||
{ name: 'Practice portrait lighting techniques', project_id: projects[7].id, priority: 1, status: 1 },
|
||||
{ name: 'Edit last weekend\'s photo shoot', project_id: projects[7].id, priority: 1, status: 0 },
|
||||
{ name: 'Research local photography groups', project_id: projects[7].id, priority: 0, status: 0 },
|
||||
{ name: 'Plan golden hour photo session', project_id: projects[7].id, priority: 1, status: 0 },
|
||||
{ name: 'Learn advanced Lightroom techniques', project_id: projects[7].id, priority: 1, status: 0 },
|
||||
|
||||
// Smart Home Setup - triggers technology AI features
|
||||
{ name: 'Install smart thermostat', project_id: projects[14].id, priority: 2, status: 1 },
|
||||
{ name: 'Configure home security system', project_id: projects[14].id, priority: 2, status: 0, due_date: getRandomDate(7) },
|
||||
{ name: 'Setup voice assistant routines', project_id: projects[14].id, priority: 1, status: 0 },
|
||||
{ name: 'Install smart door locks', project_id: projects[14].id, priority: 2, status: 0 },
|
||||
{ name: 'Configure automated lighting', project_id: projects[14].id, priority: 1, status: 0 },
|
||||
|
||||
// Blog Launch - triggers content creation AI features
|
||||
{ name: 'Write first blog post about productivity', project_id: projects[10].id, priority: 2, status: 1 },
|
||||
{ name: 'Design blog layout and theme', project_id: projects[10].id, priority: 1, status: 0 },
|
||||
{ name: 'Setup email newsletter signup', project_id: projects[10].id, priority: 1, status: 0 },
|
||||
{ name: 'Research SEO keywords for niche', project_id: projects[10].id, priority: 1, status: 0 },
|
||||
{ name: 'Create content calendar for 3 months', project_id: projects[10].id, priority: 2, status: 0 },
|
||||
|
||||
// Professional Certification - triggers career development AI features
|
||||
{ name: 'Complete AWS practice exams', project_id: projects[8].id, priority: 2, status: 1 },
|
||||
{ name: 'Schedule certification exam', project_id: projects[8].id, priority: 2, status: 0, due_date: getRandomDate(30) },
|
||||
{ name: 'Review cloud architecture patterns', project_id: projects[8].id, priority: 1, status: 0 },
|
||||
{ name: 'Practice hands-on labs', project_id: projects[8].id, priority: 1, status: 1 },
|
||||
{ name: 'Join AWS study group', project_id: projects[8].id, priority: 0, status: 0 },
|
||||
|
||||
// Meal Prep System - triggers nutrition AI features
|
||||
{ name: 'Plan balanced weekly menu', project_id: projects[13].id, priority: 1, status: 1 },
|
||||
{ name: 'Prep vegetables for the week', project_id: projects[13].id, priority: 1, status: 0 },
|
||||
{ name: 'Cook batch of protein sources', project_id: projects[13].id, priority: 1, status: 0 },
|
||||
{ name: 'Calculate macronutrient ratios', project_id: projects[13].id, priority: 1, status: 0 },
|
||||
{ name: 'Research meal prep containers', project_id: projects[13].id, priority: 0, status: 0 },
|
||||
|
||||
// Wedding Planning - triggers event planning AI features
|
||||
{ name: 'Book wedding venue', project_id: projects[12].id, priority: 2, status: 2, completed_at: getPastDate(30) },
|
||||
{ name: 'Send save the date cards', project_id: projects[12].id, priority: 2, status: 0, due_date: getRandomDate(60) },
|
||||
{ name: 'Book wedding photographer', project_id: projects[12].id, priority: 2, status: 0, due_date: getRandomDate(45) },
|
||||
{ name: 'Choose wedding cake flavors', project_id: projects[12].id, priority: 1, status: 0 },
|
||||
{ name: 'Plan seating arrangement', project_id: projects[12].id, priority: 1, status: 0 },
|
||||
{ name: 'Book honeymoon flights', project_id: projects[12].id, priority: 1, status: 0 },
|
||||
|
||||
// Garden Makeover - triggers gardening/sustainability AI features
|
||||
{ name: 'Plan vegetable garden layout', project_id: projects[9].id, priority: 1, status: 1 },
|
||||
{ name: 'Order seeds for spring planting', project_id: projects[9].id, priority: 2, status: 0, due_date: getRandomDate(14) },
|
||||
{ name: 'Install drip irrigation system', project_id: projects[9].id, priority: 1, status: 0 },
|
||||
{ name: 'Build raised garden beds', project_id: projects[9].id, priority: 2, status: 0 },
|
||||
{ name: 'Research companion planting', project_id: projects[9].id, priority: 0, status: 0 }
|
||||
];
|
||||
|
||||
// Generate massive additional tasks
|
||||
const massiveTasks = [];
|
||||
|
||||
// Add random tasks from all categories (including old tasks for backlog)
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const taskName = allTaskCategories[Math.floor(Math.random() * allTaskCategories.length)];
|
||||
const hasProject = Math.random() < 0.4; // 40% chance of having a project
|
||||
const hasDueDate = Math.random() < 0.3; // 30% chance of having a due date
|
||||
const isCompleted = Math.random() < 0.08; // 8% chance of being completed
|
||||
|
||||
const task = {
|
||||
name: taskName,
|
||||
priority: getRandomPriority(),
|
||||
status: isCompleted ? 2 : getRandomStatus(),
|
||||
note: Math.random() < 0.1 ? 'Added some notes during planning phase' : null
|
||||
};
|
||||
|
||||
if (hasProject) {
|
||||
task.project_id = projects[Math.floor(Math.random() * projects.length)].id;
|
||||
}
|
||||
|
||||
if (hasDueDate) {
|
||||
if (Math.random() < 0.2) {
|
||||
// 20% chance of overdue task (AI should flag these)
|
||||
task.due_date = getPastDate(Math.floor(Math.random() * 30) + 1);
|
||||
} else {
|
||||
// Future due date
|
||||
task.due_date = getRandomDate(Math.floor(Math.random() * 60) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (isCompleted) {
|
||||
task.completed_at = getPastDate(Math.floor(Math.random() * 30) + 1);
|
||||
}
|
||||
|
||||
massiveTasks.push(task);
|
||||
}
|
||||
|
||||
// Add specific AI trigger tasks (tasks that should trigger intelligent suggestions)
|
||||
const aiTriggerTasks = [
|
||||
// Overdue tasks (AI should suggest prioritizing)
|
||||
{ name: 'Submit tax documents', priority: 2, status: 0, due_date: getPastDate(5) },
|
||||
{ name: 'Renew car registration', priority: 2, status: 0, due_date: getPastDate(3) },
|
||||
{ name: 'Pay property taxes', priority: 2, status: 0, due_date: getPastDate(10) },
|
||||
{ name: 'Submit insurance claim', priority: 2, status: 0, due_date: getPastDate(7) },
|
||||
|
||||
// High-priority tasks with near deadlines (AI should suggest immediate action)
|
||||
{ name: 'Prepare presentation for CEO', priority: 2, status: 0, due_date: getRandomDate(1) },
|
||||
{ name: 'Submit project proposal', priority: 2, status: 0, due_date: getRandomDate(2) },
|
||||
{ name: 'Complete performance review', priority: 2, status: 0, due_date: getRandomDate(3) },
|
||||
|
||||
// Health-related tasks (AI should suggest wellness patterns)
|
||||
{ name: 'Schedule annual checkup', priority: 1, status: 0 },
|
||||
{ name: 'Get eye exam', priority: 1, status: 0 },
|
||||
{ name: 'Book dental cleaning', priority: 1, status: 0 },
|
||||
{ name: 'Update prescription medications', priority: 1, status: 0 },
|
||||
|
||||
// Financial tasks (AI should suggest money management)
|
||||
{ name: 'Review investment portfolio', priority: 1, status: 0 },
|
||||
{ name: 'Update budget spreadsheet', priority: 1, status: 0 },
|
||||
{ name: 'Research high-yield savings accounts', priority: 0, status: 0 },
|
||||
{ name: 'Review insurance coverage', priority: 1, status: 0 },
|
||||
|
||||
// Learning tasks (AI should suggest skill development)
|
||||
{ name: 'Complete Python course', priority: 1, status: 1 },
|
||||
{ name: 'Read industry publication', priority: 0, status: 0 },
|
||||
{ name: 'Attend professional conference', priority: 1, status: 0 },
|
||||
{ name: 'Update professional certifications', priority: 1, status: 0 },
|
||||
|
||||
// Maintenance tasks (AI should suggest regular upkeep)
|
||||
{ name: 'Change air filter in HVAC', priority: 0, status: 0 },
|
||||
{ name: 'Test smoke detector batteries', priority: 1, status: 0 },
|
||||
{ name: 'Backup computer files', priority: 1, status: 0 },
|
||||
{ name: 'Update software and security patches', priority: 1, status: 0 },
|
||||
|
||||
// Social/relationship tasks (AI should suggest work-life balance)
|
||||
{ name: 'Plan anniversary dinner', priority: 1, status: 0 },
|
||||
{ name: 'Call grandparents', priority: 1, status: 0 },
|
||||
{ name: 'Schedule date night', priority: 0, status: 0 },
|
||||
{ name: 'Organize family gathering', priority: 1, status: 0 },
|
||||
|
||||
// Creative/hobby tasks (AI should suggest personal fulfillment)
|
||||
{ name: 'Practice guitar daily', priority: 0, status: 0 },
|
||||
{ name: 'Work on painting project', priority: 0, status: 0 },
|
||||
{ name: 'Write in journal', priority: 0, status: 0 },
|
||||
{ name: 'Learn new recipe', priority: 0, status: 0 },
|
||||
|
||||
// Recurring daily tasks (AI should recognize patterns)
|
||||
{
|
||||
name: 'Daily meditation practice',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 1,
|
||||
due_date: new Date()
|
||||
},
|
||||
{
|
||||
name: 'Review daily priorities',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 1,
|
||||
due_date: new Date()
|
||||
},
|
||||
{
|
||||
name: 'Log daily expenses',
|
||||
priority: 0,
|
||||
status: 0,
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 1,
|
||||
due_date: new Date()
|
||||
},
|
||||
|
||||
// Weekly recurring tasks
|
||||
{
|
||||
name: 'Weekly meal planning',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 0, // Sunday
|
||||
due_date: getRandomDate(7)
|
||||
},
|
||||
{
|
||||
name: 'Weekly house cleaning',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 6, // Saturday
|
||||
due_date: getRandomDate(7)
|
||||
},
|
||||
{
|
||||
name: 'Weekly team standup',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'weekly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_weekday: 1, // Monday
|
||||
due_date: getRandomDate(7),
|
||||
project_id: projects[0].id
|
||||
},
|
||||
|
||||
// Monthly recurring tasks
|
||||
{
|
||||
name: 'Monthly budget review',
|
||||
priority: 2,
|
||||
status: 0,
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_month_day: 1,
|
||||
due_date: getRandomDate(30)
|
||||
},
|
||||
{
|
||||
name: 'Monthly backup verification',
|
||||
priority: 1,
|
||||
status: 0,
|
||||
recurrence_type: 'monthly',
|
||||
recurrence_interval: 1,
|
||||
recurrence_month_day: 15,
|
||||
due_date: getRandomDate(30)
|
||||
},
|
||||
|
||||
// Waiting status tasks (AI should suggest follow-up actions)
|
||||
{ name: 'Wait for contractor estimate', priority: 1, status: 4, project_id: projects[2].id },
|
||||
{ name: 'Wait for insurance approval', priority: 2, status: 4 },
|
||||
{ name: 'Wait for vendor response', priority: 1, status: 4, project_id: projects[0].id },
|
||||
{ name: 'Wait for medical test results', priority: 1, status: 4 },
|
||||
{ name: 'Wait for loan approval', priority: 2, status: 4 },
|
||||
|
||||
// Recently completed tasks for learning patterns
|
||||
{ name: 'Complete weekly workout goal', priority: 1, status: 2, completed_at: getPastDate(1), project_id: projects[3].id },
|
||||
{ name: 'Finish reading productivity book', priority: 0, status: 2, completed_at: getPastDate(2) },
|
||||
{ name: 'Complete online course module', priority: 1, status: 2, completed_at: getPastDate(1) },
|
||||
{ name: 'Submit weekly report', priority: 1, status: 2, completed_at: getPastDate(1), project_id: projects[0].id },
|
||||
{ name: 'Complete meal prep for week', priority: 1, status: 2, completed_at: getPastDate(1), project_id: projects[13].id },
|
||||
{ name: 'Finish monthly budget', priority: 1, status: 2, completed_at: getPastDate(3) },
|
||||
{ name: 'Complete photography assignment', priority: 1, status: 2, completed_at: getPastDate(2), project_id: projects[7].id },
|
||||
{ name: 'Finish home organization project', priority: 0, status: 2, completed_at: getPastDate(4) },
|
||||
{ name: 'Complete investment research', priority: 1, status: 2, completed_at: getPastDate(5), project_id: projects[5].id },
|
||||
{ name: 'Finish blog post draft', priority: 1, status: 2, completed_at: getPastDate(2), project_id: projects[10].id }
|
||||
];
|
||||
|
||||
// Combine all tasks
|
||||
return [...baseTaskData, ...massiveTasks, ...aiTriggerTasks];
|
||||
}
|
||||
|
||||
module.exports = { createMassiveTaskData };
|
||||
30
backend/server.log
Normal file
30
backend/server.log
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
|
||||
> backend@1.0.0 dev
|
||||
> nodemon app.js
|
||||
|
||||
[33m[nodemon] 3.1.10[39m
|
||||
[33m[nodemon] to restart at any time, enter `rs`[39m
|
||||
[33m[nodemon] watching path(s): *.*[39m
|
||||
[33m[nodemon] watching extensions: js,mjs,cjs,json[39m
|
||||
[32m[nodemon] starting `node app.js`[39m
|
||||
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)
|
||||
329
backend/services/taskEventService.js
Normal file
329
backend/services/taskEventService.js
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
const { TaskEvent } = require('../models');
|
||||
|
||||
class TaskEventService {
|
||||
/**
|
||||
* Log a task event
|
||||
* @param {Object} eventData - Event data
|
||||
* @param {number} eventData.taskId - Task ID
|
||||
* @param {number} eventData.userId - User ID
|
||||
* @param {string} eventData.eventType - Type of event
|
||||
* @param {string} eventData.fieldName - Field that changed (optional)
|
||||
* @param {any} eventData.oldValue - Old value (optional)
|
||||
* @param {any} eventData.newValue - New value (optional)
|
||||
* @param {Object} eventData.metadata - Additional metadata (optional)
|
||||
*/
|
||||
static async logEvent({ taskId, userId, eventType, fieldName = null, oldValue = null, newValue = null, metadata = {} }) {
|
||||
try {
|
||||
// Add source to metadata if not provided
|
||||
if (!metadata.source) {
|
||||
metadata.source = 'web';
|
||||
}
|
||||
|
||||
const event = await TaskEvent.create({
|
||||
task_id: taskId,
|
||||
user_id: userId,
|
||||
event_type: eventType,
|
||||
field_name: fieldName,
|
||||
old_value: oldValue ? { [fieldName || 'value']: oldValue } : null,
|
||||
new_value: newValue ? { [fieldName || 'value']: newValue } : null,
|
||||
metadata: metadata
|
||||
});
|
||||
|
||||
return event;
|
||||
} catch (error) {
|
||||
console.error('Error logging task event:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log task creation event
|
||||
*/
|
||||
static async logTaskCreated(taskId, userId, taskData, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'created',
|
||||
newValue: taskData,
|
||||
metadata: { ...metadata, action: 'task_created' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log status change event
|
||||
*/
|
||||
static async logStatusChange(taskId, userId, oldStatus, newStatus, metadata = {}) {
|
||||
const eventType = newStatus === 2 ? 'completed' :
|
||||
newStatus === 3 ? 'archived' :
|
||||
'status_changed';
|
||||
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType,
|
||||
fieldName: 'status',
|
||||
oldValue: oldStatus,
|
||||
newValue: newStatus,
|
||||
metadata: { ...metadata, action: 'status_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log priority change event
|
||||
*/
|
||||
static async logPriorityChange(taskId, userId, oldPriority, newPriority, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'priority_changed',
|
||||
fieldName: 'priority',
|
||||
oldValue: oldPriority,
|
||||
newValue: newPriority,
|
||||
metadata: { ...metadata, action: 'priority_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log due date change event
|
||||
*/
|
||||
static async logDueDateChange(taskId, userId, oldDueDate, newDueDate, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'due_date_changed',
|
||||
fieldName: 'due_date',
|
||||
oldValue: oldDueDate,
|
||||
newValue: newDueDate,
|
||||
metadata: { ...metadata, action: 'due_date_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log project change event
|
||||
*/
|
||||
static async logProjectChange(taskId, userId, oldProjectId, newProjectId, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'project_changed',
|
||||
fieldName: 'project_id',
|
||||
oldValue: oldProjectId,
|
||||
newValue: newProjectId,
|
||||
metadata: { ...metadata, action: 'project_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log task name change event
|
||||
*/
|
||||
static async logNameChange(taskId, userId, oldName, newName, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'name_changed',
|
||||
fieldName: 'name',
|
||||
oldValue: oldName,
|
||||
newValue: newName,
|
||||
metadata: { ...metadata, action: 'name_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log description change event
|
||||
*/
|
||||
static async logDescriptionChange(taskId, userId, oldDescription, newDescription, metadata = {}) {
|
||||
return await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType: 'description_changed',
|
||||
fieldName: 'description',
|
||||
oldValue: oldDescription,
|
||||
newValue: newDescription,
|
||||
metadata: { ...metadata, action: 'description_change' }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log multiple field changes at once
|
||||
*/
|
||||
static async logTaskUpdate(taskId, userId, changes, metadata = {}) {
|
||||
const events = [];
|
||||
|
||||
for (const [fieldName, { oldValue, newValue }] of Object.entries(changes)) {
|
||||
// Skip if values are the same
|
||||
if (oldValue === newValue) continue;
|
||||
|
||||
let eventType;
|
||||
switch (fieldName) {
|
||||
case 'status':
|
||||
eventType = newValue === 2 ? 'completed' :
|
||||
newValue === 3 ? 'archived' :
|
||||
'status_changed';
|
||||
break;
|
||||
default:
|
||||
eventType = `${fieldName}_changed`;
|
||||
}
|
||||
|
||||
const event = await this.logEvent({
|
||||
taskId,
|
||||
userId,
|
||||
eventType,
|
||||
fieldName,
|
||||
oldValue,
|
||||
newValue,
|
||||
metadata: { ...metadata, action: 'bulk_update' }
|
||||
});
|
||||
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task timeline (all events for a task)
|
||||
*/
|
||||
static async getTaskTimeline(taskId) {
|
||||
return await TaskEvent.findAll({
|
||||
where: { task_id: taskId },
|
||||
order: [['created_at', 'ASC']],
|
||||
include: [{
|
||||
model: require('../models').User,
|
||||
as: 'User',
|
||||
attributes: ['id', 'name', 'email']
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task completion metrics
|
||||
*/
|
||||
static async getTaskCompletionTime(taskId) {
|
||||
const events = await TaskEvent.findAll({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
event_type: ['status_changed', 'created', 'completed']
|
||||
},
|
||||
order: [['created_at', 'ASC']]
|
||||
});
|
||||
|
||||
if (events.length === 0) return null;
|
||||
|
||||
// Find when task was started (moved to in_progress or created)
|
||||
const startEvent = events.find(e =>
|
||||
e.event_type === 'created' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 1) // in_progress
|
||||
);
|
||||
|
||||
// Find when task was completed
|
||||
const completedEvent = events.find(e =>
|
||||
e.event_type === 'completed' ||
|
||||
(e.event_type === 'status_changed' && e.new_value?.status === 2) // done
|
||||
);
|
||||
|
||||
if (!startEvent || !completedEvent) return null;
|
||||
|
||||
const startTime = new Date(startEvent.created_at);
|
||||
const endTime = new Date(completedEvent.created_at);
|
||||
|
||||
return {
|
||||
task_id: taskId,
|
||||
started_at: startTime,
|
||||
completed_at: endTime,
|
||||
duration_ms: endTime - startTime,
|
||||
duration_hours: (endTime - startTime) / (1000 * 60 * 60),
|
||||
duration_days: (endTime - startTime) / (1000 * 60 * 60 * 24)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user productivity metrics
|
||||
*/
|
||||
static async getUserProductivityMetrics(userId, startDate = null, endDate = null) {
|
||||
const whereClause = { user_id: userId };
|
||||
|
||||
if (startDate && endDate) {
|
||||
whereClause.created_at = {
|
||||
[require('sequelize').Op.between]: [startDate, endDate]
|
||||
};
|
||||
}
|
||||
|
||||
const events = await TaskEvent.findAll({
|
||||
where: whereClause,
|
||||
order: [['created_at', 'ASC']]
|
||||
});
|
||||
|
||||
// Calculate metrics
|
||||
const metrics = {
|
||||
total_events: events.length,
|
||||
tasks_created: events.filter(e => e.event_type === 'created').length,
|
||||
tasks_completed: events.filter(e => e.event_type === 'completed').length,
|
||||
status_changes: events.filter(e => e.event_type === 'status_changed').length,
|
||||
average_completion_time: null,
|
||||
completion_times: []
|
||||
};
|
||||
|
||||
// Calculate completion times for all completed tasks
|
||||
const completedTasks = events.filter(e => e.event_type === 'completed');
|
||||
const completionTimes = [];
|
||||
|
||||
for (const completedEvent of completedTasks) {
|
||||
const taskCompletion = await this.getTaskCompletionTime(completedEvent.task_id);
|
||||
if (taskCompletion) {
|
||||
completionTimes.push(taskCompletion);
|
||||
}
|
||||
}
|
||||
|
||||
if (completionTimes.length > 0) {
|
||||
const totalHours = completionTimes.reduce((sum, ct) => sum + ct.duration_hours, 0);
|
||||
metrics.average_completion_time = totalHours / completionTimes.length;
|
||||
metrics.completion_times = completionTimes;
|
||||
}
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get task activity summary for a date range
|
||||
*/
|
||||
static async getTaskActivitySummary(userId, startDate, endDate) {
|
||||
const events = await TaskEvent.findAll({
|
||||
where: {
|
||||
user_id: userId,
|
||||
created_at: {
|
||||
[require('sequelize').Op.between]: [startDate, endDate]
|
||||
}
|
||||
},
|
||||
attributes: [
|
||||
'event_type',
|
||||
[require('sequelize').fn('COUNT', require('sequelize').col('id')), 'count'],
|
||||
[require('sequelize').fn('DATE', require('sequelize').col('created_at')), 'date']
|
||||
],
|
||||
group: ['event_type', 'date'],
|
||||
order: [['date', 'ASC']]
|
||||
});
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of how many times a task has been moved to today
|
||||
*/
|
||||
static async getTaskTodayMoveCount(taskId) {
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
const count = await TaskEvent.count({
|
||||
where: {
|
||||
task_id: taskId,
|
||||
event_type: 'today_changed',
|
||||
new_value: {
|
||||
[Op.like]: '%"today":true%'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskEventService;
|
||||
|
|
@ -2,8 +2,11 @@
|
|||
process.env.NODE_ENV = 'test';
|
||||
|
||||
const { sequelize } = require('../../models');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
beforeAll(async () => {
|
||||
// Ensure test database is clean and created
|
||||
await sequelize.sync({ force: true });
|
||||
}, 30000);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const { User } = require('../../models');
|
|||
const createTestUser = async (userData = {}) => {
|
||||
const defaultUser = {
|
||||
email: 'test@example.com',
|
||||
password_digest: await bcrypt.hash('password123', 10),
|
||||
password: 'password123', // Use password field to trigger model hook
|
||||
...userData
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -137,14 +137,16 @@ describe('URL Routes', () => {
|
|||
expect(response.body).toHaveProperty('title');
|
||||
}, 10000);
|
||||
|
||||
it('should return found false for URL without protocol', async () => {
|
||||
it('should detect URLs without protocol', async () => {
|
||||
const testText = 'Visit httpbin.org/html for testing';
|
||||
const response = await agent
|
||||
.post('/api/url/extract-from-text')
|
||||
.send({ text: testText });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.found).toBe(false);
|
||||
expect(response.body.found).toBe(true);
|
||||
expect(response.body.url).toBe('httpbin.org/html');
|
||||
expect(response.body.originalText).toBe(testText);
|
||||
});
|
||||
|
||||
it('should return found false when no URL in text', async () => {
|
||||
|
|
|
|||
325
backend/tests/integration/user-create-script.test.js
Normal file
325
backend/tests/integration/user-create-script.test.js
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
const { execSync, spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const { User } = require('../../models');
|
||||
|
||||
describe('User Create Script', () => {
|
||||
const scriptPath = path.join(__dirname, '../../scripts/user-create.js');
|
||||
|
||||
// Helper function to run the script and capture output
|
||||
const runUserCreateScript = (args = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('node', [scriptPath, ...args], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, NODE_ENV: 'test' }
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({
|
||||
code,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim()
|
||||
});
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up any test users created during tests
|
||||
await User.destroy({
|
||||
where: {
|
||||
email: ['testuser@example.com', 'admin@example.com', 'invalid-email', 'existing@example.com']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Success Cases', () => {
|
||||
it('should create a new user with valid email and password', async () => {
|
||||
const email = 'testuser@example.com';
|
||||
const password = 'securepassword123';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
expect(result.stdout).toContain(`📧 Email: ${email}`);
|
||||
expect(result.stdout).toContain('🆔 User ID:');
|
||||
expect(result.stdout).toContain('📅 Created:');
|
||||
|
||||
// Verify user was actually created in database
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
expect(createdUser.email).toBe(email);
|
||||
expect(createdUser.password_digest).toBeTruthy();
|
||||
expect(createdUser.password_digest).not.toBe(password); // Should be hashed
|
||||
});
|
||||
|
||||
it('should create user with minimum password length', async () => {
|
||||
const email = 'testuser2@example.com';
|
||||
const password = '123456'; // Exactly 6 characters
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Verify user was created
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
|
||||
it('should create user with complex email format', async () => {
|
||||
const email = 'user.name+tag@sub.domain.com';
|
||||
const password = 'password123';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Verify user was created
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Cases', () => {
|
||||
it('should show usage when no arguments provided', async () => {
|
||||
const result = await runUserCreateScript([]);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Usage: npm run user:create <email> <password>');
|
||||
expect(result.stderr).toContain('Example: npm run user:create admin@example.com mypassword123');
|
||||
});
|
||||
|
||||
it('should show usage when only email provided', async () => {
|
||||
const result = await runUserCreateScript(['test@example.com']);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Usage: npm run user:create <email> <password>');
|
||||
});
|
||||
|
||||
it('should show usage when only password provided', async () => {
|
||||
const result = await runUserCreateScript(['', 'password123']);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Usage: npm run user:create <email> <password>');
|
||||
});
|
||||
|
||||
it('should reject invalid email format', async () => {
|
||||
const invalidEmails = [
|
||||
'invalid-email',
|
||||
'missing@domain',
|
||||
'@missing-local.com',
|
||||
'spaces in@email.com',
|
||||
'double@@domain.com',
|
||||
'trailing.dot.@domain.com'
|
||||
];
|
||||
|
||||
for (const email of invalidEmails) {
|
||||
const result = await runUserCreateScript([email, 'password123']);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Invalid email format');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject password shorter than 6 characters', async () => {
|
||||
const shortPasswords = ['', '1', '12', '123', '1234', '12345'];
|
||||
|
||||
for (const password of shortPasswords) {
|
||||
const result = await runUserCreateScript(['test@example.com', password]);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('❌ Password must be at least 6 characters long');
|
||||
}
|
||||
});
|
||||
|
||||
it('should reject duplicate email', async () => {
|
||||
const email = 'existing@example.com';
|
||||
const password = 'password123';
|
||||
|
||||
// Create user first
|
||||
await User.create({
|
||||
email,
|
||||
password_digest: await require('bcrypt').hash(password, 10)
|
||||
});
|
||||
|
||||
// Try to create same user again
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain(`❌ User with email ${email} already exists`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with npm script', () => {
|
||||
it('should work when called via npm run command', async () => {
|
||||
const email = 'npmtest@example.com';
|
||||
const password = 'testpassword123';
|
||||
|
||||
try {
|
||||
// This simulates running: npm run user:create npmtest@example.com testpassword123
|
||||
const output = execSync(
|
||||
`npm run user:create ${email} ${password}`,
|
||||
{
|
||||
cwd: path.join(__dirname, '../..'),
|
||||
env: { ...process.env, NODE_ENV: 'test' },
|
||||
encoding: 'utf8',
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
expect(output).toContain('User created successfully');
|
||||
|
||||
// Verify user was created
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
expect(createdUser.email).toBe(email);
|
||||
|
||||
} catch (error) {
|
||||
// If the command failed, check if it's due to duplicate user (from previous test runs)
|
||||
if (error.stderr?.includes('already exists')) {
|
||||
// Clean up and retry
|
||||
await User.destroy({ where: { email } });
|
||||
|
||||
const output = execSync(
|
||||
`npm run user:create ${email} ${password}`,
|
||||
{
|
||||
cwd: path.join(__dirname, '../..'),
|
||||
env: { ...process.env, NODE_ENV: 'test' },
|
||||
encoding: 'utf8',
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
expect(output).toContain('User created successfully');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Database Validation', () => {
|
||||
it('should hash password properly', async () => {
|
||||
const email = 'hashtest@example.com';
|
||||
const password = 'plaintextpassword';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
// Password should be hashed (bcrypt hashes start with $2b$)
|
||||
expect(createdUser.password_digest).toMatch(/^\$2b\$10\$/);
|
||||
expect(createdUser.password_digest).not.toBe(password);
|
||||
expect(createdUser.password_digest.length).toBeGreaterThan(50);
|
||||
|
||||
// Verify the hash is valid
|
||||
const bcrypt = require('bcrypt');
|
||||
const isValid = await bcrypt.compare(password, createdUser.password_digest);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
|
||||
it('should set correct default values', async () => {
|
||||
const email = 'defaultstest@example.com';
|
||||
const password = 'password123';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
// Check that created_at and updated_at are set
|
||||
expect(createdUser.created_at).toBeTruthy();
|
||||
expect(createdUser.updated_at).toBeTruthy();
|
||||
|
||||
// Check that it's a valid date
|
||||
expect(createdUser.created_at instanceof Date).toBe(true);
|
||||
expect(createdUser.updated_at instanceof Date).toBe(true);
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle special characters in password', async () => {
|
||||
const email = 'specialchars@example.com';
|
||||
const password = 'p@ssw0rd!@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Verify user was created and password works
|
||||
const createdUser = await User.findOne({ where: { email } });
|
||||
expect(createdUser).toBeTruthy();
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const isValid = await bcrypt.compare(password, createdUser.password_digest);
|
||||
expect(isValid).toBe(true);
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
|
||||
it('should handle very long email', async () => {
|
||||
const longEmail = 'a'.repeat(50) + '@' + 'b'.repeat(50) + '.com';
|
||||
const password = 'password123';
|
||||
|
||||
const result = await runUserCreateScript([longEmail, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email: longEmail } });
|
||||
});
|
||||
|
||||
it('should handle very long password', async () => {
|
||||
const email = 'longpassword@example.com';
|
||||
const password = 'a'.repeat(200); // Very long password
|
||||
|
||||
const result = await runUserCreateScript([email, password]);
|
||||
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stdout).toContain('✅ User created successfully');
|
||||
|
||||
// Clean up
|
||||
await User.destroy({ where: { email } });
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
92
dist/frontend_components_Tasks_tsx.5d1f9b2b7404e2433dee.js
vendored
Normal file
92
dist/frontend_components_Tasks_tsx.5d1f9b2b7404e2433dee.js
vendored
Normal file
File diff suppressed because one or more lines are too long
2
dist/index.html
vendored
2
dist/index.html
vendored
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base href="/">
|
||||
<title>Tududi</title>
|
||||
<script defer src="/main.f7b757adb68235200251.js"></script></head>
|
||||
<script defer src="/main.272db06f0669b56179b9.js"></script></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
|
|
|
|||
15750
dist/main.272db06f0669b56179b9.js
vendored
Normal file
15750
dist/main.272db06f0669b56179b9.js
vendored
Normal file
File diff suppressed because one or more lines are too long
5436
dist/main.f7b757adb68235200251.js
vendored
5436
dist/main.f7b757adb68235200251.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -16,10 +16,12 @@ import TagDetails from "./components/Tag/TagDetails";
|
|||
import Tags from "./components/Tags";
|
||||
import Notes from "./components/Notes";
|
||||
import NoteDetails from "./components/Note/NoteDetails";
|
||||
import Calendar from "./components/Calendar";
|
||||
import ProfileSettings from "./components/Profile/ProfileSettings";
|
||||
import Layout from "./Layout";
|
||||
import { User } from "./entities/User";
|
||||
import TasksToday from "./components/Task/TasksToday";
|
||||
import TaskView from "./components/Task/TaskView";
|
||||
import LoadingScreen from "./components/Shared/LoadingScreen";
|
||||
import InboxItems from "./components/Inbox/InboxItems";
|
||||
// Lazy load Tasks component to prevent issues with tags loading
|
||||
|
|
@ -157,6 +159,7 @@ const App: React.FC = () => {
|
|||
>
|
||||
<Route index element={<Navigate to="/today" replace />} />
|
||||
<Route path="/today" element={<TasksToday />} />
|
||||
<Route path="/task/:uuid" element={<TaskView />} />
|
||||
<Route
|
||||
path="/tasks"
|
||||
element={
|
||||
|
|
@ -174,6 +177,7 @@ const App: React.FC = () => {
|
|||
<Route path="/tag/:id" element={<TagDetails />} />
|
||||
<Route path="/notes" element={<Notes />} />
|
||||
<Route path="/note/:id" element={<NoteDetails />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/profile" element={<ProfileSettings currentUser={currentUser} isDarkMode={isDarkMode} toggleDarkMode={toggleDarkMode} />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { useToast } from "./components/Shared/ToastContext";
|
||||
import Navbar from "./components/Navbar";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import "./styles/tailwind.css";
|
||||
|
|
@ -39,6 +41,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast } = useToast();
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(window.innerWidth >= 1024);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
|
|
@ -203,8 +206,20 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
try {
|
||||
if (taskData.id) {
|
||||
await updateTask(taskData.id, taskData);
|
||||
const taskLink = (
|
||||
<span>
|
||||
{t('task.updated', 'Task')} <a href="/tasks" className="text-green-200 underline hover:text-green-100">{taskData.name}</a> {t('task.updatedSuccessfully', 'updated successfully!')}
|
||||
</span>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
} else {
|
||||
await createTask(taskData);
|
||||
const createdTask = await createTask(taskData);
|
||||
const taskLink = (
|
||||
<span>
|
||||
{t('task.created', 'Task')} <a href="/tasks" className="text-green-200 underline hover:text-green-100">{createdTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')}
|
||||
</span>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
}
|
||||
// Don't refetch all tasks here - let individual components handle their own state
|
||||
// This prevents unnecessary re-renders and race conditions
|
||||
|
|
@ -440,19 +455,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
aria-label="Quick Capture"
|
||||
title={t('inbox.captureThought')}
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
<PlusIcon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{isTaskModalOpen && (
|
||||
|
|
@ -471,7 +474,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
status: "not_started",
|
||||
}}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={() => {}}
|
||||
onDelete={async () => {}}
|
||||
projects={projects}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import Login from '../../components/Login';
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const renderLogin = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Login Component', () => {
|
||||
beforeEach(() => {
|
||||
(fetch as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
it('renders login form', () => {
|
||||
renderLogin();
|
||||
|
||||
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles form submission with valid credentials', async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, user: { email: 'test@example.com' } })
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
await act(async () => {
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
|
||||
await user.type(screen.getByLabelText(/password/i), 'password123');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await user.click(screen.getByRole('button', { name: /login/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetch).toHaveBeenCalledWith('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('displays error message on failed login', async () => {
|
||||
const user = userEvent.setup();
|
||||
(fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Invalid credentials' })
|
||||
});
|
||||
|
||||
renderLogin();
|
||||
|
||||
await act(async () => {
|
||||
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
|
||||
await user.type(screen.getByLabelText(/password/i), 'wrongpassword');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await user.click(screen.getByRole('button', { name: /login/i }));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/an error occurred/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('validates required fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLogin();
|
||||
|
||||
await act(async () => {
|
||||
await user.click(screen.getByRole('button', { name: /login/i }));
|
||||
});
|
||||
|
||||
// Should not make API call with empty fields
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import Navbar from '../../components/Navbar';
|
||||
|
||||
const mockUser: {
|
||||
email: string;
|
||||
avatarUrl?: string;
|
||||
} = {
|
||||
email: 'test@example.com',
|
||||
avatarUrl: 'https://example.com/avatar.jpg'
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
isDarkMode: false,
|
||||
toggleDarkMode: jest.fn(),
|
||||
currentUser: mockUser,
|
||||
setCurrentUser: jest.fn(),
|
||||
isSidebarOpen: false,
|
||||
setIsSidebarOpen: jest.fn()
|
||||
};
|
||||
|
||||
const renderNavbar = (props = defaultProps) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<Navbar {...props} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
// Mock logout API call
|
||||
global.fetch = jest.fn();
|
||||
|
||||
describe('Navbar Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true })
|
||||
});
|
||||
});
|
||||
|
||||
it('renders navbar with user email', () => {
|
||||
renderNavbar();
|
||||
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders dark mode toggle button', () => {
|
||||
renderNavbar();
|
||||
|
||||
const darkModeButton = screen.getByRole('button', { name: /toggle dark mode/i });
|
||||
expect(darkModeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls toggleDarkMode when dark mode button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockToggleDarkMode = jest.fn();
|
||||
|
||||
renderNavbar({
|
||||
...defaultProps,
|
||||
toggleDarkMode: mockToggleDarkMode
|
||||
});
|
||||
|
||||
const darkModeButton = screen.getByRole('button', { name: /toggle dark mode/i });
|
||||
|
||||
await act(async () => {
|
||||
await user.click(darkModeButton);
|
||||
});
|
||||
|
||||
expect(mockToggleDarkMode).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders sidebar toggle button', () => {
|
||||
renderNavbar();
|
||||
|
||||
const sidebarButton = screen.getByRole('button', { name: /toggle sidebar/i });
|
||||
expect(sidebarButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls setIsSidebarOpen when sidebar button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSetIsSidebarOpen = jest.fn();
|
||||
|
||||
renderNavbar({
|
||||
...defaultProps,
|
||||
setIsSidebarOpen: mockSetIsSidebarOpen
|
||||
});
|
||||
|
||||
const sidebarButton = screen.getByRole('button', { name: /toggle sidebar/i });
|
||||
|
||||
await act(async () => {
|
||||
await user.click(sidebarButton);
|
||||
});
|
||||
|
||||
expect(mockSetIsSidebarOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('opens user dropdown when user icon is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderNavbar();
|
||||
|
||||
const userButton = screen.getByRole('button', { name: /user menu/i });
|
||||
|
||||
await act(async () => {
|
||||
await user.click(userButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/profile/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/logout/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('closes dropdown when clicking outside', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderNavbar();
|
||||
|
||||
const userButton = screen.getByRole('button', { name: /user menu/i });
|
||||
|
||||
await act(async () => {
|
||||
await user.click(userButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/logout/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click outside the dropdown
|
||||
await act(async () => {
|
||||
await user.click(document.body);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/logout/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles logout when logout button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockSetCurrentUser = jest.fn();
|
||||
|
||||
renderNavbar({
|
||||
...defaultProps,
|
||||
setCurrentUser: mockSetCurrentUser
|
||||
});
|
||||
|
||||
const userButton = screen.getByRole('button', { name: /user menu/i });
|
||||
|
||||
await act(async () => {
|
||||
await user.click(userButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/logout/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const logoutButton = screen.getByText(/logout/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(logoutButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('renders tududi brand link', () => {
|
||||
renderNavbar();
|
||||
|
||||
const brandLink = screen.getByRole('link', { name: /tududi/i });
|
||||
expect(brandLink).toBeInTheDocument();
|
||||
expect(brandLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('displays correct dark mode icon based on current mode', () => {
|
||||
// Test light mode (should show moon icon)
|
||||
const { rerender } = renderNavbar({
|
||||
...defaultProps,
|
||||
isDarkMode: false
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: /toggle dark mode/i })).toBeInTheDocument();
|
||||
|
||||
// Test dark mode (should show sun icon)
|
||||
rerender(
|
||||
<BrowserRouter>
|
||||
<Navbar {...defaultProps} isDarkMode={true} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /toggle dark mode/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles user without avatar', () => {
|
||||
const userWithoutAvatar = {
|
||||
email: 'test@example.com',
|
||||
avatarUrl: undefined
|
||||
};
|
||||
|
||||
renderNavbar({
|
||||
...defaultProps,
|
||||
currentUser: userWithoutAvatar
|
||||
});
|
||||
|
||||
expect(screen.getByText('test@example.com')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows sidebar as open when isSidebarOpen is true', () => {
|
||||
renderNavbar({
|
||||
...defaultProps,
|
||||
isSidebarOpen: true
|
||||
});
|
||||
|
||||
const sidebarButton = screen.getByRole('button', { name: /toggle sidebar/i });
|
||||
expect(sidebarButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles logout error gracefully', async () => {
|
||||
const user = userEvent.setup();
|
||||
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
renderNavbar();
|
||||
|
||||
const userButton = screen.getByRole('button', { name: /user menu/i });
|
||||
|
||||
await act(async () => {
|
||||
await user.click(userButton);
|
||||
});
|
||||
|
||||
const logoutButton = screen.getByText(/logout/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(logoutButton);
|
||||
});
|
||||
|
||||
// Should still attempt logout despite error
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('navigates to profile when profile link is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderNavbar();
|
||||
|
||||
const userButton = screen.getByRole('button', { name: /user menu/i });
|
||||
|
||||
await act(async () => {
|
||||
await user.click(userButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/profile/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const profileLink = screen.getByText(/profile/i);
|
||||
expect(profileLink.closest('a')).toHaveAttribute('href', '/profile');
|
||||
});
|
||||
|
||||
it('maintains dropdown state correctly', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderNavbar();
|
||||
|
||||
const userButton = screen.getByRole('button', { name: /user menu/i });
|
||||
|
||||
// Open dropdown
|
||||
await act(async () => {
|
||||
await user.click(userButton);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/logout/i)).toBeInTheDocument();
|
||||
|
||||
// Close dropdown by clicking button again
|
||||
await act(async () => {
|
||||
await user.click(userButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/logout/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,165 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import Projects from '../../components/Projects';
|
||||
import { Project } from '../../entities/Project';
|
||||
|
||||
// Mock the store
|
||||
const mockStore = {
|
||||
areasStore: {
|
||||
areas: [],
|
||||
setAreas: jest.fn(),
|
||||
setLoading: jest.fn(),
|
||||
setError: jest.fn()
|
||||
},
|
||||
projectsStore: {
|
||||
projects: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Project 1',
|
||||
description: 'First test project',
|
||||
active: true
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Test Project 2',
|
||||
description: 'Second test project',
|
||||
active: true
|
||||
}
|
||||
],
|
||||
setProjects: jest.fn(),
|
||||
setLoading: jest.fn(),
|
||||
setError: jest.fn(),
|
||||
isLoading: false,
|
||||
isError: false
|
||||
}
|
||||
};
|
||||
|
||||
jest.mock('../../store/useStore', () => ({
|
||||
useStore: (selector: any) => selector(mockStore)
|
||||
}));
|
||||
|
||||
// Mock services
|
||||
jest.mock('../../utils/projectsService', () => ({
|
||||
fetchProjects: jest.fn(),
|
||||
createProject: jest.fn(),
|
||||
updateProject: jest.fn(),
|
||||
deleteProject: jest.fn()
|
||||
}));
|
||||
|
||||
jest.mock('../../utils/areasService', () => ({
|
||||
fetchAreas: jest.fn()
|
||||
}));
|
||||
|
||||
const renderProjects = () => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<Projects />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Projects Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders projects list', () => {
|
||||
renderProjects();
|
||||
|
||||
expect(screen.getByText('Test Project 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Test Project 2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders component without crashing', () => {
|
||||
expect(() => renderProjects()).not.toThrow();
|
||||
});
|
||||
|
||||
it('displays projects with their data', () => {
|
||||
const { container } = renderProjects();
|
||||
|
||||
// Check that component renders with project data
|
||||
expect(container.querySelector('[class*="project"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows projects container', () => {
|
||||
const { container } = renderProjects();
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles empty projects array', () => {
|
||||
const emptyMockStore = {
|
||||
...mockStore,
|
||||
projectsStore: {
|
||||
...mockStore.projectsStore,
|
||||
projects: []
|
||||
}
|
||||
};
|
||||
|
||||
jest.doMock('../../store/useStore', () => ({
|
||||
useStore: (selector: any) => selector(emptyMockStore)
|
||||
}));
|
||||
|
||||
expect(() => renderProjects()).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles loading state', () => {
|
||||
const loadingMockStore = {
|
||||
...mockStore,
|
||||
projectsStore: {
|
||||
...mockStore.projectsStore,
|
||||
isLoading: true
|
||||
}
|
||||
};
|
||||
|
||||
jest.doMock('../../store/useStore', () => ({
|
||||
useStore: (selector: any) => selector(loadingMockStore)
|
||||
}));
|
||||
|
||||
expect(() => renderProjects()).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles error state', () => {
|
||||
const errorMockStore = {
|
||||
...mockStore,
|
||||
projectsStore: {
|
||||
...mockStore.projectsStore,
|
||||
isError: true
|
||||
}
|
||||
};
|
||||
|
||||
jest.doMock('../../store/useStore', () => ({
|
||||
useStore: (selector: any) => selector(errorMockStore)
|
||||
}));
|
||||
|
||||
expect(() => renderProjects()).not.toThrow();
|
||||
});
|
||||
|
||||
it('renders project names correctly', () => {
|
||||
renderProjects();
|
||||
|
||||
// Check that project names are displayed
|
||||
const project1 = screen.getByText('Test Project 1');
|
||||
const project2 = screen.getByText('Test Project 2');
|
||||
|
||||
expect(project1).toBeInTheDocument();
|
||||
expect(project2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays multiple projects', () => {
|
||||
renderProjects();
|
||||
|
||||
const projectNames = ['Test Project 1', 'Test Project 2'];
|
||||
projectNames.forEach(name => {
|
||||
expect(screen.getByText(name)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('maintains component structure', () => {
|
||||
const { container } = renderProjects();
|
||||
|
||||
// Should have a root container
|
||||
expect(container.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import TaskItem from '../../components/Task/TaskItem';
|
||||
import { Task } from '../../entities/Task';
|
||||
|
||||
const mockTask: Task = {
|
||||
id: 1,
|
||||
name: 'Test Task',
|
||||
status: 'not_started',
|
||||
priority: 'medium',
|
||||
due_date: '2024-12-25',
|
||||
note: 'Test note',
|
||||
created_at: '2024-01-01T00:00:00.000Z'
|
||||
};
|
||||
|
||||
const mockProps = {
|
||||
task: mockTask,
|
||||
onTaskUpdate: jest.fn(),
|
||||
onTaskDelete: jest.fn(),
|
||||
projects: []
|
||||
};
|
||||
|
||||
const renderTaskItem = (props = mockProps) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<TaskItem {...props} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('TaskItem Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders task information correctly', () => {
|
||||
renderTaskItem();
|
||||
|
||||
expect(screen.getAllByText('Test Task')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders without crashing', () => {
|
||||
expect(() => renderTaskItem()).not.toThrow();
|
||||
});
|
||||
|
||||
it('displays task with correct props structure', () => {
|
||||
const { container } = renderTaskItem();
|
||||
expect(container.firstChild).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import TaskList from '../../components/Task/TaskList';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'First Task',
|
||||
status: 'not_started',
|
||||
priority: 'high',
|
||||
created_at: '2024-01-01T00:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Second Task',
|
||||
status: 'in_progress',
|
||||
priority: 'medium',
|
||||
due_date: '2024-12-25',
|
||||
created_at: '2024-01-01T00:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Third Task',
|
||||
status: 'done',
|
||||
priority: 'low',
|
||||
note: 'Completed task',
|
||||
created_at: '2024-01-01T00:00:00.000Z'
|
||||
}
|
||||
];
|
||||
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Project',
|
||||
active: true
|
||||
}
|
||||
];
|
||||
|
||||
const mockProps = {
|
||||
tasks: mockTasks,
|
||||
onTaskUpdate: jest.fn(),
|
||||
onTaskDelete: jest.fn(),
|
||||
projects: mockProjects
|
||||
};
|
||||
|
||||
const renderTaskList = (props = mockProps) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<TaskList {...props} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('TaskList Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders all tasks when tasks are provided', () => {
|
||||
renderTaskList();
|
||||
|
||||
expect(screen.getAllByText(/First Task|Second Task|Third Task/)).toHaveLength(6); // Each task appears twice in TaskItem
|
||||
});
|
||||
|
||||
it('renders individual task items with correct props', () => {
|
||||
renderTaskList();
|
||||
|
||||
// Check that all task names are rendered
|
||||
expect(screen.getAllByText('First Task')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Second Task')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Third Task')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('displays empty state when no tasks are provided', () => {
|
||||
renderTaskList({
|
||||
...mockProps,
|
||||
tasks: []
|
||||
});
|
||||
|
||||
expect(screen.getByText('No tasks available.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with different task statuses', () => {
|
||||
renderTaskList();
|
||||
|
||||
// Tasks with different statuses should all be rendered
|
||||
const taskElements = screen.getAllByText(/First Task|Second Task|Third Task/);
|
||||
expect(taskElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('passes correct props to TaskItem components', () => {
|
||||
const customProps = {
|
||||
tasks: [mockTasks[0]],
|
||||
onTaskUpdate: jest.fn(),
|
||||
onTaskDelete: jest.fn(),
|
||||
projects: mockProjects
|
||||
};
|
||||
|
||||
renderTaskList(customProps);
|
||||
|
||||
// Should render the task
|
||||
expect(screen.getAllByText('First Task')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('handles tasks with different priorities', () => {
|
||||
const tasksWithDifferentPriorities: Task[] = [
|
||||
{ ...mockTasks[0], priority: 'high' },
|
||||
{ ...mockTasks[1], priority: 'medium' },
|
||||
{ ...mockTasks[2], priority: 'low' }
|
||||
];
|
||||
|
||||
renderTaskList({
|
||||
...mockProps,
|
||||
tasks: tasksWithDifferentPriorities
|
||||
});
|
||||
|
||||
// All tasks should be rendered regardless of priority
|
||||
expect(screen.getAllByText(/First Task|Second Task|Third Task/)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('handles tasks with and without due dates', () => {
|
||||
const tasksWithMixedDueDates: Task[] = [
|
||||
{ ...mockTasks[0] }, // No due date
|
||||
{ ...mockTasks[1], due_date: '2024-12-25' }, // With due date
|
||||
{ ...mockTasks[2], due_date: undefined } // Explicitly no due date
|
||||
];
|
||||
|
||||
renderTaskList({
|
||||
...mockProps,
|
||||
tasks: tasksWithMixedDueDates
|
||||
});
|
||||
|
||||
// All tasks should render
|
||||
expect(screen.getAllByText(/First Task|Second Task|Third Task/)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('renders without crashing when projects array is empty', () => {
|
||||
renderTaskList({
|
||||
...mockProps,
|
||||
projects: []
|
||||
});
|
||||
|
||||
expect(screen.getAllByText(/First Task|Second Task|Third Task/)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('maintains component structure with single task', () => {
|
||||
renderTaskList({
|
||||
...mockProps,
|
||||
tasks: [mockTasks[0]]
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('First Task')).toHaveLength(2);
|
||||
expect(screen.queryByText('No tasks available.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders correctly with large number of tasks', () => {
|
||||
const manyTasks: Task[] = Array.from({ length: 50 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
name: `Task ${index + 1}`,
|
||||
status: 'not_started' as const,
|
||||
priority: 'medium' as const,
|
||||
created_at: '2024-01-01T00:00:00.000Z'
|
||||
}));
|
||||
|
||||
renderTaskList({
|
||||
...mockProps,
|
||||
tasks: manyTasks
|
||||
});
|
||||
|
||||
// Should render all tasks
|
||||
expect(screen.getAllByText(/Task \d+/)).toHaveLength(100); // Each task appears twice
|
||||
});
|
||||
});
|
||||
|
|
@ -1,289 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import TaskModal from '../../components/Task/TaskModal';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
|
||||
const mockTask: Task = {
|
||||
id: 1,
|
||||
name: 'Test Task',
|
||||
status: 'not_started',
|
||||
priority: 'medium',
|
||||
due_date: '2024-12-25',
|
||||
note: 'Test note',
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
tags: [
|
||||
{ id: 1, name: 'work' },
|
||||
{ id: 2, name: 'urgent' }
|
||||
]
|
||||
};
|
||||
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Project',
|
||||
active: true
|
||||
}
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: jest.fn(),
|
||||
task: mockTask,
|
||||
onSave: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
projects: mockProjects,
|
||||
onCreateProject: jest.fn()
|
||||
};
|
||||
|
||||
// Mock fetch for tags service
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const renderTaskModal = (props = defaultProps) => {
|
||||
return render(
|
||||
<BrowserRouter>
|
||||
<TaskModal {...props} />
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('TaskModal Component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tags: [] })
|
||||
});
|
||||
});
|
||||
|
||||
it('renders modal when open', () => {
|
||||
renderTaskModal();
|
||||
|
||||
expect(screen.getByDisplayValue('Test Task')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Test note')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
renderTaskModal({
|
||||
...defaultProps,
|
||||
isOpen: false
|
||||
});
|
||||
|
||||
expect(screen.queryByDisplayValue('Test Task')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays task information correctly', () => {
|
||||
renderTaskModal();
|
||||
|
||||
expect(screen.getByDisplayValue('Test Task')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Test note')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('2024-12-25')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows editing task name', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTaskModal();
|
||||
|
||||
const nameInput = screen.getByDisplayValue('Test Task');
|
||||
|
||||
await act(async () => {
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Updated Task Name');
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Updated Task Name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows editing task note', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTaskModal();
|
||||
|
||||
const noteInput = screen.getByDisplayValue('Test note');
|
||||
|
||||
await act(async () => {
|
||||
await user.clear(noteInput);
|
||||
await user.type(noteInput, 'Updated note content');
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Updated note content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows editing due date', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTaskModal();
|
||||
|
||||
const dueDateInput = screen.getByDisplayValue('2024-12-25');
|
||||
|
||||
await act(async () => {
|
||||
await user.clear(dueDateInput);
|
||||
await user.type(dueDateInput, '2024-12-31');
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('2024-12-31')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSave when save button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnSave = jest.fn();
|
||||
|
||||
renderTaskModal({
|
||||
...defaultProps,
|
||||
onSave: mockOnSave
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText(/save/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(saveButton);
|
||||
});
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onClose when cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
renderTaskModal({
|
||||
...defaultProps,
|
||||
onClose: mockOnClose
|
||||
});
|
||||
|
||||
const cancelButton = screen.getByText(/cancel/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(cancelButton);
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows delete confirmation dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTaskModal();
|
||||
|
||||
const deleteButton = screen.getByText(/delete/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(deleteButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onDelete when delete is confirmed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnDelete = jest.fn();
|
||||
|
||||
renderTaskModal({
|
||||
...defaultProps,
|
||||
onDelete: mockOnDelete
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByText(/delete/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(deleteButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText(/confirm/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(confirmButton);
|
||||
});
|
||||
|
||||
expect(mockOnDelete).toHaveBeenCalledWith(mockTask.id);
|
||||
});
|
||||
|
||||
it('handles task without tags', () => {
|
||||
const taskWithoutTags = {
|
||||
...mockTask,
|
||||
tags: undefined
|
||||
};
|
||||
|
||||
renderTaskModal({
|
||||
...defaultProps,
|
||||
task: taskWithoutTags
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Test Task')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles task without due date', () => {
|
||||
const taskWithoutDueDate = {
|
||||
...mockTask,
|
||||
due_date: undefined
|
||||
};
|
||||
|
||||
renderTaskModal({
|
||||
...defaultProps,
|
||||
task: taskWithoutDueDate
|
||||
});
|
||||
|
||||
expect(screen.getByDisplayValue('Test Task')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders priority dropdown', () => {
|
||||
renderTaskModal();
|
||||
|
||||
// Priority dropdown should be rendered (medium priority)
|
||||
expect(screen.getByText(/priority/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders status dropdown', () => {
|
||||
renderTaskModal();
|
||||
|
||||
// Status dropdown should be rendered (not_started status)
|
||||
expect(screen.getByText(/status/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('validates required fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnSave = jest.fn();
|
||||
|
||||
renderTaskModal({
|
||||
...defaultProps,
|
||||
onSave: mockOnSave
|
||||
});
|
||||
|
||||
const nameInput = screen.getByDisplayValue('Test Task');
|
||||
|
||||
await act(async () => {
|
||||
await user.clear(nameInput);
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText(/save/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(saveButton);
|
||||
});
|
||||
|
||||
// Should not call onSave with empty name
|
||||
expect(mockOnSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles keyboard navigation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnClose = jest.fn();
|
||||
|
||||
renderTaskModal({
|
||||
...defaultProps,
|
||||
onClose: mockOnClose
|
||||
});
|
||||
|
||||
// Test Escape key
|
||||
await act(async () => {
|
||||
await user.keyboard('{Escape}');
|
||||
});
|
||||
|
||||
expect(mockOnClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,413 +0,0 @@
|
|||
import React from 'react';
|
||||
import { render, screen, waitFor, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import TaskList from '../../components/Task/TaskList';
|
||||
import TaskModal from '../../components/Task/TaskModal';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
|
||||
// Mock data
|
||||
const mockTasks: Task[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Complete project setup',
|
||||
status: 'not_started',
|
||||
priority: 'high',
|
||||
created_at: '2024-01-01T00:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Write documentation',
|
||||
status: 'in_progress',
|
||||
priority: 'medium',
|
||||
due_date: '2024-12-25',
|
||||
created_at: '2024-01-02T00:00:00.000Z'
|
||||
}
|
||||
];
|
||||
|
||||
const mockProjects: Project[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Test Project',
|
||||
active: true
|
||||
}
|
||||
];
|
||||
|
||||
// Integration test wrapper component
|
||||
const TaskWorkflowWrapper: React.FC<{
|
||||
tasks: Task[];
|
||||
onTaskUpdate: (task: Task) => void;
|
||||
onTaskDelete: (taskId: number) => void;
|
||||
}> = ({ tasks, onTaskUpdate, onTaskDelete }) => {
|
||||
const [isModalOpen, setIsModalOpen] = React.useState(false);
|
||||
const [selectedTask, setSelectedTask] = React.useState<Task | null>(null);
|
||||
|
||||
const handleTaskClick = (task: Task) => {
|
||||
setSelectedTask(task);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalSave = (updatedTask: Task) => {
|
||||
onTaskUpdate(updatedTask);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const handleModalDelete = (taskId: number) => {
|
||||
onTaskDelete(taskId);
|
||||
setIsModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const CustomTaskList: React.FC<any> = (props) => (
|
||||
<div>
|
||||
{props.tasks.map((task: Task) => (
|
||||
<div key={task.id} onClick={() => handleTaskClick(task)} style={{ cursor: 'pointer', padding: '10px', border: '1px solid #ccc', margin: '5px' }}>
|
||||
<div>{task.name}</div>
|
||||
<div>Status: {task.status}</div>
|
||||
<div>Priority: {task.priority}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div>
|
||||
<CustomTaskList {...{ tasks, onTaskUpdate, onTaskDelete }} />
|
||||
{isModalOpen && selectedTask && (
|
||||
<TaskModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleModalClose}
|
||||
task={selectedTask}
|
||||
onSave={handleModalSave}
|
||||
onDelete={handleModalDelete}
|
||||
projects={mockProjects}
|
||||
onCreateProject={async (name: string) => ({ id: 999, name, active: true })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Task Workflows Integration Tests', () => {
|
||||
let mockOnTaskUpdate: jest.Mock;
|
||||
let mockOnTaskDelete: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockOnTaskUpdate = jest.fn();
|
||||
mockOnTaskDelete = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock fetch for tags service
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ tags: [] })
|
||||
});
|
||||
});
|
||||
|
||||
it('completes full task lifecycle workflow', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TaskWorkflowWrapper
|
||||
tasks={mockTasks}
|
||||
onTaskUpdate={mockOnTaskUpdate}
|
||||
onTaskDelete={mockOnTaskDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
// 1. View tasks in list
|
||||
expect(screen.getByText('Complete project setup')).toBeInTheDocument();
|
||||
expect(screen.getByText('Write documentation')).toBeInTheDocument();
|
||||
|
||||
// 2. Click on a task to open modal
|
||||
const firstTask = screen.getByText('Complete project setup');
|
||||
|
||||
await act(async () => {
|
||||
await user.click(firstTask);
|
||||
});
|
||||
|
||||
// 3. Modal should open with task details
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Complete project setup')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 4. Edit task name
|
||||
const nameInput = screen.getByDisplayValue('Complete project setup');
|
||||
|
||||
await act(async () => {
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Updated project setup');
|
||||
});
|
||||
|
||||
// 5. Save changes
|
||||
const saveButton = screen.getByText(/save/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(saveButton);
|
||||
});
|
||||
|
||||
// 6. Verify onTaskUpdate was called
|
||||
expect(mockOnTaskUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
name: 'Updated project setup'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles task deletion workflow', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TaskWorkflowWrapper
|
||||
tasks={mockTasks}
|
||||
onTaskUpdate={mockOnTaskUpdate}
|
||||
onTaskDelete={mockOnTaskDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
// 1. Click on a task to open modal
|
||||
const firstTask = screen.getByText('Complete project setup');
|
||||
|
||||
await act(async () => {
|
||||
await user.click(firstTask);
|
||||
});
|
||||
|
||||
// 2. Click delete button
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/delete/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const deleteButton = screen.getByText(/delete/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(deleteButton);
|
||||
});
|
||||
|
||||
// 3. Confirm deletion
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/are you sure/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const confirmButton = screen.getByText(/confirm/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(confirmButton);
|
||||
});
|
||||
|
||||
// 4. Verify onTaskDelete was called
|
||||
expect(mockOnTaskDelete).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('handles task status and priority updates', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TaskWorkflowWrapper
|
||||
tasks={mockTasks}
|
||||
onTaskUpdate={mockOnTaskUpdate}
|
||||
onTaskDelete={mockOnTaskDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open task modal
|
||||
const secondTask = screen.getByText('Write documentation');
|
||||
|
||||
await act(async () => {
|
||||
await user.click(secondTask);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Write documentation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change status and priority using dropdowns
|
||||
const statusDropdown = screen.getByText(/status/i);
|
||||
expect(statusDropdown).toBeInTheDocument();
|
||||
|
||||
const priorityDropdown = screen.getByText(/priority/i);
|
||||
expect(priorityDropdown).toBeInTheDocument();
|
||||
|
||||
// Save changes
|
||||
const saveButton = screen.getByText(/save/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(saveButton);
|
||||
});
|
||||
|
||||
// Verify update was called
|
||||
expect(mockOnTaskUpdate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles due date modifications', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TaskWorkflowWrapper
|
||||
tasks={mockTasks}
|
||||
onTaskUpdate={mockOnTaskUpdate}
|
||||
onTaskDelete={mockOnTaskDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open task with due date
|
||||
const taskWithDueDate = screen.getByText('Write documentation');
|
||||
|
||||
await act(async () => {
|
||||
await user.click(taskWithDueDate);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('2024-12-25')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Update due date
|
||||
const dueDateInput = screen.getByDisplayValue('2024-12-25');
|
||||
|
||||
await act(async () => {
|
||||
await user.clear(dueDateInput);
|
||||
await user.type(dueDateInput, '2024-12-31');
|
||||
});
|
||||
|
||||
// Save changes
|
||||
const saveButton = screen.getByText(/save/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(saveButton);
|
||||
});
|
||||
|
||||
// Verify update with new due date
|
||||
expect(mockOnTaskUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 2,
|
||||
due_date: '2024-12-31'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('handles modal cancellation without saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TaskWorkflowWrapper
|
||||
tasks={mockTasks}
|
||||
onTaskUpdate={mockOnTaskUpdate}
|
||||
onTaskDelete={mockOnTaskDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open task modal
|
||||
const firstTask = screen.getByText('Complete project setup');
|
||||
|
||||
await act(async () => {
|
||||
await user.click(firstTask);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Complete project setup')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Make changes
|
||||
const nameInput = screen.getByDisplayValue('Complete project setup');
|
||||
|
||||
await act(async () => {
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, 'Changed but not saved');
|
||||
});
|
||||
|
||||
// Cancel instead of saving
|
||||
const cancelButton = screen.getByText(/cancel/i);
|
||||
|
||||
await act(async () => {
|
||||
await user.click(cancelButton);
|
||||
});
|
||||
|
||||
// Verify no update was called
|
||||
expect(mockOnTaskUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles empty task list state', () => {
|
||||
render(
|
||||
<TaskWorkflowWrapper
|
||||
tasks={[]}
|
||||
onTaskUpdate={mockOnTaskUpdate}
|
||||
onTaskDelete={mockOnTaskDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
// Should render empty container
|
||||
expect(screen.queryByText('Complete project setup')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Write documentation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('maintains task selection state during editing', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TaskWorkflowWrapper
|
||||
tasks={mockTasks}
|
||||
onTaskUpdate={mockOnTaskUpdate}
|
||||
onTaskDelete={mockOnTaskDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
// Open first task
|
||||
const firstTask = screen.getByText('Complete project setup');
|
||||
|
||||
await act(async () => {
|
||||
await user.click(firstTask);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Complete project setup')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify correct task is loaded
|
||||
expect(screen.getByDisplayValue('Complete project setup')).toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('Write documentation')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles multiple rapid task interactions', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<TaskWorkflowWrapper
|
||||
tasks={mockTasks}
|
||||
onTaskUpdate={mockOnTaskUpdate}
|
||||
onTaskDelete={mockOnTaskDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
// Rapidly interact with tasks
|
||||
const firstTask = screen.getByText('Complete project setup');
|
||||
const secondTask = screen.getByText('Write documentation');
|
||||
|
||||
// Click first task multiple times
|
||||
await act(async () => {
|
||||
await user.click(firstTask);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByDisplayValue('Complete project setup')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Close modal
|
||||
const cancelButton = screen.getByText(/cancel/i);
|
||||
await act(async () => {
|
||||
await user.click(cancelButton);
|
||||
});
|
||||
|
||||
// Should close modal properly
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByDisplayValue('Complete project setup')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import '@testing-library/jest-dom';
|
||||
|
||||
// Mock SWR to avoid network requests in tests
|
||||
jest.mock('swr', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
isLoading: false,
|
||||
mutate: jest.fn()
|
||||
})),
|
||||
mutate: jest.fn()
|
||||
}));
|
||||
|
||||
// Mock React Router
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => jest.fn(),
|
||||
useLocation: () => ({ pathname: '/' }),
|
||||
useParams: () => ({})
|
||||
}));
|
||||
|
||||
// Mock Zustand store
|
||||
jest.mock('../store/useStore', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
user: null,
|
||||
login: jest.fn(),
|
||||
logout: jest.fn(),
|
||||
isAuthenticated: false,
|
||||
darkMode: false,
|
||||
toggleDarkMode: jest.fn()
|
||||
})
|
||||
}));
|
||||
|
||||
// Mock react-i18next
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
changeLanguage: jest.fn()
|
||||
}
|
||||
})
|
||||
}));
|
||||
|
||||
// Mock ToastContext
|
||||
jest.mock('../components/Shared/ToastContext', () => ({
|
||||
useToast: () => ({
|
||||
showToast: jest.fn(),
|
||||
showSuccessToast: jest.fn(),
|
||||
showErrorToast: jest.fn()
|
||||
}),
|
||||
ToastProvider: ({ children }: { children: React.ReactNode }) => children
|
||||
}));
|
||||
|
||||
// Global test utilities
|
||||
global.fetch = jest.fn();
|
||||
|
||||
// Clean up after each test
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// This file is a setup file, not a test file
|
||||
// No tests should be defined here
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { renderHook, act } from '@testing-library/react';
|
||||
|
||||
// Mock Zustand store structure based on typical patterns
|
||||
interface StoreState {
|
||||
user: any;
|
||||
isAuthenticated: boolean;
|
||||
darkMode: boolean;
|
||||
login: (user: any) => void;
|
||||
logout: () => void;
|
||||
toggleDarkMode: () => void;
|
||||
}
|
||||
|
||||
// Mock implementation of the store
|
||||
const createMockStore = () => {
|
||||
let state: StoreState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
darkMode: false,
|
||||
login: jest.fn((user) => {
|
||||
state.user = user;
|
||||
state.isAuthenticated = true;
|
||||
}),
|
||||
logout: jest.fn(() => {
|
||||
state.user = null;
|
||||
state.isAuthenticated = false;
|
||||
}),
|
||||
toggleDarkMode: jest.fn(() => {
|
||||
state.darkMode = !state.darkMode;
|
||||
})
|
||||
};
|
||||
|
||||
return {
|
||||
getState: () => state,
|
||||
setState: (newState: Partial<StoreState>) => {
|
||||
state = { ...state, ...newState };
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
describe('useStore', () => {
|
||||
let mockStore: ReturnType<typeof createMockStore>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockStore = createMockStore();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Authentication', () => {
|
||||
it('should initialize with no user and not authenticated', () => {
|
||||
const state = mockStore.getState();
|
||||
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('should login user successfully', () => {
|
||||
const mockUser = { id: 1, email: 'test@example.com' };
|
||||
const state = mockStore.getState();
|
||||
|
||||
act(() => {
|
||||
state.login(mockUser);
|
||||
});
|
||||
|
||||
expect(state.login).toHaveBeenCalledWith(mockUser);
|
||||
expect(mockStore.getState().user).toEqual(mockUser);
|
||||
expect(mockStore.getState().isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('should logout user successfully', () => {
|
||||
// First login
|
||||
const mockUser = { id: 1, email: 'test@example.com' };
|
||||
mockStore.setState({ user: mockUser, isAuthenticated: true });
|
||||
|
||||
const state = mockStore.getState();
|
||||
|
||||
act(() => {
|
||||
state.logout();
|
||||
});
|
||||
|
||||
expect(state.logout).toHaveBeenCalled();
|
||||
expect(mockStore.getState().user).toBeNull();
|
||||
expect(mockStore.getState().isAuthenticated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode', () => {
|
||||
it('should initialize with dark mode off', () => {
|
||||
const state = mockStore.getState();
|
||||
expect(state.darkMode).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle dark mode', () => {
|
||||
const state = mockStore.getState();
|
||||
|
||||
act(() => {
|
||||
state.toggleDarkMode();
|
||||
});
|
||||
|
||||
expect(state.toggleDarkMode).toHaveBeenCalled();
|
||||
expect(mockStore.getState().darkMode).toBe(true);
|
||||
|
||||
act(() => {
|
||||
state.toggleDarkMode();
|
||||
});
|
||||
|
||||
expect(mockStore.getState().darkMode).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Store persistence', () => {
|
||||
it('should maintain state between actions', () => {
|
||||
const mockUser = { id: 1, email: 'test@example.com' };
|
||||
|
||||
// Login
|
||||
mockStore.setState({ user: mockUser, isAuthenticated: true });
|
||||
|
||||
// Toggle dark mode
|
||||
const state = mockStore.getState();
|
||||
act(() => {
|
||||
state.toggleDarkMode();
|
||||
});
|
||||
|
||||
// Check both states are maintained
|
||||
const finalState = mockStore.getState();
|
||||
expect(finalState.user).toEqual(mockUser);
|
||||
expect(finalState.isAuthenticated).toBe(true);
|
||||
expect(finalState.darkMode).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,310 +0,0 @@
|
|||
import { User } from '../../entities/User';
|
||||
|
||||
// Mock authentication utilities
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ email, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Login failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
const response = await fetch('/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Logout failed');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const getCurrentUser = async (): Promise<User | null> => {
|
||||
try {
|
||||
const response = await fetch('/api/user', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
const validatePassword = (password: string): boolean => {
|
||||
return password.length >= 8;
|
||||
};
|
||||
|
||||
const isAuthenticated = (user: User | null): boolean => {
|
||||
return user !== null && user.email !== undefined;
|
||||
};
|
||||
|
||||
describe('Authentication Utils', () => {
|
||||
beforeEach(() => {
|
||||
(global.fetch as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('successfully logs in with valid credentials', async () => {
|
||||
const mockResponse = {
|
||||
success: true,
|
||||
user: { id: 1, email: 'test@example.com' }
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse
|
||||
});
|
||||
|
||||
const result = await login('test@example.com', 'password123');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
email: 'test@example.com',
|
||||
password: 'password123'
|
||||
})
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('throws error on invalid credentials', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Invalid credentials' })
|
||||
});
|
||||
|
||||
await expect(login('test@example.com', 'wrongpassword'))
|
||||
.rejects.toThrow('Invalid credentials');
|
||||
});
|
||||
|
||||
it('throws error on network failure', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await expect(login('test@example.com', 'password123'))
|
||||
.rejects.toThrow('Network error');
|
||||
});
|
||||
|
||||
it('handles server error response', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
json: async () => ({})
|
||||
});
|
||||
|
||||
await expect(login('test@example.com', 'password123'))
|
||||
.rejects.toThrow('Login failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('successfully logs out', async () => {
|
||||
const mockResponse = { success: true };
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse
|
||||
});
|
||||
|
||||
const result = await logout();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('throws error on logout failure', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false
|
||||
});
|
||||
|
||||
await expect(logout()).rejects.toThrow('Logout failed');
|
||||
});
|
||||
|
||||
it('handles network error during logout', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await expect(logout()).rejects.toThrow('Network error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentUser', () => {
|
||||
it('returns user when authenticated', async () => {
|
||||
const mockUser = { id: 1, email: 'test@example.com' };
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockUser
|
||||
});
|
||||
|
||||
const result = await getCurrentUser();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/user', {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockUser);
|
||||
});
|
||||
|
||||
it('returns null when not authenticated', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false
|
||||
});
|
||||
|
||||
const result = await getCurrentUser();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on network error', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const result = await getCurrentUser();
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateEmail', () => {
|
||||
it('validates correct email formats', () => {
|
||||
expect(validateEmail('test@example.com')).toBe(true);
|
||||
expect(validateEmail('user.name@domain.co.uk')).toBe(true);
|
||||
expect(validateEmail('test+tag@example.org')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects invalid email formats', () => {
|
||||
expect(validateEmail('invalid-email')).toBe(false);
|
||||
expect(validateEmail('@example.com')).toBe(false);
|
||||
expect(validateEmail('test@')).toBe(false);
|
||||
expect(validateEmail('test.example.com')).toBe(false);
|
||||
expect(validateEmail('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validatePassword', () => {
|
||||
it('validates passwords with minimum length', () => {
|
||||
expect(validatePassword('password123')).toBe(true);
|
||||
expect(validatePassword('12345678')).toBe(true);
|
||||
expect(validatePassword('very-long-password')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects passwords below minimum length', () => {
|
||||
expect(validatePassword('1234567')).toBe(false);
|
||||
expect(validatePassword('short')).toBe(false);
|
||||
expect(validatePassword('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('returns true for valid user', () => {
|
||||
const user: User = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
language: 'en',
|
||||
appearance: 'light',
|
||||
timezone: 'UTC'
|
||||
};
|
||||
expect(isAuthenticated(user)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for null user', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for user without email', () => {
|
||||
const invalidUser = {
|
||||
id: 1,
|
||||
language: 'en',
|
||||
appearance: 'light',
|
||||
timezone: 'UTC'
|
||||
} as User;
|
||||
expect(isAuthenticated(invalidUser)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration scenarios', () => {
|
||||
it('handles complete login flow', async () => {
|
||||
// Mock successful login
|
||||
(global.fetch as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, user: { id: 1, email: 'test@example.com' } })
|
||||
})
|
||||
// Mock getting current user
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ id: 1, email: 'test@example.com' })
|
||||
});
|
||||
|
||||
// Login
|
||||
const loginResult = await login('test@example.com', 'password123');
|
||||
expect(loginResult.success).toBe(true);
|
||||
|
||||
// Get current user
|
||||
const currentUser = await getCurrentUser();
|
||||
expect(currentUser).toEqual({ id: 1, email: 'test@example.com' });
|
||||
|
||||
// Check authentication status
|
||||
expect(isAuthenticated(currentUser)).toBe(true);
|
||||
});
|
||||
|
||||
it('handles complete logout flow', async () => {
|
||||
// Mock successful logout
|
||||
(global.fetch as jest.Mock)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true })
|
||||
})
|
||||
// Mock user no longer authenticated
|
||||
.mockResolvedValueOnce({
|
||||
ok: false
|
||||
});
|
||||
|
||||
// Logout
|
||||
const logoutResult = await logout();
|
||||
expect(logoutResult.success).toBe(true);
|
||||
|
||||
// Get current user (should be null)
|
||||
const currentUser = await getCurrentUser();
|
||||
expect(currentUser).toBeNull();
|
||||
|
||||
// Check authentication status
|
||||
expect(isAuthenticated(currentUser)).toBe(false);
|
||||
});
|
||||
|
||||
it('validates user input before authentication', () => {
|
||||
// Valid inputs
|
||||
expect(validateEmail('user@example.com')).toBe(true);
|
||||
expect(validatePassword('securepassword')).toBe(true);
|
||||
|
||||
// Invalid inputs
|
||||
expect(validateEmail('invalid')).toBe(false);
|
||||
expect(validatePassword('short')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { format, isToday, isPast, addDays } from 'date-fns';
|
||||
|
||||
// Mock the dateUtils functions based on common patterns
|
||||
const formatDate = (date: string | Date) => {
|
||||
return format(new Date(date), 'yyyy-MM-dd');
|
||||
};
|
||||
|
||||
const isDateToday = (date: string | Date) => {
|
||||
return isToday(new Date(date));
|
||||
};
|
||||
|
||||
const isDatePast = (date: string | Date) => {
|
||||
return isPast(new Date(date));
|
||||
};
|
||||
|
||||
const getRelativeTimeString = (date: string | Date) => {
|
||||
const targetDate = new Date(date);
|
||||
const today = new Date();
|
||||
|
||||
if (isToday(targetDate)) {
|
||||
return 'Today';
|
||||
}
|
||||
|
||||
const tomorrow = addDays(today, 1);
|
||||
if (format(targetDate, 'yyyy-MM-dd') === format(tomorrow, 'yyyy-MM-dd')) {
|
||||
return 'Tomorrow';
|
||||
}
|
||||
|
||||
if (isPast(targetDate)) {
|
||||
return 'Overdue';
|
||||
}
|
||||
|
||||
return format(targetDate, 'MMM d');
|
||||
};
|
||||
|
||||
describe('Date Utils', () => {
|
||||
const mockToday = new Date('2024-06-16T12:00:00.000Z');
|
||||
const mockTomorrow = new Date('2024-06-17T12:00:00.000Z');
|
||||
const mockYesterday = new Date('2024-06-15T12:00:00.000Z');
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock the current date
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(mockToday);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe('formatDate', () => {
|
||||
it('formats date string correctly', () => {
|
||||
const result = formatDate('2024-06-16');
|
||||
expect(result).toBe('2024-06-16');
|
||||
});
|
||||
|
||||
it('formats Date object correctly', () => {
|
||||
const result = formatDate(mockToday);
|
||||
expect(result).toBe('2024-06-16');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDateToday', () => {
|
||||
it('returns true for today\'s date', () => {
|
||||
expect(isDateToday(mockToday)).toBe(true);
|
||||
expect(isDateToday('2024-06-16')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for other dates', () => {
|
||||
expect(isDateToday(mockTomorrow)).toBe(false);
|
||||
expect(isDateToday(mockYesterday)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDatePast', () => {
|
||||
it('returns true for past dates', () => {
|
||||
expect(isDatePast(mockYesterday)).toBe(true);
|
||||
expect(isDatePast('2024-06-15')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for future dates', () => {
|
||||
expect(isDatePast(mockTomorrow)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRelativeTimeString', () => {
|
||||
it('returns "Today" for today\'s date', () => {
|
||||
expect(getRelativeTimeString(mockToday)).toBe('Today');
|
||||
expect(getRelativeTimeString('2024-06-16')).toBe('Today');
|
||||
});
|
||||
|
||||
it('returns "Tomorrow" for tomorrow\'s date', () => {
|
||||
expect(getRelativeTimeString(mockTomorrow)).toBe('Tomorrow');
|
||||
expect(getRelativeTimeString('2024-06-17')).toBe('Tomorrow');
|
||||
});
|
||||
|
||||
it('returns "Overdue" for past dates', () => {
|
||||
expect(getRelativeTimeString(mockYesterday)).toBe('Overdue');
|
||||
expect(getRelativeTimeString('2024-06-15')).toBe('Overdue');
|
||||
});
|
||||
|
||||
it('returns formatted date for future dates beyond tomorrow', () => {
|
||||
const futureDate = '2024-06-20';
|
||||
const result = getRelativeTimeString(futureDate);
|
||||
expect(result).toBe('Jun 20');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
import { Task } from '../../entities/Task';
|
||||
|
||||
// Mock the tasksService functions
|
||||
const createTask = async (taskData: Partial<Task>) => {
|
||||
const response = await fetch('/api/task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(taskData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create task');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const updateTask = async (id: number, taskData: Partial<Task>) => {
|
||||
const response = await fetch(`/api/task/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(taskData)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update task');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const deleteTask = async (id: number) => {
|
||||
const response = await fetch(`/api/task/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete task');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
const fetchTasks = async () => {
|
||||
const response = await fetch('/api/tasks');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch tasks');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
|
||||
describe('Tasks Service', () => {
|
||||
beforeEach(() => {
|
||||
(global.fetch as jest.Mock).mockClear();
|
||||
});
|
||||
|
||||
describe('createTask', () => {
|
||||
it('creates a task successfully', async () => {
|
||||
const mockTask = { name: 'Test Task', priority: 'medium' as const, status: 'not_started' as const };
|
||||
const mockResponse = { id: 1, ...mockTask };
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse
|
||||
});
|
||||
|
||||
const result = await createTask(mockTask);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockTask)
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('throws error when creation fails', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false
|
||||
});
|
||||
|
||||
await expect(createTask({ name: 'Test Task' })).rejects.toThrow('Failed to create task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTask', () => {
|
||||
it('updates a task successfully', async () => {
|
||||
const mockUpdate = { name: 'Updated Task' };
|
||||
const mockResponse = { id: 1, ...mockUpdate };
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse
|
||||
});
|
||||
|
||||
const result = await updateTask(1, mockUpdate);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/task/1', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(mockUpdate)
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('throws error when update fails', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false
|
||||
});
|
||||
|
||||
await expect(updateTask(1, { name: 'Updated' })).rejects.toThrow('Failed to update task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTask', () => {
|
||||
it('deletes a task successfully', async () => {
|
||||
const mockResponse = { message: 'Task deleted successfully' };
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockResponse
|
||||
});
|
||||
|
||||
const result = await deleteTask(1);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/task/1', {
|
||||
method: 'DELETE'
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('throws error when deletion fails', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false
|
||||
});
|
||||
|
||||
await expect(deleteTask(1)).rejects.toThrow('Failed to delete task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTasks', () => {
|
||||
it('fetches tasks successfully', async () => {
|
||||
const mockTasks = {
|
||||
tasks: [
|
||||
{ id: 1, name: 'Task 1' },
|
||||
{ id: 2, name: 'Task 2' }
|
||||
],
|
||||
metrics: {}
|
||||
};
|
||||
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => mockTasks
|
||||
});
|
||||
|
||||
const result = await fetchTasks();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/tasks');
|
||||
expect(result).toEqual(mockTasks);
|
||||
});
|
||||
|
||||
it('throws error when fetch fails', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false
|
||||
});
|
||||
|
||||
await expect(fetchTasks()).rejects.toThrow('Failed to fetch tasks');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -103,19 +103,13 @@ const Areas: React.FC = () => {
|
|||
<div className="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-5xl">
|
||||
{/* Areas Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center">
|
||||
<Squares2X2Icon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
|
||||
{t('areas.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateArea}
|
||||
className="bg-blue-500 text-white rounded-md px-4 py-2 hover:bg-blue-600"
|
||||
>
|
||||
{t('areas.addArea')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Areas List */}
|
||||
|
|
|
|||
698
frontend/components/Calendar.tsx
Normal file
698
frontend/components/Calendar.tsx
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TaskModal from './Task/TaskModal';
|
||||
import { Task } from '../entities/Task';
|
||||
import { Project } from '../entities/Project';
|
||||
import { deleteTask } from '../utils/tasksService';
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CalendarIcon,
|
||||
PlusIcon,
|
||||
XMarkIcon,
|
||||
ArrowTopRightOnSquareIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { format, addWeeks, addDays } from 'date-fns';
|
||||
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';
|
||||
|
||||
const getLocale = (language: string) => {
|
||||
switch (language) {
|
||||
case 'el': return el;
|
||||
case 'es': return es;
|
||||
case 'jp': return ja;
|
||||
case 'ua': return uk;
|
||||
case 'de': return de;
|
||||
default: return enUS;
|
||||
}
|
||||
};
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
type: 'task' | 'event' | 'google';
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface GoogleCalendarStatus {
|
||||
connected: boolean;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
const Calendar: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [view, setView] = useState<'month' | 'week' | 'day'>('month');
|
||||
const [googleStatus, setGoogleStatus] = useState<GoogleCalendarStatus>({ connected: false });
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||
const [events, setEvents] = useState<CalendarEvent[]>([]);
|
||||
const [isLoadingTasks, setIsLoadingTasks] = useState(false);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [allTasks, setAllTasks] = useState<any[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isEventDetailModalOpen, setIsEventDetailModalOpen] = useState(false);
|
||||
|
||||
const locale = getLocale(i18n.language);
|
||||
|
||||
// Load Google Calendar status and tasks on component mount
|
||||
useEffect(() => {
|
||||
checkGoogleCalendarStatus();
|
||||
loadTasks();
|
||||
loadProjects();
|
||||
|
||||
// Check URL parameters for demo mode
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('demo') === 'true' && urlParams.get('connected') === 'true') {
|
||||
setGoogleStatus({ connected: true, email: 'demo@example.com' });
|
||||
setIsDemoMode(true);
|
||||
// Clean up URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkGoogleCalendarStatus = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/calendar/status', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const status = await response.json();
|
||||
setGoogleStatus(status);
|
||||
setIsDemoMode(status.demo || false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking Google Calendar status:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTasks = async () => {
|
||||
setIsLoadingTasks(true);
|
||||
try {
|
||||
const response = await fetch('/api/tasks', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Handle different API response formats
|
||||
let tasks;
|
||||
if (Array.isArray(data)) {
|
||||
tasks = data;
|
||||
} else if (data && Array.isArray(data.tasks)) {
|
||||
tasks = data.tasks;
|
||||
} else if (data && data.data && Array.isArray(data.data)) {
|
||||
tasks = data.data;
|
||||
} else {
|
||||
console.error('Unexpected API response format:', data);
|
||||
tasks = [];
|
||||
}
|
||||
|
||||
// Store the original tasks for later reference
|
||||
setAllTasks(tasks);
|
||||
|
||||
const taskEvents = convertTasksToEvents(tasks);
|
||||
setEvents(taskEvents);
|
||||
} else {
|
||||
console.error('Failed to load tasks, status:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading tasks:', error);
|
||||
} finally {
|
||||
setIsLoadingTasks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const convertTasksToEvents = (tasks: any[]): CalendarEvent[] => {
|
||||
const taskEvents: CalendarEvent[] = [];
|
||||
|
||||
if (!Array.isArray(tasks)) {
|
||||
console.error('convertTasksToEvents received non-array:', tasks);
|
||||
return [];
|
||||
}
|
||||
|
||||
tasks.forEach((task, index) => {
|
||||
|
||||
// Add tasks with due dates
|
||||
if (task.due_date) {
|
||||
const dueDate = new Date(task.due_date);
|
||||
const taskEvent = {
|
||||
id: `task-${task.id}`,
|
||||
title: task.name || task.title || `Task ${task.id}`,
|
||||
start: dueDate,
|
||||
end: new Date(dueDate.getTime() + 60 * 60 * 1000), // 1 hour duration
|
||||
type: 'task' as const,
|
||||
color: task.completed_at ? '#22c55e' : '#ef4444' // Green if completed, red if not
|
||||
};
|
||||
taskEvents.push(taskEvent);
|
||||
}
|
||||
|
||||
// Add tasks scheduled for today (if they don't have due_date)
|
||||
if (!task.due_date && task.created_at) {
|
||||
const createdDate = new Date(task.created_at);
|
||||
const today = new Date();
|
||||
|
||||
// Show tasks created today on the calendar
|
||||
if (createdDate.toDateString() === today.toDateString()) {
|
||||
const taskEvent = {
|
||||
id: `task-created-${task.id}`,
|
||||
title: `📝 ${task.name || task.title || `Task ${task.id}`}`,
|
||||
start: createdDate,
|
||||
end: new Date(createdDate.getTime() + 30 * 60 * 1000), // 30 min duration
|
||||
type: 'task' as const,
|
||||
color: task.completed_at ? '#22c55e' : '#3b82f6' // Green if completed, blue if not
|
||||
};
|
||||
taskEvents.push(taskEvent);
|
||||
}
|
||||
}
|
||||
|
||||
// Always add tasks to calendar for easier debugging
|
||||
if (!task.due_date && !task.created_at) {
|
||||
const taskEvent = {
|
||||
id: `task-fallback-${task.id}`,
|
||||
title: `📌 ${task.name || task.title || `Task ${task.id}`}`,
|
||||
start: new Date(), // Today
|
||||
end: new Date(Date.now() + 30 * 60 * 1000), // 30 min duration
|
||||
type: 'task' as const,
|
||||
color: task.completed_at ? '#22c55e' : '#8b5cf6' // Green if completed, purple if not
|
||||
};
|
||||
taskEvents.push(taskEvent);
|
||||
}
|
||||
});
|
||||
|
||||
return taskEvents;
|
||||
};
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const projectsData = await response.json();
|
||||
setProjects(Array.isArray(projectsData) ? projectsData : []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading projects:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const connectGoogleCalendar = async () => {
|
||||
if (isConnecting) return;
|
||||
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const response = await fetch('/api/calendar/auth', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.demo) {
|
||||
// Demo mode - simulate connection
|
||||
setGoogleStatus({ connected: true, email: 'demo@example.com' });
|
||||
setIsDemoMode(true);
|
||||
} else {
|
||||
// Real Google OAuth - redirect to auth URL
|
||||
window.location.href = result.authUrl;
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to get authorization URL');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error connecting to Google Calendar:', error);
|
||||
alert(t('calendar.connectionError'));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disconnectGoogleCalendar = async () => {
|
||||
try {
|
||||
if (isDemoMode) {
|
||||
// Demo mode - just update local state
|
||||
setGoogleStatus({ connected: false });
|
||||
setIsDemoMode(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Real disconnect API call
|
||||
const response = await fetch('/api/calendar/disconnect', {
|
||||
method: 'POST',
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
setGoogleStatus({ connected: false });
|
||||
} else {
|
||||
throw new Error('Failed to disconnect');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error disconnecting Google Calendar:', error);
|
||||
alert(t('calendar.disconnectionError'));
|
||||
}
|
||||
};
|
||||
|
||||
const navigate = (direction: 'prev' | 'next') => {
|
||||
setCurrentDate(prev => {
|
||||
if (view === 'month') {
|
||||
const newDate = new Date(prev);
|
||||
if (direction === 'prev') {
|
||||
newDate.setMonth(prev.getMonth() - 1);
|
||||
} else {
|
||||
newDate.setMonth(prev.getMonth() + 1);
|
||||
}
|
||||
return newDate;
|
||||
} else if (view === 'week') {
|
||||
return direction === 'prev' ? addWeeks(prev, -1) : addWeeks(prev, 1);
|
||||
} else { // day
|
||||
return direction === 'prev' ? addDays(prev, -1) : addDays(prev, 1);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const goToToday = () => {
|
||||
setCurrentDate(new Date());
|
||||
};
|
||||
|
||||
const handleDateClick = () => {
|
||||
// Date click handler - can be used for future functionality
|
||||
};
|
||||
|
||||
const handleEventClick = (event: CalendarEvent) => {
|
||||
|
||||
// Handle task events
|
||||
if (event.type === 'task') {
|
||||
// Extract task ID from event ID
|
||||
const taskId = event.id.replace(/^task(-created|-fallback)?-/, '');
|
||||
const task = allTasks.find(t => t.id.toString() === taskId);
|
||||
|
||||
if (task) {
|
||||
// Convert task to proper Task entity format for TaskModal
|
||||
const taskEntity: Task = {
|
||||
...task,
|
||||
name: task.name || task.title || `Task ${task.id}`,
|
||||
// Ensure all required Task properties are present
|
||||
priority: task.priority || 'medium',
|
||||
status: task.status || 'not_started',
|
||||
tags: task.tags || [],
|
||||
note: task.note || task.description || '',
|
||||
due_date: task.due_date,
|
||||
created_at: task.created_at,
|
||||
completed_at: task.completed_at,
|
||||
project_id: task.project_id
|
||||
};
|
||||
|
||||
setSelectedTask(taskEntity);
|
||||
setIsEventDetailModalOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeSlotClick = () => {
|
||||
// Time slot click handler - can be used for future functionality
|
||||
};
|
||||
|
||||
const handleEditTask = () => {
|
||||
setIsEventDetailModalOpen(false);
|
||||
setIsTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const handleTaskSave = (updatedTask: Task) => {
|
||||
// Update the task in allTasks
|
||||
setAllTasks(prev => prev.map(t => t.id === updatedTask.id ? updatedTask : t));
|
||||
// Refresh calendar
|
||||
loadTasks();
|
||||
// Close modal
|
||||
setIsTaskModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
};
|
||||
|
||||
const handleTaskDelete = async (taskId: number) => {
|
||||
try {
|
||||
await deleteTask(taskId);
|
||||
// Remove task from allTasks
|
||||
setAllTasks(prev => prev.filter(t => t.id !== taskId));
|
||||
// Refresh calendar
|
||||
loadTasks();
|
||||
// Close modal
|
||||
setIsTaskModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete task:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProject = async (name: string): Promise<Project> => {
|
||||
try {
|
||||
const response = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name, description: '' })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const newProject = await response.json();
|
||||
setProjects(prev => [...prev, newProject]);
|
||||
return newProject;
|
||||
} else {
|
||||
throw new Error('Failed to create project');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h2 className="text-2xl font-light flex items-center">
|
||||
<CalendarIcon className="h-6 w-6 mr-2" />
|
||||
{t('sidebar.calendar')}
|
||||
</h2>
|
||||
<span className="text-lg text-gray-600 dark:text-gray-400">
|
||||
{format(currentDate, 'MMMM yyyy', { locale })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* View selector */}
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600">
|
||||
{['month', 'week', 'day'].map((viewType) => (
|
||||
<button
|
||||
key={viewType}
|
||||
onClick={() => setView(viewType as 'month' | 'week' | 'day')}
|
||||
className={`px-3 py-1 text-sm font-medium capitalize ${
|
||||
view === viewType
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
} ${viewType === 'month' ? 'rounded-l-lg' : ''} ${viewType === 'day' ? 'rounded-r-lg' : ''}`}
|
||||
>
|
||||
{t(`calendar.${viewType}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<button
|
||||
onClick={() => navigate('prev')}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={goToToday}
|
||||
className="px-3 py-2 text-sm bg-blue-500 text-white rounded-lg hover:bg-blue-600"
|
||||
>
|
||||
{t('calendar.today')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('next')}
|
||||
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{isLoadingTasks && (
|
||||
<div className="text-center py-4 text-gray-500">
|
||||
{t('calendar.loadingTasks')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Calendar view */}
|
||||
{view === 'month' && (
|
||||
<CalendarMonthView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'week' && (
|
||||
<CalendarWeekView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
onDateClick={handleDateClick}
|
||||
onEventClick={handleEventClick}
|
||||
onTimeSlotClick={handleTimeSlotClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'day' && (
|
||||
<CalendarDayView
|
||||
currentDate={currentDate}
|
||||
events={events}
|
||||
onEventClick={handleEventClick}
|
||||
onTimeSlotClick={handleTimeSlotClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Google Calendar Integration Panel */}
|
||||
<div className="mt-6 bg-white dark:bg-gray-900 rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-medium mb-4 text-gray-900 dark:text-gray-100">
|
||||
{t('calendar.googleIntegration')}
|
||||
</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{isDemoMode
|
||||
? 'Demo mode: Google Calendar integration simulated for testing purposes.'
|
||||
: t('calendar.googleDescription')
|
||||
}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{t('calendar.googleStatus')}:
|
||||
{googleStatus.connected ? (
|
||||
<span className="text-green-500 ml-1">
|
||||
{t('calendar.connected')}
|
||||
{googleStatus.email && ` (${googleStatus.email})`}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-500 ml-1">{t('calendar.notConnected')}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{googleStatus.connected ? (
|
||||
<button
|
||||
onClick={disconnectGoogleCalendar}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
{t('calendar.disconnectGoogle')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={connectGoogleCalendar}
|
||||
disabled={isConnecting}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isConnecting ? t('calendar.connecting') : t('calendar.connectGoogle')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Event Details Modal */}
|
||||
{selectedTask && (
|
||||
<TaskEventModal
|
||||
isOpen={isEventDetailModalOpen}
|
||||
onClose={() => {
|
||||
setIsEventDetailModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
task={selectedTask}
|
||||
onEditTask={handleEditTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Full Task Edit Modal */}
|
||||
{selectedTask && (
|
||||
<TaskModal
|
||||
isOpen={isTaskModalOpen}
|
||||
onClose={() => {
|
||||
setIsTaskModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
task={selectedTask}
|
||||
onSave={handleTaskSave}
|
||||
onDelete={handleTaskDelete}
|
||||
projects={projects}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Simple Task Event Details Modal Component
|
||||
interface TaskEventModalProps {
|
||||
isOpen: boolean;
|
||||
task: Task;
|
||||
onClose: () => void;
|
||||
onEditTask: () => void;
|
||||
}
|
||||
|
||||
const TaskEventModal: React.FC<TaskEventModalProps> = ({ isOpen, task, onClose, onEditTask }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = getLocale(i18n.language);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 max-w-md w-full mx-4">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
📋 {t('calendar.taskDetails')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
<XMarkIcon className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Task Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('calendar.title')}
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">
|
||||
{task.name || `Task ${task.id}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Task Status */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('calendar.status')}
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
task.completed_at
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
}`}>
|
||||
{task.completed_at ? `✅ ${t('calendar.completed')}` : `⏳ ${t('calendar.pending')}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
{task.due_date && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('calendar.dueDate')}
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">
|
||||
{format(new Date(task.due_date), 'PPP', { locale: locale })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority */}
|
||||
{task.priority && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('calendar.priority')}
|
||||
</label>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
task.priority === 'high'
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
: task.priority === 'medium'
|
||||
? 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200'
|
||||
: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
|
||||
}`}>
|
||||
{t(`calendar.${task.priority}`)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project */}
|
||||
{task.Project?.name && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('calendar.project')}
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-gray-100">
|
||||
{task.Project.name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Area - Note: Area relationship not in Task entity, removing this section */}
|
||||
|
||||
{/* Note */}
|
||||
{task.note && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('calendar.description')}
|
||||
</label>
|
||||
<p className="text-gray-900 dark:text-gray-100 whitespace-pre-wrap">
|
||||
{task.note}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Created Date */}
|
||||
{task.created_at && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('calendar.created')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{format(new Date(task.created_at), 'PPp', { locale: locale })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="mt-6 flex justify-between">
|
||||
<a
|
||||
href="/tasks"
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="w-4 h-4 mr-1" />
|
||||
{t('calendar.goToTasks')}
|
||||
</a>
|
||||
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
{t('calendar.close')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onEditTask}
|
||||
className="px-4 py-2 text-sm font-medium bg-blue-500 text-white rounded-md hover:bg-blue-600"
|
||||
>
|
||||
{t('calendar.editTask')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
162
frontend/components/Calendar/CalendarDayView.tsx
Normal file
162
frontend/components/Calendar/CalendarDayView.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React from 'react';
|
||||
import { format, addHours, isToday } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
type: 'task' | 'event' | 'google';
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface CalendarDayViewProps {
|
||||
currentDate: Date;
|
||||
events: CalendarEvent[];
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTimeSlotClick?: (date: Date, hour: number) => void;
|
||||
}
|
||||
|
||||
const CalendarDayView: React.FC<CalendarDayViewProps> = ({
|
||||
currentDate,
|
||||
events,
|
||||
onEventClick,
|
||||
onTimeSlotClick
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
const getEventsForTimeSlot = (hour: number) => {
|
||||
return events.filter(event => {
|
||||
const eventDay = format(event.start, 'yyyy-MM-dd');
|
||||
const currentDay = format(currentDate, 'yyyy-MM-dd');
|
||||
const eventHour = event.start.getHours();
|
||||
|
||||
return eventDay === currentDay && eventHour === hour;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeSlotClick = (hour: number) => {
|
||||
if (onTimeSlotClick) {
|
||||
onTimeSlotClick(currentDate, hour);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventClick = (event: CalendarEvent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
const calculateEventHeight = (event: CalendarEvent) => {
|
||||
const durationMs = event.end.getTime() - event.start.getTime();
|
||||
const durationHours = durationMs / (1000 * 60 * 60);
|
||||
return Math.max(durationHours * 48, 24); // Minimum 24px height
|
||||
};
|
||||
|
||||
const calculateEventPosition = (event: CalendarEvent) => {
|
||||
const minutes = event.start.getMinutes();
|
||||
return (minutes / 60) * 48; // 48px per hour
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="text-center">
|
||||
<div className={`text-lg font-medium ${
|
||||
isToday(currentDate) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{format(currentDate, 'EEEE')}
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${
|
||||
isToday(currentDate) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{format(currentDate, 'd')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{format(currentDate, 'MMMM yyyy')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All day events */}
|
||||
<div className="p-2 border-b border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mb-1">All day</div>
|
||||
<div className="space-y-1">
|
||||
{events
|
||||
.filter(event => {
|
||||
const eventDay = format(event.start, 'yyyy-MM-dd');
|
||||
const currentDay = format(currentDate, 'yyyy-MM-dd');
|
||||
// Check if it's an all-day event (spans 24 hours or more)
|
||||
const duration = event.end.getTime() - event.start.getTime();
|
||||
return eventDay === currentDay && duration >= 24 * 60 * 60 * 1000;
|
||||
})
|
||||
.map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={(e) => handleEventClick(event, e)}
|
||||
className={`text-xs p-2 rounded text-white cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
event.type === 'task' ? 'border-l-2 border-l-white/50' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: event.color || '#3b82f6' }}
|
||||
title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`}
|
||||
>
|
||||
{event.type === 'task' && '📋 '}{event.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{hours.map(hour => {
|
||||
const timeSlotEvents = getEventsForTimeSlot(hour);
|
||||
|
||||
return (
|
||||
<div key={hour} className="relative border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="flex">
|
||||
{/* Time column */}
|
||||
<div className="w-16 p-2 text-xs text-gray-500 dark:text-gray-400 text-center border-r border-gray-200 dark:border-gray-700">
|
||||
{format(addHours(new Date().setHours(hour, 0, 0, 0), 0), 'HH:mm')}
|
||||
</div>
|
||||
|
||||
{/* Event area */}
|
||||
<div
|
||||
onClick={() => handleTimeSlotClick(hour)}
|
||||
className="flex-1 h-12 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative"
|
||||
>
|
||||
{timeSlotEvents.map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={(e) => handleEventClick(event, e)}
|
||||
className={`absolute left-1 right-1 text-xs p-1 rounded text-white cursor-pointer hover:opacity-80 transition-opacity z-10 ${
|
||||
event.type === 'task' ? 'border-l-2 border-l-white/50' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: event.color || '#3b82f6',
|
||||
top: calculateEventPosition(event),
|
||||
height: calculateEventHeight(event)
|
||||
}}
|
||||
title={`${event.type === 'task' ? '📋 ' : ''}${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`}
|
||||
>
|
||||
<div className="font-medium">{event.type === 'task' && '📋 '}{event.title}</div>
|
||||
<div className="text-xs opacity-90">
|
||||
{format(event.start, 'HH:mm')} - {format(event.end, 'HH:mm')}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarDayView;
|
||||
135
frontend/components/Calendar/CalendarMonthView.tsx
Normal file
135
frontend/components/Calendar/CalendarMonthView.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import React from 'react';
|
||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, startOfWeek, endOfWeek } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
type: 'task' | 'event' | 'google';
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface CalendarMonthViewProps {
|
||||
currentDate: Date;
|
||||
events: CalendarEvent[];
|
||||
onDateClick?: (date: Date) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
|
||||
currentDate,
|
||||
events,
|
||||
onDateClick,
|
||||
onEventClick
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Start on Monday
|
||||
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 });
|
||||
|
||||
const days = eachDayOfInterval({
|
||||
start: calendarStart,
|
||||
end: calendarEnd
|
||||
});
|
||||
|
||||
const weekDays = [
|
||||
t('weekdays.monday', 'Mon'),
|
||||
t('weekdays.tuesday', 'Tue'),
|
||||
t('weekdays.wednesday', 'Wed'),
|
||||
t('weekdays.thursday', 'Thu'),
|
||||
t('weekdays.friday', 'Fri'),
|
||||
t('weekdays.saturday', 'Sat'),
|
||||
t('weekdays.sunday', 'Sun')
|
||||
];
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
if (onDateClick) {
|
||||
onDateClick(date);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventClick = (event: CalendarEvent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
|
||||
{/* Week days header */}
|
||||
<div className="grid grid-cols-7 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
{weekDays.map(day => (
|
||||
<div key={day} className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="grid grid-cols-7">
|
||||
{days.map(day => {
|
||||
const dayEvents = events.filter(event =>
|
||||
format(event.start, 'yyyy-MM-dd') === format(day, 'yyyy-MM-dd')
|
||||
);
|
||||
|
||||
const isCurrentMonth = isSameMonth(day, currentDate);
|
||||
const isTodayDate = isToday(day);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={day.toString()}
|
||||
onClick={() => handleDateClick(day)}
|
||||
className={`min-h-32 p-2 border-r border-b border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||
!isCurrentMonth
|
||||
? 'bg-gray-50 dark:bg-gray-800'
|
||||
: 'bg-white dark:bg-gray-900'
|
||||
} ${isTodayDate ? 'bg-blue-50 dark:bg-blue-900/20 ring-2 ring-blue-300 dark:ring-blue-600' : ''}`}
|
||||
>
|
||||
<div className={`text-sm mb-2 ${
|
||||
!isCurrentMonth
|
||||
? 'text-gray-400 dark:text-gray-600'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
} ${isTodayDate ? 'font-bold text-blue-600 dark:text-blue-400' : ''}`}>
|
||||
{isTodayDate && (
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 bg-blue-600 text-white text-xs font-bold rounded-full">
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
)}
|
||||
{!isTodayDate && format(day, 'd')}
|
||||
</div>
|
||||
|
||||
{/* Events */}
|
||||
<div className="space-y-1">
|
||||
{dayEvents.slice(0, 3).map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={(e) => handleEventClick(event, e)}
|
||||
className={`text-xs p-1 rounded text-white truncate cursor-pointer hover:opacity-80 transition-opacity ${
|
||||
event.type === 'task' ? 'border-l-2 border-l-white/50' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: event.color || '#3b82f6' }}
|
||||
title={`${event.type === 'task' ? '📋 ' : ''}${event.title}`}
|
||||
>
|
||||
{event.type === 'task' && '📋 '}{event.title}
|
||||
</div>
|
||||
))}
|
||||
{dayEvents.length > 3 && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 px-1">
|
||||
+{dayEvents.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarMonthView;
|
||||
135
frontend/components/Calendar/CalendarWeekView.tsx
Normal file
135
frontend/components/Calendar/CalendarWeekView.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import React from 'react';
|
||||
import { format, startOfWeek, endOfWeek, eachDayOfInterval, isToday, addHours } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
start: Date;
|
||||
end: Date;
|
||||
type: 'task' | 'event' | 'google';
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface CalendarWeekViewProps {
|
||||
currentDate: Date;
|
||||
events: CalendarEvent[];
|
||||
onDateClick?: (date: Date) => void;
|
||||
onEventClick?: (event: CalendarEvent) => void;
|
||||
onTimeSlotClick?: (date: Date, hour: number) => void;
|
||||
}
|
||||
|
||||
const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
|
||||
currentDate,
|
||||
events,
|
||||
onDateClick,
|
||||
onEventClick,
|
||||
onTimeSlotClick
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
const weekEnd = endOfWeek(currentDate, { weekStartsOn: 1 });
|
||||
const weekDays = eachDayOfInterval({ start: weekStart, end: weekEnd });
|
||||
|
||||
const hours = Array.from({ length: 24 }, (_, i) => i);
|
||||
|
||||
const getEventsForTimeSlot = (day: Date, hour: number) => {
|
||||
return events.filter(event => {
|
||||
const eventDay = format(event.start, 'yyyy-MM-dd');
|
||||
const slotDay = format(day, 'yyyy-MM-dd');
|
||||
const eventHour = event.start.getHours();
|
||||
|
||||
return eventDay === slotDay && eventHour === hour;
|
||||
});
|
||||
};
|
||||
|
||||
const handleTimeSlotClick = (day: Date, hour: number) => {
|
||||
if (onTimeSlotClick) {
|
||||
onTimeSlotClick(day, hour);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventClick = (event: CalendarEvent, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onEventClick) {
|
||||
onEventClick(event);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
|
||||
{/* Header with days */}
|
||||
<div className="grid grid-cols-8 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div className="p-3 text-center text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Time
|
||||
</div>
|
||||
{weekDays.map(day => (
|
||||
<div key={day.toString()} className={`p-3 text-center border-l border-gray-200 dark:border-gray-700 ${
|
||||
isToday(day) ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||
}`}>
|
||||
<div className={`text-sm font-medium ${
|
||||
isToday(day) ? 'text-blue-600 dark:text-blue-400' : 'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
{format(day, 'EEE')}
|
||||
</div>
|
||||
<div className={`text-lg ${
|
||||
isToday(day) ? 'text-blue-600 dark:text-blue-400 font-bold' : 'text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{isToday(day) ? (
|
||||
<span className="inline-flex items-center justify-center w-8 h-8 bg-blue-600 text-white text-sm font-bold rounded-full">
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
) : (
|
||||
format(day, 'd')
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{hours.map(hour => (
|
||||
<div key={hour} className="grid grid-cols-8 border-b border-gray-100 dark:border-gray-800">
|
||||
{/* Time column */}
|
||||
<div className="p-2 text-xs text-gray-500 dark:text-gray-400 text-center border-r border-gray-200 dark:border-gray-700">
|
||||
{format(addHours(new Date().setHours(hour, 0, 0, 0), 0), 'HH:mm')}
|
||||
</div>
|
||||
|
||||
{/* Day columns */}
|
||||
{weekDays.map(day => {
|
||||
const timeSlotEvents = getEventsForTimeSlot(day, hour);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${day.toString()}-${hour}`}
|
||||
onClick={() => handleTimeSlotClick(day, hour)}
|
||||
className={`h-12 p-1 border-l border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 relative ${
|
||||
isToday(day) ? 'bg-blue-50/30 dark:bg-blue-900/10' : ''
|
||||
}`}
|
||||
>
|
||||
{timeSlotEvents.map(event => (
|
||||
<div
|
||||
key={event.id}
|
||||
onClick={(e) => handleEventClick(event, e)}
|
||||
className={`text-xs p-1 rounded text-white truncate cursor-pointer hover:opacity-80 transition-opacity absolute inset-1 ${
|
||||
event.type === 'task' ? 'border-l-2 border-l-white/50' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: event.color || '#3b82f6' }}
|
||||
title={`${event.type === 'task' ? '📋 ' : ''}${event.title} - ${format(event.start, 'HH:mm')} to ${format(event.end, 'HH:mm')}`}
|
||||
>
|
||||
{event.type === 'task' && '📋 '}{event.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarWeekView;
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { InboxItem } from '../../entities/InboxItem';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format } from 'date-fns';
|
||||
import { TrashIcon, PencilIcon, EllipsisVerticalIcon } from '@heroicons/react/24/outline';
|
||||
import { TrashIcon, PencilIcon, DocumentTextIcon, FolderIcon, ClipboardDocumentListIcon } from '@heroicons/react/24/outline';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { Note } from '../../entities/Note';
|
||||
|
|
@ -30,26 +30,9 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Handle click outside of dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [dropdownOpen]);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const handleConvertToTask = () => {
|
||||
const newTask: Task = {
|
||||
|
|
@ -58,17 +41,11 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
priority: 'medium'
|
||||
};
|
||||
|
||||
// First close the dropdown
|
||||
setDropdownOpen(false);
|
||||
|
||||
// Use a simple timeout to ensure the dropdown closes before opening modal
|
||||
setTimeout(() => {
|
||||
if (item.id !== undefined) {
|
||||
openTaskModal(newTask, item.id);
|
||||
} else {
|
||||
openTaskModal(newTask);
|
||||
}
|
||||
}, 50);
|
||||
if (item.id !== undefined) {
|
||||
openTaskModal(newTask, item.id);
|
||||
} else {
|
||||
openTaskModal(newTask);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvertToProject = () => {
|
||||
|
|
@ -78,17 +55,11 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
active: true
|
||||
};
|
||||
|
||||
// First close the dropdown
|
||||
setDropdownOpen(false);
|
||||
|
||||
// Use a simple timeout to ensure the dropdown closes before opening modal
|
||||
setTimeout(() => {
|
||||
if (item.id !== undefined) {
|
||||
openProjectModal(newProject, item.id);
|
||||
} else {
|
||||
openProjectModal(newProject);
|
||||
}
|
||||
}, 50);
|
||||
if (item.id !== undefined) {
|
||||
openProjectModal(newProject, item.id);
|
||||
} else {
|
||||
openProjectModal(newProject);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConvertToNote = async () => {
|
||||
|
|
@ -101,17 +72,34 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
|
||||
if (isUrl(item.content.trim())) {
|
||||
setLoading(true);
|
||||
const result = await extractUrlTitle(item.content.trim());
|
||||
setLoading(false);
|
||||
try {
|
||||
// Add a timeout to prevent infinite loading
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout')), 10000) // 10 second timeout
|
||||
);
|
||||
|
||||
if (result && result.title) {
|
||||
title = result.title;
|
||||
content = item.content;
|
||||
const result = await Promise.race([
|
||||
extractUrlTitle(item.content.trim()),
|
||||
timeoutPromise
|
||||
]) as any;
|
||||
|
||||
if (result && result.title) {
|
||||
title = result.title;
|
||||
content = item.content;
|
||||
isBookmark = true;
|
||||
}
|
||||
} catch (titleError) {
|
||||
console.error("Error extracting URL title:", titleError);
|
||||
// Continue with default title if URL title extraction fails
|
||||
// Still mark as bookmark if it's a URL
|
||||
isBookmark = true;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error checking URL or extracting title:", error);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
// Simple array of tag objects for the note
|
||||
|
|
@ -123,17 +111,11 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
tags: tagObjects
|
||||
};
|
||||
|
||||
// First close the dropdown
|
||||
setDropdownOpen(false);
|
||||
|
||||
// Use a simple timeout to ensure the dropdown closes before opening modal
|
||||
setTimeout(() => {
|
||||
if (item.id !== undefined) {
|
||||
openNoteModal(newNote, item.id);
|
||||
} else {
|
||||
openNoteModal(newNote);
|
||||
}
|
||||
}, 50);
|
||||
if (item.id !== undefined) {
|
||||
openNoteModal(newNote, item.id);
|
||||
} else {
|
||||
openNoteModal(newNote);
|
||||
}
|
||||
};
|
||||
|
||||
const formattedDate = item.created_at
|
||||
|
|
@ -152,7 +134,11 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1">
|
||||
<div
|
||||
className="rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<div className="flex-1 mr-4">
|
||||
<p className="text-base font-medium text-gray-900 dark:text-gray-300 break-words">
|
||||
|
|
@ -166,64 +152,56 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-0">
|
||||
<div className="flex items-center space-x-1">
|
||||
{loading && <div className="spinner" />}
|
||||
|
||||
{/* Edit Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onUpdate && item.id !== undefined) {
|
||||
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"
|
||||
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'}`}
|
||||
title={t('common.edit')}
|
||||
>
|
||||
<PencilIcon className="h-5 w-5" />
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-full"
|
||||
title={t('inbox.convertTo', 'Convert to')}
|
||||
>
|
||||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{/* 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'}`}
|
||||
title={t('inbox.createTask')}
|
||||
>
|
||||
<ClipboardDocumentListIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-700 shadow-md rounded-md z-10">
|
||||
<ul className="py-1" role="menu" aria-orientation="vertical">
|
||||
<li
|
||||
className="px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer"
|
||||
onClick={handleConvertToTask}
|
||||
role="menuitem"
|
||||
>
|
||||
{t('inbox.createTask')}
|
||||
</li>
|
||||
<li
|
||||
className="px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer"
|
||||
onClick={handleConvertToProject}
|
||||
role="menuitem"
|
||||
>
|
||||
{t('inbox.createProject')}
|
||||
</li>
|
||||
<li
|
||||
className="px-4 py-1 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 cursor-pointer"
|
||||
onClick={handleConvertToNote}
|
||||
role="menuitem"
|
||||
>
|
||||
{t('inbox.createNote', 'Create Note')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 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'}`}
|
||||
title={t('inbox.createProject')}
|
||||
>
|
||||
<FolderIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 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'}`}
|
||||
title={t('inbox.createNote', 'Create Note')}
|
||||
>
|
||||
<DocumentTextIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* 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"
|
||||
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'}`}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -196,8 +196,13 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
const handleSaveTask = async (task: Task) => {
|
||||
try {
|
||||
await createTask(task);
|
||||
showSuccessToast(t('task.createSuccess'));
|
||||
const createdTask = await createTask(task);
|
||||
const taskLink = (
|
||||
<span>
|
||||
{t('task.created', 'Task')} <a href={`/task/${createdTask.uuid}`} className="text-green-200 underline hover:text-green-100">{createdTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')}
|
||||
</span>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
|
||||
// Process the inbox item after successful task creation
|
||||
if (currentConversionItemId !== null) {
|
||||
|
|
@ -326,7 +331,7 @@ const InboxItems: React.FC = () => {
|
|||
}}
|
||||
task={taskToEdit || { name: '', status: 'not_started', priority: 'medium' }}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={() => {}} // No need to delete since it's a new task
|
||||
onDelete={async () => {}} // No need to delete since it's a new task
|
||||
projects={Array.isArray(projects) ? projects : []}
|
||||
onCreateProject={handleCreateProject}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from "react";
|
|||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { UserIcon, Bars3Icon } from "@heroicons/react/24/solid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PomodoroTimer from "./Shared/PomodoroTimer";
|
||||
|
||||
interface NavbarProps {
|
||||
isDarkMode: boolean;
|
||||
|
|
@ -25,6 +26,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [pomodoroEnabled, setPomodoroEnabled] = useState(true); // Default to true
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
|
@ -43,6 +45,37 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Fetch user's pomodoro setting
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/profile', {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (response.ok) {
|
||||
const profile = await response.json();
|
||||
setPomodoroEnabled(profile.pomodoro_enabled !== undefined ? profile.pomodoro_enabled : true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching profile:', error);
|
||||
// Keep default value (true) if fetch fails
|
||||
}
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
|
||||
// Listen for Pomodoro setting changes from ProfileSettings
|
||||
const handlePomodoroSettingChange = (event: CustomEvent) => {
|
||||
setPomodoroEnabled(event.detail.enabled);
|
||||
};
|
||||
|
||||
window.addEventListener('pomodoroSettingChanged', handlePomodoroSettingChange as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pomodoroSettingChanged', handlePomodoroSettingChange as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const toggleDropdown = () => {
|
||||
setIsDropdownOpen(!isDropdownOpen);
|
||||
};
|
||||
|
|
@ -86,6 +119,8 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{pomodoroEnabled && <PomodoroTimer />}
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
|
|||
import { PencilSquareIcon, TrashIcon, TagIcon, DocumentTextIcon } from '@heroicons/react/24/solid';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
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';
|
||||
|
||||
|
|
@ -49,8 +50,8 @@ const NoteDetails: React.FC = () => {
|
|||
const handleSaveNote = async (updatedNote: Note) => {
|
||||
try {
|
||||
if (updatedNote.id !== undefined) {
|
||||
await apiUpdateNote(updatedNote.id, updatedNote);
|
||||
setNote(updatedNote);
|
||||
const savedNote = await apiUpdateNote(updatedNote.id, updatedNote);
|
||||
setNote(savedNote);
|
||||
} else {
|
||||
console.error("Error: Note ID is undefined.");
|
||||
}
|
||||
|
|
@ -120,52 +121,51 @@ const NoteDetails: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Card with Tags and Metadata */}
|
||||
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6">
|
||||
{/* Note Tags */}
|
||||
{note.tags && note.tags.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="mt-2 flex flex-wrap space-x-2">
|
||||
{note.tags.map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => navigate(`/tasks?tag=${tag.name}`)}
|
||||
className="flex items-center space-x-1 px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded-lg cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600"
|
||||
>
|
||||
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-300" />
|
||||
<span className="text-xs text-gray-700 dark:text-gray-300">
|
||||
{tag.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{/* Tags and Project */}
|
||||
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) || note.project || note.Project ? (
|
||||
<div className="bg-white dark:bg-gray-900 shadow-md rounded-lg p-4 mb-6">
|
||||
{/* Note Tags */}
|
||||
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-start">
|
||||
<TagIcon className="h-4 w-4 text-gray-500 dark:text-gray-400 mr-3 mt-0.5" />
|
||||
<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-2 mt-1">
|
||||
{(note.tags || note.Tags || []).map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => navigate(`/tag/${tag.id}`)}
|
||||
className="flex items-center space-x-1 px-2 py-1 bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 rounded-full cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors text-xs"
|
||||
>
|
||||
<TagIcon className="h-3 w-3" />
|
||||
<span>{tag.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Note Metadata */}
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
<p>Created on: {new Date(note.created_at || '').toLocaleDateString()}</p>
|
||||
<p>Last updated: {new Date(note.updated_at || '').toLocaleDateString()}</p>
|
||||
)}
|
||||
{/* Note Project */}
|
||||
{(note.project || note.Project) && (
|
||||
<div className={((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) ? "mt-4" : ""}>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Project
|
||||
</h3>
|
||||
<Link
|
||||
to={`/project/${(note.project || note.Project)?.id}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{(note.project || note.Project)?.name}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Note Project */}
|
||||
{note.project && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Project
|
||||
</h3>
|
||||
<Link
|
||||
to={`/project/${note.project.id}`}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline"
|
||||
>
|
||||
{note.project.name}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{/* Note Content */}
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-line">
|
||||
{note.content}
|
||||
</p>
|
||||
<div className="mb-6 bg-white dark:bg-gray-900 shadow-md rounded-lg p-6">
|
||||
<MarkdownRenderer content={note.content} />
|
||||
</div>
|
||||
{/* NoteModal for editing */}
|
||||
{isNoteModalOpen && (
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|||
import { Note } from '../../entities/Note';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import TagInput from '../Tag/TagInput';
|
||||
import MarkdownRenderer from '../Shared/MarkdownRenderer';
|
||||
import { Tag } from '../../entities/Tag';
|
||||
import { fetchTags } from '../../utils/tagsService';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { EyeIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||
interface NoteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -26,6 +28,7 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'edit' | 'preview'>('edit');
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
|
|
@ -194,18 +197,59 @@ const NoteModal: React.FC<NoteModalProps> = ({ isOpen, onClose, note, onSave })
|
|||
</div>
|
||||
|
||||
<div className="pb-3 flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.noteContent')}
|
||||
</label>
|
||||
<textarea
|
||||
id="noteContent"
|
||||
name="content"
|
||||
value={formData.content}
|
||||
onChange={handleChange}
|
||||
rows={20}
|
||||
className="block w-full h-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
placeholder={t('forms.noteContentPlaceholder')}
|
||||
></textarea>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('forms.noteContent')} <span className="text-gray-500">(Markdown supported)</span>
|
||||
</label>
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('edit')}
|
||||
className={`px-3 py-1 text-xs rounded-md flex items-center space-x-1 transition-colors ${
|
||||
activeTab === 'edit'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
<span>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className={`px-3 py-1 text-xs rounded-md flex items-center space-x-1 transition-colors ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<EyeIcon className="h-3 w-3" />
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeTab === 'edit' ? (
|
||||
<textarea
|
||||
id="noteContent"
|
||||
name="content"
|
||||
value={formData.content}
|
||||
onChange={handleChange}
|
||||
rows={20}
|
||||
className="block w-full h-full rounded-md shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 transition duration-150 ease-in-out"
|
||||
placeholder="Write your content using Markdown formatting... Examples: # Heading **Bold text** *Italic text* - List item ```code```"
|
||||
/>
|
||||
) : (
|
||||
<div className="block w-full h-full rounded-md shadow-sm p-3 text-sm bg-gray-50 dark:bg-gray-800 overflow-y-auto">
|
||||
{formData.content ? (
|
||||
<MarkdownRenderer content={formData.content} />
|
||||
) : (
|
||||
<p className="text-gray-500 dark:text-gray-400 italic">
|
||||
No content to preview. Switch to Edit tab to add content.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-red-500">{error}</div>}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BookOpenIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
MagnifyingGlassIcon,
|
||||
TagIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import NoteModal from './Note/NoteModal';
|
||||
import ConfirmDialog from './Shared/ConfirmDialog';
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
|
||||
const Notes: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [selectedNote, setSelectedNote] = useState<Note | null>(null);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
|
|
@ -27,15 +29,13 @@ const Notes: React.FC = () => {
|
|||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
|
@ -69,9 +69,9 @@ const Notes: React.FC = () => {
|
|||
try {
|
||||
let updatedNotes;
|
||||
if (noteData.id) {
|
||||
await updateNote(noteData.id, noteData);
|
||||
const savedNote = await updateNote(noteData.id, noteData);
|
||||
updatedNotes = notes.map((note) =>
|
||||
note.id === noteData.id ? noteData : note
|
||||
note.id === noteData.id ? savedNote : note
|
||||
);
|
||||
} else {
|
||||
const newNote = await createNote(noteData);
|
||||
|
|
@ -91,9 +91,6 @@ const Notes: React.FC = () => {
|
|||
note.content.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
console.log('All notes:', notes);
|
||||
console.log('Search query:', searchQuery);
|
||||
console.log('Filtered notes:', filteredNotes);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
|
@ -148,20 +145,44 @@ const Notes: React.FC = () => {
|
|||
{filteredNotes.map((note) => (
|
||||
<li
|
||||
key={note.id}
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-2 flex justify-between items-center"
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-3 flex justify-between items-center"
|
||||
onMouseEnter={() => setHoveredNoteId(note.id || null)}
|
||||
onMouseLeave={() => setHoveredNoteId(null)}
|
||||
>
|
||||
<div className="flex-grow overflow-hidden pr-4">
|
||||
<Link
|
||||
to={`/note/${note.id}`}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline block"
|
||||
>
|
||||
{note.title}
|
||||
</Link>
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<Link
|
||||
to={`/note/${note.id}`}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
|
||||
>
|
||||
{note.title}
|
||||
</Link>
|
||||
{/* Tags */}
|
||||
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && (
|
||||
<>
|
||||
{(note.tags || note.Tags || []).map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/tag/${tag.id}`);
|
||||
}}
|
||||
className="flex items-center space-x-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<TagIcon className="h-3 w-3 text-gray-500 dark:text-gray-300" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{tag.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditNote(note)}
|
||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label={t('notes.editNoteAriaLabel', { noteTitle: note.title })}
|
||||
title={t('notes.editNoteTitle', { noteTitle: note.title })}
|
||||
>
|
||||
|
|
@ -172,6 +193,7 @@ const Notes: React.FC = () => {
|
|||
setNoteToDelete(note);
|
||||
setIsConfirmDialogOpen(true);
|
||||
}}
|
||||
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label={t('notes.deleteNoteAriaLabel', { noteTitle: note.title })}
|
||||
title={t('notes.deleteNoteTitle', { noteTitle: note.title })}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ const ProductivityAssistant: React.FC<ProductivityAssistantProps> = ({ tasks, pr
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-white dark:bg-gray-900 border-l-4 border-yellow-500 rounded-lg shadow">
|
||||
<div className="mb-2 p-4 bg-white dark:bg-gray-900 border-l-4 border-yellow-500 rounded-lg shadow">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center w-full"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,22 @@
|
|||
import React, { useState, useEffect, ChangeEvent, FormEvent, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from 'i18next';
|
||||
import { InformationCircleIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
InformationCircleIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
UserIcon,
|
||||
ClockIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
ShieldCheckIcon,
|
||||
LightBulbIcon,
|
||||
CogIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
BoltIcon,
|
||||
ChevronRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FaceSmileIcon,
|
||||
CheckIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
|
||||
interface ProfileSettingsProps {
|
||||
|
|
@ -10,6 +25,7 @@ interface ProfileSettingsProps {
|
|||
toggleDarkMode?: () => void;
|
||||
}
|
||||
|
||||
|
||||
interface Profile {
|
||||
id: number;
|
||||
email: string;
|
||||
|
|
@ -24,15 +40,10 @@ interface Profile {
|
|||
task_intelligence_enabled: boolean;
|
||||
auto_suggest_next_actions_enabled: boolean;
|
||||
productivity_assistant_enabled: boolean;
|
||||
next_task_suggestion_enabled: boolean;
|
||||
pomodoro_enabled: boolean;
|
||||
}
|
||||
|
||||
interface SchedulerStatus {
|
||||
success: boolean;
|
||||
enabled: boolean;
|
||||
frequency: string;
|
||||
last_run: string | null;
|
||||
next_run: string | null;
|
||||
}
|
||||
|
||||
interface TelegramBotInfo {
|
||||
username: string;
|
||||
|
|
@ -40,11 +51,6 @@ interface TelegramBotInfo {
|
|||
chat_url: string;
|
||||
}
|
||||
|
||||
const capitalize = (str: string): string => {
|
||||
if (!str) return '';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
const formatFrequency = (frequency: string): string => {
|
||||
if (frequency.endsWith('h')) {
|
||||
const value = frequency.replace('h', '');
|
||||
|
|
@ -59,14 +65,19 @@ const formatFrequency = (frequency: string): string => {
|
|||
return frequency;
|
||||
};
|
||||
|
||||
const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMode, toggleDarkMode }) => {
|
||||
const ProfileSettings: React.FC<ProfileSettingsProps> = ({ isDarkMode, toggleDarkMode }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
const [activeTab, setActiveTab] = useState('general');
|
||||
|
||||
// Password visibility state
|
||||
const [showCurrentPassword, setShowCurrentPassword] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Profile>>({
|
||||
const [formData, setFormData] = useState<Partial<Profile & {currentPassword: string, newPassword: string, confirmPassword: string}>>({
|
||||
appearance: isDarkMode ? 'dark' : 'light',
|
||||
language: 'en',
|
||||
timezone: 'UTC',
|
||||
|
|
@ -77,70 +88,48 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
task_summary_frequency: 'daily',
|
||||
auto_suggest_next_actions_enabled: true,
|
||||
productivity_assistant_enabled: true,
|
||||
next_task_suggestion_enabled: true,
|
||||
pomodoro_enabled: true,
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [updateKey, setUpdateKey] = useState(0);
|
||||
const [isChangingLanguage, setIsChangingLanguage] = useState(false);
|
||||
const [telegramBotToken, setTelegramBotToken] = useState('');
|
||||
const [telegramChatId, setTelegramChatId] = useState('');
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [isSendingSummary, setIsSendingSummary] = useState(false);
|
||||
const [schedulerStatus, setSchedulerStatus] = useState<SchedulerStatus | null>(null);
|
||||
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||
const [isPolling, setIsPolling] = useState(false);
|
||||
const [telegramSetupStatus, setTelegramSetupStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [telegramError, setTelegramError] = useState<string | null>(null);
|
||||
const [telegramBotInfo, setTelegramBotInfo] = useState<TelegramBotInfo | null>(null);
|
||||
|
||||
const forceUpdate = useCallback(() => {
|
||||
setUpdateKey(prevKey => prevKey + 1);
|
||||
}, []);
|
||||
|
||||
const fetchSchedulerStatus = async () => {
|
||||
try {
|
||||
setLoadingStatus(true);
|
||||
const response = await fetch('/api/profile/task-summary/status');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(t('profile.statusFetchError', 'Failed to fetch scheduler status.'));
|
||||
// Password validation
|
||||
const validatePasswordForm = (): {valid: boolean, errors: {[key: string]: string}} => {
|
||||
const errors: {[key: string]: string} = {};
|
||||
|
||||
// Only validate if user is trying to change password
|
||||
if (formData.currentPassword || formData.newPassword || formData.confirmPassword) {
|
||||
if (!formData.currentPassword) {
|
||||
errors.currentPassword = t('profile.currentPasswordRequired', 'Current password is required');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setSchedulerStatus(data);
|
||||
} catch (error) {
|
||||
showErrorToast((error as Error).message);
|
||||
} finally {
|
||||
setLoadingStatus(false);
|
||||
if (!formData.newPassword) {
|
||||
errors.newPassword = t('profile.newPasswordRequired', 'New password is required');
|
||||
} else if (formData.newPassword.length < 6) {
|
||||
errors.newPassword = t('profile.passwordTooShort', 'Password must be at least 6 characters');
|
||||
}
|
||||
|
||||
if (formData.newPassword !== formData.confirmPassword) {
|
||||
errors.confirmPassword = t('profile.passwordMismatch', 'Passwords do not match');
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: Object.keys(errors).length === 0, errors };
|
||||
};
|
||||
|
||||
const handleSendTaskSummaryNow = async () => {
|
||||
try {
|
||||
setIsSendingSummary(true);
|
||||
const response = await fetch('/api/profile/task-summary/send-now', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || t('profile.sendSummaryFailed', 'Failed to send summary.'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
showSuccessToast(data.message);
|
||||
|
||||
if (data.enabled) {
|
||||
fetchSchedulerStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast((error as Error).message);
|
||||
} finally {
|
||||
setIsSendingSummary(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
|
|
@ -217,13 +206,11 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
task_summary_frequency: data.task_summary_frequency || 'daily',
|
||||
auto_suggest_next_actions_enabled: data.auto_suggest_next_actions_enabled !== undefined ? data.auto_suggest_next_actions_enabled : true,
|
||||
productivity_assistant_enabled: data.productivity_assistant_enabled !== undefined ? data.productivity_assistant_enabled : true,
|
||||
next_task_suggestion_enabled: data.next_task_suggestion_enabled !== undefined ? data.next_task_suggestion_enabled : true,
|
||||
pomodoro_enabled: data.pomodoro_enabled !== undefined ? data.pomodoro_enabled : true,
|
||||
});
|
||||
setTelegramBotToken(data.telegram_bot_token || '');
|
||||
setTelegramChatId(data.telegram_chat_id || '');
|
||||
|
||||
if (data.task_summary_enabled) {
|
||||
fetchSchedulerStatus();
|
||||
}
|
||||
// Note: Task summary status checking functionality removed for now
|
||||
|
||||
if (data.telegram_bot_token) {
|
||||
fetchPollingStatus();
|
||||
|
|
@ -262,11 +249,11 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
}, [isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng: string) => {
|
||||
const handleLanguageChanged = () => {
|
||||
forceUpdate();
|
||||
};
|
||||
|
||||
const handleAppLanguageChanged = (event: CustomEvent<{ language: string }>) => {
|
||||
const handleAppLanguageChanged = () => {
|
||||
forceUpdate();
|
||||
setTimeout(() => {
|
||||
setIsChangingLanguage(false);
|
||||
|
|
@ -285,7 +272,6 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
|
||||
const handleSetupTelegram = async () => {
|
||||
setTelegramSetupStatus('loading');
|
||||
setTelegramError(null);
|
||||
setTelegramBotInfo(null);
|
||||
|
||||
try {
|
||||
|
|
@ -308,7 +294,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
|
||||
const data = await response.json();
|
||||
setTelegramSetupStatus('success');
|
||||
setSuccess(t('profile.telegramSetupSuccess'));
|
||||
showSuccessToast(t('profile.telegramSetupSuccess', 'Telegram bot configured successfully!'));
|
||||
|
||||
if (data.bot) {
|
||||
setTelegramBotInfo(data.bot);
|
||||
|
|
@ -327,7 +313,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
|
||||
} catch (error) {
|
||||
setTelegramSetupStatus('error');
|
||||
setTelegramError((error as Error).message);
|
||||
showErrorToast((error as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -391,10 +377,28 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
// Check if user is trying to change password
|
||||
const isPasswordChange = formData.currentPassword || formData.newPassword || formData.confirmPassword;
|
||||
|
||||
// Only validate password if user is trying to change password
|
||||
if (isPasswordChange) {
|
||||
const passwordValidation = validatePasswordForm();
|
||||
if (!passwordValidation.valid) {
|
||||
showErrorToast(Object.values(passwordValidation.errors)[0]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare data to send - exclude password fields if not changing password
|
||||
const dataToSend = { ...formData };
|
||||
if (!isPasswordChange) {
|
||||
delete dataToSend.currentPassword;
|
||||
delete dataToSend.newPassword;
|
||||
delete dataToSend.confirmPassword;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
|
|
@ -402,7 +406,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
body: JSON.stringify(dataToSend),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -426,6 +430,8 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
task_summary_frequency: updatedProfile.task_summary_frequency || prev.task_summary_frequency || 'daily',
|
||||
auto_suggest_next_actions_enabled: updatedProfile.auto_suggest_next_actions_enabled !== undefined ? updatedProfile.auto_suggest_next_actions_enabled : prev.auto_suggest_next_actions_enabled !== undefined ? prev.auto_suggest_next_actions_enabled : true,
|
||||
productivity_assistant_enabled: updatedProfile.productivity_assistant_enabled !== undefined ? updatedProfile.productivity_assistant_enabled : prev.productivity_assistant_enabled !== undefined ? prev.productivity_assistant_enabled : true,
|
||||
next_task_suggestion_enabled: updatedProfile.next_task_suggestion_enabled !== undefined ? updatedProfile.next_task_suggestion_enabled : prev.next_task_suggestion_enabled !== undefined ? prev.next_task_suggestion_enabled : true,
|
||||
pomodoro_enabled: updatedProfile.pomodoro_enabled !== undefined ? updatedProfile.pomodoro_enabled : prev.pomodoro_enabled !== undefined ? prev.pomodoro_enabled : true,
|
||||
}));
|
||||
|
||||
// Apply appearance change after save
|
||||
|
|
@ -438,9 +444,29 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
await handleLanguageChange(updatedProfile.language);
|
||||
}
|
||||
|
||||
setSuccess(t('profile.successMessage'));
|
||||
// Notify other components about Pomodoro setting change
|
||||
if (updatedProfile.pomodoro_enabled !== undefined) {
|
||||
window.dispatchEvent(new CustomEvent('pomodoroSettingChanged', {
|
||||
detail: { enabled: updatedProfile.pomodoro_enabled }
|
||||
}));
|
||||
}
|
||||
|
||||
// Clear password fields on successful save
|
||||
if (isPasswordChange) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
}));
|
||||
}
|
||||
|
||||
const successMessage = isPasswordChange
|
||||
? t('profile.passwordChangeSuccess', 'Password changed successfully!')
|
||||
: t('profile.successMessage', 'Profile updated successfully!');
|
||||
showSuccessToast(successMessage);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
showErrorToast((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -454,16 +480,11 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-red-500 text-lg">{error}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'general', name: t('profile.tabs.general', 'General'), icon: 'user' },
|
||||
{ id: 'security', name: t('profile.tabs.security', 'Security'), icon: 'shield' },
|
||||
{ id: 'productivity', name: t('profile.tabs.productivity', 'Productivity'), icon: 'clock' },
|
||||
{ id: 'telegram', name: t('profile.tabs.telegram', 'Telegram'), icon: 'chat' },
|
||||
{ id: 'ai', name: t('profile.tabs.ai', 'AI Features'), icon: 'sparkles' },
|
||||
];
|
||||
|
|
@ -471,23 +492,15 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
const renderTabIcon = (iconType: string) => {
|
||||
switch (iconType) {
|
||||
case 'user':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
);
|
||||
return <UserIcon className="w-5 h-5" />;
|
||||
case 'clock':
|
||||
return <ClockIcon className="w-5 h-5" />;
|
||||
case 'chat':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
);
|
||||
return <ChatBubbleLeftRightIcon className="w-5 h-5" />;
|
||||
case 'shield':
|
||||
return <ShieldCheckIcon className="w-5 h-5" />;
|
||||
case 'sparkles':
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
);
|
||||
return <LightBulbIcon className="w-5 h-5" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -522,8 +535,6 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{success && <div className="mb-4 text-green-500">{success}</div>}
|
||||
{error && <div className="mb-4 text-red-500">{error}</div>}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
|
||||
|
|
@ -531,9 +542,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
{activeTab === 'general' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
||||
<svg className="w-6 h-6 mr-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||
</svg>
|
||||
<UserIcon className="w-6 h-6 mr-3 text-blue-500" />
|
||||
{t('profile.accountSettings', 'Account & Preferences')}
|
||||
</h3>
|
||||
|
||||
|
|
@ -583,32 +592,270 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="UTC">UTC</option>
|
||||
<option value="America/New_York">America/New_York</option>
|
||||
<option value="Europe/London">Europe/London</option>
|
||||
<option value="Asia/Tokyo">Asia/Tokyo</option>
|
||||
|
||||
{/* Americas */}
|
||||
<optgroup label="Americas">
|
||||
<option value="America/New_York">Eastern Time (New York)</option>
|
||||
<option value="America/Chicago">Central Time (Chicago)</option>
|
||||
<option value="America/Denver">Mountain Time (Denver)</option>
|
||||
<option value="America/Los_Angeles">Pacific Time (Los Angeles)</option>
|
||||
<option value="America/Anchorage">Alaska Time (Anchorage)</option>
|
||||
<option value="Pacific/Honolulu">Hawaii Time (Honolulu)</option>
|
||||
<option value="America/Toronto">Eastern Time (Toronto)</option>
|
||||
<option value="America/Vancouver">Pacific Time (Vancouver)</option>
|
||||
<option value="America/Mexico_City">Central Time (Mexico City)</option>
|
||||
<option value="America/Sao_Paulo">Brasília Time (São Paulo)</option>
|
||||
<option value="America/Argentina/Buenos_Aires">Argentina Time (Buenos Aires)</option>
|
||||
<option value="America/Lima">Peru Time (Lima)</option>
|
||||
<option value="America/Bogota">Colombia Time (Bogotá)</option>
|
||||
<option value="America/Caracas">Venezuela Time (Caracas)</option>
|
||||
<option value="America/Santiago">Chile Time (Santiago)</option>
|
||||
</optgroup>
|
||||
|
||||
{/* Europe */}
|
||||
<optgroup label="Europe">
|
||||
<option value="Europe/London">Greenwich Mean Time (London)</option>
|
||||
<option value="Europe/Dublin">Greenwich Mean Time (Dublin)</option>
|
||||
<option value="Europe/Lisbon">Western European Time (Lisbon)</option>
|
||||
<option value="Europe/Paris">Central European Time (Paris)</option>
|
||||
<option value="Europe/Berlin">Central European Time (Berlin)</option>
|
||||
<option value="Europe/Madrid">Central European Time (Madrid)</option>
|
||||
<option value="Europe/Rome">Central European Time (Rome)</option>
|
||||
<option value="Europe/Amsterdam">Central European Time (Amsterdam)</option>
|
||||
<option value="Europe/Brussels">Central European Time (Brussels)</option>
|
||||
<option value="Europe/Vienna">Central European Time (Vienna)</option>
|
||||
<option value="Europe/Zurich">Central European Time (Zurich)</option>
|
||||
<option value="Europe/Prague">Central European Time (Prague)</option>
|
||||
<option value="Europe/Warsaw">Central European Time (Warsaw)</option>
|
||||
<option value="Europe/Stockholm">Central European Time (Stockholm)</option>
|
||||
<option value="Europe/Oslo">Central European Time (Oslo)</option>
|
||||
<option value="Europe/Copenhagen">Central European Time (Copenhagen)</option>
|
||||
<option value="Europe/Helsinki">Eastern European Time (Helsinki)</option>
|
||||
<option value="Europe/Athens">Eastern European Time (Athens)</option>
|
||||
<option value="Europe/Kiev">Eastern European Time (Kiev)</option>
|
||||
<option value="Europe/Moscow">Moscow Time (Moscow)</option>
|
||||
<option value="Europe/Istanbul">Turkey Time (Istanbul)</option>
|
||||
</optgroup>
|
||||
|
||||
{/* Asia */}
|
||||
<optgroup label="Asia">
|
||||
<option value="Asia/Dubai">Gulf Standard Time (Dubai)</option>
|
||||
<option value="Asia/Tehran">Iran Standard Time (Tehran)</option>
|
||||
<option value="Asia/Yerevan">Armenia Time (Yerevan)</option>
|
||||
<option value="Asia/Baku">Azerbaijan Time (Baku)</option>
|
||||
<option value="Asia/Karachi">Pakistan Standard Time (Karachi)</option>
|
||||
<option value="Asia/Kolkata">India Standard Time (Mumbai/Delhi)</option>
|
||||
<option value="Asia/Kathmandu">Nepal Time (Kathmandu)</option>
|
||||
<option value="Asia/Dhaka">Bangladesh Standard Time (Dhaka)</option>
|
||||
<option value="Asia/Yangon">Myanmar Time (Yangon)</option>
|
||||
<option value="Asia/Bangkok">Indochina Time (Bangkok)</option>
|
||||
<option value="Asia/Ho_Chi_Minh">Indochina Time (Ho Chi Minh)</option>
|
||||
<option value="Asia/Jakarta">Western Indonesia Time (Jakarta)</option>
|
||||
<option value="Asia/Kuala_Lumpur">Malaysia Time (Kuala Lumpur)</option>
|
||||
<option value="Asia/Singapore">Singapore Standard Time (Singapore)</option>
|
||||
<option value="Asia/Manila">Philippines Time (Manila)</option>
|
||||
<option value="Asia/Hong_Kong">Hong Kong Time (Hong Kong)</option>
|
||||
<option value="Asia/Shanghai">China Standard Time (Beijing/Shanghai)</option>
|
||||
<option value="Asia/Taipei">China Standard Time (Taipei)</option>
|
||||
<option value="Asia/Tokyo">Japan Standard Time (Tokyo)</option>
|
||||
<option value="Asia/Seoul">Korea Standard Time (Seoul)</option>
|
||||
<option value="Asia/Vladivostok">Vladivostok Time (Vladivostok)</option>
|
||||
</optgroup>
|
||||
|
||||
{/* Africa */}
|
||||
<optgroup label="Africa">
|
||||
<option value="Africa/Casablanca">Western European Time (Casablanca)</option>
|
||||
<option value="Africa/Lagos">West Africa Time (Lagos)</option>
|
||||
<option value="Africa/Cairo">Eastern European Time (Cairo)</option>
|
||||
<option value="Africa/Johannesburg">South Africa Standard Time (Johannesburg)</option>
|
||||
<option value="Africa/Nairobi">East Africa Time (Nairobi)</option>
|
||||
<option value="Africa/Addis_Ababa">East Africa Time (Addis Ababa)</option>
|
||||
</optgroup>
|
||||
|
||||
{/* Oceania */}
|
||||
<optgroup label="Oceania">
|
||||
<option value="Australia/Perth">Australian Western Standard Time (Perth)</option>
|
||||
<option value="Australia/Adelaide">Australian Central Standard Time (Adelaide)</option>
|
||||
<option value="Australia/Darwin">Australian Central Standard Time (Darwin)</option>
|
||||
<option value="Australia/Brisbane">Australian Eastern Standard Time (Brisbane)</option>
|
||||
<option value="Australia/Sydney">Australian Eastern Standard Time (Sydney)</option>
|
||||
<option value="Australia/Melbourne">Australian Eastern Standard Time (Melbourne)</option>
|
||||
<option value="Pacific/Auckland">New Zealand Standard Time (Auckland)</option>
|
||||
<option value="Pacific/Fiji">Fiji Time (Suva)</option>
|
||||
<option value="Pacific/Guam">Chamorro Standard Time (Guam)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Tab */}
|
||||
{activeTab === 'security' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
||||
<ShieldCheckIcon className="w-6 h-6 mr-3 text-red-500" />
|
||||
{t('profile.security', 'Security Settings')}
|
||||
</h3>
|
||||
|
||||
{/* Password Change Section */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<UserIcon className="w-5 h-5 mr-2 text-blue-500" />
|
||||
{t('profile.changePassword', 'Change Password')}
|
||||
</h4>
|
||||
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded text-blue-800 dark:text-blue-200">
|
||||
<p className="text-sm">
|
||||
<InformationCircleIcon className="w-4 h-4 inline mr-1" />
|
||||
{t('profile.passwordChangeOptional', 'Leave password fields empty to update other settings without changing your password.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.currentPassword', 'Current Password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
name="currentPassword"
|
||||
value={formData.currentPassword || ''}
|
||||
onChange={handleChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder={t('profile.enterCurrentPassword', 'Enter your current password')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.newPassword', 'New Password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
name="newPassword"
|
||||
value={formData.newPassword || ''}
|
||||
onChange={handleChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder={t('profile.enterNewPassword', 'Enter your new password')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.confirmPassword', 'Confirm New Password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword || ''}
|
||||
onChange={handleChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder={t('profile.confirmNewPassword', 'Confirm your new password')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('profile.passwordChangeNote', 'Password changes will be saved when you click "Save Changes" at the bottom of the form.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Productivity Tab */}
|
||||
{activeTab === 'productivity' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
||||
<ClockIcon className="w-6 h-6 mr-3 text-green-500" />
|
||||
{t('profile.productivityFeatures', 'Productivity Features')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Pomodoro Timer */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('profile.enablePomodoro', 'Enable Pomodoro Timer')}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('profile.pomodoroDescription', 'Enable the Pomodoro timer in the navigation bar for focused work sessions.')}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
||||
formData.pomodoro_enabled ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
pomodoro_enabled: !prev.pomodoro_enabled
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
||||
formData.pomodoro_enabled ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Telegram Tab */}
|
||||
{activeTab === 'telegram' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-blue-300 dark:border-blue-700 mb-8">
|
||||
<h3 className="text-xl font-semibold text-blue-700 dark:text-blue-300 mb-6 flex items-center">
|
||||
<svg className="w-6 h-6 mr-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
<ChatBubbleLeftRightIcon className="w-6 h-6 mr-3 text-blue-500" />
|
||||
{t('profile.telegramIntegration', 'Telegram Integration')}
|
||||
</h3>
|
||||
|
||||
{/* Bot Setup Subsection */}
|
||||
<div className="mb-8 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<CogIcon className="w-5 h-5 mr-2 text-blue-500" />
|
||||
{t('profile.botSetup', 'Bot Setup')}
|
||||
</h4>
|
||||
|
||||
|
|
@ -643,11 +890,6 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
</div>
|
||||
)}
|
||||
|
||||
{telegramError && (
|
||||
<div className="p-2 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-800 rounded text-red-800 dark:text-red-200">
|
||||
<p className="text-sm">{telegramError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{telegramBotInfo && (
|
||||
<div className="p-2 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded text-blue-800 dark:text-blue-200">
|
||||
|
|
@ -743,9 +985,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
{/* Task Summary Notifications Subsection */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-5 5v-5zM4 19h6v-1a1 1 0 011-1h4a1 1 0 011 1v1h2a2 2 0 002-2V7a2 2 0 00-2-2H4a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<ClipboardDocumentListIcon className="w-5 h-5 mr-2 text-green-500" />
|
||||
{t('profile.taskSummaryNotifications', 'Task Summary Notifications')}
|
||||
</h4>
|
||||
|
||||
|
|
@ -853,18 +1093,14 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
{activeTab === 'ai' && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
||||
<svg className="w-6 h-6 mr-3 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<LightBulbIcon className="w-6 h-6 mr-3 text-blue-500" />
|
||||
{t('profile.aiProductivityFeatures', 'AI & Productivity Features')}
|
||||
</h3>
|
||||
|
||||
{/* Task Intelligence Subsection */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<BoltIcon className="w-5 h-5 mr-2 text-purple-500" />
|
||||
{t('profile.taskIntelligence', 'Task Intelligence')}
|
||||
</h4>
|
||||
|
||||
|
|
@ -902,9 +1138,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
{/* Auto-Suggest Next Actions Subsection */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<ChevronRightIcon className="w-5 h-5 mr-2 text-green-500" />
|
||||
{t('profile.autoSuggestNextActions', 'Auto-Suggest Next Actions')}
|
||||
</h4>
|
||||
|
||||
|
|
@ -942,9 +1176,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
{/* Productivity Assistant Subsection */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<svg className="w-5 h-5 mr-2 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.464 0L4.35 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
<ExclamationTriangleIcon className="w-5 h-5 mr-2 text-yellow-500" />
|
||||
{t('profile.productivityAssistant', 'Productivity Assistant')}
|
||||
</h4>
|
||||
|
||||
|
|
@ -978,6 +1210,44 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Next Task Suggestion Subsection */}
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<FaceSmileIcon className="w-5 h-5 mr-2 text-green-500" />
|
||||
{t('profile.nextTaskSuggestion', 'Next Task Suggestion')}
|
||||
</h4>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
||||
<p>
|
||||
{t('profile.nextTaskSuggestionDescription', 'Automatically suggest the next best task to work on when you have nothing in progress, prioritizing due today tasks, then suggested tasks, then next actions.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('profile.enableNextTaskSuggestion', 'Enable Next Task Suggestions')}
|
||||
</label>
|
||||
<div
|
||||
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
||||
formData.next_task_suggestion_enabled ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
next_task_suggestion_enabled: !prev.next_task_suggestion_enabled
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
||||
formData.next_task_suggestion_enabled ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -987,9 +1257,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMo
|
|||
type="submit"
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 transition-colors duration-200 flex items-center space-x-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<CheckIcon className="w-5 h-5" />
|
||||
<span>{t('profile.saveChanges', 'Save Changes')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const AutoSuggestNextActionBox: React.FC<AutoSuggestNextActionBoxProps> = ({
|
|||
e.preventDefault();
|
||||
if (actionDescription.trim()) {
|
||||
onAddAction(actionDescription.trim());
|
||||
showSuccessToast("Next action added successfully!");
|
||||
showSuccessToast(t('success.nextActionAdded'));
|
||||
setActionDescription("");
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { useParams, useNavigate, Link } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import {
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
|
|
@ -15,7 +16,7 @@ import NewTask from "../Task/NewTask";
|
|||
import { Project } from "../../entities/Project";
|
||||
import { PriorityType, Task } from "../../entities/Task";
|
||||
import { fetchProjectById, updateProject, deleteProject } from "../../utils/projectsService";
|
||||
import { createTask, updateTask, deleteTask } from "../../utils/tasksService";
|
||||
import { createTask, updateTask, deleteTask, toggleTaskToday } from "../../utils/tasksService";
|
||||
import { fetchAreas } from "../../utils/areasService";
|
||||
import { isAuthError } from "../../utils/authUtils";
|
||||
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/react/24/solid";
|
||||
|
|
@ -35,6 +36,7 @@ const ProjectDetails: React.FC = () => {
|
|||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showSuccessToast } = useToast();
|
||||
|
||||
const areas = useStore((state) => state.areasStore.areas);
|
||||
|
||||
|
|
@ -58,9 +60,6 @@ const ProjectDetails: React.FC = () => {
|
|||
try {
|
||||
fetchAreas();
|
||||
const projectData = await fetchProjectById(id);
|
||||
console.log("Project data received:", projectData);
|
||||
console.log("Tasks in project:", projectData.tasks);
|
||||
console.log("Tasks (capital T):", projectData.Tasks);
|
||||
setProject(projectData);
|
||||
// Handle both 'tasks' and 'Tasks' property names
|
||||
const projectTasks = projectData.tasks || projectData.Tasks || [];
|
||||
|
|
@ -102,6 +101,14 @@ const ProjectDetails: React.FC = () => {
|
|||
project_id: project.id,
|
||||
});
|
||||
setTasks((prevTasks) => [...prevTasks, newTask]);
|
||||
|
||||
// Show success toast with task link
|
||||
const taskLink = (
|
||||
<span>
|
||||
{t('task.created', 'Task')} <a href={`/task/${newTask.uuid}`} className="text-green-200 underline hover:text-green-100">{newTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')}
|
||||
</span>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
} catch (err: any) {
|
||||
console.error("Error creating task:", err);
|
||||
// Check if it's an authentication error
|
||||
|
|
@ -142,6 +149,30 @@ const ProjectDetails: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleToggleToday = async (taskId: number): Promise<void> => {
|
||||
try {
|
||||
const updatedTask = await toggleTaskToday(taskId);
|
||||
// Update the task in the local state immediately to avoid UI flashing
|
||||
setTasks(prevTasks =>
|
||||
prevTasks.map(task =>
|
||||
task.id === taskId ? { ...task, today: updatedTask.today, today_move_count: updatedTask.today_move_count } : task
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error toggling task today status:", error);
|
||||
// Optionally refetch data on error to ensure consistency
|
||||
if (id) {
|
||||
try {
|
||||
const updatedProject = await fetchProjectById(id);
|
||||
setProject(updatedProject);
|
||||
setTasks(updatedProject.tasks || []);
|
||||
} catch (refetchError) {
|
||||
console.error("Error refetching project data:", refetchError);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditProject = () => {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
|
@ -173,6 +204,14 @@ const ProjectDetails: React.FC = () => {
|
|||
// Update the tasks list to include the new task
|
||||
setTasks(prevTasks => [...prevTasks, newTask]);
|
||||
setShowAutoSuggestForm(false);
|
||||
|
||||
// Show success toast with task link
|
||||
const taskLink = (
|
||||
<span>
|
||||
{t('task.created', 'Task')} <a href={`/task/${newTask.uuid}`} className="text-green-200 underline hover:text-green-100">{newTask.name}</a> {t('task.createdSuccessfully', 'created successfully!')}
|
||||
</span>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
} catch (error) {
|
||||
console.error("Error creating next action:", error);
|
||||
}
|
||||
|
|
@ -264,11 +303,11 @@ const ProjectDetails: React.FC = () => {
|
|||
</h1>
|
||||
</div>
|
||||
{/* Priority Indicator on Image */}
|
||||
{project.priority && (
|
||||
{project.priority !== undefined && project.priority !== null && (
|
||||
<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
|
||||
getPriorityStyle(project.priority)
|
||||
}`}
|
||||
title={`Priority: ${priorityLabel(project.priority)}`}
|
||||
aria-label={`Priority: ${priorityLabel(project.priority)}`}
|
||||
|
|
@ -340,12 +379,13 @@ const ProjectDetails: React.FC = () => {
|
|||
<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
|
||||
<button
|
||||
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"
|
||||
onClick={() => navigate(`/tag/${tag.id}`)}
|
||||
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 cursor-pointer hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -364,10 +404,10 @@ const ProjectDetails: React.FC = () => {
|
|||
{project.name}
|
||||
</h2>
|
||||
{/* Show priority indicator only when no image */}
|
||||
{project.priority && (
|
||||
{project.priority !== undefined && project.priority !== null && (
|
||||
<div
|
||||
className={`w-4 h-4 rounded-full border-2 border-white dark:border-gray-800 ${
|
||||
priorityStyles[project.priority] || priorityStyles.default
|
||||
getPriorityStyle(project.priority)
|
||||
}`}
|
||||
title={`Priority: ${priorityLabel(project.priority)}`}
|
||||
aria-label={`Priority: ${priorityLabel(project.priority)}`}
|
||||
|
|
@ -429,6 +469,7 @@ const ProjectDetails: React.FC = () => {
|
|||
onTaskDelete={handleTaskDelete}
|
||||
projects={project ? [project] : []}
|
||||
hideProjectName={true}
|
||||
onToggleToday={handleToggleToday}
|
||||
/>
|
||||
) : showAutoSuggestForm ? (
|
||||
<AutoSuggestNextActionBox
|
||||
|
|
@ -467,8 +508,13 @@ const ProjectDetails: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const priorityLabel = (priority: PriorityType) => {
|
||||
switch (priority) {
|
||||
const priorityLabel = (priority: PriorityType | number) => {
|
||||
// Handle both string and numeric priorities
|
||||
const normalizedPriority = typeof priority === 'number'
|
||||
? (['low', 'medium', 'high'][priority] as PriorityType)
|
||||
: priority;
|
||||
|
||||
switch (normalizedPriority) {
|
||||
case 'high':
|
||||
return 'High';
|
||||
case 'medium':
|
||||
|
|
@ -480,4 +526,13 @@ const priorityLabel = (priority: PriorityType) => {
|
|||
}
|
||||
};
|
||||
|
||||
const getPriorityStyle = (priority: PriorityType | number) => {
|
||||
// Handle both string and numeric priorities
|
||||
const normalizedPriority = typeof priority === 'number'
|
||||
? (['low', 'medium', 'high'][priority] as PriorityType)
|
||||
: priority;
|
||||
|
||||
return priorityStyles[normalizedPriority] || priorityStyles.default;
|
||||
};
|
||||
|
||||
export default ProjectDetails;
|
||||
|
|
@ -145,7 +145,10 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
{viewMode === "cards" && (
|
||||
<div className="absolute bottom-4 left-0 right-0 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2"
|
||||
title={t("projectItem.completionPercentage", { percentage: getCompletionPercentage() })}
|
||||
>
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
style={{
|
||||
|
|
@ -153,9 +156,6 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
|
|||
}}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t("projectItem.completionPercentage", { percentage: getCompletionPercentage() })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -87,14 +87,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
useEffect(() => {
|
||||
if (availableTags.length === 0) {
|
||||
console.log('Loading tags...');
|
||||
loadTags().then(() => {
|
||||
console.log('Tags loaded successfully');
|
||||
}).catch(error => {
|
||||
loadTags().catch(error => {
|
||||
console.error('Error loading tags:', error);
|
||||
});
|
||||
} else {
|
||||
console.log('Available tags:', availableTags);
|
||||
}
|
||||
}, [availableTags.length, loadTags]);
|
||||
|
||||
|
|
@ -155,13 +150,11 @@ 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>) => {
|
||||
|
|
@ -258,7 +251,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
const handleDeleteConfirm = () => {
|
||||
if (project && project.id && onDelete) {
|
||||
onDelete(project.id);
|
||||
showSuccessToast("Project deleted successfully!");
|
||||
showSuccessToast(t('success.projectDeleted'));
|
||||
setShowConfirmDialog(false);
|
||||
handleClose();
|
||||
}
|
||||
|
|
|
|||
47
frontend/components/Shared/CollapsibleSection.tsx
Normal file
47
frontend/components/Shared/CollapsibleSection.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from 'react';
|
||||
import { ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
interface CollapsibleSectionProps {
|
||||
title: string;
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
|
||||
title,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
children,
|
||||
className = ""
|
||||
}) => {
|
||||
return (
|
||||
<div className={`border-b border-gray-200 dark:border-gray-700 ${className}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full px-4 py-3 flex items-center justify-between text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</span>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="h-4 w-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRightIcon className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className={`transition-all duration-300 ease-in-out ${
|
||||
isExpanded ? 'max-h-[500px] opacity-100' : 'max-h-0 opacity-0 overflow-hidden'
|
||||
}`}>
|
||||
<div className="px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollapsibleSection;
|
||||
100
frontend/components/Shared/MarkdownRenderer.tsx
Normal file
100
frontend/components/Shared/MarkdownRenderer.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import hljs from 'highlight.js';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '' }) => {
|
||||
useEffect(() => {
|
||||
// Configure highlight.js
|
||||
hljs.configure({
|
||||
languages: ['javascript', 'typescript', 'python', 'java', 'css', 'html', 'json', 'bash', 'sql', 'yaml', 'xml', 'dockerfile', 'nginx', 'apache']
|
||||
});
|
||||
|
||||
// Manual highlighting for any missed code blocks
|
||||
const timer = setTimeout(() => {
|
||||
const codeBlocks = document.querySelectorAll('pre code:not(.hljs)');
|
||||
codeBlocks.forEach((block) => {
|
||||
hljs.highlightElement(block as HTMLElement);
|
||||
});
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div className={`markdown-content ${className}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[[rehypeHighlight, { detect: true, ignoreMissing: true }]]}
|
||||
components={{
|
||||
// Customize heading styles
|
||||
h1: ({...props}) => <h1 className="text-3xl font-bold mb-4 text-gray-900 dark:text-gray-100" {...props} />,
|
||||
h2: ({...props}) => <h2 className="text-2xl font-semibold mb-3 text-gray-900 dark:text-gray-100" {...props} />,
|
||||
h3: ({...props}) => <h3 className="text-xl font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />,
|
||||
h4: ({...props}) => <h4 className="text-lg font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />,
|
||||
h5: ({...props}) => <h5 className="text-base font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />,
|
||||
h6: ({...props}) => <h6 className="text-sm font-medium mb-2 text-gray-900 dark:text-gray-100" {...props} />,
|
||||
|
||||
// Customize paragraph styles
|
||||
p: ({...props}) => <p className="mb-3 text-gray-700 dark:text-gray-300 leading-relaxed" {...props} />,
|
||||
|
||||
// Customize list styles
|
||||
ul: ({...props}) => <ul className="mb-3 list-disc list-inside space-y-1 text-gray-700 dark:text-gray-300" {...props} />,
|
||||
ol: ({...props}) => <ol className="mb-3 list-decimal list-inside space-y-1 text-gray-700 dark:text-gray-300" {...props} />,
|
||||
li: ({...props}) => <li className="ml-4" {...props} />,
|
||||
|
||||
// Customize link styles
|
||||
a: ({...props}) => <a className="text-blue-600 dark:text-blue-400 hover:underline" {...props} />,
|
||||
|
||||
// Customize code styles
|
||||
code: ({className, children, ...props}) => {
|
||||
// Check if this is a code block (has language class) or inline code
|
||||
const isCodeBlock = className && className.startsWith('language-');
|
||||
|
||||
if (isCodeBlock) {
|
||||
// This is a code block - add hljs class to ensure our styles apply
|
||||
return <code className={`${className} hljs`} {...props}>{children}</code>;
|
||||
} else {
|
||||
// This is inline code - apply our custom styling
|
||||
// Check if parent is a pre element - if so, this might be a code block without language
|
||||
const parentIsPre = (props as any).node?.parent?.tagName === 'pre';
|
||||
if (parentIsPre) {
|
||||
return <code className="hljs" {...props}>{children}</code>;
|
||||
}
|
||||
return <code className="px-1 py-0.5 bg-gray-100 dark:bg-gray-800 rounded text-sm font-mono text-gray-900 dark:text-gray-100" {...props}>{children}</code>;
|
||||
}
|
||||
},
|
||||
pre: ({...props}) => <pre className="mb-4 rounded-lg overflow-x-auto" {...props} />,
|
||||
|
||||
// Customize blockquote styles
|
||||
blockquote: ({...props}) => <blockquote className="mb-4 pl-4 border-l-4 border-gray-300 dark:border-gray-600 italic text-gray-600 dark:text-gray-400" {...props} />,
|
||||
|
||||
// Customize table styles
|
||||
table: ({...props}) => <table className="mb-4 w-full border-collapse border border-gray-300 dark:border-gray-600" {...props} />,
|
||||
thead: ({...props}) => <thead className="bg-gray-100 dark:bg-gray-800" {...props} />,
|
||||
th: ({...props}) => <th className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-left font-semibold text-gray-900 dark:text-gray-100" {...props} />,
|
||||
td: ({...props}) => <td className="border border-gray-300 dark:border-gray-600 px-3 py-2 text-gray-700 dark:text-gray-300" {...props} />,
|
||||
|
||||
// Customize horizontal rule
|
||||
hr: ({...props}) => <hr className="my-6 border-gray-300 dark:border-gray-600" {...props} />,
|
||||
|
||||
// Customize strong/bold text
|
||||
strong: ({...props}) => <strong className="font-semibold text-gray-900 dark:text-gray-100" {...props} />,
|
||||
|
||||
// Customize italic text
|
||||
em: ({...props}) => <em className="italic text-gray-700 dark:text-gray-300" {...props} />
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
226
frontend/components/Shared/PomodoroTimer.tsx
Normal file
226
frontend/components/Shared/PomodoroTimer.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { PlayIcon, PauseIcon, ArrowPathIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PomodoroTimerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const POMODORO_STORAGE_KEY = 'tududi_pomodoro_timer';
|
||||
const DEFAULT_TIME = 25 * 60; // 25 minutes in seconds
|
||||
|
||||
interface PomodoroState {
|
||||
isActive: boolean;
|
||||
timeLeft: number;
|
||||
isRunning: boolean;
|
||||
startTime?: number;
|
||||
}
|
||||
|
||||
const PomodoroTimer: React.FC<PomodoroTimerProps> = ({ className = '' }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isActive, setIsActive] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState(DEFAULT_TIME);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [showCompletionMessage, setShowCompletionMessage] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Load state from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedState = localStorage.getItem(POMODORO_STORAGE_KEY);
|
||||
if (savedState) {
|
||||
try {
|
||||
const state: PomodoroState = JSON.parse(savedState);
|
||||
if (state.isActive) {
|
||||
setIsActive(true);
|
||||
setTimeLeft(state.timeLeft);
|
||||
setIsRunning(state.isRunning);
|
||||
|
||||
// If timer was running, calculate how much time has passed
|
||||
if (state.isRunning && state.startTime) {
|
||||
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
|
||||
const newTimeLeft = Math.max(0, state.timeLeft - elapsed);
|
||||
setTimeLeft(newTimeLeft);
|
||||
if (newTimeLeft > 0) {
|
||||
setIsRunning(true);
|
||||
} else {
|
||||
setIsRunning(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load pomodoro state:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
const state: PomodoroState = {
|
||||
isActive,
|
||||
timeLeft,
|
||||
isRunning,
|
||||
startTime: isRunning ? Date.now() - (DEFAULT_TIME - timeLeft) * 1000 : undefined
|
||||
};
|
||||
localStorage.setItem(POMODORO_STORAGE_KEY, JSON.stringify(state));
|
||||
}, [isActive, timeLeft, isRunning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isRunning && timeLeft > 0) {
|
||||
intervalRef.current = setInterval(() => {
|
||||
setTimeLeft(prev => {
|
||||
if (prev <= 1) {
|
||||
setIsRunning(false);
|
||||
setShowCompletionMessage(true);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
} else {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
}
|
||||
};
|
||||
}, [isRunning, timeLeft]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleTomatoClick = () => {
|
||||
setIsActive(true);
|
||||
setTimeLeft(DEFAULT_TIME);
|
||||
setIsRunning(false);
|
||||
};
|
||||
|
||||
const handlePlayPause = () => {
|
||||
setIsRunning(!isRunning);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setIsRunning(false);
|
||||
setTimeLeft(DEFAULT_TIME);
|
||||
setShowCompletionMessage(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsActive(false);
|
||||
setIsRunning(false);
|
||||
setTimeLeft(DEFAULT_TIME);
|
||||
setShowCompletionMessage(false);
|
||||
localStorage.removeItem(POMODORO_STORAGE_KEY);
|
||||
};
|
||||
|
||||
// Tomato SVG Icon
|
||||
const TomatoIcon = () => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className="cursor-pointer hover:scale-110 transition-transform"
|
||||
>
|
||||
{/* Tomato body */}
|
||||
<path
|
||||
d="M12 22c-4.5 0-8-3-8-7 0-2 1-4 2-5.5C7 8 8.5 7 10 7c1 0 2 .5 2 .5s1-.5 2-.5c1.5 0 3 1 4 2.5 1 1.5 2 3.5 2 5.5 0 4-3.5 7-8 7z"
|
||||
fill="#e74c3c"
|
||||
stroke="#c0392b"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
{/* Tomato stem */}
|
||||
<path
|
||||
d="M10 7c0-1 .5-2 1-3 .5 1 1.5 2 1.5 3"
|
||||
fill="none"
|
||||
stroke="#27ae60"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
{/* Tomato leaf */}
|
||||
<path
|
||||
d="M11 4c-1 0-2 1-2 2"
|
||||
fill="none"
|
||||
stroke="#27ae60"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
if (!isActive) {
|
||||
return (
|
||||
<div className={`flex items-center ${className}`} onClick={handleTomatoClick}>
|
||||
<TomatoIcon />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative flex items-center space-x-2 ${className}`}>
|
||||
<div className="flex items-center space-x-2 bg-gray-100 dark:bg-gray-800 rounded-lg px-3 py-1">
|
||||
<span className="text-sm font-mono font-semibold text-gray-900 dark:text-white">
|
||||
{formatTime(timeLeft)}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={handlePlayPause}
|
||||
className="flex items-center justify-center p-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
aria-label={isRunning ? t('pomodoro.pause') : t('pomodoro.play')}
|
||||
>
|
||||
{isRunning ? (
|
||||
<PauseIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<PlayIcon className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center justify-center p-1 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors"
|
||||
aria-label={t('pomodoro.reset')}
|
||||
>
|
||||
<ArrowPathIcon className="h-3 w-3" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex items-center justify-center p-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors"
|
||||
aria-label={t('pomodoro.close')}
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Completion Message */}
|
||||
{showCompletionMessage && (
|
||||
<div className="absolute top-full mt-2 right-0 bg-green-100 dark:bg-green-900 border border-green-300 dark:border-green-700 text-green-800 dark:text-green-200 px-3 py-2 rounded-lg shadow-lg z-50 whitespace-nowrap">
|
||||
<div className="flex items-center space-x-2 mb-2">
|
||||
<span className="text-sm font-medium">🍅 {t('pomodoro.complete')}</span>
|
||||
</div>
|
||||
<p className="text-xs mb-3">{t('pomodoro.completeMessage')}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCompletionMessage(false);
|
||||
setIsActive(false);
|
||||
setTimeLeft(DEFAULT_TIME);
|
||||
localStorage.removeItem(POMODORO_STORAGE_KEY);
|
||||
}}
|
||||
className="w-full text-xs px-3 py-1 bg-green-600 dark:bg-green-700 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{t('pomodoro.done')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PomodoroTimer;
|
||||
88
frontend/components/Shared/RecurrenceDropdown.tsx
Normal file
88
frontend/components/Shared/RecurrenceDropdown.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronDownIcon, ArrowPathIcon, CalendarDaysIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
import { RecurrenceType } from '../../entities/Task';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface RecurrenceDropdownProps {
|
||||
value: RecurrenceType;
|
||||
onChange: (value: RecurrenceType) => void;
|
||||
}
|
||||
|
||||
const RecurrenceDropdown: React.FC<RecurrenceDropdownProps> = ({ value, onChange }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const recurrenceOptions = [
|
||||
{ value: 'none', label: t('recurrence.none', 'No repeat'), icon: <ClockIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'daily', label: t('recurrence.daily', 'Daily'), icon: <ArrowPathIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'weekly', label: t('recurrence.weekly', 'Weekly'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'monthly', label: t('recurrence.monthly', 'Monthly'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'monthly_weekday', label: t('recurrence.monthlyWeekday', 'Monthly on weekday'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> },
|
||||
{ value: 'monthly_last_day', label: t('recurrence.monthlyLastDay', 'Monthly on last day'), icon: <CalendarDaysIcon className="w-5 h-5 text-gray-700 dark:text-gray-300" /> }
|
||||
];
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleToggle = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = (recurrence: RecurrenceType) => {
|
||||
onChange(recurrence);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const selectedRecurrence = recurrenceOptions.find(r => r.value === value);
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className="relative inline-block text-left w-full">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-900 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-900 rounded-md shadow-sm focus:outline-none"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{selectedRecurrence ? selectedRecurrence.icon : ''}
|
||||
<span>{selectedRecurrence ? selectedRecurrence.label : t('forms.task.labels.recurrenceType', 'Select Recurrence')}</span>
|
||||
</span>
|
||||
<ChevronDownIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 mt-2 w-full bg-white dark:bg-gray-700 shadow-lg rounded-md">
|
||||
{recurrenceOptions.map((recurrence) => (
|
||||
<button
|
||||
key={recurrence.value}
|
||||
onClick={() => handleSelect(recurrence.value as RecurrenceType)}
|
||||
className="flex items-center justify-between px-4 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-600 w-full"
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
{recurrence.icon} <span>{recurrence.label}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecurrenceDropdown;
|
||||
|
|
@ -1,26 +1,26 @@
|
|||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
interface ToastContextProps {
|
||||
showSuccessToast: (message: string) => void;
|
||||
showErrorToast: (message: string) => void;
|
||||
showSuccessToast: (message: string | React.ReactNode) => void;
|
||||
showErrorToast: (message: string | React.ReactNode) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextProps | undefined>(undefined);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | React.ReactNode | null>(null);
|
||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||
|
||||
const showSuccessToast = useCallback((message: string) => {
|
||||
const showSuccessToast = useCallback((message: string | React.ReactNode) => {
|
||||
setToastMessage(message);
|
||||
setToastType('success');
|
||||
setTimeout(() => setToastMessage(null), 3000);
|
||||
setTimeout(() => setToastMessage(null), 4000);
|
||||
}, []);
|
||||
|
||||
const showErrorToast = useCallback((message: string) => {
|
||||
const showErrorToast = useCallback((message: string | React.ReactNode) => {
|
||||
setToastMessage(message);
|
||||
setToastType('error');
|
||||
setTimeout(() => setToastMessage(null), 3000);
|
||||
setTimeout(() => setToastMessage(null), 4000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
|
|
@ -39,17 +39,19 @@ export const useToast = () => {
|
|||
return context;
|
||||
};
|
||||
|
||||
const Toast: React.FC<{ message: string; type: 'success' | 'error'; onClose: () => void }> = ({ message, type, onClose }) => {
|
||||
const Toast: React.FC<{ message: string | React.ReactNode; type: 'success' | 'error'; onClose: () => void }> = ({ message, type, onClose }) => {
|
||||
return (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 z-50 px-4 py-3 rounded-lg shadow-md text-white ${
|
||||
className={`fixed top-20 right-4 z-50 px-4 py-3 rounded-lg shadow-md text-white ${
|
||||
type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
>
|
||||
<span>{message}</span>
|
||||
<button onClick={onClose} className="ml-4">
|
||||
×
|
||||
</button>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-1">{message}</div>
|
||||
<button onClick={onClose} className="ml-4 text-xl leading-none hover:opacity-75">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
124
frontend/components/Shared/UrlPreview.tsx
Normal file
124
frontend/components/Shared/UrlPreview.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { extractTitleFromText, UrlTitleResult } from '../../utils/urlService';
|
||||
import { LinkIcon, XMarkIcon, PhotoIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface UrlPreviewProps {
|
||||
text: string;
|
||||
onPreviewChange?: (preview: UrlTitleResult | null) => void;
|
||||
}
|
||||
|
||||
const UrlPreview: React.FC<UrlPreviewProps> = ({ text, onPreviewChange }) => {
|
||||
const [preview, setPreview] = useState<UrlTitleResult | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const extractPreview = async () => {
|
||||
if (!text.trim()) {
|
||||
setPreview(null);
|
||||
onPreviewChange?.(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await extractTitleFromText(text);
|
||||
setPreview(result);
|
||||
onPreviewChange?.(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to extract URL preview:', error);
|
||||
setPreview(null);
|
||||
onPreviewChange?.(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(extractPreview, 300);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [text, onPreviewChange]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false);
|
||||
setPreview(null);
|
||||
onPreviewChange?.(null);
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
if (!isVisible || (!preview && !isLoading)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-500"></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-300">Loading preview...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!preview) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600 relative">
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 z-10"
|
||||
aria-label="Dismiss preview"
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
{preview.image && !imageError ? (
|
||||
<img
|
||||
src={preview.image}
|
||||
alt="Preview"
|
||||
className="w-16 h-16 object-cover rounded-md"
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 bg-gray-200 dark:bg-gray-600 rounded-md flex items-center justify-center">
|
||||
<PhotoIcon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pr-6">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100" style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{preview.title || 'Untitled'}
|
||||
</div>
|
||||
{preview.description && (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-300 mt-1" style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{preview.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate mt-1">
|
||||
{preview.url}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UrlPreview;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { Location } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
|
|
@ -8,7 +8,10 @@ import {
|
|||
InboxIcon,
|
||||
CheckCircleIcon,
|
||||
ListBulletIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { loadInboxItemsToStore } from '../../utils/inboxService';
|
||||
|
||||
interface SidebarNavProps {
|
||||
handleNavClick: (path: string, title: string, icon: JSX.Element) => void;
|
||||
|
|
@ -18,21 +21,27 @@ interface SidebarNavProps {
|
|||
|
||||
const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) => {
|
||||
const { t } = useTranslation();
|
||||
const store = useStore();
|
||||
|
||||
// Get inbox items count for badge
|
||||
const inboxItemsCount = store.inboxStore.inboxItems.length;
|
||||
|
||||
// Load inbox items when component mounts to ensure badge shows correct count
|
||||
useEffect(() => {
|
||||
loadInboxItemsToStore().catch(console.error);
|
||||
}, []);
|
||||
|
||||
const navLinks = [
|
||||
{ path: '/inbox', title: t('sidebar.inbox', 'Inbox'), icon: <InboxIcon className="h-5 w-5" /> },
|
||||
{ path: '/today', title: t('sidebar.today', 'Today'), icon: <CalendarDaysIcon className="h-5 w-5" />, query: 'type=today' },
|
||||
{ path: '/tasks?type=upcoming', title: t('sidebar.upcoming', 'Upcoming'), icon: <CalendarIcon className="h-5 w-5" />, query: 'type=upcoming' },
|
||||
{ path: '/tasks?type=next', title: t('sidebar.nextActions', 'Next Actions'), icon: <ArrowRightCircleIcon className="h-5 w-5" />, query: 'type=next' },
|
||||
// { path: '/tasks?type=someday', title: t('sidebar.someday', 'Someday'), icon: <ClockIcon className="h-5 w-5" />, query: 'type=someday' },
|
||||
// { path: '/tasks?type=waiting', title: t('sidebar.waitingFor', 'Waiting for'), icon: <PauseCircleIcon className="h-5 w-5" />, query: 'type=waiting' },
|
||||
{ path: '/tasks?status=done', title: t('sidebar.completed', 'Completed'), icon: <CheckCircleIcon className="h-5 w-5" />, query: 'status=done' },
|
||||
{ path: '/tasks?type=upcoming', title: t('sidebar.upcoming', 'Upcoming'), icon: <ClockIcon className="h-5 w-5" />, query: 'type=upcoming' },
|
||||
{ path: '/tasks', title: t('sidebar.allTasks', 'All Tasks'), icon: <ListBulletIcon className="h-5 w-5" /> },
|
||||
{ path: '/calendar', title: t('sidebar.calendar', 'Calendar'), icon: <CalendarIcon className="h-5 w-5" /> },
|
||||
];
|
||||
|
||||
const isActive = (path: string, query?: string) => {
|
||||
// Handle special case for paths without query parameters
|
||||
if (path === '/inbox' || path === '/today') {
|
||||
if (path === '/inbox' || path === '/today' || path === '/calendar') {
|
||||
const isPathMatch = location.pathname === path;
|
||||
return isPathMatch
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
|
|
@ -54,13 +63,20 @@ const SidebarNav: React.FC<SidebarNavProps> = ({ handleNavClick, location }) =>
|
|||
<li>
|
||||
<button
|
||||
onClick={() => handleNavClick(link.path, link.title, link.icon)}
|
||||
className={`w-full text-left px-4 py-1 flex items-center rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive(
|
||||
className={`w-full text-left px-4 py-1 flex items-center justify-between rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200 ${isActive(
|
||||
link.path,
|
||||
link.query
|
||||
)}`}
|
||||
>
|
||||
{link.icon}
|
||||
<span className="ml-2">{link.title}</span>
|
||||
<div className="flex items-center">
|
||||
{link.icon}
|
||||
<span className="ml-2">{link.title}</span>
|
||||
</div>
|
||||
{link.path === '/inbox' && inboxItemsCount > 0 && (
|
||||
<span className="inline-flex items-center justify-center w-5 h-5 text-xs font-medium text-white bg-blue-500 rounded-full">
|
||||
{inboxItemsCount > 99 ? '99+' : inboxItemsCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
{link.path === '/inbox' && (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,19 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
TagIcon,
|
||||
CheckIcon,
|
||||
BookOpenIcon,
|
||||
FolderIcon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon
|
||||
} from '@heroicons/react/24/solid';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Note } from '../../entities/Note';
|
||||
import { Project } from '../../entities/Project';
|
||||
import TaskList from '../Task/TaskList';
|
||||
import ProjectItem from '../Project/ProjectItem';
|
||||
|
||||
interface Tag {
|
||||
id: number;
|
||||
|
|
@ -12,19 +25,57 @@ const TagDetails: React.FC = () => {
|
|||
const { t } = useTranslation();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [tag, setTag] = useState<Tag | null>(null);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// State for ProjectItem components
|
||||
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
|
||||
const [hoveredNoteId, setHoveredNoteId] = useState<number | null>(null);
|
||||
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState<boolean>(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchTag = async () => {
|
||||
const fetchTagData = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/tag/${id}`);
|
||||
const data = await response.json();
|
||||
if (response.ok) {
|
||||
setTag(data);
|
||||
// First fetch tag details
|
||||
const tagResponse = await fetch(`/api/tag/${id}`);
|
||||
if (tagResponse.ok) {
|
||||
const tagData = await tagResponse.json();
|
||||
setTag(tagData);
|
||||
|
||||
// Now fetch entities that have this tag using the tag name
|
||||
const [tasksResponse, notesResponse, projectsResponse] = await Promise.all([
|
||||
fetch(`/api/tasks?tag=${encodeURIComponent(tagData.name)}`),
|
||||
fetch(`/api/notes?tag=${encodeURIComponent(tagData.name)}`),
|
||||
fetch(`/api/projects`) // Projects API doesn't support tag filtering yet
|
||||
]);
|
||||
|
||||
if (tasksResponse.ok) {
|
||||
const tasksData = await tasksResponse.json();
|
||||
setTasks(tasksData.tasks || []);
|
||||
}
|
||||
|
||||
if (notesResponse.ok) {
|
||||
const notesData = await notesResponse.json();
|
||||
setNotes(notesData || []);
|
||||
}
|
||||
|
||||
if (projectsResponse.ok) {
|
||||
const projectsData = await projectsResponse.json();
|
||||
// Filter projects client-side since API doesn't support tag filtering
|
||||
const allProjects = projectsData.projects || projectsData || [];
|
||||
const filteredProjects = allProjects.filter((project: any) =>
|
||||
project.tags && project.tags.some((tag: any) => tag.name === tagData.name)
|
||||
);
|
||||
setProjects(filteredProjects);
|
||||
}
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch tag.');
|
||||
const tagError = await tagResponse.json();
|
||||
setError(tagError.error || 'Failed to fetch tag.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('tags.error'));
|
||||
|
|
@ -32,44 +83,262 @@ const TagDetails: React.FC = () => {
|
|||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchTag();
|
||||
}, [id]);
|
||||
fetchTagData();
|
||||
}, [id, t]);
|
||||
|
||||
const handleViewTasks = () => {
|
||||
if (tag) {
|
||||
navigate(`/tasks?tag=${encodeURIComponent(tag.name)}`);
|
||||
// Task handlers
|
||||
const handleTaskUpdate = async (updatedTask: Task) => {
|
||||
try {
|
||||
const response = await fetch(`/api/task/${updatedTask.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(updatedTask),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.map((task) =>
|
||||
task.id === updatedTask.id ? updatedTask : task
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating task:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskDelete = async (taskId: number) => {
|
||||
try {
|
||||
const response = await fetch(`/api/task/${taskId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setTasks((prevTasks) => prevTasks.filter((task) => task.id !== taskId));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error deleting task:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Project handlers
|
||||
const handleEditProject = (project: Project) => {
|
||||
// For now, just log - could add modal later
|
||||
console.log("Edit project:", project);
|
||||
};
|
||||
|
||||
|
||||
const getCompletionPercentage = (project: Project) => {
|
||||
return (project as any).completion_percentage || 0;
|
||||
};
|
||||
|
||||
const getPriorityStyles = (priority: string) => {
|
||||
switch (priority) {
|
||||
case "low":
|
||||
return { color: "bg-green-500" };
|
||||
case "medium":
|
||||
return { color: "bg-yellow-500" };
|
||||
case "high":
|
||||
return { color: "bg-red-500" };
|
||||
default:
|
||||
return { color: "bg-gray-500" };
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-gray-700 dark:text-gray-300">{t('tags.loading')}</div>;
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
{t('tags.loading')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="text-red-500">{error}</div>;
|
||||
return <div className="text-red-500 p-4">{error}</div>;
|
||||
}
|
||||
|
||||
if (!tag) {
|
||||
return <div className="text-gray-700 dark:text-gray-300">{t('tags.notFound')}</div>;
|
||||
return <div className="text-gray-700 dark:text-gray-300 p-4">{t('tags.notFound')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="text-2xl font-semibold mb-4 text-gray-900 dark:text-white">{t('tags.details')}</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<strong>{t('tags.name')}:</strong> {tag.name}
|
||||
</p>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
<strong>{t('tags.status')}:</strong> {tag.active ? t('tags.active') : t('tags.inactive')}
|
||||
</p>
|
||||
<div className="flex justify-center px-4 lg:px-2">
|
||||
<div className="w-full max-w-5xl">
|
||||
{/* Tag Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center">
|
||||
<TagIcon className="h-6 w-6 mr-2 text-gray-900 dark:text-white" />
|
||||
<h2 className="text-2xl font-light text-gray-900 dark:text-white">
|
||||
{tag.name}
|
||||
</h2>
|
||||
</div>
|
||||
<div className={`px-3 py-1 rounded-full text-sm ${
|
||||
tag.active
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
|
||||
}`}>
|
||||
{tag.active ? t('tags.active') : t('tags.inactive')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* "View tasks with this tag" button */}
|
||||
<button
|
||||
onClick={handleViewTasks}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
>
|
||||
{t('tags.viewTasksWithTag')}
|
||||
</button>
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-white dark:bg-gray-900 shadow rounded-lg p-6">
|
||||
<div className="flex items-center">
|
||||
<CheckIcon className="h-8 w-8 text-blue-500 mr-3" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{tasks.length}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('tasks.title')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-900 shadow rounded-lg p-6">
|
||||
<div className="flex items-center">
|
||||
<BookOpenIcon className="h-8 w-8 text-green-500 mr-3" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{notes.length}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('notes.title')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-900 shadow rounded-lg p-6">
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="h-8 w-8 text-purple-500 mr-3" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900 dark:text-white">{projects.length}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400">{t('projects.title')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks Section */}
|
||||
{tasks.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<CheckIcon className="h-5 w-5 mr-2" />
|
||||
{t('tasks.title')} ({tasks.length})
|
||||
</h3>
|
||||
<TaskList
|
||||
tasks={tasks}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={[]} // Empty since we're viewing by tag
|
||||
hideProjectName={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes Section */}
|
||||
{notes.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<BookOpenIcon className="h-5 w-5 mr-2" />
|
||||
{t('notes.title')} ({notes.length})
|
||||
</h3>
|
||||
<ul className="space-y-1">
|
||||
{notes.map((note) => (
|
||||
<li
|
||||
key={note.id}
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg px-4 py-3 flex justify-between items-center"
|
||||
onMouseEnter={() => setHoveredNoteId(note.id || null)}
|
||||
onMouseLeave={() => setHoveredNoteId(null)}
|
||||
>
|
||||
<div className="flex-grow overflow-hidden pr-4">
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<Link
|
||||
to={`/note/${note.id}`}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
|
||||
>
|
||||
{note.title}
|
||||
</Link>
|
||||
{/* Tags */}
|
||||
{((note.tags && note.tags.length > 0) || (note.Tags && note.Tags.length > 0)) && (
|
||||
<>
|
||||
{(note.tags || note.Tags || []).map((noteTag) => (
|
||||
<button
|
||||
key={noteTag.id}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
navigate(`/tag/${noteTag.id}`);
|
||||
}}
|
||||
className="flex items-center space-x-1 px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-xs cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<TagIcon className="h-3 w-3 text-gray-500 dark:text-gray-300" />
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
{noteTag.name}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => console.log("Edit note:", note)}
|
||||
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label={`Edit ${note.title}`}
|
||||
title={`Edit ${note.title}`}
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => console.log("Delete note:", note)}
|
||||
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredNoteId === note.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label={`Delete ${note.title}`}
|
||||
title={`Delete ${note.title}`}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Projects Section */}
|
||||
{projects.length > 0 && (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4 flex items-center">
|
||||
<FolderIcon className="h-5 w-5 mr-2" />
|
||||
{t('projects.title')} ({projects.length})
|
||||
</h3>
|
||||
<div className="flex flex-col space-y-1">
|
||||
{projects.map((project) => {
|
||||
const { color } = getPriorityStyles(project.priority || "low");
|
||||
return (
|
||||
<ProjectItem
|
||||
key={project.id}
|
||||
project={project}
|
||||
viewMode="list"
|
||||
color={color}
|
||||
getCompletionPercentage={() => getCompletionPercentage(project)}
|
||||
activeDropdown={activeDropdown}
|
||||
setActiveDropdown={setActiveDropdown}
|
||||
handleEditProject={handleEditProject}
|
||||
setProjectToDelete={setProjectToDelete}
|
||||
setIsConfirmDialogOpen={setIsConfirmDialogOpen}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{tasks.length === 0 && notes.length === 0 && projects.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<TagIcon className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||
{t('tags.noItemsWithTag', `No items found with the tag "${tag.name}"`)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,9 +28,8 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
}
|
||||
}, [initialTags]);
|
||||
|
||||
useEffect(() => {
|
||||
onTagsChange(tags);
|
||||
}, [tags, onTagsChange]);
|
||||
// Remove this effect to prevent infinite loops
|
||||
// onTagsChange is called directly in addNewTag, selectTag, and removeTag
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
|
|
@ -177,7 +176,7 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
{isDropdownOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute z-10 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
className="absolute z-50 mt-1 w-full bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-lg max-h-60 overflow-auto"
|
||||
role="listbox"
|
||||
id="tag-suggestions"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PencilSquareIcon, TrashIcon, TagIcon, MagnifyingGlassIcon } from '@heroicons/react/24/solid';
|
||||
import { PencilSquareIcon, TrashIcon, TagIcon, MagnifyingGlassIcon, CheckIcon, BookOpenIcon, FolderIcon } from '@heroicons/react/24/solid';
|
||||
import ConfirmDialog from './Shared/ConfirmDialog';
|
||||
import TagModal from './Tag/TagModal';
|
||||
import { Tag } from '../entities/Tag';
|
||||
|
|
@ -15,6 +15,11 @@ const Tags: React.FC = () => {
|
|||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
const [hoveredTagId, setHoveredTagId] = useState<number | null>(null);
|
||||
const [tagMetrics, setTagMetrics] = useState<Record<string, {tasks: number, notes: number, projects: number}>>({});
|
||||
const [loadingMetrics, setLoadingMetrics] = useState<Set<string>>(new Set());
|
||||
const [loadedMetrics, setLoadedMetrics] = useState<Set<string>>(new Set());
|
||||
const [cachedProjects, setCachedProjects] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadTags = async () => {
|
||||
|
|
@ -22,6 +27,25 @@ const Tags: React.FC = () => {
|
|||
try {
|
||||
const fetchedTags = await fetchTags();
|
||||
setTags(fetchedTags);
|
||||
|
||||
// Load projects once and cache them
|
||||
try {
|
||||
const projectsResponse = await fetch('/api/projects');
|
||||
if (projectsResponse.ok) {
|
||||
const projectsData = await projectsResponse.json();
|
||||
setCachedProjects(projectsData.projects || projectsData || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch projects for tag metrics:', error);
|
||||
}
|
||||
|
||||
// Skip fetching metrics initially to avoid excessive API calls
|
||||
// Metrics will be loaded on demand when user interacts with tags
|
||||
const initialMetrics: Record<string, {tasks: number, notes: number, projects: number}> = {};
|
||||
fetchedTags.forEach(tag => {
|
||||
initialMetrics[tag.name] = { tasks: 0, notes: 0, projects: 0 };
|
||||
});
|
||||
setTagMetrics(initialMetrics);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tags:', error);
|
||||
setIsError(true);
|
||||
|
|
@ -33,11 +57,70 @@ const Tags: React.FC = () => {
|
|||
loadTags();
|
||||
}, []);
|
||||
|
||||
// Load metrics for a specific tag on demand
|
||||
const loadTagMetrics = async (tagName: string) => {
|
||||
if (loadedMetrics.has(tagName) || loadingMetrics.has(tagName)) {
|
||||
return; // Already loaded or loading
|
||||
}
|
||||
|
||||
setLoadingMetrics(prev => new Set(prev).add(tagName));
|
||||
|
||||
try {
|
||||
const [tasksResponse, notesResponse] = await Promise.all([
|
||||
fetch(`/api/tasks?tag=${encodeURIComponent(tagName)}`),
|
||||
fetch(`/api/notes?tag=${encodeURIComponent(tagName)}`)
|
||||
]);
|
||||
|
||||
let tasksCount = 0;
|
||||
let notesCount = 0;
|
||||
|
||||
if (tasksResponse.ok) {
|
||||
const tasksData = await tasksResponse.json();
|
||||
tasksCount = tasksData.tasks?.length || 0;
|
||||
}
|
||||
|
||||
if (notesResponse.ok) {
|
||||
const notesData = await notesResponse.json();
|
||||
notesCount = notesData?.length || 0;
|
||||
}
|
||||
|
||||
// Use cached projects data
|
||||
const projectsCount = cachedProjects.filter((project: any) =>
|
||||
project.tags && project.tags.some((projectTag: any) => projectTag.name === tagName)
|
||||
).length;
|
||||
|
||||
setTagMetrics(prev => ({
|
||||
...prev,
|
||||
[tagName]: {
|
||||
tasks: tasksCount,
|
||||
notes: notesCount,
|
||||
projects: projectsCount
|
||||
}
|
||||
}));
|
||||
|
||||
setLoadedMetrics(prev => new Set(prev).add(tagName));
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch metrics for tag ${tagName}:`, error);
|
||||
} finally {
|
||||
setLoadingMetrics(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(tagName);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTag = async () => {
|
||||
if (!tagToDelete) return;
|
||||
try {
|
||||
await apiDeleteTag(tagToDelete.id!);
|
||||
setTags((prev) => prev.filter((tag) => tag.id !== tagToDelete.id));
|
||||
// Remove the deleted tag from metrics as well
|
||||
setTagMetrics((prev) => {
|
||||
const newMetrics = { ...prev };
|
||||
delete newMetrics[tagToDelete.name];
|
||||
return newMetrics;
|
||||
});
|
||||
setIsConfirmDialogOpen(false);
|
||||
setTagToDelete(null);
|
||||
} catch (err) {
|
||||
|
|
@ -56,9 +139,27 @@ const Tags: React.FC = () => {
|
|||
if (tagData.id) {
|
||||
await updateTag(tagData.id, tagData);
|
||||
updatedTags = tags.map((tag) => (tag.id === tagData.id ? tagData : tag));
|
||||
|
||||
// If tag name changed, update metrics key
|
||||
const oldTag = tags.find(t => t.id === tagData.id);
|
||||
if (oldTag && oldTag.name !== tagData.name) {
|
||||
setTagMetrics((prev) => {
|
||||
const newMetrics = { ...prev };
|
||||
if (newMetrics[oldTag.name]) {
|
||||
newMetrics[tagData.name] = newMetrics[oldTag.name];
|
||||
delete newMetrics[oldTag.name];
|
||||
}
|
||||
return newMetrics;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const newTag = await createTag(tagData);
|
||||
updatedTags = [...tags, newTag];
|
||||
// Initialize metrics for new tag
|
||||
setTagMetrics((prev) => ({
|
||||
...prev,
|
||||
[newTag.name]: { tasks: 0, notes: 0, projects: 0 }
|
||||
}));
|
||||
}
|
||||
setTags(updatedTags);
|
||||
setIsTagModalOpen(false);
|
||||
|
|
@ -82,6 +183,22 @@ const Tags: React.FC = () => {
|
|||
tag.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Group tags alphabetically by first letter
|
||||
const groupedTags = filteredTags.reduce((groups, tag) => {
|
||||
const firstLetter = tag.name.charAt(0).toUpperCase();
|
||||
if (!groups[firstLetter]) {
|
||||
groups[firstLetter] = [];
|
||||
}
|
||||
groups[firstLetter].push(tag);
|
||||
return groups;
|
||||
}, {} as Record<string, typeof tags>);
|
||||
|
||||
// Sort the groups by letter and sort tags within each group
|
||||
const sortedGroupKeys = Object.keys(groupedTags).sort();
|
||||
sortedGroupKeys.forEach(letter => {
|
||||
groupedTags[letter].sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
|
|
@ -125,42 +242,104 @@ const Tags: React.FC = () => {
|
|||
{filteredTags.length === 0 ? (
|
||||
<p className="text-gray-700 dark:text-gray-300">No tags found.</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredTags.map((tag) => (
|
||||
<li
|
||||
key={tag.id}
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex-grow overflow-hidden pr-4">
|
||||
<Link
|
||||
to={`/tag/${tag.id}`}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline block"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
<div className="space-y-8">
|
||||
{sortedGroupKeys.map((letter) => (
|
||||
<div key={letter}>
|
||||
{/* Alphabetical Group Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{letter}
|
||||
</h3>
|
||||
<hr className="border-gray-300 dark:border-gray-600" />
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditTag(tag)}
|
||||
className="text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none"
|
||||
aria-label={`Edit ${tag.name}`}
|
||||
title={`Edit ${tag.name}`}
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openConfirmDialog(tag)}
|
||||
className="text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none"
|
||||
aria-label={`Delete ${tag.name}`}
|
||||
title={`Delete ${tag.name}`}
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/* Tags in this group */}
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{groupedTags[letter].map((tag) => {
|
||||
const metrics = tagMetrics[tag.name] || { tasks: 0, notes: 0, projects: 0 };
|
||||
const hasItems = metrics.tasks > 0 || metrics.notes > 0 || metrics.projects > 0;
|
||||
const isMetricsLoaded = loadedMetrics.has(tag.name);
|
||||
const isMetricsLoading = loadingMetrics.has(tag.name);
|
||||
|
||||
return (
|
||||
<li
|
||||
key={tag.id}
|
||||
className="bg-white dark:bg-gray-900 shadow rounded-lg p-4"
|
||||
onMouseEnter={() => {
|
||||
setHoveredTagId(tag.id || null);
|
||||
if (!isMetricsLoaded && !isMetricsLoading) {
|
||||
loadTagMetrics(tag.name);
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => setHoveredTagId(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Tag Name and Metrics - inline */}
|
||||
<div className="flex items-center space-x-3 flex-grow">
|
||||
<Link
|
||||
to={`/tag/${tag.id}`}
|
||||
className="text-md font-semibold text-gray-900 dark:text-gray-100 hover:underline"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
|
||||
{/* Metrics - inline with tag name */}
|
||||
{isMetricsLoading && (
|
||||
<div className="flex items-center text-sm text-gray-400 dark:text-gray-500">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 border-t-blue-500"></div>
|
||||
</div>
|
||||
)}
|
||||
{isMetricsLoaded && hasItems && (
|
||||
<div className="flex items-center space-x-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{metrics.projects > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<FolderIcon className="h-4 w-4 text-purple-500" />
|
||||
<span>{metrics.projects}</span>
|
||||
</div>
|
||||
)}
|
||||
{metrics.tasks > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<CheckIcon className="h-4 w-4 text-blue-500" />
|
||||
<span>{metrics.tasks}</span>
|
||||
</div>
|
||||
)}
|
||||
{metrics.notes > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<BookOpenIcon className="h-4 w-4 text-green-500" />
|
||||
<span>{metrics.notes}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit/Delete buttons */}
|
||||
<div className="flex space-x-2 ml-2">
|
||||
<button
|
||||
onClick={() => handleEditTag(tag)}
|
||||
className={`text-gray-500 hover:text-blue-700 dark:hover:text-blue-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label={`Edit ${tag.name}`}
|
||||
title={`Edit ${tag.name}`}
|
||||
>
|
||||
<PencilSquareIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => openConfirmDialog(tag)}
|
||||
className={`text-gray-500 hover:text-red-700 dark:hover:text-red-300 focus:outline-none transition-opacity ${hoveredTagId === tag.id ? 'opacity-100' : 'opacity-0'}`}
|
||||
aria-label={`Delete ${tag.name}`}
|
||||
title={`Delete ${tag.name}`}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TagModal */}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
|||
|
||||
try {
|
||||
await onTaskCreate(taskText);
|
||||
showSuccessToast(t('success.taskCreated', 'Task created successfully!'));
|
||||
// Success toast is now handled by the parent component
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
setTaskName(taskText);
|
||||
|
|
|
|||
152
frontend/components/Task/NextTaskSuggestion.tsx
Normal file
152
frontend/components/Task/NextTaskSuggestion.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
PlayIcon,
|
||||
XMarkIcon,
|
||||
ArrowPathIcon,
|
||||
SparklesIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
|
||||
interface NextTaskSuggestionProps {
|
||||
metrics: {
|
||||
tasks_due_today: Task[];
|
||||
suggested_tasks: Task[];
|
||||
tasks_in_progress: Task[];
|
||||
today_plan_tasks?: Task[];
|
||||
};
|
||||
onTaskUpdate: (task: Task) => Promise<void>;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const NextTaskSuggestion: React.FC<NextTaskSuggestionProps> = ({
|
||||
metrics,
|
||||
onTaskUpdate,
|
||||
onClose
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { showSuccessToast } = useToast();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [currentTaskIndex, setCurrentTaskIndex] = useState(0);
|
||||
|
||||
// Check if there are any tasks in progress
|
||||
// If there are tasks in progress, don't show the suggestion
|
||||
if (metrics.tasks_in_progress.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Helper function to check if task is not started
|
||||
const isNotStarted = (task: Task) => {
|
||||
return task.status === 'not_started' || task.status === 0;
|
||||
};
|
||||
|
||||
// Get all available tasks in priority order:
|
||||
// 1. Today plan tasks (user's intentional selection for today)
|
||||
// 2. Due today tasks (time-based urgency)
|
||||
// 3. Suggested tasks from today page (algorithm recommendations)
|
||||
const todayPlanAvailable = (metrics.today_plan_tasks || []).filter(isNotStarted);
|
||||
const dueTodayAvailable = metrics.tasks_due_today.filter(isNotStarted);
|
||||
const suggestedAvailable = metrics.suggested_tasks.filter(isNotStarted);
|
||||
|
||||
// Combine all available tasks with priority (intelligent selection)
|
||||
const allAvailableTasks = [
|
||||
...todayPlanAvailable.map(task => ({ task, source: 'today_plan' })),
|
||||
...dueTodayAvailable.map(task => ({ task, source: 'due_today' })),
|
||||
...suggestedAvailable.map(task => ({ task, source: 'suggested' }))
|
||||
];
|
||||
|
||||
if (allAvailableTasks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get current task based on index, wrap around if needed
|
||||
const currentTaskData = allAvailableTasks[currentTaskIndex % allAvailableTasks.length];
|
||||
const suggestedTask = currentTaskData.task;
|
||||
const suggestionSource = currentTaskData.source;
|
||||
|
||||
const handleStartTask = async () => {
|
||||
if (!suggestedTask || !suggestedTask.id) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
// Universal rule: when setting status to in_progress, also add to today
|
||||
const updatedTask = {
|
||||
...suggestedTask,
|
||||
status: 'in_progress' as const,
|
||||
today: true
|
||||
};
|
||||
await onTaskUpdate(updatedTask);
|
||||
showSuccessToast(t('task.startedSuccessfully', 'Task started successfully!'));
|
||||
} catch (error) {
|
||||
console.error('Error starting task:', error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGiveMeSomethingElse = () => {
|
||||
setCurrentTaskIndex(prev => prev + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6 p-4 bg-white dark:bg-gray-900 border-l-4 border-purple-500 rounded-lg shadow relative">
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex items-start">
|
||||
<SparklesIcon className="h-6 w-6 text-purple-500 dark:text-purple-400 mr-3 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 pr-8">
|
||||
<p className="text-gray-700 dark:text-gray-300 font-medium mb-2 break-words">
|
||||
{suggestionSource === 'today_plan' && t('nextTask.suggestionTodayPlan', 'Since there is nothing in progress, what about starting with this task from your today plan')}
|
||||
{suggestionSource === 'due_today' && t('nextTask.suggestionDueToday', 'Since there is nothing in progress, what about starting with this task due today')}
|
||||
{suggestionSource === 'suggested' && t('nextTask.suggestionSuggested', 'Since there is nothing in progress, what about starting with this suggested task')}
|
||||
</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-md p-3 mb-3">
|
||||
<p className="text-gray-900 dark:text-gray-100 font-medium break-words">
|
||||
{suggestedTask.name}
|
||||
</p>
|
||||
{suggestedTask.due_date && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t('forms.task.labels.dueDate', 'Due')}: {new Date(suggestedTask.due_date).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<button
|
||||
onClick={handleStartTask}
|
||||
disabled={isUpdating}
|
||||
className="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white text-sm font-medium rounded-md transition-colors"
|
||||
>
|
||||
<PlayIcon className="h-4 w-4 mr-2" />
|
||||
{isUpdating
|
||||
? t('nextTask.starting', 'Starting...')
|
||||
: t('nextTask.letsDoIt', "Yes, let's do it!")
|
||||
}
|
||||
</button>
|
||||
{allAvailableTasks.length > 1 && (
|
||||
<button
|
||||
onClick={handleGiveMeSomethingElse}
|
||||
disabled={isUpdating}
|
||||
className="inline-flex items-center px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white text-sm font-medium rounded-md transition-colors"
|
||||
>
|
||||
<ArrowPathIcon className="h-4 w-4 mr-2" />
|
||||
{t('nextTask.giveMeSomethingElse', 'Give me something else')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NextTaskSuggestion;
|
||||
|
|
@ -225,7 +225,7 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
|
||||
if (isChildTask) {
|
||||
return (
|
||||
<div className="pb-3 border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||
<div className="pb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
|
||||
</h3>
|
||||
|
|
@ -283,7 +283,7 @@ const RecurrenceInput: React.FC<RecurrenceInputProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="pb-3 border-t border-gray-200 dark:border-gray-700 pt-4 mt-4">
|
||||
<div className="pb-3">
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-4">
|
||||
{t('forms.task.recurrenceSettings', 'Recurrence Settings')}
|
||||
</h3>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { Task } from "../../entities/Task";
|
||||
import { InboxItem } from "../../entities/InboxItem";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createInboxItemWithStore } from "../../utils/inboxService";
|
||||
import { isAuthError } from "../../utils/authUtils";
|
||||
// import UrlPreview from "../Shared/UrlPreview";
|
||||
// import { UrlTitleResult } from "../../utils/urlService";
|
||||
|
||||
interface SimplifiedTaskModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -31,6 +32,7 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
|
|||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const nameInputRef = useRef<HTMLInputElement>(null);
|
||||
const [saveMode, setSaveMode] = useState<'task' | 'inbox'>('inbox');
|
||||
// const [urlPreview, setUrlPreview] = useState<UrlTitleResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && nameInputRef.current) {
|
||||
|
|
@ -155,35 +157,42 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
|
|||
isClosing ? "scale-95" : "scale-100"
|
||||
} flex flex-col`}
|
||||
>
|
||||
<div className="p-6 px-8 flex items-center">
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
name="text"
|
||||
value={inputText}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="flex-1 text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder={t('inbox.captureThought')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isSaving) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
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 ${
|
||||
inputText.trim() && !isSaving
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-blue-400 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{isSaving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
<div className="p-6 px-8">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
type="text"
|
||||
name="text"
|
||||
value={inputText}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="flex-1 text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder={t('inbox.captureThought')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isSaving) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
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 ${
|
||||
inputText.trim() && !isSaving
|
||||
? "bg-blue-600 hover:bg-blue-700"
|
||||
: "bg-blue-400 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
{isSaving ? t('common.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
{/* URL Preview disabled */}
|
||||
{/* <UrlPreview
|
||||
text={inputText}
|
||||
onPreviewChange={setUrlPreview}
|
||||
/> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface TaskActionsProps {
|
||||
taskId: number | undefined;
|
||||
|
|
@ -17,22 +18,24 @@ const TaskActions: React.FC<TaskActionsProps> = ({ taskId, onDelete, onSave, onC
|
|||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600"
|
||||
className="p-2 bg-red-600 text-white rounded hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 flex items-center justify-center"
|
||||
title={t('common.delete', 'Delete')}
|
||||
aria-label={t('common.delete', 'Delete')}
|
||||
>
|
||||
{t('common.delete', 'Delete')}
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||
className="px-3 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-200 rounded hover:bg-gray-300 dark:hover:bg-gray-600 text-sm"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600"
|
||||
className="px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-sm"
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
|
|
|
|||
35
frontend/components/Task/TaskForm/TaskContentSection.tsx
Normal file
35
frontend/components/Task/TaskForm/TaskContentSection.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TaskContentSectionProps {
|
||||
taskId: number | undefined;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
}
|
||||
|
||||
const TaskContentSection: React.FC<TaskContentSectionProps> = ({
|
||||
taskId,
|
||||
value,
|
||||
onChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<label className="block text-sm font-medium text-gray-900 dark:text-gray-100 mb-3">
|
||||
{t('forms.noteContent', 'Content')}
|
||||
</label>
|
||||
<textarea
|
||||
id={`task_note_${taskId}`}
|
||||
name="note"
|
||||
rows={3}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
|
||||
placeholder={t('forms.noteContentPlaceholder', 'Add task description...')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskContentSection;
|
||||
54
frontend/components/Task/TaskForm/TaskMetadataSection.tsx
Normal file
54
frontend/components/Task/TaskForm/TaskMetadataSection.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PriorityType, StatusType } from '../../../entities/Task';
|
||||
import StatusDropdown from '../../Shared/StatusDropdown';
|
||||
import PriorityDropdown from '../../Shared/PriorityDropdown';
|
||||
|
||||
interface TaskMetadataSectionProps {
|
||||
priority: PriorityType;
|
||||
dueDate: string;
|
||||
taskId: number | undefined;
|
||||
onStatusChange: (value: StatusType) => void;
|
||||
onPriorityChange: (value: PriorityType) => void;
|
||||
onDueDateChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const TaskMetadataSection: React.FC<TaskMetadataSectionProps> = ({
|
||||
priority,
|
||||
dueDate,
|
||||
taskId,
|
||||
onStatusChange,
|
||||
onPriorityChange,
|
||||
onDueDateChange
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.priority', 'Priority')}
|
||||
</label>
|
||||
<PriorityDropdown
|
||||
value={priority}
|
||||
onChange={onPriorityChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.dueDate', 'Due Date')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id={`task_due_date_${taskId}`}
|
||||
name="due_date"
|
||||
value={dueDate}
|
||||
onChange={onDueDateChange}
|
||||
className="block w-full focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-600 rounded-md text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskMetadataSection;
|
||||
71
frontend/components/Task/TaskForm/TaskProjectSection.tsx
Normal file
71
frontend/components/Task/TaskForm/TaskProjectSection.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Project } from '../../../entities/Project';
|
||||
|
||||
interface TaskProjectSectionProps {
|
||||
newProjectName: string;
|
||||
onProjectSearch: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
dropdownOpen: boolean;
|
||||
filteredProjects: Project[];
|
||||
onProjectSelection: (project: Project) => void;
|
||||
onCreateProject: () => void;
|
||||
isCreatingProject: boolean;
|
||||
}
|
||||
|
||||
const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
|
||||
newProjectName,
|
||||
onProjectSearch,
|
||||
dropdownOpen,
|
||||
filteredProjects,
|
||||
onProjectSelection,
|
||||
onCreateProject,
|
||||
isCreatingProject
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('forms.task.projectSearchPlaceholder', 'Search or create a project...')}
|
||||
value={newProjectName}
|
||||
onChange={onProjectSearch}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 shadow-sm px-3 py-2 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
{dropdownOpen && newProjectName && (
|
||||
<div className="absolute mt-1 bg-white dark:bg-gray-800 shadow-lg rounded-md w-full z-50 border border-gray-200 dark:border-gray-700">
|
||||
{filteredProjects.length > 0 ? (
|
||||
filteredProjects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
onClick={() => onProjectSelection(project)}
|
||||
className="block w-full text-gray-700 dark:text-gray-300 text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{project.name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-2 text-gray-500 dark:text-gray-400">
|
||||
{t('forms.task.noMatchingProjects', 'No matching projects')}
|
||||
</div>
|
||||
)}
|
||||
{newProjectName && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateProject}
|
||||
disabled={isCreatingProject}
|
||||
className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
{isCreatingProject
|
||||
? t('forms.task.creatingProject', 'Creating...')
|
||||
: t('forms.task.createProject', '+ Create') + ` "${newProjectName}"`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskProjectSection;
|
||||
41
frontend/components/Task/TaskForm/TaskRecurrenceSection.tsx
Normal file
41
frontend/components/Task/TaskForm/TaskRecurrenceSection.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from 'react';
|
||||
import { RecurrenceType, Task } from '../../../entities/Task';
|
||||
import RecurrenceInput from '../RecurrenceInput';
|
||||
|
||||
interface TaskRecurrenceSectionProps {
|
||||
formData: Task;
|
||||
parentTask: Task | null;
|
||||
parentTaskLoading: boolean;
|
||||
onRecurrenceChange: (field: string, value: any) => void;
|
||||
onEditParent?: () => void;
|
||||
onParentRecurrenceChange?: (field: string, value: any) => void;
|
||||
}
|
||||
|
||||
const TaskRecurrenceSection: React.FC<TaskRecurrenceSectionProps> = ({
|
||||
formData,
|
||||
parentTask,
|
||||
parentTaskLoading,
|
||||
onRecurrenceChange,
|
||||
onEditParent,
|
||||
onParentRecurrenceChange
|
||||
}) => {
|
||||
return (
|
||||
<RecurrenceInput
|
||||
recurrenceType={parentTask ? (parentTask.recurrence_type || 'none') : (formData.recurrence_type || 'none')}
|
||||
recurrenceInterval={parentTask ? (parentTask.recurrence_interval || 1) : (formData.recurrence_interval || 1)}
|
||||
recurrenceEndDate={parentTask ? parentTask.recurrence_end_date : formData.recurrence_end_date}
|
||||
recurrenceWeekday={parentTask ? parentTask.recurrence_weekday : formData.recurrence_weekday}
|
||||
recurrenceMonthDay={parentTask ? parentTask.recurrence_month_day : formData.recurrence_month_day}
|
||||
recurrenceWeekOfMonth={parentTask ? parentTask.recurrence_week_of_month : formData.recurrence_week_of_month}
|
||||
completionBased={parentTask ? (parentTask.completion_based || false) : (formData.completion_based || false)}
|
||||
onChange={onRecurrenceChange}
|
||||
disabled={!!parentTask}
|
||||
isChildTask={!!parentTask}
|
||||
parentTaskLoading={parentTaskLoading}
|
||||
onEditParent={parentTask ? onEditParent : undefined}
|
||||
onParentRecurrenceChange={parentTask ? onParentRecurrenceChange : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskRecurrenceSection;
|
||||
27
frontend/components/Task/TaskForm/TaskTagsSection.tsx
Normal file
27
frontend/components/Task/TaskForm/TaskTagsSection.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import TagInput from '../../Tag/TagInput';
|
||||
|
||||
interface TaskTagsSectionProps {
|
||||
tags: string[];
|
||||
onTagsChange: (tags: string[]) => void;
|
||||
availableTags: Array<{name: string}>;
|
||||
}
|
||||
|
||||
const TaskTagsSection: React.FC<TaskTagsSectionProps> = ({
|
||||
tags,
|
||||
onTagsChange,
|
||||
availableTags
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<TagInput
|
||||
onTagsChange={onTagsChange}
|
||||
initialTags={tags}
|
||||
availableTags={availableTags}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskTagsSection;
|
||||
93
frontend/components/Task/TaskForm/TaskTitleSection.tsx
Normal file
93
frontend/components/Task/TaskForm/TaskTitleSection.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TaskAnalysis {
|
||||
isVague: boolean;
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
reason: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
interface TaskTitleSectionProps {
|
||||
taskId: number | undefined;
|
||||
value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
taskAnalysis: TaskAnalysis | null;
|
||||
taskIntelligenceEnabled: boolean;
|
||||
}
|
||||
|
||||
const TaskTitleSection: React.FC<TaskTitleSectionProps> = ({
|
||||
taskId,
|
||||
value,
|
||||
onChange,
|
||||
taskAnalysis,
|
||||
taskIntelligenceEnabled
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<input
|
||||
type="text"
|
||||
id={`task_name_${taskId}`}
|
||||
name="name"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-none focus:outline-none focus:border-none focus:ring-0 shadow-sm py-2"
|
||||
placeholder={t('forms.task.namePlaceholder', 'Add Task Name')}
|
||||
/>
|
||||
{taskAnalysis && taskAnalysis.isVague && taskIntelligenceEnabled && (
|
||||
<div className={`mt-2 p-3 rounded-md border ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
|
||||
}`}>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className={`h-4 w-4 mt-0.5 ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-400'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-400'
|
||||
: 'text-blue-400'
|
||||
}`} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<p className={`text-sm ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-800 dark:text-red-200'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-800 dark:text-yellow-200'
|
||||
: 'text-blue-800 dark:text-blue-200'
|
||||
}`}>
|
||||
<strong>
|
||||
{taskAnalysis.reason === 'short' && t('task.nameHelper.short', 'Make it more descriptive!')}
|
||||
{taskAnalysis.reason === 'no_verb' && t('task.nameHelper.noVerb', 'Add an action verb!')}
|
||||
{taskAnalysis.reason === 'vague_pattern' && t('task.nameHelper.vague', 'Be more specific!')}
|
||||
</strong>
|
||||
</p>
|
||||
{taskAnalysis.suggestion && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-700 dark:text-yellow-300'
|
||||
: 'text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
{t(taskAnalysis.suggestion, taskAnalysis.suggestion)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskTitleSection;
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import React from "react";
|
||||
import { CalendarDaysIcon, CalendarIcon, PlayIcon, ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
import { TagIcon, FolderIcon } from "@heroicons/react/24/solid";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TaskPriorityIcon from "./TaskPriorityIcon";
|
||||
import TaskTags from "./TaskTags";
|
||||
import TaskStatusBadge from "./TaskStatusBadge";
|
||||
import TaskDueDate from "./TaskDueDate";
|
||||
import TaskRecurrenceBadge from "./TaskRecurrenceBadge";
|
||||
import { Project } from "../../entities/Project";
|
||||
import { Task } from "../../entities/Task";
|
||||
import { Task, StatusType } from "../../entities/Task";
|
||||
|
||||
interface TaskHeaderProps {
|
||||
task: Task;
|
||||
|
|
@ -13,6 +13,9 @@ interface TaskHeaderProps {
|
|||
onTaskClick: (e: React.MouseEvent) => void;
|
||||
onToggleCompletion?: () => void;
|
||||
hideProjectName?: boolean;
|
||||
onToggleToday?: (taskId: number) => Promise<void>;
|
||||
onTaskUpdate?: (task: Task) => Promise<void>;
|
||||
isOverdue?: boolean;
|
||||
}
|
||||
|
||||
const TaskHeader: React.FC<TaskHeaderProps> = ({
|
||||
|
|
@ -21,37 +24,174 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
onTaskClick,
|
||||
onToggleCompletion,
|
||||
hideProjectName = false,
|
||||
onToggleToday,
|
||||
onTaskUpdate,
|
||||
isOverdue = false,
|
||||
}) => {
|
||||
const capitalizeFirstLetter = (string: string | undefined) => {
|
||||
if (!string) {
|
||||
return "";
|
||||
const { t } = useTranslation();
|
||||
|
||||
const formatDueDate = (dueDate: string) => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
|
||||
if (dueDate === today) return t('dateIndicators.today', 'TODAY');
|
||||
if (dueDate === tomorrow) return t('dateIndicators.tomorrow', 'TOMORROW');
|
||||
if (dueDate === yesterday) return t('dateIndicators.yesterday', 'YESTERDAY');
|
||||
|
||||
return new Date(dueDate).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatRecurrence = (recurrenceType: string) => {
|
||||
switch (recurrenceType) {
|
||||
case 'daily':
|
||||
return t('recurrence.daily', 'Daily');
|
||||
case 'weekly':
|
||||
return t('recurrence.weekly', 'Weekly');
|
||||
case 'monthly':
|
||||
return t('recurrence.monthly', 'Monthly');
|
||||
case 'monthly_weekday':
|
||||
return t('recurrence.monthlyWeekday', 'Monthly');
|
||||
case 'monthly_last_day':
|
||||
return t('recurrence.monthlyLastDay', 'Monthly');
|
||||
default:
|
||||
return t('recurrence.recurring', 'Recurring');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTodayToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent opening task modal
|
||||
if (onToggleToday && task.id) {
|
||||
try {
|
||||
await onToggleToday(task.id);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle today status:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent opening task modal
|
||||
if (task.id && (task.status === 'not_started' || task.status === 'in_progress' || task.status === 0 || task.status === 1) && onTaskUpdate) {
|
||||
try {
|
||||
const isCurrentlyInProgress = task.status === 'in_progress' || task.status === 1;
|
||||
const updatedTask = {
|
||||
...task,
|
||||
status: (isCurrentlyInProgress ? 'not_started' : 'in_progress') as StatusType,
|
||||
// Automatically add to today plan when setting to in_progress
|
||||
today: isCurrentlyInProgress ? task.today : true
|
||||
};
|
||||
await onTaskUpdate(updatedTask);
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle in progress status:', error);
|
||||
}
|
||||
}
|
||||
return string.charAt(0).toUpperCase() + string.slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-2 px-4 cursor-pointer" onClick={onTaskClick}>
|
||||
<div className="py-2 px-4 cursor-pointer group" onClick={onTaskClick}>
|
||||
{/* Full view (md and larger) */}
|
||||
<div className="hidden md:flex flex-col md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex items-center space-x-4 mb-2 md:mb-0">
|
||||
<TaskPriorityIcon priority={task.priority} status={task.status} onToggleCompletion={onToggleCompletion} />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-md text-gray-900 dark:text-gray-100">
|
||||
{task.name}
|
||||
</span>
|
||||
{project && !hideProjectName && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{project.name}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center">
|
||||
<span className="text-md text-gray-900 dark:text-gray-100">
|
||||
{task.name}
|
||||
</span>
|
||||
{isOverdue && (
|
||||
<span
|
||||
className="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-md"
|
||||
title="Task has been in today plan for a while"
|
||||
>
|
||||
overdue
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* Project, tags, due date, and recurrence in same row, with spacing when they exist */}
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{project && !hideProjectName && (
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<span>{project.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{project && !hideProjectName && task.tags && task.tags.length > 0 && (
|
||||
<span className="mx-2">•</span>
|
||||
)}
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex items-center">
|
||||
<TagIcon className="h-3 w-3 mr-1" />
|
||||
<span>{task.tags.map(tag => tag.name).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{((project && !hideProjectName) || (task.tags && task.tags.length > 0)) && task.due_date && (
|
||||
<span className="mx-2">•</span>
|
||||
)}
|
||||
{task.due_date && (
|
||||
<div className="flex items-center">
|
||||
<CalendarIcon className="h-3 w-3 mr-1" />
|
||||
<span>{formatDueDate(task.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
{((project && !hideProjectName) || (task.tags && task.tags.length > 0) || task.due_date) && task.recurrence_type && task.recurrence_type !== 'none' && (
|
||||
<span className="mx-2">•</span>
|
||||
)}
|
||||
{task.recurrence_type && task.recurrence_type !== 'none' && (
|
||||
<div className="flex items-center">
|
||||
<ArrowPathIcon className="h-3 w-3 mr-1" />
|
||||
<span>{formatRecurrence(task.recurrence_type)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center flex-wrap justify-start md:justify-end space-x-2">
|
||||
{/* Tags without onTagRemove prop */}
|
||||
<TaskTags tags={task.tags || []} />
|
||||
{task.due_date && <TaskDueDate dueDate={task.due_date} />}
|
||||
<TaskRecurrenceBadge recurrenceType={task.recurrence_type || 'none'} />
|
||||
<TaskStatusBadge status={task.status} />
|
||||
|
||||
{/* Today Plan Controls */}
|
||||
{onToggleToday && (
|
||||
<button
|
||||
onClick={handleTodayToggle}
|
||||
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'
|
||||
}`}
|
||||
title={task.today ? t('tasks.removeFromToday', 'Remove from today plan') : t('tasks.addToToday', 'Add to today plan')}
|
||||
>
|
||||
{task.today ? (
|
||||
<CalendarDaysIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
)}
|
||||
{Number(task.today_move_count) > 1 && (
|
||||
<span className="ml-1 text-xs font-medium">
|
||||
{Number(task.today_move_count)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Play/In Progress Controls */}
|
||||
{(task.status === 'not_started' || task.status === 'in_progress' || task.status === 0 || task.status === 1) && (
|
||||
<button
|
||||
onClick={handlePlayToggle}
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${
|
||||
(task.status === 'in_progress' || task.status === 1)
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 animate-pulse'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title={(task.status === 'in_progress' || task.status === 1) ? t('tasks.setNotStarted', 'Set to not started') : t('tasks.setInProgress', 'Set in progress')}
|
||||
>
|
||||
<PlayIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -65,23 +205,89 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
{/* Task Title and Project Name */}
|
||||
<div className="ml-2 flex flex-col flex-1">
|
||||
{/* Task Title */}
|
||||
<span>{task.name}</span>
|
||||
<div className="flex items-center">
|
||||
<span>{task.name}</span>
|
||||
{isOverdue && (
|
||||
<span
|
||||
className="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-md"
|
||||
title="Task has been in today plan for a while"
|
||||
>
|
||||
overdue
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Project Name */}
|
||||
{project && !hideProjectName && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{project.name}
|
||||
</div>
|
||||
)}
|
||||
{/* Project, tags, due date, and recurrence - each on separate lines */}
|
||||
<div className="flex flex-col text-xs text-gray-500 dark:text-gray-400 mt-1 space-y-1">
|
||||
{project && !hideProjectName && (
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<span>{project.name}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.tags && task.tags.length > 0 && (
|
||||
<div className="flex items-center">
|
||||
<TagIcon className="h-3 w-3 mr-1" />
|
||||
<span>{task.tags.map(tag => tag.name).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.due_date && (
|
||||
<div className="flex items-center">
|
||||
<CalendarIcon className="h-3 w-3 mr-1" />
|
||||
<span>{formatDueDate(task.due_date)}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.recurrence_type && task.recurrence_type !== 'none' && (
|
||||
<div className="flex items-center">
|
||||
<ArrowPathIcon className="h-3 w-3 mr-1" />
|
||||
<span>{formatRecurrence(task.recurrence_type)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile badges row */}
|
||||
<div className="flex items-center flex-wrap justify-start space-x-2 mt-2 ml-8">
|
||||
<TaskTags tags={task.tags || []} />
|
||||
{task.due_date && <TaskDueDate dueDate={task.due_date} />}
|
||||
<TaskRecurrenceBadge recurrenceType={task.recurrence_type || 'none'} />
|
||||
<TaskStatusBadge status={task.status} />
|
||||
|
||||
{/* Play/In Progress Controls - Mobile */}
|
||||
{(task.status === 'not_started' || task.status === 'in_progress' || task.status === 0 || task.status === 1) && (
|
||||
<button
|
||||
onClick={handlePlayToggle}
|
||||
className={`flex items-center justify-center w-6 h-6 rounded-full transition-all duration-200 ${
|
||||
(task.status === 'in_progress' || task.status === 1)
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-600 dark:text-green-400 hover:bg-green-200 dark:hover:bg-green-800 animate-pulse'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600 opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
title={(task.status === 'in_progress' || task.status === 1) ? t('tasks.setNotStarted', 'Set to not started') : t('tasks.setInProgress', 'Set in progress')}
|
||||
>
|
||||
<PlayIcon className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Today Plan Controls - Mobile */}
|
||||
{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 ${
|
||||
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'
|
||||
}`}
|
||||
title={task.today ? t('tasks.removeFromToday', 'Remove from today plan') : t('tasks.addToToday', 'Add to today plan')}
|
||||
>
|
||||
{task.today ? (
|
||||
<CalendarDaysIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<CalendarIcon className="h-3 w-3" />
|
||||
)}
|
||||
{task.today_move_count && task.today_move_count > 1 && (
|
||||
<span className="ml-1 text-xs font-medium">
|
||||
{task.today_move_count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,13 +4,15 @@ import { Project } from '../../entities/Project';
|
|||
import TaskHeader from './TaskHeader';
|
||||
import TaskModal from './TaskModal';
|
||||
import { toggleTaskCompletion } from '../../utils/tasksService';
|
||||
import { isTaskOverdue } from '../../utils/dateUtils';
|
||||
|
||||
interface TaskItemProps {
|
||||
task: Task;
|
||||
onTaskUpdate: (task: Task) => void;
|
||||
onTaskUpdate: (task: Task) => Promise<void>;
|
||||
onTaskDelete: (taskId: number) => void;
|
||||
projects: Project[];
|
||||
hideProjectName?: boolean;
|
||||
onToggleToday?: (taskId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const TaskItem: React.FC<TaskItemProps> = ({
|
||||
|
|
@ -19,6 +21,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
onTaskDelete,
|
||||
projects,
|
||||
hideProjectName = false,
|
||||
onToggleToday,
|
||||
}) => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [projectList, setProjectList] = useState<Project[]>(projects);
|
||||
|
|
@ -27,14 +30,14 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = (updatedTask: Task) => {
|
||||
onTaskUpdate(updatedTask);
|
||||
const handleSave = async (updatedTask: Task) => {
|
||||
await onTaskUpdate(updatedTask);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
const handleDelete = async (taskId: number) => {
|
||||
if (task.id) {
|
||||
onTaskDelete(task.id);
|
||||
await onTaskDelete(task.id);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -42,7 +45,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
if (task.id) {
|
||||
try {
|
||||
const updatedTask = await toggleTaskCompletion(task.id);
|
||||
onTaskUpdate(updatedTask);
|
||||
await onTaskUpdate(updatedTask);
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
|
@ -73,9 +76,30 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
// Use the project from the task's included data if available, otherwise find from projectList
|
||||
const project = task.Project || projectList.find((p) => p.id === task.project_id);
|
||||
|
||||
// Check if task is in progress to apply pulsing border animation
|
||||
const isInProgress = task.status === 'in_progress' || task.status === 1;
|
||||
|
||||
// Check if task is overdue (created yesterday or earlier and not completed)
|
||||
const isOverdue = isTaskOverdue(task);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1">
|
||||
<TaskHeader task={task} project={project} onTaskClick={handleTaskClick} onToggleCompletion={handleToggleCompletion} hideProjectName={hideProjectName} />
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 mt-1 ${
|
||||
isInProgress
|
||||
? 'border-2 border-green-400/60 dark:border-green-500/60'
|
||||
: 'border-2 border-gray-50 dark:border-gray-800'
|
||||
}`}
|
||||
>
|
||||
<TaskHeader
|
||||
task={task}
|
||||
project={project}
|
||||
onTaskClick={handleTaskClick}
|
||||
onToggleCompletion={handleToggleCompletion}
|
||||
hideProjectName={hideProjectName}
|
||||
onToggleToday={onToggleToday}
|
||||
onTaskUpdate={onTaskUpdate}
|
||||
isOverdue={isOverdue}
|
||||
/>
|
||||
|
||||
<TaskModal
|
||||
isOpen={isModalOpen}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ import { Task } from '../../entities/Task';
|
|||
|
||||
interface TaskListProps {
|
||||
tasks: Task[];
|
||||
onTaskUpdate: (task: Task) => void;
|
||||
onTaskUpdate: (task: Task) => Promise<void>;
|
||||
onTaskCreate?: (task: Task) => void;
|
||||
onTaskDelete: (taskId: number) => void;
|
||||
projects: Project[];
|
||||
hideProjectName?: boolean;
|
||||
onToggleToday?: (taskId: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const TaskList: React.FC<TaskListProps> = ({
|
||||
|
|
@ -18,6 +19,7 @@ const TaskList: React.FC<TaskListProps> = ({
|
|||
onTaskDelete,
|
||||
projects,
|
||||
hideProjectName = false,
|
||||
onToggleToday,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
|
|
@ -30,6 +32,7 @@ const TaskList: React.FC<TaskListProps> = ({
|
|||
onTaskDelete={onTaskDelete}
|
||||
projects={projects}
|
||||
hideProjectName={hideProjectName}
|
||||
onToggleToday={onToggleToday}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,12 +1,10 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { PriorityType, StatusType, Task, RecurrenceType } from "../../entities/Task";
|
||||
import { PriorityType, StatusType, Task } from "../../entities/Task";
|
||||
import TaskActions from "./TaskActions";
|
||||
import PriorityDropdown from "../Shared/PriorityDropdown";
|
||||
import StatusDropdown from "../Shared/StatusDropdown";
|
||||
import ConfirmDialog from "../Shared/ConfirmDialog";
|
||||
import CollapsibleSection from "../Shared/CollapsibleSection";
|
||||
import { useToast } from "../Shared/ToastContext";
|
||||
import TagInput from "../Tag/TagInput";
|
||||
import RecurrenceInput from "./RecurrenceInput";
|
||||
import TimelinePanel from "./TimelinePanel";
|
||||
import { Project } from "../../entities/Project";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import { fetchTags } from '../../utils/tagsService';
|
||||
|
|
@ -14,13 +12,22 @@ import { fetchTaskById } from '../../utils/tasksService';
|
|||
import { getTaskIntelligenceEnabled } from '../../utils/profileService';
|
||||
import { analyzeTaskName, TaskAnalysis } from '../../utils/taskIntelligenceService';
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ClockIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
// Import form sections
|
||||
import TaskTitleSection from "./TaskForm/TaskTitleSection";
|
||||
import TaskContentSection from "./TaskForm/TaskContentSection";
|
||||
import TaskTagsSection from "./TaskForm/TaskTagsSection";
|
||||
import TaskProjectSection from "./TaskForm/TaskProjectSection";
|
||||
import TaskMetadataSection from "./TaskForm/TaskMetadataSection";
|
||||
import TaskRecurrenceSection from "./TaskForm/TaskRecurrenceSection";
|
||||
|
||||
interface TaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
task: Task;
|
||||
onSave: (task: Task) => void;
|
||||
onDelete: (taskId: number) => void;
|
||||
onDelete: (taskId: number) => Promise<void>;
|
||||
projects: Project[];
|
||||
onCreateProject: (name: string) => Promise<Project>;
|
||||
onEditParentTask?: (parentTask: Task) => void;
|
||||
|
|
@ -52,9 +59,26 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
const [parentTaskLoading, setParentTaskLoading] = useState(false);
|
||||
const [taskAnalysis, setTaskAnalysis] = useState<TaskAnalysis | null>(null);
|
||||
const [taskIntelligenceEnabled, setTaskIntelligenceEnabled] = useState(true);
|
||||
const [isTimelineExpanded, setIsTimelineExpanded] = useState(false);
|
||||
|
||||
// Collapsible section states
|
||||
const [expandedSections, setExpandedSections] = useState({
|
||||
tags: false,
|
||||
project: false,
|
||||
metadata: false,
|
||||
recurrence: false
|
||||
});
|
||||
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const toggleSection = useCallback((section: keyof typeof expandedSections) => {
|
||||
setExpandedSections(prev => ({
|
||||
...prev,
|
||||
[section]: !prev[section]
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData(task);
|
||||
setTags(task.tags?.map((tag) => tag.name) || []);
|
||||
|
|
@ -215,9 +239,9 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
setFilteredProjects([...filteredProjects, newProject]);
|
||||
setNewProjectName(newProject.name);
|
||||
setDropdownOpen(false);
|
||||
showSuccessToast("Project created successfully!");
|
||||
showSuccessToast(t('success.projectCreated'));
|
||||
} catch (error) {
|
||||
showErrorToast("Failed to create project.");
|
||||
showErrorToast(t('errors.projectCreationFailed'));
|
||||
console.error("Error creating project:", error);
|
||||
} finally {
|
||||
setIsCreatingProject(false);
|
||||
|
|
@ -227,7 +251,12 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
|
||||
const handleSubmit = () => {
|
||||
onSave({ ...formData, tags: tags.map((tag) => ({ name: tag })) });
|
||||
showSuccessToast("Task updated successfully!");
|
||||
const taskLink = (
|
||||
<span>
|
||||
{t('task.updated', 'Task')} <a href={`/task/${formData.uuid}`} className="text-green-200 underline hover:text-green-100">{formData.name}</a> {t('task.updatedSuccessfully', 'updated successfully!')}
|
||||
</span>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
handleClose();
|
||||
};
|
||||
|
||||
|
|
@ -235,12 +264,22 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (formData.id) {
|
||||
onDelete(formData.id);
|
||||
showSuccessToast("Task deleted successfully!");
|
||||
setShowConfirmDialog(false);
|
||||
handleClose();
|
||||
try {
|
||||
await onDelete(formData.id);
|
||||
const taskLink = (
|
||||
<span>
|
||||
{t('task.deleted', 'Task')} <a href={`/task/${formData.uuid}`} className="text-green-200 underline hover:text-green-100">{formData.name}</a> {t('task.deletedSuccessfully', 'deleted successfully!')}
|
||||
</span>
|
||||
);
|
||||
showSuccessToast(taskLink);
|
||||
setShowConfirmDialog(false);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete task:', error);
|
||||
showErrorToast(t('task.deleteError', 'Failed to delete task'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -297,205 +336,135 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
<div className="min-h-full flex items-start justify-center px-4 py-4">
|
||||
<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-3xl transform transition-transform duration-300 ${
|
||||
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-6xl transform transition-transform duration-300 ${
|
||||
isClosing ? "scale-95" : "scale-100"
|
||||
} my-4`}
|
||||
>
|
||||
<div className="p-4 space-y-3 text-sm">
|
||||
<form>
|
||||
<fieldset>
|
||||
<div className="py-4">
|
||||
<input
|
||||
type="text"
|
||||
id={`task_name_${task.id}`}
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="block w-full text-xl font-semibold dark:bg-gray-800 text-black dark:text-white border-b-2 border-gray-200 dark:border-gray-900 focus:outline-none shadow-sm py-2"
|
||||
placeholder={t('forms.task.namePlaceholder', 'Add Task Name')}
|
||||
/>
|
||||
{taskAnalysis && taskAnalysis.isVague && taskIntelligenceEnabled && (
|
||||
<div className={`mt-2 p-3 rounded-md border ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-700'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-700'
|
||||
: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-700'
|
||||
}`}>
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<svg className={`h-4 w-4 mt-0.5 ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-400'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-400'
|
||||
: 'text-blue-400'
|
||||
}`} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="ml-2">
|
||||
<p className={`text-sm ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-800 dark:text-red-200'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-800 dark:text-yellow-200'
|
||||
: 'text-blue-800 dark:text-blue-200'
|
||||
}`}>
|
||||
<strong>
|
||||
{taskAnalysis.reason === 'short' && t('task.nameHelper.short', 'Make it more descriptive!')}
|
||||
{taskAnalysis.reason === 'no_verb' && t('task.nameHelper.noVerb', 'Add an action verb!')}
|
||||
{taskAnalysis.reason === 'vague_pattern' && t('task.nameHelper.vague', 'Be more specific!')}
|
||||
</strong>
|
||||
</p>
|
||||
{taskAnalysis.suggestion && (
|
||||
<p className={`text-xs mt-1 ${
|
||||
taskAnalysis.severity === 'high'
|
||||
? 'text-red-700 dark:text-red-300'
|
||||
: taskAnalysis.severity === 'medium'
|
||||
? 'text-yellow-700 dark:text-yellow-300'
|
||||
: 'text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
{t(taskAnalysis.suggestion, taskAnalysis.suggestion)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('forms.task.labels.tags', 'Tags')}
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<TagInput
|
||||
onTagsChange={handleTagsChange}
|
||||
initialTags={formData.tags?.map((tag) => tag.name) || []}
|
||||
availableTags={localAvailableTags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-3 relative">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t('forms.task.labels.project', 'Project')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('forms.task.projectSearchPlaceholder', 'Search or create a project...')}
|
||||
value={newProjectName}
|
||||
onChange={handleProjectSearch}
|
||||
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"
|
||||
/>
|
||||
{dropdownOpen && newProjectName && (
|
||||
<div className="absolute mt-1 bg-white dark:bg-gray-900 shadow-md rounded-md w-full z-10">
|
||||
{filteredProjects.length > 0 ? (
|
||||
filteredProjects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
onClick={() => handleProjectSelection(project)}
|
||||
className="block w-full text-gray-500 dark:text-gray-300 text-left px-4 py-2 hover:bg-gray-100 dark:hover:bg-gray-600"
|
||||
>
|
||||
{project.name}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-2 text-gray-500 dark:text-gray-300">
|
||||
{t('forms.task.noMatchingProjects', 'No matching projects')}
|
||||
</div>
|
||||
)}
|
||||
{newProjectName && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCreateProject}
|
||||
disabled={isCreatingProject}
|
||||
className="block w-full text-left px-4 py-2 bg-blue-500 text-white hover:bg-blue-600"
|
||||
>
|
||||
{isCreatingProject
|
||||
? t('forms.task.creatingProject', 'Creating...')
|
||||
: t('forms.task.createProject', '+ Create') + ` "${newProjectName}"`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 pb-3 sm:grid-flow-col">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t('forms.task.labels.status', 'Status')}
|
||||
</label>
|
||||
<StatusDropdown
|
||||
value={getStatusString(formData.status)}
|
||||
onChange={(value: StatusType) =>
|
||||
setFormData({ ...formData, status: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t('forms.task.labels.priority', 'Priority')}
|
||||
</label>
|
||||
<PriorityDropdown
|
||||
value={getPriorityString(formData.priority)}
|
||||
onChange={(value: PriorityType) =>
|
||||
setFormData({ ...formData, priority: value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t('forms.task.labels.dueDate', 'Due Date')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id={`task_due_date_${task.id}`}
|
||||
name="due_date"
|
||||
value={formData.due_date || ""}
|
||||
<div className="flex flex-col lg:flex-row min-h-[400px] max-h-[90vh]">
|
||||
{/* Main Form Section */}
|
||||
<div className={`flex-1 flex flex-col transition-all duration-300 ${
|
||||
isTimelineExpanded ? 'lg:pr-2' : ''
|
||||
}`}>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<form>
|
||||
<fieldset>
|
||||
{/* Task Title Section - Always Visible */}
|
||||
<TaskTitleSection
|
||||
taskId={task.id}
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className="block w-full focus:outline-none shadow-sm px-2 py-2 text-sm bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-900 rounded-md text-gray-900 dark:text-gray-100"
|
||||
taskAnalysis={taskAnalysis}
|
||||
taskIntelligenceEnabled={taskIntelligenceEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pb-3">
|
||||
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t('forms.noteContent')}
|
||||
</label>
|
||||
<textarea
|
||||
id={`task_note_${task.id}`}
|
||||
name="note"
|
||||
rows={3}
|
||||
value={formData.note || ""}
|
||||
onChange={handleChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-900 rounded-md focus:outline-none shadow-sm p-3 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
|
||||
placeholder={t('forms.noteContentPlaceholder')}
|
||||
></textarea>
|
||||
</div>
|
||||
<RecurrenceInput
|
||||
recurrenceType={parentTask ? (parentTask.recurrence_type || 'none') : (formData.recurrence_type || 'none')}
|
||||
recurrenceInterval={parentTask ? (parentTask.recurrence_interval || 1) : (formData.recurrence_interval || 1)}
|
||||
recurrenceEndDate={parentTask ? parentTask.recurrence_end_date : formData.recurrence_end_date}
|
||||
recurrenceWeekday={parentTask ? parentTask.recurrence_weekday : formData.recurrence_weekday}
|
||||
recurrenceMonthDay={parentTask ? parentTask.recurrence_month_day : formData.recurrence_month_day}
|
||||
recurrenceWeekOfMonth={parentTask ? parentTask.recurrence_week_of_month : formData.recurrence_week_of_month}
|
||||
completionBased={parentTask ? (parentTask.completion_based || false) : (formData.completion_based || false)}
|
||||
onChange={handleRecurrenceChange}
|
||||
disabled={!!parentTask}
|
||||
isChildTask={!!parentTask}
|
||||
parentTaskLoading={parentTaskLoading}
|
||||
onEditParent={parentTask ? handleEditParent : undefined}
|
||||
onParentRecurrenceChange={parentTask ? handleParentRecurrenceChange : undefined}
|
||||
|
||||
{/* Content Section - Always Visible */}
|
||||
<TaskContentSection
|
||||
taskId={task.id}
|
||||
value={formData.note || ""}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
{/* Tags Section - Collapsible */}
|
||||
<CollapsibleSection
|
||||
title={t('forms.task.labels.tags', 'Tags')}
|
||||
isExpanded={expandedSections.tags}
|
||||
onToggle={() => toggleSection('tags')}
|
||||
>
|
||||
<TaskTagsSection
|
||||
tags={formData.tags?.map((tag) => tag.name) || []}
|
||||
onTagsChange={handleTagsChange}
|
||||
availableTags={localAvailableTags}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Project Section - Collapsible */}
|
||||
<CollapsibleSection
|
||||
title={t('forms.task.labels.project', 'Project')}
|
||||
isExpanded={expandedSections.project}
|
||||
onToggle={() => toggleSection('project')}
|
||||
>
|
||||
<TaskProjectSection
|
||||
newProjectName={newProjectName}
|
||||
onProjectSearch={handleProjectSearch}
|
||||
dropdownOpen={dropdownOpen}
|
||||
filteredProjects={filteredProjects}
|
||||
onProjectSelection={handleProjectSelection}
|
||||
onCreateProject={handleCreateProject}
|
||||
isCreatingProject={isCreatingProject}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Metadata/Options Section - Collapsible */}
|
||||
<CollapsibleSection
|
||||
title={t('forms.task.statusAndOptions', 'Status & Options')}
|
||||
isExpanded={expandedSections.metadata}
|
||||
onToggle={() => toggleSection('metadata')}
|
||||
>
|
||||
<TaskMetadataSection
|
||||
priority={getPriorityString(formData.priority)}
|
||||
dueDate={formData.due_date || ""}
|
||||
taskId={task.id}
|
||||
onStatusChange={(value: StatusType) => {
|
||||
// Universal rule: when setting status to in_progress, also add to today
|
||||
const updatedData = { ...formData, status: value };
|
||||
if (value === 'in_progress') {
|
||||
updatedData.today = true;
|
||||
}
|
||||
setFormData(updatedData);
|
||||
}}
|
||||
onPriorityChange={(value: PriorityType) =>
|
||||
setFormData({ ...formData, priority: value })
|
||||
}
|
||||
onDueDateChange={handleChange}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
|
||||
{/* Recurrence Section - Collapsible */}
|
||||
<CollapsibleSection
|
||||
title={t('forms.task.recurrence', 'Recurrence')}
|
||||
isExpanded={expandedSections.recurrence}
|
||||
onToggle={() => toggleSection('recurrence')}
|
||||
>
|
||||
<TaskRecurrenceSection
|
||||
formData={formData}
|
||||
parentTask={parentTask}
|
||||
parentTaskLoading={parentTaskLoading}
|
||||
onRecurrenceChange={handleRecurrenceChange}
|
||||
onEditParent={parentTask ? handleEditParent : undefined}
|
||||
onParentRecurrenceChange={parentTask ? handleParentRecurrenceChange : undefined}
|
||||
/>
|
||||
</CollapsibleSection>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons - Fixed at bottom */}
|
||||
<div className="flex-shrink-0 p-3 flex items-center justify-between">
|
||||
<TaskActions
|
||||
taskId={task.id}
|
||||
onDelete={handleDeleteClick}
|
||||
onSave={handleSubmit}
|
||||
onCancel={handleClose}
|
||||
/>
|
||||
|
||||
{/* Timeline Toggle Button */}
|
||||
<button
|
||||
onClick={() => setIsTimelineExpanded(!isTimelineExpanded)}
|
||||
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-full transition-colors"
|
||||
title={isTimelineExpanded ? t('timeline.hideActivityTimeline') : t('timeline.showActivityTimeline')}
|
||||
>
|
||||
<ClockIcon
|
||||
className={`h-5 w-5 transition-transform duration-200 ${isTimelineExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<div className="p-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<TaskActions
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Panel - Side Panel */}
|
||||
<TimelinePanel
|
||||
taskId={task.id}
|
||||
onDelete={handleDeleteClick}
|
||||
onSave={handleSubmit}
|
||||
onCancel={handleClose}
|
||||
isExpanded={isTimelineExpanded}
|
||||
onToggle={() => setIsTimelineExpanded(!isTimelineExpanded)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import { CheckCircleIcon } from '@heroicons/react/24/solid';
|
||||
import { CheckCircleIcon as CheckCircleOutlineIcon } from '@heroicons/react/24/outline';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TaskPriorityIconProps {
|
||||
|
|
@ -52,17 +53,10 @@ const TaskPriorityIcon: React.FC<TaskPriorityIconProps> = ({ priority, status, o
|
|||
);
|
||||
} else {
|
||||
return (
|
||||
<svg
|
||||
<CheckCircleOutlineIcon
|
||||
className={`h-5 w-5 ${colorClass} cursor-pointer hover:scale-110 transition-transform`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" fill="none" />
|
||||
</svg>
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue