Feat notifications (#594)

* Add notifications for deferred and due tasks

* Cleanup

* fixup! Cleanup

* Add notifications settings

* ADd dismissed for notifications

* Beautify project cards

* fixup! Beautify project cards

* Fix an issue with icon badge

* Cleanup scripts

* fixup! Cleanup scripts
This commit is contained in:
Chris 2025-11-25 21:16:21 +02:00 committed by GitHub
parent b2a5a4c63e
commit 18c7785b13
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 3350 additions and 361 deletions

View file

@ -184,6 +184,7 @@ const registerApiRoutes = (basePath) => {
app.use(basePath, require('./routes/task-events'));
app.use(`${basePath}/search`, require('./routes/search'));
app.use(`${basePath}/views`, require('./routes/views'));
app.use(`${basePath}/notifications`, require('./routes/notifications'));
};
// Register routes at both /api and /api/v1 (if versioned) to maintain backwards compatibility

View file

@ -0,0 +1,90 @@
'use strict';
const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils');
module.exports = {
async up(queryInterface, Sequelize) {
await safeCreateTable(queryInterface, 'notifications', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
uid: {
type: Sequelize.STRING,
unique: true,
allowNull: false,
},
user_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
onDelete: 'CASCADE',
},
type: {
type: Sequelize.STRING,
allowNull: false,
comment: 'Type of notification (task_assigned, reminder, etc.)',
},
level: {
type: Sequelize.STRING,
allowNull: false,
defaultValue: 'info',
comment: 'Notification level: info, warning, error, success',
},
title: {
type: Sequelize.STRING,
allowNull: false,
},
message: {
type: Sequelize.TEXT,
allowNull: true,
},
data: {
type: Sequelize.JSON,
allowNull: true,
comment: 'Additional structured data for the notification',
},
sources: {
type: Sequelize.JSON,
allowNull: false,
defaultValue: '[]',
comment: 'Array of source platforms: telegram, mobile, browser',
},
read_at: {
type: Sequelize.DATE,
allowNull: true,
},
sent_at: {
type: Sequelize.DATE,
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 indexes for efficient querying
await safeAddIndex(queryInterface, 'notifications', ['user_id']);
await safeAddIndex(queryInterface, 'notifications', ['read_at']);
await safeAddIndex(queryInterface, 'notifications', ['created_at']);
await safeAddIndex(queryInterface, 'notifications', [
'user_id',
'read_at',
]);
},
async down(queryInterface) {
await queryInterface.dropTable('notifications');
},
};

View file

@ -0,0 +1,94 @@
'use strict';
const {
safeAddColumns,
safeAddIndex,
safeRemoveColumn,
} = require('../utils/migration-utils');
module.exports = {
async up(queryInterface, Sequelize) {
// Add notification_preferences to users table
await safeAddColumns(queryInterface, 'users', [
{
name: 'notification_preferences',
definition: {
type: Sequelize.JSON,
allowNull: true,
defaultValue: {
dueTasks: { inApp: true, email: false, push: false },
overdueTasks: {
inApp: true,
email: false,
push: false,
},
dueProjects: { inApp: true, email: false, push: false },
overdueProjects: {
inApp: true,
email: false,
push: false,
},
deferUntil: { inApp: true, email: false, push: false },
},
comment:
'User notification channel preferences for different notification types',
},
},
]);
// Add dismissed_at to notifications table
await safeAddColumns(queryInterface, 'notifications', [
{
name: 'dismissed_at',
definition: {
type: Sequelize.DATE,
allowNull: true,
defaultValue: null,
},
},
]);
// Add indexes for better query performance
await safeAddIndex(queryInterface, 'notifications', ['dismissed_at'], {
name: 'notifications_dismissed_at_idx',
});
await safeAddIndex(
queryInterface,
'notifications',
['user_id', 'dismissed_at'],
{
name: 'notifications_user_dismissed_idx',
}
);
},
async down(queryInterface, Sequelize) {
// Remove indexes first
try {
await queryInterface.removeIndex(
'notifications',
'notifications_user_dismissed_idx'
);
} catch (error) {
console.log('Index notifications_user_dismissed_idx not found');
}
try {
await queryInterface.removeIndex(
'notifications',
'notifications_dismissed_at_idx'
);
} catch (error) {
console.log('Index notifications_dismissed_at_idx not found');
}
// Remove columns
await safeRemoveColumn(queryInterface, 'notifications', 'dismissed_at');
await safeRemoveColumn(
queryInterface,
'users',
'notification_preferences'
);
},
};

View file

@ -35,6 +35,7 @@ const Permission = require('./permission')(sequelize);
const View = require('./view')(sequelize);
const ApiToken = require('./api_token')(sequelize);
const Setting = require('./setting')(sequelize);
const Notification = require('./notification')(sequelize);
// Define associations
User.hasMany(Area, { foreignKey: 'user_id' });
@ -144,6 +145,10 @@ View.belongsTo(User, { foreignKey: 'user_id' });
User.hasMany(ApiToken, { foreignKey: 'user_id', as: 'apiTokens' });
ApiToken.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
// Notification associations
User.hasMany(Notification, { foreignKey: 'user_id', as: 'Notifications' });
Notification.belongsTo(User, { foreignKey: 'user_id', as: 'User' });
module.exports = {
sequelize,
User,
@ -160,4 +165,5 @@ module.exports = {
View,
ApiToken,
Setting,
Notification,
};

View file

@ -0,0 +1,315 @@
const { DataTypes } = require('sequelize');
const { v4: uuid } = require('uuid');
module.exports = (sequelize) => {
const Notification = sequelize.define(
'Notification',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
uid: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
defaultValue: () => uuid(),
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'users',
key: 'id',
},
},
type: {
type: DataTypes.STRING,
allowNull: false,
validate: {
isIn: [
[
'task_assigned',
'task_completed',
'task_due_soon',
'task_overdue',
'comment_added',
'mention',
'reminder',
'system',
'project_due_soon',
'project_overdue',
],
],
},
},
level: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'info',
validate: {
isIn: [['info', 'warning', 'error', 'success']],
},
},
title: {
type: DataTypes.STRING,
allowNull: false,
},
message: {
type: DataTypes.TEXT,
allowNull: true,
},
data: {
type: DataTypes.JSON,
allowNull: true,
},
sources: {
type: DataTypes.JSON,
allowNull: false,
defaultValue: [],
validate: {
isValidSources(value) {
if (!Array.isArray(value)) {
throw new Error('Sources must be an array');
}
const validSources = ['telegram', 'mobile', 'email'];
const invalidSources = value.filter(
(s) => !validSources.includes(s)
);
if (invalidSources.length > 0) {
throw new Error(
`Invalid sources: ${invalidSources.join(', ')}`
);
}
},
},
},
read_at: {
type: DataTypes.DATE,
allowNull: true,
},
sent_at: {
type: DataTypes.DATE,
allowNull: true,
},
dismissed_at: {
type: DataTypes.DATE,
allowNull: true,
},
},
{
tableName: 'notifications',
timestamps: true,
createdAt: 'created_at',
updatedAt: 'updated_at',
indexes: [
{
fields: ['user_id'],
},
{
fields: ['read_at'],
},
{
fields: ['created_at'],
},
{
fields: ['user_id', 'read_at'],
},
{
fields: ['dismissed_at'],
},
{
fields: ['user_id', 'dismissed_at'],
},
],
}
);
// Define associations
Notification.associate = function (models) {
// Notification belongs to User
Notification.belongsTo(models.User, {
foreignKey: 'user_id',
as: 'User',
});
};
/**
* Create a notification and send it via configured sources
*/
Notification.createNotification = async function ({
userId,
type,
title,
message,
data = null,
sources = [],
sentAt = null,
level = 'info',
}) {
const notification = await Notification.create({
user_id: userId,
type,
title,
message,
data,
sources,
level,
sent_at: sentAt || new Date(),
});
if (sources.includes('email')) {
await sendEmailNotification(userId, title, message, Notification);
}
return notification;
};
/**
* Send email notification
*/
async function sendEmailNotification(
userId,
title,
message,
NotificationModel
) {
try {
const {
sendEmail,
isEmailEnabled,
} = require('../services/emailService');
if (!isEmailEnabled() || !message) {
return;
}
const UserModel = NotificationModel.sequelize.models.User;
const user = await UserModel.findByPk(userId, {
attributes: ['email', 'name'],
});
if (user?.email) {
await sendEmail({
to: user.email,
subject: title,
text: message,
});
}
} catch (error) {
console.error('Failed to send email notification:', error);
}
}
/**
* Mark a notification as read
*/
Notification.prototype.markAsRead = async function () {
if (!this.read_at) {
this.read_at = new Date();
await this.save();
}
return this;
};
/**
* Mark a notification as unread
*/
Notification.prototype.markAsUnread = async function () {
this.read_at = null;
await this.save();
return this;
};
/**
* Check if notification is read
*/
Notification.prototype.isRead = function () {
return this.read_at !== null;
};
/**
* Dismiss (soft delete) a notification
*/
Notification.prototype.dismiss = async function () {
if (!this.dismissed_at) {
this.dismissed_at = new Date();
await this.save();
}
return this;
};
/**
* Check if notification is dismissed
*/
Notification.prototype.isDismissed = function () {
return this.dismissed_at !== null;
};
/**
* Get notifications for a user with pagination
*/
Notification.getUserNotifications = async function (userId, options = {}) {
const {
limit = 10,
offset = 0,
includeRead = true,
type = null,
} = options;
const where = {
user_id: userId,
dismissed_at: null, // Exclude dismissed notifications
};
if (!includeRead) {
where.read_at = null;
}
if (type) {
where.type = type;
}
const result = await Notification.findAndCountAll({
where,
order: [['created_at', 'DESC']],
limit,
offset,
});
return {
notifications: result.rows,
total: result.count,
};
};
/**
* Get count of unread notifications for a user
*/
Notification.getUnreadCount = async function (userId) {
return await Notification.count({
where: {
user_id: userId,
read_at: null,
dismissed_at: null, // Exclude dismissed notifications
},
});
};
/**
* Mark all notifications as read for a user
*/
Notification.markAllAsRead = async function (userId) {
return await Notification.update(
{ read_at: new Date() },
{
where: {
user_id: userId,
read_at: null,
dismissed_at: null, // Only mark non-dismissed notifications as read
},
}
);
};
return Notification;
};

View file

