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:
Chris 2025-06-27 14:02:18 +03:00 committed by GitHub
parent 3affbe9baf
commit 03f38f05dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
143 changed files with 80005 additions and 21674 deletions

1
.gitignore vendored
View file

@ -10,6 +10,7 @@ node_modules
public/js/bundle.js
.aider*
.claude*
backend/coverage/

View file

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

View file

@ -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)
}
});

View file

@ -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');
}
};

View 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');
}
};

View 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');
}
};

View file

@ -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');
}
};

View 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');
}
};

View 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');
}
};

View file

@ -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');
}
};

View file

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

View 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;

View file

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

View file

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

View 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;
};

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

@ -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.' });
}

View 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;

View file

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

View file

@ -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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/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 });
}

View file

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

View 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();

View file

@ -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);
}

View file

@ -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');

View 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);
});
}

View 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 };

View 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
View file

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

View 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;

View file

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

View file

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

View file

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

View 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

File diff suppressed because one or more lines are too long

2
dist/index.html vendored
View file

@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="/">
<title>Tududi</title>
<script defer src="/main.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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

@ -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();
});
});

View file

@ -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();
});
});
});

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

@ -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
});
});

View file

@ -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();
});
});

View file

@ -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();
});
});
});

View file

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

View file

@ -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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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');
});
});
});

View file

@ -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');
});
});
});

View file

@ -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 */}

View 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;

View 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;

View 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;

View 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;

View file

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

View file

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

View file

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

View file

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

View file

@ -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...&#10;&#10;Examples:&#10;# Heading&#10;**Bold text**&#10;*Italic text*&#10;- List item&#10;```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>}

View file

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

View file

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

View file

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

View file

@ -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("");
}
};

View file

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

View file

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

View file

@ -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();
}

View 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;

View 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;

View 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;

View 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;

View file

@ -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">
&times;
</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">
&times;
</button>
</div>
</div>
);
};

View 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;

View file

@ -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' && (

View file

@ -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>
);
};

View file

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

View file

@ -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 */}

View file

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

View 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;

View file

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

View file

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

View file

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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View file

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

View file

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

View file

@ -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}
/>
))
) : (

View file

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

View file

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