@ -176,6 +176,17 @@ module.exports = (sequelize) => {
},
},
},
notification_preferences: {
type: DataTypes.JSON,
allowNull: true,
defaultValue: {
dueTasks: { inApp: true, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: true, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
},
},
email_verified: {
type: DataTypes.BOOLEAN,
allowNull: false,

View file

@ -0,0 +1,159 @@
const express = require('express');
const { Notification } = require('../models');
const { logError } = require('../services/logService');
const router = express.Router();
const { getAuthenticatedUserId } = require('../utils/request-utils');
// Middleware to require authentication
router.use((req, res, next) => {
const userId = getAuthenticatedUserId(req);
if (!userId) {
return res.status(401).json({ error: 'Authentication required' });
}
req.authUserId = userId;
next();
});
// GET /notifications - Get user's notifications
router.get('/', async (req, res) => {
try {
const {
limit = 10,
offset = 0,
includeRead = 'true',
type,
} = req.query;
const { notifications, total } =
await Notification.getUserNotifications(req.authUserId, {
limit: parseInt(limit),
offset: parseInt(offset),
includeRead: includeRead === 'true',
type: type || null,
});
res.json({
notifications,
total,
});
} catch (error) {
logError('Error fetching notifications:', error);
res.status(500).json({ error: 'Failed to fetch notifications' });
}
});
// GET /notifications/unread-count - Get count of unread notifications
router.get('/unread-count', async (req, res) => {
try {
const count = await Notification.getUnreadCount(req.authUserId);
res.json({ count });
} catch (error) {
logError('Error fetching unread count:', error);
res.status(500).json({ error: 'Failed to fetch unread count' });
}
});
// POST /notifications/:id/read - Mark notification as read
router.post('/:id/read', async (req, res) => {
try {
const notification = await Notification.findOne({
where: {
id: req.params.id,
user_id: req.authUserId,
},
});
if (!notification) {
return res.status(404).json({ error: 'Notification not found' });
}
await notification.markAsRead();
res.json({
notification,
message: 'Notification marked as read',
});
} catch (error) {
logError('Error marking notification as read:', error);
res.status(500).json({ error: 'Failed to mark notification as read' });
}
});
// POST /notifications/:id/unread - Mark notification as unread
router.post('/:id/unread', async (req, res) => {
try {
const notification = await Notification.findOne({
where: {
id: req.params.id,
user_id: req.authUserId,
},
});
if (!notification) {
return res.status(404).json({ error: 'Notification not found' });
}
await notification.markAsUnread();
res.json({
notification,
message: 'Notification marked as unread',
});
} catch (error) {
logError('Error marking notification as unread:', error);
res.status(500).json({
error: 'Failed to mark notification as unread',
});
}
});
// POST /notifications/mark-all-read - Mark all notifications as read
router.post('/mark-all-read', async (req, res) => {
try {
const [count] = await Notification.markAllAsRead(req.authUserId);
res.json({
count,
message: `Marked ${count} notifications as read`,
});
} catch (error) {
logError('Error marking all notifications as read:', error);
res.status(500).json({
error: 'Failed to mark all notifications as read',
});
}
});
// DELETE /notifications/:id - Soft delete (dismiss) a notification
router.delete('/:id', async (req, res) => {
try {
console.log(
`Attempting to dismiss notification ${req.params.id} for user ${req.authUserId}`
);
const notification = await Notification.findOne({
where: {
id: req.params.id,
user_id: req.authUserId,
dismissed_at: null, // Only allow dismissing non-dismissed notifications
},
});
if (!notification) {
console.log(
`Notification ${req.params.id} not found or already dismissed for user ${req.authUserId}`
);
return res.status(404).json({ error: 'Notification not found' });
}
await notification.dismiss();
console.log(`Successfully dismissed notification ${req.params.id}`);
res.json({ message: 'Notification dismissed successfully' });
} catch (error) {
logError('Error dismissing notification:', error);
res.status(500).json({ error: 'Failed to dismiss notification' });
}
});
module.exports = router;

View file

@ -221,6 +221,10 @@ router.get('/projects', async (req, res) => {
model: Task,
required: false,
attributes: ['id', 'status'],
where: {
parent_task_id: null,
recurring_parent_id: null,
},
},
{
model: Area,

View file

@ -129,6 +129,7 @@ router.get('/profile', async (req, res) => {
'sidebar_settings',
'productivity_assistant_enabled',
'next_task_suggestion_enabled',
'notification_preferences',
],
});
@ -186,6 +187,7 @@ router.patch('/profile', async (req, res) => {
next_task_suggestion_enabled,
pomodoro_enabled,
ui_settings,
notification_preferences,
currentPassword,
newPassword,
} = req.body;
@ -223,6 +225,8 @@ router.patch('/profile', async (req, res) => {
if (pomodoro_enabled !== undefined)
allowedUpdates.pomodoro_enabled = pomodoro_enabled;
if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings;
if (notification_preferences !== undefined)
allowedUpdates.notification_preferences = notification_preferences;
// Validate first_day_of_week if provided
if (first_day_of_week !== undefined) {
@ -287,6 +291,7 @@ router.patch('/profile', async (req, res) => {
'productivity_assistant_enabled',
'next_task_suggestion_enabled',
'pomodoro_enabled',
'notification_preferences',
],
});

View file

@ -1,97 +0,0 @@
#!/usr/bin/env node
/**
* Simple script to populate missing UIDs for inbox items using sqlite3 directly
* Usage: node backend/scripts/fix-inbox-uids.js [database_path]
*/
const sqlite3 = require('sqlite3').verbose();
const { uid } = require('../utils/uid');
const dbPath = process.argv[2] || 'backend/db/development.sqlite3';
console.log(`\nConnecting to database: ${dbPath}\n`);
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Error opening database:', err);
process.exit(1);
}
});
function getItemsWithoutUID() {
return new Promise((resolve, reject) => {
db.all(
'SELECT id, content FROM inbox_items WHERE uid IS NULL OR uid = ""',
[],
(err, rows) => {
if (err) reject(err);
else resolve(rows);
}
);
});
}
function updateItemUID(id, newUid) {
return new Promise((resolve, reject) => {
db.run(
'UPDATE inbox_items SET uid = ? WHERE id = ?',
[newUid, id],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
}
async function fixInboxItemUIDs() {
try {
console.log('Checking for inbox items without UIDs...\n');
const items = await getItemsWithoutUID();
console.log(`Found ${items.length} inbox item(s) without UIDs\n`);
if (items.length === 0) {
console.log('✓ All inbox items have UIDs!');
db.close();
return;
}
console.log('Items to fix:');
items.forEach((item) => {
const preview = item.content.substring(0, 50);
console.log(
` - ID: ${item.id}, Content: ${preview}${item.content.length > 50 ? '...' : ''}`
);
});
console.log('\nGenerating and assigning UIDs...\n');
for (const item of items) {
const newUid = uid();
await updateItemUID(item.id, newUid);
console.log(`✓ Fixed item ${item.id}: assigned UID ${newUid}`);
}
console.log(`\n✓ Successfully fixed ${items.length} inbox item(s)!\n`);
// Verify
const remainingItems = await getItemsWithoutUID();
if (remainingItems.length === 0) {
console.log('✓ Verification passed: All items now have UIDs\n');
} else {
console.log(
`⚠ Warning: ${remainingItems.length} item(s) still without UIDs\n`
);
}
} catch (error) {
console.error('Error fixing inbox item UIDs:', error);
process.exit(1);
} finally {
db.close();
}
}
// Run the fix
fixInboxItemUIDs();

107
backend/scripts/reset-and-seed.js Executable file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const { getConfig } = require('../config/config');
const { sequelize } = require('../models');
/**
* Reset database and seed with comprehensive test data
* Run with: NODE_ENV=development node backend/scripts/reset-and-seed.js
*/
const config = getConfig();
console.log('🔄 Starting database reset and seed...\n');
console.log(`📁 Database: ${config.dbFile}`);
console.log(`🌍 Environment: ${config.environment}\n`);
if (config.environment === 'production') {
console.error(
'❌ ERROR: Cannot run this script in production environment!'
);
process.exit(1);
}
async function main() {
try {
// Step 1: Delete existing database file
console.log('1⃣ Removing existing database...');
if (fs.existsSync(config.dbFile)) {
fs.unlinkSync(config.dbFile);
console.log(' ✅ Database removed\n');
} else {
console.log(' No existing database found\n');
}
// Step 2: Reset database using sequelize.sync
console.log('2⃣ Creating fresh database...');
await sequelize.sync({ force: true });
console.log(' ✅ Database created\n');
// Step 3: Seed basic development data
console.log('3⃣ Seeding basic development data...');
const { seedDatabase } = require('../seeders/dev-seeder');
await seedDatabase();
console.log(' ✅ Basic data seeded\n');
// Step 4: Seed notification test data
console.log('4⃣ Seeding notification test data...');
const {
seedNotificationTestData,
} = require('./seed-notification-test-data');
// Override process.exit to prevent the seeder from exiting
const originalExit = process.exit;
process.exit = () => {}; // No-op
await seedNotificationTestData();
// Restore original process.exit
process.exit = originalExit;
console.log(' ✅ Notification test data seeded\n');
// Step 5: Generate notifications
console.log('5⃣ Generating notifications...');
const { checkDueTasks } = require('../services/dueTaskService');
const {
checkDeferredTasks,
} = require('../services/deferredTaskService');
const { checkDueProjects } = require('../services/dueProjectService');
const dueTasksResult = await checkDueTasks();
const deferredTasksResult = await checkDeferredTasks();
const dueProjectsResult = await checkDueProjects();
const total =
dueTasksResult.notificationsCreated +
deferredTasksResult.notificationsCreated +
dueProjectsResult.notificationsCreated;
console.log(` ✅ Generated ${total} notifications\n`);
// Final summary
console.log('✅ Database reset and seed completed successfully!\n');
console.log('📊 Summary:');
console.log(' • Database: Fresh and ready');
console.log(' • Users: Test users created');
console.log(' • Tasks: Sample tasks with various due dates');
console.log(' • Projects: Sample projects with various due dates');
console.log(` • Notifications: ${total} notifications generated`);
console.log('\n🚀 You can now start the application with:');
console.log(' npm start\n');
await sequelize.close();
process.exit(0);
} catch (error) {
console.error('❌ Error:', error.message);
console.error(error);
await sequelize.close();
process.exit(1);
}
}
// Run the main function
main();

View file

@ -0,0 +1,56 @@
const { checkDueTasks } = require('../services/dueTaskService');
const { checkDeferredTasks } = require('../services/deferredTaskService');
const { checkDueProjects } = require('../services/dueProjectService');
/**
* Run all notification services
* Run with: NODE_ENV=development node backend/scripts/run-notification-services.js
*/
async function runAllNotificationServices() {
console.log('🔔 Running all notification services...\n');
try {
// Run due tasks service
console.log('📋 Checking due tasks...');
const dueTasksResult = await checkDueTasks();
console.log(' Result:', JSON.stringify(dueTasksResult, null, 2));
// Run deferred tasks service
console.log('\n⏰ Checking deferred tasks...');
const deferredTasksResult = await checkDeferredTasks();
console.log(' Result:', JSON.stringify(deferredTasksResult, null, 2));
// Run due projects service
console.log('\n📁 Checking due projects...');
const dueProjectsResult = await checkDueProjects();
console.log(' Result:', JSON.stringify(dueProjectsResult, null, 2));
console.log('\n✅ All notification services completed!');
console.log('\n📊 Summary:');
console.log(
` • Due tasks: ${dueTasksResult.notificationsCreated} notifications created`
);
console.log(
` • Deferred tasks: ${deferredTasksResult.notificationsCreated} notifications created`
);
console.log(
` • Due projects: ${dueProjectsResult.notificationsCreated} notifications created`
);
console.log(
` • Total: ${dueTasksResult.notificationsCreated + deferredTasksResult.notificationsCreated + dueProjectsResult.notificationsCreated} notifications created\n`
);
process.exit(0);
} catch (error) {
console.error('\n❌ Error running notification services:', error);
process.exit(1);
}
}
// Run the services
if (require.main === module) {
runAllNotificationServices();
}
module.exports = { runAllNotificationServices };

View file

@ -0,0 +1,234 @@
const { User, Task, Project } = require('../models');
/**
* Seed script to create test tasks and projects for notification testing
* Run with: NODE_ENV=development node backend/scripts/seed-notification-test-data.js
*/
async function seedNotificationTestData() {
try {
console.log('🌱 Starting to seed notification test data...');
// Get the first user (or create one if none exists)
let user = await User.findOne();
if (!user) {
console.log('📝 No users found, creating test user...');
const bcrypt = require('bcrypt');
const passwordHash = await bcrypt.hash('password123', 10);
user = await User.create({
email: 'test@tududi.com',
password_digest: passwordHash,
name: 'Test',
surname: 'User',
appearance: 'light',
language: 'en',
timezone: 'UTC',
});
console.log(`✅ Created test user: ${user.email}`);
} else {
console.log(
`👤 Using existing user: ${user.email} (ID: ${user.id})`
);
}
const now = new Date();
// Helper to create date offsets
const hoursAgo = (hours) =>
new Date(now.getTime() - hours * 60 * 60 * 1000);
const hoursFromNow = (hours) =>
new Date(now.getTime() + hours * 60 * 60 * 1000);
const daysAgo = (days) =>
new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
const daysFromNow = (days) =>
new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
console.log('\n📋 Creating test tasks...');
const tasks = [
// Overdue tasks
{
name: '🚨 Very overdue task',
user_id: user.id,
status: 0,
due_date: daysAgo(5),
description: 'This task is 5 days overdue',
},
{
name: '⚠️ Overdue yesterday',
user_id: user.id,
status: 0,
due_date: daysAgo(1),
description: 'This task was due yesterday',
},
{
name: '🔴 Overdue today',
user_id: user.id,
status: 0,
due_date: hoursAgo(6),
description: 'This task was due 6 hours ago',
},
// Due soon tasks
{
name: '🟡 Due in 2 hours',
user_id: user.id,
status: 0,
due_date: hoursFromNow(2),
description: 'This task is due soon',
},
{
name: '🟢 Due in 12 hours',
user_id: user.id,
status: 0,
due_date: hoursFromNow(12),
description: 'This task is due within 24 hours',
},
{
name: '📅 Due tomorrow',
user_id: user.id,
status: 0,
due_date: daysFromNow(1),
description: 'This task is due tomorrow',
},
// Deferred tasks
{
name: '⏰ Defer until now (should be active)',
user_id: user.id,
status: 0,
defer_until: hoursAgo(1),
description: 'This task was deferred but is now available',
},
{
name: '⏳ Defer until in 2 hours',
user_id: user.id,
status: 0,
defer_until: hoursFromNow(2),
description: 'This task will be available in 2 hours',
},
{
name: '📆 Defer until tomorrow',
user_id: user.id,
status: 0,
defer_until: daysFromNow(1),
description: 'This task will be available tomorrow',
},
// Tasks with no due date (should not trigger notifications)
{
name: '✨ No due date',
user_id: user.id,
status: 0,
description: 'This task has no due date',
},
// Completed task (should not trigger notifications)
{
name: '✅ Completed overdue task',
user_id: user.id,
status: 2,
due_date: daysAgo(3),
description: 'This task is completed so no notification',
completed_at: new Date(),
},
];
for (const taskData of tasks) {
const task = await Task.create(taskData);
console.log(` ✓ Created: ${task.name}`);
}
console.log('\n📁 Creating test projects...');
const projects = [
// Overdue projects
{
name: '🚨 Very overdue project',
user_id: user.id,
state: 'active',
due_date_at: daysAgo(7),
description: 'This project is 7 days overdue',
},
{
name: '⚠️ Project overdue yesterday',
user_id: user.id,
state: 'active',
due_date_at: daysAgo(1),
description: 'This project was due yesterday',
},
// Due soon projects
{
name: '🟡 Project due in 6 hours',
user_id: user.id,
state: 'active',
due_date_at: hoursFromNow(6),
description: 'This project is due soon',
},
{
name: '📅 Project due tomorrow',
user_id: user.id,
state: 'active',
due_date_at: daysFromNow(1),
description: 'This project is due within 24 hours',
},
// Projects with no due date
{
name: '✨ Project with no due date',
user_id: user.id,
state: 'active',
description: 'This project has no due date',
},
// Completed project (should not trigger notifications)
{
name: '✅ Completed overdue project',
user_id: user.id,
state: 'completed',
due_date_at: daysAgo(5),
description: 'This project is completed so no notification',
},
];
for (const projectData of projects) {
const project = await Project.create(projectData);
console.log(` ✓ Created: ${project.name}`);
}
console.log('\n✅ Seeding complete!');
console.log('\n📊 Summary:');
console.log(` • Created ${tasks.length} tasks`);
console.log(` • Created ${projects.length} projects`);
console.log(` • For user: ${user.email}\n`);
console.log(
'🔔 To generate notifications, run the notification services:'
);
console.log(
' • Due tasks: NODE_ENV=development node -e "require(\'./services/dueTaskService\').checkDueTasks().then(console.log)"'
);
console.log(
' • Deferred tasks: NODE_ENV=development node -e "require(\'./services/deferredTaskService\').checkDeferredTasks().then(console.log)"'
);
console.log(
' • Due projects: NODE_ENV=development node -e "require(\'./services/dueProjectService\').checkDueProjects().then(console.log)"'
);
console.log('');
process.exit(0);
} catch (error) {
console.error('❌ Error seeding data:', error);
process.exit(1);
}
}
// Run the seeder
if (require.main === module) {
seedNotificationTestData();
}
module.exports = { seedNotificationTestData };

View file

@ -1,56 +0,0 @@
const { Task, sequelize } = require('../models');
const { Op } = require('sequelize');
async function testQuery() {
const whereClause = {
parent_task_id: null,
status: {
[Op.notIn]: [
Task.STATUS.DONE,
Task.STATUS.ARCHIVED,
'done',
'archived',
],
},
};
whereClause[Op.or] = [
{
[Op.and]: [
{
[Op.or]: [
{ recurrence_type: 'none' },
{ recurrence_type: null },
],
},
{ recurring_parent_id: null },
],
},
{
[Op.and]: [{ recurring_parent_id: { [Op.ne]: null } }],
},
];
// Log the SQL that will be generated
const query = Task.findAll({
where: whereClause,
attributes: ['id', 'name', 'recurrence_type', 'recurring_parent_id'],
logging: console.log,
});
console.log('\nThis query should:');
console.log(
'✓ Include: Regular tasks (recurrence_type = null/none, recurring_parent_id = null)'
);
console.log('✓ Include: Recurring instances (recurring_parent_id != null)');
console.log(
'✗ Exclude: Recurring parent templates (recurrence_type = daily/weekly/etc, recurring_parent_id = null)'
);
await sequelize.close();
}
testQuery().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -348,6 +348,50 @@ async function seedDatabase() {
tasks.push(todayTask);
}
// Create subtasks for some tasks
console.log('📋 Creating subtasks for parent tasks...');
const { faker } = require('@faker-js/faker');
// Select 15-20 random tasks to have subtasks
const parentTaskIndices = [];
while (parentTaskIndices.length < 15) {
const randomIndex = Math.floor(Math.random() * tasks.length);
if (
!parentTaskIndices.includes(randomIndex) &&
tasks[randomIndex].status !== 2
) {
// Don't add subtasks to completed tasks
parentTaskIndices.push(randomIndex);
}
}
for (const parentIndex of parentTaskIndices) {
const parentTask = tasks[parentIndex];
const numSubtasks = Math.floor(Math.random() * 4) + 2; // 2-5 subtasks
for (let i = 0; i < numSubtasks; i++) {
const subtask = await Task.create({
name: faker.lorem.sentence({ min: 3, max: 6 }),
description:
Math.random() < 0.5 ? faker.lorem.paragraph() : null,
priority: Math.floor(Math.random() * 3),
status: Math.floor(Math.random() * 3), // 0, 1, or 2
user_id: testUser.id,
parent_task_id: parentTask.id,
order: i,
note:
Math.random() < 0.3
? `${faker.lorem.sentence()}\n\n- ${faker.lorem.sentence()}\n- ${faker.lorem.sentence()}`
: null,
});
tasks.push(subtask);
}
}
console.log(
` ✅ Created subtasks for ${parentTaskIndices.length} parent tasks`
);
// Create intelligent task-tag associations
console.log('🔗 Creating intelligent task-tag associations...');

View file

@ -1,3 +1,5 @@
const { faker } = require('@faker-js/faker');
// Helper function to create massive task data with AI feature triggers
function createMassiveTaskData(projects, getRandomDate, getPastDate) {
// Helper to get random items from array
@ -15,6 +17,26 @@ function createMassiveTaskData(projects, getRandomDate, getPastDate) {
return statuses[Math.floor(Math.random() * statuses.length)];
};
// Helper to generate task description (70% of tasks get a description)
const maybeDescription = () => {
if (Math.random() < 0.7) {
return faker.lorem.paragraph({ min: 1, max: 3 });
}
return null;
};
// Helper to generate task notes (40% of tasks get notes)
const maybeNote = () => {
if (Math.random() < 0.4) {
const bulletPoints = Array.from(
{ length: faker.number.int({ min: 2, max: 5 }) },
() => `- ${faker.lorem.sentence()}`
).join('\n');
return `${faker.lorem.sentence()}\n\n${bulletPoints}`;
}
return null;
};
// Productivity and work tasks
const workTasks = [
'Review quarterly performance metrics',
@ -341,24 +363,34 @@ function createMassiveTaskData(projects, getRandomDate, getPastDate) {
},
{
name: 'Create wireframes for homepage',
description:
'Design low-fidelity wireframes for the new homepage layout. Focus on user flow, CTA placement, and mobile-first approach.',
project_id: projects[0].id,
priority: 2,
status: 1,
note: 'Need to review with stakeholders\n\n- Include hero section\n- Add testimonials section\n- Feature products prominently\n- Ensure accessibility standards',
},
{
name: 'Design new color palette',
description:
'Research and create a modern color palette that aligns with brand identity. Should work well for both light and dark modes.',
project_id: projects[0].id,
priority: 1,
status: 0,
},
{
name: 'Write content for About page',
description:
'Draft engaging copy for the About page that tells our story and highlights company values.',
project_id: projects[0].id,
priority: 1,
status: 0,
note: 'Content guidelines:\n\n- Keep it under 500 words\n- Include team photos\n- Highlight mission and values\n- Add company timeline',
},
{
name: 'Set up staging environment',
description:
'Configure staging server with proper environment variables, SSL certificates, and deployment pipeline.',
project_id: projects[0].id,
priority: 2,
status: 0,
@ -1282,12 +1314,10 @@ function createMassiveTaskData(projects, getRandomDate, getPastDate) {
const task = {
name: taskName,
description: maybeDescription(),
priority: getRandomPriority(),
status: isCompleted ? 2 : getRandomStatus(),
note:
Math.random() < 0.1
? 'Added some notes during planning phase'
: null,
note: maybeNote(),
};
if (hasProject) {

View file

@ -0,0 +1,184 @@
const { Task, Notification, User } = require('../models');
const { Op } = require('sequelize');
const { logError } = require('./logService');
const {
shouldSendInAppNotification,
} = require('../utils/notificationPreferences');
/**
* Service to check for deferred tasks that are now active
* and create notifications for users
*/
/**
* Check for tasks that have a defer_until date that has passed
* and create notifications for the task owners
*/
async function checkDeferredTasks() {
try {
const now = new Date();
const fiveMinutesFromNow = new Date(now.getTime() + 5 * 60 * 1000);
const deferredTasks = await Task.findAll({
where: {
defer_until: {
[Op.not]: null,
[Op.lte]: fiveMinutesFromNow,
},
status: {
[Op.ne]: 2,
},
},
include: [
{
model: User,
attributes: [
'id',
'email',
'name',
'notification_preferences',
],
},
],
});
if (deferredTasks.length === 0) {
return {
success: true,
tasksProcessed: 0,
notificationsCreated: 0,
};
}
let notificationsCreated = 0;
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
for (const task of deferredTasks) {
try {
// Check if user wants defer until notifications
if (!shouldSendInAppNotification(task.User, 'deferUntil')) {
continue;
}
// Check for existing notifications (including dismissed ones)
// If a notification was dismissed, don't create it again
const recentNotifications = await Notification.findAll({
where: {
user_id: task.user_id,
type: 'task_due_soon',
created_at: {
[Op.gte]: oneDayAgo,
},
},
});
const existingNotification = recentNotifications.find(
(notif) =>
notif.data?.taskUid === task.uid &&
notif.data?.reason === 'defer_until_reached'
);
if (existingNotification) {
// Skip if notification exists, even if it was dismissed
// This prevents re-notifying users about tasks they've already dismissed
continue;
}
await Notification.createNotification({
userId: task.user_id,
type: 'task_due_soon',
title: 'Task is now active',
message: `Your task "${task.name}" is now available to work on`,
sources: [],
data: {
taskUid: task.uid,
taskName: task.name,
deferUntil: task.defer_until,
reason: 'defer_until_reached',
},
sentAt: new Date(),
});
notificationsCreated++;
} catch (error) {
logError(
`Error creating notification for task ${task.id}:`,
error
);
}
}
return {
success: true,
tasksProcessed: deferredTasks.length,
notificationsCreated,
};
} catch (error) {
logError('Error checking deferred tasks:', error);
throw error;
}
}
/**
* Get statistics about deferred tasks
*/
async function getDeferredTaskStats() {
try {
const now = new Date();
const [totalDeferred, activeNow, activeSoon] = await Promise.all([
// Total tasks with defer_until set
Task.count({
where: {
defer_until: {
[Op.not]: null,
},
status: {
[Op.ne]: 2, // Not completed
},
},
}),
// Tasks that should be active now
Task.count({
where: {
defer_until: {
[Op.not]: null,
[Op.lte]: now,
},
status: {
[Op.ne]: 2, // Not completed
},
},
}),
// Tasks that will be active in the next hour
Task.count({
where: {
defer_until: {
[Op.not]: null,
[Op.gt]: now,
[Op.lte]: new Date(now.getTime() + 60 * 60 * 1000),
},
status: {
[Op.ne]: 2, // Not completed
},
},
}),
]);
return {
totalDeferred,
activeNow,
activeSoon,
};
} catch (error) {
logError('Error getting deferred task stats:', error);
throw error;
}
}
module.exports = {
checkDeferredTasks,
getDeferredTaskStats,
};

View file

@ -0,0 +1,178 @@
const { Project, Notification, User } = require('../models');
const { Op } = require('sequelize');
const { logError } = require('./logService');
const {
shouldSendInAppNotification,
} = require('../utils/notificationPreferences');
/**
* Service to check for due and overdue projects
* and create notifications for users
*/
/**
* Check for projects that are due soon or overdue
* and create notifications for the project owners
*/
async function checkDueProjects() {
try {
const now = new Date();
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
const dueProjects = await Project.findAll({
where: {
due_date_at: {
[Op.not]: null,
[Op.lte]: tomorrow,
},
state: {
[Op.notIn]: ['completed'],
},
},
include: [
{
model: User,
attributes: [
'id',
'email',
'name',
'notification_preferences',
],
},
],
});
if (dueProjects.length === 0) {
return {
success: true,
projectsProcessed: 0,
notificationsCreated: 0,
};
}
let notificationsCreated = 0;
for (const project of dueProjects) {
try {
const dueDate = new Date(project.due_date_at);
const isOverdue = dueDate < now;
const notificationType = isOverdue
? 'project_overdue'
: 'project_due_soon';
const level = isOverdue ? 'error' : 'warning';
// Check if user wants this notification
if (
!shouldSendInAppNotification(project.User, notificationType)
) {
continue;
}
// Check for existing notifications (including dismissed ones)
// If a notification was dismissed, don't create it again
const recentNotifications = await Notification.findAll({
where: {
user_id: project.user_id,
type: {
[Op.in]: ['project_due_soon', 'project_overdue'],
},
created_at: {
[Op.gte]: twoDaysAgo,
},
},
});
const existingNotification = recentNotifications.find(
(notif) =>
notif.data?.projectUid === project.uid &&
notif.type === notificationType
);
if (existingNotification) {
// Skip if notification exists, even if it was dismissed
// This prevents re-notifying users about tasks they've already dismissed
continue;
}
const { title, message } = generateNotificationContent(
project.name,
dueDate,
now,
isOverdue
);
await Notification.createNotification({
userId: project.user_id,
type: notificationType,
title,
message,
level,
sources: [],
data: {
projectUid: project.uid,
projectName: project.name,
dueDate: project.due_date_at,
isOverdue,
},
sentAt: new Date(),
});
notificationsCreated++;
} catch (error) {
logError(
`Error creating notification for project ${project.id}:`,
error
);
}
}
return {
success: true,
projectsProcessed: dueProjects.length,
notificationsCreated,
};
} catch (error) {
logError('Error checking due projects:', error);
throw error;
}
}
/**
* Generate notification title and message based on project due date
*/
function generateNotificationContent(projectName, dueDate, now, isOverdue) {
if (isOverdue) {
const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24));
const title = 'Project is overdue';
let message;
if (daysOverdue === 0) {
message = `Your project "${projectName}" was due today`;
} else if (daysOverdue === 1) {
message = `Your project "${projectName}" was due yesterday`;
} else {
message = `Your project "${projectName}" was due ${daysOverdue} days ago`;
}
return { title, message };
} else {
const hoursUntilDue = Math.floor((dueDate - now) / (1000 * 60 * 60));
const title = 'Project due soon';
let message;
if (hoursUntilDue < 1) {
message = `Your project "${projectName}" is due in less than 1 hour`;
} else if (hoursUntilDue < 24) {
message = `Your project "${projectName}" is due in ${hoursUntilDue} hours`;
} else {
message = `Your project "${projectName}" is due tomorrow`;
}
return { title, message };
}
}
module.exports = {
checkDueProjects,
};

View file

@ -0,0 +1,176 @@
const { Task, Notification, User } = require('../models');
const { Op } = require('sequelize');
const { logError } = require('./logService');
const {
shouldSendInAppNotification,
} = require('../utils/notificationPreferences');
/**
* Service to check for due and overdue tasks
* and create notifications for users
*/
/**
* Check for tasks that are due soon or overdue
* and create notifications for the task owners
*/
async function checkDueTasks() {
try {
const now = new Date();
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000);
const dueTasks = await Task.findAll({
where: {
due_date: {
[Op.not]: null,
[Op.lte]: tomorrow,
},
status: {
[Op.ne]: 2,
},
},
include: [
{
model: User,
attributes: [
'id',
'email',
'name',
'notification_preferences',
],
},
],
});
if (dueTasks.length === 0) {
return {
success: true,
tasksProcessed: 0,
notificationsCreated: 0,
};
}
let notificationsCreated = 0;
for (const task of dueTasks) {
try {
const dueDate = new Date(task.due_date);
const isOverdue = dueDate < now;
const notificationType = isOverdue
? 'task_overdue'
: 'task_due_soon';
const level = isOverdue ? 'error' : 'warning';
// Check if user wants this notification
if (!shouldSendInAppNotification(task.User, notificationType)) {
continue;
}
// Check for existing notifications (including dismissed ones)
// If a notification was dismissed, don't create it again
const recentNotifications = await Notification.findAll({
where: {
user_id: task.user_id,
type: {
[Op.in]: ['task_due_soon', 'task_overdue'],
},
created_at: {
[Op.gte]: twoDaysAgo,
},
},
});
const existingNotification = recentNotifications.find(
(notif) =>
notif.data?.taskUid === task.uid &&
notif.type === notificationType
);
if (existingNotification) {
// Skip if notification exists, even if it was dismissed
// This prevents re-notifying users about tasks they've already dismissed
continue;
}
const { title, message } = generateNotificationContent(
task.name,
dueDate,
now,
isOverdue
);
await Notification.createNotification({
userId: task.user_id,
type: notificationType,
title,
message,
level,
sources: [],
data: {
taskUid: task.uid,
taskName: task.name,
dueDate: task.due_date,
isOverdue,
},
sentAt: new Date(),
});
notificationsCreated++;
} catch (error) {
logError(
`Error creating notification for task ${task.id}:`,
error
);
}
}
return {
success: true,
tasksProcessed: dueTasks.length,
notificationsCreated,
};
} catch (error) {
logError('Error checking due tasks:', error);
throw error;
}
}
/**
* Generate notification title and message based on task due date
*/
function generateNotificationContent(taskName, dueDate, now, isOverdue) {
if (isOverdue) {
const daysOverdue = Math.floor((now - dueDate) / (1000 * 60 * 60 * 24));
const title = 'Task is overdue';
let message;
if (daysOverdue === 0) {
message = `Your task "${taskName}" was due today`;
} else if (daysOverdue === 1) {
message = `Your task "${taskName}" was due yesterday`;
} else {
message = `Your task "${taskName}" was due ${daysOverdue} days ago`;
}
return { title, message };
} else {
const hoursUntilDue = Math.floor((dueDate - now) / (1000 * 60 * 60));
const title = 'Task due soon';
let message;
if (hoursUntilDue < 1) {
message = `Your task "${taskName}" is due in less than 1 hour`;
} else if (hoursUntilDue < 24) {
message = `Your task "${taskName}" is due in ${hoursUntilDue} hours`;
} else {
message = `Your task "${taskName}" is due tomorrow`;
}
return { title, message };
}
}
module.exports = {
checkDueTasks,
};

View file

@ -36,6 +36,9 @@ const getCronExpression = (frequency) => {
'12h': '0 */12 * * *',
recurring_tasks: '0 6 * * *', // Daily at 6 AM for recurring task generation
cleanup_tokens: '0 2 * * *', // Daily at 2 AM for cleaning up expired tokens
deferred_tasks: '*/5 * * * *', // Every 5 minutes to check deferred tasks
due_tasks: '*/15 * * * *', // Every 15 minutes to check due/overdue tasks
due_projects: '*/15 * * * *', // Every 15 minutes to check due/overdue projects
};
return expressions[frequency];
};
@ -46,6 +49,12 @@ const createJobHandler = (frequency) => async () => {
await processRecurringTasks();
} else if (frequency === 'cleanup_tokens') {
await cleanupExpiredTokens();
} else if (frequency === 'deferred_tasks') {
await processDeferredTasks();
} else if (frequency === 'due_tasks') {
await processDueTasks();
} else if (frequency === 'due_projects') {
await processDueProjects();
} else {
await processSummariesForFrequency(frequency);
}
@ -64,6 +73,9 @@ const createJobEntries = () => {
'12h',
'recurring_tasks',
'cleanup_tokens',
'deferred_tasks',
'due_tasks',
'due_projects',
];
return frequencies.map((frequency) => {
@ -151,6 +163,39 @@ const cleanupExpiredTokens = async () => {
}
};
// Function to process deferred tasks (contains side effects)
const processDeferredTasks = async () => {
try {
const { checkDeferredTasks } = require('./deferredTaskService');
const result = await checkDeferredTasks();
return result;
} catch (error) {
throw error;
}
};
// Function to process due tasks (contains side effects)
const processDueTasks = async () => {
try {
const { checkDueTasks } = require('./dueTaskService');
const result = await checkDueTasks();
return result;
} catch (error) {
throw error;
}
};
// Function to process due projects (contains side effects)
const processDueProjects = async () => {
try {
const { checkDueProjects } = require('./dueProjectService');
const result = await checkDueProjects();
return result;
} catch (error) {
throw error;
}
};
// Function to initialize scheduler (contains side effects)
const initialize = async () => {
if (schedulerState.isInitialized) {
@ -214,6 +259,9 @@ module.exports = {
processSummariesForFrequency,
processRecurringTasks,
cleanupExpiredTokens,
processDeferredTasks,
processDueTasks,
processDueProjects,
// For testing
_createSchedulerState: createSchedulerState,
_shouldDisableScheduler: shouldDisableScheduler,

View file

@ -0,0 +1,199 @@
const request = require('supertest');
const app = require('../../app');
const { User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Notification Preferences', () => {
let user, agent;
beforeEach(async () => {
user = await createTestUser({
email: `test_${Date.now()}@example.com`,
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: user.email,
password: 'password123',
});
});
describe('GET /api/profile - notification_preferences', () => {
it('should include notification_preferences in profile response', async () => {
const response = await agent.get('/api/profile');
expect(response.status).toBe(200);
expect(response.body).toHaveProperty('notification_preferences');
});
it('should return default notification_preferences for new users', async () => {
const response = await agent.get('/api/profile');
expect(response.status).toBe(200);
expect(response.body.notification_preferences).toEqual({
dueTasks: { inApp: true, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: true, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
});
});
it('should return saved notification_preferences', async () => {
const preferences = {
dueTasks: { inApp: false, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: false, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
};
await User.update(
{ notification_preferences: preferences },
{ where: { id: user.id } }
);
const response = await agent.get('/api/profile');
expect(response.status).toBe(200);
expect(response.body.notification_preferences).toEqual(preferences);
});
});
describe('PATCH /api/profile - notification_preferences', () => {
it('should update notification preferences', async () => {
const preferences = {
dueTasks: { inApp: false, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: false, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
};
const response = await agent
.patch('/api/profile')
.send({ notification_preferences: preferences });
expect(response.status).toBe(200);
expect(response.body.notification_preferences).toEqual(preferences);
// Verify it was saved to database
const updatedUser = await User.findByPk(user.id);
expect(updatedUser.notification_preferences).toEqual(preferences);
});
it('should allow partial notification preference updates', async () => {
// Set initial preferences
const initialPreferences = {
dueTasks: { inApp: true, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: true, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
};
await agent
.patch('/api/profile')
.send({ notification_preferences: initialPreferences });
// Update only some types
const updatedPreferences = {
dueTasks: { inApp: false, email: false, push: false },
overdueTasks: { inApp: false, email: false, push: false },
dueProjects: { inApp: true, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
};
const response = await agent
.patch('/api/profile')
.send({ notification_preferences: updatedPreferences });
expect(response.status).toBe(200);
expect(response.body.notification_preferences).toEqual(
updatedPreferences
);
});
it('should allow setting preferences to null', async () => {
// First set some preferences
await agent.patch('/api/profile').send({
notification_preferences: {
dueTasks: { inApp: false, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: false, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
},
});
// Then set to null
const response = await agent
.patch('/api/profile')
.send({ notification_preferences: null });
expect(response.status).toBe(200);
expect(response.body.notification_preferences).toBeNull();
});
it('should not affect other profile fields', async () => {
const preferences = {
dueTasks: { inApp: false, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: false, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
};
const response = await agent
.patch('/api/profile')
.send({ notification_preferences: preferences });
expect(response.status).toBe(200);
expect(response.body.email).toBe(user.email);
expect(response.body.appearance).toBe(user.appearance);
expect(response.body.language).toBe(user.language);
});
it('should require authentication', async () => {
const preferences = {
dueTasks: { inApp: false, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: false, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
};
const response = await request(app)
.patch('/api/profile')
.send({ notification_preferences: preferences });
expect(response.status).toBe(401);
expect(response.body.error).toBe('Authentication required');
});
it('should work with other profile updates in same request', async () => {
const updateData = {
appearance: 'dark',
language: 'es',
notification_preferences: {
dueTasks: { inApp: false, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: false, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
},
};
const response = await agent.patch('/api/profile').send(updateData);
expect(response.status).toBe(200);
expect(response.body.appearance).toBe('dark');
expect(response.body.language).toBe('es');
expect(response.body.notification_preferences).toEqual(
updateData.notification_preferences
);
});
});
});

View file

@ -0,0 +1,230 @@
const request = require('supertest');
const app = require('../../app');
const { User, Notification, Task } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Notification Soft Delete', () => {
let user, agent, task;
beforeEach(async () => {
user = await createTestUser({
email: `test_${Date.now()}@example.com`,
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: user.email,
password: 'password123',
});
// Create a test task directly in database
task = await Task.create({
name: 'Test Task',
user_id: user.id,
status: 0,
});
});
describe('DELETE /api/notifications/:id - Soft Delete', () => {
it('should soft delete (dismiss) a notification', async () => {
// Create a notification
const notification = await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Test Notification',
message: 'This is a test',
sources: [],
data: { taskUid: task.uid },
});
// Delete (dismiss) the notification
const deleteResponse = await agent.delete(
`/api/notifications/${notification.id}`
);
expect(deleteResponse.status).toBe(200);
expect(deleteResponse.body.message).toBe(
'Notification dismissed successfully'
);
// Verify the notification still exists in database but is dismissed
const dismissedNotification = await Notification.findByPk(
notification.id
);
expect(dismissedNotification).not.toBeNull();
expect(dismissedNotification.dismissed_at).not.toBeNull();
expect(dismissedNotification.isDismissed()).toBe(true);
});
it('should not allow dismissing an already dismissed notification', async () => {
// Create and dismiss a notification
const notification = await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Test Notification',
message: 'This is a test',
sources: [],
data: { taskUid: task.uid },
});
await notification.dismiss();
// Try to dismiss again
const deleteResponse = await agent.delete(
`/api/notifications/${notification.id}`
);
expect(deleteResponse.status).toBe(404);
expect(deleteResponse.body.error).toBe('Notification not found');
});
it('should hide dismissed notifications from GET /api/notifications', async () => {
// Create two notifications
const notification1 = await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Notification 1',
message: 'This is test 1',
sources: [],
});
const notification2 = await Notification.createNotification({
userId: user.id,
type: 'task_overdue',
title: 'Notification 2',
message: 'This is test 2',
sources: [],
});
// Dismiss the first notification
await agent.delete(`/api/notifications/${notification1.id}`);
// Get notifications
const getResponse = await agent.get('/api/notifications');
expect(getResponse.status).toBe(200);
expect(getResponse.body.total).toBe(1);
expect(getResponse.body.notifications.length).toBe(1);
expect(getResponse.body.notifications[0].id).toBe(notification2.id);
});
it('should exclude dismissed notifications from unread count', async () => {
// Create two unread notifications
const notification1 = await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Notification 1',
message: 'This is test 1',
sources: [],
});
await Notification.createNotification({
userId: user.id,
type: 'task_overdue',
title: 'Notification 2',
message: 'This is test 2',
sources: [],
});
// Check unread count (should be 2)
let countResponse = await agent.get(
'/api/notifications/unread-count'
);
expect(countResponse.body.count).toBe(2);
// Dismiss one notification
await agent.delete(`/api/notifications/${notification1.id}`);
// Check unread count again (should be 1)
countResponse = await agent.get('/api/notifications/unread-count');
expect(countResponse.body.count).toBe(1);
});
it('should not recreate dismissed notifications in cron jobs', async () => {
// Update task with due date in the past
const dueDate = new Date(Date.now() - 1000 * 60 * 60); // 1 hour ago
await task.update({
due_date: dueDate,
});
// Run the due task service
const { checkDueTasks } = require('../../services/dueTaskService');
let result = await checkDueTasks();
// Should create 1 notification
expect(result.notificationsCreated).toBe(1);
// Get the notification
const notifications = await Notification.findAll({
where: { user_id: user.id },
});
expect(notifications.length).toBe(1);
const notification = notifications[0];
// Dismiss the notification
await notification.dismiss();
// Run the service again
result = await checkDueTasks();
// Should not create a new notification (dismissed one should be skipped)
expect(result.notificationsCreated).toBe(0);
// Verify only one notification exists (the dismissed one)
const allNotifications = await Notification.findAll({
where: { user_id: user.id },
});
expect(allNotifications.length).toBe(1);
expect(allNotifications[0].isDismissed()).toBe(true);
});
});
describe('Notification model - isDismissed and dismiss methods', () => {
it('should correctly identify dismissed notifications', async () => {
const notification = await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Test',
message: 'Test',
sources: [],
});
// Reload from database to get actual values
await notification.reload();
expect(notification.dismissed_at).toBeNull();
expect(notification.isDismissed()).toBe(false);
await notification.dismiss();
expect(notification.isDismissed()).toBe(true);
expect(notification.dismissed_at).not.toBeNull();
});
it('should not change dismissed_at if already dismissed', async () => {
const notification = await Notification.createNotification({
userId: user.id,
type: 'task_due_soon',
title: 'Test',
message: 'Test',
sources: [],
});
await notification.dismiss();
const firstDismissedAt = notification.dismissed_at;
// Wait a bit
await new Promise((resolve) => setTimeout(resolve, 10));
// Try to dismiss again
await notification.dismiss();
// dismissed_at should be the same
expect(notification.dismissed_at.getTime()).toBe(
firstDismissedAt.getTime()
);
});
});
});

View file

@ -0,0 +1,183 @@
const {
shouldSendInAppNotification,
getDefaultNotificationPreferences,
NOTIFICATION_TYPE_MAPPING,
} = require('../../../utils/notificationPreferences');
describe('notificationPreferences utils', () => {
describe('getDefaultNotificationPreferences', () => {
it('should return default preferences with all in-app enabled', () => {
const defaults = getDefaultNotificationPreferences();
expect(defaults).toEqual({
dueTasks: { inApp: true, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: true, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
});
});
it('should return a new object each time', () => {
const defaults1 = getDefaultNotificationPreferences();
const defaults2 = getDefaultNotificationPreferences();
expect(defaults1).not.toBe(defaults2);
expect(defaults1).toEqual(defaults2);
});
});
describe('shouldSendInAppNotification', () => {
it('should return true when user has no preferences set', () => {
const user = { notification_preferences: null };
expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe(
true
);
expect(shouldSendInAppNotification(user, 'task_overdue')).toBe(
true
);
expect(shouldSendInAppNotification(user, 'project_due_soon')).toBe(
true
);
expect(shouldSendInAppNotification(user, 'project_overdue')).toBe(
true
);
});
it('should return true when user object is null', () => {
expect(shouldSendInAppNotification(null, 'task_due_soon')).toBe(
true
);
});
it('should return true when notification type is enabled', () => {
const user = {
notification_preferences: {
dueTasks: { inApp: true, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
},
};
expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe(
true
);
expect(shouldSendInAppNotification(user, 'task_overdue')).toBe(
true
);
});
it('should return false when notification type is disabled', () => {
const user = {
notification_preferences: {
dueTasks: { inApp: false, email: false, push: false },
overdueTasks: { inApp: false, email: false, push: false },
},
};
expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe(
false
);
expect(shouldSendInAppNotification(user, 'task_overdue')).toBe(
false
);
});
it('should map backend notification types correctly', () => {
const user = {
notification_preferences: {
dueTasks: { inApp: false, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: false, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
},
};
// task_due_soon maps to dueTasks (disabled)
expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe(
false
);
// task_overdue maps to overdueTasks (enabled)
expect(shouldSendInAppNotification(user, 'task_overdue')).toBe(
true
);
// project_due_soon maps to dueProjects (disabled)
expect(shouldSendInAppNotification(user, 'project_due_soon')).toBe(
false
);
// project_overdue maps to overdueProjects (enabled)
expect(shouldSendInAppNotification(user, 'project_overdue')).toBe(
true
);
});
it('should handle deferUntil type directly', () => {
const user = {
notification_preferences: {
deferUntil: { inApp: false, email: false, push: false },
},
};
expect(shouldSendInAppNotification(user, 'deferUntil')).toBe(false);
});
it('should default to true for unknown notification types', () => {
const user = {
notification_preferences: {
dueTasks: { inApp: true, email: false, push: false },
},
};
// Unknown type should default to enabled
expect(shouldSendInAppNotification(user, 'unknown_type')).toBe(
true
);
});
it('should handle partial preferences object', () => {
const user = {
notification_preferences: {
dueTasks: { inApp: false, email: false, push: false },
// overdueTasks not defined
},
};
// Defined type should respect setting
expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe(
false
);
// Undefined type should default to enabled
expect(shouldSendInAppNotification(user, 'task_overdue')).toBe(
true
);
});
it('should handle missing inApp property', () => {
const user = {
notification_preferences: {
dueTasks: { email: false, push: false },
},
};
// Missing inApp should default to true
expect(shouldSendInAppNotification(user, 'task_due_soon')).toBe(
true
);
});
});
describe('NOTIFICATION_TYPE_MAPPING', () => {
it('should have correct mappings', () => {
expect(NOTIFICATION_TYPE_MAPPING).toEqual({
task_due_soon: 'dueTasks',
task_overdue: 'overdueTasks',
project_due_soon: 'dueProjects',
project_overdue: 'overdueProjects',
});
});
});
});

View file

@ -2,6 +2,17 @@
async function safeAddColumns(queryInterface, tableName, columns) {
try {
// First check if table exists
const tables = await queryInterface.showAllTables();
const tableExists = tables.includes(tableName);
if (!tableExists) {
console.log(
`Table ${tableName} does not exist, skipping column additions`
);
return;
}
const tableInfo = await queryInterface.describeTable(tableName);
for (const column of columns) {
@ -38,6 +49,17 @@ async function safeCreateTable(queryInterface, tableName, tableDefinition) {
async function safeAddIndex(queryInterface, tableName, fields, options = {}) {
try {
// First check if table exists
const tables = await queryInterface.showAllTables();
const tableExists = tables.includes(tableName);
if (!tableExists) {
console.log(
`Table ${tableName} does not exist, skipping index addition`
);
return;
}
const indexes = await queryInterface.showIndex(tableName);
const indexExists = indexes.some((index) =>
index.fields.some((field) => fields.includes(field.attribute))

View file

@ -0,0 +1,62 @@
/**
* Utility functions for checking user notification preferences
*/
const DEFAULT_PREFERENCES = {
dueTasks: { inApp: true, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: true, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
};
/**
* Mapping from backend notification types to preference keys
*/
const NOTIFICATION_TYPE_MAPPING = {
task_due_soon: 'dueTasks',
task_overdue: 'overdueTasks',
project_due_soon: 'dueProjects',
project_overdue: 'overdueProjects',
};
/**
* Check if user has enabled in-app notifications for a specific type
* @param {Object} user - User model instance with notification_preferences field
* @param {string} notificationType - Backend notification type (e.g., 'task_due_soon', 'task_overdue')
* @returns {boolean} - True if in-app notifications are enabled for this type
*/
function shouldSendInAppNotification(user, notificationType) {
// If no user or no preferences set, default to enabled
if (!user || !user.notification_preferences) {
return true;
}
const prefs = user.notification_preferences;
// Map notification type to preference key
const prefKey =
NOTIFICATION_TYPE_MAPPING[notificationType] || notificationType;
// If notification type not configured, default to enabled
if (!prefs[prefKey]) {
return true;
}
// Check if in-app channel is enabled (default to true if not set)
return prefs[prefKey].inApp !== false;
}
/**
* Get default notification preferences
* @returns {Object} - Default preferences object
*/
function getDefaultNotificationPreferences() {
return { ...DEFAULT_PREFERENCES };
}
module.exports = {
shouldSendInAppNotification,
getDefaultNotificationPreferences,
NOTIFICATION_TYPE_MAPPING,
};

View file

@ -458,15 +458,17 @@ const Layout: React.FC<LayoutProps> = ({
/>
<div
className={`transition-all duration-300 ease-in-out ${mainContentMarginLeft}`}
className={`transition-all duration-300 ease-in-out ${mainContentMarginLeft} h-screen flex flex-col`}
>
<div className="flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 min-h-screen overflow-y-auto">
<div className="flex flex-col bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 flex-1 overflow-hidden">
<div
className={`flex-grow py-0 px-0 transition-all duration-300 ${
className={`flex-1 flex flex-col py-0 px-0 transition-all duration-300 ${
isMobileSearchOpen ? 'pt-32' : 'pt-20'
} md:pt-20 ${isUpcomingView ? '' : 'md:px-4'}`}
} md:pt-20 ${isUpcomingView ? '' : 'md:px-4'} overflow-hidden`}
>
<div className="w-full">{children}</div>
<div className="w-full h-full overflow-auto">
{children}
</div>
</div>
</div>
</div>

View file

@ -41,24 +41,14 @@ interface CalendarEvent {
title: string;
start: Date;
end: Date;
type: 'task' | 'event' | 'google';
type: 'task' | 'event';
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);
@ -71,44 +61,12 @@ const Calendar: React.FC = () => {
const locale = getLocale(i18n.language);
// Load Google Calendar status and tasks on component mount
// Load tasks and projects 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(getApiPath('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 {
@ -155,6 +113,20 @@ const Calendar: React.FC = () => {
}
tasks.forEach((task) => {
// Add deferred tasks with defer_until dates
if (task.defer_until) {
const deferDate = new Date(task.defer_until);
const taskEvent = {
id: `task-defer-${task.id}`,
title: `${task.name || task.title || `Task ${task.id}`}`,
start: deferDate,
end: new Date(deferDate.getTime() + 60 * 60 * 1000), // 1 hour duration
type: 'task' as const,
color: task.completed_at ? '#22c55e' : '#f59e0b', // Green if completed, amber if deferred
};
taskEvents.push(taskEvent);
}
// Add tasks with due dates
if (task.due_date) {
const dueDate = new Date(task.due_date);
@ -169,8 +141,8 @@ const Calendar: React.FC = () => {
taskEvents.push(taskEvent);
}
// Add tasks scheduled for today (if they don't have due_date)
if (!task.due_date && task.created_at) {
// Add tasks scheduled for today (if they don't have defer_until or due_date)
if (!task.defer_until && !task.due_date && task.created_at) {
const createdDate = new Date(task.created_at);
const today = new Date();
@ -188,8 +160,8 @@ const Calendar: React.FC = () => {
}
}
// Always add tasks to calendar for easier debugging
if (!task.due_date && !task.created_at) {
// Always add tasks to calendar for easier debugging (only if no defer_until, due_date, or created_at)
if (!task.defer_until && !task.due_date && !task.created_at) {
const taskEvent = {
id: `task-fallback-${task.id}`,
title: `📌 ${task.name || task.title || `Task ${task.id}`}`,
@ -219,63 +191,6 @@ const Calendar: React.FC = () => {
}
};
const connectGoogleCalendar = async () => {
if (isConnecting) return;
setIsConnecting(true);
try {
const response = await fetch(getApiPath('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(getApiPath('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') {
@ -310,8 +225,11 @@ const Calendar: React.FC = () => {
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)?-/, '');
// Extract task ID from event ID (handles task-, task-defer-, task-created-, task-fallback-)
const taskId = event.id.replace(
/^task(-defer|-created|-fallback)?-/,
''
);
const task = allTasks.find((t) => t.id.toString() === taskId);
if (task) {
@ -395,10 +313,10 @@ const Calendar: React.FC = () => {
};
return (
<div className="flex justify-center px-4 lg:px-2">
<div className="w-full max-w-6xl">
<div className="h-full flex flex-col px-4 py-4">
<div className="w-full flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center justify-between mb-4">
<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" />
@ -463,80 +381,34 @@ const Calendar: React.FC = () => {
)}
{/* Calendar view */}
{view === 'month' && (
<CalendarMonthView
currentDate={currentDate}
events={events}
onDateClick={handleDateClick}
onEventClick={handleEventClick}
/>
)}
<div className="flex-1 overflow-hidden min-h-0">
{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 === '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>
{view === 'day' && (
<CalendarDayView
currentDate={currentDate}
events={events}
onEventClick={handleEventClick}
onTimeSlotClick={handleTimeSlotClick}
/>
)}
</div>
{/* Event Details Modal */}

View file

@ -6,7 +6,7 @@ interface CalendarEvent {
title: string;
start: Date;
end: Date;
type: 'task' | 'event' | 'google';
type: 'task' | 'event';
color?: string;
}
@ -60,7 +60,7 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
};
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
<div className="h-full bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden flex flex-col">
{/* 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">
@ -131,7 +131,7 @@ const CalendarDayView: React.FC<CalendarDayViewProps> = ({
</div>
{/* Time slots */}
<div className="max-h-96 overflow-y-auto">
<div className="flex-1 overflow-y-auto">
{hours.map((hour) => {
const timeSlotEvents = getEventsForTimeSlot(hour);

View file

@ -20,7 +20,7 @@ interface CalendarEvent {
title: string;
start: Date;
end: Date;
type: 'task' | 'event' | 'google';
type: 'task' | 'event';
color?: string;
}
@ -105,7 +105,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
};
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
<div className="h-full bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden flex flex-col">
{/* 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) => (
@ -119,7 +119,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
</div>
{/* Calendar grid */}
<div className="grid grid-cols-7">
<div className="grid grid-cols-7 flex-1 min-h-0 auto-rows-fr">
{days.map((day) => {
const dayEvents = events.filter(
(event) =>
@ -134,7 +134,7 @@ const CalendarMonthView: React.FC<CalendarMonthViewProps> = ({
<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 ${
className={`p-2 border-r border-b border-gray-100 dark:border-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 flex flex-col ${
!isCurrentMonth
? 'bg-gray-50 dark:bg-gray-800'
: 'bg-white dark:bg-gray-900'

View file

@ -17,7 +17,7 @@ interface CalendarEvent {
title: string;
start: Date;
end: Date;
type: 'task' | 'event' | 'google';
type: 'task' | 'event';
color?: string;
}
@ -87,7 +87,7 @@ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
};
return (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden">
<div className="h-full bg-white dark:bg-gray-900 rounded-lg shadow overflow-hidden flex flex-col">
{/* 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">
@ -129,7 +129,7 @@ const CalendarWeekView: React.FC<CalendarWeekViewProps> = ({
</div>
{/* Time slots */}
<div className="max-h-96 overflow-y-auto">
<div className="flex-1 overflow-y-auto">
{hours.map((hour) => (
<div
key={hour}

View file

@ -10,6 +10,7 @@ import { EnvelopeIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
import { useTranslation } from 'react-i18next';
import PomodoroTimer from './Shared/PomodoroTimer';
import UniversalSearch from './UniversalSearch/UniversalSearch';
import NotificationsDropdown from './Notifications/NotificationsDropdown';
import { getApiPath } from '../config/paths';
interface NavbarProps {
@ -210,6 +211,8 @@ const Navbar: React.FC<NavbarProps> = ({
</button>
{pomodoroEnabled && <PomodoroTimer />}
<NotificationsDropdown isDarkMode={isDarkMode} />
<div className="relative" ref={dropdownRef}>
<button
onClick={toggleDropdown}

View file

@ -0,0 +1,384 @@
import React, { useState, useRef, useEffect } from 'react';
import {
BellIcon,
CheckIcon,
XMarkIcon,
InformationCircleIcon,
ExclamationTriangleIcon,
ExclamationCircleIcon,
CheckCircleIcon,
} from '@heroicons/react/24/outline';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { getApiPath } from '../../config/paths';
interface Notification {
id: number;
title: string;
message: string;
level: 'info' | 'warning' | 'error' | 'success';
source: string;
is_read: boolean;
created_at: string;
data?: {
taskUid?: string;
projectUid?: string;
[key: string]: any;
};
}
interface NotificationsDropdownProps {
isDarkMode: boolean;
}
const NotificationsDropdown: React.FC<NotificationsDropdownProps> = ({
isDarkMode,
}) => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const [unreadCount, setUnreadCount] = useState(0);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [loading, setLoading] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const fetchUnreadCount = async () => {
try {
const response = await fetch(
getApiPath('notifications/unread-count'),
{
credentials: 'include',
}
);
if (response.ok) {
const data = await response.json();
setUnreadCount(data.count || 0);
}
} catch (error) {
console.error('Error fetching unread count:', error);
}
};
const fetchNotifications = async () => {
setLoading(true);
try {
const response = await fetch(
getApiPath('notifications?limit=20&includeRead=true'),
{
credentials: 'include',
}
);
if (response.ok) {
const data = await response.json();
setNotifications(data.notifications || []);
}
} catch (error) {
console.error('Error fetching notifications:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUnreadCount();
const interval = setInterval(fetchUnreadCount, 30000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
if (isOpen) {
fetchNotifications();
}
}, [isOpen]);
const handleToggle = () => {
setIsOpen(!isOpen);
};
const handleMarkAsRead = async (id: number) => {
try {
const response = await fetch(
getApiPath(`notifications/${id}/read`),
{
method: 'POST',
credentials: 'include',
}
);
if (response.ok) {
setNotifications((prev) =>
prev.map((n) => (n.id === id ? { ...n, is_read: true } : n))
);
fetchUnreadCount();
}
} catch (error) {
console.error('Error marking notification as read:', error);
}
};
const handleMarkAllAsRead = async () => {
try {
const response = await fetch(
getApiPath('notifications/mark-all-read'),
{
method: 'POST',
credentials: 'include',
}
);
if (response.ok) {
setNotifications((prev) =>
prev.map((n) => ({ ...n, is_read: true }))
);
setUnreadCount(0);
}
} catch (error) {
console.error('Error marking all notifications as read:', error);
}
};
const handleDelete = async (id: number) => {
try {
const response = await fetch(getApiPath(`notifications/${id}`), {
method: 'DELETE',
credentials: 'include',
});
if (response.ok) {
setNotifications((prev) => prev.filter((n) => n.id !== id));
fetchUnreadCount();
}
} catch (error) {
console.error('Error deleting notification:', error);
}
};
const getLevelIcon = (level: string) => {
switch (level) {
case 'success':
return (
<CheckCircleIcon className="h-5 w-5 text-green-500 flex-shrink-0" />
);
case 'warning':
return (
<ExclamationTriangleIcon className="h-5 w-5 text-yellow-500 flex-shrink-0" />
);
case 'error':
return (
<ExclamationCircleIcon className="h-5 w-5 text-red-500 flex-shrink-0" />
);
default:
return (
<InformationCircleIcon className="h-5 w-5 text-blue-500 flex-shrink-0" />
);
}
};
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return t('notifications.justNow', 'Just now');
if (minutes < 60)
return t('notifications.minutesAgo', '{{count}} min ago', {
count: minutes,
});
if (hours < 24)
return t('notifications.hoursAgo', '{{count}}h ago', {
count: hours,
});
if (days < 7)
return t('notifications.daysAgo', '{{count}}d ago', {
count: days,
});
return date.toLocaleDateString();
};
const handleNotificationClick = (notification: Notification) => {
if (notification.data?.taskUid) {
setIsOpen(false);
navigate(`/task/${notification.data.taskUid}`);
} else if (notification.data?.projectUid) {
setIsOpen(false);
navigate(`/project/${notification.data.projectUid}`);
}
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={handleToggle}
className="relative flex items-center focus:outline-none"
aria-label="Notifications"
>
<BellIcon className="h-6 w-6 text-gray-700 dark:text-gray-300" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 bg-red-500 text-white text-[10px] rounded-full h-4 min-w-4 px-1 flex items-center justify-center font-medium">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</button>
{isOpen && (
<div
className={`absolute right-0 mt-2 w-96 rounded-lg shadow-lg z-50 ${
isDarkMode ? 'bg-gray-800' : 'bg-white'
} border ${
isDarkMode ? 'border-gray-700' : 'border-gray-200'
}`}
>
<div
className={`p-4 border-b flex items-center justify-between ${
isDarkMode ? 'border-gray-700' : 'border-gray-200'
}`}
>
<h3 className="text-lg font-semibold">
{t('notifications.title', 'Notifications')}
</h3>
{unreadCount > 0 && (
<button
onClick={handleMarkAllAsRead}
className="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400 dark:hover:text-blue-300"
>
{t(
'notifications.markAllRead',
'Mark all as read'
)}
</button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{loading ? (
<div className="p-4 text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('notifications.loading', 'Loading...')}
</p>
</div>
) : notifications.length === 0 ? (
<div className="p-4 text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t(
'notifications.noNotifications',
'No notifications yet'
)}
</p>
</div>
) : (
notifications.map((notification) => (
<div
key={notification.id}
className={`p-4 border-b ${
isDarkMode
? 'border-gray-700'
: 'border-gray-200'
} ${
!notification.is_read
? isDarkMode
? 'bg-gray-700/50'
: 'bg-blue-50'
: ''
} hover:${
isDarkMode
? 'bg-gray-700'
: 'bg-gray-50'
} transition-colors`}
>
<div className="flex items-start space-x-3">
{getLevelIcon(notification.level)}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between">
<div
className={`flex-1 ${
notification.data
?.taskUid ||
notification.data
?.projectUid
? 'cursor-pointer'
: ''
}`}
onClick={() =>
handleNotificationClick(
notification
)
}
>
<p className="text-sm font-medium">
{notification.title}
</p>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-1">
{notification.message}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{formatTimestamp(
notification.created_at
)}
</p>
</div>
<div className="flex items-center space-x-1 ml-2">
{!notification.is_read && (
<button
onClick={(e) => {
e.stopPropagation();
handleMarkAsRead(
notification.id
);
}}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
title={t(
'notifications.markAsRead',
'Mark as read'
)}
>
<CheckIcon className="h-4 w-4 text-gray-500 dark:text-gray-400" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(
notification.id
);
}}
className="p-1 hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
title={t(
'notifications.delete',
'Delete'
)}
>
<XMarkIcon className="h-4 w-4 text-gray-500 dark:text-gray-400" />
</button>
</div>
</div>
</div>
</div>
</div>
))
)}
</div>
</div>
)}
</div>
);
};
export default NotificationsDropdown;

View file

@ -14,6 +14,7 @@ import {
LightBulbIcon,
KeyIcon,
CheckIcon,
BellIcon,
} from '@heroicons/react/24/outline';
import TelegramIcon from '../Icons/TelegramIcon';
import { useToast } from '../Shared/ToastContext';
@ -38,6 +39,7 @@ import ApiKeysTab from './tabs/ApiKeysTab';
import ProductivityTab from './tabs/ProductivityTab';
import TelegramTab from './tabs/TelegramTab';
import AiTab from './tabs/AiTab';
import NotificationsTab from './tabs/NotificationsTab';
import type {
ProfileSettingsProps,
Profile,
@ -93,6 +95,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
productivity_assistant_enabled: true,
next_task_suggestion_enabled: true,
pomodoro_enabled: true,
notification_preferences: null,
currentPassword: '',
newPassword: '',
confirmPassword: '',
@ -443,6 +446,8 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
data.pomodoro_enabled !== undefined
? data.pomodoro_enabled
: true,
notification_preferences:
data.notification_preferences || null,
});
if (data.telegram_bot_token) {
@ -1013,6 +1018,11 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
name: t('profile.tabs.productivity', 'Productivity'),
icon: <ClockIcon className="w-5 h-5" />,
},
{
id: 'notifications',
name: t('profile.tabs.notifications', 'Notifications'),
icon: <BellIcon className="w-5 h-5" />,
},
{
id: 'telegram',
name: t('profile.tabs.telegram', 'Telegram'),
@ -1124,6 +1134,19 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({
}
/>
<NotificationsTab
isActive={activeTab === 'notifications'}
notificationPreferences={
formData.notification_preferences
}
onChange={(preferences) =>
setFormData((prev) => ({
...prev,
notification_preferences: preferences,
}))
}
/>
<TelegramTab
isActive={activeTab === 'telegram'}
formData={formData}

View file

@ -0,0 +1,270 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
BellIcon,
BellAlertIcon,
ExclamationTriangleIcon,
FolderIcon,
FolderOpenIcon,
ClockIcon,
} from '@heroicons/react/24/outline';
import type { NotificationPreferences } from '../types';
interface NotificationsTabProps {
isActive: boolean;
notificationPreferences: NotificationPreferences | null | undefined;
onChange: (preferences: NotificationPreferences) => void;
}
const DEFAULT_PREFERENCES: NotificationPreferences = {
dueTasks: { inApp: true, email: false, push: false },
overdueTasks: { inApp: true, email: false, push: false },
dueProjects: { inApp: true, email: false, push: false },
overdueProjects: { inApp: true, email: false, push: false },
deferUntil: { inApp: true, email: false, push: false },
};
interface NotificationTypeRowProps {
icon: React.ComponentType<{ className?: string }>;
label: string;
description: string;
preferences: { inApp: boolean; email: boolean; push: boolean };
onToggle: (channel: 'inApp' | 'email' | 'push', value: boolean) => void;
}
const NotificationTypeRow: React.FC<NotificationTypeRowProps> = ({
icon: Icon,
label,
description,
preferences,
onToggle,
}) => {
const renderToggle = (
channel: 'inApp' | 'email' | 'push',
isEnabled: boolean,
isAvailable: boolean
) => (
<button
type="button"
onClick={() => isAvailable && onToggle(channel, !isEnabled)}
disabled={!isAvailable}
className={`
relative inline-flex h-5 w-9 items-center rounded-full
transition-colors duration-200 ease-in-out
${isAvailable ? 'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2' : 'cursor-not-allowed opacity-50'}
${isEnabled && isAvailable ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'}
`}
aria-label={`Toggle ${channel} for ${label}`}
>
<span
className={`
inline-block h-3 w-3 transform rounded-full
bg-white transition-transform duration-200 ease-in-out
${isEnabled && isAvailable ? 'translate-x-5' : 'translate-x-1'}
`}
/>
</button>
);
return (
<tr className="border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/30">
<td className="py-4 px-4">
<div className="flex items-center space-x-3">
<Icon className="w-5 h-5 text-gray-600 dark:text-gray-400 flex-shrink-0" />
<div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{label}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{description}
</div>
</div>
</div>
</td>
<td className="py-4 px-4 text-center">
{renderToggle('inApp', preferences.inApp, true)}
</td>
<td className="py-4 px-4 text-center">
{renderToggle('email', preferences.email, false)}
</td>
<td className="py-4 px-4 text-center">
{renderToggle('push', preferences.push, false)}
</td>
</tr>
);
};
const NotificationsTab: React.FC<NotificationsTabProps> = ({
isActive,
notificationPreferences,
onChange,
}) => {
const { t } = useTranslation();
if (!isActive) return null;
// Merge with defaults to ensure all types exist
const preferences: NotificationPreferences = {
...DEFAULT_PREFERENCES,
...notificationPreferences,
};
const handleToggle = (
notificationType: keyof NotificationPreferences,
channel: 'inApp' | 'email' | 'push',
value: boolean
) => {
const updatedPreferences = {
...preferences,
[notificationType]: {
...preferences[notificationType],
[channel]: value,
},
};
onChange(updatedPreferences);
};
return (
<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-2 flex items-center">
<BellIcon className="w-6 h-6 mr-3 text-purple-500" />
{t('profile.tabs.notifications', 'Notification Preferences')}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{t(
'profile.notificationsDescription',
'Choose how you want to be notified about important events.'
)}
</p>
{/* Notifications Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b-2 border-gray-300 dark:border-gray-600">
<th className="py-3 px-4 text-left text-sm font-semibold text-gray-700 dark:text-gray-300">
{t(
'notifications.table.type',
'Notification Type'
)}
</th>
<th className="py-3 px-4 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
{t('notifications.channels.inApp', 'In-app')}
</th>
<th className="py-3 px-4 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
<div className="flex items-center justify-center gap-1">
{t('notifications.channels.email', 'Email')}
<span className="text-[10px] text-gray-500 dark:text-gray-500 font-normal">
({t('common.comingSoon', 'Coming Soon')}
)
</span>
</div>
</th>
<th className="py-3 px-4 text-center text-sm font-semibold text-gray-700 dark:text-gray-300">
<div className="flex items-center justify-center gap-1">
{t('notifications.channels.push', 'Push')}
<span className="text-[10px] text-gray-500 dark:text-gray-500 font-normal">
({t('common.comingSoon', 'Coming Soon')}
)
</span>
</div>
</th>
</tr>
</thead>
<tbody>
<NotificationTypeRow
icon={BellAlertIcon}
label={t(
'notifications.types.dueTasks',
'Due Tasks'
)}
description={t(
'notifications.descriptions.dueTasks',
'Tasks that are due within 24 hours'
)}
preferences={preferences.dueTasks}
onToggle={(channel, value) =>
handleToggle('dueTasks', channel, value)
}
/>
<NotificationTypeRow
icon={ExclamationTriangleIcon}
label={t(
'notifications.types.overdueTasks',
'Overdue Tasks'
)}
description={t(
'notifications.descriptions.overdueTasks',
'Tasks that have passed their due date'
)}
preferences={preferences.overdueTasks}
onToggle={(channel, value) =>
handleToggle('overdueTasks', channel, value)
}
/>
<NotificationTypeRow
icon={ClockIcon}
label={t(
'notifications.types.deferUntil',
'Defer Until'
)}
description={t(
'notifications.descriptions.deferUntil',
'Tasks that are now available to work on'
)}
preferences={preferences.deferUntil}
onToggle={(channel, value) =>
handleToggle('deferUntil', channel, value)
}
/>
<NotificationTypeRow
icon={FolderIcon}
label={t(
'notifications.types.dueProjects',
'Due Projects'
)}
description={t(
'notifications.descriptions.dueProjects',
'Projects that are due within 24 hours'
)}
preferences={preferences.dueProjects}
onToggle={(channel, value) =>
handleToggle('dueProjects', channel, value)
}
/>
<NotificationTypeRow
icon={FolderOpenIcon}
label={t(
'notifications.types.overdueProjects',
'Overdue Projects'
)}
description={t(
'notifications.descriptions.overdueProjects',
'Projects that have passed their due date'
)}
preferences={preferences.overdueProjects}
onToggle={(channel, value) =>
handleToggle('overdueProjects', channel, value)
}
/>
</tbody>
</table>
</div>
{/* Help Text */}
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-200">
<span className="font-medium">
{t('notifications.info.title', 'Note:')}
</span>{' '}
{t(
'notifications.info.message',
'Email and Push notifications are coming soon. In-app notifications are currently available.'
)}
</p>
</div>
</div>
);
};
export default NotificationsTab;

View file

@ -4,6 +4,14 @@ export interface ProfileSettingsProps {
toggleDarkMode?: () => void;
}
export interface NotificationPreferences {
dueTasks: { inApp: boolean; email: boolean; push: boolean };
overdueTasks: { inApp: boolean; email: boolean; push: boolean };
dueProjects: { inApp: boolean; email: boolean; push: boolean };
overdueProjects: { inApp: boolean; email: boolean; push: boolean };
deferUntil: { inApp: boolean; email: boolean; push: boolean };
}
export interface Profile {
uid: string;
email: string;
@ -24,6 +32,7 @@ export interface Profile {
productivity_assistant_enabled: boolean;
next_task_suggestion_enabled: boolean;
pomodoro_enabled: boolean;
notification_preferences?: NotificationPreferences | null;
}
export interface TelegramBotInfo {

View file

@ -200,7 +200,9 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
: 'items-center flex-1'
}`}
>
<div className="flex items-center">
<div
className={`flex ${viewMode === 'cards' ? 'flex-col' : 'items-center'}`}
>
<Link
to={
project.uid
@ -218,6 +220,11 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
>
{project.name}
</Link>
{viewMode === 'cards' && project.description && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1 line-clamp-2">
{project.description}
</p>
)}
</div>
<div className="relative dropdown-container">
{viewMode === 'cards' ? (
@ -379,18 +386,27 @@ const ProjectItem: React.FC<ProjectItemProps> = ({
<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"
title={t('projectItem.completionPercentage', {
percentage: getCompletionPercentage(),
})}
className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2 cursor-help"
title={
(project as any).task_status
? `${(project as any).task_status.done} of ${(project as any).task_status.total} tasks completed (${getCompletionPercentage()}%)`
: t('projectItem.completionPercentage', {
percentage: getCompletionPercentage(),
})
}
>
<div
className="bg-blue-500 h-2 rounded-full"
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
style={{
width: `${getCompletionPercentage()}%`,
}}
></div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 font-medium whitespace-nowrap">
{(project as any).task_status
? `${(project as any).task_status.done}/${(project as any).task_status.total}`
: '0/0'}
</span>
</div>
</div>
)}

124
package-lock.json generated
View file

@ -59,6 +59,7 @@
"tagify": "^0.1.1",
"typescript-eslint": "^8.36.0",
"uuid": "~11.1.0",
"web-push": "^3.6.7",
"zustand": "^5.0.3"
},
"devDependencies": {
@ -66,6 +67,7 @@
"@babel/preset-env": "^7.25.7",
"@babel/preset-react": "^7.25.7",
"@babel/preset-typescript": "^7.25.7",
"@faker-js/faker": "^10.1.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@swc/core": "^1.13.3",
"@testing-library/jest-dom": "^6.0.0",
@ -2215,6 +2217,23 @@
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@faker-js/faker": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz",
"integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/fakerjs"
}
],
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.13.0 || ^23.5.0 || >=24.0.0",
"npm": ">=10"
}
},
"node_modules/@gar/promisify": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
@ -5168,6 +5187,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@ -5556,6 +5587,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
@ -5716,6 +5753,12 @@
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -7495,6 +7538,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/editorconfig": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.4.tgz",
@ -9743,6 +9795,15 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-cache-semantics": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz",
@ -12098,6 +12159,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -13495,7 +13577,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"dev": true,
"license": "ISC"
},
"node_modules/minimatch": {
@ -19309,6 +19390,47 @@
"minimalistic-assert": "^1.0.0"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-push/node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/web-push/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",

View file

@ -44,6 +44,7 @@
"db:reset": "cd backend && node scripts/db-reset.js",
"db:status": "cd backend && node scripts/db-status.js",
"db:seed": "cd backend && node scripts/seed-dev-data.js",
"db:reset-and-seed": "cd backend && NODE_ENV=development node scripts/reset-and-seed.js",
"user:create": "cd backend && node scripts/user-create.js",
"migration:create": "cd backend && node scripts/migration-create.js",
"migration:run": "cd backend && npx sequelize-cli db:migrate",
@ -65,6 +66,7 @@
"@babel/preset-env": "^7.25.7",
"@babel/preset-react": "^7.25.7",
"@babel/preset-typescript": "^7.25.7",
"@faker-js/faker": "^10.1.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
"@swc/core": "^1.13.3",
"@testing-library/jest-dom": "^6.0.0",
@ -158,6 +160,7 @@
"tagify": "^0.1.1",
"typescript-eslint": "^8.36.0",
"uuid": "~11.1.0",
"web-push": "^3.6.7",
"zustand": "^5.0.3"
}
}